mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-15 12:40:12 +00:00
Share the CLI watcher with a reconciled name inventory, observe local mutations, and retain newer notifications across overlapping reads. Adopt the shared index for Obsidian and preserve fresh bulk operations.
497 lines
18 KiB
TypeScript
497 lines
18 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;
|
|
documentId?: string;
|
|
currentRevision: string | null;
|
|
conflictCount: number;
|
|
conflictRevisions: string[];
|
|
unavailableConflictRevisions: string[];
|
|
revisions: RevisionDatabaseInfo[];
|
|
mergeBases: FileDatabaseMergeBaseInfo[];
|
|
};
|
|
};
|
|
|
|
export type InspectFileDatabaseInfoOptions = {
|
|
documentId?: string;
|
|
databasePath?: FilePathWithPrefix | FilePath;
|
|
signal?: AbortSignal;
|
|
};
|
|
|
|
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;
|
|
path?: FilePathWithPrefix | FilePath;
|
|
size?: number;
|
|
type?: string;
|
|
};
|
|
|
|
function checkInspectionCancelled(signal?: AbortSignal): void {
|
|
if (signal?.aborted) throw new DOMException("File inspection cancelled", "AbortError");
|
|
}
|
|
|
|
async function getLocalDatabaseMeta(
|
|
core: FileDatabaseInfoCore,
|
|
path: FilePathWithPrefix | FilePath,
|
|
options: PouchDB.Core.GetOptions,
|
|
target?: InspectFileDatabaseInfoOptions
|
|
): Promise<DatabaseMeta | false> {
|
|
checkInspectionCancelled(target?.signal);
|
|
const documentId = target?.documentId ?? (await core.services.path.path2id(path));
|
|
checkInspectionCancelled(target?.signal);
|
|
let raw: RawDatabaseDocument;
|
|
try {
|
|
raw = await core.localDatabase.localDatabase.get(documentId, options);
|
|
} catch (error) {
|
|
checkInspectionCancelled(target?.signal);
|
|
if (isNotFoundError(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
checkInspectionCancelled(target?.signal);
|
|
|
|
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: target ? (raw.path ?? path) : 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,
|
|
signal?: AbortSignal
|
|
): Promise<RevisionDatabaseInfo> {
|
|
checkInspectionCancelled(signal);
|
|
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;
|
|
checkInspectionCancelled(signal);
|
|
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,
|
|
options?: InspectFileDatabaseInfoOptions
|
|
): Promise<FileDatabaseInfo> {
|
|
checkInspectionCancelled(options?.signal);
|
|
const storageExists = await core.storageAccess.isExistsIncludeHidden(path);
|
|
checkInspectionCancelled(options?.signal);
|
|
const storageStat = storageExists ? await core.storageAccess.statHidden(path) : null;
|
|
const requestedDatabasePath = options?.databasePath ?? toDatabasePath(path);
|
|
const currentMeta = await getLocalDatabaseMeta(
|
|
core,
|
|
requestedDatabasePath,
|
|
{
|
|
conflicts: true,
|
|
revs: true,
|
|
revs_info: true,
|
|
},
|
|
options
|
|
);
|
|
const databasePath = options && currentMeta ? currentMeta.path : requestedDatabasePath;
|
|
|
|
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> => {
|
|
checkInspectionCancelled(options?.signal);
|
|
const cached = metadataByRevision.get(revision);
|
|
if (cached !== undefined) {
|
|
return cached;
|
|
}
|
|
const meta = await getLocalDatabaseMeta(
|
|
core,
|
|
requestedDatabasePath,
|
|
{
|
|
rev: revision,
|
|
revs: true,
|
|
revs_info: true,
|
|
},
|
|
options
|
|
);
|
|
metadataByRevision.set(revision, meta);
|
|
return meta;
|
|
};
|
|
|
|
if (currentMeta) {
|
|
revisions.push(await collectRevisionDatabaseInfo(core, currentMeta, true, options?.signal));
|
|
for (const revision of conflictRevisions) {
|
|
const conflictMeta = await getRevisionMeta(revision);
|
|
if (conflictMeta) {
|
|
revisions.push(await collectRevisionDatabaseInfo(core, conflictMeta, false, options?.signal));
|
|
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, options?.signal)
|
|
: 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
checkInspectionCancelled(options?.signal);
|
|
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,
|
|
...(options?.documentId !== undefined ? { documentId: options.documentId } : {}),
|
|
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");
|
|
// These names seed a chooser or inspection; per-entry reads establish current
|
|
// state before offering actions. Hidden-file mode keeps its separate listing scope.
|
|
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);
|
|
}
|