Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
afb1ae6
docs: update in-app help for v2.4.0
adibhanna Jun 12, 2026
e4e1694
Fix tab scroll position restore (#127)
adibhanna Jun 12, 2026
33604d2
Fix calendar date note detection (#131)
adibhanna Jun 12, 2026
689431d
Add configurable daily and weekly note patterns (#77)
adibhanna Jun 12, 2026
64318cc
Fix duplicated hover text on sidebar icons (#141)
adibhanna Jun 12, 2026
12f8714
docs: add #77 feature and #141 fix to v2.4.0 release notes
adibhanna Jun 12, 2026
554dd49
Keep release notes local-only (stop tracking docs/releases)
adibhanna Jun 12, 2026
ab19a08
Fix blank Window menu items on macOS (#154)
adibhanna Jun 13, 2026
321c2a1
Fix sidebar header not reflecting the remote vault (#153)
adibhanna Jun 13, 2026
61bc6f4
Fix switching from a remote workspace back to a local vault
adibhanna Jun 13, 2026
c30eaf8
Fix hint mode in the tasks and tags views (#151)
adibhanna Jun 13, 2026
e43d9c2
Fix remapped pane-focus shortcuts colliding with the editor (#124)
adibhanna Jun 15, 2026
3c63a6b
Add Vim yank-to-clipboard and yank highlight; fix Linux clipboard hin…
adibhanna Jun 15, 2026
527d5fd
Let Space reach Vim while a command awaits an argument (#147)
adibhanna Jun 15, 2026
c7b41b7
Ship a complete macOS app icon (.icns) (#165)
adibhanna Jun 15, 2026
dd81d13
docs: refresh in-app help for the Tasks/Tags keyboard changes
adibhanna Jun 15, 2026
680032f
Show editor ex-command errors inline instead of a blocking alert (#173)
adibhanna Jun 15, 2026
9c1c792
Sort sidebar folders/notes/assets by name numerically (#168)
adibhanna Jun 15, 2026
2394fc3
Skip unreadable files/dirs in the server vault scan (#159)
adibhanna Jun 15, 2026
84e3e43
Fix zen CLI opening two windows on cold start (#178)
adibhanna Jun 15, 2026
2f750ff
Add WYSIWYG live-preview editor (tables, blocks, vim tables)
adibhanna Jun 15, 2026
111fb30
Render Obsidian-style callouts in Edit mode
adibhanna Jun 15, 2026
53f507c
Add vim editing to WYSIWYG table cells
adibhanna Jun 15, 2026
92aaddf
Close the window on Cmd+W when no tab is open (#192)
adibhanna Jun 16, 2026
bd1c6c4
Keep the reference-link definition colon visible in Edit mode (#188)
adibhanna Jun 16, 2026
9ac1b18
Show images/PDFs in a capital-cased Inbox folder (#186)
adibhanna Jun 16, 2026
a480be0
Stop logging an error when an open database is deleted
adibhanna Jun 16, 2026
53144c7
Don't trigger actions on the Enter that confirms an IME conversion (#…
adibhanna Jun 16, 2026
cbbbe76
Don't let the file watcher hang the server where inotify is restricte…
adibhanna Jun 16, 2026
84eb7ba
Make a database a self-contained .base folder
adibhanna Jun 16, 2026
d20c782
Add back/forward nav and restyle the tab bar (from #185)
adibhanna Jun 16, 2026
6cf89df
Unify assets under assets/ + add an Assets view (from #185)
adibhanna Jun 16, 2026
1bf0fa5
Assets view: usage + actions + table layout, and nav polish
adibhanna Jun 16, 2026
da530df
Fix Vim/normal-mode key interaction in list views
adibhanna Jun 16, 2026
0e4f7fe
Add markdown snippet auto-close in the editor
adibhanna Jun 16, 2026
eaa3ae8
Add Flatpak packaging (#145)
weslley75 Jun 16, 2026
028e72a
feat: add tar.gz Linux target and simplify AUR PKGBUILD (#133)
Lund1337 Jun 16, 2026
613fd93
packaging(aur): SKIP sha256 until the v2.4.0 tarball is published
adibhanna Jun 16, 2026
bdcb315
ci(aur): skip makepkg build until the release asset is published
adibhanna Jun 16, 2026
2bf6045
feat: [[Note#heading]] wikilinks — resolve, jump to heading, autocomp…
adibhanna Jun 17, 2026
4cdbb22
feat(ui): add middle-click to close tabs
victor-rm Jun 12, 2026
6290f82
packaging(nix): add Nix/NixOS flake and packages (#149)
hallwack Jun 17, 2026
b26395b
fix: set quick capture window title
dannrs Jun 14, 2026
3bfd08c
fix: Ctrl+D and other emacs chords work in Vim mode on macOS
adibhanna Jun 17, 2026
15b3712
feat: render comment bodies as Markdown
adibhanna Jun 17, 2026
99358db
feat: focus the title field when creating a note from the sidebar
adibhanna Jun 17, 2026
69f3c2d
fix: warn when inbox-mode vault hides root-level notes (#195)
adibhanna Jun 17, 2026
8870c98
feat: per-folder accent colors
adibhanna Jun 17, 2026
b0bcbde
feat: copy the whole note as Markdown
adibhanna Jun 17, 2026
287846d
feat: Excalidraw drawings as a first-class file type
adibhanna Jun 17, 2026
704ad85
feat(server): Excalidraw drawings on the self-hosted/web build
adibhanna Jun 17, 2026
180be75
feat: Favorites + Notion-style sidebar reorganization
adibhanna Jun 17, 2026
731abb7
feat(sync): foundation for bidirectional synced vaults
adibhanna Jun 17, 2026
757098a
feat(sync): the bidirectional sync engine (M3 I/O)
adibhanna Jun 17, 2026
aa22df5
feat(sync): app integration — activation, IPC, and status UI (M3+M4)
adibhanna Jun 17, 2026
4fd805b
feat(sync): full-vault coverage — databases, comments, settings, asse…
adibhanna Jun 17, 2026
a38ce62
feat(sync): conflict-resolution UI (M7)
adibhanna Jun 17, 2026
46376ea
Revert the synced-vaults work (M1–M7) — will revisit
adibhanna Jun 18, 2026
132cc6c
feat(web): databases on the web client (desktop/web parity)
adibhanna Jun 18, 2026
4e6ea38
feat: daily-note task calendar, vim calendar control, and Notion-styl…
adibhanna Jun 18, 2026
ab32486
fix: don't reorganize loose files in an existing Obsidian vault (#202)
adibhanna Jun 18, 2026
aaa0f78
fix: make standard Markdown links clickable (#201)
adibhanna Jun 19, 2026
d35627a
feat: live-sync database fields to record-page frontmatter + tidy met…
adibhanna Jun 19, 2026
9e744c0
feat: Notion-style selection toolbar with formatting + shortcuts
adibhanna Jun 19, 2026
5c0d4f3
fix: recognize tags in any script — Cyrillic, CJK, etc. (#205)
adibhanna Jun 19, 2026
0af2573
chore(release): prep v2.4.0 — version bump, green typecheck, packaging
adibhanna Jun 19, 2026
29ea269
Merge branch 'main' into release/v2.4.0
adibhanna Jun 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion .github/workflows/aur-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@v4

- name: Build the package
- name: Set up build user
run: |
set -euxo pipefail
# makepkg refuses to run as root — build as an unprivileged user with
Expand All @@ -35,18 +35,45 @@ jobs:
install -d -o builder -g builder /tmp/build
cp packaging/aur/PKGBUILD packaging/aur/.SRCINFO /tmp/build/
chown -R builder:builder /tmp/build

# The PKGBUILD always points at the upcoming release's tarball, which does
# not exist until that release is published. Skip the full build (which has
# to download it) when the asset isn't up yet, rather than failing — the
# metadata + .SRCINFO are still checked, and the real build runs once the
# release exists.
- name: Check whether the release asset is published
id: asset
run: |
set -uo pipefail
# shellcheck disable=SC1091
source packaging/aur/PKGBUILD
url="${source[0]#*::}"
echo "url=$url"
if curl -fsIL -o /dev/null "$url"; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "::notice title=AUR build skipped::Release asset not published yet ($url). PKGBUILD metadata + .SRCINFO are still validated; the full makepkg build runs once the release exists."
fi

- name: Build the package
if: steps.asset.outputs.exists == 'true'
run: |
set -euxo pipefail
cd /tmp/build
sudo -u builder makepkg -s --noconfirm --noprogressbar
ls -la ./*.pkg.tar.*

- name: Lint (non-blocking)
if: steps.asset.outputs.exists == 'true'
run: |
cd /tmp/build
namcap PKGBUILD || true
namcap ./*.pkg.tar.* || true

- name: Verify .SRCINFO is in sync
run: |
set -euxo pipefail
cd /tmp/build
sudo -u builder makepkg --printsrcinfo > .SRCINFO.generated
if ! diff -u .SRCINFO .SRCINFO.generated; then
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ jobs:
files+=("$file")
done < <(
find dist -maxdepth 1 -type f \
\( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.pacman' -o -name '*.blockmap' -o -name 'latest*.yml' -o -name 'latest*.yaml' \)
\( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.pacman' -o -name '*.tar.gz' -o -name '*.blockmap' -o -name 'latest*.yml' -o -name 'latest*.yaml' \)
)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found in dist/" >&2
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@ apps/server/bin
data/
/vault/
/remotevault/
# Nix build outputs
result
result-*
# Release notes / launch copy are kept local only, never committed.
docs/releases/
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ Pick whatever suits your distro:
```sh
sudo apt install ./ZenNotes-<version>-linux-amd64.deb
```
- **Nix / NixOS:**
Read [packaging/nix/README.md](packaging/nix/README.md) for installation instructions
- **Any distro — AppImage:**
```sh
chmod +x ZenNotes-<version>-linux-x86_64.AppImage
Expand Down Expand Up @@ -141,14 +143,29 @@ System folders still exist, but the vault model is more flexible now:

The built-in folder labels are also customizable in the UI without changing the underlying internal ids.

### Daily notes
### Daily and weekly notes

Daily notes are optional and can be enabled from Settings.
Daily and weekly notes are optional and can be enabled from Settings.

- when enabled, ZenNotes can open or create today's note automatically
- the title is a simple ISO date like `2026-04-21`
- daily notes live in a dedicated directory under your primary notes area
- the default directory is `Daily Notes`
- when enabled, ZenNotes can open or create today's daily note and this week's weekly note automatically
- the default daily title is a simple ISO date like `2026-04-21`
- the default weekly title is an ISO week title like `2026-W24`
- date notes live in dedicated directories under your primary notes area
- the default directories are `Daily Notes` and `Weekly Notes`
- the directory and title can use date patterns such as `yyyy/MM-MMM` and `yyyy-MM-dd-EEE`
- weekly title patterns can use ISO week tokens such as `yyyy-'W'ww`
- localized month and weekday names can follow `system`, `en-US`, `pt-BR`, or another BCP 47 locale

Supported date note pattern tokens:

- `yyyy` / `yy` for `2026` / `26`
- `M` / `MM` / `MMM` / `MMMM` for `6` / `06` / `Jun` / `June`
- `d` / `dd` for `9` / `09`
- `EEE` / `EEEE` for `Tue` / `Tuesday`
- `w` / `ww` for ISO week numbers like `24`
- single-quoted literals, such as `'Daily Notes'/yyyy/MM-MMM`

Weekly notes render date tokens from the ISO week's Monday, and `yyyy` is the ISO week-year for weekly patterns.

### Editor and preview

Expand Down
Binary file added apps/desktop/build/icon.icns
Binary file not shown.
24 changes: 21 additions & 3 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
"version": "2.3.0",
"version": "2.4.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down Expand Up @@ -191,6 +191,7 @@
"electronVersion": "41.2.1",
"electronUpdaterCompatibility": ">=2.16",
"mac": {
"icon": "build/icon.icns",
"category": "public.app-category.productivity",
"hardenedRuntime": true,
"gatekeeperAssess": false,
Expand Down Expand Up @@ -227,11 +228,28 @@
"target": [
"AppImage",
"deb",
"pacman"
"pacman",
"tar.gz"
],
"icon": "build/icons",
"maintainer": "Adib Hanna <adibhanna@gmail.com>",
"category": "Office"
"category": "Office",
"extraResources": [
{
"from": "../../packaging/aur/arch-extras/zennotes.desktop",
"to": "arch-extras/zennotes.desktop"
},
{
"from": "build/icons",
"to": "arch-extras/icons"
}
],
"extraFiles": [
{
"from": "../../LICENSE",
"to": "LICENSE"
}
]
},
"deb": {
"afterInstall": "build/after-install.sh"
Expand Down
67 changes: 67 additions & 0 deletions apps/desktop/src/main/assets-migrate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { mkdtemp, mkdir, rm, writeFile, access } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { migrateLooseAssets, listAssets } from './vault'

const tempDirs: string[] = []

async function makeVault(): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), 'zen-amig-'))
tempDirs.push(dir)
return dir
}

afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true })))
})

const exists = (root: string, rel: string): Promise<boolean> =>
access(path.join(root, rel))
.then(() => true)
.catch(() => false)

describe('migrateLooseAssets (#185 assets/ unification)', () => {
it('moves root-level attachments + legacy dirs into assets/, never notes or databases', async () => {
const root = await makeVault()
await writeFile(path.join(root, 'photo.png'), 'x', 'utf8')
await writeFile(path.join(root, 'note.md'), '# hi', 'utf8') // a note — must stay
await writeFile(path.join(root, 'books.csv'), 'id\n', 'utf8') // legacy db — must stay
await mkdir(path.join(root, 'attachements'), { recursive: true })
await writeFile(path.join(root, 'attachements', 'doc.pdf'), 'x', 'utf8')

const { moved, skipped } = await migrateLooseAssets(root)
expect(moved.sort()).toEqual(['assets/doc.pdf', 'assets/photo.png'])
expect(skipped).toEqual([])

expect(await exists(root, 'assets/photo.png')).toBe(true)
expect(await exists(root, 'assets/doc.pdf')).toBe(true)
expect(await exists(root, 'photo.png')).toBe(false)
expect(await exists(root, 'attachements')).toBe(false) // emptied + removed
// Notes and database files are untouched.
expect(await exists(root, 'note.md')).toBe(true)
expect(await exists(root, 'books.csv')).toBe(true)
})

it('skips a file whose basename already exists in assets/ (keeps refs unambiguous)', async () => {
const root = await makeVault()
await mkdir(path.join(root, 'assets'), { recursive: true })
await writeFile(path.join(root, 'assets', 'logo.png'), 'existing', 'utf8')
await writeFile(path.join(root, 'logo.png'), 'loose', 'utf8')

const { moved, skipped } = await migrateLooseAssets(root)
expect(moved).toEqual([])
expect(skipped).toEqual(['logo.png'])
expect(await exists(root, 'logo.png')).toBe(true) // left in place
})

it('is idempotent and surfaces migrated assets via listAssets', async () => {
const root = await makeVault()
await writeFile(path.join(root, 'pic.png'), 'x', 'utf8')
expect((await migrateLooseAssets(root)).moved).toEqual(['assets/pic.png'])
expect((await migrateLooseAssets(root)).moved).toEqual([])

const assets = await listAssets(root)
expect(assets.map((a) => a.path)).toContain('assets/pic.png')
})
})
103 changes: 103 additions & 0 deletions apps/desktop/src/main/databases-migrate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { mkdtemp, mkdir, rm, writeFile, readFile, access } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { migrateLegacyDatabases } from './vault'
import { readDatabase } from './databases'

const tempDirs: string[] = []

async function makeVault(): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), 'zen-dbmig-'))
tempDirs.push(dir)
await mkdir(path.join(dir, 'inbox'), { recursive: true })
return dir
}

afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true })))
})

const exists = (p: string): Promise<boolean> =>
access(p)
.then(() => true)
.catch(() => false)

// A minimal legacy database: loose `<Name>.csv` + co-located sidecar, with one
// record-page note under the per-database `<Name>/` folder.
async function seedLegacyDatabase(root: string): Promise<void> {
await writeFile(path.join(root, 'inbox', 'Books.csv'), 'id,Title\nr1,Dune\n', 'utf8')
await mkdir(path.join(root, 'inbox', 'Books'), { recursive: true })
await writeFile(path.join(root, 'inbox', 'Books', 'Dune.md'), '# Dune\n\nGreat book.', 'utf8')
const sidecar = {
version: 1,
idFieldId: 'f_id',
fields: [
{ id: 'f_id', name: 'id', type: 'text', hidden: true },
{ id: 'f_title', name: 'Title', type: 'text' }
],
views: [{ id: 'v1', name: 'Table', type: 'table', filters: [], sorts: [] }],
activeViewId: 'v1',
pages: { r1: 'inbox/Books/Dune.md' }
}
await writeFile(
path.join(root, 'inbox', 'Books.csv.base.json'),
JSON.stringify(sidecar, null, 2),
'utf8'
)
}

describe('migrateLegacyDatabases (#185 .base reorg)', () => {
it('moves a legacy database into a self-contained .base folder', async () => {
const root = await makeVault()
await seedLegacyDatabase(root)

const count = await migrateLegacyDatabases(root)
expect(count).toBe(1)

const baseDir = path.join(root, 'inbox', 'Books.base')
expect(await exists(path.join(baseDir, 'data.csv'))).toBe(true)
expect(await exists(path.join(baseDir, 'schema.json'))).toBe(true)
expect(await exists(path.join(baseDir, 'Dune.md'))).toBe(true)

// Old files + per-database folder are gone.
expect(await exists(path.join(root, 'inbox', 'Books.csv'))).toBe(false)
expect(await exists(path.join(root, 'inbox', 'Books.csv.base.json'))).toBe(false)
expect(await exists(path.join(root, 'inbox', 'Books'))).toBe(false)

// schema.json stores the page path RELATIVE to the folder.
const schema = JSON.parse(await readFile(path.join(baseDir, 'schema.json'), 'utf8'))
expect(schema.pages).toEqual({ r1: 'Dune.md' })

// data.csv content is preserved.
expect(await readFile(path.join(baseDir, 'data.csv'), 'utf8')).toContain('Dune')
})

it('readDatabase resolves the migrated db and its pages to full paths', async () => {
const root = await makeVault()
await seedLegacyDatabase(root)
await migrateLegacyDatabases(root)

const doc = await readDatabase(root, 'inbox/Books.base/data.csv')
expect(doc.title).toBe('Books')
expect(doc.rows).toHaveLength(1)
// pages resolved back to full vault-relative paths on read.
expect(doc.pages?.r1).toBe('inbox/Books.base/Dune.md')
})

it('is idempotent — a second run migrates nothing and leaves data intact', async () => {
const root = await makeVault()
await seedLegacyDatabase(root)
expect(await migrateLegacyDatabases(root)).toBe(1)
expect(await migrateLegacyDatabases(root)).toBe(0)
expect(await exists(path.join(root, 'inbox', 'Books.base', 'data.csv'))).toBe(true)
})

it('leaves a plain CSV (no sidecar) untouched', async () => {
const root = await makeVault()
await writeFile(path.join(root, 'inbox', 'data-dump.csv'), 'a,b\n1,2\n', 'utf8')
expect(await migrateLegacyDatabases(root)).toBe(0)
expect(await exists(path.join(root, 'inbox', 'data-dump.csv'))).toBe(true)
expect(await exists(path.join(root, 'inbox', 'data-dump.base'))).toBe(false)
})
})
47 changes: 47 additions & 0 deletions apps/desktop/src/main/databases-walk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { mkdtemp, mkdir, rm } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createDatabase, createRecordPage } from './databases'
import { listFolders, listNotes, listAssets } from './vault'

const tempDirs: string[] = []

async function makeVault(): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), 'zen-dbwalk-'))
tempDirs.push(dir)
await mkdir(path.join(dir, 'inbox'), { recursive: true })
return dir
}

afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true })))
})

describe('vault walk treats a .base database as one unit (#185)', () => {
it('surfaces the database folder + record pages, hides data.csv/schema.json', async () => {
const root = await makeVault()
const doc = await createDatabase(root, 'inbox', '', 'Reading List')
expect(doc.path).toBe('inbox/Reading List.base/data.csv')
await createRecordPage(root, doc.path, 'Dune', '# Dune')

// The database appears as a folder node (the sidebar renders it as a database).
const folderEntries = await listFolders(root)
expect(folderEntries.some((f) => f.subpath === 'Reading List.base')).toBe(true)

// The record page is a note nested under the database folder.
const notes = await listNotes(root)
const page = notes.find((n) => n.path === 'inbox/Reading List.base/Dune.md')
expect(page).toBeTruthy()
expect(page?.folder).toBe('inbox')

// data.csv and schema.json never appear as assets or notes.
const assets = await listAssets(root)
expect(assets.some((a) => a.path.includes('.base'))).toBe(false)
expect(notes.some((n) => n.path.endsWith('data.csv') || n.path.endsWith('schema.json'))).toBe(
false
)
// The database folder is NOT descended into as ordinary subfolders.
expect(folderEntries.some((f) => f.subpath.startsWith('Reading List.base/'))).toBe(false)
})
})
Loading
Loading