mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-11 05:07:06 +00:00
403 lines
18 KiB
TypeScript
403 lines
18 KiB
TypeScript
import { parseYaml } from "@/deps.ts";
|
|
import type {
|
|
FilePath,
|
|
FilePathWithPrefix,
|
|
InternalFileEntry,
|
|
LOG_LEVEL,
|
|
SavingEntry,
|
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
|
import { LOG_LEVEL_DEBUG, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
|
import {
|
|
createBlob,
|
|
createTextBlob,
|
|
getDocData,
|
|
getDocDataAsArray,
|
|
isDocContentSame,
|
|
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
|
import { EVEN } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
|
|
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
|
|
import { arrayBufferToBase64 } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
|
|
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
|
|
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
|
import type { IPathService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
|
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
|
import { base64ToArrayBuffer } from "octagonal-wheels/binary/base64";
|
|
import { serialized } from "octagonal-wheels/concurrency/lock";
|
|
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
|
|
|
import { createCustomisationSyncCodec, type PluginDataEx } from "./customisationSyncCodec.ts";
|
|
import type { CustomisationSyncPathOperations } from "./customisationSyncPathOperations.ts";
|
|
import { readCustomisationFile } from "./customisationSyncReadOperations.ts";
|
|
|
|
const {
|
|
serialize,
|
|
deserialize,
|
|
dummyHead: DUMMY_HEAD,
|
|
dummyEnd: DUMMY_END,
|
|
} = createCustomisationSyncCodec({ digestHash, parseYaml });
|
|
|
|
type SnapshotPersistenceDatabase = Pick<
|
|
LiveSyncLocalDB,
|
|
"getDBEntryFromMeta" | "getDBEntryMeta" | "putDBEntry" | "putRaw"
|
|
>;
|
|
|
|
type SnapshotPersistenceStorage = Pick<StorageAccess, "readHiddenFileBinary" | "statHidden">;
|
|
|
|
type SnapshotPersistencePath = Pick<
|
|
CustomisationSyncPathOperations,
|
|
"getFileCategory" | "filenameToUnifiedKey" | "filenameWithUnifiedKey"
|
|
> &
|
|
Pick<IPathService, "isMarkedAsSameChanges" | "markChangesAreSame" | "path2id">;
|
|
|
|
export type SnapshotPersistenceDependencies = {
|
|
getLocalDatabase(): SnapshotPersistenceDatabase;
|
|
storageAccess: SnapshotPersistenceStorage;
|
|
path: SnapshotPersistencePath;
|
|
log: LogFunction;
|
|
getConfigDir(): string;
|
|
};
|
|
|
|
export type SnapshotRefresh = {
|
|
mode: "v1" | "v2";
|
|
timing: "await" | "fire-and-forget";
|
|
path: FilePathWithPrefix;
|
|
};
|
|
|
|
export type SnapshotPersistenceStatus = "saved" | "skipped" | "missing" | "deleted" | "already-deleted" | "failed";
|
|
|
|
export type SnapshotPersistenceResult<Value> = {
|
|
value: Value;
|
|
status: SnapshotPersistenceStatus;
|
|
refreshes: readonly SnapshotRefresh[];
|
|
};
|
|
|
|
type DatabaseSaveResult = Awaited<ReturnType<SnapshotPersistenceDatabase["putDBEntry"]>>;
|
|
type StoreResultValue = DatabaseSaveResult | true | undefined;
|
|
|
|
function result<Value>(
|
|
value: Value,
|
|
status: SnapshotPersistenceStatus,
|
|
refreshes: readonly SnapshotRefresh[] = []
|
|
): SnapshotPersistenceResult<Value> {
|
|
return { value, status, refreshes };
|
|
}
|
|
|
|
/**
|
|
* Persists local Customisation Sync snapshots without owning catalogue state,
|
|
* lifecycle, replication, or user-interface behaviour.
|
|
*/
|
|
export class SnapshotPersistence {
|
|
private readonly dependencies: SnapshotPersistenceDependencies;
|
|
|
|
constructor(dependencies: SnapshotPersistenceDependencies) {
|
|
this.dependencies = dependencies;
|
|
}
|
|
|
|
private _log(message: unknown, level?: LOG_LEVEL, key?: string) {
|
|
this.dependencies.log(message, level, key);
|
|
}
|
|
|
|
private async readFile(path: FilePath) {
|
|
return await readCustomisationFile(
|
|
{
|
|
storageAccess: this.dependencies.storageAccess,
|
|
log: this.dependencies.log,
|
|
},
|
|
path,
|
|
this.dependencies.getConfigDir()
|
|
);
|
|
}
|
|
|
|
// Compatibility question: the inherited force parameter is not read.
|
|
// Preserve it until its intended write-bypass semantics are decided.
|
|
async storeCustomisationFileV2(
|
|
path: FilePath,
|
|
term: string,
|
|
force = false
|
|
): Promise<SnapshotPersistenceResult<StoreResultValue>> {
|
|
void force;
|
|
const vf = this.dependencies.path.filenameWithUnifiedKey(path, term);
|
|
return await serialized(`plugin-${vf}`, async () => {
|
|
const prefixedFileName = vf;
|
|
|
|
const id = await this.dependencies.path.path2id(prefixedFileName);
|
|
const stat = await this.dependencies.storageAccess.statHidden(path);
|
|
if (!stat) {
|
|
return result(false, "missing");
|
|
}
|
|
const mtime = stat.mtime;
|
|
const content = await this.dependencies.storageAccess.readHiddenFileBinary(path);
|
|
const contentBlob = createBlob([DUMMY_HEAD, DUMMY_END, ...(await arrayBufferToBase64(content))]);
|
|
// const contentBlob = createBlob(content);
|
|
try {
|
|
const old = await this.dependencies
|
|
.getLocalDatabase()
|
|
.getDBEntryMeta(prefixedFileName, undefined, false);
|
|
let saveData: SavingEntry;
|
|
if (old === false) {
|
|
saveData = {
|
|
_id: id,
|
|
path: prefixedFileName,
|
|
data: contentBlob,
|
|
mtime,
|
|
ctime: mtime,
|
|
datatype: "plain",
|
|
size: contentBlob.size,
|
|
children: [],
|
|
deleted: false,
|
|
type: "plain",
|
|
eden: {},
|
|
};
|
|
} else {
|
|
// Compatibility question: this inherited marker check
|
|
// precedes loading the old document and can suppress a
|
|
// content comparison. Preserve that event-suppression
|
|
// ordering until its scan contract is reviewed.
|
|
if (
|
|
this.dependencies.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN
|
|
) {
|
|
this._log(
|
|
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Already checked the same)`,
|
|
LOG_LEVEL_DEBUG
|
|
);
|
|
return result(undefined, "skipped");
|
|
}
|
|
const docXDoc = await this.dependencies.getLocalDatabase().getDBEntryFromMeta(old, false, false);
|
|
if (docXDoc == false) {
|
|
throw new LiveSyncError("Could not load the document");
|
|
}
|
|
const dataSrc = getDocData(docXDoc.data);
|
|
const dataStart = dataSrc.indexOf(DUMMY_END);
|
|
const oldContent = dataSrc.substring(dataStart + DUMMY_END.length);
|
|
const oldContentArray = base64ToArrayBuffer(oldContent);
|
|
if (await isDocContentSame(oldContentArray, content)) {
|
|
this._log(
|
|
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (the same content)`,
|
|
LOG_LEVEL_VERBOSE
|
|
);
|
|
this.dependencies.path.markChangesAreSame(prefixedFileName, old.mtime, mtime + 1);
|
|
return result(true, "skipped");
|
|
}
|
|
saveData = {
|
|
...old,
|
|
data: contentBlob,
|
|
mtime,
|
|
size: contentBlob.size,
|
|
datatype: "plain",
|
|
children: [],
|
|
deleted: false,
|
|
type: "plain",
|
|
};
|
|
}
|
|
const ret = await this.dependencies.getLocalDatabase().putDBEntry(saveData);
|
|
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
|
|
// Compatibility question: the inherited refresh path omits the
|
|
// explicit term override and therefore uses the current term.
|
|
// Preserve that path until its cross-device semantics are reviewed.
|
|
return result(ret, "saved", [
|
|
{
|
|
mode: "v2",
|
|
timing: "fire-and-forget",
|
|
path: this.dependencies.path.filenameWithUnifiedKey(path),
|
|
},
|
|
]);
|
|
} catch (ex) {
|
|
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
|
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
|
return result(false, "failed");
|
|
}
|
|
});
|
|
}
|
|
|
|
async storeCustomizationFiles(path: FilePath, term: string): Promise<SnapshotPersistenceResult<StoreResultValue>> {
|
|
const vf = this.dependencies.path.filenameToUnifiedKey(path, term);
|
|
// console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`);
|
|
|
|
return await serialized(`plugin-${vf}`, async () => {
|
|
const category = this.dependencies.path.getFileCategory(path);
|
|
let mtime = 0;
|
|
let fileTargets = [] as FilePath[];
|
|
// let savePath = "";
|
|
const name =
|
|
category == "CONFIG" || category == "SNIPPET"
|
|
? path.split("/").reverse()[0]
|
|
: path.split("/").reverse()[1];
|
|
const parentPath = path.split("/").slice(0, -1).join("/");
|
|
const prefixedFileName = this.dependencies.path.filenameToUnifiedKey(path, term);
|
|
const id = await this.dependencies.path.path2id(prefixedFileName);
|
|
const dt: PluginDataEx = {
|
|
category: category,
|
|
files: [],
|
|
name: name,
|
|
mtime: 0,
|
|
term: term,
|
|
};
|
|
// let scheduleKey = "";
|
|
if (
|
|
category == "CONFIG" ||
|
|
category == "SNIPPET" ||
|
|
category == "PLUGIN_ETC" ||
|
|
category == "PLUGIN_DATA"
|
|
) {
|
|
fileTargets = [path];
|
|
if (category == "PLUGIN_ETC") {
|
|
dt.displayName = path.split("/").slice(-1).join("/");
|
|
}
|
|
} else if (category == "PLUGIN_MAIN") {
|
|
fileTargets = ["manifest.json", "main.js", "styles.css"].map((e) => `${parentPath}/${e}` as FilePath);
|
|
} else if (category == "THEME") {
|
|
fileTargets = ["manifest.json", "theme.css"].map((e) => `${parentPath}/${e}` as FilePath);
|
|
}
|
|
for (const target of fileTargets) {
|
|
const data = await this.readFile(target);
|
|
if (data == false) {
|
|
this._log(`Config: skipped (Possibly is not exist): ${target} `, LOG_LEVEL_VERBOSE);
|
|
continue;
|
|
}
|
|
if (data.version) {
|
|
dt.version = data.version;
|
|
}
|
|
if (data.displayName) {
|
|
dt.displayName = data.displayName;
|
|
}
|
|
// Compatibility question: the inherited aggregation uses an
|
|
// average rather than the newest member mtime. Preserve that
|
|
// scan behaviour until its timestamp policy is reviewed.
|
|
mtime = mtime == 0 ? data.mtime : (data.mtime + mtime) / 2;
|
|
dt.files.push(data);
|
|
}
|
|
dt.mtime = mtime;
|
|
|
|
// Compatibility question: the inherited empty-file path performs a
|
|
// deletion refresh and then an unconditional explicit refresh. Keep
|
|
// both outcomes, including the extra refresh when deletion succeeds.
|
|
if (dt.files.length == 0) {
|
|
this._log(`Nothing left: deleting.. ${path}`);
|
|
const deletion = await this.deleteConfigOnDatabase(prefixedFileName);
|
|
return result(undefined, deletion.status, [
|
|
...deletion.refreshes,
|
|
{ mode: "v1", timing: "await", path: prefixedFileName },
|
|
]);
|
|
}
|
|
|
|
const content = createTextBlob(serialize(dt));
|
|
try {
|
|
const old = await this.dependencies
|
|
.getLocalDatabase()
|
|
.getDBEntryMeta(prefixedFileName, undefined, false);
|
|
let saveData: SavingEntry;
|
|
if (old === false) {
|
|
saveData = {
|
|
_id: id,
|
|
path: prefixedFileName,
|
|
data: content,
|
|
mtime,
|
|
ctime: mtime,
|
|
datatype: "newnote",
|
|
size: content.size,
|
|
children: [],
|
|
deleted: false,
|
|
type: "newnote",
|
|
eden: {},
|
|
};
|
|
} else {
|
|
if (old.mtime == mtime) {
|
|
// this._log(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE);
|
|
return result(true, "skipped");
|
|
}
|
|
const oldC = await this.dependencies.getLocalDatabase().getDBEntryFromMeta(old, false, false);
|
|
if (oldC) {
|
|
const d = deserialize(getDocDataAsArray(oldC.data), {}) as PluginDataEx;
|
|
if (d.files.length == dt.files.length) {
|
|
// Compatibility question: the inherited comparison
|
|
// looks up each current file by the previous filename
|
|
// and compares a missing lookup as empty content.
|
|
// Preserve this rename/empty-file behaviour for now.
|
|
const diffs = d.files
|
|
.map((previous) => ({
|
|
prev: previous,
|
|
curr: dt.files.find((e) => e.filename == previous.filename),
|
|
}))
|
|
.map(async (e) => {
|
|
try {
|
|
return await isDocContentSame(e.curr?.data ?? [], e.prev.data);
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
const isSame = (await Promise.all(diffs)).every((e) => e == true);
|
|
if (isSame) {
|
|
this._log(
|
|
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same content)`,
|
|
LOG_LEVEL_VERBOSE
|
|
);
|
|
return result(true, "skipped");
|
|
}
|
|
}
|
|
}
|
|
saveData = {
|
|
...old,
|
|
data: content,
|
|
mtime,
|
|
size: content.size,
|
|
datatype: "newnote",
|
|
children: [],
|
|
deleted: false,
|
|
type: "newnote",
|
|
};
|
|
}
|
|
const ret = await this.dependencies.getLocalDatabase().putDBEntry(saveData);
|
|
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
|
|
return result(ret, "saved", [{ mode: "v1", timing: "await", path: saveData.path }]);
|
|
} catch (ex) {
|
|
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
|
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
|
return result(false, "failed");
|
|
}
|
|
});
|
|
}
|
|
|
|
// Compatibility question: the inherited forceWrite parameter is not read.
|
|
// Preserve it until callers define whether deletion should bypass a marker.
|
|
async deleteConfigOnDatabase(
|
|
prefixedFileName: FilePathWithPrefix,
|
|
forceWrite = false
|
|
): Promise<SnapshotPersistenceResult<boolean>> {
|
|
void forceWrite;
|
|
// const id = await this.path2id(prefixedFileName);
|
|
const mtime = new Date().getTime();
|
|
return await serialized("file-x-" + prefixedFileName, async () => {
|
|
try {
|
|
const old = (await this.dependencies
|
|
.getLocalDatabase()
|
|
.getDBEntryMeta(prefixedFileName, undefined, false)) as InternalFileEntry | false;
|
|
let saveData: InternalFileEntry;
|
|
if (old === false) {
|
|
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) already deleted (Not found on database)`);
|
|
return result(true, "missing");
|
|
} else {
|
|
if (old.deleted) {
|
|
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) already deleted`);
|
|
return result(true, "already-deleted");
|
|
}
|
|
saveData = {
|
|
...old,
|
|
mtime,
|
|
size: 0,
|
|
children: [],
|
|
deleted: true,
|
|
type: "newnote",
|
|
};
|
|
}
|
|
await this.dependencies.getLocalDatabase().putRaw(saveData);
|
|
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Done`);
|
|
return result(true, "deleted", [{ mode: "v1", timing: "await", path: prefixedFileName }]);
|
|
} catch (ex) {
|
|
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`);
|
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
|
return result(false, "failed");
|
|
}
|
|
});
|
|
}
|
|
}
|