feat: custom on-disk folder paths for system folders - #398
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 |
The PR was written against a 39-commit-older branch; these close the
gaps between it and what shipped since, all the same disease: code
classifying notes by literal folder names that a remap renames.
- Task rescans: scanTasksForPath now passes vault settings, so a
remapped Trash's checkboxes stop leaking into the Tasks view on
incremental scans (the full scan was already settings-aware).
- Desktop watcher: holds a settings snapshot, refreshed when
vault.json changes, mirroring the PR's own fix for the Go watcher.
- Workflows: WorkflowNote carries the note's system bucket, so `all`
keeps its promise of never touching a remapped Trash or Archive;
`folder trash` / `folder archive` mean THE trash and archive by
classification, not by directory name; and the `archive` / `trash`
steps (plan projection, containment pre-flight, and the applied
move) file into the remapped directory instead of creating a dead
literal one the app no longer watches.
- MCP server: its synced-copy folder model learns systemFolderPaths
(validated with the same rules), so listings, search, and the task
scanner classify remapped folders correctly headlessly too.
- The "notes at your vault root are hidden" banner no longer fires on
a remapped vault whose only root dirs are its own system folders.
Verified in the built app on an Obsidian-shaped vault ("01 - Entry"
inbox, "99 - Deleted" trash): sidebar counts, task inclusion and
exclusion, note creation, and the trash move all land in the remapped
directories, with no literal inbox/ or trash/ ever created. Six new
regression tests (engine remap semantics, folderTarget resolution,
MCP classification and traversal rejection).
|
Merged into One heads-up about a follow-up commit that rode along (
|
CodeQL flagged the new mtime-keyed vault-settings cache from #398 as a check-then-use window (js/file-system-race, high): a stat of the path followed by a read of the path can cache one version's mtime with a swapped-in version's content. One file handle now serves both, the same treatment readWorkflowImportFile got, so the cached mtime always describes the bytes that were parsed. Behavior otherwise unchanged, including the missing-file fallback and the cache invalidation on delete.
Two additions to the in-app help for 2.20.0 features: - A new core-concept card compressing the whole workflow pipeline grammar: the six sources, twelve filters and shapers, the mutating steps, the outputs and composition, and the template variables, each with an inline example, pointing at the live ? reference in the Workflows view for the per-step details. - The system-folders card now documents Settings -> Vault -> Folder Paths (#115/#398): remapping Inbox, Quick Notes, Archive, and Trash to your own directories, stored in the vault, followed by every surface.
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