From 04d6303a5728d2ed607291a0560ee1756e749eb2 Mon Sep 17 00:00:00 2001 From: SecPan Date: Fri, 10 Jul 2026 04:37:29 -0700 Subject: [PATCH] Extract current-document persistence actions from App.vue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves every way the current editor document is persisted or exported into a createCurrentDocumentPersistence factory in features/document-session: local-draft autosave, Android document save with its in-flight coalescing flags, draft-to-device save, save-a-copy, sharing, lifecycle flushes, and the recovery-draft plus recents bookkeeping around them (recents marking stays private to the module; its only caller is the Android save path). The existing save/share workflows and document-state transitions are reused unchanged. App destructures the same function names so all call sites — exit prompts, editor menu, incoming-document orchestration, and lifecycle flush — are textually unchanged. The autosave scheduler now receives its two save triggers as deferred closures because the two factories reference each other's handles. --- src/App.vue | 464 +++------------ .../currentDocumentPersistence.test.ts | 346 +++++++++++ .../currentDocumentPersistence.ts | 540 ++++++++++++++++++ 3 files changed, 950 insertions(+), 400 deletions(-) create mode 100644 src/features/document-session/currentDocumentPersistence.test.ts create mode 100644 src/features/document-session/currentDocumentPersistence.ts diff --git a/src/App.vue b/src/App.vue index c8aa467..9dcbe36 100644 --- a/src/App.vue +++ b/src/App.vue @@ -52,19 +52,11 @@ import { getShowHomeDocumentSaveAction, type AppScreen, } from './lib/appExitDecisions' -import { - createAndroidRecoveryDraft, - getAndroidRecoveryDraftId, - isAndroidRecoveryDraftId, -} from './features/android-documents/androidRecoveryDrafts' -import { - markSavedAndroidRecentDocument, - rememberAndroidRecentDocument, -} from './features/android-documents/androidRecentDocuments' +import { isAndroidRecoveryDraftId } from './features/android-documents/androidRecoveryDrafts' +import { rememberAndroidRecentDocument } from './features/android-documents/androidRecentDocuments' import { getImageSharingSettings } from './features/android-documents/imageSharingSettings' import { createUntitledDocument, - markDocumentSaveFailed, updateDocumentMarkdown, } from './lib/documentState' import { @@ -75,18 +67,12 @@ import { getSortedRecentDocumentListItems, } from './features/document-session/documentSettings' import { createAutosaveScheduler } from './features/document-session/autosaveScheduler' +import { createCurrentDocumentPersistence } from './features/document-session/currentDocumentPersistence' import { createAndroidDocumentOpenResult, openAndroidMarkdownDocumentWorkflow, type AndroidDocumentOpenSource, } from './features/android-documents/androidDocumentOpenWorkflow' -import { - createSaveAndroidDocumentWorkflowStart, - saveAndroidDocumentWorkflow, -} from './features/android-documents/saveAndroidDocumentWorkflow' -import { saveAndroidDocumentCopyWorkflow } from './features/android-documents/saveAndroidDocumentCopyWorkflow' -import { saveLocalDraftToAndroidDocumentWorkflow } from './features/android-documents/saveLocalDraftToAndroidDocumentWorkflow' -import { shareAndroidMarkdownDocumentWorkflow } from './features/android-documents/shareAndroidMarkdownDocumentWorkflow' import { createAndroidImageInsertStart, createLinkInsertSheetWorkflow, @@ -116,10 +102,8 @@ import { } from './features/editor/editorRuntime' import { removeLocalDraft, - upsertLocalDraft, type LocalDraftRecord, } from './lib/localDrafts' -import { createLocalDraftAutosaveResult } from './features/local-drafts/localDraftAutosave' import { readLegacyDraft, readStoredAndroidRecentDocuments, @@ -294,8 +278,6 @@ function setEditorElement(element: HTMLElement | null) { let editor: MuyaEditor | null = null let editorInitToken = 0 let lastContentLogAt = 0 -let androidSaveInFlight = false -let androidSaveRequestedAfterCurrent = false let appLifecycleListeners: AppLifecycleListeners | null = null let pendingInlineInsertRange: Range | null = null let startupActionTimer: number | null = null @@ -488,8 +470,10 @@ const { documentState, isEditingActive: () => Boolean(editor) && currentScreen.value === 'editor', canPersistLocalDrafts, - saveDraft, - saveAndroidDocument, + // Deferred lookups: the persistence factory below provides these, and its + // own options reference this scheduler's timer handles. + saveDraft: () => saveDraft(), + saveAndroidDocument: () => saveAndroidDocument(), }) function getTransientAndroidSaveMessage() { @@ -1084,364 +1068,63 @@ function persistPinnedDocuments(nextPins: PinnedDocumentRecord[]) { writeStoredPinnedDocuments(nextPins) } -function persistAndroidRecoveryDraft(sourceUri: string, markdown: string) { - if (!canPersistAndroidRecoveryDrafts()) { - androidDocumentLog.warn('skip Android recovery draft because recovery drafts are disabled', { - sourceUri, - characters: markdown.length, - }) - return - } - - const recoveryDraft = createAndroidRecoveryDraft(sourceUri, markdown, new Date().toISOString()) - if (!recoveryDraft) { - return - } - - persistLocalDrafts(upsertLocalDraft(localDrafts.value, recoveryDraft)) - draftLog.warn('kept Android document edits as a local recovery draft', { - sourceUri, - characters: markdown.length, - }) -} - -function removeAndroidRecoveryDraft(sourceUri: string) { - const recoveryDraftId = getAndroidRecoveryDraftId(sourceUri) - if (localDrafts.value.some(draft => draft.id === recoveryDraftId)) { - persistLocalDrafts(removeLocalDraft(localDrafts.value, recoveryDraftId)) - } -} - -function markAndroidRecentDocumentSaved(savedAt: string) { - const sourceUri = documentState.value.sourceUri - if (!sourceUri) { - return - } - - const nextDocuments = markSavedAndroidRecentDocument(androidRecentDocuments.value, sourceUri, { - markdown: documentState.value.markdown, - savedAt, - canWrite: currentAndroidDocumentCanWrite.value, - }) - if (!nextDocuments) { - androidDocumentLog.warn('saved Android recent document not found', { sourceUri }) - return - } - - persistAndroidRecentDocuments(nextDocuments) - removeAndroidRecoveryDraft(sourceUri) -} - -async function saveAndroidDocument() { - clearAndroidDocumentSaveTimer() - - if (!editor) { - return true - } - - const value = getEditorMarkdownSnapshot(true) - const startResult = createSaveAndroidDocumentWorkflowStart({ - documentState: documentState.value, - markdown: value, - canWrite: currentAndroidDocumentCanWrite.value, - markdownSaveSettings: markdownSaveSettings.value, - }) - - if (startResult.kind === 'not-android-document' || startResult.kind === 'clean') { - return true - } - - if (startResult.kind === 'missing-source') { - status.value = startResult.status - androidDocumentLog.error('Android document save missing source URI', { - id: startResult.documentId, - }) - return false - } - - if (startResult.kind === 'read-only') { - status.value = startResult.status - androidDocumentLog.debug('skip Android document autosave without write access', { - id: startResult.documentId, - sourceUri: startResult.sourceUri, - }) - return startResult.saved - } - - if (androidSaveInFlight) { - androidSaveRequestedAfterCurrent = true - return false - } - - androidSaveInFlight = true - documentState.value = startResult.request.savingDocument - status.value = startResult.status - - try { - const result = await saveAndroidDocumentWorkflow({ - request: startResult.request, - getCurrentDocumentState: () => documentState.value, - writeAndroidMarkdownDocument, - getAndroidDocumentUserMessage, - recoveryMessage: ANDROID_SAVE_RECOVERY_MESSAGE, - logger: androidDocumentLog, - }) - - if (result.kind === 'saved') { - documentState.value = result.savedDocument - markAndroidRecentDocumentSaved(result.savedAt) - status.value = result.status - return true - } - - if (result.kind === 'changed-during-save') { - androidSaveRequestedAfterCurrent = true - return false - } - - documentState.value = result.failedDocument - if (canPersistAndroidRecoveryDrafts()) { - persistAndroidRecoveryDraft(result.recoveryDraft.sourceUri, result.recoveryDraft.markdown) - status.value = result.status - } else { - status.value = getAndroidDocumentUserMessage(result.error) - } - return false - } finally { - androidSaveInFlight = false - if (androidSaveRequestedAfterCurrent) { - androidSaveRequestedAfterCurrent = false - if ( - editor && - documentState.value.autosaveTarget === 'android-document' && - currentAndroidDocumentCanWrite.value - ) { - scheduleAndroidDocumentSave() - } - } - } -} - -async function saveLocalDraftToAndroidDocument(options: { returnHomeAfterSave?: boolean } = {}) { - closeEditorMenu() - - if (!editor || !isLocalDraftDocument() || savingLocalDraftToAndroid.value) { - return false - } - - saveDraft() - const draftDocument = syncDocumentFromEditor(false, true) - - const reopenPromptOnCancel = draftExitPromptOpen.value && options.returnHomeAfterSave === true - draftExitPromptOpen.value = false - savingLocalDraftToAndroid.value = true - status.value = 'Choose a location' - - const result = await saveLocalDraftToAndroidDocumentWorkflow({ - draftDocument, - returnHomeAfterSave: options.returnHomeAfterSave === true, - reopenPromptOnCancel, - transientAccessMessage: getTransientAndroidSaveMessage(), - createAndroidMarkdownDocument, - getAndroidDocumentUserMessage, - markdownSaveSettings: markdownSaveSettings.value, - logger: androidDocumentLog, - }) - - try { - if (result.kind === 'empty') { - status.value = result.status - return false - } - - if (result.kind === 'canceled') { - status.value = canPersistLocalDrafts() ? result.status : 'Edited' - if (reopenPromptOnCancel) { - draftExitPromptOpen.value = true - } - return false - } - - if (result.kind === 'transient') { - status.value = result.status - homeNotice.value = result.homeNotice - currentAndroidDocumentCanWrite.value = false - if (result.closeEditorToHome) { - closeEditorToHome() - } - return false - } - - if (result.kind === 'saved') { - persistLocalDrafts(removeLocalDraft(localDrafts.value, result.removeLocalDraftId)) - rememberAndroidDocument(result.createdDocument) - currentAndroidDocumentCanWrite.value = result.canWrite - promptLocalDraftSaveOnExit.value = false - documentState.value = result.savedDocument - status.value = getAndroidEditorStatus() - if (result.closeEditorToHome) { - closeEditorToHome() - } - return true - } - - documentState.value = result.failedDocument - status.value = result.status - if (result.reopenPrompt) { - draftExitPromptOpen.value = true - } - return false - } finally { - savingLocalDraftToAndroid.value = false - } -} - -async function saveAndroidDocumentCopy(options: { returnHomeAfterSave?: boolean } = {}) { - closeEditorMenu() - - if (!editor || !canSaveAndroidDocumentCopy() || savingAndroidDocumentCopy.value) { - return false - } - - const originalSourceUri = documentState.value.sourceUri - const copySourceDocument = syncDocumentFromEditor(documentState.value.isDirty, true) - - savingAndroidDocumentCopy.value = true - status.value = 'Choose a location' - - try { - const result = await saveAndroidDocumentCopyWorkflow({ - copySourceDocument, - originalSourceUri, - reservedDisplayNames: androidRecentDocuments.value.map(document => document.displayName), - returnHomeAfterSave: options.returnHomeAfterSave === true, - transientAccessMessage: getTransientAndroidSaveMessage(), - createAndroidMarkdownDocument, - getAndroidDocumentUserMessage, - markdownSaveSettings: markdownSaveSettings.value, - logger: androidDocumentLog, - }) - - if (result.kind === 'canceled') { - status.value = getAndroidEditorStatus() - return false - } - - if (result.kind === 'transient') { - if (result.recoveryDraft && canPersistAndroidRecoveryDrafts()) { - persistAndroidRecoveryDraft(result.recoveryDraft.sourceUri, result.recoveryDraft.markdown) - } - status.value = result.status - return false - } - - if (result.kind === 'saved') { - if (result.removeRecoveryDraftSourceUri) { - removeAndroidRecoveryDraft(result.removeRecoveryDraftSourceUri) - } - rememberAndroidDocument(result.createdDocument) - currentAndroidDocumentCanWrite.value = result.canWrite - promptLocalDraftSaveOnExit.value = false - documentState.value = result.savedDocument - status.value = getAndroidEditorStatus() - androidExitPromptOpen.value = false - if (result.closeEditorToHome) { - closeEditorToHome() - } - return true - } - - if (result.recoveryDraft && canPersistAndroidRecoveryDrafts()) { - persistAndroidRecoveryDraft(result.recoveryDraft.sourceUri, result.recoveryDraft.markdown) - } - documentState.value = result.failedDocument - status.value = result.status - return false - } finally { - savingAndroidDocumentCopy.value = false - } -} - -async function shareCurrentMarkdownDocument() { - closeEditorMenu() - - if (!editor || !canShareCurrentDocument() || sharingCurrentDocument.value) { - return false - } - - const currentDocument = syncDocumentFromEditor(documentState.value.isDirty, true) - if (currentDocument.autosaveTarget === 'local-draft') { - saveDraft() - } - - sharingCurrentDocument.value = true - status.value = 'Sharing' - - try { - const result = await shareAndroidMarkdownDocumentWorkflow({ - currentDocument, - shareAndroidMarkdownDocument, - getAndroidDocumentUserMessage, - imageSharingSettings: imageSharingSettings.value, - markdownSaveSettings: markdownSaveSettings.value, - logger: androidDocumentLog, - }) - - status.value = result.status - return result.kind === 'shared' - } finally { - sharingCurrentDocument.value = false - } -} - -function saveDraft() { - clearDraftSaveTimer() - - if (!editor) { - return - } - - if (documentState.value.autosaveTarget !== 'local-draft') { - androidDocumentLog.debug('skip local draft autosave for Android document', { - id: documentState.value.id, - sourceUri: documentState.value.sourceUri, - autosaveState: documentState.value.autosaveState, - }) - return - } - - if (!canPersistLocalDrafts()) { - clearDraftSaveTimer() - draftLog.debug('skip local draft save because local drafts are disabled', { - id: documentState.value.id, - }) - return - } - - const value = getEditorMarkdownSnapshot(true) - const localDraftAutosave = createLocalDraftAutosaveResult( - documentState.value, - value, - localDrafts.value, - ) - documentState.value = localDraftAutosave.savingDocument - - try { - persistLocalDrafts(localDraftAutosave.nextDrafts) - documentState.value = localDraftAutosave.savedDocument - status.value = localDraftAutosave.hasContent ? 'Autosaved locally' : 'Ready' - draftLog.debug('local draft saved', { - characters: characterCount.value, - words: wordCount.value, - lines: lineCount.value, - }) - } catch (error) { - documentState.value = markDocumentSaveFailed(documentState.value, error) - status.value = 'Autosave failed' - draftLog.error('local draft save failed', error) - } -} - +const { + persistAndroidRecoveryDraft, + removeAndroidRecoveryDraft, + saveDraft, + saveAndroidDocument, + saveLocalDraftToAndroidDocument, + saveAndroidDocumentCopy, + shareCurrentMarkdownDocument, + flushCurrentDocument, +} = createCurrentDocumentPersistence({ + currentScreen, + documentState, + status, + homeNotice, + localDrafts, + androidRecentDocuments, + currentAndroidDocumentCanWrite, + promptLocalDraftSaveOnExit, + draftExitPromptOpen, + androidExitPromptOpen, + savingLocalDraftToAndroid, + savingAndroidDocumentCopy, + sharingCurrentDocument, + hasEditor: () => Boolean(editor), + getEditorMarkdownSnapshot, + syncDocumentFromEditor, + closeEditorMenu, + closeEditorToHome, + isLocalDraftDocument, + canSaveAndroidDocumentCopy, + canShareCurrentDocument, + canPersistLocalDrafts, + canPersistAndroidRecoveryDrafts, + getTransientAndroidSaveMessage, + getAndroidEditorStatus, + getDraftLogStats: () => ({ + characters: characterCount.value, + words: wordCount.value, + lines: lineCount.value, + }), + persistLocalDrafts, + persistAndroidRecentDocuments, + rememberAndroidDocument, + clearDraftSaveTimer, + clearAndroidDocumentSaveTimer, + scheduleAndroidDocumentSave, + writeAndroidMarkdownDocument, + createAndroidMarkdownDocument, + shareAndroidMarkdownDocument, + getAndroidDocumentUserMessage, + markdownSaveSettings, + imageSharingSettings, + androidSaveRecoveryMessage: ANDROID_SAVE_RECOVERY_MESSAGE, + appLogger: appLog, + documentLogger: androidDocumentLog, + draftLogger: draftLog, +}) async function initEditor(initialMarkdown: string) { const element = editorElement.value if (!element) { @@ -1965,25 +1648,6 @@ function discardAndroidChangesAndShowHome() { closeEditorToHome() } -async function flushCurrentDocument(reason: string) { - if (currentScreen.value !== 'editor' || !editor) { - return - } - - appLog.info('flush current editor document', { - reason, - autosaveTarget: documentState.value.autosaveTarget, - isDirty: documentState.value.isDirty, - autosaveState: documentState.value.autosaveState, - }) - - if (documentState.value.autosaveTarget === 'android-document') { - await saveAndroidDocument() - } else { - saveDraft() - } -} - function requestLifecycleFlush(reason: string) { void flushCurrentDocument(reason) } diff --git a/src/features/document-session/currentDocumentPersistence.test.ts b/src/features/document-session/currentDocumentPersistence.test.ts new file mode 100644 index 0000000..93fccdf --- /dev/null +++ b/src/features/document-session/currentDocumentPersistence.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createCurrentDocumentPersistence } from './currentDocumentPersistence' +import type { AppScreen } from '../../lib/appExitDecisions' +import type { OpenedAndroidDocument } from '../../lib/androidDocuments' +import { + createUntitledDocument, + updateDocumentMarkdown, + type MarkdownDocumentState, +} from '../../lib/documentState' +import type { LocalDraftRecord } from '../../lib/localDrafts' +import type { RecentDocumentRecord } from '../../lib/recentDocuments' +import type { ImageSharingSettings } from '../android-documents/imageSharingSettings' +import { DEFAULT_MARKDOWN_SAVE_SETTINGS } from '../settings/advancedSettings' + +const noopLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } + +const ANDROID_URI = 'content://provider/original.md' + +function dirtyAndroidDocumentState(markdown = '# Original\n\nchanged') { + return updateDocumentMarkdown( + createUntitledDocument({ + markdown: '# Original', + displayName: 'Original.md', + sourceUri: ANDROID_URI, + autosaveTarget: 'android-document', + now: '2026-07-10T00:00:00.000Z', + }), + markdown, + { markDirty: true, now: '2026-07-10T00:01:00.000Z' }, + ) +} + +function androidRecentRecord(): RecentDocumentRecord { + return { + id: `android-document:${ANDROID_URI}`, + kind: 'android-document', + displayName: 'Original.md', + title: 'Original', + sourceUri: ANDROID_URI, + providerName: 'Documents', + pathHint: 'Original.md', + markdownPreview: null, + createdAt: '2026-07-10T00:00:00.000Z', + updatedAt: '2026-07-10T00:00:00.000Z', + lastOpenedAt: '2026-07-10T00:00:00.000Z', + lastSavedAt: null, + autosaveState: 'clean', + canWrite: true, + } +} + +const createdDocument: OpenedAndroidDocument = { + canceled: false, + sourceUri: 'content://provider/created.md', + displayName: 'created.md', + providerName: 'Documents', + pathHint: 'created.md', + mimeType: 'text/markdown', + markdown: '# Draft', + canWrite: true, + persisted: true, +} + +function createPersistence(overrides: { + documentState?: MarkdownDocumentState + drafts?: LocalDraftRecord[] + androidDocuments?: RecentDocumentRecord[] + hasEditor?: boolean + currentScreen?: AppScreen + canPersistLocalDrafts?: boolean + canPersistRecoveryDrafts?: boolean + markdownSnapshot?: string +} = {}) { + const localDrafts = ref(overrides.drafts ?? []) + const androidRecentDocuments = ref(overrides.androidDocuments ?? []) + const documentState = ref( + overrides.documentState ?? createUntitledDocument({ autosaveTarget: 'local-draft' }), + ) + + const options = { + currentScreen: ref(overrides.currentScreen ?? 'editor'), + documentState, + status: ref('Ready'), + homeNotice: ref(null), + localDrafts, + androidRecentDocuments, + currentAndroidDocumentCanWrite: ref(true), + promptLocalDraftSaveOnExit: ref(true), + draftExitPromptOpen: ref(false), + androidExitPromptOpen: ref(false), + savingLocalDraftToAndroid: ref(false), + savingAndroidDocumentCopy: ref(false), + sharingCurrentDocument: ref(false), + hasEditor: () => overrides.hasEditor ?? true, + getEditorMarkdownSnapshot: vi.fn( + () => overrides.markdownSnapshot ?? documentState.value.markdown, + ), + syncDocumentFromEditor: vi.fn(() => documentState.value), + closeEditorMenu: vi.fn(), + closeEditorToHome: vi.fn(), + isLocalDraftDocument: () => documentState.value.autosaveTarget === 'local-draft', + canSaveAndroidDocumentCopy: () => true, + canShareCurrentDocument: () => true, + canPersistLocalDrafts: () => overrides.canPersistLocalDrafts ?? true, + canPersistAndroidRecoveryDrafts: () => overrides.canPersistRecoveryDrafts ?? true, + getTransientAndroidSaveMessage: () => 'transient message', + getAndroidEditorStatus: () => 'Saved', + getDraftLogStats: () => ({ characters: 0, words: 0, lines: 0 }), + persistLocalDrafts: vi.fn((drafts: LocalDraftRecord[]) => { + localDrafts.value = drafts + }), + persistAndroidRecentDocuments: vi.fn((records: RecentDocumentRecord[]) => { + androidRecentDocuments.value = records + }), + rememberAndroidDocument: vi.fn(), + clearDraftSaveTimer: vi.fn(), + clearAndroidDocumentSaveTimer: vi.fn(), + scheduleAndroidDocumentSave: vi.fn(), + writeAndroidMarkdownDocument: vi.fn().mockResolvedValue(undefined), + createAndroidMarkdownDocument: vi.fn().mockResolvedValue(createdDocument), + shareAndroidMarkdownDocument: vi.fn().mockResolvedValue({ + displayName: 'x.md', + mimeType: 'text/markdown', + bytes: 1, + imageCount: 0, + sharedFileCount: 1, + }), + getAndroidDocumentUserMessage: (error: unknown) => + `user message: ${(error as Error)?.message ?? 'unknown'}`, + markdownSaveSettings: ref(DEFAULT_MARKDOWN_SAVE_SETTINGS), + imageSharingSettings: ref({ + shareImages: 'attach', + imageCopyImages: true, + } as ImageSharingSettings), + androidSaveRecoveryMessage: 'Save failed. A local recovery draft was kept.', + appLogger: noopLogger, + documentLogger: noopLogger, + draftLogger: noopLogger, + } + + return { persistence: createCurrentDocumentPersistence(options), options } +} + +describe('currentDocumentPersistence', () => { + it('autosaves a dirty local draft and reports the saved status', () => { + const dirtyDraft = updateDocumentMarkdown( + createUntitledDocument({ markdown: '', autosaveTarget: 'local-draft' }), + '# Draft body', + { markDirty: true }, + ) + const { persistence, options } = createPersistence({ documentState: dirtyDraft }) + + persistence.saveDraft() + + expect(options.clearDraftSaveTimer).toHaveBeenCalled() + expect(options.localDrafts.value[0]?.markdown).toBe('# Draft body') + expect(options.documentState.value.autosaveState).toBe('clean') + expect(options.status.value).toBe('Autosaved locally') + }) + + it('skips draft autosave for Android documents and when drafts are disabled', () => { + const android = createPersistence({ documentState: dirtyAndroidDocumentState() }) + android.persistence.saveDraft() + expect(android.options.persistLocalDrafts).not.toHaveBeenCalled() + + const disabled = createPersistence({ canPersistLocalDrafts: false }) + disabled.persistence.saveDraft() + expect(disabled.options.persistLocalDrafts).not.toHaveBeenCalled() + }) + + it('saves a dirty Android document, updates recents, and drops its recovery draft', async () => { + const recoveryDraft: LocalDraftRecord = { + id: `android-recovery:${ANDROID_URI}`, + markdown: '# stale recovery', + createdAt: '2026-07-10T00:00:00.000Z', + updatedAt: '2026-07-10T00:00:00.000Z', + lastSavedAt: null, + } + const { persistence, options } = createPersistence({ + documentState: dirtyAndroidDocumentState(), + drafts: [recoveryDraft], + androidDocuments: [androidRecentRecord()], + }) + + const saved = await persistence.saveAndroidDocument() + + expect(saved).toBe(true) + expect(options.writeAndroidMarkdownDocument).toHaveBeenCalledTimes(1) + expect(options.documentState.value.isDirty).toBe(false) + expect(options.androidRecentDocuments.value[0].lastSavedAt).not.toBeNull() + expect(options.localDrafts.value).toEqual([]) + }) + + it('keeps a recovery draft and the recovery status when the Android save fails', async () => { + const { persistence, options } = createPersistence({ + documentState: dirtyAndroidDocumentState(), + androidDocuments: [androidRecentRecord()], + }) + options.writeAndroidMarkdownDocument.mockRejectedValue(new Error('disk full')) + + const saved = await persistence.saveAndroidDocument() + + expect(saved).toBe(false) + expect( + options.localDrafts.value.some(draft => draft.id === `android-recovery:${ANDROID_URI}`), + ).toBe(true) + // The workflow prefixes the user-facing failure before the recovery note. + expect(options.status.value).toBe( + 'user message: disk full Save failed. A local recovery draft was kept.', + ) + }) + + it('falls back to the user message when recovery drafts are disabled', async () => { + const { persistence, options } = createPersistence({ + documentState: dirtyAndroidDocumentState(), + androidDocuments: [androidRecentRecord()], + canPersistRecoveryDrafts: false, + }) + options.writeAndroidMarkdownDocument.mockRejectedValue(new Error('disk full')) + + await persistence.saveAndroidDocument() + + expect(options.localDrafts.value).toEqual([]) + expect(options.status.value).toBe('user message: disk full') + }) + + it('coalesces overlapping Android saves and reschedules once the first completes', async () => { + const { persistence, options } = createPersistence({ + documentState: dirtyAndroidDocumentState(), + androidDocuments: [androidRecentRecord()], + }) + let releaseWrite!: () => void + options.writeAndroidMarkdownDocument.mockImplementation( + () => new Promise(resolve => (releaseWrite = () => resolve())), + ) + + const first = persistence.saveAndroidDocument() + const second = await persistence.saveAndroidDocument() + expect(second).toBe(false) + expect(options.writeAndroidMarkdownDocument).toHaveBeenCalledTimes(1) + + // Keep the document dirty for the rescheduling check on completion. + options.documentState.value = dirtyAndroidDocumentState('# even newer') + releaseWrite() + await first + + expect(options.scheduleAndroidDocumentSave).toHaveBeenCalledTimes(1) + }) + + it('flushes to the save path matching the current autosave target', async () => { + const android = createPersistence({ documentState: dirtyAndroidDocumentState() }) + await android.persistence.flushCurrentDocument('test') + expect(android.options.writeAndroidMarkdownDocument).toHaveBeenCalled() + + const home = createPersistence({ currentScreen: 'home' }) + await home.persistence.flushCurrentDocument('test') + expect(home.options.clearDraftSaveTimer).not.toHaveBeenCalled() + }) + + it('saves a local draft to the device and cleans up the draft copy', async () => { + const dirtyDraft = updateDocumentMarkdown( + createUntitledDocument({ markdown: '', autosaveTarget: 'local-draft' }), + '# Draft body', + { markDirty: true }, + ) + const { persistence, options } = createPersistence({ documentState: dirtyDraft }) + + const saved = await persistence.saveLocalDraftToAndroidDocument({ returnHomeAfterSave: true }) + + expect(saved).toBe(true) + expect(options.rememberAndroidDocument).toHaveBeenCalledWith( + expect.objectContaining({ sourceUri: 'content://provider/created.md' }), + ) + expect(options.localDrafts.value).toEqual([]) + expect(options.promptLocalDraftSaveOnExit.value).toBe(false) + expect(options.documentState.value.autosaveTarget).toBe('android-document') + expect(options.closeEditorToHome).toHaveBeenCalled() + expect(options.savingLocalDraftToAndroid.value).toBe(false) + }) + + it('reopens the exit prompt when a return-home draft save is canceled', async () => { + const dirtyDraft = updateDocumentMarkdown( + createUntitledDocument({ markdown: '', autosaveTarget: 'local-draft' }), + '# Draft body', + { markDirty: true }, + ) + const { persistence, options } = createPersistence({ documentState: dirtyDraft }) + options.draftExitPromptOpen.value = true + options.createAndroidMarkdownDocument.mockResolvedValue({ canceled: true }) + + const saved = await persistence.saveLocalDraftToAndroidDocument({ returnHomeAfterSave: true }) + + expect(saved).toBe(false) + expect(options.draftExitPromptOpen.value).toBe(true) + }) + + it('saves a copy, clears the exit prompt, and removes the original recovery draft', async () => { + const recoveryDraft: LocalDraftRecord = { + id: `android-recovery:${ANDROID_URI}`, + markdown: '# stale recovery', + createdAt: '2026-07-10T00:00:00.000Z', + updatedAt: '2026-07-10T00:00:00.000Z', + lastSavedAt: null, + } + const { persistence, options } = createPersistence({ + documentState: dirtyAndroidDocumentState(), + drafts: [recoveryDraft], + androidDocuments: [androidRecentRecord()], + }) + options.androidExitPromptOpen.value = true + + const saved = await persistence.saveAndroidDocumentCopy() + + expect(saved).toBe(true) + expect(options.rememberAndroidDocument).toHaveBeenCalledWith( + expect.objectContaining({ sourceUri: 'content://provider/created.md' }), + ) + expect(options.androidExitPromptOpen.value).toBe(false) + expect(options.localDrafts.value).toEqual([]) + }) + + it('shares the current document and restores the busy flag', async () => { + const { persistence, options } = createPersistence({ + documentState: dirtyAndroidDocumentState(), + }) + + const shared = await persistence.shareCurrentMarkdownDocument() + + expect(shared).toBe(true) + expect(options.status.value).toBe('Share sheet opened') + expect(options.sharingCurrentDocument.value).toBe(false) + }) + + it('refuses to share while a share is already in flight', async () => { + const { persistence, options } = createPersistence({ + documentState: dirtyAndroidDocumentState(), + }) + options.sharingCurrentDocument.value = true + + const shared = await persistence.shareCurrentMarkdownDocument() + + expect(shared).toBe(false) + expect(options.shareAndroidMarkdownDocument).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/document-session/currentDocumentPersistence.ts b/src/features/document-session/currentDocumentPersistence.ts new file mode 100644 index 0000000..28f35a3 --- /dev/null +++ b/src/features/document-session/currentDocumentPersistence.ts @@ -0,0 +1,540 @@ +import type { Ref } from 'vue' +import { + createSaveAndroidDocumentWorkflowStart, + saveAndroidDocumentWorkflow, +} from '../android-documents/saveAndroidDocumentWorkflow' +import { saveAndroidDocumentCopyWorkflow } from '../android-documents/saveAndroidDocumentCopyWorkflow' +import { saveLocalDraftToAndroidDocumentWorkflow } from '../android-documents/saveLocalDraftToAndroidDocumentWorkflow' +import { shareAndroidMarkdownDocumentWorkflow } from '../android-documents/shareAndroidMarkdownDocumentWorkflow' +import { + createAndroidRecoveryDraft, + getAndroidRecoveryDraftId, +} from '../android-documents/androidRecoveryDrafts' +import { markSavedAndroidRecentDocument } from '../android-documents/androidRecentDocuments' +import type { ImageSharingSettings } from '../android-documents/imageSharingSettings' +import { createLocalDraftAutosaveResult } from '../local-drafts/localDraftAutosave' +import type { MarkdownSaveSettings } from '../settings/advancedSettings' +import type { + AndroidShareResult, + OpenedAndroidDocument, +} from '../../lib/androidDocuments' +import type { AppScreen } from '../../lib/appExitDecisions' +import { + markDocumentSaveFailed, + type MarkdownDocumentState, +} from '../../lib/documentState' +import { + removeLocalDraft, + upsertLocalDraft, + type LocalDraftRecord, +} from '../../lib/localDrafts' +import type { RecentDocumentRecord } from '../../lib/recentDocuments' + +interface PersistenceLogger { + debug(message: string, context?: unknown): void + info(message: string, context?: unknown): void + warn(message: string, context?: unknown): void + error(message: string, context?: unknown): void +} + +type CreateAndroidMarkdownDocument = Parameters< + typeof saveLocalDraftToAndroidDocumentWorkflow +>[0]['createAndroidMarkdownDocument'] + +type WriteAndroidMarkdownDocument = Parameters< + typeof saveAndroidDocumentWorkflow +>[0]['writeAndroidMarkdownDocument'] + +export interface CurrentDocumentPersistenceOptions { + // App-owned state the persistence flows read and write. + currentScreen: Ref + documentState: Ref + status: Ref + homeNotice: Ref + localDrafts: Ref + androidRecentDocuments: Ref + currentAndroidDocumentCanWrite: Ref + promptLocalDraftSaveOnExit: Ref + draftExitPromptOpen: Ref + androidExitPromptOpen: Ref + savingLocalDraftToAndroid: Ref + savingAndroidDocumentCopy: Ref + sharingCurrentDocument: Ref + // Editor access and chrome, owned by App. + hasEditor: () => boolean + getEditorMarkdownSnapshot: (flushPending?: boolean) => string + syncDocumentFromEditor: (markDirty: boolean, flushPending: boolean) => MarkdownDocumentState + closeEditorMenu: () => void + closeEditorToHome: () => void + // Predicates and presentation helpers that stay with App state. + isLocalDraftDocument: () => boolean + canSaveAndroidDocumentCopy: () => boolean + canShareCurrentDocument: () => boolean + canPersistLocalDrafts: () => boolean + canPersistAndroidRecoveryDrafts: () => boolean + getTransientAndroidSaveMessage: () => string + getAndroidEditorStatus: () => string + getDraftLogStats: () => { characters: number, words: number, lines: number } + // Store persistence and recents bookkeeping owned by App. + persistLocalDrafts: (drafts: LocalDraftRecord[]) => void + persistAndroidRecentDocuments: (records: RecentDocumentRecord[]) => void + rememberAndroidDocument: (document: OpenedAndroidDocument) => void + // Autosave scheduler handles. + clearDraftSaveTimer: () => void + clearAndroidDocumentSaveTimer: () => void + scheduleAndroidDocumentSave: () => void + // Native adapters. + writeAndroidMarkdownDocument: WriteAndroidMarkdownDocument + createAndroidMarkdownDocument: CreateAndroidMarkdownDocument + shareAndroidMarkdownDocument: ( + markdown: string, + suggestedName: string, + options: { attachImages: boolean, encoding: MarkdownSaveSettings['encoding'] }, + ) => Promise + getAndroidDocumentUserMessage: (error: unknown) => string + // Settings and messages. + markdownSaveSettings: Ref + imageSharingSettings: Ref + androidSaveRecoveryMessage: string + appLogger: PersistenceLogger + documentLogger: PersistenceLogger + draftLogger: PersistenceLogger +} + +export interface CurrentDocumentPersistence { + persistAndroidRecoveryDraft(sourceUri: string, markdown: string): void + removeAndroidRecoveryDraft(sourceUri: string): void + saveDraft(): void + saveAndroidDocument(): Promise + saveLocalDraftToAndroidDocument(options?: { returnHomeAfterSave?: boolean }): Promise + saveAndroidDocumentCopy(options?: { returnHomeAfterSave?: boolean }): Promise + shareCurrentMarkdownDocument(): Promise + flushCurrentDocument(reason: string): Promise +} + +/** + * Orchestrates every way the current editor document is persisted or exported: + * local-draft autosave, Android document save with in-flight coalescing, + * draft-to-device save, save-a-copy, sharing, lifecycle flushes, and the + * recovery-draft and recents bookkeeping around them. The save/share workflows + * and document-state transitions are reused unchanged. + */ +export function createCurrentDocumentPersistence( + options: CurrentDocumentPersistenceOptions, +): CurrentDocumentPersistence { + let androidSaveInFlight = false + let androidSaveRequestedAfterCurrent = false + + function persistAndroidRecoveryDraft(sourceUri: string, markdown: string) { + if (!options.canPersistAndroidRecoveryDrafts()) { + options.documentLogger.warn('skip Android recovery draft because recovery drafts are disabled', { + sourceUri, + characters: markdown.length, + }) + return + } + + const recoveryDraft = createAndroidRecoveryDraft(sourceUri, markdown, new Date().toISOString()) + if (!recoveryDraft) { + return + } + + options.persistLocalDrafts(upsertLocalDraft(options.localDrafts.value, recoveryDraft)) + options.draftLogger.warn('kept Android document edits as a local recovery draft', { + sourceUri, + characters: markdown.length, + }) + } + + function removeAndroidRecoveryDraft(sourceUri: string) { + const recoveryDraftId = getAndroidRecoveryDraftId(sourceUri) + if (options.localDrafts.value.some(draft => draft.id === recoveryDraftId)) { + options.persistLocalDrafts(removeLocalDraft(options.localDrafts.value, recoveryDraftId)) + } + } + + function markAndroidRecentDocumentSaved(savedAt: string) { + const sourceUri = options.documentState.value.sourceUri + if (!sourceUri) { + return + } + + const nextDocuments = markSavedAndroidRecentDocument( + options.androidRecentDocuments.value, + sourceUri, + { + markdown: options.documentState.value.markdown, + savedAt, + canWrite: options.currentAndroidDocumentCanWrite.value, + }, + ) + if (!nextDocuments) { + options.documentLogger.warn('saved Android recent document not found', { sourceUri }) + return + } + + options.persistAndroidRecentDocuments(nextDocuments) + removeAndroidRecoveryDraft(sourceUri) + } + + function saveDraft() { + options.clearDraftSaveTimer() + + if (!options.hasEditor()) { + return + } + + if (options.documentState.value.autosaveTarget !== 'local-draft') { + options.documentLogger.debug('skip local draft autosave for Android document', { + id: options.documentState.value.id, + sourceUri: options.documentState.value.sourceUri, + autosaveState: options.documentState.value.autosaveState, + }) + return + } + + if (!options.canPersistLocalDrafts()) { + options.clearDraftSaveTimer() + options.draftLogger.debug('skip local draft save because local drafts are disabled', { + id: options.documentState.value.id, + }) + return + } + + const value = options.getEditorMarkdownSnapshot(true) + const localDraftAutosave = createLocalDraftAutosaveResult( + options.documentState.value, + value, + options.localDrafts.value, + ) + options.documentState.value = localDraftAutosave.savingDocument + + try { + options.persistLocalDrafts(localDraftAutosave.nextDrafts) + options.documentState.value = localDraftAutosave.savedDocument + options.status.value = localDraftAutosave.hasContent ? 'Autosaved locally' : 'Ready' + options.draftLogger.debug('local draft saved', options.getDraftLogStats()) + } catch (error) { + options.documentState.value = markDocumentSaveFailed(options.documentState.value, error) + options.status.value = 'Autosave failed' + options.draftLogger.error('local draft save failed', error) + } + } + + async function saveAndroidDocument() { + options.clearAndroidDocumentSaveTimer() + + if (!options.hasEditor()) { + return true + } + + const value = options.getEditorMarkdownSnapshot(true) + const startResult = createSaveAndroidDocumentWorkflowStart({ + documentState: options.documentState.value, + markdown: value, + canWrite: options.currentAndroidDocumentCanWrite.value, + markdownSaveSettings: options.markdownSaveSettings.value, + }) + + if (startResult.kind === 'not-android-document' || startResult.kind === 'clean') { + return true + } + + if (startResult.kind === 'missing-source') { + options.status.value = startResult.status + options.documentLogger.error('Android document save missing source URI', { + id: startResult.documentId, + }) + return false + } + + if (startResult.kind === 'read-only') { + options.status.value = startResult.status + options.documentLogger.debug('skip Android document autosave without write access', { + id: startResult.documentId, + sourceUri: startResult.sourceUri, + }) + return startResult.saved + } + + if (androidSaveInFlight) { + androidSaveRequestedAfterCurrent = true + return false + } + + androidSaveInFlight = true + options.documentState.value = startResult.request.savingDocument + options.status.value = startResult.status + + try { + const result = await saveAndroidDocumentWorkflow({ + request: startResult.request, + getCurrentDocumentState: () => options.documentState.value, + writeAndroidMarkdownDocument: options.writeAndroidMarkdownDocument, + getAndroidDocumentUserMessage: options.getAndroidDocumentUserMessage, + recoveryMessage: options.androidSaveRecoveryMessage, + logger: options.documentLogger, + }) + + if (result.kind === 'saved') { + options.documentState.value = result.savedDocument + markAndroidRecentDocumentSaved(result.savedAt) + options.status.value = result.status + return true + } + + if (result.kind === 'changed-during-save') { + androidSaveRequestedAfterCurrent = true + return false + } + + options.documentState.value = result.failedDocument + if (options.canPersistAndroidRecoveryDrafts()) { + persistAndroidRecoveryDraft(result.recoveryDraft.sourceUri, result.recoveryDraft.markdown) + options.status.value = result.status + } else { + options.status.value = options.getAndroidDocumentUserMessage(result.error) + } + return false + } finally { + androidSaveInFlight = false + if (androidSaveRequestedAfterCurrent) { + androidSaveRequestedAfterCurrent = false + if ( + options.hasEditor() + && options.documentState.value.autosaveTarget === 'android-document' + && options.currentAndroidDocumentCanWrite.value + ) { + options.scheduleAndroidDocumentSave() + } + } + } + } + + async function saveLocalDraftToAndroidDocument( + saveOptions: { returnHomeAfterSave?: boolean } = {}, + ) { + options.closeEditorMenu() + + if ( + !options.hasEditor() + || !options.isLocalDraftDocument() + || options.savingLocalDraftToAndroid.value + ) { + return false + } + + saveDraft() + const draftDocument = options.syncDocumentFromEditor(false, true) + + const reopenPromptOnCancel + = options.draftExitPromptOpen.value && saveOptions.returnHomeAfterSave === true + options.draftExitPromptOpen.value = false + options.savingLocalDraftToAndroid.value = true + options.status.value = 'Choose a location' + + const result = await saveLocalDraftToAndroidDocumentWorkflow({ + draftDocument, + returnHomeAfterSave: saveOptions.returnHomeAfterSave === true, + reopenPromptOnCancel, + transientAccessMessage: options.getTransientAndroidSaveMessage(), + createAndroidMarkdownDocument: options.createAndroidMarkdownDocument, + getAndroidDocumentUserMessage: options.getAndroidDocumentUserMessage, + markdownSaveSettings: options.markdownSaveSettings.value, + logger: options.documentLogger, + }) + + try { + if (result.kind === 'empty') { + options.status.value = result.status + return false + } + + if (result.kind === 'canceled') { + options.status.value = options.canPersistLocalDrafts() ? result.status : 'Edited' + if (reopenPromptOnCancel) { + options.draftExitPromptOpen.value = true + } + return false + } + + if (result.kind === 'transient') { + options.status.value = result.status + options.homeNotice.value = result.homeNotice + options.currentAndroidDocumentCanWrite.value = false + if (result.closeEditorToHome) { + options.closeEditorToHome() + } + return false + } + + if (result.kind === 'saved') { + options.persistLocalDrafts( + removeLocalDraft(options.localDrafts.value, result.removeLocalDraftId), + ) + options.rememberAndroidDocument(result.createdDocument) + options.currentAndroidDocumentCanWrite.value = result.canWrite + options.promptLocalDraftSaveOnExit.value = false + options.documentState.value = result.savedDocument + options.status.value = options.getAndroidEditorStatus() + if (result.closeEditorToHome) { + options.closeEditorToHome() + } + return true + } + + options.documentState.value = result.failedDocument + options.status.value = result.status + if (result.reopenPrompt) { + options.draftExitPromptOpen.value = true + } + return false + } finally { + options.savingLocalDraftToAndroid.value = false + } + } + + async function saveAndroidDocumentCopy(saveOptions: { returnHomeAfterSave?: boolean } = {}) { + options.closeEditorMenu() + + if ( + !options.hasEditor() + || !options.canSaveAndroidDocumentCopy() + || options.savingAndroidDocumentCopy.value + ) { + return false + } + + const originalSourceUri = options.documentState.value.sourceUri + const copySourceDocument = options.syncDocumentFromEditor( + options.documentState.value.isDirty, + true, + ) + + options.savingAndroidDocumentCopy.value = true + options.status.value = 'Choose a location' + + try { + const result = await saveAndroidDocumentCopyWorkflow({ + copySourceDocument, + originalSourceUri, + reservedDisplayNames: options.androidRecentDocuments.value.map( + document => document.displayName, + ), + returnHomeAfterSave: saveOptions.returnHomeAfterSave === true, + transientAccessMessage: options.getTransientAndroidSaveMessage(), + createAndroidMarkdownDocument: options.createAndroidMarkdownDocument, + getAndroidDocumentUserMessage: options.getAndroidDocumentUserMessage, + markdownSaveSettings: options.markdownSaveSettings.value, + logger: options.documentLogger, + }) + + if (result.kind === 'canceled') { + options.status.value = options.getAndroidEditorStatus() + return false + } + + if (result.kind === 'transient') { + if (result.recoveryDraft && options.canPersistAndroidRecoveryDrafts()) { + persistAndroidRecoveryDraft(result.recoveryDraft.sourceUri, result.recoveryDraft.markdown) + } + options.status.value = result.status + return false + } + + if (result.kind === 'saved') { + if (result.removeRecoveryDraftSourceUri) { + removeAndroidRecoveryDraft(result.removeRecoveryDraftSourceUri) + } + options.rememberAndroidDocument(result.createdDocument) + options.currentAndroidDocumentCanWrite.value = result.canWrite + options.promptLocalDraftSaveOnExit.value = false + options.documentState.value = result.savedDocument + options.status.value = options.getAndroidEditorStatus() + options.androidExitPromptOpen.value = false + if (result.closeEditorToHome) { + options.closeEditorToHome() + } + return true + } + + if (result.recoveryDraft && options.canPersistAndroidRecoveryDrafts()) { + persistAndroidRecoveryDraft(result.recoveryDraft.sourceUri, result.recoveryDraft.markdown) + } + options.documentState.value = result.failedDocument + options.status.value = result.status + return false + } finally { + options.savingAndroidDocumentCopy.value = false + } + } + + async function shareCurrentMarkdownDocument() { + options.closeEditorMenu() + + if ( + !options.hasEditor() + || !options.canShareCurrentDocument() + || options.sharingCurrentDocument.value + ) { + return false + } + + const currentDocument = options.syncDocumentFromEditor( + options.documentState.value.isDirty, + true, + ) + if (currentDocument.autosaveTarget === 'local-draft') { + saveDraft() + } + + options.sharingCurrentDocument.value = true + options.status.value = 'Sharing' + + try { + const result = await shareAndroidMarkdownDocumentWorkflow({ + currentDocument, + shareAndroidMarkdownDocument: options.shareAndroidMarkdownDocument, + getAndroidDocumentUserMessage: options.getAndroidDocumentUserMessage, + imageSharingSettings: options.imageSharingSettings.value, + markdownSaveSettings: options.markdownSaveSettings.value, + logger: options.documentLogger, + }) + + options.status.value = result.status + return result.kind === 'shared' + } finally { + options.sharingCurrentDocument.value = false + } + } + + async function flushCurrentDocument(reason: string) { + if (options.currentScreen.value !== 'editor' || !options.hasEditor()) { + return + } + + options.appLogger.info('flush current editor document', { + reason, + autosaveTarget: options.documentState.value.autosaveTarget, + isDirty: options.documentState.value.isDirty, + autosaveState: options.documentState.value.autosaveState, + }) + + if (options.documentState.value.autosaveTarget === 'android-document') { + await saveAndroidDocument() + } else { + saveDraft() + } + } + + return { + persistAndroidRecoveryDraft, + removeAndroidRecoveryDraft, + saveDraft, + saveAndroidDocument, + saveLocalDraftToAndroidDocument, + saveAndroidDocumentCopy, + shareCurrentMarkdownDocument, + flushCurrentDocument, + } +}