From 893ed3436bcbd7ccfaa3417c6f733d36a601e553 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Fri, 27 Mar 2026 09:44:30 +0300 Subject: [PATCH 1/3] refactor(markdown): flatten persisted space layout --- src/main/index.ts | 12 +-- src/main/ipc/handlers/fs.ts | 4 +- .../notes/runtime/__tests__/constants.test.ts | 18 +--- .../markdown/notes/runtime/constants.ts | 15 +-- .../notes/storages/__tests__/folders.test.ts | 4 +- .../notes/storages/__tests__/notes.test.ts | 2 +- .../markdown/runtime/__tests__/paths.test.ts | 95 ++++++++++++++++++- .../providers/markdown/runtime/constants.ts | 13 ++- .../providers/markdown/runtime/index.ts | 6 +- .../providers/markdown/runtime/paths.ts | 9 +- .../runtime/shared/__tests__/path.test.ts | 14 +-- .../providers/markdown/runtime/snippets.ts | 4 +- .../providers/markdown/runtime/spaces.ts | 56 ++++++++++- .../providers/markdown/runtime/sync.ts | 12 +-- .../providers/markdown/runtime/validation.ts | 3 +- .../storages/__tests__/folders.test.ts | 8 ++ 16 files changed, 205 insertions(+), 70 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 1897c499..d195b8db 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,6 +9,7 @@ import { registerIPC } from './ipc' import { startThemeWatcher, stopThemeWatcher } from './ipc/handlers/theme' import { mainMenu } from './menu/main' import { startMarkdownWatcher, stopMarkdownWatcher } from './storage' +import { ensureFlatSpacesLayout } from './storage/providers/markdown/runtime/spaces' import { store } from './store' import { checkForUpdates } from './updates' import { isSqliteFile, log } from './utils' @@ -106,13 +107,8 @@ else { store.preferences.get('storagePath') as string, 'markdown-vault', ) - const filePath = path.join( - vaultPath, - '__spaces__', - 'notes', - 'assets', - fileName, - ) + ensureFlatSpacesLayout(vaultPath) + const filePath = path.join(vaultPath, 'notes', 'assets', fileName) try { const data = await readFile(filePath) @@ -148,9 +144,9 @@ else { const vaultPath = (store.preferences.get('storage.vaultPath') as string | null) || path.join(storagePath, 'markdown-vault') + ensureFlatSpacesLayout(vaultPath) const statePath = path.join( vaultPath, - '__spaces__', 'code', '.masscode', 'state.json', diff --git a/src/main/ipc/handlers/fs.ts b/src/main/ipc/handlers/fs.ts index d25ca019..cac0db6e 100644 --- a/src/main/ipc/handlers/fs.ts +++ b/src/main/ipc/handlers/fs.ts @@ -4,6 +4,7 @@ import { ipcMain } from 'electron' import { ensureDirSync, writeFileSync } from 'fs-extra' import { nanoid } from 'nanoid' import slash from 'slash' +import { ensureFlatSpacesLayout } from '../../storage/providers/markdown/runtime/spaces' import { store } from '../../store' const ASSETS_DIR = 'assets' @@ -38,7 +39,8 @@ export function registerFsHandlers() { return new Promise((resolve, reject) => { try { - const assetsPath = join(vaultPath, '__spaces__', 'notes', 'assets') + ensureFlatSpacesLayout(vaultPath) + const assetsPath = join(vaultPath, 'notes', 'assets') const name = `${nanoid()}${ext}` const dest = join(assetsPath, name) diff --git a/src/main/storage/providers/markdown/notes/runtime/__tests__/constants.test.ts b/src/main/storage/providers/markdown/notes/runtime/__tests__/constants.test.ts index 1417a0ca..92b2d1d2 100644 --- a/src/main/storage/providers/markdown/notes/runtime/__tests__/constants.test.ts +++ b/src/main/storage/providers/markdown/notes/runtime/__tests__/constants.test.ts @@ -22,7 +22,7 @@ afterEach(() => { }) describe('getNotesPaths', () => { - it('migrates nested notes space from __spaces__/code to root __spaces__/notes', () => { + it('migrates nested notes space from legacy code root to flat notes root', () => { const vaultPath = createTempDir() const legacyNotesRoot = path.join( vaultPath, @@ -42,18 +42,16 @@ describe('getNotesPaths', () => { const notesPaths = getNotesPaths(vaultPath) - expect(notesPaths.notesRoot).toBe( - path.join(vaultPath, '__spaces__', 'notes'), - ) + expect(notesPaths.notesRoot).toBe(path.join(vaultPath, 'notes')) expect( fs.pathExistsSync(path.join(notesPaths.notesRoot, 'Folder', 'note.md')), ).toBe(true) expect(fs.pathExistsSync(legacyNotesRoot)).toBe(false) }) - it('merges nested notes space into existing root notes space', () => { + it('merges nested notes space into existing flat notes space', () => { const vaultPath = createTempDir() - const notesRoot = path.join(vaultPath, '__spaces__', 'notes') + const notesRoot = path.join(vaultPath, 'notes') fs.ensureDirSync(path.join(notesRoot, '.masscode')) fs.writeFileSync( path.join(notesRoot, '.masscode', 'state.json'), @@ -62,13 +60,7 @@ describe('getNotesPaths', () => { ) fs.writeFileSync(path.join(notesRoot, 'root-note.md'), '# Root') - const legacyNotesRoot = path.join( - vaultPath, - '__spaces__', - 'code', - '__spaces__', - 'notes', - ) + const legacyNotesRoot = path.join(vaultPath, 'code', '__spaces__', 'notes') fs.ensureDirSync(legacyNotesRoot) fs.writeFileSync(path.join(legacyNotesRoot, 'legacy-note.md'), '# Legacy') diff --git a/src/main/storage/providers/markdown/notes/runtime/constants.ts b/src/main/storage/providers/markdown/notes/runtime/constants.ts index 5a808a80..0fdd32dd 100644 --- a/src/main/storage/providers/markdown/notes/runtime/constants.ts +++ b/src/main/storage/providers/markdown/notes/runtime/constants.ts @@ -6,15 +6,14 @@ import { INBOX_DIR_NAME, META_DIR_NAME, META_FILE_NAME, - SPACES_DIR_NAME, + NOTES_SPACE_ID, STATE_FILE_NAME, TRASH_DIR_NAME, } from '../../runtime/constants' +import { getSpaceDirPath } from '../../runtime/spaces' export { INBOX_DIR_NAME, META_DIR_NAME, META_FILE_NAME, TRASH_DIR_NAME } -export const NOTES_SPACE_ID = 'notes' - export const NOTES_INBOX_RELATIVE_PATH = `${META_DIR_NAME}/${INBOX_DIR_NAME}` export const NOTES_TRASH_RELATIVE_PATH = `${META_DIR_NAME}/${TRASH_DIR_NAME}` @@ -28,13 +27,7 @@ export const notesRuntimeRef: { cache: NotesRuntimeCache | null } = { } function getLegacyNestedNotesRoot(vaultPath: string): string { - return path.join( - vaultPath, - SPACES_DIR_NAME, - CODE_SPACE_ID, - SPACES_DIR_NAME, - NOTES_SPACE_ID, - ) + return path.join(vaultPath, CODE_SPACE_ID, '__spaces__', NOTES_SPACE_ID) } function hasNotesState(notesRoot: string): boolean { @@ -66,7 +59,7 @@ function migrateNestedNotesSpace(vaultPath: string, notesRoot: string): void { } export function getNotesPaths(vaultPath: string) { - const notesRoot = path.join(vaultPath, SPACES_DIR_NAME, NOTES_SPACE_ID) + const notesRoot = getSpaceDirPath(vaultPath, NOTES_SPACE_ID) migrateNestedNotesSpace(vaultPath, notesRoot) const metaDirPath = path.join(notesRoot, META_DIR_NAME) diff --git a/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts b/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts index 0a353e41..3e7c5137 100644 --- a/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts +++ b/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts @@ -78,7 +78,7 @@ describe('folders storage validations', () => { tempVaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'folders-storage-')) resetNotesRuntimeCache() - const notesRoot = path.join(tempVaultPath, '__spaces__', 'notes') + const notesRoot = path.join(tempVaultPath, 'notes') const metaDirPath = path.join(notesRoot, '.masscode') ensureNotesStateFile({ @@ -124,7 +124,7 @@ describe('folders storage validations', () => { const { id } = storage.createFolder({ name: 'Source' }) storage.getFolders() - const notesRoot = path.join(tempVaultPath, '__spaces__', 'notes') + const notesRoot = path.join(tempVaultPath, 'notes') fs.ensureDirSync(path.join(notesRoot, 'Target')) expect(() => storage.updateFolder(id, { name: 'Target' })).toThrow( diff --git a/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts b/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts index e3f7a94c..8018fc7d 100644 --- a/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts +++ b/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts @@ -78,7 +78,7 @@ describe('notes storage validations', () => { tempVaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'notes-storage-')) resetNotesRuntimeCache() - const notesRoot = path.join(tempVaultPath, '__spaces__', 'notes') + const notesRoot = path.join(tempVaultPath, 'notes') const metaDirPath = path.join(notesRoot, '.masscode') ensureNotesStateFile({ diff --git a/src/main/storage/providers/markdown/runtime/__tests__/paths.test.ts b/src/main/storage/providers/markdown/runtime/__tests__/paths.test.ts index e2e7612a..22d40020 100644 --- a/src/main/storage/providers/markdown/runtime/__tests__/paths.test.ts +++ b/src/main/storage/providers/markdown/runtime/__tests__/paths.test.ts @@ -80,7 +80,7 @@ afterEach(() => { }) describe('getPaths', () => { - it('migrates legacy code vault root into __spaces__/code', () => { + it('migrates legacy code vault root into code', () => { const vaultPath = createTempDir() fs.ensureDirSync(path.join(vaultPath, '.masscode')) @@ -114,7 +114,7 @@ describe('getPaths', () => { const paths = getPaths(vaultPath) - expect(paths.vaultPath).toBe(path.join(vaultPath, '__spaces__', 'code')) + expect(paths.vaultPath).toBe(path.join(vaultPath, 'code')) expect( fs.pathExistsSync(path.join(paths.vaultPath, '.masscode', 'state.json')), ).toBe(true) @@ -128,7 +128,7 @@ describe('getPaths', () => { it('keeps existing initialized code space without forced root migration', () => { const vaultPath = createTempDir() - const codeRoot = path.join(vaultPath, '__spaces__', 'code') + const codeRoot = path.join(vaultPath, 'code') fs.ensureDirSync(path.join(codeRoot, '.masscode')) fs.writeJSONSync(path.join(codeRoot, '.masscode', 'state.json'), { counters: { @@ -172,7 +172,7 @@ describe('getPaths', () => { it('migrates when code space exists but is not initialized', () => { const vaultPath = createTempDir() - const codeRoot = path.join(vaultPath, '__spaces__', 'code') + const codeRoot = path.join(vaultPath, 'code') fs.ensureDirSync(codeRoot) fs.ensureDirSync(path.join(vaultPath, '.masscode')) @@ -203,4 +203,91 @@ describe('getPaths', () => { ) expect(fs.pathExistsSync(path.join(vaultPath, 'Legacy'))).toBe(false) }) + + it('flattens __spaces__ wrapper for all known spaces before resolving code path', () => { + const vaultPath = createTempDir() + const legacyCodeRoot = path.join(vaultPath, '__spaces__', 'code') + const legacyNotesRoot = path.join(vaultPath, '__spaces__', 'notes') + const legacyMathRoot = path.join(vaultPath, '__spaces__', 'math') + + fs.ensureDirSync(path.join(legacyCodeRoot, '.masscode')) + fs.writeJSONSync(path.join(legacyCodeRoot, '.masscode', 'state.json'), { + counters: { + contentId: 0, + folderId: 0, + snippetId: 0, + tagId: 0, + }, + folderUi: {}, + folders: [], + snippets: [], + tags: [], + version: 2, + }) + fs.writeFileSync(path.join(legacyCodeRoot, 'snippet.md'), '# Code') + + fs.ensureDirSync(legacyNotesRoot) + fs.writeFileSync(path.join(legacyNotesRoot, 'note.md'), '# Note') + + fs.ensureDirSync(legacyMathRoot) + fs.writeFileSync(path.join(legacyMathRoot, '.state.yaml'), 'sheets: []') + + const paths = getPaths(vaultPath) + + expect(paths.vaultPath).toBe(path.join(vaultPath, 'code')) + expect(fs.pathExistsSync(path.join(vaultPath, 'code', 'snippet.md'))).toBe( + true, + ) + expect(fs.pathExistsSync(path.join(vaultPath, 'notes', 'note.md'))).toBe( + true, + ) + expect(fs.pathExistsSync(path.join(vaultPath, 'math', '.state.yaml'))).toBe( + true, + ) + expect(fs.pathExistsSync(path.join(vaultPath, '__spaces__'))).toBe(false) + }) + + it('merges partially migrated __spaces__ content into existing flat roots', () => { + const vaultPath = createTempDir() + const codeRoot = path.join(vaultPath, 'code') + const legacyCodeRoot = path.join(vaultPath, '__spaces__', 'code') + const notesRoot = path.join(vaultPath, 'notes') + const legacyNotesRoot = path.join(vaultPath, '__spaces__', 'notes') + + fs.ensureDirSync(path.join(codeRoot, '.masscode')) + fs.writeJSONSync(path.join(codeRoot, '.masscode', 'state.json'), { + counters: { + contentId: 0, + folderId: 0, + snippetId: 0, + tagId: 0, + }, + folderUi: {}, + folders: [], + snippets: [], + tags: [], + version: 2, + }) + fs.writeFileSync(path.join(codeRoot, 'root.md'), '# Root') + + fs.ensureDirSync(legacyCodeRoot) + fs.writeFileSync(path.join(legacyCodeRoot, 'legacy.md'), '# Legacy') + + fs.ensureDirSync(notesRoot) + fs.writeFileSync(path.join(notesRoot, 'existing.md'), '# Existing') + + fs.ensureDirSync(legacyNotesRoot) + fs.writeFileSync(path.join(legacyNotesRoot, 'migrated.md'), '# Migrated') + + const paths = getPaths(vaultPath) + + expect(paths.vaultPath).toBe(codeRoot) + expect(fs.pathExistsSync(path.join(codeRoot, 'root.md'))).toBe(true) + expect(fs.pathExistsSync(path.join(codeRoot, 'legacy.md'))).toBe(true) + expect(fs.pathExistsSync(path.join(notesRoot, 'existing.md'))).toBe(true) + expect(fs.pathExistsSync(path.join(notesRoot, 'migrated.md'))).toBe(true) + expect(fs.pathExistsSync(legacyCodeRoot)).toBe(false) + expect(fs.pathExistsSync(legacyNotesRoot)).toBe(false) + expect(fs.pathExistsSync(path.join(vaultPath, '__spaces__'))).toBe(false) + }) }) diff --git a/src/main/storage/providers/markdown/runtime/constants.ts b/src/main/storage/providers/markdown/runtime/constants.ts index 0d05d925..e44344b0 100644 --- a/src/main/storage/providers/markdown/runtime/constants.ts +++ b/src/main/storage/providers/markdown/runtime/constants.ts @@ -2,11 +2,18 @@ export const META_DIR_NAME = '.masscode' export const STATE_FILE_NAME = 'state.json' export const INBOX_DIR_NAME = 'inbox' export const TRASH_DIR_NAME = 'trash' -export const SPACES_DIR_NAME = '__spaces__' export const CODE_SPACE_ID = 'code' +export const MATH_SPACE_ID = 'math' +export const NOTES_SPACE_ID = 'notes' export const META_FILE_NAME = '.meta.yaml' export const SPACE_STATE_FILE_NAME = '.state.yaml' export const LEGACY_FOLDER_META_FILE_NAME = '.masscode-folder.yml' +export const PERSISTED_SPACE_IDS = [ + CODE_SPACE_ID, + MATH_SPACE_ID, + NOTES_SPACE_ID, +] as const +export const SPACE_IDS = new Set(PERSISTED_SPACE_IDS) export const INBOX_RELATIVE_PATH = `${META_DIR_NAME}/${INBOX_DIR_NAME}` export const TRASH_RELATIVE_PATH = `${META_DIR_NAME}/${TRASH_DIR_NAME}` @@ -16,7 +23,9 @@ export const LEGACY_TRASH_RELATIVE_PATH = TRASH_DIR_NAME export const RESERVED_ROOT_NAMES = new Set([ INBOX_DIR_NAME, TRASH_DIR_NAME, - SPACES_DIR_NAME, + CODE_SPACE_ID, + MATH_SPACE_ID, + NOTES_SPACE_ID, ]) export const NEW_LINE_SPLIT_RE = /\r?\n/ export const SEARCH_DIACRITICS_RE = /[\u0300-\u036F]/g diff --git a/src/main/storage/providers/markdown/runtime/index.ts b/src/main/storage/providers/markdown/runtime/index.ts index 2b02167b..7ad5b226 100644 --- a/src/main/storage/providers/markdown/runtime/index.ts +++ b/src/main/storage/providers/markdown/runtime/index.ts @@ -6,10 +6,13 @@ export { INBOX_DIR_NAME, INVALID_NAME_CHARS_RE, LEGACY_FOLDER_META_FILE_NAME, + MATH_SPACE_ID, META_DIR_NAME, META_FILE_NAME, + NOTES_SPACE_ID, + PERSISTED_SPACE_IDS, + SPACE_IDS, SPACE_STATE_FILE_NAME, - SPACES_DIR_NAME, TRASH_DIR_NAME, WINDOWS_RESERVED_NAME_RE, } from './constants' @@ -61,6 +64,7 @@ export { // Spaces export { + ensureFlatSpacesLayout, ensureSpaceDirectory, getSpaceDirPath, getSpaceStatePath, diff --git a/src/main/storage/providers/markdown/runtime/paths.ts b/src/main/storage/providers/markdown/runtime/paths.ts index 7906a102..03fcba72 100644 --- a/src/main/storage/providers/markdown/runtime/paths.ts +++ b/src/main/storage/providers/markdown/runtime/paths.ts @@ -8,7 +8,7 @@ import { CODE_SPACE_ID, INBOX_DIR_NAME, META_DIR_NAME, - SPACES_DIR_NAME, + SPACE_IDS, STATE_FILE_NAME, TRASH_DIR_NAME, } from './constants' @@ -18,6 +18,7 @@ import { getFolderSiblings as getFolderSiblingsShared, getNextFolderOrder as getNextFolderOrderShared, } from './shared/folderIndex' +import { getSpaceDirPath } from './spaces' export function getVaultPath(): string { const configuredVaultPath = store.preferences.get('storage.vaultPath') as @@ -147,7 +148,7 @@ function collectLegacyCodeEntries(vaultPath: string): Set { && fs.pathExistsSync(path.join(vaultPath, META_DIR_NAME)) ) { fs.readdirSync(vaultPath).forEach((entry) => { - if (entry !== SPACES_DIR_NAME) { + if (entry !== '__spaces__' && !SPACE_IDS.has(entry)) { entries.add(entry) } }) @@ -177,7 +178,7 @@ function migrateLegacyCodeDataToCodeSpace( fs.ensureDirSync(codeRootPath) legacyEntries.forEach((entry) => { - if (entry === SPACES_DIR_NAME) { + if (entry === '__spaces__' || SPACE_IDS.has(entry)) { return } @@ -206,7 +207,7 @@ function migrateLegacyCodeDataToCodeSpace( } function resolveCodeVaultPath(vaultPath: string): string { - const codeRootPath = path.join(vaultPath, SPACES_DIR_NAME, CODE_SPACE_ID) + const codeRootPath = getSpaceDirPath(vaultPath, CODE_SPACE_ID) const shouldMigrateLegacyData = hasLegacyCodeData(vaultPath) && (!fs.pathExistsSync(codeRootPath) || !isCodeSpaceInitialized(codeRootPath)) diff --git a/src/main/storage/providers/markdown/runtime/shared/__tests__/path.test.ts b/src/main/storage/providers/markdown/runtime/shared/__tests__/path.test.ts index 6da9845f..089d8558 100644 --- a/src/main/storage/providers/markdown/runtime/shared/__tests__/path.test.ts +++ b/src/main/storage/providers/markdown/runtime/shared/__tests__/path.test.ts @@ -138,9 +138,9 @@ describe('listMarkdownFiles', () => { it('respects skipDirNames at root level', () => { const root = createTmpDir() - const spacesDir = path.join(root, '__spaces__') - fs.ensureDirSync(spacesDir) - fs.writeFileSync(path.join(spacesDir, 'file.md'), 'content') + const codeDir = path.join(root, 'code') + fs.ensureDirSync(codeDir) + fs.writeFileSync(path.join(codeDir, 'file.md'), 'content') fs.writeFileSync(path.join(root, 'root.md'), 'content') const result = listMarkdownFiles( @@ -148,14 +148,14 @@ describe('listMarkdownFiles', () => { META, INBOX, TRASH, - new Set(['__spaces__']), + new Set(['code']), ) expect(result).toEqual(['root.md']) }) it('does not skip skipDirNames in subdirectories', () => { const root = createTmpDir() - const nestedDir = path.join(root, 'folder', '__spaces__') + const nestedDir = path.join(root, 'folder', 'code') fs.ensureDirSync(nestedDir) fs.writeFileSync(path.join(nestedDir, 'file.md'), 'content') @@ -164,9 +164,9 @@ describe('listMarkdownFiles', () => { META, INBOX, TRASH, - new Set(['__spaces__']), + new Set(['code']), ) - expect(result).toEqual(['folder/__spaces__/file.md']) + expect(result).toEqual(['folder/code/file.md']) }) it('does not enter other subdirectories of meta dir', () => { diff --git a/src/main/storage/providers/markdown/runtime/snippets.ts b/src/main/storage/providers/markdown/runtime/snippets.ts index ce17d5b8..84a6b744 100644 --- a/src/main/storage/providers/markdown/runtime/snippets.ts +++ b/src/main/storage/providers/markdown/runtime/snippets.ts @@ -16,7 +16,6 @@ import { LEGACY_INBOX_RELATIVE_PATH, LEGACY_TRASH_RELATIVE_PATH, META_DIR_NAME, - SPACES_DIR_NAME, TRASH_DIR_NAME, TRASH_RELATIVE_PATH, } from './constants' @@ -55,12 +54,13 @@ export function isTrashSnippetDirectory(directoryPath: string): boolean { } export function listMarkdownFiles(rootPath: string): string[] { + // This helper always runs inside the resolved code space root, not vault root. return listMarkdownFilesShared( rootPath, META_DIR_NAME, INBOX_DIR_NAME, TRASH_DIR_NAME, - new Set([SPACES_DIR_NAME]), + new Set(), ) } diff --git a/src/main/storage/providers/markdown/runtime/spaces.ts b/src/main/storage/providers/markdown/runtime/spaces.ts index 2940242d..2e8cd23c 100644 --- a/src/main/storage/providers/markdown/runtime/spaces.ts +++ b/src/main/storage/providers/markdown/runtime/spaces.ts @@ -1,9 +1,61 @@ import path from 'node:path' import fs from 'fs-extra' -import { SPACE_STATE_FILE_NAME, SPACES_DIR_NAME } from './constants' +import { PERSISTED_SPACE_IDS, SPACE_STATE_FILE_NAME } from './constants' + +const LEGACY_SPACES_DIR_NAME = '__spaces__' +const migratedVaultPaths = new Set() + +export function ensureFlatSpacesLayout(vaultPath: string): void { + const normalizedVaultPath = path.resolve(vaultPath) + + if (migratedVaultPaths.has(normalizedVaultPath)) { + return + } + + const spacesDir = path.join(normalizedVaultPath, LEGACY_SPACES_DIR_NAME) + if (fs.pathExistsSync(spacesDir)) { + PERSISTED_SPACE_IDS.forEach((spaceId) => { + const legacyPath = path.join(spacesDir, spaceId) + if (!fs.pathExistsSync(legacyPath)) { + return + } + + const targetPath = path.join(normalizedVaultPath, spaceId) + if (!fs.pathExistsSync(targetPath)) { + fs.moveSync(legacyPath, targetPath, { overwrite: false }) + return + } + + const legacyStat = fs.statSync(legacyPath) + const targetStat = fs.statSync(targetPath) + + if (!legacyStat.isDirectory() || !targetStat.isDirectory()) { + throw new Error( + `SPACE_LAYOUT_CONFLICT: cannot migrate ${spaceId} into existing non-directory target`, + ) + } + + fs.copySync(legacyPath, targetPath, { + errorOnExist: false, + overwrite: false, + }) + fs.removeSync(legacyPath) + }) + + if ( + fs.pathExistsSync(spacesDir) + && fs.readdirSync(spacesDir).length === 0 + ) { + fs.removeSync(spacesDir) + } + } + + migratedVaultPaths.add(normalizedVaultPath) +} export function getSpaceDirPath(vaultPath: string, spaceId: string): string { - return path.join(vaultPath, SPACES_DIR_NAME, spaceId) + ensureFlatSpacesLayout(vaultPath) + return path.join(vaultPath, spaceId) } export function ensureSpaceDirectory( diff --git a/src/main/storage/providers/markdown/runtime/sync.ts b/src/main/storage/providers/markdown/runtime/sync.ts index 87744a38..5c6dd48f 100644 --- a/src/main/storage/providers/markdown/runtime/sync.ts +++ b/src/main/storage/providers/markdown/runtime/sync.ts @@ -9,12 +9,7 @@ import type { import path from 'node:path' import fs from 'fs-extra' import { runtimeRef } from './cache' -import { - INBOX_DIR_NAME, - META_DIR_NAME, - SPACES_DIR_NAME, - TRASH_DIR_NAME, -} from './constants' +import { INBOX_DIR_NAME, META_DIR_NAME, TRASH_DIR_NAME } from './constants' import { readFolderMetadata, writeFolderMetadataFile } from './parser' import { buildFolderPathMap, @@ -47,10 +42,7 @@ import { function isTechnicalRootFolder(name: string): boolean { return ( - name === META_DIR_NAME - || name === INBOX_DIR_NAME - || name === TRASH_DIR_NAME - || name === SPACES_DIR_NAME + name === META_DIR_NAME || name === INBOX_DIR_NAME || name === TRASH_DIR_NAME ) } diff --git a/src/main/storage/providers/markdown/runtime/validation.ts b/src/main/storage/providers/markdown/runtime/validation.ts index 1100ff98..3aa72e47 100644 --- a/src/main/storage/providers/markdown/runtime/validation.ts +++ b/src/main/storage/providers/markdown/runtime/validation.ts @@ -5,7 +5,6 @@ import { INVALID_NAME_CHARS, META_DIR_NAME, RESERVED_ROOT_NAMES, - SPACES_DIR_NAME, WINDOWS_RESERVED_NAME_RE, } from './constants' import { normalizeErrorMessage } from './normalizers' @@ -87,7 +86,7 @@ export function assertNotReservedRootFolderName( ): void { const normalizedName = name.toLowerCase() - if (normalizedName === META_DIR_NAME || normalizedName === SPACES_DIR_NAME) { + if (normalizedName === META_DIR_NAME) { throwStorageError('RESERVED_NAME', 'This folder name is reserved') } diff --git a/src/main/storage/providers/markdown/storages/__tests__/folders.test.ts b/src/main/storage/providers/markdown/storages/__tests__/folders.test.ts index 4f4e6d2f..5fc84476 100644 --- a/src/main/storage/providers/markdown/storages/__tests__/folders.test.ts +++ b/src/main/storage/providers/markdown/storages/__tests__/folders.test.ts @@ -128,4 +128,12 @@ describe('code folders storage validations', () => { 'NAME_CONFLICT', ) }) + + it('rejects reserved flat space names at root level', () => { + const storage = createFoldersStorage() + + expect(() => storage.createFolder({ name: 'math' })).toThrow( + 'RESERVED_NAME', + ) + }) }) From ed68030805c17b57c0496c1f3fb1a5f80f8adeb5 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Fri, 27 Mar 2026 09:45:09 +0300 Subject: [PATCH 2/3] refactor(markdown): tighten watcher space routing --- .../markdown/__tests__/watcher.test.ts | 18 +++ .../storage/providers/markdown/watcher.ts | 148 ++++-------------- .../providers/markdown/watcherPaths.ts | 115 ++++++++++++++ 3 files changed, 161 insertions(+), 120 deletions(-) create mode 100644 src/main/storage/providers/markdown/__tests__/watcher.test.ts create mode 100644 src/main/storage/providers/markdown/watcherPaths.ts diff --git a/src/main/storage/providers/markdown/__tests__/watcher.test.ts b/src/main/storage/providers/markdown/__tests__/watcher.test.ts new file mode 100644 index 00000000..f5be42ad --- /dev/null +++ b/src/main/storage/providers/markdown/__tests__/watcher.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { getWatchPathSpaceId, shouldIgnoreWatchPath } from '../watcherPaths' + +describe('watcher routing', () => { + const vaultRoot = '/vault' + + it('keeps math state file observable after flat layout migration', () => { + expect(shouldIgnoreWatchPath(vaultRoot, '/vault/math/.state.yaml')).toBe( + false, + ) + expect(getWatchPathSpaceId('math/.state.yaml')).toBe('math') + }) + + it('drops unknown vault-root entries from space routing', () => { + expect(getWatchPathSpaceId('README.md')).toBe(null) + expect(getWatchPathSpaceId('random-dir/file.md')).toBe(null) + }) +}) diff --git a/src/main/storage/providers/markdown/watcher.ts b/src/main/storage/providers/markdown/watcher.ts index 1ef894db..96f6dd15 100644 --- a/src/main/storage/providers/markdown/watcher.ts +++ b/src/main/storage/providers/markdown/watcher.ts @@ -1,5 +1,4 @@ import type { ChokidarOptions, FSWatcher } from 'chokidar' -import path from 'node:path' import { BrowserWindow } from 'electron' import { importEsm, log } from '../../../utils' import { @@ -9,27 +8,31 @@ import { syncNotesRuntimeWithDisk, } from './notes/runtime' import { - CODE_SPACE_ID, ensureStateFile, getPaths, getVaultPath, - INBOX_DIR_NAME, - META_DIR_NAME, type Paths, peekRuntimeCache, resetRuntimeCache, - SPACES_DIR_NAME, syncRuntimeWithDisk, syncSnippetFileWithDisk, - TRASH_DIR_NAME, } from './runtime' -import { toPosixPath } from './runtime/shared/path' +import { + getWatchPathSpaceId, + isCodeWatchPath, + isMathWatchPath, + isNotesWatchPath, + normalizeRelativeWatchPath, + shouldIgnoreWatchPath, + toCodeRelativePath, +} from './watcherPaths' let markdownWatcher: FSWatcher | null = null let markdownWatchTimer: NodeJS.Timeout | null = null let watchedVaultPath: string | null = null let pendingFilePath: string | null = null let hasPendingFullSync = false +let hasPendingMathSync = false let hasPendingNotesSync = false let watcherStartToken = 0 let chokidarWatchLoader: Promise | null = null @@ -39,9 +42,6 @@ type ChokidarWatch = ( options?: ChokidarOptions, ) => FSWatcher -const NOTES_SPACE_WATCH_PREFIX = `${SPACES_DIR_NAME.toLowerCase()}/notes` -const CODE_SPACE_WATCH_PREFIX = `${SPACES_DIR_NAME.toLowerCase()}/${CODE_SPACE_ID.toLowerCase()}` - async function getChokidarWatch(): Promise { if (chokidarWatchLoader) { return chokidarWatchLoader @@ -71,123 +71,20 @@ async function getChokidarWatch(): Promise { return chokidarWatchLoader } -function normalizeRelativeWatchPath( - watchRootPath: string, - changedPath: string, -): string | null { - const normalizedChangedPath = changedPath.trim() - if (!normalizedChangedPath) { - return null - } - - const absolutePath = path.isAbsolute(normalizedChangedPath) - ? normalizedChangedPath - : path.join(watchRootPath, normalizedChangedPath) - const relativePath = toPosixPath(path.relative(watchRootPath, absolutePath)) - - if (!relativePath || relativePath === '.' || relativePath.startsWith('../')) { - return null - } - - return relativePath -} - -function shouldIgnoreWatchPath( - watchRootPath: string, - watchPath: string, -): boolean { - const relativePath = normalizeRelativeWatchPath(watchRootPath, watchPath) - if (!relativePath) { - return false - } - - const basename = path.posix.basename(relativePath) - - // Never ignore meta files (both legacy and new) - if (basename === '.meta.yaml' || basename === '.masscode-folder.yml') { - return false - } - - const normalizedRelativePath = relativePath.toLowerCase() - - // Never ignore __spaces__/ directory and its contents - const spacesPrefix = SPACES_DIR_NAME.toLowerCase() - if ( - normalizedRelativePath === spacesPrefix - || normalizedRelativePath.startsWith(`${spacesPrefix}/`) - ) { - return false - } - - const metaPrefix = META_DIR_NAME.toLowerCase() - if (normalizedRelativePath === metaPrefix) { - return false - } - - const inboxPrefix = `${META_DIR_NAME}/${INBOX_DIR_NAME}`.toLowerCase() - const trashPrefix = `${META_DIR_NAME}/${TRASH_DIR_NAME}`.toLowerCase() - const canContainSnippets - = normalizedRelativePath === inboxPrefix - || normalizedRelativePath.startsWith(`${inboxPrefix}/`) - || normalizedRelativePath === trashPrefix - || normalizedRelativePath.startsWith(`${trashPrefix}/`) - - if (canContainSnippets) { - return false - } - - return normalizedRelativePath - .split('/') - .some(segment => segment.startsWith('.')) -} - -function isNotesWatchPath(relativePath: string | null): boolean { - if (!relativePath) { - return false - } - - const normalizedRelativePath = relativePath.toLowerCase() - return ( - normalizedRelativePath === NOTES_SPACE_WATCH_PREFIX - || normalizedRelativePath.startsWith(`${NOTES_SPACE_WATCH_PREFIX}/`) - ) -} - -function isCodeWatchPath(relativePath: string | null): boolean { - if (!relativePath) { - return false - } - - const normalizedRelativePath = relativePath.toLowerCase() - return ( - normalizedRelativePath === CODE_SPACE_WATCH_PREFIX - || normalizedRelativePath.startsWith(`${CODE_SPACE_WATCH_PREFIX}/`) - ) -} - -function toCodeRelativePath(relativePath: string): string | null { - const normalizedRelativePath = relativePath.toLowerCase() - - if (normalizedRelativePath === CODE_SPACE_WATCH_PREFIX) { - return null - } - - const codePrefix = `${CODE_SPACE_WATCH_PREFIX}/` - if (!normalizedRelativePath.startsWith(codePrefix)) { - return null - } - - return relativePath.slice(codePrefix.length) -} - function scheduleStateSync( vaultRootPath: string, paths: Paths, changedPath: string | null, forceFullSync = false, ): void { + const changedSpaceId = getWatchPathSpaceId(changedPath) + if (changedPath && !changedSpaceId) { + return + } + const changedNotesPath = isNotesWatchPath(changedPath) const changedCodePath = isCodeWatchPath(changedPath) + const changedMathPath = isMathWatchPath(changedPath) const changedCodeRelativePath = changedPath && changedCodePath ? toCodeRelativePath(changedPath) : null @@ -195,9 +92,16 @@ function scheduleStateSync( hasPendingNotesSync = true } + if (changedMathPath) { + hasPendingMathSync = true + } + if (changedNotesPath) { // Notes space has separate runtime cache sync path. } + else if (changedMathPath) { + // Math space has no main-process cache to sync; broadcast only. + } else if (forceFullSync || !changedPath) { hasPendingFullSync = true @@ -228,10 +132,12 @@ function scheduleStateSync( const previousCache = peekRuntimeCache() const previousNotesCache = peekNotesRuntimeCache() const changedFilePath = hasPendingFullSync ? null : pendingFilePath + const shouldNotifyMath = hasPendingMathSync const shouldSyncCode = hasPendingFullSync || changedFilePath !== null const shouldSyncNotes = hasPendingNotesSync hasPendingFullSync = false + hasPendingMathSync = false hasPendingNotesSync = false pendingFilePath = null @@ -257,8 +163,9 @@ function scheduleStateSync( const hasNotesChanges = shouldSyncNotes && (!previousNotesCache || nextNotesCache !== previousNotesCache) + const hasMathChanges = shouldNotifyMath - if (hasCodeChanges || hasNotesChanges) { + if (hasCodeChanges || hasNotesChanges || hasMathChanges) { BrowserWindow.getAllWindows().forEach((window) => { window.webContents.send('system:storage-synced') }) @@ -286,6 +193,7 @@ export function stopMarkdownWatcher(): void { watchedVaultPath = null pendingFilePath = null hasPendingFullSync = false + hasPendingMathSync = false hasPendingNotesSync = false resetRuntimeCache() resetNotesRuntimeCache() diff --git a/src/main/storage/providers/markdown/watcherPaths.ts b/src/main/storage/providers/markdown/watcherPaths.ts new file mode 100644 index 00000000..2eb04a86 --- /dev/null +++ b/src/main/storage/providers/markdown/watcherPaths.ts @@ -0,0 +1,115 @@ +import path from 'node:path' +import { + CODE_SPACE_ID, + INBOX_DIR_NAME, + MATH_SPACE_ID, + META_DIR_NAME, + NOTES_SPACE_ID, + SPACE_IDS, + TRASH_DIR_NAME, +} from './runtime/constants' +import { toPosixPath } from './runtime/shared/path' + +export const NOTES_SPACE_WATCH_PREFIX = NOTES_SPACE_ID.toLowerCase() +export const CODE_SPACE_WATCH_PREFIX = CODE_SPACE_ID.toLowerCase() + +export function normalizeRelativeWatchPath( + watchRootPath: string, + changedPath: string, +): string | null { + const normalizedChangedPath = changedPath.trim() + if (!normalizedChangedPath) { + return null + } + + const absolutePath = path.isAbsolute(normalizedChangedPath) + ? normalizedChangedPath + : path.join(watchRootPath, normalizedChangedPath) + const relativePath = toPosixPath(path.relative(watchRootPath, absolutePath)) + + if (!relativePath || relativePath === '.' || relativePath.startsWith('../')) { + return null + } + + return relativePath +} + +export function getWatchPathSpaceId( + relativePath: string | null, +): string | null { + if (!relativePath) { + return null + } + + const [firstSegment] = relativePath.toLowerCase().split('/') + return firstSegment && SPACE_IDS.has(firstSegment) ? firstSegment : null +} + +export function shouldIgnoreWatchPath( + watchRootPath: string, + watchPath: string, +): boolean { + const relativePath = normalizeRelativeWatchPath(watchRootPath, watchPath) + if (!relativePath) { + return false + } + + const basename = path.posix.basename(relativePath) + + if (basename === '.meta.yaml' || basename === '.masscode-folder.yml') { + return false + } + + if (getWatchPathSpaceId(relativePath)) { + return false + } + + const normalizedRelativePath = relativePath.toLowerCase() + const metaPrefix = META_DIR_NAME.toLowerCase() + if (normalizedRelativePath === metaPrefix) { + return false + } + + const inboxPrefix = `${META_DIR_NAME}/${INBOX_DIR_NAME}`.toLowerCase() + const trashPrefix = `${META_DIR_NAME}/${TRASH_DIR_NAME}`.toLowerCase() + const canContainSnippets + = normalizedRelativePath === inboxPrefix + || normalizedRelativePath.startsWith(`${inboxPrefix}/`) + || normalizedRelativePath === trashPrefix + || normalizedRelativePath.startsWith(`${trashPrefix}/`) + + if (canContainSnippets) { + return false + } + + return normalizedRelativePath + .split('/') + .some(segment => segment.startsWith('.')) +} + +export function isNotesWatchPath(relativePath: string | null): boolean { + return getWatchPathSpaceId(relativePath) === NOTES_SPACE_ID +} + +export function isCodeWatchPath(relativePath: string | null): boolean { + return getWatchPathSpaceId(relativePath) === CODE_SPACE_ID +} + +export function isMathWatchPath(relativePath: string | null): boolean { + return getWatchPathSpaceId(relativePath) === MATH_SPACE_ID +} + +export function toCodeRelativePath(relativePath: string): string | null { + const normalizedRelativePath = relativePath.toLowerCase() + + if (normalizedRelativePath === CODE_SPACE_WATCH_PREFIX) { + return null + } + + const codePrefix = `${CODE_SPACE_WATCH_PREFIX}/` + if (!normalizedRelativePath.startsWith(codePrefix)) { + return null + } + + return relativePath.slice(codePrefix.length) +} From 2386c3ea1b92471135774f6d34f050eb52512a15 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Fri, 27 Mar 2026 10:01:57 +0300 Subject: [PATCH 3/3] test(markdown): harden migration and watcher helpers --- .../markdown/__tests__/watcher.test.ts | 51 +++++++++++++++++- .../markdown/runtime/__tests__/spaces.test.ts | 53 +++++++++++++++++++ .../providers/markdown/runtime/spaces.ts | 5 ++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/main/storage/providers/markdown/runtime/__tests__/spaces.test.ts diff --git a/src/main/storage/providers/markdown/__tests__/watcher.test.ts b/src/main/storage/providers/markdown/__tests__/watcher.test.ts index f5be42ad..95f90987 100644 --- a/src/main/storage/providers/markdown/__tests__/watcher.test.ts +++ b/src/main/storage/providers/markdown/__tests__/watcher.test.ts @@ -1,9 +1,29 @@ import { describe, expect, it } from 'vitest' -import { getWatchPathSpaceId, shouldIgnoreWatchPath } from '../watcherPaths' +import { + getWatchPathSpaceId, + isCodeWatchPath, + isMathWatchPath, + isNotesWatchPath, + normalizeRelativeWatchPath, + shouldIgnoreWatchPath, + toCodeRelativePath, +} from '../watcherPaths' describe('watcher routing', () => { const vaultRoot = '/vault' + it('normalizes relative watch paths under the vault root', () => { + expect(normalizeRelativeWatchPath(vaultRoot, '/vault/code/demo.md')).toBe( + 'code/demo.md', + ) + expect(normalizeRelativeWatchPath(vaultRoot, 'notes/note.md')).toBe( + 'notes/note.md', + ) + expect(normalizeRelativeWatchPath(vaultRoot, '/outside/file.md')).toBe( + null, + ) + }) + it('keeps math state file observable after flat layout migration', () => { expect(shouldIgnoreWatchPath(vaultRoot, '/vault/math/.state.yaml')).toBe( false, @@ -15,4 +35,33 @@ describe('watcher routing', () => { expect(getWatchPathSpaceId('README.md')).toBe(null) expect(getWatchPathSpaceId('random-dir/file.md')).toBe(null) }) + + it('keeps known space roots observable even for dotfiles', () => { + expect(shouldIgnoreWatchPath(vaultRoot, '/vault/code/.gitkeep')).toBe( + false, + ) + expect(shouldIgnoreWatchPath(vaultRoot, '/vault/notes/.obsidian')).toBe( + false, + ) + }) + + it('ignores hidden non-space paths', () => { + expect(shouldIgnoreWatchPath(vaultRoot, '/vault/.git/config')).toBe(true) + expect(shouldIgnoreWatchPath(vaultRoot, '/vault/random/.cache/file')).toBe( + true, + ) + }) + + it('recognizes code, notes and math space paths', () => { + expect(isCodeWatchPath('code/demo.md')).toBe(true) + expect(isCodeWatchPath('notes/demo.md')).toBe(false) + expect(isNotesWatchPath('notes/demo.md')).toBe(true) + expect(isMathWatchPath('math/.state.yaml')).toBe(true) + }) + + it('extracts code-relative paths only from code space entries', () => { + expect(toCodeRelativePath('code')).toBe(null) + expect(toCodeRelativePath('code/folder/demo.md')).toBe('folder/demo.md') + expect(toCodeRelativePath('notes/demo.md')).toBe(null) + }) }) diff --git a/src/main/storage/providers/markdown/runtime/__tests__/spaces.test.ts b/src/main/storage/providers/markdown/runtime/__tests__/spaces.test.ts new file mode 100644 index 00000000..b32352bf --- /dev/null +++ b/src/main/storage/providers/markdown/runtime/__tests__/spaces.test.ts @@ -0,0 +1,53 @@ +import os from 'node:os' +import path from 'node:path' +import fs from 'fs-extra' +import { afterEach, describe, expect, it } from 'vitest' +import { + ensureFlatSpacesLayout, + resetMigrationGuardForTesting, +} from '../spaces' + +const tempDirs: string[] = [] + +function createTempDir(): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'markdown-spaces-')) + tempDirs.push(tempDir) + return tempDir +} + +afterEach(() => { + resetMigrationGuardForTesting() + + while (tempDirs.length > 0) { + const tempDir = tempDirs.pop() + if (tempDir) { + fs.removeSync(tempDir) + } + } +}) + +describe('ensureFlatSpacesLayout', () => { + it('can be re-armed in tests after the migration guard was set', () => { + const vaultPath = createTempDir() + const firstLegacyCodeRoot = path.join(vaultPath, '__spaces__', 'code') + fs.ensureDirSync(firstLegacyCodeRoot) + fs.writeFileSync(path.join(firstLegacyCodeRoot, 'first.md'), '# First') + + ensureFlatSpacesLayout(vaultPath) + + const secondLegacyCodeRoot = path.join(vaultPath, '__spaces__', 'code') + fs.ensureDirSync(secondLegacyCodeRoot) + fs.writeFileSync(path.join(secondLegacyCodeRoot, 'second.md'), '# Second') + + resetMigrationGuardForTesting() + ensureFlatSpacesLayout(vaultPath) + + expect(fs.pathExistsSync(path.join(vaultPath, 'code', 'first.md'))).toBe( + true, + ) + expect(fs.pathExistsSync(path.join(vaultPath, 'code', 'second.md'))).toBe( + true, + ) + expect(fs.pathExistsSync(path.join(vaultPath, '__spaces__'))).toBe(false) + }) +}) diff --git a/src/main/storage/providers/markdown/runtime/spaces.ts b/src/main/storage/providers/markdown/runtime/spaces.ts index 2e8cd23c..5b5ccb36 100644 --- a/src/main/storage/providers/markdown/runtime/spaces.ts +++ b/src/main/storage/providers/markdown/runtime/spaces.ts @@ -2,6 +2,7 @@ import path from 'node:path' import fs from 'fs-extra' import { PERSISTED_SPACE_IDS, SPACE_STATE_FILE_NAME } from './constants' +// Legacy wrapper directory kept only to migrate early v5 vault layouts. const LEGACY_SPACES_DIR_NAME = '__spaces__' const migratedVaultPaths = new Set() @@ -70,3 +71,7 @@ export function ensureSpaceDirectory( export function getSpaceStatePath(vaultPath: string, spaceId: string): string { return path.join(getSpaceDirPath(vaultPath, spaceId), SPACE_STATE_FILE_NAME) } + +export function resetMigrationGuardForTesting(): void { + migratedVaultPaths.clear() +}