diff --git a/assets/i18n/bg.json b/assets/i18n/bg.json index 9fa706dc..bdd64833 100644 --- a/assets/i18n/bg.json +++ b/assets/i18n/bg.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Сгъваем блок за съдържанието", "No headings in the selected range.": "Няма заглавия в избрания диапазон.", "Copy entire document as Markdown": "Копиране на целия документ като Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Копиране на целия документ като Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Копиране на целия документ като Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Възстановяването и връщането към предишното състояние са неуспешни. Редактирането остава блокирано; презаредете, преди да продължите." } diff --git a/assets/i18n/de.json b/assets/i18n/de.json index 533e2f72..7f9ee837 100644 --- a/assets/i18n/de.json +++ b/assets/i18n/de.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Einklappbares Inhaltsverzeichnis", "No headings in the selected range.": "Keine Überschriften im ausgewählten Bereich.", "Copy entire document as Markdown": "Gesamtes Dokument als Markdown kopieren", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Gesamtes Dokument als Markdown kopieren (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Gesamtes Dokument als Markdown kopieren (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Wiederherstellung und Rücksetzung sind fehlgeschlagen. Die Bearbeitung bleibt gesperrt; laden Sie die Anwendung neu, bevor Sie fortfahren." } diff --git a/assets/i18n/en.json b/assets/i18n/en.json index e658ac55..42aa5cef 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Collapsible TOC wrapper", "No headings in the selected range.": "No headings in the selected range.", "Copy entire document as Markdown": "Copy entire document as Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Restore and rollback failed. Editing remains blocked; reload before continuing." } diff --git a/assets/i18n/es.json b/assets/i18n/es.json index 1fc747fa..0c768888 100644 --- a/assets/i18n/es.json +++ b/assets/i18n/es.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Tabla de contenido plegable", "No headings in the selected range.": "No hay encabezados en el rango seleccionado.", "Copy entire document as Markdown": "Copiar todo el documento como Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copiar todo el documento como Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copiar todo el documento como Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "La restauración y la reversión fallaron. La edición sigue bloqueada; recarga la aplicación antes de continuar." } diff --git a/assets/i18n/fr.json b/assets/i18n/fr.json index 7c1df13f..ad32c39e 100644 --- a/assets/i18n/fr.json +++ b/assets/i18n/fr.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Table des matières repliable", "No headings in the selected range.": "Aucun titre dans la plage sélectionnée.", "Copy entire document as Markdown": "Copier tout le document au format Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copier tout le document au format Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copier tout le document au format Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "La restauration et le retour à l’état précédent ont échoué. La modification reste bloquée ; rechargez l’application avant de continuer." } diff --git a/assets/i18n/it.json b/assets/i18n/it.json index e36ea04d..2d6aa7b3 100644 --- a/assets/i18n/it.json +++ b/assets/i18n/it.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Sommario comprimibile", "No headings in the selected range.": "Nessun titolo nell’intervallo selezionato.", "Copy entire document as Markdown": "Copia l’intero documento come Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copia l’intero documento come Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copia l’intero documento come Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Il ripristino e il ritorno allo stato precedente non sono riusciti. La modifica rimane bloccata; ricarica l’applicazione prima di continuare." } diff --git a/assets/i18n/ja.json b/assets/i18n/ja.json index bebb5c02..c58ea8f5 100644 --- a/assets/i18n/ja.json +++ b/assets/i18n/ja.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "折りたたみ可能な目次", "No headings in the selected range.": "選択した範囲に見出しがありません。", "Copy entire document as Markdown": "ドキュメント全体をMarkdownとしてコピー", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "ドキュメント全体をMarkdownとしてコピー (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "ドキュメント全体をMarkdownとしてコピー (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "復元と以前の状態へのロールバックに失敗しました。編集は引き続きブロックされています。続行する前にアプリを再読み込みしてください。" } diff --git a/assets/i18n/ko.json b/assets/i18n/ko.json index 2b1a7da3..bef21102 100644 --- a/assets/i18n/ko.json +++ b/assets/i18n/ko.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "접을 수 있는 목차", "No headings in the selected range.": "선택한 범위에 제목이 없습니다.", "Copy entire document as Markdown": "전체 문서를 Markdown으로 복사", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "전체 문서를 Markdown으로 복사 (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "전체 문서를 Markdown으로 복사 (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "복원과 이전 상태로의 되돌리기가 실패했습니다. 편집이 계속 차단됩니다. 계속하기 전에 앱을 새로고침하세요." } diff --git a/assets/i18n/pl.json b/assets/i18n/pl.json index bafc8513..ff02adbb 100644 --- a/assets/i18n/pl.json +++ b/assets/i18n/pl.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Zwijany spis treści", "No headings in the selected range.": "Brak nagłówków w wybranym zakresie.", "Copy entire document as Markdown": "Kopiuj cały dokument jako Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Kopiuj cały dokument jako Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Kopiuj cały dokument jako Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Przywracanie i powrót do poprzedniego stanu nie powiodły się. Edycja pozostaje zablokowana; odśwież aplikację przed kontynuowaniem." } diff --git a/assets/i18n/pt.json b/assets/i18n/pt.json index 9cb78c4f..d6cae46f 100644 --- a/assets/i18n/pt.json +++ b/assets/i18n/pt.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Sumário recolhível", "No headings in the selected range.": "Nenhum título no intervalo selecionado.", "Copy entire document as Markdown": "Copiar todo o documento como Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copiar todo o documento como Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copiar todo o documento como Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "A restauração e a reversão falharam. A edição permanece bloqueada; recarregue o aplicativo antes de continuar." } diff --git a/assets/i18n/ru.json b/assets/i18n/ru.json index fe777e89..2c0d25ed 100644 --- a/assets/i18n/ru.json +++ b/assets/i18n/ru.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Сворачиваемое оглавление", "No headings in the selected range.": "В выбранном диапазоне нет заголовков.", "Copy entire document as Markdown": "Копировать весь документ как Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Копировать весь документ как Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Копировать весь документ как Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Восстановление и откат не удались. Редактирование остаётся заблокированным; перезагрузите приложение перед продолжением." } diff --git a/assets/i18n/tr.json b/assets/i18n/tr.json index 5be7e9e5..9fbd17cc 100644 --- a/assets/i18n/tr.json +++ b/assets/i18n/tr.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Daraltılabilir içindekiler tablosu", "No headings in the selected range.": "Seçilen aralıkta başlık yok.", "Copy entire document as Markdown": "Belgenin tamamını Markdown olarak kopyala", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Belgenin tamamını Markdown olarak kopyala (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Belgenin tamamını Markdown olarak kopyala (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Geri yükleme ve önceki duruma dönüş başarısız oldu. Düzenleme engellenmeye devam ediyor; devam etmeden önce uygulamayı yeniden yükleyin." } diff --git a/assets/i18n/tw.json b/assets/i18n/tw.json index 753cea98..a303335e 100644 --- a/assets/i18n/tw.json +++ b/assets/i18n/tw.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "可摺疊目錄", "No headings in the selected range.": "所選範圍內沒有標題。", "Copy entire document as Markdown": "將整份文件複製為 Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "將整份文件複製為 Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "將整份文件複製為 Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "還原及回復至先前狀態均失敗。編輯仍被封鎖;請重新載入應用程式後再繼續。" } diff --git a/assets/i18n/uk.json b/assets/i18n/uk.json index 9b4e46c9..b2abdccc 100644 --- a/assets/i18n/uk.json +++ b/assets/i18n/uk.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Згортаний зміст", "No headings in the selected range.": "У вибраному діапазоні немає заголовків.", "Copy entire document as Markdown": "Копіювати весь документ як Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Копіювати весь документ як Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Копіювати весь документ як Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Відновлення та повернення до попереднього стану не вдалися. Редагування залишається заблокованим; перезавантажте застосунок, перш ніж продовжити." } diff --git a/assets/i18n/zh.json b/assets/i18n/zh.json index 5e155994..b9919509 100644 --- a/assets/i18n/zh.json +++ b/assets/i18n/zh.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "可折叠目录", "No headings in the selected range.": "所选范围内没有标题。", "Copy entire document as Markdown": "将整个文档复制为 Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "将整个文档复制为 Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "将整个文档复制为 Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "恢复及回滚到先前状态均失败。编辑仍被阻止;请重新加载应用程序后再继续。" } diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index 6901db2b..683c1f45 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -32,10 +32,10 @@ - + - +
diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index dd54b8e8..af11b098 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -2098,18 +2098,12 @@ document.addEventListener("DOMContentLoaded", async function () { detail: 'Validating backup…' }); let restoreCommitted = false; + let maintenanceStarted = false; let preferenceSnapshot = null; try { const backup = await parseWorkspaceBackup(input); - suspendWorkspacePersistence = true; - clearTimeout(saveTabStateTimeout); - clearTimeout(secretWorkspaceSaveTimeout); - await Promise.all([ - workspacePersistenceChain.catch(function() {}), - secretWorkspaceSaveChain.catch(function() {}), - secretDirtyJournalChain.catch(function() {}), - organizationPersistenceChain.catch(function() {}) - ]); + await beginWorkspaceMaintenance(); + maintenanceStarted = true; preferenceSnapshot = captureApplicationPreferences(); try { await replaceApplicationPreferences(backup.preferences); @@ -2143,7 +2137,7 @@ document.addEventListener("DOMContentLoaded", async function () { }); window.setTimeout(function() { window.location.reload(); }, 1100); } catch (error) { - if (!restoreCommitted) suspendWorkspacePersistence = false; + if (!restoreCommitted && maintenanceStarted && !error.rollbackError) endWorkspaceMaintenance(); console.error('Workspace backup import failed:', error); if (restoreCommitted) { finishImportProgress(100, 100, { @@ -2158,7 +2152,8 @@ document.addEventListener("DOMContentLoaded", async function () { success: false, title: 'Import failed', countText: 'Workspace not restored', - detail: error && error.message ? error.message : 'The backup could not be imported.' + detail: error.rollbackError ? 'Restore and rollback failed. Editing remains blocked; reload before continuing.' : + (error && error.message ? error.message : 'The backup could not be imported.') }); } } @@ -4262,6 +4257,8 @@ document.addEventListener("DOMContentLoaded", async function () { let pendingWorkspacePersistAll = false; let pendingWorkspacePersistIds = new Set(); let suspendWorkspacePersistence = false; + let workspaceMaintenancePending = false; + let workspaceMaintenancePreviousInert = false; let secretWorkspaceEnvelopeCache = null; const LIVE_EDIT_ORIGIN = Symbol("markdown-viewer-live-local-edit"); const LIVE_RELAY_ORIGIN = Symbol("markdown-viewer-live-relay"); @@ -8346,6 +8343,12 @@ document.addEventListener("DOMContentLoaded", async function () { function persistReviewThreads(message) { syncLiveReviewThreadsFromTab(); + const tab = getActiveReviewTab(); + if (tab && tab.contentLoaded !== false) { + saveCurrentTabState(); + const journal = writeDirtyDocumentJournal(tab, tab.content); + if (!journal) persistEditWithoutDurableJournal(tab, tab.content); + } if (activeTabId) saveTabsToStorage(tabs, [activeTabId]); decorateReviewTargets(); renderReviewPanel(); @@ -10169,7 +10172,7 @@ document.addEventListener("DOMContentLoaded", async function () { const input = reviewList && reviewList.querySelector( '.review-reply-edit-form[data-review-id="' + thread.id + '"][data-review-reply-id="' + reply.id + '"] .review-reply-edit-input' ); - if (input) { + if (input && document.activeElement !== input) { input.focus({ preventScroll: true }); input.setSelectionRange(input.value.length, input.value.length); } @@ -10592,6 +10595,7 @@ document.addEventListener("DOMContentLoaded", async function () { savedAt: Date.now(), metadata: { title: tab.title || 'Untitled', + reviewThreads: tab.reviewThreads || [], workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID, folderId: tab.folderId || null, viewMode: tab.viewMode || 'split', @@ -10622,6 +10626,7 @@ document.addEventListener("DOMContentLoaded", async function () { savedAt: Date.now(), metadata: { title: tab.title || 'Untitled', + reviewThreads: tab.reviewThreads || [], workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID, folderId: tab.folderId || null, viewMode: tab.viewMode || 'split', @@ -10645,6 +10650,7 @@ document.addEventListener("DOMContentLoaded", async function () { savedAt: Date.now(), metadata: { title: tab.title || 'Untitled', + reviewThreads: tab.reviewThreads || [], workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID, folderId: tab.folderId || null, viewMode: tab.viewMode || 'split', @@ -10826,8 +10832,63 @@ document.addEventListener("DOMContentLoaded", async function () { return source; } - function clearDirtyDocumentJournal(tab) { + async function captureDirtyDocumentJournalSnapshot(storageTabs, changedIds) { + const snapshots = new Map(); + changedIds.forEach(function(id) { + const tab = storageTabs.find(function(item) { return item.id === id; }); + // Metadata-only writes do not commit a document body. + if (!tab || tab.contentLoaded === false || typeof tab.content !== 'string') return; + const keys = Array.from(new Set((tab._dirtyJournalKeys || []).concat(getDirtyDocumentJournalKey(id)))); + const snapshot = { content: tab.content, reviewThreads: JSON.stringify(tab.reviewThreads || []), local: {}, session: {}, indexed: {} }; + [[localStorage, snapshot.local], [sessionStorage, snapshot.session]].forEach(function(pair) { + keys.forEach(function(key) { + try { + const raw = pair[0].getItem(key); + const value = raw && JSON.parse(raw); + if (value && value.documentId === id && value.content === snapshot.content && + (!value.metadata || !Array.isArray(value.metadata.reviewThreads) || JSON.stringify(value.metadata.reviewThreads) === snapshot.reviewThreads)) pair[1][key] = raw; + } catch (_) { /* Unknown recovery data must be retained. */ } + }); + }); + snapshots.set(id, snapshot); + }); + if (workspaceStorage && typeof workspaceStorage.listDirtyJournals === 'function') { + try { + (await workspaceStorage.listDirtyJournals()).forEach(function(record) { + const snapshot = snapshots.get(record.documentId); + if (snapshot && record.journalId && record.content === snapshot.content && + (!record.metadata || !Array.isArray(record.metadata.reviewThreads) || JSON.stringify(record.metadata.reviewThreads) === snapshot.reviewThreads)) { + snapshot.indexed[record.journalId] = record; + } + }); + } catch (error) { + console.warn('Unable to snapshot recovery journals; retaining them:', error); + } + } + return snapshots; + } + + function clearDirtyDocumentJournal(tab, committedSnapshot) { if (!tab) return Promise.resolve(); + if (committedSnapshot) { + // Compare the exact journal, not merely its key or timestamp. A new edit + // may reuse the same key while a storage operation is awaiting completion. + [[localStorage, committedSnapshot.local], [sessionStorage, committedSnapshot.session]].forEach(function(pair) { + Object.keys(pair[1]).forEach(function(key) { + try { + if (pair[0].getItem(key) === pair[1][key]) pair[0].removeItem(key); + } catch (_) {} + }); + }); + if (workspaceStorage && typeof workspaceStorage.deleteDirtyJournals === 'function') { + return workspaceStorage.deleteDirtyJournals(Object.keys(committedSnapshot.indexed), { + expectedJournals: committedSnapshot.indexed + }).catch(function(error) { + console.warn('Unable to clear a committed emergency journal:', error); + }); + } + return Promise.resolve(); + } const keys = Array.from(new Set((tab._dirtyJournalKeys || []).concat(getDirtyDocumentJournalKey(tab.id)))); keys.forEach(function(key) { try { localStorage.removeItem(key); } catch (_) {} @@ -10899,6 +10960,7 @@ document.addEventListener("DOMContentLoaded", async function () { existing.content = journal.content; existing.contentLoaded = true; existing.lastEditedAt = Number(journal.savedAt) || Date.now(); + if (journal.metadata && Array.isArray(journal.metadata.reviewThreads)) existing.reviewThreads = normalizeReviewThreads(journal.metadata.reviewThreads); existing._dirtyJournalKeys = Array.from(new Set((existing._dirtyJournalKeys || []).concat(entry.key))); existing._dirtyIndexedDbJournalIds = Array.from(new Set((existing._dirtyIndexedDbJournalIds || []).concat(entry.key))); restoredOriginalIds.add(existing.id); @@ -10912,6 +10974,7 @@ document.addEventListener("DOMContentLoaded", async function () { folderId: metadata.folderId || null }); recovered.favorite = metadata.favorite === true; + recovered.reviewThreads = normalizeReviewThreads(metadata.reviewThreads); recovered.createdAt = Number(metadata.createdAt) || recovered.createdAt; recovered.lastOpenedAt = Number(metadata.lastOpenedAt) || recovered.lastOpenedAt; recovered.lastEditedAt = Number(journal.savedAt) || Date.now(); @@ -11095,20 +11158,27 @@ document.addEventListener("DOMContentLoaded", async function () { } try { const storageTabs = getTabsForStorage(tabsArr); + const commit = async function() { + // A queued save must not resurrect documents removed before it runs. + const currentStorageTabs = storageTabs.filter(function(tab) { return tabs.some(function(item) { return item.id === tab.id; }); }); + const journalSnapshots = await captureDirtyDocumentJournalSnapshot(currentStorageTabs, changedIds); + if (workspaceStorage) { + await workspaceStorage.saveDocuments(currentStorageTabs, documentOrganization, { changedIds: changedIds }); + } else { + saveStorageItem(STORAGE_KEY, JSON.stringify(currentStorageTabs)); + } + await Promise.all(changedIds.map(function(id) { + const tab = tabs.find(function(item) { return item.id === id; }); + const snapshot = journalSnapshots.get(id); + return tab && snapshot ? clearDirtyDocumentJournal(tab, snapshot) : Promise.resolve(); + })); + }; if (workspaceStorage) { - workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(function() { - return workspaceStorage.saveDocuments(storageTabs, documentOrganization, { - changedIds: changedIds - }); - }); + workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(commit); await workspacePersistenceChain; } else { - saveStorageItem(STORAGE_KEY, JSON.stringify(storageTabs)); + await commit(); } - await Promise.all(changedIds.map(function(id) { - const tab = tabs.find(function(item) { return item.id === id; }); - return tab ? clearDirtyDocumentJournal(tab) : Promise.resolve(); - })); evictInactiveDocumentContent(); updateSaveStatus('saved'); return true; @@ -11119,7 +11189,7 @@ document.addEventListener("DOMContentLoaded", async function () { } console.warn('Failed to save workspace documents:', e); updateSaveStatus('error'); - showAppToast('The workspace could not be saved. Existing saved documents were kept.', { + showAppToast(e.code === 'WORKSPACE_MOVE_BLOCKED' ? e.message : 'The workspace could not be saved. Existing saved documents were kept.', { tone: 'error', title: 'Changes not saved' }); @@ -11127,7 +11197,10 @@ document.addEventListener("DOMContentLoaded", async function () { } } - async function flushWorkspaceBeforeExit() { + async function flushWorkspaceBeforeExit(options) { + if ((workspaceMaintenancePending || suspendWorkspacePersistence) && !(options && options.allowMaintenance === true)) { + throw new Error('Workspace maintenance is still in progress. Wait for it to complete before closing.'); + } saveCurrentTabState(); await secretDirtyJournalChain.catch(function() {}); const normalSaved = await _flushTabsToStorage(tabs); @@ -11141,6 +11214,36 @@ document.addEventListener("DOMContentLoaded", async function () { window.MarkdownViewerFlushWorkspace = flushWorkspaceBeforeExit; + async function beginWorkspaceMaintenance() { + if (workspaceMaintenancePending || suspendWorkspacePersistence) throw new Error('Workspace maintenance is already active. Reload or wait before retrying.'); + if (nativeMarkdownSaveInProgress) throw new Error('A file Save is still in progress. Wait for it to finish, then retry.'); + if (liveCollaboration) throw new Error('Leave the live collaboration session before replacing the workspace.'); + workspaceMaintenancePending = true; + const container = document.querySelector('.app-container'); + workspaceMaintenancePreviousInert = !!(container && container.inert); + if (container) container.inert = true; + clearTimeout(saveTabStateTimeout); + clearTimeout(secretWorkspaceSaveTimeout); + try { + await documentDeletionChain; + saveSecondarySplitState(); + await flushWorkspaceBeforeExit({ allowMaintenance: true }); + await secretWorkspaceSaveChain; + if (await organizationPersistenceChain === false) throw new Error('Workspace organization could not be saved. Maintenance was cancelled.'); + suspendWorkspacePersistence = true; + } catch (error) { + endWorkspaceMaintenance(); + throw error; + } + } + + function endWorkspaceMaintenance() { + suspendWorkspacePersistence = false; + workspaceMaintenancePending = false; + const container = document.querySelector('.app-container'); + if (container) container.inert = workspaceMaintenancePreviousInert; + } + // Browser lifecycle events cannot guarantee that asynchronous storage finishes, // so ordinary edits are still debounced continuously. The desktop close handler // explicitly awaits this hook before exiting. @@ -12462,6 +12565,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function newTab(content, title, location) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return false; closeSlashCommandMenu(); if (content === undefined) content = ''; const targetLocation = location || (documentOrganization ? getPreferredDocumentLocation() : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); @@ -12481,7 +12585,17 @@ document.addEventListener("DOMContentLoaded", async function () { return true; } - async function deleteTab(tabId) { + let documentDeletionChain = Promise.resolve(); + function deleteTab(tabId) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return Promise.resolve(false); + const operation = documentDeletionChain.catch(function() {}).then(function() { + return deleteTabSnapshot(tabId); + }); + documentDeletionChain = operation; + return operation; + } + + async function deleteTabSnapshot(tabId) { if (pendingReviewDelete && pendingReviewDelete.tabId === tabId) { cancelReviewDeleteConfirmation(); } @@ -12494,11 +12608,11 @@ document.addEventListener("DOMContentLoaded", async function () { return; } - const idx = tabs.findIndex(function(t) { return t.id === tabId; }); - if (idx === -1) return; + let idx = tabs.findIndex(function(t) { return t.id === tabId; }); + if (idx === -1) return false; const tab = tabs[idx]; - const wasActive = activeTabId === tabId; - const splitPartnerId = wasActive ? secondarySplitTabId : null; + let wasActive = activeTabId === tabId; + let splitPartnerId = wasActive ? secondarySplitTabId : null; if (wasActive) saveCurrentTabState(); saveSecondarySplitState(); if (!isTemporaryDocument(tab) && tab.workspaceId !== SECRET_WORKSPACE_ID && workspaceStorage && !isPrivateStorageMode()) { @@ -12511,7 +12625,14 @@ document.addEventListener("DOMContentLoaded", async function () { return false; } try { - await workspaceStorage.deleteDocument(tab.id, { expectedRevision: tab._storageRevision }); + // Use the same queue as document saves. Remove the live entry before + // subsequent queued saves select their current documents. + workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(async function() { + await workspaceStorage.deleteDocument(tab.id, { expectedRevision: tab._storageRevision }); + const currentIndex = tabs.indexOf(tab); + if (currentIndex >= 0) { idx = currentIndex; tabs.splice(currentIndex, 1); } + }); + await workspacePersistenceChain; } catch (error) { if (error && error.name === 'WorkspaceConflictError') { showAppToast('The document changed in another tab and was not deleted. Reload before trying again.', { @@ -12528,6 +12649,8 @@ document.addEventListener("DOMContentLoaded", async function () { return false; } } + wasActive = activeTabId === tabId; + splitPartnerId = wasActive ? secondarySplitTabId : null; if (wasActive) { closeReviewComposer(); clearReviewDecorations(); @@ -12543,7 +12666,8 @@ document.addEventListener("DOMContentLoaded", async function () { delete tabHistories[tabId]; } - tabs.splice(idx, 1); + const remainingIndex = tabs.indexOf(tab); + if (remainingIndex >= 0) { idx = remainingIndex; tabs.splice(remainingIndex, 1); } clearDirtyDocumentJournal(tab); if (tab.workspaceId === SECRET_WORKSPACE_ID) saveTabsToStorage(tabs, []); if (wasActive) { @@ -12555,7 +12679,7 @@ document.addEventListener("DOMContentLoaded", async function () { } else { clearActiveDocument(); } - return; + return true; } if (selectedDocumentId === tabId) selectedDocumentId = activeTabId; renderDocumentSplitView(); @@ -12759,21 +12883,15 @@ document.addEventListener("DOMContentLoaded", async function () { if (closeBtn) closeBtn.disabled = true; confirmBtn.classList.add('is-loading'); confirmBtn.setAttribute('aria-busy', 'true'); - suspendWorkspacePersistence = true; - clearTimeout(saveTabStateTimeout); - clearTimeout(secretWorkspaceSaveTimeout); showImportProgress(100, { title: 'Resetting workspace', detail: 'Preparing a fresh workspace…' }); let resetStarted = false; + let maintenanceStarted = false; try { - await Promise.all([ - workspacePersistenceChain.catch(function() {}), - secretWorkspaceSaveChain.catch(function() {}), - secretDirtyJournalChain.catch(function() {}), - organizationPersistenceChain.catch(function() {}) - ]); + await beginWorkspaceMaintenance(); + maintenanceStarted = true; updateImportProgress(25, 100, 'Clearing workspace settings…'); await replaceApplicationPreferences({}); await clearLegacyWorkspaceStateForReset(); @@ -12803,7 +12921,7 @@ document.addEventListener("DOMContentLoaded", async function () { cancelBtn.disabled = resetStarted; if (backupBtn) backupBtn.disabled = resetStarted; if (closeBtn) closeBtn.disabled = resetStarted; - if (!resetStarted) suspendWorkspacePersistence = false; + if (!resetStarted && maintenanceStarted) endWorkspaceMaintenance(); isResetting = false; } } @@ -13129,6 +13247,7 @@ document.addEventListener("DOMContentLoaded", async function () { // Late-load callback hook for Neutralino command-line files window.NL_IMPORT_EXTERNAL_FILE = function(content, name) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return false; if (typeof tabs === 'undefined') return; const existing = tabs.find(function(t) { return t.title === name && t.content === content; }); if (existing) { @@ -15378,6 +15497,7 @@ ${selector} .arrowheadPath { } function createImportedDocument(content, title, location) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return null; const targetLocation = location || (documentOrganization ? getPreferredDocumentLocation() : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); @@ -19187,10 +19307,9 @@ ${selector} .arrowheadPath { columnInput.addEventListener('keydown', onKey); rowInput.addEventListener('keydown', onKey); - requestAnimationFrame(() => { - columnInput.focus(); - columnInput.select(); - }); + // Do not redirect input after the user has moved to the row field. + columnInput.focus(); + columnInput.select(); } function openEmojiModal() { @@ -21105,14 +21224,13 @@ ${selector} .arrowheadPath { uploadFields.style.display = isUpload ? 'flex' : 'none'; urlFields.style.display = isUpload ? 'none' : 'flex'; if (shouldFocus) { - requestAnimationFrame(function() { - if (isUpload) { - fileInput.focus(); - } else { - urlInput.focus(); - urlInput.select(); - } - }); + // Focus immediately so a deferred callback cannot steal subsequent input. + if (isUpload) { + fileInput.focus(); + } else { + urlInput.focus(); + urlInput.select(); + } } } @@ -21261,10 +21379,10 @@ ${selector} .arrowheadPath { urlInput.addEventListener('keydown', onKey); titleInput.addEventListener('keydown', onKey); - requestAnimationFrame(function() { - numberInput.focus(); - numberInput.select(); - }); + // The modal is already displayed. Focus synchronously so a deferred callback + // cannot steal focus after the user starts filling the URL or title field. + numberInput.focus(); + numberInput.select(); } function escapeRegExp(text) { @@ -24564,7 +24682,10 @@ ${selector} .arrowheadPath { updateStlThemes(); }); + let nativeMarkdownSaveInProgress = false; async function nativeSaveMarkdown() { + if (nativeMarkdownSaveInProgress || workspaceMaintenancePending || suspendWorkspacePersistence) return; + nativeMarkdownSaveInProgress = true; try { const content = markdownEditor.value; const result = await Neutralino.os.showSaveDialog("Save Markdown File", { @@ -24587,6 +24708,8 @@ ${selector} .arrowheadPath { } catch (e) { console.error("Native save failed:", e); alert("Native save failed: " + e.message); + } finally { + nativeMarkdownSaveInProgress = false; } } diff --git a/desktop-app/resources/js/workspace-storage.js b/desktop-app/resources/js/workspace-storage.js index df3eb067..b8dc26b1 100644 --- a/desktop-app/resources/js/workspace-storage.js +++ b/desktop-app/resources/js/workspace-storage.js @@ -1229,6 +1229,37 @@ tab._storageContentJournalPaths = Array.from(new Set((tab._storageContentJournalPaths || []).concat(journalPath))); } + async _desktopInspectMovePath(path) { + try { return await Neutralino.filesystem.getStats(path); } + catch (error) { + if (!error || error.code !== 'NE_FS_NOPATHE') throw error; + // Native stat can report missing for an inaccessible entry. Confirm + // absence using its parent; never turn permission errors into absence. + const normalized = normalizePathSeparators(path); + const separator = normalized.lastIndexOf('/'); + if (separator < 0) throw error; + const entries = await Neutralino.filesystem.readDirectory(normalized.slice(0, separator)); + const name = normalized.slice(separator + 1); + const insensitive = typeof NL_OS !== 'undefined' && NL_OS === 'Windows'; + if (entries.some(function(entry) { + return insensitive ? String(entry.entry).toLowerCase() === name.toLowerCase() : entry.entry === name; + })) { + throw Object.assign(new Error('Move cancelled: a file exists but cannot be inspected at ' + path + '. No existing file was replaced.'), { code: 'WORKSPACE_MOVE_BLOCKED' }); + } + return null; + } + } + + async _desktopCheckMovePaths(source, destination) { + const stats = await this._desktopInspectMovePath(source); + if (!stats || stats.isDirectory || stats.isFile === false) { + throw Object.assign(new Error('Move cancelled: the source is missing or is not a file at ' + source + '. No replacement was created.'), { code: 'WORKSPACE_MOVE_BLOCKED' }); + } + if (await this._desktopInspectMovePath(destination)) { + throw Object.assign(new Error('Move cancelled: the destination already exists at ' + destination + '. Both files were left unchanged.'), { code: 'WORKSPACE_MOVE_BLOCKED' }); + } + } + async _desktopSaveDocument(tab, organization, forceContent) { requireDocumentId(tab && tab.id); const existing = this.vaultIndex.documents.find(function (item) { return item.id === tab.id; }); @@ -1254,6 +1285,7 @@ if (existing && existing.vaultRelativePath && existing.vaultRelativePath !== relativePath) { const oldPath = await this._desktopResolveVaultRelativePath(existing.vaultRelativePath); + await this._desktopCheckMovePaths(oldPath, fullPath); const journalDirectory = await this._pathJoin(this.vaultPath, INTERNAL_DIR, 'journal'); const moveJournalPath = await this._pathJoin(journalDirectory, sanitizePathSegment(tab.id, 'document') + '.move.json'); await this._writeJsonFile(moveJournalPath, { @@ -1266,14 +1298,10 @@ startedAt: Date.now() }); tab._storageMoveJournalPath = moveJournalPath; - if (await this._pathExists(oldPath)) { - if (await this._pathExists(fullPath)) { - await Neutralino.filesystem.writeFile(fullPath, typeof tab.content === 'string' ? tab.content : await Neutralino.filesystem.readFile(oldPath)); - await Neutralino.filesystem.remove(oldPath); - } else { - await Neutralino.filesystem.move(oldPath, fullPath); - } - } + // Recheck after asynchronous journal writing. Do not overwrite a + // collision, delete the source or fall back to a destructive copy. + await this._desktopCheckMovePaths(oldPath, fullPath); + await Neutralino.filesystem.move(oldPath, fullPath); } if (forceContent || tab.contentLoaded !== false) { const content = typeof tab.content === 'string' ? tab.content : ''; @@ -1861,14 +1889,28 @@ return records; } - async deleteDirtyJournals(journalIds) { + async deleteDirtyJournals(journalIds, options) { if (this.desktop) return; const ids = Array.isArray(journalIds) ? journalIds.filter(Boolean) : []; if (!ids.length) return; const transaction = this.db.transaction('journals', 'readwrite'); + const completion = transactionToPromise(transaction); const store = transaction.objectStore('journals'); - ids.forEach(function(id) { store.delete(id); }); - await transactionToPromise(transaction); + const expected = options && options.expectedJournals; + ids.forEach(function(id) { + if (!expected) { + store.delete(id); + return; + } + if (!Object.prototype.hasOwnProperty.call(expected, id)) return; + const request = store.get(id); + request.onsuccess = function() { + // The read and conditional delete share a readwrite transaction, + // so a concurrent replacement cannot be deleted between them. + if (JSON.stringify(request.result) === JSON.stringify(expected[id])) store.delete(id); + }; + }); + await completion; } async restoreTrashItem(trashId) { diff --git a/index.html b/index.html index c7085e90..6317e012 100644 --- a/index.html +++ b/index.html @@ -132,10 +132,10 @@ - + - +
diff --git a/package-lock.json b/package-lock.json index 48ec9f43..840cd7f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,14 +11,14 @@ "@highlightjs/cdn-assets": "11.9.0", "@playwright/test": "1.61.1", "bootstrap": "5.3.2", - "dompurify": "3.4.13", + "dompurify": "3.4.16", "entities": "6.0.1", "esbuild": "0.25.12", "file-saver": "2.0.5", "github-markdown-css": "5.3.0", "github-slugger": "2.0.0", "highlight.js": "11.9.0", - "js-yaml": "4.3.1", + "js-yaml": "4.3.2", "jszip": "3.10.1", "marked": "9.1.6", "pako": "2.1.0" @@ -550,11 +550,10 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", "dev": true, - "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -688,9 +687,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -702,7 +701,6 @@ "url": "https://github.com/sponsors/nodeca" } ], - "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, diff --git a/package.json b/package.json index 1872f23c..da437478 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,14 @@ "@highlightjs/cdn-assets": "11.9.0", "@playwright/test": "1.61.1", "bootstrap": "5.3.2", - "dompurify": "3.4.13", + "dompurify": "3.4.16", "entities": "6.0.1", "esbuild": "0.25.12", "file-saver": "2.0.5", "github-markdown-css": "5.3.0", "github-slugger": "2.0.0", "highlight.js": "11.9.0", - "js-yaml": "4.3.1", + "js-yaml": "4.3.2", "jszip": "3.10.1", "marked": "9.1.6", "pako": "2.1.0" diff --git a/script.js b/script.js index dd54b8e8..af11b098 100644 --- a/script.js +++ b/script.js @@ -2098,18 +2098,12 @@ document.addEventListener("DOMContentLoaded", async function () { detail: 'Validating backup…' }); let restoreCommitted = false; + let maintenanceStarted = false; let preferenceSnapshot = null; try { const backup = await parseWorkspaceBackup(input); - suspendWorkspacePersistence = true; - clearTimeout(saveTabStateTimeout); - clearTimeout(secretWorkspaceSaveTimeout); - await Promise.all([ - workspacePersistenceChain.catch(function() {}), - secretWorkspaceSaveChain.catch(function() {}), - secretDirtyJournalChain.catch(function() {}), - organizationPersistenceChain.catch(function() {}) - ]); + await beginWorkspaceMaintenance(); + maintenanceStarted = true; preferenceSnapshot = captureApplicationPreferences(); try { await replaceApplicationPreferences(backup.preferences); @@ -2143,7 +2137,7 @@ document.addEventListener("DOMContentLoaded", async function () { }); window.setTimeout(function() { window.location.reload(); }, 1100); } catch (error) { - if (!restoreCommitted) suspendWorkspacePersistence = false; + if (!restoreCommitted && maintenanceStarted && !error.rollbackError) endWorkspaceMaintenance(); console.error('Workspace backup import failed:', error); if (restoreCommitted) { finishImportProgress(100, 100, { @@ -2158,7 +2152,8 @@ document.addEventListener("DOMContentLoaded", async function () { success: false, title: 'Import failed', countText: 'Workspace not restored', - detail: error && error.message ? error.message : 'The backup could not be imported.' + detail: error.rollbackError ? 'Restore and rollback failed. Editing remains blocked; reload before continuing.' : + (error && error.message ? error.message : 'The backup could not be imported.') }); } } @@ -4262,6 +4257,8 @@ document.addEventListener("DOMContentLoaded", async function () { let pendingWorkspacePersistAll = false; let pendingWorkspacePersistIds = new Set(); let suspendWorkspacePersistence = false; + let workspaceMaintenancePending = false; + let workspaceMaintenancePreviousInert = false; let secretWorkspaceEnvelopeCache = null; const LIVE_EDIT_ORIGIN = Symbol("markdown-viewer-live-local-edit"); const LIVE_RELAY_ORIGIN = Symbol("markdown-viewer-live-relay"); @@ -8346,6 +8343,12 @@ document.addEventListener("DOMContentLoaded", async function () { function persistReviewThreads(message) { syncLiveReviewThreadsFromTab(); + const tab = getActiveReviewTab(); + if (tab && tab.contentLoaded !== false) { + saveCurrentTabState(); + const journal = writeDirtyDocumentJournal(tab, tab.content); + if (!journal) persistEditWithoutDurableJournal(tab, tab.content); + } if (activeTabId) saveTabsToStorage(tabs, [activeTabId]); decorateReviewTargets(); renderReviewPanel(); @@ -10169,7 +10172,7 @@ document.addEventListener("DOMContentLoaded", async function () { const input = reviewList && reviewList.querySelector( '.review-reply-edit-form[data-review-id="' + thread.id + '"][data-review-reply-id="' + reply.id + '"] .review-reply-edit-input' ); - if (input) { + if (input && document.activeElement !== input) { input.focus({ preventScroll: true }); input.setSelectionRange(input.value.length, input.value.length); } @@ -10592,6 +10595,7 @@ document.addEventListener("DOMContentLoaded", async function () { savedAt: Date.now(), metadata: { title: tab.title || 'Untitled', + reviewThreads: tab.reviewThreads || [], workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID, folderId: tab.folderId || null, viewMode: tab.viewMode || 'split', @@ -10622,6 +10626,7 @@ document.addEventListener("DOMContentLoaded", async function () { savedAt: Date.now(), metadata: { title: tab.title || 'Untitled', + reviewThreads: tab.reviewThreads || [], workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID, folderId: tab.folderId || null, viewMode: tab.viewMode || 'split', @@ -10645,6 +10650,7 @@ document.addEventListener("DOMContentLoaded", async function () { savedAt: Date.now(), metadata: { title: tab.title || 'Untitled', + reviewThreads: tab.reviewThreads || [], workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID, folderId: tab.folderId || null, viewMode: tab.viewMode || 'split', @@ -10826,8 +10832,63 @@ document.addEventListener("DOMContentLoaded", async function () { return source; } - function clearDirtyDocumentJournal(tab) { + async function captureDirtyDocumentJournalSnapshot(storageTabs, changedIds) { + const snapshots = new Map(); + changedIds.forEach(function(id) { + const tab = storageTabs.find(function(item) { return item.id === id; }); + // Metadata-only writes do not commit a document body. + if (!tab || tab.contentLoaded === false || typeof tab.content !== 'string') return; + const keys = Array.from(new Set((tab._dirtyJournalKeys || []).concat(getDirtyDocumentJournalKey(id)))); + const snapshot = { content: tab.content, reviewThreads: JSON.stringify(tab.reviewThreads || []), local: {}, session: {}, indexed: {} }; + [[localStorage, snapshot.local], [sessionStorage, snapshot.session]].forEach(function(pair) { + keys.forEach(function(key) { + try { + const raw = pair[0].getItem(key); + const value = raw && JSON.parse(raw); + if (value && value.documentId === id && value.content === snapshot.content && + (!value.metadata || !Array.isArray(value.metadata.reviewThreads) || JSON.stringify(value.metadata.reviewThreads) === snapshot.reviewThreads)) pair[1][key] = raw; + } catch (_) { /* Unknown recovery data must be retained. */ } + }); + }); + snapshots.set(id, snapshot); + }); + if (workspaceStorage && typeof workspaceStorage.listDirtyJournals === 'function') { + try { + (await workspaceStorage.listDirtyJournals()).forEach(function(record) { + const snapshot = snapshots.get(record.documentId); + if (snapshot && record.journalId && record.content === snapshot.content && + (!record.metadata || !Array.isArray(record.metadata.reviewThreads) || JSON.stringify(record.metadata.reviewThreads) === snapshot.reviewThreads)) { + snapshot.indexed[record.journalId] = record; + } + }); + } catch (error) { + console.warn('Unable to snapshot recovery journals; retaining them:', error); + } + } + return snapshots; + } + + function clearDirtyDocumentJournal(tab, committedSnapshot) { if (!tab) return Promise.resolve(); + if (committedSnapshot) { + // Compare the exact journal, not merely its key or timestamp. A new edit + // may reuse the same key while a storage operation is awaiting completion. + [[localStorage, committedSnapshot.local], [sessionStorage, committedSnapshot.session]].forEach(function(pair) { + Object.keys(pair[1]).forEach(function(key) { + try { + if (pair[0].getItem(key) === pair[1][key]) pair[0].removeItem(key); + } catch (_) {} + }); + }); + if (workspaceStorage && typeof workspaceStorage.deleteDirtyJournals === 'function') { + return workspaceStorage.deleteDirtyJournals(Object.keys(committedSnapshot.indexed), { + expectedJournals: committedSnapshot.indexed + }).catch(function(error) { + console.warn('Unable to clear a committed emergency journal:', error); + }); + } + return Promise.resolve(); + } const keys = Array.from(new Set((tab._dirtyJournalKeys || []).concat(getDirtyDocumentJournalKey(tab.id)))); keys.forEach(function(key) { try { localStorage.removeItem(key); } catch (_) {} @@ -10899,6 +10960,7 @@ document.addEventListener("DOMContentLoaded", async function () { existing.content = journal.content; existing.contentLoaded = true; existing.lastEditedAt = Number(journal.savedAt) || Date.now(); + if (journal.metadata && Array.isArray(journal.metadata.reviewThreads)) existing.reviewThreads = normalizeReviewThreads(journal.metadata.reviewThreads); existing._dirtyJournalKeys = Array.from(new Set((existing._dirtyJournalKeys || []).concat(entry.key))); existing._dirtyIndexedDbJournalIds = Array.from(new Set((existing._dirtyIndexedDbJournalIds || []).concat(entry.key))); restoredOriginalIds.add(existing.id); @@ -10912,6 +10974,7 @@ document.addEventListener("DOMContentLoaded", async function () { folderId: metadata.folderId || null }); recovered.favorite = metadata.favorite === true; + recovered.reviewThreads = normalizeReviewThreads(metadata.reviewThreads); recovered.createdAt = Number(metadata.createdAt) || recovered.createdAt; recovered.lastOpenedAt = Number(metadata.lastOpenedAt) || recovered.lastOpenedAt; recovered.lastEditedAt = Number(journal.savedAt) || Date.now(); @@ -11095,20 +11158,27 @@ document.addEventListener("DOMContentLoaded", async function () { } try { const storageTabs = getTabsForStorage(tabsArr); + const commit = async function() { + // A queued save must not resurrect documents removed before it runs. + const currentStorageTabs = storageTabs.filter(function(tab) { return tabs.some(function(item) { return item.id === tab.id; }); }); + const journalSnapshots = await captureDirtyDocumentJournalSnapshot(currentStorageTabs, changedIds); + if (workspaceStorage) { + await workspaceStorage.saveDocuments(currentStorageTabs, documentOrganization, { changedIds: changedIds }); + } else { + saveStorageItem(STORAGE_KEY, JSON.stringify(currentStorageTabs)); + } + await Promise.all(changedIds.map(function(id) { + const tab = tabs.find(function(item) { return item.id === id; }); + const snapshot = journalSnapshots.get(id); + return tab && snapshot ? clearDirtyDocumentJournal(tab, snapshot) : Promise.resolve(); + })); + }; if (workspaceStorage) { - workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(function() { - return workspaceStorage.saveDocuments(storageTabs, documentOrganization, { - changedIds: changedIds - }); - }); + workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(commit); await workspacePersistenceChain; } else { - saveStorageItem(STORAGE_KEY, JSON.stringify(storageTabs)); + await commit(); } - await Promise.all(changedIds.map(function(id) { - const tab = tabs.find(function(item) { return item.id === id; }); - return tab ? clearDirtyDocumentJournal(tab) : Promise.resolve(); - })); evictInactiveDocumentContent(); updateSaveStatus('saved'); return true; @@ -11119,7 +11189,7 @@ document.addEventListener("DOMContentLoaded", async function () { } console.warn('Failed to save workspace documents:', e); updateSaveStatus('error'); - showAppToast('The workspace could not be saved. Existing saved documents were kept.', { + showAppToast(e.code === 'WORKSPACE_MOVE_BLOCKED' ? e.message : 'The workspace could not be saved. Existing saved documents were kept.', { tone: 'error', title: 'Changes not saved' }); @@ -11127,7 +11197,10 @@ document.addEventListener("DOMContentLoaded", async function () { } } - async function flushWorkspaceBeforeExit() { + async function flushWorkspaceBeforeExit(options) { + if ((workspaceMaintenancePending || suspendWorkspacePersistence) && !(options && options.allowMaintenance === true)) { + throw new Error('Workspace maintenance is still in progress. Wait for it to complete before closing.'); + } saveCurrentTabState(); await secretDirtyJournalChain.catch(function() {}); const normalSaved = await _flushTabsToStorage(tabs); @@ -11141,6 +11214,36 @@ document.addEventListener("DOMContentLoaded", async function () { window.MarkdownViewerFlushWorkspace = flushWorkspaceBeforeExit; + async function beginWorkspaceMaintenance() { + if (workspaceMaintenancePending || suspendWorkspacePersistence) throw new Error('Workspace maintenance is already active. Reload or wait before retrying.'); + if (nativeMarkdownSaveInProgress) throw new Error('A file Save is still in progress. Wait for it to finish, then retry.'); + if (liveCollaboration) throw new Error('Leave the live collaboration session before replacing the workspace.'); + workspaceMaintenancePending = true; + const container = document.querySelector('.app-container'); + workspaceMaintenancePreviousInert = !!(container && container.inert); + if (container) container.inert = true; + clearTimeout(saveTabStateTimeout); + clearTimeout(secretWorkspaceSaveTimeout); + try { + await documentDeletionChain; + saveSecondarySplitState(); + await flushWorkspaceBeforeExit({ allowMaintenance: true }); + await secretWorkspaceSaveChain; + if (await organizationPersistenceChain === false) throw new Error('Workspace organization could not be saved. Maintenance was cancelled.'); + suspendWorkspacePersistence = true; + } catch (error) { + endWorkspaceMaintenance(); + throw error; + } + } + + function endWorkspaceMaintenance() { + suspendWorkspacePersistence = false; + workspaceMaintenancePending = false; + const container = document.querySelector('.app-container'); + if (container) container.inert = workspaceMaintenancePreviousInert; + } + // Browser lifecycle events cannot guarantee that asynchronous storage finishes, // so ordinary edits are still debounced continuously. The desktop close handler // explicitly awaits this hook before exiting. @@ -12462,6 +12565,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function newTab(content, title, location) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return false; closeSlashCommandMenu(); if (content === undefined) content = ''; const targetLocation = location || (documentOrganization ? getPreferredDocumentLocation() : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); @@ -12481,7 +12585,17 @@ document.addEventListener("DOMContentLoaded", async function () { return true; } - async function deleteTab(tabId) { + let documentDeletionChain = Promise.resolve(); + function deleteTab(tabId) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return Promise.resolve(false); + const operation = documentDeletionChain.catch(function() {}).then(function() { + return deleteTabSnapshot(tabId); + }); + documentDeletionChain = operation; + return operation; + } + + async function deleteTabSnapshot(tabId) { if (pendingReviewDelete && pendingReviewDelete.tabId === tabId) { cancelReviewDeleteConfirmation(); } @@ -12494,11 +12608,11 @@ document.addEventListener("DOMContentLoaded", async function () { return; } - const idx = tabs.findIndex(function(t) { return t.id === tabId; }); - if (idx === -1) return; + let idx = tabs.findIndex(function(t) { return t.id === tabId; }); + if (idx === -1) return false; const tab = tabs[idx]; - const wasActive = activeTabId === tabId; - const splitPartnerId = wasActive ? secondarySplitTabId : null; + let wasActive = activeTabId === tabId; + let splitPartnerId = wasActive ? secondarySplitTabId : null; if (wasActive) saveCurrentTabState(); saveSecondarySplitState(); if (!isTemporaryDocument(tab) && tab.workspaceId !== SECRET_WORKSPACE_ID && workspaceStorage && !isPrivateStorageMode()) { @@ -12511,7 +12625,14 @@ document.addEventListener("DOMContentLoaded", async function () { return false; } try { - await workspaceStorage.deleteDocument(tab.id, { expectedRevision: tab._storageRevision }); + // Use the same queue as document saves. Remove the live entry before + // subsequent queued saves select their current documents. + workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(async function() { + await workspaceStorage.deleteDocument(tab.id, { expectedRevision: tab._storageRevision }); + const currentIndex = tabs.indexOf(tab); + if (currentIndex >= 0) { idx = currentIndex; tabs.splice(currentIndex, 1); } + }); + await workspacePersistenceChain; } catch (error) { if (error && error.name === 'WorkspaceConflictError') { showAppToast('The document changed in another tab and was not deleted. Reload before trying again.', { @@ -12528,6 +12649,8 @@ document.addEventListener("DOMContentLoaded", async function () { return false; } } + wasActive = activeTabId === tabId; + splitPartnerId = wasActive ? secondarySplitTabId : null; if (wasActive) { closeReviewComposer(); clearReviewDecorations(); @@ -12543,7 +12666,8 @@ document.addEventListener("DOMContentLoaded", async function () { delete tabHistories[tabId]; } - tabs.splice(idx, 1); + const remainingIndex = tabs.indexOf(tab); + if (remainingIndex >= 0) { idx = remainingIndex; tabs.splice(remainingIndex, 1); } clearDirtyDocumentJournal(tab); if (tab.workspaceId === SECRET_WORKSPACE_ID) saveTabsToStorage(tabs, []); if (wasActive) { @@ -12555,7 +12679,7 @@ document.addEventListener("DOMContentLoaded", async function () { } else { clearActiveDocument(); } - return; + return true; } if (selectedDocumentId === tabId) selectedDocumentId = activeTabId; renderDocumentSplitView(); @@ -12759,21 +12883,15 @@ document.addEventListener("DOMContentLoaded", async function () { if (closeBtn) closeBtn.disabled = true; confirmBtn.classList.add('is-loading'); confirmBtn.setAttribute('aria-busy', 'true'); - suspendWorkspacePersistence = true; - clearTimeout(saveTabStateTimeout); - clearTimeout(secretWorkspaceSaveTimeout); showImportProgress(100, { title: 'Resetting workspace', detail: 'Preparing a fresh workspace…' }); let resetStarted = false; + let maintenanceStarted = false; try { - await Promise.all([ - workspacePersistenceChain.catch(function() {}), - secretWorkspaceSaveChain.catch(function() {}), - secretDirtyJournalChain.catch(function() {}), - organizationPersistenceChain.catch(function() {}) - ]); + await beginWorkspaceMaintenance(); + maintenanceStarted = true; updateImportProgress(25, 100, 'Clearing workspace settings…'); await replaceApplicationPreferences({}); await clearLegacyWorkspaceStateForReset(); @@ -12803,7 +12921,7 @@ document.addEventListener("DOMContentLoaded", async function () { cancelBtn.disabled = resetStarted; if (backupBtn) backupBtn.disabled = resetStarted; if (closeBtn) closeBtn.disabled = resetStarted; - if (!resetStarted) suspendWorkspacePersistence = false; + if (!resetStarted && maintenanceStarted) endWorkspaceMaintenance(); isResetting = false; } } @@ -13129,6 +13247,7 @@ document.addEventListener("DOMContentLoaded", async function () { // Late-load callback hook for Neutralino command-line files window.NL_IMPORT_EXTERNAL_FILE = function(content, name) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return false; if (typeof tabs === 'undefined') return; const existing = tabs.find(function(t) { return t.title === name && t.content === content; }); if (existing) { @@ -15378,6 +15497,7 @@ ${selector} .arrowheadPath { } function createImportedDocument(content, title, location) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return null; const targetLocation = location || (documentOrganization ? getPreferredDocumentLocation() : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); @@ -19187,10 +19307,9 @@ ${selector} .arrowheadPath { columnInput.addEventListener('keydown', onKey); rowInput.addEventListener('keydown', onKey); - requestAnimationFrame(() => { - columnInput.focus(); - columnInput.select(); - }); + // Do not redirect input after the user has moved to the row field. + columnInput.focus(); + columnInput.select(); } function openEmojiModal() { @@ -21105,14 +21224,13 @@ ${selector} .arrowheadPath { uploadFields.style.display = isUpload ? 'flex' : 'none'; urlFields.style.display = isUpload ? 'none' : 'flex'; if (shouldFocus) { - requestAnimationFrame(function() { - if (isUpload) { - fileInput.focus(); - } else { - urlInput.focus(); - urlInput.select(); - } - }); + // Focus immediately so a deferred callback cannot steal subsequent input. + if (isUpload) { + fileInput.focus(); + } else { + urlInput.focus(); + urlInput.select(); + } } } @@ -21261,10 +21379,10 @@ ${selector} .arrowheadPath { urlInput.addEventListener('keydown', onKey); titleInput.addEventListener('keydown', onKey); - requestAnimationFrame(function() { - numberInput.focus(); - numberInput.select(); - }); + // The modal is already displayed. Focus synchronously so a deferred callback + // cannot steal focus after the user starts filling the URL or title field. + numberInput.focus(); + numberInput.select(); } function escapeRegExp(text) { @@ -24564,7 +24682,10 @@ ${selector} .arrowheadPath { updateStlThemes(); }); + let nativeMarkdownSaveInProgress = false; async function nativeSaveMarkdown() { + if (nativeMarkdownSaveInProgress || workspaceMaintenancePending || suspendWorkspacePersistence) return; + nativeMarkdownSaveInProgress = true; try { const content = markdownEditor.value; const result = await Neutralino.os.showSaveDialog("Save Markdown File", { @@ -24587,6 +24708,8 @@ ${selector} .arrowheadPath { } catch (e) { console.error("Native save failed:", e); alert("Native save failed: " + e.message); + } finally { + nativeMarkdownSaveInProgress = false; } } diff --git a/tests/e2e/data-loss-hardening.spec.js b/tests/e2e/data-loss-hardening.spec.js index 3c5d6c05..d9331dd4 100644 --- a/tests/e2e/data-loss-hardening.spec.js +++ b/tests/e2e/data-loss-hardening.spec.js @@ -401,7 +401,7 @@ test('desktop journals recover a new file and a move before their index commits' path = normalize(path); if (directories.has(path)) return { isDirectory: true, size: 0, modifiedAt: Date.now() }; if (files.has(path)) return { isDirectory: false, size: files.get(path).length, modifiedAt: Date.now() }; - throw new Error('missing'); + throw Object.assign(new Error('missing'), { code: 'NE_FS_NOPATHE' }); }, createDirectory: async path => directories.add(normalize(path)), readFile: async path => { diff --git a/tests/e2e/data-loss-regressions.spec.js b/tests/e2e/data-loss-regressions.spec.js index b0355d26..033ba301 100644 --- a/tests/e2e/data-loss-regressions.spec.js +++ b/tests/e2e/data-loss-regressions.spec.js @@ -64,6 +64,9 @@ async function replacementBackupBuffer() { test('a stale tab move cannot delete documents created in another tab', async ({ context, page }) => { await openApp(page); + // Seed a persisted document before the second writer takes its snapshot. + await setEditorContent(page, '# Shared document before opening the stale writer'); + await waitForStoredContent(page, 'Shared document before opening the stale writer'); const staleDocumentId = await page.locator('#tab-list .tab-item.active').getAttribute('data-tab-id'); const secondPage = await context.newPage(); await openApp(secondPage); diff --git a/tests/e2e/desktop-move-safety.spec.js b/tests/e2e/desktop-move-safety.spec.js new file mode 100644 index 00000000..1b88f3bd --- /dev/null +++ b/tests/e2e/desktop-move-safety.spec.js @@ -0,0 +1,28 @@ +const { test, expect } = require('@playwright/test'); +const { openApp } = require('../helpers/app'); +const { installDesktopFilesystem } = require('../helpers/desktop-filesystem'); + +test('stock desktop rename collision preserves both bodies and the committed index', async ({ page }) => { + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await openApp(page); + const result = await page.evaluate(async () => { + const storage = new MarkdownWorkspaceStorage(); + await storage.init(); + const tab = { id: 'rename-test', title: 'Before', content: '# original', contentLoaded: true, workspaceId: 'workspace_default' }; + await storage.saveDocuments([tab], { folders: [] }, { forceContent: true }); + const original = storage.vaultPath + '/' + tab._vaultRelativePath; + const indexPath = storage.vaultPath + '/.markdown-viewer/index.json'; + const index = __desktopFiles.get(indexPath); + tab.title = 'After'; + const destination = storage.vaultPath + '/' + await storage._desktopDocumentRelativePath(tab, { folders: [] }); + __desktopFiles.set(destination, '# unrelated body'); + let error; + try { await storage.saveDocuments([tab], { folders: [] }, { changedIds: [tab.id] }); } + catch (failure) { error = failure.message; } + return { error, original: __desktopFiles.get(original), destination: __desktopFiles.get(destination), indexUnchanged: __desktopFiles.get(indexPath) === index }; + }); + expect(result.error).toMatch(/destination.*exists/i); + expect(result.original).toBe('# original'); + expect(result.destination).toBe('# unrelated body'); + expect(result.indexUnchanged).toBe(true); +}); diff --git a/tests/e2e/recovery-journal-commit.spec.js b/tests/e2e/recovery-journal-commit.spec.js new file mode 100644 index 00000000..bf1954e1 --- /dev/null +++ b/tests/e2e/recovery-journal-commit.spec.js @@ -0,0 +1,50 @@ +const { test, expect } = require('@playwright/test'); +const { openApp, setEditorContent } = require('../helpers/app'); + +test('an edit arriving as an older save finishes keeps its browser recovery journal', async ({ page }) => { + await openApp(page); + await page.evaluate(() => { + const prototype = window.MarkdownWorkspaceStorage.prototype; + const save = prototype.saveDocuments; + prototype.saveDocuments = async function(documents, ...args) { + const shouldInject = !window.__newJournalInjected && documents.some(tab => tab.content === '# Older snapshot'); + const result = await save.call(this, documents, ...args); + if (shouldInject) { + const editor = document.querySelector('#markdown-editor'); + editor.value = '# Newer recovery draft'; + editor.dispatchEvent(new Event('input', { bubbles: true })); + window.__newJournalInjected = true; + // Observe after the old flush cleanup, before the next debounced save. + setTimeout(() => { + window.__journalAfterOldSave = Object.keys(localStorage) + .filter(key => key.startsWith('markdownViewerDirtyDocument:')) + .map(key => { try { return JSON.parse(localStorage.getItem(key)); } catch (_) { return null; } }) + .filter(Boolean); + }, 100); + } + return result; + }; + }); + await setEditorContent(page, '# Older snapshot'); + await page.waitForFunction(() => window.__journalAfterOldSave); + const records = await page.evaluate(() => window.__journalAfterOldSave); + expect(records.some(record => record.content === '# Newer recovery draft')).toBe(true); + await expect(page.locator('#markdown-editor')).toHaveValue('# Newer recovery draft'); +}); + +test('IndexedDB compare-and-delete retains a replacement record and deletes a matching record', async ({ page }) => { + await openApp(page); + const result = await page.evaluate(async () => { + const storage = new window.MarkdownWorkspaceStorage(); + await storage.init(); + const old = { journalId: 'conditional-journal-test', documentId: 'test-document', content: 'OLD', savedAt: 1 }; + const newer = { ...old, content: 'NEW', savedAt: 2 }; + await storage.saveDirtyJournal(old); + await storage.saveDirtyJournal(newer); + await storage.deleteDirtyJournals([old.journalId], { expectedJournals: { [old.journalId]: old } }); + const retained = (await storage.listDirtyJournals()).find(record => record.journalId === old.journalId); + await storage.deleteDirtyJournals([old.journalId], { expectedJournals: { [old.journalId]: newer } }); + return { retained: retained?.content, remaining: (await storage.listDirtyJournals()).some(record => record.journalId === old.journalId) }; + }); + expect(result).toEqual({ retained: 'NEW', remaining: false }); +}); diff --git a/tests/e2e/responsive-storage.spec.js b/tests/e2e/responsive-storage.spec.js index 3c2cd1df..5b5dd5bb 100644 --- a/tests/e2e/responsive-storage.spec.js +++ b/tests/e2e/responsive-storage.spec.js @@ -40,8 +40,8 @@ test('theme switching stores and restores the selected theme', async ({ page }) await expect(themeSwitch).toHaveCSS('width', await privateModeSwitch.evaluate(element => getComputedStyle(element).width)); await expect(themeSwitch).toHaveCSS('height', await privateModeSwitch.evaluate(element => getComputedStyle(element).height)); await expect(themeSwitch.locator(':scope > span')).toHaveCSS('border-radius', '50%'); - await expect(themeSwitch.locator(':scope > span')).toHaveCSS('background-color', await page.locator('body').evaluate(element => getComputedStyle(element).backgroundColor)); - await expect(page.locator('#theme-switch-icon')).toHaveCSS('color', await page.locator('body').evaluate(element => getComputedStyle(element).color)); + await expect(themeSwitch.locator(':scope > span')).toHaveCSS('background-color', await resolveCssColor(page, '--bg-color')); + await expect(page.locator('#theme-switch-icon')).toHaveCSS('color', await resolveCssColor(page, '--text-color')); await themeToggle.click(); const toggledTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); @@ -49,8 +49,14 @@ test('theme switching stores and restores the selected theme', async ({ page }) expect(toggledTheme).not.toBe(initialTheme); await expect(themeToggle).toHaveAttribute('aria-pressed', String(toggledTheme === 'dark')); await expect(page.locator('#theme-switch-icon')).toHaveClass(new RegExp(`\\b${toggledThemeIcon}\\b`)); - await expect(themeSwitch.locator(':scope > span')).toHaveCSS('background-color', await page.locator('body').evaluate(element => getComputedStyle(element).backgroundColor)); - await expect(page.locator('#theme-switch-icon')).toHaveCSS('color', await page.locator('body').evaluate(element => getComputedStyle(element).color)); + // Assert the target theme color, not a one-time snapshot halfway through + // the body's transition. Playwright still waits for the icon to reach it. + const backgroundColor = await resolveCssColor(page, '--bg-color'); + const textColor = await resolveCssColor(page, '--text-color'); + await expect(page.locator('body')).toHaveCSS('background-color', backgroundColor); + await expect(page.locator('body')).toHaveCSS('color', textColor); + await expect(themeSwitch.locator(':scope > span')).toHaveCSS('background-color', backgroundColor); + await expect(page.locator('#theme-switch-icon')).toHaveCSS('color', textColor); await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('markdownViewerGlobalState') || '{}').theme)).toBe(toggledTheme); await page.reload(); diff --git a/tests/e2e/runtime-dependencies.spec.js b/tests/e2e/runtime-dependencies.spec.js new file mode 100644 index 00000000..73964923 --- /dev/null +++ b/tests/e2e/runtime-dependencies.spec.js @@ -0,0 +1,14 @@ +const { test, expect } = require('@playwright/test'); +const { openApp, setEditorContent } = require('../helpers/app'); + +test('updated YAML and sanitizer preserve frontmatter and reject active HTML', async ({ page }) => { + const errors = await openApp(page); + await setEditorContent(page, '---\ntitle: Dependency smoke test\ntags:\n - markdown\n - safety\n---\n\n# Safe preview\n\n**Formatted text**\n\n\n\nUnsafe link'); + const preview = page.locator('#markdown-preview'); + await expect(preview.locator('.frontmatter-table')).toContainText('Dependency smoke test'); + await expect(preview.locator('h1')).toHaveText('Safe preview'); + await expect(preview.locator('strong')).toContainText('Formatted text'); + await expect(preview.locator('[onerror], [href^="javascript:"]')).toHaveCount(0); + expect(await page.evaluate(() => window.__unsafePreview)).toBeUndefined(); + expect(errors).toEqual([]); +}); diff --git a/tests/e2e/slash-command-actions.spec.js b/tests/e2e/slash-command-actions.spec.js index 26f37ef2..ced20df8 100644 --- a/tests/e2e/slash-command-actions.spec.js +++ b/tests/e2e/slash-command-actions.spec.js @@ -118,6 +118,48 @@ for (const pane of ['primary', 'secondary']) { await expect(editor).toHaveValue(prefix + expected + suffix); } }); + test('table chooser does not steal focus from an edited row field', async ({ page }) => { + await page.evaluate(() => { + const nativeFrame = window.requestAnimationFrame; + window.__tableFocusFrames = []; + window.requestAnimationFrame = function(callback) { + if (String(callback).includes('columnInput.focus()')) { + window.__tableFocusFrames.push(callback); + return 0; + } + return nativeFrame.call(window, callback); + }; + }); + await enterCommand(page, editor, 'table', 'table'); + await page.locator('#table-modal-columns').fill('2'); + await page.locator('#table-modal-rows').fill('2'); + await page.evaluate(() => window.__tableFocusFrames.splice(0).forEach(callback => callback())); + await expect(page.locator('#table-modal-rows')).toBeFocused(); + await page.locator('#table-modal-insert').click(); + await expect(editor).toHaveValue(prefix + '| Column 1 | Column 2 |\n| --- | --- |\n| Value | Value |\n' + suffix); + }); + + test('media chooser does not steal focus from an edited field', async ({ page }) => { + await page.evaluate(() => { + const nativeFrame = window.requestAnimationFrame; + window.__mediaFocusFrames = []; + window.requestAnimationFrame = function(callback) { + if (String(callback).includes('fileInput.focus()')) { + window.__mediaFocusFrames.push(callback); + return 0; + } + return nativeFrame.call(window, callback); + }; + }); + await enterCommand(page, editor, 'image', 'image'); + await page.locator('#image-modal-url').fill('https://example.com/focus.mp4'); + await page.locator('#image-modal-alt').fill('Kept title'); + await page.evaluate(() => window.__mediaFocusFrames.splice(0).forEach(callback => callback())); + await expect(page.locator('#image-modal-alt')).toBeFocused(); + await page.locator('#image-modal-insert').click(); + await expect(editor).toHaveValue(prefix + '' + suffix); + }); + test('media upload stays in the command target editor (mock upload API)', async ({ page }) => { await page.route('**/api/image', route => route.fulfill({ json: { id: mediaId } })); await mockMediaDownload(page); @@ -154,6 +196,29 @@ for (const pane of ['primary', 'secondary']) { await expect(editor).toBeFocused(); }); + test('reference chooser does not steal focus from a field already being edited', async ({ page }) => { + // Capture the chooser's deferred focus callback without delaying slash-menu + // opening. Flushing it after a user selects another field exposes the race. + await page.evaluate(() => { + const nativeFrame = window.requestAnimationFrame; + window.__referenceFocusFrames = []; + window.requestAnimationFrame = function(callback) { + if (String(callback).includes('numberInput.focus()')) { + window.__referenceFocusFrames.push(callback); + return 0; + } + return nativeFrame.call(window, callback); + }; + }); + await enterCommand(page, editor, 'reference', 'reference'); + await page.locator('#reference-modal-url').fill('https://example.com/focus'); + await page.evaluate(() => window.__referenceFocusFrames.splice(0).forEach(callback => callback())); + await expect(page.locator('#reference-modal-url')).toBeFocused(); + await page.locator('#reference-modal-title-input').fill('Kept title'); + await page.locator('#reference-modal-apply').click(); + await expect(editor).toHaveValue(prefix + '[1]' + suffix + '\n[1]: https://example.com/focus "Kept title"'); + }); + test('cancels insertion when the target changes during upload', async ({ page }) => { let releaseUpload; const pendingUpload = new Promise(resolve => { releaseUpload = resolve; }); diff --git a/tests/e2e/trash.spec.js b/tests/e2e/trash.spec.js index 02703141..61af4f6b 100644 --- a/tests/e2e/trash.spec.js +++ b/tests/e2e/trash.spec.js @@ -148,6 +148,7 @@ test('automatic Trash cleanup removes only valid items older than 30 days', asyn test('Trash maintenance failure never blocks workspace initialization', async ({ page }) => { await openApp(page); + await page.evaluate(() => window.MarkdownViewerFlushWorkspace()); const result = await page.evaluate(async () => { const original = window.MarkdownWorkspaceStorage.prototype.purgeExpiredTrash; window.MarkdownWorkspaceStorage.prototype.purgeExpiredTrash = async function() { @@ -337,7 +338,7 @@ test('dedicated Trash restores, permanently deletes, and empties selected data', await page.locator('#trash-delete-button').click(); await expect(page.locator('#document-confirm-modal-title')).toHaveText('Permanently delete 2 files?'); await expect(page.locator('#document-confirm-modal-description')).toHaveText( - '“Deleted document” and “Delete from Trash” will be permanently removed from Trash immediately. This action cannot be undone.' + /^(?:“Deleted document” and “Delete from Trash”|“Delete from Trash” and “Deleted document”) will be permanently removed from Trash immediately\. This action cannot be undone\.$/ ); await page.locator('#document-confirm-modal-cancel').click(); await expect(page.locator('#trash-modal')).toHaveClass(/is-visible/); diff --git a/tests/e2e/workspace-maintenance.spec.js b/tests/e2e/workspace-maintenance.spec.js new file mode 100644 index 00000000..a9777716 --- /dev/null +++ b/tests/e2e/workspace-maintenance.spec.js @@ -0,0 +1,66 @@ +const { test, expect } = require('@playwright/test'); +const JSZip = require('jszip'); +const { openApp } = require('../helpers/app'); +const { installDesktopFilesystem } = require('../helpers/desktop-filesystem'); + +async function requestImport(page) { + const zip = new JSZip(); + zip.file('Workspace/New.md', '# replacement'); + zip.file('.markdown-viewer/documents.json', JSON.stringify([{ metadata: { id: 'replacement', title: 'Replacement', workspaceId: 'workspace_default' }, path: 'Workspace/New.md' }])); + zip.file('.markdown-viewer/organization.json', '{}'); + zip.file('.markdown-viewer/preferences.json', '{}'); + zip.file('markdown-viewer-backup.json', JSON.stringify({ format: 'markdown-viewer-backup', version: 1, normalDocumentCount: 1, secureRecordCount: 0 })); + await page.getByRole('button', { name: 'Open workspace settings' }).click(); + await page.locator('#storage-settings-button').click(); + await page.locator('#storage-backup-file-input').setInputFiles({ name: 'maintenance.zip', mimeType: 'application/zip', buffer: await zip.generateAsync({ type: 'nodebuffer' }) }); + await page.locator('#storage-import-confirm-confirm').click(); +} + +test.beforeEach(async ({ page }) => { + await page.route('**/libs/jszip.min.js', route => route.fulfill({ contentType: 'application/javascript', body: require('fs').readFileSync(require.resolve('jszip/dist/jszip.min.js')) })); + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await openApp(page); + await page.evaluate(async () => { + __desktopFiles.set('C:/notes.md', '# original'); + await NL_IMPORT_EXTERNAL_FILE('# original', 'notes', 'C:/notes.md'); + await MarkdownViewerFlushWorkspace(); + }); +}); + +test('import gates native launches, persists live drafts, and re-enables after safe failure', async ({ page }) => { + await page.locator('#markdown-editor').fill('# newest draft'); + await page.evaluate(() => { + MarkdownWorkspaceStorage.prototype.restoreBackupData = async function () { + window.__maintenanceReached = true; + await new Promise(resolve => { window.__releaseMaintenance = resolve; }); + throw new Error('Simulated restore failed safely'); + }; + }); + await requestImport(page); + await expect.poll(() => page.evaluate(() => window.__maintenanceReached)).toBe(true); + expect(await page.evaluate(async () => { + try { await MarkdownViewerFlushWorkspace(); return 'unexpected success'; } + catch (error) { return error.message; } + })).toContain('maintenance is still in progress'); + expect(await page.evaluate(async () => { + const linked = await NL_IMPORT_EXTERNAL_FILE('# late', 'late', 'C:/late.md'); + const storage = new MarkdownWorkspaceStorage(); await storage.init(); + const meta = (await storage.listDocumentMetadata()).find(x => x.title === 'notes'); + return { linked: !!linked, inert: document.querySelector('.app-container').inert, draft: await storage.loadDocumentContent(meta.id), original: __desktopFiles.get('C:/notes.md') }; + })).toEqual({ linked: false, inert: true, draft: '# newest draft', original: '# original' }); + await page.evaluate(() => __releaseMaintenance()); + await expect(page.locator('#import-progress-detail')).toContainText('Simulated restore failed safely'); + await expect.poll(() => page.evaluate(() => document.querySelector('.app-container').inert)).toBe(false); + await expect(page.locator('#markdown-editor')).toHaveValue('# newest draft'); +}); + +test('import does not resume editing when restore rollback also failed', async ({ page }) => { + await page.evaluate(() => { + MarkdownWorkspaceStorage.prototype.restoreBackupData = async () => { + const error = new Error('Failed restore'); error.rollbackError = new Error('Failed rollback'); throw error; + }; + }); + await requestImport(page); + await expect(page.locator('#import-progress-detail')).toContainText('Editing remains blocked'); + expect(await page.evaluate(() => document.querySelector('.app-container').inert)).toBe(true); +}); diff --git a/tests/helpers/app.js b/tests/helpers/app.js index f31a45d9..8e25474b 100644 --- a/tests/helpers/app.js +++ b/tests/helpers/app.js @@ -14,9 +14,9 @@ const localBrowserAssets = [ ['https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js', 'node_modules/marked/marked.min.js', 'application/javascript'], ['https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 'node_modules/@highlightjs/cdn-assets/highlight.min.js', 'application/javascript'], ['https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/powershell.min.js', 'node_modules/@highlightjs/cdn-assets/languages/powershell.min.js', 'application/javascript'], - ['https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.13/purify.min.js', 'node_modules/dompurify/dist/purify.min.js', 'application/javascript'], + ['https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.16/purify.min.js', 'node_modules/dompurify/dist/purify.min.js', 'application/javascript'], ['https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js', 'node_modules/file-saver/dist/FileSaver.min.js', 'application/javascript'], - ['https://cdn.jsdelivr.net/npm/js-yaml@4.3.1/dist/js-yaml.min.js', 'node_modules/js-yaml/dist/js-yaml.min.js', 'application/javascript'], + ['https://cdn.jsdelivr.net/npm/js-yaml@4.3.2/dist/js-yaml.min.js', 'node_modules/js-yaml/dist/js-yaml.min.js', 'application/javascript'], ['https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.bundle.min.js', 'node_modules/bootstrap/dist/js/bootstrap.bundle.min.js', 'application/javascript'], ['https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js', 'node_modules/jszip/dist/jszip.min.js', 'application/javascript'], ['https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js', 'node_modules/pako/dist/pako.min.js', 'application/javascript'] diff --git a/tests/helpers/desktop-filesystem.js b/tests/helpers/desktop-filesystem.js new file mode 100644 index 00000000..b8e8eacf --- /dev/null +++ b/tests/helpers/desktop-filesystem.js @@ -0,0 +1,72 @@ +// In-memory Neutralino filesystem for testing the real desktop storage backend. +function installDesktopFilesystem(target) { + const files = new Map(); + const directories = new Set(['C:', 'C:/Documents']); + const normalize = value => String(value).replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, ''); + const join = (...parts) => normalize(parts.filter(Boolean).join('/')); + const storageData = new Map(); + target.__desktopFiles = files; + target.NL_PORT = 1; + target.Neutralino = { + os: { getPath: async () => 'C:/Documents', open: async () => true }, + events: { on: async () => true }, + storage: { + getData: async key => { + if (!storageData.has(key)) throw new Error('missing'); + return storageData.get(key); + }, + setData: async (key, value) => storageData.set(key, value), + removeData: async key => storageData.delete(key) + }, + filesystem: { + getJoinedPath: async (...parts) => join(...parts), + getStats: async path => { + path = normalize(path); + if (directories.has(path)) return { isDirectory: true, size: 0, modifiedAt: Date.now() }; + if (files.has(path)) return { isDirectory: false, isFile: true, size: new TextEncoder().encode(files.get(path)).byteLength, modifiedAt: Date.now() }; + throw Object.assign(new Error('missing'), { code: 'NE_FS_NOPATHE' }); + }, + createDirectory: async path => directories.add(normalize(path)), + readFile: async (path, options) => { + path = normalize(path); + if (!files.has(path)) throw new Error('missing'); + const bytes = new TextEncoder().encode(files.get(path)); + const pos = options && options.pos || 0; + return new TextDecoder().decode(bytes.slice(pos, options && options.size !== undefined ? pos + options.size : undefined)); + }, + writeFile: async (path, value) => files.set(normalize(path), String(value)), + copy: async (source, destination) => files.set(normalize(destination), files.get(normalize(source))), + move: async (source, destination) => { + source = normalize(source); + destination = normalize(destination); + if (!files.has(source)) throw new Error('missing'); + files.set(destination, files.get(source)); + files.delete(source); + }, + remove: async path => { + path = normalize(path); + files.delete(path); + directories.delete(path); + }, + readDirectory: async path => { + const prefix = normalize(path) + '/'; + const entries = new Map(); + directories.forEach(candidate => { + if (!candidate.startsWith(prefix)) return; + const rest = candidate.slice(prefix.length); + if (rest && !rest.includes('/')) entries.set(rest, { entry: rest, type: 'DIRECTORY' }); + }); + files.forEach((_, candidate) => { + if (!candidate.startsWith(prefix)) return; + const rest = candidate.slice(prefix.length); + if (rest && !rest.includes('/')) entries.set(rest, { entry: rest, type: 'FILE' }); + }); + return Array.from(entries.values()); + }, + createWatcher: async () => 1, + removeWatcher: async () => true + } + }; +} + +module.exports = { installDesktopFilesystem }; diff --git a/tests/unit/desktop-move-safety.test.mjs b/tests/unit/desktop-move-safety.test.mjs new file mode 100644 index 00000000..216eae12 --- /dev/null +++ b/tests/unit/desktop-move-safety.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import { webcrypto } from 'node:crypto'; +import mock from '../helpers/desktop-filesystem.js'; +const source = fs.readFileSync(new URL('../../workspace-storage.js', import.meta.url), 'utf8'); +async function setup() { + const target = {crypto:webcrypto, TextEncoder, console, NL_OS:'Windows', localStorage:{getItem:()=>null,removeItem(){}}}; + target.window=target; target.self=target; mock.installDesktopFilesystem(target); + vm.runInNewContext(source,target); + const storage = new target.MarkdownWorkspaceStorage(); await storage.init(); + const tab = {id:'moving',title:'Before',content:'# original',contentLoaded:true,workspaceId:'workspace_default',_storageRevision:0}; + await storage.saveDocuments([tab],{folders:[]},{forceContent:true}); + const original = storage.vaultPath + '/' + tab._vaultRelativePath; + const index = target.__desktopFiles.get(storage.vaultPath+'/.markdown-viewer/index.json'); + tab.title='After'; + const destination = storage.vaultPath + '/' + await storage._desktopDocumentRelativePath(tab,{folders:[]}); + return {target,storage,tab,original,destination,index,save:()=>storage.saveDocuments([tab],{folders:[]},{changedIds:[tab.id]})}; +} +test('occupied move destination preserves both files and committed index', async () => { + const s=await setup(); s.target.__desktopFiles.set(s.destination,'# unrelated existing file'); + await assert.rejects(s.save(),/destination.*exists/i); + assert.equal(s.target.__desktopFiles.get(s.original),'# original'); + assert.equal(s.target.__desktopFiles.get(s.destination),'# unrelated existing file'); + assert.equal(s.target.__desktopFiles.get(s.storage.vaultPath+'/.markdown-viewer/index.json'),s.index); +}); +test('missing move source does not silently create a replacement body', async () => { + const s=await setup(); s.target.__desktopFiles.delete(s.original); + await assert.rejects(s.save(),/source.*missing/i); + assert.equal(s.target.__desktopFiles.has(s.destination),false); +}); +test('stat failure with an existing destination entry must not permit overwrite', async () => { + const s=await setup(); s.target.__desktopFiles.set(s.destination,'# private existing file'); + const stat=s.target.Neutralino.filesystem.getStats; + s.target.Neutralino.filesystem.getStats=async p=>{if(p===s.destination)throw {code:'NE_FS_NOPATHE'}; return stat(p);}; + await assert.rejects(s.save(),/cannot be inspected/i); + assert.equal(s.target.__desktopFiles.get(s.original),'# original'); + assert.equal(s.target.__desktopFiles.get(s.destination),'# private existing file'); +}); +test('destination appearing while move journal is written is not overwritten', async () => { + const s=await setup(); const write=s.target.Neutralino.filesystem.writeFile; + s.target.Neutralino.filesystem.writeFile=async(p,text)=>{await write(p,text); if(p.endsWith('.move.json'))s.target.__desktopFiles.set(s.destination,'# externally created');}; + await assert.rejects(s.save(),/destination.*exists/i); + assert.equal(s.target.__desktopFiles.get(s.original),'# original'); + assert.equal(s.target.__desktopFiles.get(s.destination),'# externally created'); +}); +test('normal document rename persists and reopens its body', async () => { + const s=await setup(); await s.save(); + assert.equal(s.target.__desktopFiles.has(s.original),false); + assert.equal(s.target.__desktopFiles.get(s.destination),'# original'); + const reopened=new s.target.MarkdownWorkspaceStorage(); await reopened.init(); + assert.equal(await reopened.loadDocumentContent(s.tab.id),'# original'); +}); +test('move permission failure leaves source and index unchanged', async () => { + const s=await setup(); s.target.Neutralino.filesystem.move=async()=>{throw new Error('Access denied');}; + await assert.rejects(s.save(),/Access denied/); + assert.equal(s.target.__desktopFiles.get(s.original),'# original'); + assert.equal(s.target.__desktopFiles.has(s.destination),false); + assert.equal(s.target.__desktopFiles.get(s.storage.vaultPath+'/.markdown-viewer/index.json'),s.index); + const reopened=new s.target.MarkdownWorkspaceStorage(); await reopened.init(); + assert.equal(await reopened.loadDocumentContent(s.tab.id),'# original'); +}); +test('unavailable parent listing stops move rather than assuming absence', async () => { + const s=await setup(); s.target.Neutralino.filesystem.readDirectory=async()=>{throw new Error('Parent access denied');}; + await assert.rejects(s.save(),/Parent access denied/); + assert.equal(s.target.__desktopFiles.get(s.original),'# original'); + assert.equal(s.target.__desktopFiles.has(s.destination),false); +}); diff --git a/tests/unit/document-delete-races.test.mjs b/tests/unit/document-delete-races.test.mjs new file mode 100644 index 00000000..09e7e95a --- /dev/null +++ b/tests/unit/document-delete-races.test.mjs @@ -0,0 +1,55 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +function setup() { + const source = readFileSync(new URL('../../script.js', import.meta.url), 'utf8'); + const start = source.indexOf(' let documentDeletionChain ='); + const end = source.indexOf(' function closeTab(', start); + const deleted = [], gates = []; + const context = { tabs: [{ id: 'A' }, { id: 'B' }, { id: 'C' }], activeTabId: null, + secondarySplitTabId: null, pendingReviewDelete: null, liveCollaboration: null, + SECRET_WORKSPACE_ID: 'secret', selectedDocumentId: null, + workspacePersistenceChain: Promise.resolve(), workspaceMaintenancePending: false, suspendWorkspacePersistence: false, + shareSnapshotViewOnlyTabIds: new Set(), tabHistories: {}, console, + workspaceStorage: { deleteDocument: async id => deleted.push(id) }, + _flushTabsToStorage: () => new Promise(resolve => gates.push(resolve)), + isPrivateStorageMode: () => false, isTemporaryDocument: () => false }; + for (const name of ['saveSecondarySplitState', 'markReleaseNotesSeen', 'clearDirtyDocumentJournal', + 'renderDocumentSplitView', 'renderTabBar', 'closeReviewComposer', 'renderReviewPanel', 'renderDocumentSidebar', 'showAppToast']) context[name] = () => {}; + const remove = vm.runInNewContext(source.slice(start, end) + '\ndeleteTab;', context); + return { context, remove, deleted, gates }; +} +const tick = () => new Promise(resolve => setImmediate(resolve)); + +test('simultaneous deletions serialize and remove only the requested IDs', async () => { + const s = setup(); + const a = s.remove('A'), b = s.remove('B'); + await tick(); assert.equal(s.gates.length, 1); + s.gates[0](true); assert.equal(await a, true); + await tick(); s.gates[1](true); assert.equal(await b, true); + assert.deepEqual(s.deleted, ['A', 'B']); + assert.deepEqual(s.context.tabs.map(x => x.id), ['C']); +}); + +test('another list mutation during a deletion cannot remove a different document', async () => { + const s = setup(); const b = s.remove('B'); await tick(); + s.context.tabs.shift(); s.gates[0](true); await b; + assert.deepEqual(s.context.tabs.map(x => x.id), ['C']); +}); + +test('duplicate delete requests do not double-delete or remove a neighbour', async () => { + const s = setup(); const a = s.remove('A'), again = s.remove('A'); + await tick(); s.gates[0](true); await a; + assert.equal(await again, false); + assert.deepEqual(s.deleted, ['A']); + assert.deepEqual(s.context.tabs.map(x => x.id), ['B', 'C']); +}); + +test('a cancelled delete does not poison the deletion queue', async () => { + const s = setup(); const a = s.remove('A'), b = s.remove('B'); + await tick(); s.gates[0](false); assert.equal(await a, false); + await tick(); s.gates[1](true); await b; + assert.deepEqual(s.context.tabs.map(x => x.id), ['A', 'C']); +}); diff --git a/tests/unit/recovery-journal-commit.test.mjs b/tests/unit/recovery-journal-commit.test.mjs new file mode 100644 index 00000000..4f7e4c31 --- /dev/null +++ b/tests/unit/recovery-journal-commit.test.mjs @@ -0,0 +1,125 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +const source = fs.readFileSync(new URL('../../script.js', import.meta.url), 'utf8'); +function productionFunction(name) { + const start = source.indexOf(` function ${name}(`) >= 0 + ? source.indexOf(` function ${name}(`) : source.indexOf(` async function ${name}(`); + assert.ok(start >= 0, `Missing production function ${name}`); + const end = source.indexOf('\n }', start) + 4; + return source.slice(start, end); +} +function storage() { + const records = new Map(); + return { getItem: key => records.get(key) ?? null, setItem: (key, value) => records.set(key, value), removeItem: key => records.delete(key) }; +} +function setup() { + const tab = { id: 'notes', content: 'OLD', contentLoaded: true }; + const target = { + tabs: [tab], documentOrganization: {}, localStorage: storage(), sessionStorage: storage(), + DIRTY_DOCUMENT_JOURNAL_PREFIX: 'journal:', workspaceWriterId: 'writer', + saveTabStateTimeout: null, suspendWorkspacePersistence: false, + pendingWorkspacePersistAll: false, pendingWorkspacePersistIds: new Set(), + workspacePersistenceChain: Promise.resolve(), clearTimeout() {}, + getTabsForStorage: tabs => tabs, isPrivateStorageMode: () => false, + evictInactiveDocumentContent() {}, updateSaveStatus() {}, + preserveWorkspaceConflict: async () => false, showAppToast() {}, + console: { warn() {} }, saved: [], journals: new Map() + }; + target.workspaceStorage = { + async saveDocuments(tabs) { target.saved.push(tabs[0].content); }, + async listDirtyJournals() { return [...target.journals.values()]; }, + async deleteDirtyJournals(ids, options) { + for (const id of ids) { + const current = target.journals.get(id); + if (!options || !options.expectedJournals || JSON.stringify(current) === JSON.stringify(options.expectedJournals[id])) target.journals.delete(id); + } + } + }; + const names = ['getDirtyDocumentJournalKey', 'clearDirtyDocumentJournal', '_flushTabsToStorage']; + if (source.includes('function captureDirtyDocumentJournalSnapshot(')) names.unshift('captureDirtyDocumentJournalSnapshot'); + vm.createContext(target); + vm.runInContext(names.map(productionFunction).join('\n'), target); + tab._dirtyJournalKeys = ['journal:writer:notes']; + return { target, tab, key: tab._dirtyJournalKeys[0] }; +} +function journal(content, savedAt = 1) { return { documentId: 'notes', content, savedAt }; } + +test('a newer comments-only journal survives an older save of unchanged Markdown', async () => { + const { target, tab, key } = setup(); + tab.reviewThreads = [{ body: 'OLD COMMENT' }]; + target.localStorage.setItem(key, JSON.stringify({ ...journal('OLD'), metadata: { reviewThreads: [{ body: 'NEW COMMENT' }] } })); + await target._flushTabsToStorage([tab], { changedIds: [tab.id] }); + assert.equal(JSON.parse(target.localStorage.getItem(key)).metadata.reviewThreads[0].body, 'NEW COMMENT'); +}); + +test('a matching comments journal clears only after its save succeeds', async () => { + const { target, tab, key } = setup(); + tab.reviewThreads = [{ body: 'COMMENT' }]; + target.localStorage.setItem(key, JSON.stringify({ ...journal('OLD'), metadata: { reviewThreads: tab.reviewThreads } })); + await target._flushTabsToStorage([tab], { changedIds: [tab.id] }); + assert.equal(target.localStorage.getItem(key), null); +}); + +for (const location of ['localStorage', 'sessionStorage', 'indexeddb']) { + test(`older save must retain newer ${location} recovery journal`, async () => { + const { target, tab, key } = setup(); + if (location === 'indexeddb') target.journals.set(key, { journalId: key, ...journal('OLD') }); + else target[location].setItem(key, JSON.stringify(journal('OLD'))); + target.workspaceStorage.saveDocuments = async () => { + target.saved.push('OLD'); + tab.content = 'NEW'; + if (location === 'indexeddb') target.journals.set(key, { journalId: key, ...journal('NEW', 2) }); + else target[location].setItem(key, JSON.stringify(journal('NEW', 2))); + }; + assert.equal(await target._flushTabsToStorage([tab], { changedIds: [tab.id] }), true); + const current = location === 'indexeddb' ? target.journals.get(key) : JSON.parse(target[location].getItem(key)); + assert.equal(current?.content, 'NEW'); + }); +} + +test('successful save clears matching journals but retains a journal newer than tab.content', async () => { + const { target, tab, key } = setup(); + target.localStorage.setItem(key, JSON.stringify(journal('OLD'))); + target.sessionStorage.setItem(key, JSON.stringify(journal('NEW', 2))); + target.journals.set(key, { journalId: key, ...journal('OLD') }); + assert.equal(await target._flushTabsToStorage([tab], { changedIds: [tab.id] }), true); + assert.equal(target.localStorage.getItem(key), null); + assert.equal(target.journals.has(key), false); + assert.equal(JSON.parse(target.sessionStorage.getItem(key)).content, 'NEW'); +}); + +test('failed save preserves the existing recovery journal', async () => { + const { target, tab, key } = setup(); + target.localStorage.setItem(key, JSON.stringify(journal('OLD'))); + target.workspaceStorage.saveDocuments = async () => { throw Error('simulated failure'); }; + assert.equal(await target._flushTabsToStorage([tab], { changedIds: [tab.id] }), false); + assert.equal(JSON.parse(target.localStorage.getItem(key)).content, 'OLD'); +}); + +test('metadata-only save must not discard recovery content', async () => { + const { target, tab, key } = setup(); + tab.contentLoaded = false; + tab.content = undefined; + target.localStorage.setItem(key, JSON.stringify(journal('RECOVER ME'))); + assert.equal(await target._flushTabsToStorage([tab], { changedIds: [tab.id] }), true); + assert.equal(JSON.parse(target.localStorage.getItem(key)).content, 'RECOVER ME'); +}); + +test('a newer journal survives an old save, then is cleared by its own successful save', async () => { + const { target, tab, key } = setup(); + target.localStorage.setItem(key, JSON.stringify(journal('OLD'))); + const normalSave = target.workspaceStorage.saveDocuments; + target.workspaceStorage.saveDocuments = async () => { + tab.content = 'NEW'; + target.localStorage.setItem(key, JSON.stringify(journal('NEW', 2))); + }; + await target._flushTabsToStorage([tab], { changedIds: [tab.id] }); + assert.equal(JSON.parse(target.localStorage.getItem(key)).content, 'NEW'); + target.workspaceStorage.saveDocuments = normalSave; + await target._flushTabsToStorage([tab], { changedIds: [tab.id] }); + assert.equal(target.localStorage.getItem(key), null); + assert.equal(target.saved.at(-1), 'NEW'); +}); diff --git a/tests/unit/runtime-dependencies.test.mjs b/tests/unit/runtime-dependencies.test.mjs new file mode 100644 index 00000000..4bfe40b6 --- /dev/null +++ b/tests/unit/runtime-dependencies.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import yaml from 'js-yaml'; + +const root = new URL('../../', import.meta.url); +const read = relative => fs.readFileSync(new URL(relative, root)); +const manifest = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const html = read('index.html').toString(); +const fixtures = read('tests/helpers/app.js').toString(); + +for (const [name, version, url, asset] of [ + ['dompurify', '3.4.16', 'https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.16/purify.min.js', 'dist/purify.min.js'], + ['js-yaml', '4.3.2', 'https://cdn.jsdelivr.net/npm/js-yaml@4.3.2/dist/js-yaml.min.js', 'dist/js-yaml.min.js'] +]) { + test(`${name}: package, lockfile, browser URL and SRI agree`, () => { + assert.equal(manifest.devDependencies[name], version); + assert.equal(lock.packages[''].devDependencies[name], version); + assert.equal(lock.packages[`node_modules/${name}`].version, version); + assert.equal(JSON.parse(read(`node_modules/${name}/package.json`)).version, version); + const tag = html.match(/]*>/g).find(tag => tag.includes(`src="${url}"`)); + assert.ok(tag, 'Missing pinned CDN script'); + const expected = 'sha384-' + crypto.createHash('sha384') + .update(read(`node_modules/${name}/${asset}`)).digest('base64'); + assert.ok(tag.includes(`integrity="${expected}"`), 'SRI differs from installed browser asset'); + assert.ok(fixtures.includes(url), 'Browser fixtures must route the same CDN URL'); + }); +} + +test('YAML counts empty merge sources against the configured work limit', () => { + // Bounded regression: no expensive denial-of-service payload is necessary. + const document = 'sources: &sources [{}, {}, {}]\nvalue:\n <<: *sources\n'; + assert.throws(() => yaml.load(document, { maxTotalMergeKeys: 2 }), /maxTotalMergeKeys/); + assert.deepEqual(yaml.load(document, { maxTotalMergeKeys: 3 }).value, {}); +}); diff --git a/tests/unit/workspace-maintenance.test.mjs b/tests/unit/workspace-maintenance.test.mjs new file mode 100644 index 00000000..9284db97 --- /dev/null +++ b/tests/unit/workspace-maintenance.test.mjs @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +function setup() { + const source = readFileSync(new URL('../../script.js', import.meta.url), 'utf8'); + const start = source.indexOf(' async function beginWorkspaceMaintenance('); + const end = source.indexOf('\n // Browser lifecycle', start); + const container = { inert: false }, events = []; + const state = { + workspaceMaintenancePending: false, suspendWorkspacePersistence: false, + workspaceMaintenancePreviousInert: false, liveCollaboration: null, nativeMarkdownSaveInProgress: false, + saveTabStateTimeout: null, secretWorkspaceSaveTimeout: null, + documentDeletionChain: Promise.resolve(), secretWorkspaceSaveChain: Promise.resolve(), + organizationPersistenceChain: Promise.resolve(true), + document: { querySelector: () => container }, clearTimeout, + saveSecondarySplitState: () => events.push('split'), + flushWorkspaceBeforeExit: async options => { assert.equal(options.allowMaintenance, true); events.push('flush'); } + }; + vm.runInNewContext(source.slice(start, end) + '\nthis.begin = beginWorkspaceMaintenance; this.end = endWorkspaceMaintenance;', state); + return { state, container, events }; +} + +test('maintenance waits for deletions and flushes split drafts before suspension', async () => { + const { state, container, events } = setup(); let release; + state.documentDeletionChain = new Promise(resolve => { release = resolve; }); + const pending = state.begin(); + assert.equal(state.workspaceMaintenancePending, true); assert.equal(container.inert, true); + assert.equal(state.suspendWorkspacePersistence, false); assert.deepEqual(events, []); + release(); await pending; assert.deepEqual(events, ['split', 'flush']); + assert.equal(state.suspendWorkspacePersistence, true); + state.end(); assert.equal(container.inert, false); assert.equal(state.workspaceMaintenancePending, false); +}); + +test('failed draft persistence re-enables editing before destructive work', async () => { + const { state, container } = setup(); state.flushWorkspaceBeforeExit = async () => { throw new Error('disk full'); }; + await assert.rejects(state.begin(), /disk full/); + assert.equal(state.workspaceMaintenancePending, false); assert.equal(state.suspendWorkspacePersistence, false); + assert.equal(container.inert, false); +}); + +test('a second maintenance request cannot unblock the first request', async () => { + const { state, container } = setup(); await state.begin(); + await assert.rejects(state.begin(), /already active/); + assert.equal(state.suspendWorkspacePersistence, true); assert.equal(container.inert, true); +}); + +test('live collaboration prevents maintenance without taking ownership', async () => { + const { state, container } = setup(); state.liveCollaboration = {}; + await assert.rejects(state.begin(), /live collaboration/); + assert.equal(state.workspaceMaintenancePending, false); assert.equal(container.inert, false); +}); + +test('ending maintenance preserves an existing inert state', async () => { + const { state, container } = setup(); container.inert = true; + await state.begin(); state.end(); assert.equal(container.inert, true); +}); + +test('an active desktop Save prevents workspace replacement', async () => { + const { state, container } = setup(); state.nativeMarkdownSaveInProgress = true; + await assert.rejects(state.begin(), /Save is still in progress/); + assert.equal(state.workspaceMaintenancePending, false); assert.equal(container.inert, false); +}); diff --git a/wiki/Privacy-and-Security.md b/wiki/Privacy-and-Security.md index 6b918d67..e4ab874f 100644 --- a/wiki/Privacy-and-Security.md +++ b/wiki/Privacy-and-Security.md @@ -33,6 +33,17 @@ Temporary Share Snapshot and Live Share participant tabs are removed before norm Browser storage quotas and user-cleared site data still apply. The application cannot recover data after the browser, operating system, user, or storage policy deletes it. +Normal-document recovery journals retain newer edits and review comments when an +older save completes. Matching committed records are cleared conditionally; a +failed save retains its recovery records. Recovery is local and is not a substitute +for an external backup. Secret Workspace recovery continues using encrypted records. + +Document saves and deletions are coordinated to avoid restoring a document through +an obsolete queued save. Desktop moves check their source and destination before +moving a document and reject occupied destinations. These checks are not filesystem +locks or atomic compare-and-swap guarantees; another process can still change files +after a check. + Deleted normal documents and encrypted Secret Workspace recovery records remain in the application Trash for 30 days. Users can restore one item, permanently delete one item, or empty Trash after an explicit confirmation. Automatic cleanup requires a valid deletion timestamp and a recognized, structurally valid Trash record; missing, corrupt, or future-format records are retained rather than guessed at and deleted. Trash is local recovery storage, not a backup, and is excluded from exported workspace ZIP files. ## Private Mode, Reset Workspace, and Secret Workspace @@ -48,6 +59,13 @@ These controls have different purposes: Both **Reset workspace** and **Reset Secret Workspace** are destructive to encrypted content. Reset workspace clears everything; Reset Secret Workspace targets only the encrypted area. +Whole-workspace reset and backup restore temporarily block editing and new imports, +wait for pending document deletions, and flush current drafts before replacement. +Failure during preparation re-enables editing without starting replacement. If a +restore and its rollback both fail, editing remains blocked until reload so the +application cannot continue writing an uncertain workspace state. These safeguards +do not make reset reversible or guarantee recovery after storage failure. + Secret Workspace uses PBKDF2 with SHA-256, 250,000 iterations, a random salt, and AES-GCM with a 256-bit derived key. The key remains in memory only while the Workspace is unlocked. File contents and folder names are encrypted; item counts, salt, initialization vector, iteration count, and format version remain outside the ciphertext as metadata. A forgotten password cannot be recovered. Secret Workspace protects the stored payload from casual inspection at rest. It does not protect content after you unlock it, export it, send it through a network feature, or expose the running device or browser session. diff --git a/workspace-storage.js b/workspace-storage.js index df3eb067..b8dc26b1 100644 --- a/workspace-storage.js +++ b/workspace-storage.js @@ -1229,6 +1229,37 @@ tab._storageContentJournalPaths = Array.from(new Set((tab._storageContentJournalPaths || []).concat(journalPath))); } + async _desktopInspectMovePath(path) { + try { return await Neutralino.filesystem.getStats(path); } + catch (error) { + if (!error || error.code !== 'NE_FS_NOPATHE') throw error; + // Native stat can report missing for an inaccessible entry. Confirm + // absence using its parent; never turn permission errors into absence. + const normalized = normalizePathSeparators(path); + const separator = normalized.lastIndexOf('/'); + if (separator < 0) throw error; + const entries = await Neutralino.filesystem.readDirectory(normalized.slice(0, separator)); + const name = normalized.slice(separator + 1); + const insensitive = typeof NL_OS !== 'undefined' && NL_OS === 'Windows'; + if (entries.some(function(entry) { + return insensitive ? String(entry.entry).toLowerCase() === name.toLowerCase() : entry.entry === name; + })) { + throw Object.assign(new Error('Move cancelled: a file exists but cannot be inspected at ' + path + '. No existing file was replaced.'), { code: 'WORKSPACE_MOVE_BLOCKED' }); + } + return null; + } + } + + async _desktopCheckMovePaths(source, destination) { + const stats = await this._desktopInspectMovePath(source); + if (!stats || stats.isDirectory || stats.isFile === false) { + throw Object.assign(new Error('Move cancelled: the source is missing or is not a file at ' + source + '. No replacement was created.'), { code: 'WORKSPACE_MOVE_BLOCKED' }); + } + if (await this._desktopInspectMovePath(destination)) { + throw Object.assign(new Error('Move cancelled: the destination already exists at ' + destination + '. Both files were left unchanged.'), { code: 'WORKSPACE_MOVE_BLOCKED' }); + } + } + async _desktopSaveDocument(tab, organization, forceContent) { requireDocumentId(tab && tab.id); const existing = this.vaultIndex.documents.find(function (item) { return item.id === tab.id; }); @@ -1254,6 +1285,7 @@ if (existing && existing.vaultRelativePath && existing.vaultRelativePath !== relativePath) { const oldPath = await this._desktopResolveVaultRelativePath(existing.vaultRelativePath); + await this._desktopCheckMovePaths(oldPath, fullPath); const journalDirectory = await this._pathJoin(this.vaultPath, INTERNAL_DIR, 'journal'); const moveJournalPath = await this._pathJoin(journalDirectory, sanitizePathSegment(tab.id, 'document') + '.move.json'); await this._writeJsonFile(moveJournalPath, { @@ -1266,14 +1298,10 @@ startedAt: Date.now() }); tab._storageMoveJournalPath = moveJournalPath; - if (await this._pathExists(oldPath)) { - if (await this._pathExists(fullPath)) { - await Neutralino.filesystem.writeFile(fullPath, typeof tab.content === 'string' ? tab.content : await Neutralino.filesystem.readFile(oldPath)); - await Neutralino.filesystem.remove(oldPath); - } else { - await Neutralino.filesystem.move(oldPath, fullPath); - } - } + // Recheck after asynchronous journal writing. Do not overwrite a + // collision, delete the source or fall back to a destructive copy. + await this._desktopCheckMovePaths(oldPath, fullPath); + await Neutralino.filesystem.move(oldPath, fullPath); } if (forceContent || tab.contentLoaded !== false) { const content = typeof tab.content === 'string' ? tab.content : ''; @@ -1861,14 +1889,28 @@ return records; } - async deleteDirtyJournals(journalIds) { + async deleteDirtyJournals(journalIds, options) { if (this.desktop) return; const ids = Array.isArray(journalIds) ? journalIds.filter(Boolean) : []; if (!ids.length) return; const transaction = this.db.transaction('journals', 'readwrite'); + const completion = transactionToPromise(transaction); const store = transaction.objectStore('journals'); - ids.forEach(function(id) { store.delete(id); }); - await transactionToPromise(transaction); + const expected = options && options.expectedJournals; + ids.forEach(function(id) { + if (!expected) { + store.delete(id); + return; + } + if (!Object.prototype.hasOwnProperty.call(expected, id)) return; + const request = store.get(id); + request.onsuccess = function() { + // The read and conditional delete share a readwrite transaction, + // so a concurrent replacement cannot be deleted between them. + if (JSON.stringify(request.result) === JSON.stringify(expected[id])) store.delete(id); + }; + }); + await completion; } async restoreTrashItem(trashId) {