import { type FilePathWithPrefix, type DocumentID, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, type FilePath, type EntryDoc, type diff_result, } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { createBlob, escapeMarkdownValue, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger"; import { shouldBeIgnored } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; import { Menu, diff_match_patch, setIcon } from "@/deps.ts"; import { $msg } from "@/common/translation"; import { Semaphore } from "octagonal-wheels/concurrency/semaphore"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; import { EVENT_ANALYSE_DB_USAGE, EVENT_REQUEST_CHECK_REMOTE_SIZE, EVENT_REQUEST_RUN_DOCTOR, EVENT_REQUEST_RUN_FIX_INCOMPLETE, eventHub, } from "@/common/events.ts"; import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts"; import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { PageFunctions } from "./SettingPane.ts"; import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; import { chooseAndCopyFileDatabaseInfo, collectFileDatabaseInfoPaths, copyFileDatabaseInfo, retryReadFileDatabaseRevision, } from "@/serviceFeatures/fileDatabaseInfo.ts"; import { discardLiveBranch, discardUnreadableLiveRevision, inspectFileRepair, type FileRepairInspection, type FileRepairRevision, } from "@/serviceFeatures/fileRepair.ts"; import { getFileRepairRevisionActions, getFileRepairRevisionComparison, } from "@/serviceFeatures/fileRepairPresentation.ts"; import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts"; import { inspectMetadataDocumentIdentities, MetadataDocumentRepairResults, OfflineScanUnresolvedReasons, repairMetadataDocumentIdentity, type MetadataDocumentIdentityIssue, } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; import { metadataIdentityPathKey, selectUnresolvedMetadataIdentityEntries, } from "@/serviceFeatures/metadataIdentityInspection.ts"; import { executeMetadataIdentityRepair, MetadataIdentityRepairExecutions, } from "@/serviceFeatures/metadataIdentityRepair.ts"; export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void { // const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` }); // hatchWarn.addClass("op-warn-info"); void addPanel(paneEl, $msg("Setting.TroubleShooting")).then((paneEl) => { new Setting(paneEl) .setName($msg("Setting.TroubleShooting.Doctor")) .setDesc($msg("Setting.TroubleShooting.Doctor.Desc")) .addButton((button) => button .setButtonText($msg("Run Doctor")) .setCta() .setDisabled(false) .onClick(() => { this.closeSetting(); eventHub.emitEvent(EVENT_REQUEST_RUN_DOCTOR, "you wanted(Thank you)!"); }) ); new Setting(paneEl) .setName($msg("Setting.TroubleShooting.ScanBrokenFiles")) .setDesc($msg("Setting.TroubleShooting.ScanBrokenFiles.Desc")) .addButton((button) => button .setButtonText("Scan for Broken files") .setCta() .setDisabled(false) .onClick(() => { this.closeSetting(); eventHub.emitEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE); }) ); new Setting(paneEl).setName($msg("Prepare the 'report' to create an issue")).addButton((button) => button .setButtonText($msg("Copy Report to clipboard")) .setCta() .setDisabled(false) .onClick(async () => { await this.app.commands.executeCommandById("obsidian-livesync:dump-debug-info"); }) ); new Setting(paneEl) .setName($msg("Copy database information for a file")) .setDesc( $msg( "Copy revision, conflict, and local chunk availability information, including document and chunk identifiers but not file contents." ) ) .addButton((button) => button.setButtonText($msg("Choose file")).onClick(async () => { await chooseAndCopyFileDatabaseInfo(this.core); }) ); new Setting(paneEl) .setName($msg("Analyse database usage")) .setDesc( $msg( "Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like." ) ) .addButton((button) => button.setButtonText($msg("Analyse")).onClick(() => { eventHub.emitEvent(EVENT_ANALYSE_DB_USAGE); }) ); new Setting(paneEl) .setName($msg("Reset notification threshold and check the remote database usage")) .setDesc($msg("Reset the remote storage size threshold and check the remote storage size again.")) .addButton((button) => button.setButtonText($msg("Check")).onClick(() => { eventHub.emitEvent(EVENT_REQUEST_CHECK_REMOTE_SIZE); }) ); new Setting(paneEl).autoWireToggle("writeLogToTheFile"); }); void addPanel(paneEl, "Scram Switches").then((paneEl) => { new Setting(paneEl).autoWireToggle("suspendFileWatching"); this.addOnSaved("suspendFileWatching", () => this.services.appLifecycle.askRestart()); new Setting(paneEl).autoWireToggle("suspendParseReplicationResult"); this.addOnSaved("suspendParseReplicationResult", () => this.services.appLifecycle.askRestart()); }); void addPanel(paneEl, "Recovery and Repair").then((paneEl) => { const resultArea = paneEl.createDiv({ text: "", cls: "sls-repair-results" }); type RepairMenuAction = { title: string; run: () => Promise | void; warning?: boolean; }; const addActionMenu = ( parent: HTMLElement, label: string, actions: RepairMenuAction[] ) => { if (actions.length === 0) { return; } this.createEl(parent, "button", { cls: "sls-repair-action-menu" }, (button) => { setIcon(button, "wrench"); button.setAttr("aria-label", label); button.setAttr("title", label); button.onClickEvent(() => { const menu = new Menu(); for (const action of actions) { menu.addItem((item) => { item.setTitle(action.title); if (action.warning) { item.setWarning(true); } item.onClick(() => { button.disabled = true; void Promise.resolve() .then(() => action.run()) .catch((error) => { Logger(error, LOG_LEVEL_VERBOSE); Logger( `Repair action '${action.title}' failed`, LOG_LEVEL_NOTICE ); }) .finally(() => { if (button.isConnected) { button.disabled = false; } }); }); }); } const rect = button.getBoundingClientRect(); menu.showAtPosition({ x: rect.left, y: rect.bottom }); }); }); }; const addMetadataIdentityResult = (entry: MetadataDocumentIdentityIssue) => { const { diagnostic } = entry.inspection; const card = this.createEl(resultArea, "div", { cls: "sls-repair-result" }); this.createEl(card, "h6", { text: diagnostic.declaredPath }); this.createEl(card, "div", { text: $msg("Metadata entry requires review and was left unchanged"), cls: "sls-repair-status-warning", }); this.createEl(card, "div", { text: diagnostic.reason === OfflineScanUnresolvedReasons.DOCUMENT_ID_MISMATCH ? $msg("The stored document ID does not match the ID derived from its recorded path.") : $msg( "The stored document ID and recorded path are handled by different synchronisation features." ), cls: "sls-repair-metric", }); this.createEl(card, "div", { text: $msg("Stored document ID: ${ID}", { ID: diagnostic.actualDocumentId }), cls: "sls-repair-metric", }); if (diagnostic.expectedDocumentId !== undefined) { this.createEl(card, "div", { text: $msg("Expected document ID: ${ID}", { ID: diagnostic.expectedDocumentId }), cls: "sls-repair-metric", }); } this.createEl(card, "div", { text: $msg("Source revision: ${REVISION}", { REVISION: entry.sourceRevision ?? $msg("Unknown revision"), }), cls: "sls-repair-metric", }); if (entry.logicallyDeleted) { this.createEl(card, "div", { text: $msg("🗑️ Logical deletion"), cls: "sls-repair-metric mod-warning", }); } if (entry.conflictRevisions.length > 0) { this.createEl(card, "div", { text: $msg("⚠️ Conflicts: ${COUNT}", { COUNT: `${entry.conflictRevisions.length}` }), cls: "sls-repair-metric mod-warning", }); } if ( !entry.repairAvailable || diagnostic.expectedDocumentId === undefined || entry.sourceRevision === null ) { this.createEl(card, "div", { text: $msg( "One-step repair is unavailable because this entry is ambiguous, no longer current, or unsafe to change." ), cls: "sls-repair-metric mod-warning", }); return; } this.createEl(card, "div", { text: entry.targetAlreadyPresent ? $msg("An exact target is already present; repair can remove the obsolete ID.") : $msg("Repair is available for this entry."), cls: "sls-repair-status-ok", }); const request = { actualDocumentId: diagnostic.actualDocumentId, expectedDocumentId: diagnostic.expectedDocumentId, sourceRevision: entry.sourceRevision, }; const repairAction = $msg("Repair Metadata ID"); const keepAction = $msg("Keep unchanged"); addActionMenu(card, $msg("More actions for ${FILE}", { FILE: diagnostic.declaredPath }), [ { title: $msg("Repair this Metadata document ID"), warning: true, run: async () => { const execution = await executeMetadataIdentityRepair(request, { confirm: async () => (await this.core.confirm.confirmWithMessage( $msg("Repair Metadata document ID"), $msg( "This moves one local Metadata entry to the ID derived from its recorded path.\n\n**File:** `${FILE}` \n**Source:** `${SOURCE}@${REVISION}` \n**Target:** `${TARGET}`\n\nThe target is verified before the source is removed. Its CouchDB revision ancestry cannot be preserved.\n\n> [!warning] Before repairing\n> - Back up this device.\n> - If file-name case or path obfuscation was intentionally changed for the whole database, use Rebuild instead.\n> - If other devices share this database, pause them, allow this device to upload the repair, then resume them one at a time.", { FILE: escapeMarkdownValue(diagnostic.declaredPath), SOURCE: escapeMarkdownValue(diagnostic.actualDocumentId), REVISION: escapeMarkdownValue(entry.sourceRevision!), TARGET: escapeMarkdownValue(diagnostic.expectedDocumentId!), } ), [repairAction, keepAction], keepAction, undefined, "vertical" )) === repairAction, repair: async (repairRequest) => await repairMetadataDocumentIdentity(this.core, repairRequest), requestOrdinaryScan: async () => await this.services.vault.scanVault(true, false), }); if (execution.status === MetadataIdentityRepairExecutions.CANCELLED) return; const result = execution.result; if (result.message) Logger(result.message, LOG_LEVEL_VERBOSE); if (result.status === MetadataDocumentRepairResults.COMPLETED) { if (execution.scanError !== undefined) { Logger(execution.scanError, LOG_LEVEL_VERBOSE); } resultArea.replaceChildren(); this.createEl(resultArea, "div", { text: execution.scanCompleted ? $msg( "Metadata document ID repair and the ordinary Vault scan completed. Run this inspection again after synchronisation." ) : $msg( "Metadata document ID repair completed, but the ordinary Vault scan did not run. Keep synchronisation paused, resolve the scan condition, then run 'Scan storage and database again'." ), cls: execution.scanCompleted ? "sls-repair-status-ok" : "sls-repair-metric mod-warning", }); return; } const resultMessage = result.status === MetadataDocumentRepairResults.STALE || result.status === MetadataDocumentRepairResults.BLOCKED ? $msg("The inspected state changed. No repair was performed; run inspection again.") : result.targetCreated ? $msg( "Repair stopped after creating the target. The source was retained. Run inspection again before retrying." ) : $msg( "Repair failed before the source was removed. Run inspection again before retrying." ); this.createEl(card, "div", { text: resultMessage, cls: "sls-repair-metric mod-warning", }); }, }, ]); }; const findHiddenFile = async (path: string) => { const addOn = this.core.getAddOn(HiddenFileSync.name); if (!addOn) { return false; } const file = (await addOn.scanInternalFiles()).find((entry) => entry.path === path); if (!file) { Logger(`Failed to find the file in the internal files: ${path}`, LOG_LEVEL_NOTICE); return false; } return { addOn, file }; }; const storeStorageInDatabase = async (path: string): Promise => { if (path.startsWith(".")) { const hidden = await findHiddenFile(path); return hidden ? Boolean(await hidden.addOn.storeInternalFileToDatabase(hidden.file, true)) : false; } return Boolean(await this.core.fileHandler.storeFileToDB(path as FilePath, true)); }; const storeStorageOnRevision = async ( path: string, revision: string, createIfDifferent = true ): Promise => { if (path.startsWith(".")) { const hidden = await findHiddenFile(path); return hidden ? Boolean( await hidden.addOn.storeInternalFileToDatabaseWithBaseRevision( hidden.file, revision, createIfDifferent ) ) : false; } return Boolean( await this.core.fileHandler.storeFileToDBWithBaseRevision( path as FilePath, revision, createIfDifferent ) ); }; const applyRevisionToStorage = async ( path: string, revision: string, force: boolean ): Promise => { if (path.startsWith(".")) { const addOn = this.core.getAddOn(HiddenFileSync.name); return addOn ? Boolean( await addOn.extractInternalFileRevisionFromDatabase( path as FilePath, revision, force ) ) : false; } return Boolean( await this.core.fileHandler.dbToStorageWithSpecificRev( path as FilePath, revision, force ) ); }; const openRevisionComparison = async ( path: string, selectedRevision: string ): Promise => { const latest = await inspectFileRepair(this.core, path); const revision = latest.revisions.find( ({ metadata }) => metadata.revision === selectedRevision ); if ( !latest.information.storage.exists || !revision || revision.loadedEntry === false ) { Logger( `Could not compare ${path} revision ${selectedRevision}; the Vault file or selected live revision is no longer readable`, LOG_LEVEL_NOTICE ); return false; } const vaultText = await createBlob( await this.core.storageAccess.readHiddenFileBinary(path) ).text(); const databaseText = await readAsBlob(revision.loadedEntry).text(); const dmp = new diff_match_patch(); const diff = dmp.diff_main(vaultText, databaseText); dmp.diff_cleanupSemantic(diff); const result: diff_result = { left: { rev: "vault", data: vaultText, ctime: latest.information.storage.ctime ?? 0, mtime: latest.information.storage.mtime ?? 0, }, right: { rev: selectedRevision, data: databaseText, ctime: revision.metadata.ctime, mtime: revision.metadata.mtime, }, diff, }; new ConflictResolveModal( this.app, path as FilePathWithPrefix, result, false, undefined, { readOnly: true, title: $msg("Vault and database revision"), localName: $msg("Vault file"), remoteName: $msg("Database revision"), } ).open(); return true; }; const formatSigned = (value: number) => `${value >= 0 ? "+" : ""}${value}`; const timestampRelationLabel = ( relation: ReturnType["timestampRelation"] ) => { switch (relation) { case "vault-newer": return $msg("Vault file is newer"); case "database-newer": return $msg("Database revision is newer"); case "same-window": return $msg("Within the two-second comparison window"); default: return $msg("Timestamp comparison unavailable"); } }; const addRepairResult = (inspection: FileRepairInspection) => { const { information, revisions } = inspection; const path = information.path; const card = this.createEl(resultArea, "div", { cls: "sls-repair-result" }); const refresh = async () => { card.remove(); const refreshed = await inspectFileRepair(this.core, path); if (refreshed.requiresAttention) { addRepairResult(refreshed); } else { Logger(`Verification no longer reports a problem for ${path}`, LOG_LEVEL_NOTICE); } }; const runMutation = async ( description: string, mutation: () => Promise ) => { try { const succeeded = await mutation(); if (!succeeded) { Logger(`${description} failed: ${path}`, LOG_LEVEL_NOTICE); } } finally { await refresh(); } }; const discardLiveBranchAction = (revision: string): RepairMenuAction => ({ title: $msg("Discard this branch"), warning: true, run: async () => { const confirmed = (await this.core.confirm.askYesNoDialog( $msg( "Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.", { REVISION: revision, FILE: path, } ), { title: $msg("Discard branch"), defaultOption: "No", } )) === "yes"; if (!confirmed) { return; } const result = await discardLiveBranch(this.core, path, revision); Logger( `Discard database branch ${revision} of ${path}: ${result}`, result === "discarded" ? LOG_LEVEL_NOTICE : LOG_LEVEL_VERBOSE ); await refresh(); }, }); const fileHeader = this.createEl(card, "div", { cls: "sls-repair-header" }); this.createEl(fileHeader, "h6", { text: path }); const fileMenuHost = this.createEl(fileHeader, "div"); if (information.storage.exists) { this.createEl(card, "div", { text: $msg("📁 Vault: ${SIZE} B · ${TIME}", { TIME: new Date(information.storage.mtime ?? 0).toLocaleString(), SIZE: `${information.storage.size ?? 0}`, }), cls: "sls-repair-metric", }); } else { this.createEl(card, "div", { text: $msg("📁 Vault: missing"), cls: "sls-repair-metric", }); } if (!information.database.exists) { this.createEl(card, "div", { text: $msg("🗄️ Local DB: missing"), cls: "sls-repair-metric", }); } if (information.database.conflictCount > 0) { const winner = revisions.find(({ role }) => role === "winner"); const vaultMatchesWinner = winner !== undefined && (winner.metadata.deleted ? !information.storage.exists : information.storage.exists && winner.contentMatchesStorage === true); const status = this.createEl(card, "div", { cls: "sls-repair-status" }); if (vaultMatchesWinner) { this.createEl(status, "span", { text: $msg("✅ Vault matches winner"), cls: "sls-repair-status-ok", }); } this.createEl(status, "span", { text: $msg("⚠️ Conflicts: ${COUNT}", { COUNT: `${information.database.conflictCount}`, }), cls: "sls-repair-status-warning", }); } const addRevision = (revision: FileRepairRevision) => { const { metadata } = revision; const revisionEl = this.createEl(card, "div", { cls: "sls-repair-revision" }); const revisionHeader = this.createEl(revisionEl, "div", { cls: "sls-repair-header", }); this.createEl(revisionHeader, "div", { text: $msg("${ROLE}: ${REVISION}", { ROLE: revision.role === "winner" ? $msg("Winner revision") : $msg("Conflict revision"), REVISION: metadata.revision ?? $msg("Unknown revision"), }), cls: "sls-repair-revision-title", }); const revisionMenuHost = this.createEl(revisionHeader, "div"); const comparison = getFileRepairRevisionComparison(inspection, revision); if (metadata.deleted) { this.createEl(revisionEl, "div", { text: $msg("🗑️ Logical deletion"), cls: "sls-repair-metric", }); } else if (revision.contentReadable) { this.createEl(revisionEl, "div", { text: $msg( "📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B", { RECORDED: `${comparison.recordedSize}`, DECODED: `${comparison.decodedSize ?? 0}`, DIFFERENCE: formatSigned( comparison.recordedToDecodedSizeDifference ?? 0 ), } ), cls: "sls-repair-metric", }); } else { const missing = metadata.chunks.filter( ({ embedded, localDatabaseState }) => !embedded && localDatabaseState !== "available" ); this.createEl(revisionEl, "div", { text: $msg("🧩 Missing chunks: ${COUNT}", { COUNT: `${missing.length}`, }), cls: "sls-repair-metric mod-warning", }); this.createEl(revisionEl, "div", { text: $msg("📦 DB: recorded ${RECORDED} B · decoded unavailable", { RECORDED: `${comparison.recordedSize}`, }), cls: "sls-repair-metric", }); if (missing.length > 0) { this.createEl(revisionEl, "code", { text: missing .slice(0, 3) .map(({ id }) => id) .join(", ") + (missing.length > 3 ? ", …" : ""), }); } } if ( comparison.vaultSize !== null && comparison.databaseToVaultSizeDifference !== null ) { this.createEl(revisionEl, "div", { text: $msg("📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B", { VAULT: `${comparison.vaultSize}`, DIFFERENCE: formatSigned( comparison.databaseToVaultSizeDifference ), }), cls: "sls-repair-metric", }); } if ( comparison.vaultMtime !== null && comparison.timestampDifferenceMs !== null ) { this.createEl(revisionEl, "div", { text: $msg( "🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})", { DATABASE_TIME: new Date( comparison.databaseMtime ).toLocaleString(), VAULT_TIME: new Date( comparison.vaultMtime ).toLocaleString(), DIFFERENCE: formatSigned( comparison.timestampDifferenceMs ), RELATION: timestampRelationLabel( comparison.timestampRelation ), } ), cls: "sls-repair-metric", }); } if (revision.contentMatchesStorage === true) { this.createEl(revisionEl, "div", { text: $msg("✅ Matches Vault"), cls: "sls-repair-metric", }); } else if (revision.contentMatchesStorage === false) { this.createEl(revisionEl, "div", { text: $msg("⚠️ Differs from Vault"), cls: "sls-repair-metric mod-warning", }); } const policy = getFileRepairRevisionActions(inspection, revision); const revisionActions: RepairMenuAction[] = []; if (metadata.revision && policy.compareWithVault) { revisionActions.push({ title: $msg("Compare with Vault"), run: async () => { await openRevisionComparison(path, metadata.revision!); }, }); } if (metadata.revision && policy.applyRevisionToVault) { revisionActions.push({ title: $msg("Apply this revision to Vault"), run: async () => { if (await this.core.storageAccess.isExistsIncludeHidden(path)) { const confirmed = (await this.core.confirm.askYesNoDialog( $msg( "Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.", { REVISION: metadata.revision!, FILE: path, } ), { title: $msg("Apply database revision to Vault"), defaultOption: "No", } )) === "yes"; if (!confirmed) { return; } } await runMutation( `Apply database revision ${metadata.revision} to the Vault`, () => applyRevisionToStorage( path, metadata.revision!, true ) ); }, }); } if (metadata.revision && policy.markAsVaultRevision) { revisionActions.push({ title: $msg("Mark this revision as the Vault version"), run: async () => { await runMutation( `Mark database revision ${metadata.revision} as the Vault version`, () => storeStorageOnRevision( path, metadata.revision!, false ) ); }, }); } if (metadata.revision && policy.storeVaultOnBranch) { revisionActions.push({ title: $msg("Store Vault file as a child of this revision"), run: async () => { await runMutation( `Store the Vault file on database revision ${metadata.revision}`, () => storeStorageOnRevision( path, metadata.revision! ) ); }, }); } if (metadata.revision && policy.applyLogicalDeletionToVault) { revisionActions.push({ title: $msg("Apply logical deletion to Vault"), warning: true, run: async () => { if (await this.core.storageAccess.isExistsIncludeHidden(path)) { const confirmed = (await this.core.confirm.askYesNoDialog( $msg( "Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.", { REVISION: metadata.revision!, FILE: path, } ), { title: $msg("Apply logical deletion to Vault"), defaultOption: "No", } )) === "yes"; if (!confirmed) { return; } } await runMutation( `Apply logical deletion ${metadata.revision} to the Vault`, () => applyRevisionToStorage( path, metadata.revision!, true ) ); }, }); } if (metadata.revision && policy.retryRevision) { revisionActions.push({ title: $msg("Retry reading revision"), run: async () => { const loaded = await retryReadFileDatabaseRevision( this.core, path, metadata.revision! ); Logger( loaded ? `Revision ${metadata.revision} of ${path} is readable after retry` : `Revision ${metadata.revision} of ${path} remains unreadable`, LOG_LEVEL_NOTICE ); await refresh(); }, }); } if (metadata.revision && policy.discardBranch) { revisionActions.push(discardLiveBranchAction(metadata.revision)); } if (metadata.revision && policy.discardRevision) { revisionActions.push({ title: $msg("Discard unreadable revision"), warning: true, run: async () => { const confirmed = (await this.core.confirm.askYesNoDialog( $msg( "Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.", { REVISION: metadata.revision!, FILE: path, } ), { title: $msg("Discard unreadable revision"), defaultOption: "No", } )) === "yes"; if (!confirmed) { return; } const result = await discardUnreadableLiveRevision( this.core, path, metadata.revision! ); Logger( `Discard unreadable revision ${metadata.revision} of ${path}: ${result}`, result === "discarded" ? LOG_LEVEL_NOTICE : LOG_LEVEL_VERBOSE ); await refresh(); }, }); } addActionMenu( revisionMenuHost, $msg("More actions for revision ${REVISION}", { REVISION: metadata.revision ?? $msg("Unknown revision"), }), revisionActions ); }; revisions.forEach(addRevision); for (const revision of information.database.unavailableConflictRevisions) { const revisionEl = this.createEl(card, "div", { cls: "sls-repair-revision" }); const revisionHeader = this.createEl(revisionEl, "div", { cls: "sls-repair-header", }); this.createEl(revisionHeader, "div", { text: $msg("${ROLE}: ${REVISION}", { ROLE: $msg("Conflict revision"), REVISION: revision, }), cls: "sls-repair-revision-title", }); const revisionMenuHost = this.createEl(revisionHeader, "div"); this.createEl(revisionEl, "div", { text: $msg("Revision metadata is unavailable on this device"), cls: "mod-warning", }); addActionMenu( revisionMenuHost, $msg("More actions for revision ${REVISION}", { REVISION: revision, }), [ { title: $msg("Retry reading revision"), run: async () => { await retryReadFileDatabaseRevision( this.core, path, revision ); await refresh(); }, }, discardLiveBranchAction(revision), ] ); } for (const base of information.database.mergeBases) { if (base.contentAvailableLocally) { continue; } this.createEl(card, "div", { text: base.revision ? $msg( "Shared ancestor ${REVISION} is not readable on this device. Automatic three-way merging may be unavailable, but the live revisions remain available for explicit review.", { REVISION: base.revision, } ) : $msg( "No shared ancestor is available for this conflict. The live revisions remain available for explicit review." ), cls: "sls-repair-ancestor-warning", }); } const winner = revisions.find(({ role }) => role === "winner"); const fileActions: RepairMenuAction[] = []; if (winner?.loadedEntry) { const winnerEntry = winner.loadedEntry; fileActions.push({ title: $msg("Show revision history"), run: () => { eventHub.emitEvent(EVENT_REQUEST_SHOW_HISTORY, { file: path as FilePathWithPrefix, fileOnDB: winnerEntry, }); }, }); } if (information.storage.exists && !information.database.exists) { fileActions.push({ title: $msg("Store Vault file as a new local database document"), run: async () => { await runMutation( "Store the Vault file as a new local database document", () => storeStorageInDatabase(path) ); }, }); } fileActions.push({ title: $msg("Copy database information"), run: async () => { await copyFileDatabaseInfo(this.core, path); }, }); addActionMenu( fileMenuHost, $msg("More actions for ${FILE}", { FILE: path }), fileActions ); }; new Setting(paneEl) .setName($msg("Recreate chunks for current Vault files")) .setDesc( $msg( "Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content." ) ) .addButton((button) => button .setButtonText($msg("Recreate current chunks")) .setCta() .onClick(async () => { await this.core.fileHandler.createAllChunks(true); }) ); new Setting(paneEl) .setName($msg("Inspect conflicts and file/database differences")) .setDesc( $msg( "Scan Vault files and local-database Metadata for conflicts, missing chunks, identity mismatches, and differences. Each result provides actions for one exact entry or revision." ) ) .addButton((button) => button .setButtonText($msg("Begin inspection")) .setDisabled(false) .setCta() .onClick(async () => { resultArea.replaceChildren(); Logger("Start inspecting file/database state", LOG_LEVEL_NOTICE, "verify"); this.core.localDatabase.clearCaches(); const identityEntries = await inspectMetadataDocumentIdentities(this.core); const handleFilenameCaseSensitive = this.core.settings.handleFilenameCaseSensitive; const unresolvedIdentity = selectUnresolvedMetadataIdentityEntries( identityEntries, handleFilenameCaseSensitive ); unresolvedIdentity.entries.forEach(addMetadataIdentityResult); const allPaths = await collectFileDatabaseInfoPaths(this.core); let i = 0; const incProc = () => { i++; if (i % 25 == 0) Logger( `Checking ${i}/${allPaths.length} files \n`, LOG_LEVEL_NOTICE, "verify-processed" ); }; const semaphore = Semaphore(10); const processes = allPaths.map(async (path) => { try { if ( unresolvedIdentity.unresolvedPathKeys.has( metadataIdentityPathKey(path, handleFilenameCaseSensitive) ) ) { return incProc(); } if (shouldBeIgnored(path)) { return incProc(); } const stat = (await this.core.storageAccess.isExistsIncludeHidden(path)) ? await this.core.storageAccess.statHidden(path) : false; const fileOnStorage = stat != null ? stat : false; if (!(await this.services.vault.isTargetFile(path))) return incProc(); if (fileOnStorage && this.services.vault.isFileSizeTooLarge(fileOnStorage.size)) return incProc(); const releaser = await semaphore.acquire(1); try { const inspection = await inspectFileRepair(this.core, path); const winner = inspection.revisions.find(({ role }) => role === "winner"); if ( winner && this.services.vault.isFileSizeTooLarge(winner.metadata.recordedSize) ) return incProc(); if (inspection.requiresAttention) { addRepairResult(inspection); } else { Logger(`Compare: SAME: ${path}`); } } catch (ex) { Logger(`Error while processing ${path}`, LOG_LEVEL_NOTICE); Logger(ex, LOG_LEVEL_VERBOSE); } finally { releaser(); incProc(); } } catch (ex) { Logger(`Error while processing without semaphore ${path}`, LOG_LEVEL_NOTICE); Logger(ex, LOG_LEVEL_VERBOSE); } }); await Promise.all(processes); Logger("done", LOG_LEVEL_NOTICE, "verify"); // Logger(`${i}/${files.length}\n`, LOG_LEVEL_NOTICE, "verify-processed"); }) ); new Setting(paneEl) .setName("Resolve All conflicted files by the newer one") .setDesc( "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one." ) .addButton((button) => button .setButtonText("Resolve All") .setCta() .onClick(async () => { const confirmed = (await this.core.confirm.askYesNoDialog( $msg( "Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable." ), { title: $msg("Resolve all conflicts by the newest version"), defaultOption: "No", } )) === "yes"; if (!confirmed) { return; } await this.services.conflict.resolveAllConflictedFilesByNewerOnes(); }) ); new Setting(paneEl) .setName("Check and convert non-path-obfuscated files") .setDesc("") .addButton((button) => button .setButtonText("Perform") .setDisabled(false) .setWarning() .onClick(async () => { for await (const docName of this.core.localDatabase.findAllDocNames()) { if (!docName.startsWith("f:")) { const idEncoded = await this.services.path.path2id(docName as FilePathWithPrefix); const doc = await this.core.localDatabase.getRaw(docName as DocumentID); if (!doc) continue; if (doc.type != "newnote" && doc.type != "plain") { continue; } if (doc?.deleted ?? false) continue; const newDoc = { ...doc }; //Prepare converted data newDoc._id = idEncoded; newDoc.path = docName as FilePathWithPrefix; // @ts-ignore delete newDoc._rev; try { const obfuscatedDoc = await this.core.localDatabase.getRaw(idEncoded, { revs_info: true, }); // Unfortunately we have to delete one of them. // Just now, save it as a conflicted document. obfuscatedDoc._revs_info?.shift(); // Drop latest revision. const previousRev = obfuscatedDoc._revs_info?.shift(); // Use second revision. if (previousRev) { newDoc._rev = previousRev.rev; } else { //If there are no revisions, set the possibly unique one newDoc._rev = "1-" + `00000000000000000000000000000000${~~(Math.random() * 1e9)}${~~(Math.random() * 1e9)}${~~(Math.random() * 1e9)}${~~(Math.random() * 1e9)}`.slice( -32 ); } const ret = await this.core.localDatabase.putRaw(newDoc, { force: true }); if (ret.ok) { Logger( `${docName} has been converted as conflicted document`, LOG_LEVEL_NOTICE ); doc._deleted = true; if ((await this.core.localDatabase.putRaw(doc)).ok) { Logger(`Old ${docName} has been deleted`, LOG_LEVEL_NOTICE); } await this.services.conflict.queueCheckForIfOpen(docName as FilePathWithPrefix); } else { Logger(`Converting ${docName} Failed!`, LOG_LEVEL_NOTICE); Logger(ret, LOG_LEVEL_VERBOSE); } } catch (ex: unknown) { if (isNotFoundError(ex)) { // We can perform this safely if ((await this.core.localDatabase.putRaw(newDoc)).ok) { Logger(`${docName} has been converted`, LOG_LEVEL_NOTICE); doc._deleted = true; if ((await this.core.localDatabase.putRaw(doc)).ok) { Logger(`Old ${docName} has been deleted`, LOG_LEVEL_NOTICE); } } } else { Logger(`Something went wrong while converting ${docName}`, LOG_LEVEL_NOTICE); Logger(ex, LOG_LEVEL_VERBOSE); // Something wrong. } } } } Logger(`Converting finished`, LOG_LEVEL_NOTICE); }) ); }); void addPanel(paneEl, "Reset").then((paneEl) => { new Setting(paneEl).setName("Back to non-configured").addButton((button) => button .setButtonText("Back") .setDisabled(false) .onClick(async () => { this.editingSettings.isConfigured = false; await this.saveAllDirtySettings(); this.services.appLifecycle.askRestart(); }) ); new Setting(paneEl).setName("Delete all customization sync data").addButton((button) => button .setButtonText("Delete") .setDisabled(false) .setWarning() .onClick(async () => { Logger(`Deleting customization sync data`, LOG_LEVEL_NOTICE); const entriesToDelete = await this.core.localDatabase.allDocsRaw({ startkey: "ix:", endkey: "ix:\u{10ffff}", include_docs: true, }); const newData = entriesToDelete.rows.map((e) => ({ ...e.doc, _deleted: true, })) as EntryDoc[]; const r = await this.core.localDatabase.bulkDocsRaw(newData); // Do not care about the result. Logger( `${r.length} items have been removed, to confirm how many items are left, please perform it again.`, LOG_LEVEL_NOTICE ); }) ); }); }