diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index a5f93279..59f00785 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -66,6 +66,7 @@ import { importFiles, importPastedImage, invalidateNoteMetaCache, + invalidateVaultSettingsCache, invalidateVaultTextSearchCache, listAssets, listFolders, @@ -231,6 +232,7 @@ const windowVaults = new WindowVaultRegistry({ invalidateVault: (root, ev) => { invalidateNoteMetaCache(root, ev.scope === 'vault-settings' ? undefined : ev.path) invalidateVaultTextSearchCache(root) + invalidateVaultSettingsCache(root) }, sendVaultChange: (windowId, ev) => { const win = BrowserWindow.fromId(windowId) diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index a5e4fb2e..d2ea090f 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -66,6 +66,11 @@ import { isObsidianExcalidrawMarkdown } from '@shared/excalidraw' import { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from './demo-tour-data' +import { + normalizeSystemFolderPaths, + resolveFolderPath, + buildReverseFolderMap +} from '@shared/system-folder-paths' const CONFIG_FILE = 'zennotes.config.json' const FOLDERS: NoteFolder[] = ['inbox', 'quick', 'archive', 'trash'] @@ -254,6 +259,7 @@ const noteMetaCache = new Map< >() const loadedPersistedNoteMetaCacheRoots = new Set() const noteMetaCachePersistTimers = new Map>() +const vaultSettingsCache = new Map() export interface PersistedWindowState { x: number @@ -1012,6 +1018,7 @@ function normalizeVaultSettings( folderColors?: Record | null favorites?: unknown view?: unknown + systemFolderPaths?: unknown } const folderIcons: Record = {} if (candidate.folderIcons && typeof candidate.folderIcons === 'object') { @@ -1064,7 +1071,8 @@ function normalizeVaultSettings( folderIcons, folderColors: normalizeFolderColors(candidate.folderColors), favorites: normalizeFavorites(candidate.favorites), - view: normalizeVaultViewSettings(candidate.view) + view: normalizeVaultViewSettings(candidate.view), + systemFolderPaths: normalizeSystemFolderPaths(candidate.systemFolderPaths) } } @@ -1249,13 +1257,32 @@ async function vaultLooksEmpty(root: string): Promise { } export async function getVaultSettings(root: string): Promise { + const settingsFile = vaultSettingsPath(root) + let stat + try { + stat = await fs.stat(settingsFile) + } catch { + const cached = vaultSettingsCache.get(root) + if (cached) { + vaultSettingsCache.delete(root) + } + const fallbackPrimary = await inferPrimaryNotesLocation(root) + return normalizeVaultSettings(null, fallbackPrimary) + } + const cached = vaultSettingsCache.get(root) + if (cached && sameMtimeMs(cached.mtimeMs, stat.mtimeMs)) { + return cached.settings + } let fallbackPrimary = DEFAULT_VAULT_SETTINGS.primaryNotesLocation try { fallbackPrimary = await inferPrimaryNotesLocation(root) - const raw = await fs.readFile(vaultSettingsPath(root), 'utf8') - return normalizeVaultSettings(JSON.parse(raw), fallbackPrimary) + const raw = await fs.readFile(settingsFile, 'utf8') + const settings = normalizeVaultSettings(JSON.parse(raw), fallbackPrimary) + vaultSettingsCache.set(root, { settings, mtimeMs: stat.mtimeMs }) + return settings } catch { - return normalizeVaultSettings(null, fallbackPrimary) + const fallback = normalizeVaultSettings(null, fallbackPrimary) + return fallback } } @@ -1267,31 +1294,61 @@ export async function setVaultSettings( const normalized = normalizeVaultSettings(next, fallbackPrimary) await fs.mkdir(path.dirname(vaultSettingsPath(root)), { recursive: true }) await fs.writeFile(vaultSettingsPath(root), JSON.stringify(normalized, null, 2), 'utf8') + const writeStat = await fs.stat(vaultSettingsPath(root)) + vaultSettingsCache.set(root, { settings: normalized, mtimeMs: writeStat.mtimeMs }) if (normalized.primaryNotesLocation === 'inbox') { await fs.mkdir(path.join(root, 'inbox'), { recursive: true }) } return cloneVaultSettings(normalized) } +export function invalidateVaultSettingsCache(root: string): void { + vaultSettingsCache.delete(root) +} + async function primaryNotesRoot(root: string): Promise { const settings = await getVaultSettings(root) return settings.primaryNotesLocation === 'root' ? root : path.join(root, 'inbox') } -function shouldHidePrimaryRootEntry(name: string): boolean { - return HIDDEN_PRIMARY_ROOT_NAMES.has(name) +function shouldHidePrimaryRootEntry(name: string, extra?: Set): boolean { + return HIDDEN_PRIMARY_ROOT_NAMES.has(name) || (extra?.has(name) ?? false) +} + +function customHiddenPrimaryRootNames(settings: VaultSettings): Set { + const names = new Set() + if (!settings.systemFolderPaths) return names + for (const f of ['quick', 'archive', 'trash'] as NoteFolder[]) { + const custom = settings.systemFolderPaths[f] + if (custom && custom !== f) names.add(custom) + } + return names } export async function folderRoot(root: string, folder: NoteFolder): Promise { - if (folder === 'inbox') return await primaryNotesRoot(root) - return path.join(root, folder) + if (folder === 'inbox') { + const settings = await getVaultSettings(root) + if (settings.primaryNotesLocation === 'root') return root + return path.join(root, resolveFolderPath('inbox', settings.systemFolderPaths)) + } + const settings = await getVaultSettings(root) + return path.join(root, resolveFolderPath(folder, settings.systemFolderPaths)) } -export function folderForRelativePath(rel: string): NoteFolder | null { +export function folderForRelativePath( + rel: string, + settings?: VaultSettings | null +): NoteFolder | null { const normalized = normalizeVaultRelativePath(rel) const top = normalized.split('/')[0] - if (SYSTEM_FOLDERS.has(top as NoteFolder)) return top as NoteFolder if (!top || top.startsWith('.')) return null + const lower = top.toLowerCase() + if (SYSTEM_FOLDERS.has(lower as NoteFolder)) return lower as NoteFolder + if (settings?.systemFolderPaths) { + const reverseMap = buildReverseFolderMap(settings.systemFolderPaths) + const mapped = reverseMap.get(lower) + if (mapped) return mapped + } if (RESERVED_ROOT_NAMES.has(top)) return null return 'inbox' } @@ -1306,7 +1363,8 @@ export async function ensureVaultLayout(root: string): Promise { const settings = await getVaultSettings(root) for (const f of FOLDERS) { if (f === 'inbox' && settings.primaryNotesLocation === 'root') continue - await fs.mkdir(path.join(root, f), { recursive: true }) + const dirPath = resolveFolderPath(f, settings.systemFolderPaths) + await fs.mkdir(path.join(root, dirPath), { recursive: true }) } if (wasEmpty) { const welcomeDir = await primaryNotesRoot(root) @@ -1559,8 +1617,9 @@ function markdownDestination(p: string): string { return `<${p.replace(/>/g, '%3E')}>` } -function folderOf(root: string, absPath: string): NoteFolder | null { - return folderForRelativePath(path.relative(root, absPath)) +async function folderOf(root: string, absPath: string): Promise { + const settings = await getVaultSettings(root) + return folderForRelativePath(path.relative(root, absPath), settings) } /** @@ -2105,8 +2164,8 @@ function resolveSearchBackend( return 'builtin' } -function noteFolderFromRelPath(relPath: string): NoteFolder | null { - return folderForRelativePath(relPath) +function noteFolderFromRelPath(relPath: string, settings?: VaultSettings | null): NoteFolder | null { + return folderForRelativePath(relPath, settings) } // A directory entry counts as a markdown note when it's a real .md file @@ -2179,6 +2238,8 @@ async function resolveDirDescent( async function collectBuiltinSearchCandidates(root: string): Promise { const files: Array<{ full: string; folder: NoteFolder }> = [] + const settings = await getVaultSettings(root) + const extraHidden = customHiddenPrimaryRootNames(settings) const walkFolder = async ( folder: NoteFolder, dirAbs: string, @@ -2199,7 +2260,7 @@ async function collectBuiltinSearchCandidates(root: string): Promise ): Promise { + const settings = await getVaultSettings(root) let stdout = '' try { const ripgrep = await searchExecutable('ripgrep', paths) @@ -2317,7 +2379,7 @@ async function collectRipgrepSearchCandidates( ? (rawLines as { text: string }).text.replace(/\r?\n$/, '') : null if (!relPath || rawLineText == null || typeof lineNumber !== 'number') continue - const folder = noteFolderFromRelPath(relPath) + const folder = noteFolderFromRelPath(relPath, settings) if (!folder || !SEARCHABLE_TEXT_FOLDERS.includes(folder)) continue candidates.push({ path: relPath, @@ -2601,6 +2663,8 @@ async function mapLimit( */ export async function listFolders(root: string): Promise { const out: FolderEntry[] = [] + const settings = await getVaultSettings(root) + const extraHidden = customHiddenPrimaryRootNames(settings) for (const folder of FOLDERS) { const topAbs = await folderRoot(root, folder) const isPrimaryRoot = folder === 'inbox' && path.resolve(topAbs) === path.resolve(root) @@ -2618,7 +2682,7 @@ export async function listFolders(root: string): Promise { const childReal = await resolveDirDescent(full, e, dirReal, ancestors) if (childReal === null) continue if (e.name.startsWith('.')) continue - if (isPrimaryRoot && dirAbs === topAbs && shouldHidePrimaryRootEntry(e.name)) continue + if (isPrimaryRoot && dirAbs === topAbs && shouldHidePrimaryRootEntry(e.name, extraHidden)) continue const nextSub = subpath ? `${subpath}/${e.name}` : e.name out.push({ folder, subpath: nextSub, siblingOrder: index, isSymlink: e.isSymbolicLink() }) // A `.base` database folder is listed (the renderer shows it as a @@ -2638,6 +2702,8 @@ export async function listFolders(root: string): Promise { export async function listNotes(root: string): Promise { const startedAt = performance.now() await hydratePersistedNoteMetaCache(root) + const settings = await getVaultSettings(root) + const extraHidden = customHiddenPrimaryRootNames(settings) const noteFiles: Array<{ full: string folder: NoteFolder @@ -2663,7 +2729,7 @@ export async function listNotes(root: string): Promise { const childReal = await resolveDirDescent(full, entry, dirReal, ancestors) if (childReal !== null) { if (entry.name.startsWith('.')) continue - if (isPrimaryRoot && dirAbs === topAbs && shouldHidePrimaryRootEntry(entry.name)) continue + if (isPrimaryRoot && dirAbs === topAbs && shouldHidePrimaryRootEntry(entry.name, extraHidden)) continue ancestors.add(childReal) await walkFolder(folder, full, childReal, topAbs, isPrimaryRoot, ancestors) ancestors.delete(childReal) @@ -2757,7 +2823,7 @@ function resolveSafe(root: string, rel: string): string { export async function readNote(root: string, rel: string): Promise { const abs = resolveSafe(root, rel) - const folder = folderOf(root, abs) + const folder = await folderOf(root, abs) if (!folder) throw new Error(`Note not in a known folder: ${rel}`) const body = await fs.readFile(abs, 'utf8') const meta = await readMeta(root, abs, folder) @@ -2770,7 +2836,7 @@ export async function writeNote(root: string, rel: string, body: string): Promis await fs.writeFile(abs, body, 'utf8') invalidateNoteMetaCache(root, rel) invalidateVaultTextSearchCache(root) - const folder = folderOf(root, abs) + const folder = await folderOf(root, abs) if (!folder) throw new Error(`Note not in a known folder: ${rel}`) return await readMeta(root, abs, folder) } @@ -2910,7 +2976,7 @@ export async function appendToNote( position: 'start' | 'end' ): Promise { const abs = resolveSafe(root, rel) - const folder = folderOf(root, abs) + const folder = await folderOf(root, abs) if (!folder) throw new Error(`Note not in a known folder: ${rel}`) const existing = await fs.readFile(abs, 'utf8') const trimmedAddition = body.replace(/\s+$/u, '') @@ -3166,7 +3232,7 @@ export async function createExcalidraw( */ export async function convertObsidianExcalidraw(root: string, rel: string): Promise { const abs = resolveSafe(root, rel) - const folder = folderOf(root, abs) + const folder = await folderOf(root, abs) if (!folder) throw new Error(`Drawing is not in a known folder: ${rel}`) const markdown = await fs.readFile(abs, 'utf8') if (!isObsidianExcalidrawPath(rel) && !isObsidianExcalidrawMarkdown(markdown)) { @@ -3216,7 +3282,7 @@ export async function importExternalNote(root: string, sourceAbsPath: string): P const rel = toPosix(path.relative(root, destAbs)) invalidateNoteMetaCache(root, rel) invalidateVaultTextSearchCache(root) - return await readMeta(root, destAbs, folderForRelativePath(rel) ?? 'inbox') + return await readMeta(root, destAbs, folderForRelativePath(rel, await getVaultSettings(root)) ?? 'inbox') } export async function renameNote( @@ -3225,7 +3291,7 @@ export async function renameNote( nextTitle: string ): Promise { const abs = resolveSafe(root, rel) - const folder = folderOf(root, abs) + const folder = await folderOf(root, abs) if (!folder) throw new Error(`Note not in a known folder: ${rel}`) const dir = path.dirname(abs) const trimmed = sanitizeNoteTitle(nextTitle) @@ -3308,7 +3374,7 @@ async function updateInboundWikilinks( * the reverse move puts the note back in the subfolder it came from. */ async function folderSubpathOf(root: string, abs: string): Promise { - const folder = folderOf(root, abs) + const folder = await folderOf(root, abs) if (!folder) return '' const sourceRoot = await folderRoot(root, folder) const relDir = path.relative(sourceRoot, path.dirname(abs)) @@ -3361,10 +3427,12 @@ export function unarchiveNote(root: string, rel: string): Promise { } export async function emptyTrash(root: string): Promise { - const trashDir = path.join(root, 'trash') + const trashDir = await folderRoot(root, 'trash') + const settings = await getVaultSettings(root) + const trashRelPrefix = resolveFolderPath('trash', settings.systemFolderPaths) try { const entries = await fs.readdir(trashDir) - await Promise.all(entries.map((e) => removeNoteComments(root, `trash/${e}`))) + await Promise.all(entries.map((e) => removeNoteComments(root, `${trashRelPrefix}/${e}`))) await Promise.all( entries.map((e) => fs.rm(path.join(trashDir, e), { recursive: true, force: true })) ) @@ -3835,7 +3903,7 @@ export async function moveNote( // No-op if the source already lives at the destination. if (path.dirname(oldAbs) === destDir) { - const folder = folderOf(root, oldAbs) + const folder = await folderOf(root, oldAbs) if (!folder) throw new Error(`Note not in a known folder: ${oldRel}`) return await readMeta(root, oldAbs, folder) } @@ -3856,7 +3924,7 @@ export async function moveNote( export async function duplicateNote(root: string, rel: string): Promise { const abs = resolveSafe(root, rel) - const folder = folderOf(root, abs) + const folder = await folderOf(root, abs) if (!folder) throw new Error(`Note not in a known folder: ${rel}`) const dir = path.dirname(abs) const ext = path.extname(abs) diff --git a/apps/server/cmd/zennotes-server/main.go b/apps/server/cmd/zennotes-server/main.go index 8870ee94..4360bd52 100644 --- a/apps/server/cmd/zennotes-server/main.go +++ b/apps/server/cmd/zennotes-server/main.go @@ -50,6 +50,10 @@ func main() { w := watcher.StartOrDisabled(v.Root(), cfg.DisableWatcher) defer w.Close() + if settings, err := v.GetSettings(); err == nil { + w.SetFolderPaths(settings.SystemFolderPaths) + } + dist, err := web.Dist() if err != nil { log.Printf("warning: embedded web bundle not available: %v", err) diff --git a/apps/server/internal/vault/types.go b/apps/server/internal/vault/types.go index 253774f2..fbf29dca 100644 --- a/apps/server/internal/vault/types.go +++ b/apps/server/internal/vault/types.go @@ -43,7 +43,114 @@ func IsValidFolder(f NoteFolder) bool { var AllFolders = []NoteFolder{FolderInbox, FolderQuick, FolderArchive, FolderTrash} +var defaultFolderPaths = map[NoteFolder]string{ + FolderInbox: string(FolderInbox), + FolderQuick: string(FolderQuick), + FolderArchive: string(FolderArchive), + FolderTrash: string(FolderTrash), +} + +var reservedFolderPathNames = map[string]struct{}{ + "assets": {}, + ".zennotes": {}, + "attachements": {}, + "_assets": {}, + "deleted-assets": {}, + "comments": {}, +} + +func isValidFolderPath(p string) bool { + if p == "" || len(p) > 128 { + return false + } + if strings.Contains(p, "/") || strings.Contains(p, "\\") { + return false + } + if p == "." || p == ".." || strings.HasPrefix(p, ".") { + return false + } + for _, c := range p { + if c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || + c == '|' || c == '#' || c == '^' || c == '[' || c == ']' { + return false + } + } + if _, reserved := reservedFolderPathNames[strings.ToLower(p)]; reserved { + return false + } + return true +} + +func normalizeSystemFolderPaths(raw map[string]string) map[string]string { + if raw == nil { + return nil + } + next := map[string]string{} + for _, folder := range AllFolders { + val, ok := raw[string(folder)] + if !ok || val == "" { + continue + } + val = strings.TrimSpace(val) + if !isValidFolderPath(val) { + continue + } + if val == string(folder) { + continue + } + next[string(folder)] = val + } + changed := true + for changed { + changed = false + for _, folder := range AllFolders { + val, ok := next[string(folder)] + if !ok { + continue + } + lower := strings.ToLower(val) + for _, other := range AllFolders { + if other == folder { + continue + } + otherResolved := strings.ToLower(resolveFolderPath(other, next)) + if lower == otherResolved { + delete(next, string(folder)) + changed = true + break + } + } + } + } + if len(next) == 0 { + return nil + } + return next +} + +func resolveFolderPath(folder NoteFolder, paths map[string]string) string { + if p, ok := paths[string(folder)]; ok { + return p + } + return defaultFolderPaths[folder] +} + +func buildReverseFolderMap(paths map[string]string) map[string]NoteFolder { + m := make(map[string]NoteFolder) + for _, folder := range AllFolders { + p := resolveFolderPath(folder, paths) + if p != string(folder) { + m[strings.ToLower(p)] = folder + } + } + return m +} + func FolderForRelativePath(rel string) (NoteFolder, bool) { + return FolderForRelativePathWithSettings(rel, nil) +} + +func FolderForRelativePathWithSettings(rel string, paths map[string]string) (NoteFolder, bool) { normalized := filepath.ToSlash(rel) top := strings.SplitN(normalized, "/", 2)[0] if IsValidFolder(NoteFolder(top)) { @@ -52,6 +159,13 @@ func FolderForRelativePath(rel string) (NoteFolder, bool) { if top == "" || strings.HasPrefix(top, ".") { return "", false } + if len(paths) > 0 { + if reverseMap := buildReverseFolderMap(paths); len(reverseMap) > 0 { + if folder, ok := reverseMap[strings.ToLower(top)]; ok { + return folder, true + } + } + } if _, reserved := reservedRootNames[top]; reserved { return "", false } @@ -105,6 +219,9 @@ type VaultSettings struct { // Favorites are note paths or `folder:subpath` keys pinned to the top of // the sidebar. Persisted so the web client's favorites survive a round-trip. Favorites []string `json:"favorites"` + // Per-system-folder on-disk path overrides (#115). Maps internal folder IDs + // to vault-relative directory names. Absent entries fall back to the default. + SystemFolderPaths map[string]string `json:"systemFolderPaths,omitempty"` } // NoteMeta — vault-relative note metadata. Mirrors shared/ipc.ts NoteMeta. diff --git a/apps/server/internal/vault/vault.go b/apps/server/internal/vault/vault.go index e580ab71..dc37eefa 100644 --- a/apps/server/internal/vault/vault.go +++ b/apps/server/internal/vault/vault.go @@ -135,9 +135,27 @@ func init() { } } -func shouldHidePrimaryRootName(name string) bool { - _, hidden := hiddenPrimaryRootNames[name] - return hidden +func shouldHidePrimaryRootName(name string, extra map[string]struct{}) bool { + if _, hidden := hiddenPrimaryRootNames[name]; hidden { + return true + } + if extra != nil { + if _, hidden := extra[name]; hidden { + return true + } + } + return false +} + +func customHiddenPrimaryRootNames(settings VaultSettings) map[string]struct{} { + out := map[string]struct{}{} + for _, folder := range []NoteFolder{FolderQuick, FolderArchive, FolderTrash} { + p := resolveFolderPath(folder, settings.SystemFolderPaths) + if p != string(folder) { + out[p] = struct{}{} + } + } + return out } // Vault encapsulates all operations against a filesystem vault root. @@ -491,8 +509,9 @@ func normalizeVaultSettings(value VaultSettings, fallbackPrimary PrimaryNotesLoc LegacyPatterns: normalizeMonthlyNoteLegacyPatterns(value.MonthlyNotes.LegacyPatterns), TemplateID: value.MonthlyNotes.TemplateID, }, - FolderIcons: folderIcons, - Favorites: normalizeFavorites(value.Favorites), + FolderIcons: folderIcons, + Favorites: normalizeFavorites(value.Favorites), + SystemFolderPaths: normalizeSystemFolderPaths(value.SystemFolderPaths), } } @@ -678,7 +697,12 @@ func (v *Vault) folderRoot(folder NoteFolder) (string, error) { if folder == FolderInbox { return v.primaryNotesRoot() } - return filepath.Join(v.root, string(folder)), nil + settings, err := v.GetSettings() + if err != nil { + return "", err + } + p := resolveFolderPath(folder, settings.SystemFolderPaths) + return filepath.Join(v.root, p), nil } // EnsureLayout creates the four top-level folders and seeds a welcome @@ -693,7 +717,8 @@ func (v *Vault) EnsureLayout() error { if f == FolderInbox && settings.PrimaryNotesLocation == PrimaryNotesRoot { continue } - if err := os.MkdirAll(filepath.Join(v.root, string(f)), v.dirMode); err != nil { + p := resolveFolderPath(f, settings.SystemFolderPaths) + if err := os.MkdirAll(filepath.Join(v.root, p), v.dirMode); err != nil { return err } } @@ -859,6 +884,12 @@ func (v *Vault) ListNotes() ([]NoteMeta, error) { defer v.mu.RUnlock() v.hydratePersistedNoteMetaCache() + settings, err := v.GetSettings() + if err != nil { + return nil, err + } + extraHidden := customHiddenPrimaryRootNames(settings) + type noteFile struct { folder NoteFolder path string @@ -883,12 +914,12 @@ func (v *Vault) ListNotes() ([]NoteMeta, error) { return filepath.SkipDir } if isFormDirName(d.Name()) { - return filepath.SkipDir // database folder — not loose notes + return filepath.SkipDir } if isPrimaryRoot && path != folderRoot { parent := filepath.Dir(path) if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name()) { + if shouldHidePrimaryRootName(d.Name(), extraHidden) { return filepath.SkipDir } } @@ -898,7 +929,7 @@ func (v *Vault) ListNotes() ([]NoteMeta, error) { if isPrimaryRoot { parent := filepath.Dir(path) if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name()) { + if shouldHidePrimaryRootName(d.Name(), extraHidden) { return filepath.SkipDir } } @@ -966,6 +997,11 @@ func assignSiblingOrder[T any](list []T, key func(T) string, set func(*T, int)) func (v *Vault) ListFolders() ([]FolderEntry, error) { v.mu.RLock() defer v.mu.RUnlock() + settings, err := v.GetSettings() + if err != nil { + return nil, err + } + extraHidden := customHiddenPrimaryRootNames(settings) out := []FolderEntry{} for _, folder := range AllFolders { folderRoot, err := v.folderRoot(folder) @@ -992,7 +1028,7 @@ func (v *Vault) ListFolders() ([]FolderEntry, error) { if isPrimaryRoot { parent := filepath.Dir(path) if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name()) { + if shouldHidePrimaryRootName(d.Name(), extraHidden) { return filepath.SkipDir } } @@ -1907,6 +1943,11 @@ func (v *Vault) DuplicateFolder(folder NoteFolder, subpath string) (string, erro func (v *Vault) ScanTasks() ([]Task, error) { v.mu.RLock() defer v.mu.RUnlock() + settings, err := v.GetSettings() + if err != nil { + return nil, err + } + extraHidden := customHiddenPrimaryRootNames(settings) all := []Task{} for _, folder := range []NoteFolder{FolderInbox, FolderQuick, FolderArchive} { folderRoot, err := v.folderRoot(folder) @@ -1928,7 +1969,7 @@ func (v *Vault) ScanTasks() ([]Task, error) { if isPrimaryRoot && path != folderRoot { parent := filepath.Dir(path) if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name()) { + if shouldHidePrimaryRootName(d.Name(), extraHidden) { return filepath.SkipDir } } @@ -1938,7 +1979,7 @@ func (v *Vault) ScanTasks() ([]Task, error) { if isPrimaryRoot { parent := filepath.Dir(path) if filepath.Clean(parent) == filepath.Clean(folderRoot) { - if shouldHidePrimaryRootName(d.Name()) { + if shouldHidePrimaryRootName(d.Name(), extraHidden) { return nil } } @@ -1986,6 +2027,11 @@ func (v *Vault) SearchCapabilities() TextSearchCapabilities { func (v *Vault) textSearchFilesLocked() (uint64, []textSearchFile, error) { h := fnv.New64a() files := []textSearchFile{} + settings, err := v.GetSettings() + if err != nil { + return 0, nil, err + } + extraHidden := customHiddenPrimaryRootNames(settings) for _, folder := range []NoteFolder{FolderInbox, FolderQuick, FolderArchive} { folderRoot, err := v.folderRoot(folder) if err != nil { @@ -2005,7 +2051,7 @@ func (v *Vault) textSearchFilesLocked() (uint64, []textSearchFile, error) { if isPrimaryRoot && path != folderRoot { parent := filepath.Dir(path) if filepath.Clean(parent) == cleanFolderRoot { - if shouldHidePrimaryRootName(d.Name()) { + if shouldHidePrimaryRootName(d.Name(), extraHidden) { return filepath.SkipDir } } @@ -2015,7 +2061,7 @@ func (v *Vault) textSearchFilesLocked() (uint64, []textSearchFile, error) { if isPrimaryRoot { parent := filepath.Dir(path) if filepath.Clean(parent) == cleanFolderRoot { - if shouldHidePrimaryRootName(d.Name()) { + if shouldHidePrimaryRootName(d.Name(), extraHidden) { return nil } } diff --git a/apps/server/internal/watcher/watcher.go b/apps/server/internal/watcher/watcher.go index 50abd8c4..5656f352 100644 --- a/apps/server/internal/watcher/watcher.go +++ b/apps/server/internal/watcher/watcher.go @@ -1,6 +1,7 @@ package watcher import ( + "encoding/json" "log" "os" "path/filepath" @@ -33,6 +34,36 @@ type Watcher struct { // as a folder change. Only touched from the single loop goroutine (and // Start, before the loop begins), so it needs no separate lock. dirs map[string]struct{} + // folderPaths holds the systemFolderPaths from vault settings for + // classifying note paths to folder IDs. + folderPaths map[string]string +} + +func (w *Watcher) SetFolderPaths(paths map[string]string) { + w.mu.Lock() + defer w.mu.Unlock() + w.folderPaths = paths +} + +func (w *Watcher) getFolderPaths() map[string]string { + w.mu.Lock() + defer w.mu.Unlock() + return w.folderPaths +} + +func (w *Watcher) reloadFolderPaths() { + settingsPath := filepath.Join(w.root, vaultSettingsFilePath) + raw, err := os.ReadFile(settingsPath) + if err != nil { + return + } + var settings struct { + SystemFolderPaths map[string]string `json:"systemFolderPaths"` + } + if err := json.Unmarshal(raw, &settings); err != nil { + return + } + w.SetFolderPaths(settings.SystemFolderPaths) } func Start(root string) (*Watcher, error) { @@ -41,11 +72,12 @@ func Start(root string) (*Watcher, error) { return nil, err } w := &Watcher{ - root: root, - fs: fsw, - subs: map[chan vault.ChangeEvent]struct{}{}, - stopCh: make(chan struct{}), - dirs: map[string]struct{}{}, + root: root, + fs: fsw, + subs: map[chan vault.ChangeEvent]struct{}{}, + stopCh: make(chan struct{}), + dirs: map[string]struct{}{}, + folderPaths: nil, } // Recursively add all existing directories under the vault. var addErrs int @@ -222,6 +254,7 @@ func (w *Watcher) handle(ev fsnotify.Event) { return } if relPosix == vaultSettingsFilePath { + w.reloadFolderPaths() kind := eventKind(ev) if kind == "" { return @@ -239,7 +272,7 @@ func (w *Watcher) handle(ev fsnotify.Event) { if kind == "" { return } - folder, ok := vault.FolderForRelativePath(notePath) + folder, ok := vault.FolderForRelativePathWithSettings(notePath, w.getFolderPaths()) if !ok { folder = vault.FolderInbox } @@ -254,7 +287,7 @@ func (w *Watcher) handle(ev fsnotify.Event) { if strings.HasPrefix(relPosix, ".") || strings.Contains(relPosix, "/.") { return } - folder, ok := vault.FolderForRelativePath(relPosix) + folder, ok := vault.FolderForRelativePathWithSettings(relPosix, w.getFolderPaths()) if !ok { if relPosix == vault.AssetsDir || strings.HasPrefix(relPosix, vault.AssetsDir+"/") || @@ -300,7 +333,7 @@ func (w *Watcher) broadcastFolder(absPath, kind string) { if rel == "" { return } - folder, ok := vault.FolderForRelativePath(rel) + folder, ok := vault.FolderForRelativePathWithSettings(rel, w.getFolderPaths()) if !ok { return } diff --git a/apps/server/internal/watcher/watcher_test.go b/apps/server/internal/watcher/watcher_test.go index 68f36fd8..84bc90aa 100644 --- a/apps/server/internal/watcher/watcher_test.go +++ b/apps/server/internal/watcher/watcher_test.go @@ -21,11 +21,12 @@ func newTestWatcher(t *testing.T, root string) *Watcher { } t.Cleanup(func() { _ = fsw.Close() }) return &Watcher{ - root: root, - fs: fsw, - subs: map[chan vault.ChangeEvent]struct{}{}, - dirs: map[string]struct{}{}, - stopCh: make(chan struct{}), + root: root, + fs: fsw, + subs: map[chan vault.ChangeEvent]struct{}{}, + dirs: map[string]struct{}{}, + stopCh: make(chan struct{}), + folderPaths: nil, } } diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index a3444d3c..494f75eb 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -76,6 +76,7 @@ import { DEFAULT_SYSTEM_FOLDER_LABELS, getSystemFolderLabel, } from "../lib/system-folder-labels"; +import { DEFAULT_FOLDER_PATHS, resolveFolderPath } from "@shared/system-folder-paths"; import { normalizeDailyNoteLocale, normalizeDailyNotesDirectory, @@ -3635,6 +3636,10 @@ export function SettingsModal(): JSX.Element { "archive-label", "trash-label", "tasks-label", + "inbox-path", + "quick-notes-path", + "archive-path", + "trash-path", ], content: (
@@ -3691,6 +3696,47 @@ export function SettingsModal(): JSX.Element { {getSystemFolderLabel("tasks", systemFolderLabels)}. +
+
+ {( + [ + { key: "inbox" as const, label: "Inbox path", desc: "Main notes area." }, + { key: "quick" as const, label: "Quick Notes path", desc: "Quick-capture folder." }, + { key: "archive" as const, label: "Archive path", desc: "Cold-storage notes." }, + { key: "trash" as const, label: "Trash path", desc: "Deleted-note recovery." }, + ] as const + ).map(({ key, label, desc }) => ( + { + const trimmed = (next ?? "").trim() + void persistVaultSettings({ + ...vaultSettings, + systemFolderPaths: { + ...vaultSettings.systemFolderPaths, + [key]: trimmed || undefined, + }, + }); + }} + /> + ))} + + Resolved paths:{" "} + {(["inbox", "quick", "archive", "trash"] as const) + .map((f) => resolveFolderPath(f, vaultSettings.systemFolderPaths)) + .join(", ")} + . + +
+
), }, diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index 32cf1045..82a3bf72 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -79,6 +79,7 @@ import { noteFolderSubpath, parseFavoriteFolderKey, } from "../lib/vault-layout"; +import { resolveFolderPath } from "@shared/system-folder-paths"; import { getCurrentDragPayload, hasZenItem, @@ -262,7 +263,8 @@ function vaultRelativeFolderPath( vaultSettings: ReturnType["vaultSettings"], ): string { if (folder === "inbox" && isPrimaryNotesAtRoot(vaultSettings)) return subpath; - return subpath ? `${folder}/${subpath}` : folder; + const folderPath = resolveFolderPath(folder, vaultSettings.systemFolderPaths); + return subpath ? `${folderPath}/${subpath}` : folderPath; } type SidebarSelectionItem = diff --git a/packages/app-core/src/lib/database-links.ts b/packages/app-core/src/lib/database-links.ts index a0fda369..86a57b17 100644 --- a/packages/app-core/src/lib/database-links.ts +++ b/packages/app-core/src/lib/database-links.ts @@ -6,6 +6,7 @@ */ import type { FolderEntry, NoteFolder, VaultSettings } from '@shared/ipc' import { csvPathForFormDir, formTitleFromCsvPath, isFormDirName } from '@shared/databases' +import { resolveFolderPath } from '@shared/system-folder-paths' import { isPrimaryNotesAtRoot } from './vault-layout' import { stripWikilinkAnchor } from './wikilinks' @@ -23,7 +24,9 @@ function vaultRelativeFolderPath( vaultSettings: VaultSettings ): string { if (folder === 'inbox' && isPrimaryNotesAtRoot(vaultSettings)) return subpath - return subpath ? `${folder}/${subpath}` : folder + return subpath + ? `${resolveFolderPath(folder, vaultSettings.systemFolderPaths)}/${subpath}` + : resolveFolderPath(folder, vaultSettings.systemFolderPaths) } /** diff --git a/packages/app-core/src/lib/vault-layout.ts b/packages/app-core/src/lib/vault-layout.ts index 3f097762..3de0d339 100644 --- a/packages/app-core/src/lib/vault-layout.ts +++ b/packages/app-core/src/lib/vault-layout.ts @@ -18,6 +18,7 @@ import { type NoteMeta, type VaultSettings } from '@shared/ipc' +import { buildReverseFolderMap, normalizeSystemFolderPaths, resolveFolderPath } from '@shared/system-folder-paths' import { getISOWeek, getISOWeekYear, mondayOfISOWeek } from './template-render' const SYSTEM_FOLDERS = new Set(['inbox', 'quick', 'archive', 'trash']) @@ -631,6 +632,7 @@ export function normalizeVaultSettings( folderIcons: normalizedFolderIcons, folderColors: normalizedFolderColors, favorites: normalizedFavorites, + systemFolderPaths: normalizeSystemFolderPaths(settings?.systemFolderPaths), // Per-vault view overrides (#292): passed through as-is; the store validates // each value when it overlays them onto the live prefs. ...(settings?.view ? { view: settings.view } : {}) @@ -895,11 +897,8 @@ export function notePathWithinFolder( settings: VaultSettings | null | undefined ): string { if (folder === 'inbox' && isPrimaryNotesAtRoot(settings)) return path - const prefix = `${folder}/` - // Case-insensitive so a note under a capitalized on-disk system folder - // (e.g. `Inbox/`) lands in the same subpath as its sibling assets, which use - // the same lenient stripping. Mirrors `assetPathWithinFolder`. (#186) - return path.toLowerCase().startsWith(prefix) ? path.slice(prefix.length) : path + const prefix = `${resolveFolderPath(folder, settings?.systemFolderPaths)}/` + return path.toLowerCase().startsWith(prefix.toLowerCase()) ? path.slice(prefix.length) : path } export function noteFolderSubpath( @@ -1445,9 +1444,12 @@ export function findDateNoteByTitle( // `listAssets`/the watcher walk real directory entries and preserve the on-disk // case (e.g. `Inbox/…`). Comparing case-sensitively dropped those assets to // `null`, so a capitalized `Inbox/` showed its notes but hid its images/PDFs. (#186) -function systemFolderForTopSegment(top: string): NoteFolder | null { +function systemFolderForTopSegment( + top: string, + reverseMap: Map +): NoteFolder | null { const lower = top.toLowerCase() - return SYSTEM_FOLDERS.has(lower as NoteFolder) ? (lower as NoteFolder) : null + return SYSTEM_FOLDERS.has(lower as NoteFolder) ? (lower as NoteFolder) : reverseMap.get(lower) ?? null } export function folderForVaultRelativePath( @@ -1457,7 +1459,10 @@ export function folderForVaultRelativePath( const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '') const top = normalized.split('/')[0] ?? '' if (!top || top.startsWith('.')) return null - const system = systemFolderForTopSegment(top) + const reverseMap = settings?.systemFolderPaths + ? buildReverseFolderMap(settings.systemFolderPaths) + : new Map() + const system = systemFolderForTopSegment(top, reverseMap) if (system) return system if (isPrimaryNotesAtRoot(settings) && !RESERVED_ROOT_NAMES.has(top.toLowerCase())) return 'inbox' return null @@ -1470,10 +1475,8 @@ export function assetPathWithinFolder( ): string { const normalized = assetPath.replace(/\\/g, '/').replace(/^\/+/, '') if (folder === 'inbox' && isPrimaryNotesAtRoot(settings)) return normalized - const prefix = `${folder}/` - // Strip the system-folder prefix case-insensitively so a capitalized on-disk - // folder (e.g. `Inbox/`) lands in the same subpath tree as its notes. (#186) - return normalized.toLowerCase().startsWith(prefix) + const prefix = `${resolveFolderPath(folder, settings?.systemFolderPaths)}/` + return normalized.toLowerCase().startsWith(prefix.toLowerCase()) ? normalized.slice(prefix.length) : normalized } diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 4e7ff4c3..7a493e4b 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand' import type { EditorView } from '@codemirror/view' import { DEFAULT_VAULT_SETTINGS } from '@shared/ipc' +import { resolveFolderPath } from '@shared/system-folder-paths' import type { AssetMeta, DateNotePatternSettings, @@ -6826,13 +6827,16 @@ export const useStore = create((set, get) => { renameFolder: async (folder, oldSubpath, newSubpath) => { await window.zen.renameFolder(folder, oldSubpath, newSubpath) - const oldPrefix = `${folder}/${oldSubpath}/` - const newPrefix = `${folder}/${newSubpath}/` + const folderPath = resolveFolderPath(folder, get().vaultSettings.systemFolderPaths) + const oldPrefix = `${folderPath}/${oldSubpath}/` + const newPrefix = `${folderPath}/${newSubpath}/` const rewritePath = (p: string): string => - p.startsWith(oldPrefix) ? newPrefix + p.slice(oldPrefix.length) : p + p.toLowerCase().startsWith(oldPrefix.toLowerCase()) + ? newPrefix + p.slice(oldPrefix.length) + : p const notes = get().notes.map((n) => - n.path.startsWith(oldPrefix) ? { ...n, path: rewritePath(n.path) } : n + n.path.toLowerCase().startsWith(oldPrefix.toLowerCase()) ? { ...n, path: rewritePath(n.path) } : n ) const folders = get().folders.map((f) => { if (f.folder !== folder) return f @@ -6923,7 +6927,8 @@ export const useStore = create((set, get) => { ) { set({ view: { kind: 'folder', folder, subpath: '' } }) } - const prefix = `${folder}/${subpath}/` + const folderPath = resolveFolderPath(folder, get().vaultSettings.systemFolderPaths) + const prefix = `${folderPath}/${subpath}/` const nextFolderIcons = removeFolderIcons(get().vaultSettings.folderIcons, folder, subpath) const nextFolderColors = removeFolderColors(get().vaultSettings.folderColors, folder, subpath) set((s) => { diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index e23bc938..b795cc45 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -402,6 +402,15 @@ export interface VaultSettings { * distinguishable. Order is the display order in the Favorites section. */ favorites: string[] + /** + * Per-system-folder on-disk path overrides (#115). Maps internal folder IDs + * to vault-relative directory names (e.g. `{ inbox: '01 - Entry', archive: 'Archive' }`). + * Absent entries fall back to the default folder name (same as the ID). + * Notes stored in remapped folders retain their vault-relative paths as-is + * (e.g. `01 - Entry/Projects/Idea.md`), while `NoteMeta.folder` remains the + * internal ID (`inbox`). + */ + systemFolderPaths?: Partial> } export const DEFAULT_DAILY_NOTES_DIRECTORY = 'Daily Notes' @@ -440,7 +449,8 @@ export const DEFAULT_VAULT_SETTINGS: VaultSettings = { databasesLocation: { mode: 'primary' }, folderIcons: {}, folderColors: {}, - favorites: [] + favorites: [], + systemFolderPaths: {} } export interface NoteMeta { diff --git a/packages/shared-domain/src/system-folder-paths.test.ts b/packages/shared-domain/src/system-folder-paths.test.ts new file mode 100644 index 00000000..1af9fc85 --- /dev/null +++ b/packages/shared-domain/src/system-folder-paths.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest' +import { + normalizeSystemFolderPaths, + resolveFolderPath, + buildReverseFolderMap, + DEFAULT_FOLDER_PATHS +} from './system-folder-paths' + +describe('normalizeSystemFolderPaths', () => { + it('returns empty object for non-object input', () => { + expect(normalizeSystemFolderPaths(null)).toEqual({}) + expect(normalizeSystemFolderPaths(undefined)).toEqual({}) + expect(normalizeSystemFolderPaths('string')).toEqual({}) + expect(normalizeSystemFolderPaths(42)).toEqual({}) + }) + + it('returns empty object for empty object', () => { + expect(normalizeSystemFolderPaths({})).toEqual({}) + }) + + it('returns empty object when all paths are defaults', () => { + expect(normalizeSystemFolderPaths({ inbox: 'inbox', trash: 'trash' })).toEqual({}) + }) + + it('normalizes valid custom paths', () => { + expect(normalizeSystemFolderPaths({ inbox: '01 - Entry' })).toEqual({ inbox: '01 - Entry' }) + expect(normalizeSystemFolderPaths({ archive: 'Archive', trash: 'deleted' })).toEqual({ + archive: 'Archive', + trash: 'deleted' + }) + }) + + it('rejects backslashes in paths', () => { + expect(normalizeSystemFolderPaths({ inbox: 'Notes\\Inbox' })).toEqual({}) + }) + + it('rejects paths with .. segments', () => { + expect(normalizeSystemFolderPaths({ inbox: '../inbox' })).toEqual({}) + }) + + it('rejects paths with leading /', () => { + expect(normalizeSystemFolderPaths({ inbox: '/inbox' })).toEqual({}) + }) + + it('rejects paths with empty segments', () => { + expect(normalizeSystemFolderPaths({ inbox: 'inbox//sub' })).toEqual({}) + }) + + it('rejects paths starting with dot', () => { + expect(normalizeSystemFolderPaths({ trash: '.trash' })).toEqual({}) + }) + + it('rejects paths with invalid characters', () => { + expect(normalizeSystemFolderPaths({ inbox: 'inbox:name' })).toEqual({}) + expect(normalizeSystemFolderPaths({ inbox: 'inbox?' })).toEqual({}) + }) + + it('rejects paths exceeding max length', () => { + const long = 'a'.repeat(129) + expect(normalizeSystemFolderPaths({ inbox: long })).toEqual({}) + }) + + it('rejects empty string values', () => { + expect(normalizeSystemFolderPaths({ inbox: '' })).toEqual({}) + expect(normalizeSystemFolderPaths({ inbox: ' ' })).toEqual({}) + }) + + it('rejects non-string values', () => { + expect(normalizeSystemFolderPaths({ inbox: 42 })).toEqual({}) + expect(normalizeSystemFolderPaths({ inbox: true })).toEqual({}) + }) + + it('rejects paths colliding with reserved root names', () => { + expect(normalizeSystemFolderPaths({ inbox: 'assets' })).toEqual({}) + expect(normalizeSystemFolderPaths({ inbox: '.zennotes' })).toEqual({}) + expect(normalizeSystemFolderPaths({ trash: 'comments' })).toEqual({}) + }) + + it('keeps one of duplicate custom paths, drops the other', () => { + const result = normalizeSystemFolderPaths({ inbox: 'Notes', archive: 'Notes' }) + // One will be kept, the other dropped due to collision + expect(result).toEqual({ archive: 'Notes' }) + }) + + it('rejects custom path colliding with another folder default', () => { + expect(normalizeSystemFolderPaths({ inbox: 'archive' })).toEqual({}) + expect(normalizeSystemFolderPaths({ trash: 'inbox' })).toEqual({}) + }) + + it('keeps valid overrides when some are conflicting with each other', () => { + const result = normalizeSystemFolderPaths({ inbox: 'my-inbox', trash: 'dup', archive: 'dup' }) + // 'dup' collides between trash and archive; one is removed, the other kept + expect(result).toEqual({ inbox: 'my-inbox', trash: 'dup' }) + }) + + it('removes override that collides with a default', () => { + expect(normalizeSystemFolderPaths({ inbox: 'my-inbox', trash: 'archive' })).toEqual({ + inbox: 'my-inbox' + }) + }) + + it('rejects multi-segment paths', () => { + expect(normalizeSystemFolderPaths({ trash: 'sys/trash' })).toEqual({}) + expect(normalizeSystemFolderPaths({ inbox: 'Notes/Inbox' })).toEqual({}) + }) + + it('ignores unknown keys', () => { + expect(normalizeSystemFolderPaths({ inbox: '01 - Entry', other: 'value' })).toEqual({ + inbox: '01 - Entry' + }) + }) + + it('allows setting all four folders', () => { + const result = normalizeSystemFolderPaths({ + inbox: '01 - Entry', + quick: 'Quick Notes', + archive: 'Archive', + trash: 'deleted' + }) + expect(result).toEqual({ + inbox: '01 - Entry', + quick: 'Quick Notes', + archive: 'Archive', + trash: 'deleted' + }) + }) +}) + +describe('resolveFolderPath', () => { + it('returns default when no overrides', () => { + expect(resolveFolderPath('inbox')).toBe('inbox') + expect(resolveFolderPath('trash')).toBe('trash') + }) + + it('returns default when override is null/undefined', () => { + expect(resolveFolderPath('inbox', null)).toBe('inbox') + expect(resolveFolderPath('inbox', undefined)).toBe('inbox') + }) + + it('returns custom path when set', () => { + expect(resolveFolderPath('inbox', { inbox: '01 - Entry' })).toBe('01 - Entry') + }) + + it('falls back to default for unset folders', () => { + const overrides = { inbox: '01 - Entry' } + expect(resolveFolderPath('archive', overrides)).toBe('archive') + }) +}) + +describe('buildReverseFolderMap', () => { + it('returns empty map when no overrides', () => { + expect(buildReverseFolderMap(null)).toEqual(new Map()) + expect(buildReverseFolderMap({})).toEqual(new Map()) + }) + + it('maps custom single-segment paths to folder IDs', () => { + const map = buildReverseFolderMap({ inbox: '01 - Entry', trash: 'deleted' }) + expect(map.get('01 - entry')).toBe('inbox') + expect(map.get('deleted')).toBe('trash') + expect(map.size).toBe(2) + }) +}) + +describe('DEFAULT_FOLDER_PATHS', () => { + it('maps each folder ID to itself', () => { + for (const key of ['inbox', 'quick', 'archive', 'trash'] as const) { + expect(DEFAULT_FOLDER_PATHS[key]).toBe(key) + } + }) +}) diff --git a/packages/shared-domain/src/system-folder-paths.ts b/packages/shared-domain/src/system-folder-paths.ts new file mode 100644 index 00000000..4e2aa159 --- /dev/null +++ b/packages/shared-domain/src/system-folder-paths.ts @@ -0,0 +1,95 @@ +import type { NoteFolder } from '@zennotes/bridge-contract/ipc' + +export type SystemFolderPaths = Partial> + +export const DEFAULT_FOLDER_PATHS: Record = { + inbox: 'inbox', + quick: 'quick', + archive: 'archive', + trash: 'trash' +} + +const FOLDER_IDS: NoteFolder[] = ['inbox', 'quick', 'archive', 'trash'] + +const MAX_PATH_LENGTH = 128 + +const RESERVED_ROOT_NAMES = new Set([ + 'assets', + '.zennotes', + 'attachements', + '_assets', + 'deleted-assets', + 'comments' +]) + +const INVALID_CHARS_RE = /[\\:*?"<>|#^\[\]<>]/ + +function normalizeSystemFolderPath(value: unknown): string | null { + if (typeof value !== 'string') return null + const trimmed = value.trim() + if (!trimmed) return null + if (trimmed.length > MAX_PATH_LENGTH) return null + if (trimmed.includes('/') || trimmed.includes('\\')) return null + if (trimmed.startsWith('/')) return null + if (trimmed === '.' || trimmed === '..' || trimmed.startsWith('.')) return null + if (INVALID_CHARS_RE.test(trimmed)) return null + return trimmed +} + +export function normalizeSystemFolderPaths( + value: unknown +): SystemFolderPaths { + if (!value || typeof value !== 'object') return {} + const raw = value as Partial> + const next: SystemFolderPaths = {} + for (const key of FOLDER_IDS) { + const p = normalizeSystemFolderPath(raw[key]) + if (p && p !== DEFAULT_FOLDER_PATHS[key]) { + next[key] = p + } + } + let changed = true + while (changed) { + changed = false + for (const key of FOLDER_IDS) { + if (!next[key]) continue + const resolved = resolveFolderPath(key, next) + const lower = resolved.toLowerCase() + if (RESERVED_ROOT_NAMES.has(lower)) { + delete next[key] + changed = true + continue + } + for (const other of FOLDER_IDS) { + if (other === key) continue + const otherResolved = resolveFolderPath(other, next).toLowerCase() + if (lower === otherResolved) { + delete next[key] + changed = true + break + } + } + } + } + return next +} + +export function resolveFolderPath( + folder: NoteFolder, + overrides?: SystemFolderPaths | null +): string { + return overrides?.[folder] ?? DEFAULT_FOLDER_PATHS[folder] +} + +export function buildReverseFolderMap( + overrides?: SystemFolderPaths | null +): Map { + const map = new Map() + for (const folder of FOLDER_IDS) { + const p = resolveFolderPath(folder, overrides) + if (p !== DEFAULT_FOLDER_PATHS[folder]) { + map.set(p.toLowerCase(), folder) + } + } + return map +}