Skip to content

feat: custom on-disk folder paths for system folders#398

Open
wholetthekfcgo wants to merge 2 commits into
ZenNotes:mainfrom
wholetthekfcgo:feat/custom-system-folder-paths
Open

feat: custom on-disk folder paths for system folders#398
wholetthekfcgo wants to merge 2 commits into
ZenNotes:mainfrom
wholetthekfcgo:feat/custom-system-folder-paths

Conversation

@wholetthekfcgo

Copy link
Copy Markdown

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 primaryNotesLocation pattern (a per-vault setting in vault.json that 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 - Entry for 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: systemFolderPaths vault setting

Added systemFolderPaths?: Partial<Record<NoteFolder, string>> to VaultSettings. 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 (mirrors system-folder-labels.ts pattern)
  • packages/shared-domain/src/system-folder-paths.test.ts — 27 tests

Backend (Desktop main process — apps/desktop/src/main/vault.ts)

  • folderRoot() resolves custom paths; folderForRelativePath() classifies notes via reverse map; ensureVaultLayout() creates custom directories; emptyTrash() uses folderRoot; folderOf() made async to load settings
  • Walk functions (listNotes, listFolders, search candidates) hide custom folder top-segments from primary root views

Renderer

  • vault-layout.tsfolderForVaultRelativePath() uses reverse map; notePathWithinFolder() and assetPathWithinFolder() strip custom folder prefixes
  • store.ts — Folder rename/delete prefix matching uses resolveFolderPath
  • SettingsModal.tsx — New "Folder Paths" section with 4 inputs + resolved path display
  • Sidebar.tsxvaultRelativeFolderPath uses custom folder path
  • database-links.ts — Database link path construction uses custom folder path

Go server

  • types.goSystemFolderPaths field + FolderForRelativePathWithSettings(), resolveFolderPath(), buildReverseFolderMap()
  • vault.gofolderRoot() and EnsureLayout() use custom paths
  • watcher.go — Settings-aware note classification via SetFolderPaths()
  • main.go — Syncs folder paths to watcher on startup

Bridge contract

  • ipc.tssystemFolderPaths field + default on VaultSettings

Validation rules

  • Non-empty string, max 128 chars, POSIX-style (backslashes normalized)
  • No .., ., or segments starting with . (prevents invisible dotfile folders)
  • No leading / or empty segments
  • No reserved root names (assets, .zennotes, attachements, _assets, deleted-assets, comments)
  • No collisions between custom folder paths
  • No nested paths that are a prefix of another system folder

Testing steps for reviewers

1. Basic functionality

  1. Open a vault in ZenNotes
  2. Go to Settings > Vault > Folders > Folder Paths
  3. Set Inbox to 01 - Entry, click away (auto-saves)
  4. Verify 01 - Entry/ directory was created in the vault
  5. Create a new note — it should appear inside 01 - Entry/
  6. Quick-capture a note — it should appear in the Quick Notes folder (unchanged)
  7. Archive the note — it should move to archive/
  8. Trash the note — it should move to trash/
  9. Restore from trash — note should return to 01 - Entry/

2. Obsidian-style vault

  1. Open a vault with existing folders like 01 - Entry/, Archive/
  2. Set Inbox → 01 - Entry, Archive → Archive
  3. Notes in 01 - Entry/ should appear in the Inbox view
  4. Notes in Archive/ should appear in the Archive view
  5. Subfolders should work: 01 - Entry/Projects/MyNote.md shows under Inbox > Projects

3. Validation

  1. Try setting a path to .trash — should be rejected (dot-prefix blocked)
  2. Try setting two folders to the same name — should be rejected (collision)
  3. Try setting inbox to Archive and archive to Archive/Old — should be rejected (prefix collision)
  4. Try empty string — should fall back to default
  5. Try setting to assets — should be rejected (reserved name)

4. Text search

  1. Set a custom inbox path
  2. Search for text in a note under the custom inbox — should find it

5. Go server

  1. Run go test ./apps/server/... — all tests pass
  2. Start the server with a vault that has custom folder paths in vault.json
  3. Verify notes are listed with correct folder field
  4. Verify the watcher classifies file events correctly for remapped folders

6. Unit tests

npx vitest run packages/shared-domain/src/system-folder-paths.test.ts   # 27 tests
go test ./apps/server/...                                               # all Go tests

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

@adibhanna adibhanna left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/trash

buildReverseFolderMap 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.ts renameFolder: the outer filter n.path.startsWith(oldPrefix) stayed case-sensitive while the inner rewritePath compares 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)
@wholetthekfcgo
wholetthekfcgo requested a review from adibhanna July 18, 2026 22:58
@wholetthekfcgo

Copy link
Copy Markdown
Author

@adibhanna Could you do review? thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Set the Folders for Inbox, Archive, Quicknotes and Trash

2 participants