feat: custom on-disk folder paths for system folders#398
feat: custom on-disk folder paths for system folders#398wholetthekfcgo wants to merge 2 commits into
Conversation
Allow users to map the four built-in folders (Inbox, Quick Notes, Archive, Trash) to custom vault-relative directories via Settings > Vault > Folders > Folder Paths. This makes ZenNotes a drop-in for existing Obsidian vaults with established folder structures (e.g. '01 - Entry' for inbox, 'Archive' for archive). Closes ZenNotes#115
There was a problem hiding this comment.
Thanks for this, @wholetthekfcgo. This is a feature we genuinely want (#115 has three requesters and it's the main blocker for adopting existing Obsidian vaults), and the shape of the implementation is right: mirroring the system-folder-labels / primaryNotesLocation patterns, storing per-vault in vault.json, shipping a validation module with tests, and even protecting the intentionally misspelled attachements directory in the reserved names. I cherry-picked the branch locally: typecheck is clean across all packages, the folderOf sync-to-async conversion updated every call site correctly, and all suites pass (shared-domain 82, app-core 798, desktop 252, Go). Nice work.
That said, I probed the scenarios the tests don't cover and found several concrete issues, a few of them in the exact use cases from #115. Requesting changes for these; happy to merge once they're addressed.
Confirmed bugs (each verified by running the code)
1. The flagship #115 case breaks: custom paths containing an uppercase letter fail subpath resolution.
notePathWithinFolder('01 - Entry/Projects/Idea.md', 'inbox',
{ ...settings, systemFolderPaths: { inbox: '01 - Entry' } })
// actual: "01 - Entry/Projects/Idea.md"
// expected: "Projects/Idea.md"In vault-layout.ts, notePathWithinFolder and assetPathWithinFolder build the prefix from resolveFolderPath(...) (which can be mixed case) but still compare it against path.toLowerCase(), so the prefix never matches. The pre-existing code was safe only because folder was always lowercase. Lowercasing the resolved prefix before comparison fixes it; 01 - Entry from the original issue is the perfect regression test.
2. A custom path can collide with another folder's default.
normalizeSystemFolderPaths({ inbox: 'archive' })
// actual: { inbox: 'archive' } (accepted)
// expected: {} (rejected)hasValidPaths only cross-checks the overrides against each other. It needs to validate against the resolved paths of all four folders (overrides plus the defaults of non-overridden folders), otherwise inbox and archive silently share a directory and classification becomes ambiguous.
3. One invalid entry silently wipes every override.
normalizeSystemFolderPaths({ inbox: 'my-inbox', trash: 'dup', archive: 'dup' })
// actual: {} (the valid inbox override is lost too)if (!hasValidPaths(next)) return {} throws away the user's whole config because of one bad pair, and the Settings UI gives no feedback (the text inputs persist on change and the values just quietly stop applying). Dropping only the invalid entries, or surfacing a validation error in the Folder Paths section, would both be fine; silent total loss is the one behavior that isn't.
4. A nested custom path confiscates its entire top-level directory.
folderForVaultRelativePath('sys/unrelated-note.md',
{ ...settings, systemFolderPaths: { trash: 'sys/trash' } })
// actual: "trash"
// expected: "inbox" (or null), since the note is not inside sys/trashbuildReverseFolderMap maps only the top segment, and customHiddenPrimaryRootNames hides the whole top segment from primary views, so with trash: "sys/trash" every note under sys/ is classified as trash and hidden from the flat view, including sys/unrelated-note.md. Simplest fix: restrict validation to single-segment paths (which covers every request in #115). If you want to keep nesting, classification and hiding have to match on the full path prefix, not the top segment.
Server-side gaps
5. No validation on the Go side: path traversal. The desktop sanitizes systemFolderPaths on read (normalizeVaultSettings), but the Go server unmarshals the raw map and folderRoot does filepath.Join(v.root, p) with it. A vault.json containing "trash": "../../outside" makes EnsureLayout create directories outside the vault root and points folder operations there. The Go side needs a port of normalizeSystemFolderPaths (or at minimum: reject absolute paths, .., and dot-prefixed segments) applied wherever settings are loaded or written.
6. The web flat view shows custom folders' contents. The desktop walks hide custom folder top segments from primary-root views (customHiddenPrimaryRootNames), but the server's hiddenPrimaryRootNames (vault.go) was not extended. On a primaryNotesLocation: root vault with a custom trash path, the web app lists the trash directory's notes in the flat view as well as in Trash.
7. Watcher mapping goes stale. w.SetFolderPaths is called once at server startup; if settings change while the server runs, the watcher keeps classifying against the old paths.
Performance
8. folderOf now reads vault.json from disk on every call. getVaultSettings is uncached (fs.readFile + inferPrimaryNotesLocation each time), and folderOf runs on every readNote / writeNote / appendToNote. That is a hot-path regression compared to the previous pure string classification. A small settings cache invalidated by setVaultSettings (and the vault watcher for external edits), or plumbing the already-loaded settings into these call sites, would keep the hot paths clean.
Minor
store.tsrenameFolder: the outer filtern.path.startsWith(oldPrefix)stayed case-sensitive while the innerrewritePathcompares case-insensitively, so notes under a capitalized custom path fail the outer check and never get rewritten.- Please add regression tests for the cases above, especially 1, 2, and 4; they are all one-liners against the exported helpers.
Again, the direction is exactly right and most of this is tightening rather than redesign. I'm happy to re-review quickly once these are addressed.
- Fix uppercase custom paths breaking subpath resolution (case-insensitive prefix match) - Reject custom paths colliding with another folder's default - Keep valid overrides when some entries conflict (don't wipe all) - Reject nested/multi-segment paths to prevent top-level confiscation - Add Go-side validation: reject path traversal (../../outside) - Extend Go server walk hidden names for custom folder paths - Reload watcher folder paths when vault.json changes at runtime - Cache vault settings by mtime to avoid disk reads on hot paths - Fix renameFolder case-sensitive outer filter mismatch - Clean up dead code in buildReverseFolderMap and customHiddenPrimaryRootNames - Add regression tests (28 total)
|
@adibhanna Could you do review? thanks |
Summary
Allows users to map the four built-in system folders (Inbox, Quick Notes, Archive, Trash) to custom vault-relative directories via Settings > Vault > Folders > Folder Paths. Previously only display labels could be customized — the actual on-disk folder names (
inbox/,quick/,archive/,trash/) were hardcoded, making ZenNotes enforce its own folder structure and preventing seamless adoption by users with existing Obsidian vaults.This follows the existing
primaryNotesLocationpattern (a per-vault setting invault.jsonthat changes where the inbox resolves on disk).Closes #115
How it addresses the issue and comments
Issue (#115 by @danscheer): User has an Obsidian vault with
01 - Entryfor inbox and wants real folder reassignment, not just UI labels. ✅ Users can now set any vault-relative path for each system folder.Comment 1 (@TomSchimana): Wants to map at least Inbox and Quick Notes to own filesystem folders, with the structured Inbox/Quick view working alongside their existing folder structure. ✅ All four folders can be independently remapped; subfolder navigation works correctly with custom paths.
Comment 2 (@hotondo): Wants custom folder locations; suggested as alternative that system folders go inside
.zennotes/. ✅ Custom folder locations fully implemented — users can set any path, making the.zennotes/alternative unnecessary.Changes
New:
systemFolderPathsvault settingAdded
systemFolderPaths?: Partial<Record<NoteFolder, string>>toVaultSettings. Absent entries fall back to the default folder name. Stored in<vault>/.zennotes/vault.json.Example:
{ "systemFolderPaths": { "inbox": "01 - Entry", "archive": "Archive", "trash": "deleted" } }New files
packages/shared-domain/src/system-folder-paths.ts— Validation, resolution, and reverse-mapping utility (mirrorssystem-folder-labels.tspattern)packages/shared-domain/src/system-folder-paths.test.ts— 27 testsBackend (Desktop main process —
apps/desktop/src/main/vault.ts)folderRoot()resolves custom paths;folderForRelativePath()classifies notes via reverse map;ensureVaultLayout()creates custom directories;emptyTrash()usesfolderRoot;folderOf()made async to load settingslistNotes,listFolders, search candidates) hide custom folder top-segments from primary root viewsRenderer
vault-layout.ts—folderForVaultRelativePath()uses reverse map;notePathWithinFolder()andassetPathWithinFolder()strip custom folder prefixesstore.ts— Folder rename/delete prefix matching usesresolveFolderPathSettingsModal.tsx— New "Folder Paths" section with 4 inputs + resolved path displaySidebar.tsx—vaultRelativeFolderPathuses custom folder pathdatabase-links.ts— Database link path construction uses custom folder pathGo server
types.go—SystemFolderPathsfield +FolderForRelativePathWithSettings(),resolveFolderPath(),buildReverseFolderMap()vault.go—folderRoot()andEnsureLayout()use custom pathswatcher.go— Settings-aware note classification viaSetFolderPaths()main.go— Syncs folder paths to watcher on startupBridge contract
ipc.ts—systemFolderPathsfield + default onVaultSettingsValidation rules
..,., or segments starting with.(prevents invisible dotfile folders)/or empty segmentsassets,.zennotes,attachements,_assets,deleted-assets,comments)Testing steps for reviewers
1. Basic functionality
01 - Entry, click away (auto-saves)01 - Entry/directory was created in the vault01 - Entry/archive/trash/01 - Entry/2. Obsidian-style vault
01 - Entry/,Archive/01 - Entry, Archive →Archive01 - Entry/should appear in the Inbox viewArchive/should appear in the Archive view01 - Entry/Projects/MyNote.mdshows under Inbox > Projects3. Validation
.trash— should be rejected (dot-prefix blocked)Archiveand archive toArchive/Old— should be rejected (prefix collision)assets— should be rejected (reserved name)4. Text search
5. Go server
go test ./apps/server/...— all tests passvault.jsonfolderfield6. Unit tests