From 538aeba402f76c398acfb22f4484f2fd97e5926d Mon Sep 17 00:00:00 2001 From: markm39 Date: Fri, 28 Aug 2026 23:32:21 -0500 Subject: [PATCH 1/3] fix(storage): durable note catalog with crash recovery Customer report: a tablet freeze wiped their entire library. Root cause: the note index and all metadata lived solely in AsyncStorage, whose manifest can be lost wholesale when the app dies mid-write. A corrupt index silently became an empty library, and the next note creation overwrote it permanently, even though every note body file survived on disk. - Add a durable catalog (Documents/notes-catalog.json) as the source of truth for notes and folders, written with the same atomic tmp-verify-rename pattern bodies use, mirrored to AsyncStorage, and reconciled on load against body files on disk: any orphaned body is recovered into the library (mirror metadata preferred, stub title as fallback, preview/PDF background restored). Notes pointing at lost folders move to the root instead of vanishing. - Never open an unreadable body as an empty note (autosave would then overwrite the real file): show a recovery screen with retry, back, and an explicit start-blank choice. - Flush pending autosaves on back navigation (previously cancelled, silently dropping the last strokes) with a retry dialog on failure, and on app background (timers do not fire in background). - Skip the metadata update when a body write fails so updatedAt and thumbnail never advance past what actually persisted. - Serialize catalog mutations through a shared promise queue, fixing read-modify-write races between concurrent deletes/moves. Verified: 14 new node tests including reproductions of the corrupt-index and total-manifest-loss scenarios; E2E on iPad simulator recovering seeded notes after deleting the catalog and corrupting the AsyncStorage manifest. Claude-Session: https://claude.ai/code/session_01EEVkk7pkngePErcAAQveCx --- app/note/[id].tsx | 234 +++++++++++---- package.json | 2 + scripts/catalogStore.test.mjs | 348 ++++++++++++++++++++++ src/hooks/useAutosave.ts | 39 ++- src/services/atomicFile.ts | 27 ++ src/services/catalogEnv.ts | 61 ++++ src/services/catalogStore.ts | 490 +++++++++++++++++++++++++++++++ src/services/foldersRepo.ts | 129 ++------ src/services/lifecycleService.ts | 10 +- src/services/noteBodyStorage.ts | 103 +++++-- src/services/notesRepo.ts | 174 +++++------ src/utils/promiseQueue.ts | 22 ++ tsconfig.json | 2 + 13 files changed, 1337 insertions(+), 304 deletions(-) create mode 100644 scripts/catalogStore.test.mjs create mode 100644 src/services/atomicFile.ts create mode 100644 src/services/catalogEnv.ts create mode 100644 src/services/catalogStore.ts create mode 100644 src/utils/promiseQueue.ts diff --git a/app/note/[id].tsx b/app/note/[id].tsx index 440d66c..f92eb9c 100644 --- a/app/note/[id].tsx +++ b/app/note/[id].tsx @@ -12,7 +12,9 @@ import { Keyboard, KeyboardAvoidingView, Platform, + Pressable, StyleSheet, + Text, View, useWindowDimensions, } from 'react-native'; @@ -51,6 +53,7 @@ import { renameNote, saveNoteBody, } from '../../src/services/notesRepo'; +import { createEmptyNotebookData } from '../../src/services/noteBodyStorage'; import { pickFromCamera, pickFromLibrary, @@ -59,6 +62,8 @@ import { import { exportNotebookAsPdf } from '../../src/services/exportService'; import { recordSuccessfulNoteSave } from '../../src/services/lifecycleService'; import { textBoxId, insertedElementId } from '../../src/utils/id'; +import { spacing } from '../../src/theme/spacing'; +import { typography } from '../../src/theme/typography'; import type { NoteMetadata } from '../../src/types/note'; import type { ToolDescriptor } from '../../src/utils/toolPalette'; @@ -133,7 +138,9 @@ export default function NoteScreen() { const pendingBodyRef = useRef(null); const canvasReadyRef = useRef(false); const isMountedRef = useRef(true); - const navigatingRef = useRef(false); + // Exit flow state: 'editing' (normal), 'confirming' (final save running or + // its failure dialog showing), 'navigating' (leaving; UI updates frozen). + const exitRef = useRef<'editing' | 'confirming' | 'navigating'>('editing'); const fingerDrawingPrefLoadedRef = useRef(false); const storedPreviewByPageIdRef = useRef(new Map()); const lastPenToolRef = useRef<'pen' | 'highlighter' | 'crayon' | 'calligraphy'>('pen'); @@ -151,6 +158,11 @@ export default function NoteScreen() { const [currentPageIndex, setCurrentPageIndex] = useState(0); const [autosaveStatus, setAutosaveStatus] = useState('idle'); const [loading, setLoading] = useState(true); + const [bodyLoadFailed, setBodyLoadFailed] = useState(false); + const [loadAttempt, setLoadAttempt] = useState(0); + // Autosave stays disabled until the body has loaded (or the user explicitly + // chose to start blank), so nothing can overwrite an unread body on disk. + const [bodyReady, setBodyReady] = useState(false); const [selection, setSelection] = useState(null); const [action, setAction] = useState(null); const [isExporting, setIsExporting] = useState(false); @@ -189,7 +201,7 @@ export default function NoteScreen() { }, []); const persistMerged = useCallback(async () => { - if (!id || !canvasRef.current || navigatingRef.current) return; + if (!id || !canvasRef.current) return; const canvasData = await canvasRef.current.getNotebookData(); const overlay = overlayMapRef.current; const mergedPages = mergeStoredPreviews(canvasData.pages).map((page) => { @@ -218,11 +230,10 @@ export default function NoteScreen() { schedule: scheduleAutosave, flushNow, cancelPending: cancelPendingAutosave, - waitForIdle: waitForAutosaveIdle, } = useAutosave({ onSave: persistMerged, onStatusChange: safeStatusChange, - enabled: Boolean(id) && autosaveEnabled, + enabled: Boolean(id) && autosaveEnabled && bodyReady, }); useEffect(() => { @@ -264,44 +275,51 @@ export default function NoteScreen() { }); }, [fingerDrawingEnabled]); + const applyNotebookData = useCallback( + (notebookData: SerializedNotebookData) => { + rememberPagePreviews(notebookData.pages); + setEnginePages(mergeStoredPreviews(notebookData.pages)); + const initialMap = new Map(); + for (const page of notebookData.pages) { + initialMap.set(page.id, overlayFromPage(page)); + } + setOverlayMap(initialMap); + setBodyReady(true); + if (canvasReadyRef.current) { + void canvasRef.current?.loadNotebookData(notebookData); + } else { + pendingBodyRef.current = notebookData; + } + }, + [mergeStoredPreviews, rememberPagePreviews], + ); + // Initial load useEffect(() => { let cancelled = false; if (!id) return; storedPreviewByPageIdRef.current = new Map(); + setBodyReady(false); + setBodyLoadFailed(false); setEnginePages([]); setCurrentPageIndex(0); + setLoading(true); (async () => { try { - const meta = await getNote(id); - if (!cancelled && meta) setMetadata(meta); - const body = await readNoteBody(id); + const [meta, body] = await Promise.all([getNote(id), readNoteBody(id)]); if (cancelled) return; - const notebookData: SerializedNotebookData = body ?? { - version: '1.0', - pages: [ - { - id: 'page-1', - title: 'Page 1', - data: '{"pages":{}}', - rotation: 0, - }, - ], - }; - rememberPagePreviews(notebookData.pages); - setEnginePages(mergeStoredPreviews(notebookData.pages)); - const initialMap = new Map(); - for (const page of notebookData.pages) { - initialMap.set(page.id, overlayFromPage(page)); - } - setOverlayMap(initialMap); - if (canvasReadyRef.current) { - void canvasRef.current?.loadNotebookData(notebookData); - } else { - pendingBodyRef.current = notebookData; + if (meta) setMetadata(meta); + if (body.kind === 'unreadable') { + // A body file exists but cannot be read. Never fall back to an empty + // notebook here: autosave would overwrite the file and destroy + // whatever it still contains. + setBodyLoadFailed(true); + return; } + applyNotebookData(body.kind === 'ok' ? body.data : createEmptyNotebookData()); } catch (error) { if (__DEV__) console.warn('[NoteScreen] load failed', error); + if (!cancelled) setBodyLoadFailed(true); } finally { if (!cancelled) setLoading(false); } @@ -309,7 +327,29 @@ export default function NoteScreen() { return () => { cancelled = true; }; - }, [id, mergeStoredPreviews, rememberPagePreviews]); + }, [id, applyNotebookData, loadAttempt]); + + const retryLoad = useCallback(() => { + setLoadAttempt((value) => value + 1); + }, []); + + const startBlankAfterLoadFailure = useCallback(() => { + Alert.alert( + 'Start with a blank note?', + 'The existing contents of this note could not be read. Starting blank will replace them the next time the note saves.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Start blank', + style: 'destructive', + onPress: () => { + setBodyLoadFailed(false); + applyNotebookData(createEmptyNotebookData()); + }, + }, + ], + ); + }, [applyNotebookData]); const handleCanvasReady = useCallback(() => { canvasReadyRef.current = true; @@ -321,7 +361,7 @@ export default function NoteScreen() { }, []); const handlePagesChange = useCallback((next: NotebookPage[]) => { - if (!isMountedRef.current || navigatingRef.current) return; + if (!isMountedRef.current || exitRef.current === 'navigating') return; rememberPagePreviews(next); const pagesWithPreviews = mergeStoredPreviews(next); setEnginePages(pagesWithPreviews); @@ -347,20 +387,20 @@ export default function NoteScreen() { }, [mergeStoredPreviews, rememberPagePreviews]); const handleDrawingChange = useCallback(() => { - if (navigatingRef.current) return; + if (exitRef.current === 'navigating') return; scheduleAutosave(); }, [scheduleAutosave]); const handleTransform = useCallback( (t: Parameters[0]) => { - if (navigatingRef.current) return; + if (exitRef.current === 'navigating') return; store.onTransformChange(t); }, [store], ); const handleCurrentPageChange = useCallback((nextPageIndex: number) => { - if (!isMountedRef.current || navigatingRef.current) return; + if (!isMountedRef.current || exitRef.current === 'navigating') return; setCurrentPageIndex(nextPageIndex); }, []); @@ -695,30 +735,59 @@ export default function NoteScreen() { rememberPagePreviews, ]); - const handleBack = useCallback(async () => { - if (navigatingRef.current) return; - navigatingRef.current = true; - Keyboard.dismiss(); - setAction(null); - setSelection(null); - setToolPopover(null); - void Haptics.selectionAsync(); - if (!isMountedRef.current) return; + const navigateHome = useCallback(() => { + exitRef.current = 'navigating'; cancelPendingAutosave(); setAutosaveEnabled(false); - try { - await waitForAutosaveIdle(); - } catch (error) { - if (__DEV__) console.warn('[NoteScreen] pending save failed before navigation', error); - } - if (!isMountedRef.current) return; try { router.replace('/'); } catch (error) { - navigatingRef.current = false; + exitRef.current = 'editing'; if (__DEV__) console.warn('[NoteScreen] navigation failed', error); } - }, [cancelPendingAutosave, router, waitForAutosaveIdle]); + }, [cancelPendingAutosave, router]); + + // Save-then-leave. On failure the user chooses: retry, stay, or explicitly + // discard. The named function expression lets the retry button re-invoke it. + const attemptSaveAndLeave = useCallback(async function attempt(): Promise { + const saved = await flushNow(); + if (!isMountedRef.current) return; + if (saved) { + navigateHome(); + return; + } + Alert.alert( + "Couldn't save your note", + 'Your latest changes could not be written to storage. Leaving now will discard them.', + [ + { text: 'Try again', onPress: () => void attempt() }, + { + text: 'Leave anyway', + style: 'destructive', + onPress: () => navigateHome(), + }, + { + text: 'Stay', + style: 'cancel', + onPress: () => { + exitRef.current = 'editing'; + }, + }, + ], + ); + }, [flushNow, navigateHome]); + + const handleBack = useCallback(async () => { + if (exitRef.current !== 'editing') return; + exitRef.current = 'confirming'; + Keyboard.dismiss(); + setAction(null); + setSelection(null); + setToolPopover(null); + void Haptics.selectionAsync(); + if (!isMountedRef.current) return; + await attemptSaveAndLeave(); + }, [attemptSaveAndLeave]); useFocusEffect( useCallback(() => { @@ -750,6 +819,55 @@ export default function NoteScreen() { ); } + if (bodyLoadFailed) { + return ( + + + Couldn't open this note + + + The note's contents could not be read from storage. Nothing has been + changed; your data is still on this device. + + + Try again + + void handleBack()} + style={[styles.errorButton, { backgroundColor: theme.colors.surfaceMuted }]} + > + + Back to library + + + + + Start with a blank note + + + + ); + } + if (loading) { return ( [key, map.has(key) ? map.get(key) : null]); + }, + async multiSet(pairs) { + for (const [key, value] of pairs) map.set(key, value); + }, + async multiRemove(keys) { + for (const key of keys) map.delete(key); + }, + }; +} + +function makeEnv({ + kv = makeKv(), + catalogFile = null, + bodyFiles = [], + previews = {}, + pdfs = new Set(), + failCatalogWrites = false, +} = {}) { + const env = { + kv, + catalogFile, + preservedCorruptFile: null, + warnings: [], + async readCatalogFile() { + return env.catalogFile; + }, + async writeCatalogFile(json) { + if (failCatalogWrites) return false; + env.catalogFile = json; + return true; + }, + async preserveCorruptCatalogFile() { + env.preservedCorruptFile = env.catalogFile; + env.catalogFile = null; + }, + async listBodyIds() { + return bodyFiles.map((entry) => (typeof entry === 'string' ? entry : entry.id)); + }, + async bodyModifiedAt(id) { + const entry = bodyFiles.find((e) => typeof e !== 'string' && e.id === id); + return entry ? entry.modifiedAt : null; + }, + async readBodyPreview(id) { + return previews[id] ?? null; + }, + async pdfExistsForNote(id) { + return pdfs.has(id); + }, + pdfUriForNote(id) { + return `file:///documents/pdfs/${id}.pdf`; + }, + now() { + return '2026-08-28T00:00:00.000Z'; + }, + warn(message) { + env.warnings.push(message); + }, + }; + return env; +} + +function noteJson(id, extra = {}) { + return JSON.stringify({ + id, + title: `Title ${id}`, + folderId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + backgroundType: 'plain', + pdfUri: null, + thumbnailUri: null, + ...extra, + }); +} + +function folderJson(id, extra = {}) { + return JSON.stringify({ + id, + name: `Folder ${id}`, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + ...extra, + }); +} + +test('REPRO: a corrupted notes index no longer hides notes whose records survived', async () => { + // This is the shipped-bug scenario: the device died mid-write, the + // AsyncStorage index value is garbage, but the per-note entries survived. + // The old readIndex() returned [] on parse failure, so the library showed + // zero notes. The rebuild must recover them from a key scan instead. + const kv = makeKv({ + [NOTES_INDEX_KEY]: '{"truncated-garbage', + [`${NOTE_KEY_PREFIX}note-a`]: noteJson('note-a'), + [`${NOTE_KEY_PREFIX}note-b`]: noteJson('note-b'), + }); + const store = createCatalogStore(makeEnv({ kv })); + const catalog = await store.getCatalog(); + assert.deepEqual(catalog.notes.map((n) => n.id).sort(), ['note-a', 'note-b']); + assert.equal(catalog.notes[0].title, `Title ${catalog.notes[0].id}`); +}); + +test('REPRO: total key-value loss recovers every note that still has a body file on disk', async () => { + // Worst case: the AsyncStorage manifest is gone entirely (the "all my + // notes were deleted" report). Body files in Documents/ survive that, so + // the recovery scan must resurrect them. + const env = makeEnv({ + bodyFiles: [ + { id: 'note-lab-data', modifiedAt: '2026-08-01T10:00:00.000Z' }, + { id: 'note-problems', modifiedAt: null }, + ], + previews: { 'note-lab-data': 'file:///previews/lab.png' }, + pdfs: new Set(['note-problems']), + }); + const store = createCatalogStore(env); + const catalog = await store.getCatalog(); + + assert.equal(catalog.notes.length, 2); + for (const note of catalog.notes) { + assert.equal(note.title, RECOVERED_NOTE_TITLE); + assert.equal(note.folderId, null); + } + const lab = catalog.notes.find((n) => n.id === 'note-lab-data'); + assert.equal(lab.thumbnailUri, 'file:///previews/lab.png'); + assert.equal(lab.updatedAt, '2026-08-01T10:00:00.000Z'); + const problems = catalog.notes.find((n) => n.id === 'note-problems'); + assert.equal(problems.backgroundType, 'pdf'); + assert.equal(problems.pdfUri, 'file:///documents/pdfs/note-problems.pdf'); + + // Recovery is persisted so the next launch does not depend on the scan. + const persisted = parseCatalog(env.catalogFile); + assert.equal(persisted.notes.length, 2); +}); + +test('a corrupt catalog file is preserved for diagnostics and rebuilt from the mirror', async () => { + const kv = makeKv({ + [NOTES_INDEX_KEY]: JSON.stringify(['note-a']), + [`${NOTE_KEY_PREFIX}note-a`]: noteJson('note-a'), + }); + const env = makeEnv({ kv, catalogFile: '{"notes": [truncated' }); + const store = createCatalogStore(env); + const catalog = await store.getCatalog(); + assert.deepEqual(catalog.notes.map((n) => n.id), ['note-a']); + assert.equal(env.preservedCorruptFile, '{"notes": [truncated'); + assert.notEqual(env.catalogFile, null); +}); + +test('a note pointing at a lost folder is moved to the root so it stays visible', async () => { + const env = makeEnv({ + catalogFile: JSON.stringify({ + version: 1, + notes: [JSON.parse(noteJson('note-a', { folderId: 'folder-gone' }))], + folders: [], + }), + }); + const store = createCatalogStore(env); + const catalog = await store.getCatalog(); + assert.equal(catalog.notes[0].folderId, null); +}); + +test('legacy-namespace records migrate into the catalog', async () => { + const legacy = '@simple' + 'notes:'; + const kv = makeKv({ + [`${legacy}notes:index`]: JSON.stringify(['note-old']), + [`${legacy}note:note-old`]: noteJson('note-old'), + [`${legacy}folders:index`]: JSON.stringify(['folder-old']), + [`${legacy}folder:folder-old`]: folderJson('folder-old'), + }); + const store = createCatalogStore(makeEnv({ kv })); + const catalog = await store.getCatalog(); + assert.deepEqual(catalog.notes.map((n) => n.id), ['note-old']); + assert.deepEqual(catalog.folders.map((f) => f.id), ['folder-old']); +}); + +test('deleting a note removes its mirror keys so a rebuild cannot resurrect it', async () => { + const kv = makeKv({ + [NOTES_INDEX_KEY]: JSON.stringify(['note-a', 'note-b']), + [`${NOTE_KEY_PREFIX}note-a`]: noteJson('note-a'), + [`${NOTE_KEY_PREFIX}note-b`]: noteJson('note-b'), + }); + const env = makeEnv({ kv }); + const store = createCatalogStore(env); + await store.getCatalog(); + await store.mutate((catalog) => ({ + ...catalog, + notes: catalog.notes.filter((n) => n.id !== 'note-a'), + })); + + assert.equal(kv.map.has(`${NOTE_KEY_PREFIX}note-a`), false); + assert.deepEqual(JSON.parse(kv.map.get(NOTES_INDEX_KEY)), ['note-b']); + + // A store rebuilt from the same kv (catalog file lost) must not bring the + // deleted note back. + const rebuilt = createCatalogStore(makeEnv({ kv })); + const catalog = await rebuilt.getCatalog(); + assert.deepEqual(catalog.notes.map((n) => n.id), ['note-b']); +}); + +test('mutations persist to both the catalog file and the mirror', async () => { + const env = makeEnv(); + const store = createCatalogStore(env); + await store.mutate((catalog) => ({ + ...catalog, + notes: [JSON.parse(noteJson('note-new')), ...catalog.notes], + })); + + const persisted = parseCatalog(env.catalogFile); + assert.deepEqual(persisted.notes.map((n) => n.id), ['note-new']); + assert.equal(env.kv.map.has(`${NOTE_KEY_PREFIX}note-new`), true); + assert.deepEqual(JSON.parse(env.kv.map.get(NOTES_INDEX_KEY)), ['note-new']); +}); + +test('a failing catalog file write still leaves data readable in-session and mirrored', async () => { + const env = makeEnv({ failCatalogWrites: true }); + const store = createCatalogStore(env); + await store.mutate((catalog) => ({ + ...catalog, + notes: [JSON.parse(noteJson('note-a')), ...catalog.notes], + })); + + const catalog = await store.getCatalog(); + assert.deepEqual(catalog.notes.map((n) => n.id), ['note-a']); + assert.equal(env.kv.map.has(`${NOTE_KEY_PREFIX}note-a`), true); + + // After a relaunch (fresh store, no catalog file) the mirror restores it. + const rebuilt = createCatalogStore(makeEnv({ kv: env.kv })); + const restored = await rebuilt.getCatalog(); + assert.deepEqual(restored.notes.map((n) => n.id), ['note-a']); +}); + +test('reconciliation does not duplicate notes already in the catalog', async () => { + const env = makeEnv({ + catalogFile: JSON.stringify({ + version: 1, + notes: [JSON.parse(noteJson('note-a'))], + folders: [], + }), + bodyFiles: ['note-a'], + }); + const store = createCatalogStore(env); + const catalog = await store.getCatalog(); + assert.equal(catalog.notes.length, 1); + assert.equal(catalog.notes[0].title, 'Title note-a'); +}); + +test('a body file missing from a valid catalog is restored with its mirror metadata, not a stub', async () => { + // Covers a stale catalog file (e.g. after a version downgrade/upgrade + // cycle): the note is absent from the catalog, but its kv record survived. + const kv = makeKv({ + [`${NOTE_KEY_PREFIX}note-fresh`]: noteJson('note-fresh', { folderId: 'folder-a' }), + }); + const env = makeEnv({ + kv, + catalogFile: JSON.stringify({ + version: 1, + notes: [], + folders: [JSON.parse(folderJson('folder-a'))], + }), + bodyFiles: ['note-fresh'], + }); + const store = createCatalogStore(env); + const catalog = await store.getCatalog(); + assert.equal(catalog.notes.length, 1); + assert.equal(catalog.notes[0].title, 'Title note-fresh'); + assert.equal(catalog.notes[0].folderId, 'folder-a'); +}); + +test('concurrent mutations are serialized and both apply', async () => { + const store = createCatalogStore(makeEnv()); + await Promise.all([ + store.mutate((catalog) => ({ + ...catalog, + notes: [JSON.parse(noteJson('note-1')), ...catalog.notes], + })), + store.mutate((catalog) => ({ + ...catalog, + notes: [JSON.parse(noteJson('note-2')), ...catalog.notes], + })), + ]); + const catalog = await store.getCatalog(); + assert.deepEqual(catalog.notes.map((n) => n.id).sort(), ['note-1', 'note-2']); +}); + +test('parseCatalog rejects garbage and skips malformed entries', () => { + assert.equal(parseCatalog('not json'), null); + assert.equal(parseCatalog('42'), null); + assert.equal(parseCatalog('{"notes": "nope", "folders": []}'), null); + + const mixed = parseCatalog( + JSON.stringify({ + version: 1, + notes: [JSON.parse(noteJson('note-a')), { id: '' }, 'junk', null], + folders: [JSON.parse(folderJson('folder-a')), 7], + }), + ); + assert.deepEqual(mixed.notes.map((n) => n.id), ['note-a']); + assert.deepEqual(mixed.folders.map((f) => f.id), ['folder-a']); +}); + +test('kv index order is preserved; unindexed survivors sort by recency after it', async () => { + const kv = makeKv({ + [NOTES_INDEX_KEY]: JSON.stringify(['note-b', 'note-a']), + [`${NOTE_KEY_PREFIX}note-a`]: noteJson('note-a'), + [`${NOTE_KEY_PREFIX}note-b`]: noteJson('note-b'), + [`${NOTE_KEY_PREFIX}note-stray`]: noteJson('note-stray', { + updatedAt: '2026-05-01T00:00:00.000Z', + }), + }); + const store = createCatalogStore(makeEnv({ kv })); + const catalog = await store.getCatalog(); + assert.deepEqual( + catalog.notes.map((n) => n.id), + ['note-b', 'note-a', 'note-stray'], + ); +}); + +test('empty folders index with surviving folder records still lists folders', async () => { + const kv = makeKv({ + [FOLDERS_INDEX_KEY]: '###', + [`${FOLDER_KEY_PREFIX}folder-a`]: folderJson('folder-a'), + }); + const store = createCatalogStore(makeEnv({ kv })); + const catalog = await store.getCatalog(); + assert.deepEqual(catalog.folders.map((f) => f.id), ['folder-a']); +}); diff --git a/src/hooks/useAutosave.ts b/src/hooks/useAutosave.ts index dbdaeaa..89efe70 100644 --- a/src/hooks/useAutosave.ts +++ b/src/hooks/useAutosave.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef } from 'react'; +import { AppState } from 'react-native'; const DEFAULT_DEBOUNCE_MS = 350; @@ -18,7 +19,8 @@ export function useAutosave({ enabled = true, }: UseAutosaveOptions) { const timerRef = useRef | null>(null); - const inFlightRef = useRef | null>(null); + const inFlightRef = useRef | null>(null); + const dirtyRef = useRef(false); const onSaveRef = useRef(onSave); const onStatusRef = useRef(onStatusChange); const enabledRef = useRef(enabled); @@ -35,19 +37,24 @@ export function useAutosave({ enabledRef.current = enabled; }, [enabled]); - const runSave = useCallback(async (): Promise => { - if (!enabledRef.current) return; + const runSave = useCallback(async (): Promise => { + if (!enabledRef.current) return true; + // Cleared before the save so changes made while it runs mark dirty again. + dirtyRef.current = false; onStatusRef.current?.('saving'); try { await onSaveRef.current(); onStatusRef.current?.('saved'); + return true; } catch (error) { + dirtyRef.current = true; if (__DEV__) console.warn('[useAutosave] save failed', error); onStatusRef.current?.('error'); + return false; } }, []); - const flushNow = useCallback(async (): Promise => { + const flushNow = useCallback(async (): Promise => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; @@ -55,9 +62,10 @@ export function useAutosave({ if (inFlightRef.current) { await inFlightRef.current; } + if (!dirtyRef.current) return true; const promise = runSave(); inFlightRef.current = promise; - await promise.finally(() => { + return promise.finally(() => { if (inFlightRef.current === promise) inFlightRef.current = null; }); }, [runSave]); @@ -70,14 +78,9 @@ export function useAutosave({ onStatusRef.current?.('idle'); }, []); - const waitForIdle = useCallback(async (): Promise => { - if (inFlightRef.current) { - await inFlightRef.current; - } - }, []); - const schedule = useCallback(() => { if (!enabledRef.current) return; + dirtyRef.current = true; onStatusRef.current?.('pending'); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { @@ -89,6 +92,18 @@ export function useAutosave({ }, debounceMs); }, [debounceMs, runSave]); + // Flush the moment the app leaves the foreground: JS timers do not fire in + // the background, so a pending debounced save would otherwise sit unsaved + // until the app returns — and be lost if it never does. + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'background' || state === 'inactive') { + void flushNow(); + } + }); + return () => subscription.remove(); + }, [flushNow]); + useEffect(() => { return () => { if (timerRef.current) { @@ -98,5 +113,5 @@ export function useAutosave({ }; }, []); - return { schedule, flushNow, cancelPending, waitForIdle }; + return { schedule, flushNow, cancelPending }; } diff --git a/src/services/atomicFile.ts b/src/services/atomicFile.ts new file mode 100644 index 0000000..fd777f8 --- /dev/null +++ b/src/services/atomicFile.ts @@ -0,0 +1,27 @@ +import * as FileSystem from 'expo-file-system/legacy'; + +/** + * Writes a string to `path` atomically: write to a temp file, verify the + * bytes landed, then rename over the destination. A crash at any point leaves + * either the old file or the new file in place, never a truncated one. + * Returns false (and cleans up the temp file) if verification fails. + */ +export async function writeStringAtomic(path: string, contents: string): Promise { + const tmp = `${path}.tmp`; + await FileSystem.deleteAsync(tmp, { idempotent: true }); + await FileSystem.writeAsStringAsync(tmp, contents); + + const info = await FileSystem.getInfoAsync(tmp); + if (!info.exists || typeof info.size !== 'number' || info.size < contents.length) { + await FileSystem.deleteAsync(tmp, { idempotent: true }); + if (__DEV__) { + console.warn( + `[atomicFile] verify failed for ${path}: expected ${contents.length}, got ${info.exists ? info.size : 'missing'}`, + ); + } + return false; + } + + await FileSystem.moveAsync({ from: tmp, to: path }); + return true; +} diff --git a/src/services/catalogEnv.ts b/src/services/catalogEnv.ts new file mode 100644 index 0000000..d076311 --- /dev/null +++ b/src/services/catalogEnv.ts @@ -0,0 +1,61 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as FileSystem from 'expo-file-system/legacy'; +import { writeStringAtomic } from './atomicFile'; +import { createCatalogStore, type CatalogEnv, type CatalogStore } from './catalogStore'; +import { bodyModifiedAt, listBodyIds, readBody } from './noteBodyStorage'; +import { pdfUriForNote } from './pdfStorage'; + +const CATALOG_FILENAME = 'notes-catalog.json'; + +function catalogPath(): string { + return `${FileSystem.documentDirectory ?? ''}${CATALOG_FILENAME}`; +} + +const env: CatalogEnv = { + kv: AsyncStorage, + + async readCatalogFile(): Promise { + const path = catalogPath(); + const info = await FileSystem.getInfoAsync(path); + if (!info.exists) return null; + return FileSystem.readAsStringAsync(path); + }, + + writeCatalogFile(json: string): Promise { + return writeStringAtomic(catalogPath(), json); + }, + + async preserveCorruptCatalogFile(): Promise { + const path = catalogPath(); + const preserved = `${path}.corrupt`; + await FileSystem.deleteAsync(preserved, { idempotent: true }); + await FileSystem.moveAsync({ from: path, to: preserved }); + }, + + listBodyIds, + + bodyModifiedAt, + + async readBodyPreview(id: string): Promise { + const result = await readBody(id); + if (result.kind !== 'ok') return null; + return result.data.pages[0]?.previewUri ?? null; + }, + + async pdfExistsForNote(id: string): Promise { + const info = await FileSystem.getInfoAsync(pdfUriForNote(id)); + return info.exists; + }, + + pdfUriForNote, + + now(): string { + return new Date().toISOString(); + }, + + warn(message: string, error?: unknown): void { + if (__DEV__) console.warn(message, error ?? ''); + }, +}; + +export const catalogStore: CatalogStore = createCatalogStore(env); diff --git a/src/services/catalogStore.ts b/src/services/catalogStore.ts new file mode 100644 index 0000000..b1ee925 --- /dev/null +++ b/src/services/catalogStore.ts @@ -0,0 +1,490 @@ +// Only type-only imports and the pure promiseQueue util here: this module must +// load under plain `node --test` with fake adapters, so it cannot depend on +// React Native or Expo modules. The explicit .ts extension is what lets Node +// resolve the import when running the test suite. +import type { BackgroundType, FolderMetadata, NoteMetadata } from '../types/note'; +import { createPromiseQueue } from '../utils/promiseQueue.ts'; + +// Record forces a compile error if BackgroundType ever +// gains or loses a member without this map being updated. +const BACKGROUND_TYPE_FLAGS: Record = { + plain: true, + grid: true, + lined: true, + dotted: true, + graph: true, + pdf: true, +}; + +/** + * Durable catalog of all notes and folders. + * + * The catalog is the single source of truth for which notes/folders exist and + * their metadata. It is persisted as one JSON file on disk with an atomic + * verified write (the same pattern note bodies use), and mirrored into the + * key-value store (AsyncStorage) for redundancy. On load it reconciles against + * the note body files on disk, so even if both the catalog file and the + * key-value store are lost or corrupted, every note body still on disk is + * recovered into the library instead of silently disappearing. + */ + +export interface Catalog { + version: 1; + notes: NoteMetadata[]; + folders: FolderMetadata[]; +} + +export interface KeyValueStore { + getItem(key: string): Promise; + getAllKeys(): Promise; + multiGet(keys: string[]): Promise; + multiSet(pairs: [string, string][]): Promise; + multiRemove(keys: string[]): Promise; +} + +export interface CatalogEnv { + kv: KeyValueStore; + /** Returns file contents, or null if the file does not exist. Throws on read errors. */ + readCatalogFile(): Promise; + /** Atomic verified write. Returns false if the write could not be completed. */ + writeCatalogFile(json: string): Promise; + /** Moves a corrupt catalog file aside so it is preserved for diagnostics. */ + preserveCorruptCatalogFile(): Promise; + /** Ids of every note body file on disk (a single directory read). */ + listBodyIds(): Promise; + /** Last-modified time of a note's body file, or null if unavailable. */ + bodyModifiedAt(id: string): Promise; + /** First-page preview URI from a body file, or null if unavailable. */ + readBodyPreview(id: string): Promise; + pdfExistsForNote(id: string): Promise; + /** Deterministic URI where pdfStorage keeps a note's background PDF. */ + pdfUriForNote(id: string): string; + now(): string; + warn(message: string, error?: unknown): void; +} + +export const NOTES_INDEX_KEY = '@opennotes:notes:index'; +export const NOTE_KEY_PREFIX = '@opennotes:note:'; +export const FOLDERS_INDEX_KEY = '@opennotes:folders:index'; +export const FOLDER_KEY_PREFIX = '@opennotes:folder:'; + +const LEGACY_NAMESPACE = '@simple' + 'notes:'; +const LEGACY_NOTES_INDEX_KEY = `${LEGACY_NAMESPACE}notes:index`; +const LEGACY_NOTE_KEY_PREFIX = `${LEGACY_NAMESPACE}note:`; +const LEGACY_FOLDERS_INDEX_KEY = `${LEGACY_NAMESPACE}folders:index`; +const LEGACY_FOLDER_KEY_PREFIX = `${LEGACY_NAMESPACE}folder:`; + +export const RECOVERED_NOTE_TITLE = 'Recovered note'; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function normalizeNote(value: unknown): NoteMetadata | null { + if (typeof value !== 'object' || value === null) return null; + const v = value as Record; + if (!isNonEmptyString(v.id)) return null; + const backgroundType = + typeof v.backgroundType === 'string' && v.backgroundType in BACKGROUND_TYPE_FLAGS + ? (v.backgroundType as BackgroundType) + : 'plain'; + return { + id: v.id, + title: typeof v.title === 'string' && v.title.trim() ? v.title : 'Untitled', + folderId: isNonEmptyString(v.folderId) ? v.folderId : null, + createdAt: isNonEmptyString(v.createdAt) ? v.createdAt : new Date(0).toISOString(), + updatedAt: isNonEmptyString(v.updatedAt) ? v.updatedAt : new Date(0).toISOString(), + backgroundType, + pdfUri: isNonEmptyString(v.pdfUri) ? v.pdfUri : null, + thumbnailUri: isNonEmptyString(v.thumbnailUri) ? v.thumbnailUri : null, + }; +} + +function normalizeFolder(value: unknown): FolderMetadata | null { + if (typeof value !== 'object' || value === null) return null; + const v = value as Record; + if (!isNonEmptyString(v.id)) return null; + return { + id: v.id, + name: typeof v.name === 'string' && v.name.trim() ? v.name : 'Folder', + createdAt: isNonEmptyString(v.createdAt) ? v.createdAt : new Date(0).toISOString(), + updatedAt: isNonEmptyString(v.updatedAt) ? v.updatedAt : new Date(0).toISOString(), + }; +} + +/** Parses and validates a serialized catalog. Returns null if it is not usable. */ +export function parseCatalog(raw: string): Catalog | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null) return null; + const v = parsed as Record; + if (!Array.isArray(v.notes) || !Array.isArray(v.folders)) return null; + const notes: NoteMetadata[] = []; + const seenNotes = new Set(); + for (const entry of v.notes) { + const note = normalizeNote(entry); + if (note && !seenNotes.has(note.id)) { + seenNotes.add(note.id); + notes.push(note); + } + } + const folders: FolderMetadata[] = []; + const seenFolders = new Set(); + for (const entry of v.folders) { + const folder = normalizeFolder(entry); + if (folder && !seenFolders.has(folder.id)) { + seenFolders.add(folder.id); + folders.push(folder); + } + } + return { version: 1, notes, folders }; +} + +function parseIndex(raw: string | null): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter((x): x is string => typeof x === 'string') + : []; + } catch { + return []; + } +} + +interface KvNamespace { + indexKey: string; + entryPrefix: string; +} + +/** + * Rebuilds one entity list (notes or folders) from the key-value mirror. + * + * The stored index is only a hint for ordering: the authoritative id set comes + * from scanning all keys by prefix, so a corrupted or lost index cannot hide + * entries whose individual records survived. + */ +async function rebuildFromKv( + kv: KeyValueStore, + namespaces: KvNamespace[], + normalize: (value: unknown) => T | null, + warn: (message: string, error?: unknown) => void, +): Promise { + let allKeys: readonly string[] = []; + try { + allKeys = await kv.getAllKeys(); + } catch (error) { + warn('[catalogStore] kv.getAllKeys failed during rebuild', error); + } + + const indexOrder: string[] = []; + const indexOrderSet = new Set(); + for (const ns of namespaces) { + let raw: string | null = null; + try { + raw = await kv.getItem(ns.indexKey); + } catch (error) { + warn(`[catalogStore] kv read failed for ${ns.indexKey}`, error); + } + for (const id of parseIndex(raw)) { + if (!indexOrderSet.has(id)) { + indexOrderSet.add(id); + indexOrder.push(id); + } + } + } + + const keysToRead = new Map(); + for (const ns of namespaces) { + for (const id of indexOrder) { + const key = `${ns.entryPrefix}${id}`; + if (!keysToRead.has(key)) keysToRead.set(key, id); + } + for (const key of allKeys) { + if (key.startsWith(ns.entryPrefix) && !keysToRead.has(key)) { + keysToRead.set(key, key.slice(ns.entryPrefix.length)); + } + } + } + if (keysToRead.size === 0) return []; + + let entries: readonly (readonly [string, string | null])[] = []; + try { + entries = await kv.multiGet([...keysToRead.keys()]); + } catch (error) { + warn('[catalogStore] kv.multiGet failed during rebuild', error); + return []; + } + + const byId = new Map(); + for (const [key, raw] of entries) { + if (!raw) continue; + const id = keysToRead.get(key); + if (!id || byId.has(id)) continue; + try { + const normalized = normalize(JSON.parse(raw)); + if (normalized && normalized.id === id) byId.set(id, normalized); + } catch { + warn(`[catalogStore] skipping corrupt kv entry ${key}`); + } + } + + const ordered: T[] = []; + for (const id of indexOrder) { + const entry = byId.get(id); + if (entry) { + ordered.push(entry); + byId.delete(id); + } + } + const extras = [...byId.values()].sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); + return [...ordered, ...extras]; +} + +/** + * Reconciles a catalog against the body files on disk. Returns the reconciled + * catalog and whether anything changed. + * + * - Any body file without a catalog entry becomes a recovered note. + * - Any note pointing at a folder that no longer exists is moved to the root + * so it stays visible in the library. + */ +async function reconcileCatalog( + env: CatalogEnv, + catalog: Catalog, +): Promise<{ catalog: Catalog; changed: boolean }> { + let changed = false; + + let bodyIds: string[] = []; + try { + bodyIds = await env.listBodyIds(); + } catch (error) { + env.warn('[catalogStore] listing body files failed; skipping recovery scan', error); + } + + const knownNoteIds = new Set(catalog.notes.map((n) => n.id)); + const orphanIds = bodyIds.filter((id) => !knownNoteIds.has(id)); + // In the normal case orphanIds is empty and no per-note I/O happens at all; + // when there is something to recover, the notes recover in parallel. + const recovered = await Promise.all(orphanIds.map((id) => recoverNote(env, id))); + if (recovered.length > 0) changed = true; + + const folderIds = new Set(catalog.folders.map((f) => f.id)); + const notes = [...catalog.notes, ...recovered].map((note) => { + if (note.folderId !== null && !folderIds.has(note.folderId)) { + changed = true; + return { ...note, folderId: null }; + } + return note; + }); + + return { + catalog: changed ? { version: 1, notes, folders: catalog.folders } : catalog, + changed, + }; +} + +async function recoverNote(env: CatalogEnv, id: string): Promise { + // Prefer the key-value mirror record when one survived: it carries the real + // title, folder, and background instead of a recovery stub. + const mirrored = await recoverNoteFromKv(env, id); + if (mirrored) return mirrored; + + let modifiedAt: string | null = null; + try { + modifiedAt = await env.bodyModifiedAt(id); + } catch (error) { + env.warn(`[catalogStore] stat failed for recovered note ${id}`, error); + } + const timestamp = modifiedAt ?? env.now(); + let thumbnailUri: string | null = null; + try { + thumbnailUri = await env.readBodyPreview(id); + } catch (error) { + env.warn(`[catalogStore] preview read failed for recovered note ${id}`, error); + } + let hasPdf = false; + try { + hasPdf = await env.pdfExistsForNote(id); + } catch (error) { + env.warn(`[catalogStore] pdf check failed for recovered note ${id}`, error); + } + return { + id, + title: RECOVERED_NOTE_TITLE, + folderId: null, + createdAt: timestamp, + updatedAt: timestamp, + backgroundType: hasPdf ? 'pdf' : 'plain', + pdfUri: hasPdf ? env.pdfUriForNote(id) : null, + thumbnailUri, + }; +} + +async function recoverNoteFromKv(env: CatalogEnv, id: string): Promise { + for (const key of [`${NOTE_KEY_PREFIX}${id}`, `${LEGACY_NOTE_KEY_PREFIX}${id}`]) { + let raw: string | null = null; + try { + raw = await env.kv.getItem(key); + } catch (error) { + env.warn(`[catalogStore] kv read failed for ${key}`, error); + } + if (!raw) continue; + try { + const note = normalizeNote(JSON.parse(raw)); + if (note && note.id === id) return note; + } catch { + env.warn(`[catalogStore] skipping corrupt kv entry ${key}`); + } + } + return null; +} + +export interface CatalogStore { + getCatalog(): Promise; + /** + * Applies a serialized mutation. Return null from the mutator to indicate no + * change (nothing is persisted). Mutators must not modify the input catalog. + */ + mutate(fn: (catalog: Catalog) => Catalog | null): Promise; +} + +export function createCatalogStore(env: CatalogEnv): CatalogStore { + const queue = createPromiseQueue(); + let cached: Catalog | null = null; + + async function loadLocked(): Promise { + if (cached) return cached; + + let catalog: Catalog | null = null; + let needsPersist = false; + + let raw: string | null = null; + let readFailed = false; + try { + raw = await env.readCatalogFile(); + } catch (error) { + readFailed = true; + env.warn('[catalogStore] catalog file read failed; rebuilding from mirror', error); + } + if (raw !== null) { + catalog = parseCatalog(raw); + if (!catalog) { + env.warn('[catalogStore] catalog file corrupt; preserving and rebuilding'); + try { + await env.preserveCorruptCatalogFile(); + } catch (error) { + env.warn('[catalogStore] failed to preserve corrupt catalog file', error); + } + needsPersist = true; + } + } else if (!readFailed) { + needsPersist = true; + } + + if (!catalog) { + const [notes, folders] = await Promise.all([ + rebuildFromKv( + env.kv, + [ + { indexKey: NOTES_INDEX_KEY, entryPrefix: NOTE_KEY_PREFIX }, + { indexKey: LEGACY_NOTES_INDEX_KEY, entryPrefix: LEGACY_NOTE_KEY_PREFIX }, + ], + normalizeNote, + env.warn, + ), + rebuildFromKv( + env.kv, + [ + { indexKey: FOLDERS_INDEX_KEY, entryPrefix: FOLDER_KEY_PREFIX }, + { indexKey: LEGACY_FOLDERS_INDEX_KEY, entryPrefix: LEGACY_FOLDER_KEY_PREFIX }, + ], + normalizeFolder, + env.warn, + ), + ]); + catalog = { version: 1, notes, folders }; + } + + const reconciled = await reconcileCatalog(env, catalog); + catalog = reconciled.catalog; + if (reconciled.changed || needsPersist) { + await persist(catalog, null); + } + cached = catalog; + return catalog; + } + + async function persist(next: Catalog, prev: Catalog | null): Promise { + try { + const ok = await env.writeCatalogFile(JSON.stringify(next)); + if (!ok) env.warn('[catalogStore] catalog file write did not complete'); + } catch (error) { + env.warn('[catalogStore] catalog file write failed', error); + } + + // Mirror into the key-value store so the catalog survives loss of either + // store independently. Removals must be mirrored too, otherwise deleted + // entries would be resurrected by a later rebuild. Mutations are + // immutable, so an entry with the same object identity as before is + // unchanged and can be skipped — a typical autosave mirrors one note, not + // the whole library. + try { + const prevNotes = new Map(prev?.notes.map((n) => [n.id, n]) ?? []); + const prevFolders = new Map(prev?.folders.map((f) => [f.id, f]) ?? []); + const pairs: [string, string][] = [ + [NOTES_INDEX_KEY, JSON.stringify(next.notes.map((n) => n.id))], + [FOLDERS_INDEX_KEY, JSON.stringify(next.folders.map((f) => f.id))], + ...next.notes + .filter((n) => prevNotes.get(n.id) !== n) + .map((n): [string, string] => [`${NOTE_KEY_PREFIX}${n.id}`, JSON.stringify(n)]), + ...next.folders + .filter((f) => prevFolders.get(f.id) !== f) + .map((f): [string, string] => [`${FOLDER_KEY_PREFIX}${f.id}`, JSON.stringify(f)]), + ]; + await env.kv.multiSet(pairs); + + if (prev) { + const nextNoteIds = new Set(next.notes.map((n) => n.id)); + const nextFolderIds = new Set(next.folders.map((f) => f.id)); + const staleKeys: string[] = []; + for (const note of prev.notes) { + if (!nextNoteIds.has(note.id)) { + staleKeys.push(`${NOTE_KEY_PREFIX}${note.id}`, `${LEGACY_NOTE_KEY_PREFIX}${note.id}`); + } + } + for (const folder of prev.folders) { + if (!nextFolderIds.has(folder.id)) { + staleKeys.push( + `${FOLDER_KEY_PREFIX}${folder.id}`, + `${LEGACY_FOLDER_KEY_PREFIX}${folder.id}`, + ); + } + } + if (staleKeys.length > 0) await env.kv.multiRemove(staleKeys); + } + } catch (error) { + env.warn('[catalogStore] kv mirror write failed', error); + } + } + + return { + getCatalog(): Promise { + return queue.enqueue(loadLocked); + }, + mutate(fn: (catalog: Catalog) => Catalog | null): Promise { + return queue.enqueue(async () => { + const current = await loadLocked(); + const next = fn(current); + if (next === null) return current; + await persist(next, current); + cached = next; + return next; + }); + }, + }; +} diff --git a/src/services/foldersRepo.ts b/src/services/foldersRepo.ts index 9448d84..31be98c 100644 --- a/src/services/foldersRepo.ts +++ b/src/services/foldersRepo.ts @@ -1,92 +1,16 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import type { FolderMetadata } from '../types/note'; import { folderId as makeFolderId } from '../utils/id'; -import { - deleteAllNotesInFolder, - orphanNotesInFolder, - listAllMetadata, -} from './notesRepo'; - -const INDEX_KEY = '@opennotes:folders:index'; -const FOLDER_PREFIX = '@opennotes:folder:'; -const LEGACY_NAMESPACE = '@simple' + 'notes:'; -const LEGACY_INDEX_KEY = `${LEGACY_NAMESPACE}folders:index`; -const LEGACY_FOLDER_PREFIX = `${LEGACY_NAMESPACE}folder:`; - -function folderKey(id: string): string { - return `${FOLDER_PREFIX}${id}`; -} - -function legacyFolderKey(id: string): string { - return `${LEGACY_FOLDER_PREFIX}${id}`; -} - -async function readIndex(): Promise { - let raw = await AsyncStorage.getItem(INDEX_KEY); - if (!raw) { - raw = await AsyncStorage.getItem(LEGACY_INDEX_KEY); - if (raw) await AsyncStorage.setItem(INDEX_KEY, raw); - } - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []; - } catch { - return []; - } -} - -async function writeIndex(ids: string[]): Promise { - await AsyncStorage.setItem(INDEX_KEY, JSON.stringify(ids)); -} - -async function bumpInIndex(id: string): Promise { - const ids = await readIndex(); - const filtered = ids.filter((x) => x !== id); - filtered.unshift(id); - await writeIndex(filtered); -} - -async function removeFromIndex(id: string): Promise { - const ids = await readIndex(); - const filtered = ids.filter((x) => x !== id); - await writeIndex(filtered); -} +import { catalogStore } from './catalogEnv'; +import { deleteAllNotesInFolder, orphanNotesInFolder, listAllMetadata } from './notesRepo'; export async function listFolders(): Promise { - const ids = await readIndex(); - if (ids.length === 0) return []; - const entries = await AsyncStorage.multiGet(ids.map(folderKey)); - const out: FolderMetadata[] = []; - for (let i = 0; i < entries.length; i += 1) { - const id = ids[i]; - let raw = entries[i]?.[1] ?? null; - if (!raw) { - raw = await AsyncStorage.getItem(legacyFolderKey(id)); - if (raw) await AsyncStorage.setItem(folderKey(id), raw); - } - if (!raw) continue; - try { - out.push(JSON.parse(raw) as FolderMetadata); - } catch { - // skip corrupt - } - } - return out; + const catalog = await catalogStore.getCatalog(); + return catalog.folders; } export async function getFolder(id: string): Promise { - let raw = await AsyncStorage.getItem(folderKey(id)); - if (!raw) { - raw = await AsyncStorage.getItem(legacyFolderKey(id)); - if (raw) await AsyncStorage.setItem(folderKey(id), raw); - } - if (!raw) return null; - try { - return JSON.parse(raw) as FolderMetadata; - } catch { - return null; - } + const catalog = await catalogStore.getCatalog(); + return catalog.folders.find((f) => f.id === id) ?? null; } export async function createFolder(name: string): Promise { @@ -97,22 +21,30 @@ export async function createFolder(name: string): Promise { createdAt: now, updatedAt: now, }; - await AsyncStorage.setItem(folderKey(meta.id), JSON.stringify(meta)); - await bumpInIndex(meta.id); + await catalogStore.mutate((catalog) => ({ + ...catalog, + folders: [meta, ...catalog.folders.filter((f) => f.id !== meta.id)], + })); return meta; } export async function renameFolder(id: string, name: string): Promise { - const current = await getFolder(id); - if (!current) return null; - const next: FolderMetadata = { - ...current, - name: name.trim() || current.name, - updatedAt: new Date().toISOString(), - }; - await AsyncStorage.setItem(folderKey(id), JSON.stringify(next)); - await bumpInIndex(id); - return next; + const next = await catalogStore.mutate((catalog) => { + const current = catalog.folders.find((f) => f.id === id); + if (!current) return null; + const updated: FolderMetadata = { + ...current, + name: name.trim() || current.name, + updatedAt: new Date().toISOString(), + }; + return { + ...catalog, + folders: [updated, ...catalog.folders.filter((f) => f.id !== id)], + }; + }); + // When the folder was missing the mutator returned null, the catalog is + // unchanged, and this find comes back empty. + return next.folders.find((f) => f.id === id) ?? null; } export type FolderDeleteMode = 'orphan-notes' | 'delete-notes'; @@ -123,11 +55,10 @@ export async function deleteFolder(id: string, mode: FolderDeleteMode): Promise< } else { await orphanNotesInFolder(id); } - await Promise.all([ - AsyncStorage.removeItem(folderKey(id)), - AsyncStorage.removeItem(legacyFolderKey(id)), - removeFromIndex(id), - ]); + await catalogStore.mutate((catalog) => ({ + ...catalog, + folders: catalog.folders.filter((f) => f.id !== id), + })); } export async function noteCountInFolder(id: string): Promise { diff --git a/src/services/lifecycleService.ts b/src/services/lifecycleService.ts index 10b074b..d21dd40 100644 --- a/src/services/lifecycleService.ts +++ b/src/services/lifecycleService.ts @@ -10,21 +10,17 @@ import { type CommunityPromptState, type LifecycleState, } from './lifecyclePolicy'; +import { createPromiseQueue } from '../utils/promiseQueue'; const LIFECYCLE_KEY = '@opennotes:lifecycle:v1'; const APP_VERSION = Constants.expoConfig?.version ?? 'unknown'; -let stateQueue: Promise = Promise.resolve(); +const stateQueue = createPromiseQueue(); let reviewRequest: Promise | null = null; let communityPromptClaimedThisSession = false; function withStateLock(operation: () => Promise): Promise { - const result = stateQueue.then(operation, operation); - stateQueue = result.then( - () => undefined, - () => undefined, - ); - return result; + return stateQueue.enqueue(operation); } async function readStateUnlocked(): Promise { diff --git a/src/services/noteBodyStorage.ts b/src/services/noteBodyStorage.ts index 00ec18e..8642d64 100644 --- a/src/services/noteBodyStorage.ts +++ b/src/services/noteBodyStorage.ts @@ -1,14 +1,16 @@ import * as FileSystem from 'expo-file-system/legacy'; import type { SerializedNotebookData } from '@mathnotes/mobile-ink'; +import { writeStringAtomic } from './atomicFile'; const BODIES_SUBDIR = 'notebook-bodies/'; +const BODY_EXTENSION = '.body'; function bodiesDir(): string { return `${FileSystem.documentDirectory ?? ''}${BODIES_SUBDIR}`; } function bodyPath(id: string): string { - return `${bodiesDir()}${encodeURIComponent(id)}.body`; + return `${bodiesDir()}${encodeURIComponent(id)}${BODY_EXTENSION}`; } let dirReady: Promise | null = null; @@ -28,17 +30,35 @@ async function ensureDir(): Promise { return dirReady; } -export async function readBody(id: string): Promise { +export type BodyReadResult = + | { kind: 'ok'; data: SerializedNotebookData } + /** No body file exists for this note (a note that was never drawn in). */ + | { kind: 'missing' } + /** + * A body file exists but could not be read or parsed. Callers must NOT + * treat this as an empty note: writing an empty body over the file would + * destroy content that may be recoverable. + */ + | { kind: 'unreadable' }; + +export async function readBody(id: string): Promise { + const path = bodyPath(id); + let exists: boolean; try { - const path = bodyPath(id); const info = await FileSystem.getInfoAsync(path); - if (!info.exists) return null; + exists = info.exists; + } catch (error) { + if (__DEV__) console.warn('[noteBodyStorage] readBody stat failed', id, error); + return { kind: 'unreadable' }; + } + if (!exists) return { kind: 'missing' }; + try { const raw = await FileSystem.readAsStringAsync(path); - if (!raw) return null; - return JSON.parse(raw) as SerializedNotebookData; + if (!raw) return { kind: 'unreadable' }; + return { kind: 'ok', data: JSON.parse(raw) as SerializedNotebookData }; } catch (error) { - if (__DEV__) console.log('[noteBodyStorage] readBody miss', id, error); - return null; + if (__DEV__) console.warn('[noteBodyStorage] readBody failed', id, error); + return { kind: 'unreadable' }; } } @@ -47,25 +67,7 @@ export async function writeBody(id: string, data: SerializedNotebookData): Promi if (!json) return false; try { await ensureDir(); - const path = bodyPath(id); - const tmp = `${path}.tmp`; - - await FileSystem.deleteAsync(tmp, { idempotent: true }); - await FileSystem.writeAsStringAsync(tmp, json); - - const info = await FileSystem.getInfoAsync(tmp); - if (!info.exists || typeof info.size !== 'number' || info.size < json.length) { - await FileSystem.deleteAsync(tmp, { idempotent: true }); - if (__DEV__) { - console.warn( - `[noteBodyStorage] verify failed for ${id}: expected ${json.length}, got ${info.exists ? info.size : 'missing'}`, - ); - } - return false; - } - - await FileSystem.moveAsync({ from: tmp, to: path }); - return true; + return await writeStringAtomic(bodyPath(id), json); } catch (error) { if (__DEV__) console.warn(`[noteBodyStorage] write failed for ${id}`, error); return false; @@ -79,3 +81,50 @@ export async function deleteBody(id: string): Promise { if (__DEV__) console.log('[noteBodyStorage] delete failed', id, error); } } + +/** + * Lists the ids of every note body file on disk. Used to recover notes whose + * catalog and key-value records were lost. Returns [] when the directory does + * not exist. A single directory read; no per-file stats. + */ +export async function listBodyIds(): Promise { + const dir = bodiesDir(); + const info = await FileSystem.getInfoAsync(dir); + if (!info.exists) return []; + const names = await FileSystem.readDirectoryAsync(dir); + const out: string[] = []; + for (const name of names) { + if (!name.endsWith(BODY_EXTENSION)) continue; + const id = decodeURIComponent(name.slice(0, -BODY_EXTENSION.length)); + if (id) out.push(id); + } + return out; +} + +/** Last-modified time of a note's body file, or null if unavailable. */ +export async function bodyModifiedAt(id: string): Promise { + try { + const info = await FileSystem.getInfoAsync(bodyPath(id)); + if (info.exists && typeof info.modificationTime === 'number') { + return new Date(info.modificationTime * 1000).toISOString(); + } + } catch { + // Timestamp is best-effort; recovery proceeds without it. + } + return null; +} + +/** The ink engine's empty single-page document. */ +export function createEmptyNotebookData(): SerializedNotebookData { + return { + version: '1.0', + pages: [ + { + id: 'page-1', + title: 'Page 1', + data: '{"pages":{}}', + rotation: 0, + }, + ], + }; +} diff --git a/src/services/notesRepo.ts b/src/services/notesRepo.ts index 3a99600..0a24af9 100644 --- a/src/services/notesRepo.ts +++ b/src/services/notesRepo.ts @@ -1,77 +1,14 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import type { SerializedNotebookData } from '@mathnotes/mobile-ink'; import type { BackgroundType, NoteMetadata } from '../types/note'; import { noteId as makeNoteId } from '../utils/id'; -import { deleteBody, readBody, writeBody } from './noteBodyStorage'; +import { catalogStore } from './catalogEnv'; +import { deleteBody, readBody, writeBody, type BodyReadResult } from './noteBodyStorage'; import { deletePdfForNote } from './pdfStorage'; import { deleteImagesForNote } from './imageInsertStorage'; -const INDEX_KEY = '@opennotes:notes:index'; -const NOTE_PREFIX = '@opennotes:note:'; -const LEGACY_NAMESPACE = '@simple' + 'notes:'; -const LEGACY_INDEX_KEY = `${LEGACY_NAMESPACE}notes:index`; -const LEGACY_NOTE_PREFIX = `${LEGACY_NAMESPACE}note:`; - -function noteKey(id: string): string { - return `${NOTE_PREFIX}${id}`; -} - -function legacyNoteKey(id: string): string { - return `${LEGACY_NOTE_PREFIX}${id}`; -} - -async function readIndex(): Promise { - let raw = await AsyncStorage.getItem(INDEX_KEY); - if (!raw) { - raw = await AsyncStorage.getItem(LEGACY_INDEX_KEY); - if (raw) await AsyncStorage.setItem(INDEX_KEY, raw); - } - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []; - } catch { - return []; - } -} - -async function writeIndex(ids: string[]): Promise { - await AsyncStorage.setItem(INDEX_KEY, JSON.stringify(ids)); -} - -async function bumpInIndex(id: string): Promise { - const ids = await readIndex(); - const filtered = ids.filter((x) => x !== id); - filtered.unshift(id); - await writeIndex(filtered); -} - -async function removeFromIndex(id: string): Promise { - const ids = await readIndex(); - const filtered = ids.filter((x) => x !== id); - await writeIndex(filtered); -} - export async function listAllMetadata(): Promise { - const ids = await readIndex(); - if (ids.length === 0) return []; - const entries = await AsyncStorage.multiGet(ids.map(noteKey)); - const out: NoteMetadata[] = []; - for (let i = 0; i < entries.length; i += 1) { - const id = ids[i]; - let raw = entries[i]?.[1] ?? null; - if (!raw) { - raw = await AsyncStorage.getItem(legacyNoteKey(id)); - if (raw) await AsyncStorage.setItem(noteKey(id), raw); - } - if (!raw) continue; - try { - out.push(JSON.parse(raw) as NoteMetadata); - } catch { - // skip corrupt entry - } - } - return out; + const catalog = await catalogStore.getCatalog(); + return catalog.notes; } export async function listNotes(folderId: string | null): Promise { @@ -80,17 +17,8 @@ export async function listNotes(folderId: string | null): Promise { - let raw = await AsyncStorage.getItem(noteKey(id)); - if (!raw) { - raw = await AsyncStorage.getItem(legacyNoteKey(id)); - if (raw) await AsyncStorage.setItem(noteKey(id), raw); - } - if (!raw) return null; - try { - return JSON.parse(raw) as NoteMetadata; - } catch { - return null; - } + const catalog = await catalogStore.getCatalog(); + return catalog.notes.find((n) => n.id === id) ?? null; } export async function createNote(opts: { @@ -109,8 +37,10 @@ export async function createNote(opts: { pdfUri: null, thumbnailUri: null, }; - await AsyncStorage.setItem(noteKey(meta.id), JSON.stringify(meta)); - await bumpInIndex(meta.id); + await catalogStore.mutate((catalog) => ({ + ...catalog, + notes: [meta, ...catalog.notes.filter((n) => n.id !== meta.id)], + })); return meta; } @@ -118,21 +48,27 @@ export async function updateMetadata( id: string, patch: Partial>, ): Promise { - const current = await getNote(id); - if (!current) return null; - const next: NoteMetadata = { - ...current, - ...patch, - id: current.id, - createdAt: current.createdAt, - updatedAt: patch.updatedAt ?? new Date().toISOString(), - }; - await AsyncStorage.setItem(noteKey(id), JSON.stringify(next)); - await bumpInIndex(id); - return next; + const next = await catalogStore.mutate((catalog) => { + const current = catalog.notes.find((n) => n.id === id); + if (!current) return null; + const updated: NoteMetadata = { + ...current, + ...patch, + id: current.id, + createdAt: current.createdAt, + updatedAt: patch.updatedAt ?? new Date().toISOString(), + }; + return { + ...catalog, + notes: [updated, ...catalog.notes.filter((n) => n.id !== id)], + }; + }); + // When the note was missing the mutator returned null, the catalog is + // unchanged, and this find comes back empty. + return next.notes.find((n) => n.id === id) ?? null; } -export async function readNoteBody(id: string): Promise { +export async function readNoteBody(id: string): Promise { return readBody(id); } @@ -141,12 +77,17 @@ export async function saveNoteBody( data: SerializedNotebookData, ): Promise<{ ok: boolean; metadata: NoteMetadata | null }> { const ok = await writeBody(id, data); + if (!ok) { + // The body did not reach disk; leave the metadata (updatedAt, thumbnail) + // pointing at the last version that actually persisted. + return { ok: false, metadata: null }; + } const previewUri = data.pages[0]?.previewUri ?? null; const meta = await updateMetadata(id, { thumbnailUri: previewUri, updatedAt: new Date().toISOString(), }); - return { ok, metadata: meta }; + return { ok: true, metadata: meta }; } export async function moveNote(id: string, folderId: string | null): Promise { @@ -165,25 +106,42 @@ export async function setNoteBackground( return updateMetadata(id, { backgroundType, pdfUri }); } +async function deleteNoteFiles(id: string): Promise { + await Promise.all([deleteBody(id), deletePdfForNote(id), deleteImagesForNote(id)]); +} + export async function deleteNote(id: string): Promise { - await Promise.all([ - deleteBody(id), - deletePdfForNote(id), - deleteImagesForNote(id), - AsyncStorage.removeItem(noteKey(id)), - AsyncStorage.removeItem(legacyNoteKey(id)), - removeFromIndex(id), - ]); + // Remove the catalog entry first so the note disappears from the library + // even if a file delete fails; the body delete prevents the recovery scan + // from resurrecting it. + await catalogStore.mutate((catalog) => ({ + ...catalog, + notes: catalog.notes.filter((n) => n.id !== id), + })); + await deleteNoteFiles(id); } export async function deleteAllNotesInFolder(folderId: string): Promise { - const all = await listAllMetadata(); - const targets = all.filter((n) => n.folderId === folderId); - await Promise.all(targets.map((n) => deleteNote(n.id))); + let targets: NoteMetadata[] = []; + await catalogStore.mutate((catalog) => { + targets = catalog.notes.filter((n) => n.folderId === folderId); + if (targets.length === 0) return null; + return { + ...catalog, + notes: catalog.notes.filter((n) => n.folderId !== folderId), + }; + }); + await Promise.all(targets.map((n) => deleteNoteFiles(n.id))); } export async function orphanNotesInFolder(folderId: string): Promise { - const all = await listAllMetadata(); - const targets = all.filter((n) => n.folderId === folderId); - await Promise.all(targets.map((n) => moveNote(n.id, null))); + await catalogStore.mutate((catalog) => { + if (!catalog.notes.some((n) => n.folderId === folderId)) return null; + return { + ...catalog, + notes: catalog.notes.map((n) => + n.folderId === folderId ? { ...n, folderId: null } : n, + ), + }; + }); } diff --git a/src/utils/promiseQueue.ts b/src/utils/promiseQueue.ts new file mode 100644 index 0000000..b07e351 --- /dev/null +++ b/src/utils/promiseQueue.ts @@ -0,0 +1,22 @@ +/** + * Serializes async operations: each enqueued operation starts only after the + * previous one settles, whether it resolved or rejected. Pure module with no + * runtime dependencies so it can load under plain `node --test`. + */ +export interface PromiseQueue { + enqueue(operation: () => Promise): Promise; +} + +export function createPromiseQueue(): PromiseQueue { + let tail: Promise = Promise.resolve(); + return { + enqueue(operation: () => Promise): Promise { + const result = tail.then(operation, operation); + tail = result.then( + () => undefined, + () => undefined, + ); + return result; + }, + }; +} diff --git a/tsconfig.json b/tsconfig.json index d008812..b83ff9a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,8 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, "baseUrl": ".", "paths": { "@/*": [ From ba5343d509076985b32121a82156f2a84f80b170 Mon Sep 17 00:00:00 2001 From: markm39 Date: Fri, 28 Aug 2026 23:47:17 -0500 Subject: [PATCH 2/3] chore(release): bump version to 1.1 (build 9), declare exempt encryption Build 8 was prepared on chore/release-1.1-build-8; build 9 supersedes it with the data-durability fix included. Claude-Session: https://claude.ai/code/session_01EEVkk7pkngePErcAAQveCx --- android/app/build.gradle | 2 +- app.json | 4 ++-- ios/OpenNotes.xcodeproj/project.pbxproj | 4 ++-- ios/OpenNotes/Info.plist | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index aefad83..7f3b3fd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -92,7 +92,7 @@ android { applicationId 'com.builderpro.opennotes' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 8 + versionCode 9 versionName "1.1" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" diff --git a/app.json b/app.json index 4c4ae53..27e2dce 100644 --- a/app.json +++ b/app.json @@ -16,7 +16,7 @@ "ios": { "bundleIdentifier": "com.builderpro.opennotes", "icon": "./assets/icon.png", - "buildNumber": "8", + "buildNumber": "9", "supportsTablet": true, "infoPlist": { "ITSAppUsesNonExemptEncryption": false, @@ -55,7 +55,7 @@ }, "android": { "package": "com.builderpro.opennotes", - "versionCode": 8, + "versionCode": 9, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#F7F7F4" diff --git a/ios/OpenNotes.xcodeproj/project.pbxproj b/ios/OpenNotes.xcodeproj/project.pbxproj index dc731d1..b2982a0 100644 --- a/ios/OpenNotes.xcodeproj/project.pbxproj +++ b/ios/OpenNotes.xcodeproj/project.pbxproj @@ -354,7 +354,7 @@ CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = U2CPXQV7AJ; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -393,7 +393,7 @@ CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_TEAM = U2CPXQV7AJ; INFOPLIST_FILE = OpenNotes/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; diff --git a/ios/OpenNotes/Info.plist b/ios/OpenNotes/Info.plist index 1f0832e..bbaa9ff 100644 --- a/ios/OpenNotes/Info.plist +++ b/ios/OpenNotes/Info.plist @@ -54,7 +54,7 @@ CFBundleVersion - 8 + 9 ITSAppUsesNonExemptEncryption LSMinimumSystemVersion From 4e5da11388841c527bc2d30865a0fd1398d79232 Mon Sep 17 00:00:00 2001 From: markm39 Date: Fri, 28 Aug 2026 23:49:33 -0500 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20version=201.2=20(build=20?= =?UTF-8?q?9)=20=E2=80=94=201.1=20is=20already=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01EEVkk7pkngePErcAAQveCx --- android/app/build.gradle | 2 +- app.json | 2 +- ios/OpenNotes.xcodeproj/project.pbxproj | 4 ++-- ios/OpenNotes/Info.plist | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 7f3b3fd..812ff60 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -93,7 +93,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 9 - versionName "1.1" + versionName "1.2" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } diff --git a/app.json b/app.json index 27e2dce..964a34a 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "OpenNotes", "slug": "open-notes", - "version": "1.1", + "version": "1.2", "scheme": "opennotes", "orientation": "default", "icon": "./assets/icon.png", diff --git a/ios/OpenNotes.xcodeproj/project.pbxproj b/ios/OpenNotes.xcodeproj/project.pbxproj index b2982a0..6053df0 100644 --- a/ios/OpenNotes.xcodeproj/project.pbxproj +++ b/ios/OpenNotes.xcodeproj/project.pbxproj @@ -367,7 +367,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -401,7 +401,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/ios/OpenNotes/Info.plist b/ios/OpenNotes/Info.plist index bbaa9ff..807a1f7 100644 --- a/ios/OpenNotes/Info.plist +++ b/ios/OpenNotes/Info.plist @@ -34,7 +34,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.1 + 1.2 CFBundleSignature ???? CFBundleURLTypes