mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-26 05:17:06 +00:00
456 lines
16 KiB
TypeScript
456 lines
16 KiB
TypeScript
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<IPathService, "path2id">;
|
|
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<string, unknown>;
|
|
mtime?: number;
|
|
size?: number;
|
|
type?: string;
|
|
};
|
|
|
|
async function getLocalDatabaseMeta(
|
|
core: FileDatabaseInfoCore,
|
|
path: FilePathWithPrefix | FilePath,
|
|
options: PouchDB.Core.GetOptions
|
|
): Promise<DatabaseMeta | false> {
|
|
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<RevisionDatabaseInfo> {
|
|
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<string, number>();
|
|
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<FileDatabaseInfo> {
|
|
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<string, DatabaseMeta | false>();
|
|
if (currentMeta !== false && currentMeta._rev) {
|
|
metadataByRevision.set(currentMeta._rev, currentMeta);
|
|
}
|
|
const getRevisionMeta = async (revision: string): Promise<DatabaseMeta | false> => {
|
|
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<LoadedEntry | false> {
|
|
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<LoadedEntry | false> {
|
|
return await core.localDatabase.getDBEntry(toDatabasePath(path), { rev: revision }, false, true, true);
|
|
}
|
|
|
|
export async function buildFileDatabaseInfoReport(core: FileDatabaseInfoCore, path: string): Promise<string> {
|
|
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<boolean> {
|
|
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<string[]> {
|
|
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<boolean> {
|
|
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);
|
|
}
|