From 3ff99adb3dcaac4ae899f73b6783bb045b22b9a9 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 4 May 2026 10:24:05 +0200 Subject: [PATCH] feat: add mobile vault storage boundary --- apps/mobile/src/mobileVaultRepository.test.ts | 31 +++++++++++- apps/mobile/src/mobileVaultRepository.ts | 49 ++++++++++++++++++- apps/mobile/src/mobileVaultStorage.test.ts | 38 ++++++++++++++ apps/mobile/src/mobileVaultStorage.ts | 31 ++++++++++++ docs/MOBILE_PROGRESS.md | 9 +++- 5 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/mobileVaultStorage.test.ts create mode 100644 apps/mobile/src/mobileVaultStorage.ts diff --git a/apps/mobile/src/mobileVaultRepository.test.ts b/apps/mobile/src/mobileVaultRepository.test.ts index ce67dc90..8a98a78e 100644 --- a/apps/mobile/src/mobileVaultRepository.test.ts +++ b/apps/mobile/src/mobileVaultRepository.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' -import { createFixtureMobileVaultRepository } from './mobileVaultRepository' +import { createMobileVaultConfig } from './mobileVaultConfig' +import { createFixtureMobileVaultRepository, createStoredMobileVaultRepository } from './mobileVaultRepository' +import { createMemoryMobileVaultStorage } from './mobileVaultStorage' import type { MobileNoteSource } from './mobileNoteProjection' const sources: MobileNoteSource[] = [ @@ -30,6 +32,24 @@ describe('mobile vault repository', () => { await expect(repository.readNote('missing')).resolves.toBeNull() }) + + it('projects notes from app-local markdown storage', async () => { + const repository = createStoredMobileVaultRepository({ + storage: createMemoryMobileVaultStorage([ + { path: 'inbox/workflow.md', content: '# Workflow\n\nStored markdown body.' }, + { path: 'release.md', content: '# Release\n\nRelease body.' }, + ]), + vault: createVault(), + }) + + const notes = await repository.listNotes() + + expect(notes.map((note) => note.id)).toEqual(['inbox/workflow', 'release']) + await expect(repository.readNote('release')).resolves.toMatchObject({ + id: 'release', + title: 'Release', + }) + }) }) function createSource(id: string, title: string): MobileNoteSource { @@ -44,3 +64,12 @@ function createSource(id: string, title: string): MobileNoteSource { content: `---\ntitle: ${title}\n---\n\n# ${title}\n\nBody text for ${title}.`, } } + +function createVault() { + const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' }) + if (!result.ok) { + throw new Error(result.error) + } + + return result.config +} diff --git a/apps/mobile/src/mobileVaultRepository.ts b/apps/mobile/src/mobileVaultRepository.ts index 18fb394c..521c5022 100644 --- a/apps/mobile/src/mobileVaultRepository.ts +++ b/apps/mobile/src/mobileVaultRepository.ts @@ -1,4 +1,6 @@ -import { projectMobileNotes, type MobileNote, type MobileNoteSource } from './mobileNoteProjection' +import { projectMobileNote, projectMobileNotes, type MobileNote, type MobileNoteSource } from './mobileNoteProjection' +import type { MobileVaultConfig } from './mobileVaultConfig' +import type { MobileVaultFile, MobileVaultStorageDriver } from './mobileVaultStorage' export type MobileVaultRepository = { listNotes: () => Promise @@ -13,3 +15,48 @@ export function createFixtureMobileVaultRepository(sources: MobileNoteSource[]): readNote: (id) => Promise.resolve(notes.find((note) => note.id === id) ?? null), } } + +export function createStoredMobileVaultRepository({ + storage, + vault, +}: { + storage: MobileVaultStorageDriver + vault: MobileVaultConfig +}): MobileVaultRepository { + return { + listNotes: async () => projectMobileNotes((await storage.listMarkdownFiles(vault)).map(fileToSource)), + readNote: async (id) => { + const file = await findFileById({ id, storage, vault }) + return file ? projectMobileNote(fileToSource(file)) : null + }, + } +} + +async function findFileById({ + id, + storage, + vault, +}: { + id: string + storage: MobileVaultStorageDriver + vault: MobileVaultConfig +}) { + return (await storage.listMarkdownFiles(vault)).find((file) => fileId(file.path) === id) ?? null +} + +function fileToSource(file: MobileVaultFile): MobileNoteSource { + return { + id: fileId(file.path), + type: 'Note', + icon: 'file-text', + date: '', + modified: '', + filename: file.path, + content: file.content, + tags: [], + } +} + +function fileId(path: string) { + return path.replace(/\.md$/, '') +} diff --git a/apps/mobile/src/mobileVaultStorage.test.ts b/apps/mobile/src/mobileVaultStorage.test.ts new file mode 100644 index 00000000..7e01078a --- /dev/null +++ b/apps/mobile/src/mobileVaultStorage.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { createMobileVaultConfig } from './mobileVaultConfig' +import { createMemoryMobileVaultStorage } from './mobileVaultStorage' + +const vault = createVault() + +describe('mobile vault storage', () => { + it('lists only markdown files in stable path order', async () => { + const storage = createMemoryMobileVaultStorage([ + { path: 'zeta.md', content: '# Zeta' }, + { path: 'asset.png', content: 'binary' }, + { path: 'alpha.md', content: '# Alpha' }, + ]) + + await expect(storage.listMarkdownFiles(vault)).resolves.toEqual([ + { path: 'alpha.md', content: '# Alpha' }, + { path: 'zeta.md', content: '# Zeta' }, + ]) + }) + + it('reads and writes markdown file content by vault path', async () => { + const storage = createMemoryMobileVaultStorage([{ path: 'inbox.md', content: '# Inbox' }]) + + await storage.writeMarkdownFile(vault, 'inbox.md', '# Updated Inbox') + + await expect(storage.readMarkdownFile(vault, 'inbox.md')).resolves.toBe('# Updated Inbox') + await expect(storage.readMarkdownFile(vault, 'missing.md')).resolves.toBeNull() + }) +}) + +function createVault() { + const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' }) + if (!result.ok) { + throw new Error(result.error) + } + + return result.config +} diff --git a/apps/mobile/src/mobileVaultStorage.ts b/apps/mobile/src/mobileVaultStorage.ts new file mode 100644 index 00000000..8d068ec1 --- /dev/null +++ b/apps/mobile/src/mobileVaultStorage.ts @@ -0,0 +1,31 @@ +import type { MobileVaultConfig } from './mobileVaultConfig' + +export type MobileVaultFile = { + path: string + content: string +} + +export type MobileVaultStorageDriver = { + listMarkdownFiles: (vault: MobileVaultConfig) => Promise + readMarkdownFile: (vault: MobileVaultConfig, path: string) => Promise + writeMarkdownFile: (vault: MobileVaultConfig, path: string, content: string) => Promise +} + +export function createMemoryMobileVaultStorage(files: MobileVaultFile[]): MobileVaultStorageDriver { + const fileByPath = new Map(files.map((file) => [file.path, file.content])) + + return { + listMarkdownFiles: () => Promise.resolve(markdownFiles(fileByPath)), + readMarkdownFile: (_vault, path) => Promise.resolve(fileByPath.get(path) ?? null), + writeMarkdownFile: async (_vault, path, content) => { + fileByPath.set(path, content) + }, + } +} + +function markdownFiles(fileByPath: Map) { + return [...fileByPath.entries()] + .filter(([path]) => path.endsWith('.md')) + .map(([path, content]) => ({ path, content })) + .sort((left, right) => left.path.localeCompare(right.path)) +} diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index 403e86d6..fd634e6e 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -8,7 +8,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Branch: `codex/mobile` - Active phase: Phase 2 - Mobile Shell -- Active slice: Add supported TenTap Markdown serializer +- Active slice: Add mobile vault storage driver boundary - Push policy: commit locally; do not push unless explicitly requested - Validation target: iPad/iOS simulator first @@ -50,6 +50,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Created [ADR-0110](./adr/0110-tentap-mobile-editor-spike.md) for the TenTap mobile editor spike and its acceptance gates. - Added a TenTap draft callback boundary that captures editor HTML but explicitly marks it non-persistable until Markdown serialization exists, preventing HTML from becoming canonical vault content by accident. - Added the first supported TenTap HTML-to-Markdown serializer for H1, paragraph, and unordered-list output; unsupported HTML remains blocked from persistence. +- Added a mobile vault storage driver contract plus memory implementation, and connected the mobile vault repository to app-local markdown files behind that storage interface. ## Next Action @@ -57,7 +58,7 @@ Continue Phase 2 with the next mobile shell slice: 1. Dismiss or suppress Expo Go's first-run tools modal during simulator QA so screenshots capture the app without the overlay. 2. Expand the serializer for additional TenTap output needed by real notes: links, emphasis, code, headings, ordered lists, and task lists. -3. Add the first native storage adapter around the vault config/repository contracts after the editor spike confirms the app shape. +3. Implement the Expo FileSystem-backed storage driver behind the new mobile vault storage contract. ## Verification Log @@ -148,6 +149,10 @@ Continue Phase 2 with the next mobile shell slice: - `pnpm --filter @tolaria/mobile typecheck` passed after supported Markdown serialization. - CodeScene after supported Markdown serialization: `apps/mobile/src/mobileEditorDraft.ts`, `apps/mobile/src/mobileEditorDraft.test.ts`, `apps/mobile/src/mobileEditorDocument.ts`, and `apps/mobile/src/MobileEditorAdapter.tsx` scored `10`. - `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after supported Markdown serialization. +- `pnpm --filter @tolaria/mobile test -- src/mobileVaultStorage.test.ts src/mobileVaultRepository.test.ts` passed after storage boundary extraction: 10 files / 35 tests. +- `pnpm --filter @tolaria/mobile typecheck` passed after storage boundary extraction. +- CodeScene after storage boundary extraction: `apps/mobile/src/mobileVaultStorage.ts`, `apps/mobile/src/mobileVaultStorage.test.ts`, `apps/mobile/src/mobileVaultRepository.ts`, and `apps/mobile/src/mobileVaultRepository.test.ts` scored `10`. +- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after storage boundary extraction. ## Risks / Watch Items