mntno_obsidian-come-through/src/data/SyncManager.ts
2026-05-05 17:58:17 +07:00

305 lines
12 KiB
TypeScript

import { DataStore, StatisticsData } from "#/data/DataStore";
import { FullID, NoteID } from "#/data/FullID";
import { DeclarationCodec } from "#/declarations/DeclarationCodec";
import { DeclarationInfo, DeclarationParser, PostParseInfo } from "#/declarations/DeclarationParser";
import { CardDeclarable, ExplicitDeclarationAssistant } from "#/declarations/ExplicitDeclaration";
import { Env } from "#/env";
import { asNoteID, isString } from "#/TypeAssistant";
import { UNARY_UNION_SUPPRESS } from "#/utils/ts";
import { App, CachedMetadata, Editor, FileManager, TAbstractFile, TFile } from "obsidian";
/** Keeps the plugin's internal data synchronized with card declarations in the vault by listening for and processing file system events. */
export class SyncManager {
public readonly app: App;
private readonly dataStore: DataStore;
private readonly statisticsFactory: () => StatisticsData;
private isSuspended = false;
public constructor(dataStore: DataStore, app: App, statisticsFactory: () => StatisticsData) {
this.dataStore = dataStore;
this.app = app;
this.statisticsFactory = statisticsFactory;
}
public suspend() {
this.isSuspended = true;
}
public resume() {
this.isSuspended = false;
}
/** Ensures that {@link resume} is called after {@link action} has exited. */
public whileSuspended(action: () => void) {
Env.log.d("SyncManager:whileSuspended: suspending");
try {
this.suspend();
action();
}
catch (error) {
Env.error(error);
}
finally {
Env.log.d("SyncManager:whileSuspended: resuming");
this.resume();
}
}
public open = async (file: TFile | null) => {
Env.log.d(`SyncManager:open isSuspended: ${this.isSuspended}`);
if (this.isSuspended)
return;
if (file) {
const ids = await SyncManager.processFile(file, this.app);
await this.syncIDs(ids, file);
}
};
public changed = async (file: TFile, data: string, cache: CachedMetadata) => {
Env.log.d(`SyncManager:changed isSuspended: ${this.isSuspended}`);
if (this.isSuspended)
return;
const ids = await SyncManager.processFileChanged(file, data, cache, this.app);
await this.syncIDs(ids, file);
};
public delete = async (file: TAbstractFile) => {
Env.log.d(`SyncManager:delete isSuspended: ${this.isSuspended}`);
if (this.isSuspended)
return;
if (file instanceof TFile && this.dataStore.removeNote(asNoteID(file.path)))
await this.dataStore.save();
};
public rename = async (file: TAbstractFile, oldPath: string) => {
Env.log.d(`SyncManager:rename isSuspended: ${this.isSuspended}`);
if (this.isSuspended)
return;
if (file instanceof TFile && this.dataStore.changeNoteID(asNoteID(oldPath), asNoteID(file), false))
await this.dataStore.save();
};
private static async processFile(file: TFile, app: App) {
const { ids, output } = await DeclarationParser.getAllIDsInFile(file, app, (id) => id.isFrontSide)
const autoGeneratedIDs = await this.postProcessWithFile(app, file, output);
return [...ids, ...autoGeneratedIDs];
}
private static async processFileChanged(file: TFile, fileContent: string, cache: CachedMetadata, app: App) {
const { ids, output } = DeclarationParser.getAllIDsFromMetadata(asNoteID(file), fileContent, cache, (id) => id.isFrontSide);
const editor = app.workspace.activeEditor?.editor;
const autoGeneratedIDs = editor ? await this.postProcessWithEditor(editor, output, file, app.fileManager) : [];
return [...ids, ...autoGeneratedIDs];
}
/**
* This method and {@link postProcessWithFile} goes through all defaultable/incomplete declarations
* (those whose empty keys can be assigned default values, such as a generated id)
* in {@link postInfo} that can be assigned default values and does so, modifying the file
* in the process.
*
* The returned values are the {@link FullID}s of those declarations that were completed
* and therefore also need to be saved to persistant storage.
*
* This method is preferred over {@link postProcessWithFile} when the file can be modified via an
* {@link Editor} instance.
*
* @param editor
* @param postInfo
* @returns IDs of declarations that this method was able to make valid.
*/
private static async postProcessWithEditor(editor: Editor, postInfo: PostParseInfo, file: TFile, fileManager: FileManager): Promise<FullID[]> {
const replacementDeclarationInfos = SyncManager.getFrontSideInvalidDeclarations(postInfo);
if (replacementDeclarationInfos.length == 0)
return [];
const cursorPosition = editor.getCursor(); // Do not modify the section if the cursor is there.
const autoGeneratedIDs: FullID[] = [];
const existingIDs = new Set<string>();
let diff = 0;
for (const info of replacementDeclarationInfos) {
const completeDeclarationFromFrontmatter = await SyncManager.processFrontmatter(file, fileManager, info, existingIDs);
if (completeDeclarationFromFrontmatter) {
SyncManager.createAndAddIDFromDeclarable(autoGeneratedIDs, completeDeclarationFromFrontmatter, info.noteID, existingIDs);
continue; // This declaration done, go to next.
}
const startOffsetBeforeModification = info.section.position.start.offset + diff;
const endOffsetBeforeModification = info.section.position.end.offset + diff;
const startPosBeforeModification = editor.offsetToPos(startOffsetBeforeModification);
const endPosBeforeModification = editor.offsetToPos(endOffsetBeforeModification);
// Cursor is in section.
if (cursorPosition.line >= startPosBeforeModification.line &&
cursorPosition.line <= endPosBeforeModification.line)
continue;
const completeDeclaration = ExplicitDeclarationAssistant.completeOrThrow(info.declaration, existingIDs);
const replacement = DeclarationCodec.toYaml(completeDeclaration);
// This will trigger file changed events.
editor.replaceRange(
replacement,
editor.offsetToPos(info.section.position.start.offset + info.location.start + diff),
{ line: endPosBeforeModification.line, ch: 0 },
undefined
);
existingIDs.add(completeDeclaration.id);
autoGeneratedIDs.push(FullID.create(
info.noteID,
completeDeclaration.id,
ExplicitDeclarationAssistant.isFrontSide(completeDeclaration)
));
diff += replacement.length - (info.location.end - info.location.start);
}
return autoGeneratedIDs;
}
/**
* See {@link postProcessWithEditor}.
*
* @param app
* @param file
* @param postInfo
* @returns
*/
private static async postProcessWithFile(app: App, file: TFile, postInfo: PostParseInfo): Promise<FullID[]> {
const replacementDeclarationInfos = SyncManager.getFrontSideInvalidDeclarations(postInfo);
if (replacementDeclarationInfos.length == 0)
return [];
const autoGeneratedIDs: FullID[] = [];
const existingIDs = new Set<string>();
for (const info of replacementDeclarationInfos) {
const completeDeclarationFromFrontmatter = await SyncManager.processFrontmatter(file, app.fileManager, info, existingIDs);
if (completeDeclarationFromFrontmatter) {
this.createAndAddIDFromDeclarable(autoGeneratedIDs, completeDeclarationFromFrontmatter, info.noteID, existingIDs);
replacementDeclarationInfos.remove(info); // Remove this one so it doesn't get processed again below.
}
}
if (replacementDeclarationInfos.length == 0)
return [];
// This will trigger file changed events
await app.vault.process(file, (data) => {
const parts: string[] = [];
let sliceStartIndex = 0;
for (const info of replacementDeclarationInfos) {
const startOffset = info.section.position.start.offset + info.location.start;
const replace = DeclarationCodec.toYaml(info.declaration);
const completeDeclaration = ExplicitDeclarationAssistant.completeOrThrow(info.declaration, existingIDs);
const replacement = DeclarationCodec.toYaml(completeDeclaration);
parts.push(data.slice(sliceStartIndex, startOffset));
parts.push(replacement);
sliceStartIndex = startOffset + replace.length;
SyncManager.createAndAddIDFromDeclarable(autoGeneratedIDs, completeDeclaration, info.noteID, existingIDs);
}
parts.push(data.slice(sliceStartIndex));
return parts.join(""); // If [separator is] omitted, the array elements are separated with a comma.
});
return autoGeneratedIDs;
}
/**
* {@link PostParseInfo.incompleteDeclarationInfos} should contain {@link DeclarationInfo}s
* that can be represented as statistics items and persisted to disk if missing values are generated.
*
* This method filters out declarations that should not be persisted.
* @param postInfo As populated by {@link DeclarationParser.getAllIDsInFile} or {@link DeclarationParser.getAllIDsFromMetadata}.
* @returns
*/
private static getFrontSideInvalidDeclarations(postInfo: PostParseInfo) {
const declarations = postInfo.incompleteDeclarationInfos
.filter(info => (
ExplicitDeclarationAssistant.canComplete(info.declaration) &&
ExplicitDeclarationAssistant.isFrontSide(info.declaration))
);
return declarations;
}
private static createAndAddIDFromDeclarable(ids: FullID[], declarable: CardDeclarable, noteID: NoteID, existingIDs: Set<string>) {
existingIDs.add(declarable.id);
ids.push(FullID.create(
noteID,
declarable.id,
ExplicitDeclarationAssistant.isFrontSide(declarable)
));
}
/**
* If {@link info} refers to a declaration within the frontmatter, this method first
* makes it valid/complete, updates the frontmatter, then returns the valid declaration.
* @param file The file containing the frontmatter referred to by {@link info}.
* @param fileManager Used to modify the declaration in the frontmatter if needed.
* @param info Referring to an invalid declaration (method will assert and return `null` if declaration is already valid).
* @param preventIDs IDs to not generate if an ID needs to be generated for the declaration found in {@link info}.
* @returns Declarable found in {@link info} or `null` if none found.
*/
private static async processFrontmatter(file: TFile, fileManager: FileManager, info: DeclarationInfo, preventIDs: Set<string>) {
let completeDeclaration: CardDeclarable | null = null;
if (DeclarationParser.isExternalSectionCache(info.section)) {
switch (info.section.externalType) {
case UNARY_UNION_SUPPRESS:
break;
case "frontmatter": {
if (ExplicitDeclarationAssistant.is(info.declaration) && ExplicitDeclarationAssistant.isValid(info.declaration)) {
Env.assert(false, "Expected an invalid declaration.");
completeDeclaration = null; // info.declaration;
}
else {
// Set default values and modify the file.
completeDeclaration = ExplicitDeclarationAssistant.completeOrThrow(info.declaration, preventIDs);
const key = info.section.id;
if (isString(key)) {
// This seems to work fine while cursor is in the frontmatter (so no need to skip this declaration now because of that).
await fileManager.processFrontMatter(file, (fm) => fm[key] = completeDeclaration).catch(console.error);
}
else {
Env.assert(key, "Expected section id to be set to the frontmatter YAML key assinged to the declaration.");
}
}
break;
}
}
}
return completeDeclaration;
}
private async syncIDs(ids: FullID[], file: TFile) {
Env.log.d(`SyncManager:syncIDs isSuspended: ${this.isSuspended}, num IDs: ${ids.length}`);
if (this.isSuspended)
return;
// Continue even if ids.length == 0
// The syncing mechanism looks at the diffs to be able to determine if IDs were removed.
try {
this.dataStore.syncData(ids, file.path, this.statisticsFactory);
await this.dataStore.save();
}
catch (error) {
console.error("Failed to sync", error);
//this.ui.displayErrorNotice(`${(error instanceof Error) ? `Persisting changes failed: ${error.message}` : "An unknown error occurred while persisting changes."}`);
}
}
}