Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/obsidian/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ import { TagNodeHandler } from "~/utils/tagNodeHandler";
import { TldrawView } from "~/components/canvas/TldrawView";
import { NodeTagSuggestPopover } from "~/components/NodeTagSuggestModal";
import { initializeSupabaseSync } from "~/utils/syncDgNodesToSupabase";
import { FileChangeListener } from "~/utils/fileChangeListener";

export default class DiscourseGraphPlugin extends Plugin {
settings: Settings = { ...DEFAULT_SETTINGS };
private styleElement: HTMLStyleElement | null = null;
private tagNodeHandler: TagNodeHandler | null = null;
private fileChangeListener: FileChangeListener | null = null;
private currentViewActions: { leaf: WorkspaceLeaf; action: any }[] = [];
private pendingCanvasSwitches = new Set<string>();

Expand All @@ -43,6 +45,14 @@ export default class DiscourseGraphPlugin extends Plugin {
5000,
);
});

try {
this.fileChangeListener = new FileChangeListener(this);
this.fileChangeListener.initialize();
} catch (error) {
console.error("Failed to initialize FileChangeListener:", error);
this.fileChangeListener = null;
}
}

registerCommands(this);
Expand Down Expand Up @@ -353,6 +363,11 @@ export default class DiscourseGraphPlugin extends Plugin {
this.tagNodeHandler = null;
}

if (this.fileChangeListener) {
this.fileChangeListener.cleanup();
this.fileChangeListener = null;
}

this.app.workspace.detachLeavesOfType(VIEW_TYPE_DISCOURSE_CONTEXT);
}
}
315 changes: 315 additions & 0 deletions apps/obsidian/src/utils/fileChangeListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
import { TFile, TAbstractFile, EventRef } from "obsidian";
import { default as DiscourseGraphPlugin } from "~/index";
import {
syncDiscourseNodeChanges,
type ChangeType,
cleanupOrphanedNodes,
} from "./syncDgNodesToSupabase";
import { getNodeTypeById } from "./typeUtils";

type QueuedChange = {
filePath: string;
changeTypes: Set<ChangeType>;
oldPath?: string; // For rename operations
};

const DEBOUNCE_DELAY_MS = 5000; // 5 seconds

/**
* FileChangeListener monitors Obsidian vault events for DG node changes
* and queues them for sync to Supabase with debouncing.
*/
export class FileChangeListener {
private plugin: DiscourseGraphPlugin;
private changeQueue: Map<string, QueuedChange> = new Map();
private debounceTimer: NodeJS.Timeout | null = null;
private eventRefs: EventRef[] = [];
private metadataChangeCallback: ((file: TFile) => void) | null = null;
private isProcessing = false;
private hasPendingOrphanCleanup = false;
private pendingCreates: Set<string> = new Set();

constructor(plugin: DiscourseGraphPlugin) {
this.plugin = plugin;
}

/**
* Initialize the file change listener and register vault event handlers
*/
initialize(): void {
const createRef = this.plugin.app.vault.on(
"create",
(file: TAbstractFile) => {
this.handleFileCreate(file);
},
);
this.eventRefs.push(createRef);

const modifyRef = this.plugin.app.vault.on(
"modify",
(file: TAbstractFile) => {
this.handleFileModify(file);
},
);
this.eventRefs.push(modifyRef);

const deleteRef = this.plugin.app.vault.on(
"delete",
(file: TAbstractFile) => {
this.handleFileDelete(file);
},
);
this.eventRefs.push(deleteRef);

const renameRef = this.plugin.app.vault.on(
"rename",
(file: TAbstractFile, oldPath: string) => {
this.handleFileRename(file, oldPath);
},
);
this.eventRefs.push(renameRef);

this.metadataChangeCallback = (file: TFile) => {
this.handleMetadataChange(file);
};
this.plugin.app.metadataCache.on("changed", this.metadataChangeCallback);

console.debug("FileChangeListener initialized");
}

/**
* Check if a file is a DG node (has nodeTypeId in frontmatter that matches a node type in settings)
*/
private isDiscourseNode(file: TAbstractFile): boolean {
if (!(file instanceof TFile)) {
return false;
}

// Only process markdown files
if (!file.path.endsWith(".md")) {
return false;
}

const cache = this.plugin.app.metadataCache.getFileCache(file);
const nodeTypeId = cache?.frontmatter?.nodeTypeId as string | undefined;

if (!nodeTypeId || typeof nodeTypeId !== "string") {
return false;
}

// Verify that the nodeTypeId matches one of the node types in settings
return !!getNodeTypeById(this.plugin, nodeTypeId);
}

/**
* Handle file creation event
*/
private handleFileCreate(file: TAbstractFile): void {
if (!(file instanceof TFile)) {
return;
}

if (!file.path.endsWith(".md")) {
return;
}

this.pendingCreates.add(file.path);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trying to understand why you add non-DNode files to pendingCreates. Is it because they might become DNodes? But then that would be handled in the handleMetadataChange, right?
I may be missing something but this does not feel needed.


if (this.isDiscourseNode(file)) {
this.queueChange(file.path, "title");
this.queueChange(file.path, "content");
this.pendingCreates.delete(file.path);
}
}

/**
* Handle file modification event
*/
private handleFileModify(file: TAbstractFile): void {
if (!this.isDiscourseNode(file)) {
return;
}

console.log(`File modified: ${file.path}`);
this.queueChange(file.path, "content");
}

/**
* Handle file deletion event (placeholder - log only)
*/
private handleFileDelete(file: TAbstractFile): void {
if (!(file instanceof TFile) || !file.path.endsWith(".md")) {
return;
}

console.log(`File deleted: ${file.path}`);
this.hasPendingOrphanCleanup = true;
this.resetDebounceTimer();
}

/**
* Handle file rename/move event
*/
private handleFileRename(file: TAbstractFile, oldPath: string): void {
if (!this.isDiscourseNode(file)) {
// Check if the old file was a DG node (in case it lost nodeTypeId)
const oldFile = this.plugin.app.vault.getAbstractFileByPath(oldPath);
if (oldFile instanceof TFile) {
const oldCache = this.plugin.app.metadataCache.getFileCache(oldFile);
if (oldCache?.frontmatter?.nodeTypeId) {
console.log(`File renamed from DG node: ${oldPath} -> ${file.path}`);
this.queueChange(file.path, "title", oldPath);
}
}
return;
}

console.log(`File renamed: ${oldPath} -> ${file.path}`);
this.queueChange(file.path, "title", oldPath);
}

/**
* Handle metadata changes (placeholder for relation metadata)
*/
private handleMetadataChange(file: TFile): void {
if (!this.isDiscourseNode(file)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What of the case where a discourseNode stops being a discourseNode because someone removed the nodeTypeId was removed?
Not sure you would have access to the old value in the metadataCache, OTH?
Also: What happens if someone changes or removes the nodeTypeId?

return;
}

if (this.pendingCreates.has(file.path)) {
this.queueChange(file.path, "title");
this.queueChange(file.path, "content");
this.pendingCreates.delete(file.path);
return;
}

// Placeholder: Check for relation metadata changes
// For now, we'll just log that metadata changed
// In the future, this can detect specific relation changes
const cache = this.plugin.app.metadataCache.getFileCache(file);
if (cache?.frontmatter) {
console.debug(
`Metadata changed for ${file.path} (relation metadata placeholder)`,
);
}
}

/**
* Queue a file change for sync
*/
private queueChange(
filePath: string,
changeType: ChangeType,
oldPath?: string,
): void {
const existing = this.changeQueue.get(filePath);
if (existing) {
existing.changeTypes.add(changeType);
if (oldPath && !existing.oldPath) {
existing.oldPath = oldPath;
}
} else {
this.changeQueue.set(filePath, {
filePath,
changeTypes: new Set([changeType]),
oldPath,
});
}

this.resetDebounceTimer();
}

/**
* Reset the debounce timer
*/
private resetDebounceTimer(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}

this.debounceTimer = setTimeout(() => {
void this.processQueue();
}, DEBOUNCE_DELAY_MS);
}

