mirror of
https://github.com/mntno/obsidian-come-through.git
synced 2026-07-22 12:40:30 +00:00
305 lines
12 KiB
TypeScript
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."}`);
|
|
}
|
|
}
|
|
}
|