-
Notifications
You must be signed in to change notification settings - Fork 5
[ENG-1178] Automatic local remote sync nodes relations #706
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
trangdoan982
wants to merge
13
commits into
main
Choose a base branch
from
eng-1178-f9-automatic-local-remote-sync-nodes-relations
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+799
−65
Open
Changes from 12 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
b854d72
current progress
trangdoan982 812e0b9
redo the auth so that only one user acc is created
trangdoan982 22d1dae
sync all nodes on load
trangdoan982 04dc7fc
current progress
trangdoan982 42db03a
revert changes
trangdoan982 23c76ab
enable cors
trangdoan982 0db348a
current progress
trangdoan982 014dd24
restructure + add cleanupOrphanedNodes
trangdoan982 f7da57a
clean up
trangdoan982 eb3a19f
cleanup
trangdoan982 7cc57d1
make sure file created is upserted
trangdoan982 777b079
address PR comments
trangdoan982 7845a32
address coderabbit
trangdoan982 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,349 @@ | ||
| 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); | ||
|
|
||
| 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)) { | ||
maparent marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 { | ||
| // Process files one by one, removing from queue as we go | ||
| const processedFiles: string[] = []; | ||
| const failedFiles: string[] = []; | ||
|
|
||
| while (this.changeQueue.size > 0) { | ||
| // Get the first item from the queue | ||
| const firstEntry = this.changeQueue.entries().next().value as | ||
| | [string, QueuedChange] | ||
| | undefined; | ||
|
|
||
| if (!firstEntry) { | ||
| break; | ||
| } | ||
|
|
||
| const [filePath, change] = firstEntry; | ||
|
|
||
| try { | ||
| const changeTypesByPath = new Map<string, ChangeType[]>(); | ||
| changeTypesByPath.set(filePath, Array.from(change.changeTypes)); | ||
|
|
||
| await syncDiscourseNodeChanges(this.plugin, changeTypesByPath); | ||
|
|
||
| // Only remove from queue after successful processing | ||
| this.changeQueue.delete(filePath); | ||
| processedFiles.push(filePath); | ||
| } catch (error) { | ||
| console.error( | ||
| `Error processing file ${filePath}, will retry later:`, | ||
| error, | ||
| ); | ||
| // Remove from queue even on failure to prevent infinite retry loops | ||
| // Failed files will be re-queued if they change again | ||
| this.changeQueue.delete(filePath); | ||
| failedFiles.push(filePath); | ||
| } | ||
| } | ||
|
|
||
| if (processedFiles.length > 0) { | ||
| console.debug( | ||
| `Successfully processed ${processedFiles.length} file(s):`, | ||
| processedFiles, | ||
| ); | ||
| } | ||
|
|
||
| if (failedFiles.length > 0) { | ||
| console.warn( | ||
| `Failed to process ${failedFiles.length} file(s), will retry on next change:`, | ||
| failedFiles, | ||
| ); | ||
| } | ||
|
|
||
| if (this.hasPendingOrphanCleanup) { | ||
| const deletedCount = await cleanupOrphanedNodes(this.plugin); | ||
| if (deletedCount > 0) { | ||
| console.debug(`Deleted ${deletedCount} orphaned node(s)`); | ||
| } | ||
| this.hasPendingOrphanCleanup = false; | ||
| } | ||
|
|
||
| if (processedFiles.length > 0 || failedFiles.length === 0) { | ||
| console.debug("Sync queue processed"); | ||
| } | ||
| } catch (error) { | ||
| console.error("Error processing sync queue:", error); | ||
| // Items that weren't processed remain in the queue for retry | ||
| } 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"); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resolved during sync meeting: newly created node might not have nodeTypeId yet -> miss being in the queue. but agreed that in the future handleMetadataChangess will handle this case
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought we resolved it, but not sure anymore. It is handled by handleMetadataChanges now, afaik. (In the future: of that file's lifecycle, not in a future version of the code, right?)
So I still don't see why pendingCreates exists.