/**
* Process the queued changes and sync to Supabase
*/
private async processQueue(): Promise<void> {
if (this.isProcessing) {
console.debug("Sync already in progress, skipping");
return;
}

if (this.changeQueue.size === 0 && !this.hasPendingOrphanCleanup) {
return;
}

this.isProcessing = true;

try {
const filesToSync = Array.from(this.changeQueue.values());

if (filesToSync.length > 0) {
const filePaths = filesToSync.map((change) => change.filePath);
console.debug(
`Processing ${filePaths.length} file(s) for sync:`,
filePaths,
);

const fileChanges = filesToSync.map((change) => ({
filePath: change.filePath,
changeTypes: Array.from(change.changeTypes),
oldPath: change.oldPath,
}));

await syncDiscourseNodeChanges(this.plugin, fileChanges);
}

if (this.hasPendingOrphanCleanup) {
const deletedCount = await cleanupOrphanedNodes(this.plugin);
if (deletedCount > 0) {
console.debug(`Deleted ${deletedCount} orphaned node(s)`);
}
}

this.changeQueue.clear();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comes after a lot of async work; new changes may have happened in the meantime and be lost. I suggest making the clear immediately after line 251, when the events are copied, so getting the queue is atomic. Then you'd want a try/catch, so you can put elements back if it fails... and then worry about recurring errors, or partial treatment...
Ok even better: I suggest dequeuing elements one by one as they are processed.

this.hasPendingOrphanCleanup = false;
console.debug("Sync queue processed successfully");
} catch (error) {
console.error("Error processing sync queue:", error);
// Keep the queue for retry (could implement retry logic later)
} finally {
this.isProcessing = false;
}
}

/**
* Cleanup event listeners
*/
cleanup(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}

this.eventRefs.forEach((ref) => {
this.plugin.app.vault.offref(ref);
});
this.eventRefs = [];

if (this.metadataChangeCallback) {
this.plugin.app.metadataCache.off(
"changed",
this.metadataChangeCallback as (...data: unknown[]) => unknown,
);
this.metadataChangeCallback = null;
}

this.changeQueue.clear();
this.pendingCreates.clear();
this.isProcessing = false;

console.debug("FileChangeListener cleaned up");
}
}
Loading