Skip to content

Commit eb68d8b

Browse files
committed
feature finished
1 parent 696f5bd commit eb68d8b

File tree

5 files changed

+254
-19
lines changed

5 files changed

+254
-19
lines changed

apps/obsidian/src/components/DiscourseContextView.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
1-
import { ItemView, TFile, WorkspaceLeaf } from "obsidian";
1+
import { ItemView, TFile, WorkspaceLeaf, Notice } from "obsidian";
22
import { createRoot, Root } from "react-dom/client";
33
import DiscourseGraphPlugin from "~/index";
44
import { getDiscourseNodeFormatExpression } from "~/utils/getDiscourseNodeFormatExpression";
55
import { RelationshipSection } from "~/components/RelationshipSection";
66
import { VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types";
77
import { PluginProvider, usePlugin } from "~/components/PluginContext";
88
import { getNodeTypeById } from "~/utils/typeUtils";
9+
import { refreshImportedFile } from "~/utils/importNodes";
10+
import { useState } from "react";
911

1012
type DiscourseContextProps = {
1113
activeFile: TFile | null;
1214
};
1315

1416
const DiscourseContext = ({ activeFile }: DiscourseContextProps) => {
1517
const plugin = usePlugin();
18+
const [isRefreshing, setIsRefreshing] = useState(false);
1619

1720
const extractContentFromTitle = (format: string, title: string): string => {
1821
if (!format) return "";
@@ -21,6 +24,30 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => {
2124
return match?.[1] ?? title;
2225
};
2326

27+
const handleRefresh = async () => {
28+
if (!activeFile || isRefreshing) return;
29+
30+
setIsRefreshing(true);
31+
try {
32+
const result = await refreshImportedFile({ plugin, file: activeFile });
33+
if (result.success) {
34+
new Notice("File refreshed successfully", 3000);
35+
} else {
36+
new Notice(
37+
`Failed to refresh file: ${result.error || "Unknown error"}`,
38+
5000,
39+
);
40+
}
41+
} catch (error) {
42+
const errorMessage =
43+
error instanceof Error ? error.message : String(error);
44+
new Notice(`Refresh failed: ${errorMessage}`, 5000);
45+
console.error("Refresh failed:", error);
46+
} finally {
47+
setIsRefreshing(false);
48+
}
49+
};
50+
2451
const renderContent = () => {
2552
if (!activeFile) {
2653
return <div>No file is open</div>;
@@ -45,6 +72,9 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => {
4572
if (!nodeType) {
4673
return <div>Unknown node type: {frontmatter.nodeTypeId}</div>;
4774
}
75+
76+
const isImported = !!frontmatter.importedFromSpaceId;
77+
4878
return (
4979
<>
5080
<div className="mb-6">
@@ -56,6 +86,18 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => {
5686
/>
5787
)}
5888
{nodeType.name || "Unnamed Node Type"}
89+
{isImported && (
90+
<button
91+
onClick={() => {
92+
void handleRefresh();
93+
}}
94+
disabled={isRefreshing}
95+
className="ml-auto rounded border px-2 py-1 text-xs"
96+
title="Refresh from source"
97+
>
98+
{isRefreshing ? "Refreshing..." : "🔄 Refresh"}
99+
</button>
100+
)}
59101
</div>
60102

61103
{nodeType.format && (

apps/obsidian/src/services/QueryEngine.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,36 @@ export class QueryEngine {
290290
}
291291
}
292292

293+
/**
294+
* Find an existing imported file by nodeInstanceId and importedFromSpaceId
295+
* Returns the file if found, null otherwise
296+
*/
297+
findExistingImportedFile = (
298+
nodeInstanceId: string,
299+
importedFromSpaceId: number,
300+
): TFile | null => {
301+
if (this.dc) {
302+
try {
303+
const dcQuery = `@page and nodeInstanceId = "${nodeInstanceId}" and importedFromSpaceId = ${importedFromSpaceId}`;
304+
const results = this.dc.query(dcQuery);
305+
306+
for (const page of results) {
307+
if (page.$path) {
308+
const file = this.app.vault.getAbstractFileByPath(page.$path);
309+
if (file && file instanceof TFile) {
310+
return file;
311+
}
312+
}
313+
}
314+
return null;
315+
} catch (error) {
316+
console.warn("Error querying DataCore for imported file:", error);
317+
return null;
318+
}
319+
}
320+
return null;
321+
};
322+
293323
private async fallbackScanVault(
294324
patterns: BulkImportPattern[],
295325
validNodeTypes: DiscourseNode[],

apps/obsidian/src/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ export type ImportableNode = {
6060
spaceId: number;
6161
spaceName: string;
6262
groupId: string;
63-
groupName?: string;
6463
selected: boolean;
6564
};
6665

apps/obsidian/src/utils/importNodes.ts

Lines changed: 139 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
2+
import { TFile } from "obsidian";
23
import type { DGSupabaseClient } from "@repo/database/lib/client";
34
import type DiscourseGraphPlugin from "~/index";
45
import { getLoggedInClient, getSupabaseContext } from "./supabaseContext";
56
import type { DiscourseNode, ImportableNode } from "~/types";
67
import generateUid from "~/utils/generateUid";
8+
import { QueryEngine } from "~/services/QueryEngine";
79

810
export const getAvailableGroups = async (
911
client: DGSupabaseClient,
@@ -225,6 +227,7 @@ export const fetchNodeMetadata = async ({
225227
};
226228
};
227229

230+
228231
const sanitizeFileName = (fileName: string): string => {
229232
// Remove invalid characters for file names
230233
return fileName
@@ -413,9 +416,9 @@ const processFileContent = async ({
413416
sourceSpaceId: number;
414417
rawContent: string;
415418
filePath: string;
416-
}): Promise<void> => {
419+
}): Promise<{ file: TFile; error?: string }> => {
417420
const { frontmatter } = parseFrontmatter(rawContent);
418-
const sourceNodeTypeId = frontmatter.nodeTypeId
421+
const sourceNodeTypeId = frontmatter.nodeTypeId;
419422

420423
let mappedNodeTypeId: string | undefined;
421424
if (sourceNodeTypeId && typeof sourceNodeTypeId === "string") {
@@ -427,14 +430,20 @@ const processFileContent = async ({
427430
});
428431
}
429432

430-
const file = await plugin.app.vault.create(filePath, rawContent);
431-
433+
let file: TFile | null = plugin.app.vault.getFileByPath(filePath);
434+
if (!file) {
435+
file = await plugin.app.vault.create(filePath, rawContent);
436+
} else {
437+
await plugin.app.vault.modify(file, rawContent);
438+
}
432439
await plugin.app.fileManager.processFrontMatter(file, (fm) => {
433440
if (mappedNodeTypeId !== undefined) {
434441
(fm as Record<string, unknown>).nodeTypeId = mappedNodeTypeId;
435442
}
436443
(fm as Record<string, unknown>).importedFromSpaceId = sourceSpaceId;
437444
});
445+
446+
return { file };
438447
};
439448

440449
export const importSelectedNodes = async ({
@@ -454,6 +463,8 @@ export const importSelectedNodes = async ({
454463
throw new Error("Cannot get Supabase context");
455464
}
456465

466+
const queryEngine = new QueryEngine(plugin.app);
467+
457468
let successCount = 0;
458469
let failedCount = 0;
459470

@@ -480,6 +491,12 @@ export const importSelectedNodes = async ({
480491
// Process each node in this space
481492
for (const node of nodes) {
482493
try {
494+
// Check if file already exists by nodeInstanceId + importedFromSpaceId
495+
const existingFile = queryEngine.findExistingImportedFile(
496+
node.nodeInstanceId,
497+
node.spaceId,
498+
);
499+
483500
// Fetch the file name (direct variant) and content (full variant)
484501
const fileName = await fetchNodeContent({
485502
client,
@@ -504,34 +521,62 @@ export const importSelectedNodes = async ({
504521
});
505522

506523
if (content === null) {
507-
console.warn(
508-
`No full variant found for node ${node.nodeInstanceId}`,
509-
);
524+
console.warn(`No full variant found for node ${node.nodeInstanceId}`);
510525
failedCount++;
511526
continue;
512527
}
513528

514529
// Sanitize file name
515530
const sanitizedFileName = sanitizeFileName(fileName);
516-
const filePath = `${importFolderPath}/${sanitizedFileName}.md`;
517-
518-
// Check if file already exists and handle duplicates
519-
let finalFilePath = filePath;
520-
let counter = 1;
521-
while (await plugin.app.vault.adapter.exists(finalFilePath)) {
522-
finalFilePath = `${importFolderPath}/${sanitizedFileName} (${counter}).md`;
523-
counter++;
531+
let finalFilePath: string;
532+
533+
if (existingFile) {
534+
// Update existing file - use its current path
535+
finalFilePath = existingFile.path;
536+
} else {
537+
// Create new file in the import folder
538+
finalFilePath = `${importFolderPath}/${sanitizedFileName}.md`;
539+
540+
// Check if file path already exists (edge case: same title but different nodeInstanceId)
541+
let counter = 1;
542+
while (await plugin.app.vault.adapter.exists(finalFilePath)) {
543+
finalFilePath = `${importFolderPath}/${sanitizedFileName} (${counter}).md`;
544+
counter++;
545+
}
524546
}
525547

526548
// Process the file content (maps nodeTypeId, handles frontmatter)
527-
// This creates the file and uses Obsidian's processFrontMatter API
528-
await processFileContent({
549+
// This updates existing file or creates new one
550+
const result = await processFileContent({
529551
plugin,
530552
client,
531553
sourceSpaceId: node.spaceId,
532554
rawContent: content,
533555
filePath: finalFilePath,
534556
});
557+
558+
if (result.error) {
559+
console.error(
560+
`Error processing file content for node ${node.nodeInstanceId}:`,
561+
result.error,
562+
);
563+
failedCount++;
564+
continue;
565+
}
566+
567+
// If title changed and file exists, rename it to match the new title
568+
const processedFile = result.file;
569+
if (existingFile && processedFile.basename !== sanitizedFileName) {
570+
const newPath = `${importFolderPath}/${sanitizedFileName}.md`;
571+
let targetPath = newPath;
572+
let counter = 1;
573+
while (await plugin.app.vault.adapter.exists(targetPath)) {
574+
targetPath = `${importFolderPath}/${sanitizedFileName} (${counter}).md`;
575+
counter++;
576+
}
577+
await plugin.app.fileManager.renameFile(processedFile, targetPath);
578+
}
579+
535580
successCount++;
536581
} catch (error) {
537582
console.error(
@@ -545,3 +590,80 @@ export const importSelectedNodes = async ({
545590

546591
return { success: successCount, failed: failedCount };
547592
};
593+
594+
/**
595+
* Refresh a single imported file by fetching the latest content from the database
596+
* Reuses the same logic as importSelectedNodes by treating it as a single-node import
597+
*/
598+
export const refreshImportedFile = async ({
599+
plugin,
600+
file,
601+
client,
602+
}: {
603+
plugin: DiscourseGraphPlugin;
604+
file: TFile;
605+
client?: DGSupabaseClient;
606+
}): Promise<{ success: boolean; error?: string }> => {
607+
const supabaseClient = client || await getLoggedInClient(plugin);
608+
if (!supabaseClient) {
609+
throw new Error("Cannot get Supabase client");
610+
}
611+
const cache = plugin.app.metadataCache.getFileCache(file);
612+
const frontmatter = cache?.frontmatter;
613+
const spaceName = await getSpaceName(supabaseClient, frontmatter?.importedFromSpaceId as number);
614+
const result = await importSelectedNodes({ plugin, selectedNodes: [{
615+
nodeInstanceId: frontmatter?.nodeInstanceId as string,
616+
title: file.basename,
617+
spaceId: frontmatter?.importedFromSpaceId as number,
618+
spaceName: spaceName,
619+
groupId: frontmatter?.publishedToGroups?.[0] as string,
620+
selected: false,
621+
}] });
622+
return { success: result.success > 0, error: result.failed > 0 ? "Failed to refresh imported file" : undefined };
623+
};
624+
625+
/**
626+
* Refresh all imported files in the vault
627+
*/
628+
export const refreshAllImportedFiles = async (
629+
plugin: DiscourseGraphPlugin,
630+
): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> => {
631+
const allFiles = plugin.app.vault.getMarkdownFiles();
632+
const importedFiles: TFile[] = [];
633+
const client = await getLoggedInClient(plugin);
634+
if (!client) {
635+
throw new Error("Cannot get Supabase client");
636+
}
637+
// Find all imported files
638+
for (const file of allFiles) {
639+
const cache = plugin.app.metadataCache.getFileCache(file);
640+
const frontmatter = cache?.frontmatter;
641+
if (frontmatter?.importedFromSpaceId && frontmatter?.nodeInstanceId) {
642+
importedFiles.push(file);
643+
}
644+
}
645+
646+
if (importedFiles.length === 0) {
647+
return { success: 0, failed: 0, errors: [] };
648+
}
649+
650+
let successCount = 0;
651+
let failedCount = 0;
652+
const errors: Array<{ file: string; error: string }> = [];
653+
654+
// Refresh each file
655+
for (const file of importedFiles) {
656+
const result = await refreshImportedFile({ plugin, file, client });
657+
if (result.success) {
658+
successCount++;
659+
} else {
660+
failedCount++;
661+
errors.push({
662+
file: file.path,
663+
error: result.error || "Unknown error",
664+
});
665+
}
666+
}
667+
668+
return { success: successCount, failed: failedCount, errors };
669+
};

0 commit comments

Comments
 (0)