import { $msg } from "@/common/translation"; import type PouchDB from "pouchdb-core"; import type { FilePath, FilePathWithPrefix, LoadedEntry, ObsidianLiveSyncSettings, } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { getFileRegExp } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; import { ICHeader, ICXHeader, PSCHeader } from "@vrtmrz/livesync-commonlib/compat/common/models/fileaccess.const"; import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess"; import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB"; import type { IPathService, IUIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; import { addPrefix, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; type DatabaseMeta = LoadedEntry & { _rawStorageType: string | null; _legacyBodyPresent: boolean; _revs_info?: Array<{ rev: string; status: string; }>; }; export type FileDatabaseInfoCore = { localDatabase: Pick< LiveSyncLocalDB, "allDocsRaw" | "findAllDocs" | "getDBEntryFromMeta" | "getDBEntry" | "localDatabase" >; services: { path: Pick; UI: IUIService; }; settings: ObsidianLiveSyncSettings; storageAccess: Pick< StorageAccess, "getFileNames" | "getFilesIncludeHidden" | "isExistsIncludeHidden" | "statHidden" >; }; export type RevisionDatabaseInfo = { documentId: string; revision: string | null; current: boolean; deleted: boolean; storageType: string; storageLayout: "chunked" | "legacy-inline"; ctime: number; mtime: number; recordedSize: number; revisionHistory: Array<{ revision: string; status: string; }>; chunkReferences: number; uniqueChunkReferences: number; embeddedChunkReferences: number; locallyStoredChunkReferences: number; contentAvailableLocally: boolean; chunks: Array<{ id: string; referenceCount: number; embedded: boolean; storedInLocalDatabase: boolean; localDatabaseState: "available" | "deleted" | "missing"; localDatabaseRevision: string | null; }>; }; export type FileDatabaseMergeBaseInfo = { winnerRevision: string; conflictRevision: string; revision: string | null; metadataAvailableLocally: boolean; contentAvailableLocally: boolean; missingChunkIds: string[]; unavailableSharedRevisions: string[]; }; export type FileDatabaseInfo = { path: string; databasePath: FilePathWithPrefix | FilePath; storage: { exists: boolean; ctime?: number; mtime?: number; size?: number; }; database: { source: "local database on this device"; remoteQueried: false; exists: boolean; currentRevision: string | null; conflictCount: number; conflictRevisions: string[]; unavailableConflictRevisions: string[]; revisions: RevisionDatabaseInfo[]; mergeBases: FileDatabaseMergeBaseInfo[]; }; }; const REPORT_WARNING = "All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted."; function toDatabasePath(path: string): FilePathWithPrefix | FilePath { if (path.startsWith(".")) { return addPrefix(path as FilePath, ICHeader); } return path as FilePath; } type RawDatabaseDocument = { _id: string; _rev?: string; _conflicts?: string[]; _deleted?: boolean; _revs_info?: Array<{ rev: string; status: string; }>; children?: string[]; ctime?: number; deleted?: boolean; data?: string | string[]; eden?: Record; mtime?: number; size?: number; type?: string; }; async function getLocalDatabaseMeta( core: FileDatabaseInfoCore, path: FilePathWithPrefix | FilePath, options: PouchDB.Core.GetOptions ): Promise { const documentId = await core.services.path.path2id(path); let raw: RawDatabaseDocument; try { raw = await core.localDatabase.localDatabase.get(documentId, options); } catch (error) { if (isNotFoundError(error)) { return false; } throw error; } if (raw.type === "leaf") { return false; } if (raw.type && raw.type !== "notes" && raw.type !== "newnote" && raw.type !== "plain") { return false; } const rawStorageType = raw.type ?? null; const legacy = rawStorageType === null || rawStorageType === "notes"; const type = legacy ? "notes" : rawStorageType; const legacyBodyPresent = legacy && (typeof raw.data === "string" || (Array.isArray(raw.data) && raw.data.every((item) => typeof item === "string"))); return { _id: raw._id, _rev: raw._rev, _conflicts: raw._conflicts, _revs_info: raw._revs_info, path, data: legacyBodyPresent ? raw.data : "", ctime: raw.ctime ?? 0, mtime: raw.mtime ?? 0, size: raw.size ?? 0, children: type === "newnote" || type === "plain" ? (raw.children ?? []) : [], datatype: type === "newnote" ? "newnote" : "plain", deleted: raw.deleted ?? raw._deleted, type, eden: raw.eden ?? {}, _rawStorageType: rawStorageType, _legacyBodyPresent: legacyBodyPresent, } as DatabaseMeta; } async function collectRevisionDatabaseInfo( core: FileDatabaseInfoCore, meta: DatabaseMeta, current: boolean ): Promise { const legacy = meta._rawStorageType === null || meta._rawStorageType === "notes"; const children = legacy ? [] : "children" in meta ? meta.children : []; const uniqueChildren = [...new Set(children)]; const referenceCounts = new Map(); for (const child of children) { referenceCounts.set(child, (referenceCounts.get(child) ?? 0) + 1); } const embeddedChildren = new Set( Object.keys("eden" in meta && meta.eden ? meta.eden : {}).filter((id) => uniqueChildren.includes(id)) ); const localRows = uniqueChildren.length === 0 ? [] : ( await core.localDatabase.allDocsRaw({ keys: uniqueChildren, include_docs: false, }) ).rows; const localChunkStates = new Map( localRows .filter((row) => "value" in row) .map( (row) => [ row.key, { state: row.value.deleted ? ("deleted" as const) : ("available" as const), revision: row.value.rev, }, ] as const ) ); return { documentId: meta._id, revision: meta._rev ?? null, current, deleted: Boolean(meta.deleted ?? meta._deleted), storageType: meta._rawStorageType ?? "absent", storageLayout: legacy ? "legacy-inline" : "chunked", ctime: meta.ctime, mtime: meta.mtime, recordedSize: meta.size, revisionHistory: (meta._revs_info ?? []).map(({ rev, status }) => ({ revision: rev, status, })), chunkReferences: children.length, uniqueChunkReferences: uniqueChildren.length, embeddedChunkReferences: children.filter((id) => embeddedChildren.has(id)).length, locallyStoredChunkReferences: children.filter((id) => localChunkStates.get(id)?.state === "available").length, contentAvailableLocally: legacy ? meta._legacyBodyPresent : uniqueChildren.every( (id) => embeddedChildren.has(id) || localChunkStates.get(id)?.state === "available" ), chunks: uniqueChildren.map((id) => { const localState = localChunkStates.get(id); return { id, referenceCount: referenceCounts.get(id) ?? 0, embedded: embeddedChildren.has(id), storedInLocalDatabase: localState?.state === "available", localDatabaseState: localState?.state ?? "missing", localDatabaseRevision: localState?.revision ?? null, }; }), }; } function revisionHistory(meta: DatabaseMeta): Array<{ revision: string; status: string }> { const history = (meta._revs_info ?? []).map(({ rev, status }) => ({ revision: rev, status, })); if (meta._rev && !history.some(({ revision }) => revision === meta._rev)) { history.unshift({ revision: meta._rev, status: "available", }); } return history; } function missingChunkIds(info: RevisionDatabaseInfo): string[] { return info.chunks .filter(({ embedded, localDatabaseState }) => !embedded && localDatabaseState !== "available") .map(({ id }) => id); } export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path: string): Promise { const storageExists = await core.storageAccess.isExistsIncludeHidden(path); const storageStat = storageExists ? await core.storageAccess.statHidden(path) : null; const databasePath = toDatabasePath(path); const currentMeta = await getLocalDatabaseMeta(core, databasePath, { conflicts: true, revs: true, revs_info: true, }); const revisions: RevisionDatabaseInfo[] = []; const conflictRevisions = currentMeta === false ? [] : (currentMeta._conflicts ?? []); const unavailableConflictRevisions: string[] = []; const mergeBases: FileDatabaseMergeBaseInfo[] = []; const metadataByRevision = new Map(); if (currentMeta !== false && currentMeta._rev) { metadataByRevision.set(currentMeta._rev, currentMeta); } const getRevisionMeta = async (revision: string): Promise => { const cached = metadataByRevision.get(revision); if (cached !== undefined) { return cached; } const meta = await getLocalDatabaseMeta(core, databasePath, { rev: revision, revs: true, revs_info: true, }); metadataByRevision.set(revision, meta); return meta; }; if (currentMeta) { revisions.push(await collectRevisionDatabaseInfo(core, currentMeta, true)); for (const revision of conflictRevisions) { const conflictMeta = await getRevisionMeta(revision); if (conflictMeta) { revisions.push(await collectRevisionDatabaseInfo(core, conflictMeta, false)); const winnerHistory = revisionHistory(currentMeta); const conflictHistory = revisionHistory(conflictMeta); const conflictHistoryByRevision = new Map( conflictHistory.map(({ revision: historyRevision, status }) => [historyRevision, status]) ); const sharedHistory = winnerHistory.filter(({ revision: historyRevision }) => conflictHistoryByRevision.has(historyRevision) ); const sharedRevision = sharedHistory[0]?.revision ?? null; const unavailableSharedRevisions = sharedHistory .filter( ({ revision: historyRevision, status }) => status !== "available" || conflictHistoryByRevision.get(historyRevision) !== "available" ) .map(({ revision: historyRevision }) => historyRevision); const sharedMeta = sharedRevision ? await getRevisionMeta(sharedRevision) : false; const sharedInfo = sharedMeta ? await collectRevisionDatabaseInfo(core, sharedMeta, false) : undefined; mergeBases.push({ winnerRevision: currentMeta._rev ?? "", conflictRevision: revision, revision: sharedRevision, metadataAvailableLocally: Boolean(sharedMeta), contentAvailableLocally: sharedInfo?.contentAvailableLocally ?? false, missingChunkIds: sharedInfo ? missingChunkIds(sharedInfo) : [], unavailableSharedRevisions, }); } else { unavailableConflictRevisions.push(revision); } } } const report: FileDatabaseInfo = { path, databasePath, storage: storageStat ? { exists: true, ctime: storageStat.ctime, mtime: storageStat.mtime, size: storageStat.size, } : { exists: false, }, database: { source: "local database on this device", remoteQueried: false, exists: currentMeta !== false, currentRevision: currentMeta ? (currentMeta._rev ?? null) : null, conflictCount: conflictRevisions.length, conflictRevisions, unavailableConflictRevisions, revisions, mergeBases, }, }; return report; } export async function readFileDatabaseRevisionLocally( core: FileDatabaseInfoCore, path: string, revision: string ): Promise { const databasePath = toDatabasePath(path); const meta = await getLocalDatabaseMeta(core, databasePath, { rev: revision, revs: true, revs_info: true, }); if (!meta) { return false; } const info = await collectRevisionDatabaseInfo(core, meta, false); if (info.deleted || !info.contentAvailableLocally) { return false; } return await core.localDatabase.getDBEntryFromMeta(meta, false, false); } export async function retryReadFileDatabaseRevision( core: FileDatabaseInfoCore, path: string, revision: string ): Promise { return await core.localDatabase.getDBEntry(toDatabasePath(path), { rev: revision }, false, true, true); } export async function buildFileDatabaseInfoReport(core: FileDatabaseInfoCore, path: string): Promise { const report = await inspectFileDatabaseInfo(core, path); return `${$msg(REPORT_WARNING)} \`\`\`json ${JSON.stringify(report, null, 2)} \`\`\``; } export async function copyFileDatabaseInfo(core: FileDatabaseInfoCore, path: string): Promise { const report = await buildFileDatabaseInfoReport(core, path); return await core.services.UI.promptCopyToClipboard( $msg("Database information for ${FILE}", { FILE: path }), report ); } export async function collectFileDatabaseInfoPaths(core: FileDatabaseInfoCore): Promise { const ignorePatterns = getFileRegExp(core.settings, "syncInternalFilesIgnorePatterns"); const targetPatterns = getFileRegExp(core.settings, "syncInternalFilesTargetPatterns"); const storagePaths = core.settings.syncInternalFiles ? await core.storageAccess.getFilesIncludeHidden("/", targetPatterns, ignorePatterns) : await core.storageAccess.getFileNames(); const databasePaths: string[] = []; for await (const entry of core.localDatabase.findAllDocs()) { const prefixedPath = entry.path; if (prefixedPath.startsWith(ICXHeader) || prefixedPath.startsWith(PSCHeader)) { continue; } if (!core.settings.syncInternalFiles && prefixedPath.startsWith(ICHeader)) { continue; } databasePaths.push(stripAllPrefixes(prefixedPath)); } return [...new Set([...storagePaths, ...databasePaths])].sort((left, right) => left < right ? -1 : left > right ? 1 : 0 ); } export async function chooseAndCopyFileDatabaseInfo(core: FileDatabaseInfoCore): Promise { const paths = await collectFileDatabaseInfoPaths(core); const selected = await core.services.UI.confirm.askSelectString($msg("Choose a file to inspect"), paths); if (!selected) { return false; } return await copyFileDatabaseInfo(core, selected); }