diff --git a/apps/obsidian/package.json b/apps/obsidian/package.json index 99e457466..70537f7fc 100644 --- a/apps/obsidian/package.json +++ b/apps/obsidian/package.json @@ -19,10 +19,10 @@ "@octokit/core": "^6.1.2", "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", + "@types/mime-types": "3.0.1", "@types/node": "^20", "@types/react": "catalog:obsidian", "@types/react-dom": "catalog:obsidian", - "@types/mime-types": "3.0.1", "autoprefixer": "^10.4.21", "builtin-modules": "3.3.0", "dotenv": "^16.4.5", @@ -43,6 +43,7 @@ "@repo/utils": "workspace:*", "@supabase/supabase-js": "catalog:", "date-fns": "^4.1.0", + "gray-matter": "^4.0.3", "mime-types": "^3.0.1", "nanoid": "^4.0.2", "react": "catalog:obsidian", diff --git a/apps/obsidian/src/components/DiscourseContextView.tsx b/apps/obsidian/src/components/DiscourseContextView.tsx index 7adc4d1ec..0ed2c6326 100644 --- a/apps/obsidian/src/components/DiscourseContextView.tsx +++ b/apps/obsidian/src/components/DiscourseContextView.tsx @@ -1,4 +1,4 @@ -import { ItemView, TFile, WorkspaceLeaf } from "obsidian"; +import { ItemView, TFile, WorkspaceLeaf, Notice } from "obsidian"; import { createRoot, Root } from "react-dom/client"; import DiscourseGraphPlugin from "~/index"; import { getDiscourseNodeFormatExpression } from "~/utils/getDiscourseNodeFormatExpression"; @@ -6,6 +6,8 @@ import { RelationshipSection } from "~/components/RelationshipSection"; import { VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types"; import { PluginProvider, usePlugin } from "~/components/PluginContext"; import { getNodeTypeById } from "~/utils/typeUtils"; +import { refreshImportedFile } from "~/utils/importNodes"; +import { useState } from "react"; type DiscourseContextProps = { activeFile: TFile | null; @@ -13,6 +15,7 @@ type DiscourseContextProps = { const DiscourseContext = ({ activeFile }: DiscourseContextProps) => { const plugin = usePlugin(); + const [isRefreshing, setIsRefreshing] = useState(false); const extractContentFromTitle = (format: string, title: string): string => { if (!format) return ""; @@ -21,6 +24,30 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => { return match?.[1] ?? title; }; + const handleRefresh = async () => { + if (!activeFile || isRefreshing) return; + + setIsRefreshing(true); + try { + const result = await refreshImportedFile({ plugin, file: activeFile }); + if (result.success) { + new Notice("File refreshed successfully", 3000); + } else { + new Notice( + `Failed to refresh file: ${result.error || "Unknown error"}`, + 5000, + ); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + new Notice(`Refresh failed: ${errorMessage}`, 5000); + console.error("Refresh failed:", error); + } finally { + setIsRefreshing(false); + } + }; + const renderContent = () => { if (!activeFile) { return
No file is open
; @@ -45,6 +72,20 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => { if (!nodeType) { return
Unknown node type: {frontmatter.nodeTypeId}
; } + + const isImported = !!frontmatter.importedFromSpaceUri; + const modifiedAt = + typeof frontmatter.lastModified === "number" + ? frontmatter.lastModified + : activeFile.stat.mtime; + const sourceDates = + isImported && activeFile?.stat + ? { + createdAt: new Date(activeFile.stat.ctime).toLocaleString(), + modifiedAt: new Date(modifiedAt).toLocaleString(), + } + : null; + return ( <>
@@ -56,6 +97,18 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => { /> )} {nodeType.name || "Unnamed Node Type"} + {isImported && ( + + )}
{nodeType.format && ( @@ -64,6 +117,13 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => { {extractContentFromTitle(nodeType.format, activeFile.basename)} )} + + {isImported && sourceDates && ( +
+
Created in source: {sourceDates.createdAt}
+
Last modified in source: {sourceDates.modifiedAt}
+
+ )}
diff --git a/apps/obsidian/src/components/ImportNodesModal.tsx b/apps/obsidian/src/components/ImportNodesModal.tsx new file mode 100644 index 000000000..16f72d78c --- /dev/null +++ b/apps/obsidian/src/components/ImportNodesModal.tsx @@ -0,0 +1,380 @@ +import { App, Modal, Notice } from "obsidian"; +import { createRoot, Root } from "react-dom/client"; +import { StrictMode, useState, useEffect, useCallback } from "react"; +import type DiscourseGraphPlugin from "../index"; +import type { ImportableNode, GroupWithNodes } from "~/types"; +import { + getAvailableGroups, + getPublishedNodesForGroups, + getLocalNodeInstanceIds, + getSpaceNameFromIds, + importSelectedNodes, +} from "~/utils/importNodes"; +import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext"; + +type ImportNodesModalProps = { + plugin: DiscourseGraphPlugin; + onClose: () => void; +}; + +const ImportNodesContent = ({ plugin, onClose }: ImportNodesModalProps) => { + const [step, setStep] = useState<"loading" | "select" | "importing">( + "loading", + ); + const [groupsWithNodes, setGroupsWithNodes] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [importProgress, setImportProgress] = useState({ + current: 0, + total: 0, + }); + + const loadImportableNodes = useCallback(async () => { + setIsLoading(true); + try { + const client = await getLoggedInClient(plugin); + if (!client) { + new Notice("Cannot get Supabase client"); + onClose(); + return; + } + + const context = await getSupabaseContext(plugin); + if (!context) { + new Notice("Cannot get Supabase context"); + onClose(); + return; + } + + const groups = await getAvailableGroups(client); + if (groups.length === 0) { + new Notice("You are not a member of any groups"); + onClose(); + return; + } + + const groupIds = groups.map((g) => g.group_id); + + const publishedNodes = await getPublishedNodesForGroups({ + client, + groupIds, + currentSpaceId: context.spaceId, + }); + + const localNodeInstanceIds = getLocalNodeInstanceIds(plugin); + + // Filter out nodes that already exist locally + const importableNodes = publishedNodes.filter( + (node) => !localNodeInstanceIds.has(node.source_local_id), + ); + + const uniqueSpaceIds = [ + ...new Set(importableNodes.map((n) => n.space_id)), + ]; + const spaceNames = await getSpaceNameFromIds(client, uniqueSpaceIds); + const grouped: Map = new Map(); + + for (const node of importableNodes) { + const groupId = String(node.space_id); + if (!grouped.has(groupId)) { + grouped.set(groupId, { + groupId, + groupName: + spaceNames.get(node.space_id) ?? `Space ${node.space_id}`, + nodes: [], + }); + } + + const group = grouped.get(groupId)!; + group.nodes.push({ + nodeInstanceId: node.source_local_id, + title: node.text, + spaceId: node.space_id, + spaceName: spaceNames.get(node.space_id) ?? `Space ${node.space_id}`, + groupId, + selected: false, + createdAt: node.createdAt, + modifiedAt: node.modifiedAt, + filePath: node.filePath, + }); + } + + setGroupsWithNodes(Array.from(grouped.values())); + setStep("select"); + } catch (error) { + console.error("Error loading importable nodes:", error); + const errorMessage = + error instanceof Error ? error.message : String(error); + new Notice(`Failed to load nodes: ${errorMessage}`, 5000); + onClose(); + } finally { + setIsLoading(false); + } + }, [plugin, onClose]); + + useEffect(() => { + void loadImportableNodes(); + }, [loadImportableNodes]); + + const handleNodeToggle = (groupId: string, nodeIndex: number) => { + setGroupsWithNodes((prev) => + prev.map((group) => { + if (group.groupId !== groupId) return group; + return { + ...group, + nodes: group.nodes.map((node, idx) => + idx === nodeIndex ? { ...node, selected: !node.selected } : node, + ), + }; + }), + ); + }; + + const handleImport = async () => { + const selectedNodes: ImportableNode[] = []; + for (const group of groupsWithNodes) { + for (const node of group.nodes) { + if (node.selected) { + selectedNodes.push(node); + } + } + } + + if (selectedNodes.length === 0) { + new Notice("Please select at least one node to import"); + return; + } + + setStep("importing"); + setImportProgress({ current: 0, total: selectedNodes.length }); + + try { + const result = await importSelectedNodes({ + plugin, + selectedNodes, + onProgress: (current, total) => { + setImportProgress({ current, total }); + }, + }); + + if (result.failed > 0) { + new Notice( + `Import completed with some issues:\n${result.success} files imported successfully\n${result.failed} files failed`, + 5000, + ); + } else { + new Notice(`Successfully imported ${result.success} node(s)`, 3000); + } + + onClose(); + } catch (error) { + console.error("Error importing nodes:", error); + const errorMessage = + error instanceof Error ? error.message : String(error); + new Notice(`Import failed: ${errorMessage}`, 5000); + setStep("select"); + } + }; + + const renderLoadingStep = () => ( +
+

Loading importable nodes...

+
+ Fetching groups and published nodes +
+
+ ); + + const renderSelectStep = () => { + const totalNodes = groupsWithNodes.reduce( + (sum, group) => sum + group.nodes.length, + 0, + ); + const selectedCount = groupsWithNodes.reduce( + (sum, group) => sum + group.nodes.filter((n) => n.selected).length, + 0, + ); + + // Group nodes by space for better organization + const nodesBySpace = new Map< + number, + { + spaceName: string; + nodes: Array<{ + node: ImportableNode; + groupId: string; + nodeIndex: number; + }>; + } + >(); + + for (const group of groupsWithNodes) { + for (const [nodeIndex, node] of group.nodes.entries()) { + if (!nodesBySpace.has(node.spaceId)) { + nodesBySpace.set(node.spaceId, { + spaceName: node.spaceName, + nodes: [], + }); + } + nodesBySpace.get(node.spaceId)!.nodes.push({ + node, + groupId: group.groupId, + nodeIndex, + }); + } + } + + return ( +
+

Select Nodes to Import

+

+ {totalNodes > 0 + ? `${totalNodes} importable node(s) found. Select which nodes to import into your vault.` + : "No importable nodes found."} +

+ +
+ + +
+ +
+ {Array.from(nodesBySpace.entries()).map( + ([spaceId, { spaceName, nodes }]) => { + return ( +
+
+ 📂 + + {spaceName} + + + ({nodes.length} node{nodes.length !== 1 ? "s" : ""}) + +
+ + {nodes.map(({ node, groupId, nodeIndex }) => ( +
+ handleNodeToggle(groupId, nodeIndex)} + className="mr-3 mt-1 flex-shrink-0" + /> +
+
+ {node.title} +
+
+
+ ))} +
+ ); + }, + )} +
+ +
+ + +
+
+ ); + }; + + const renderImportingStep = () => ( +
+

Importing nodes

+
+
+
+
+
+ {importProgress.current} of {importProgress.total} node(s) processed +
+
+
+ ); + + if (isLoading || step === "loading") { + return renderLoadingStep(); + } + + switch (step) { + case "select": + return renderSelectStep(); + case "importing": + return renderImportingStep(); + default: + return null; + } +}; + +export class ImportNodesModal extends Modal { + private plugin: DiscourseGraphPlugin; + private root: Root | null = null; + + constructor(app: App, plugin: DiscourseGraphPlugin) { + super(app); + this.plugin = plugin; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + this.root = createRoot(contentEl); + this.root.render( + + this.close()} /> + , + ); + } + + onClose() { + if (this.root) { + this.root.unmount(); + this.root = null; + } + } +} diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 0b139c2be..36c65e301 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -272,7 +272,16 @@ export default class DiscourseGraphPlugin extends Plugin { const keysToHide: string[] = []; if (!this.settings.showIdsInFrontmatter) { - keysToHide.push("nodeTypeId"); + keysToHide.push( + ...[ + "nodeTypeId", + "importedFromSpaceUri", + "nodeInstanceId", + "publishedToGroups", + "lastModified", + "importedAssets", + ], + ); keysToHide.push(...this.settings.relationTypes.map((rt) => rt.id)); } diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts index cbf3d090d..f8502b4a8 100644 --- a/apps/obsidian/src/services/QueryEngine.ts +++ b/apps/obsidian/src/services/QueryEngine.ts @@ -290,6 +290,53 @@ export class QueryEngine { } } + /** + * Find an existing imported file by nodeInstanceId and importedFromSpaceUri + * Uses DataCore when available; falls back to vault iteration otherwise + * Returns the file if found, null otherwise + */ + findExistingImportedFile = ( + nodeInstanceId: string, + importedFromSpaceUri: string, + ): TFile | null => { + if (this.dc) { + try { + const safeId = nodeInstanceId + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"'); + const safeUri = importedFromSpaceUri + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"'); + const dcQuery = `@page and nodeInstanceId = "${safeId}" and importedFromSpaceUri = "${safeUri}"`; + const results = this.dc.query(dcQuery); + + for (const page of results) { + if (page.$path) { + const file = this.app.vault.getAbstractFileByPath(page.$path); + if (file && file instanceof TFile) { + return file; + } + } + } + } catch (error) { + console.warn("Error querying DataCore for imported file:", error); + } + } + + // Fallback: DataCore absent, query failed, or indexed field mismatch + const allFiles = this.app.vault.getMarkdownFiles(); + for (const f of allFiles) { + const fm = this.app.metadataCache.getFileCache(f)?.frontmatter; + if ( + fm?.nodeInstanceId === nodeInstanceId && + fm.importedFromSpaceUri === importedFromSpaceUri + ) { + return f; + } + } + return null; + }; + private async fallbackScanVault( patterns: BulkImportPattern[], validNodeTypes: DiscourseNode[], diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index b9f849e08..7c4be2588 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -61,4 +61,23 @@ export type BulkImportPattern = { enabled: boolean; }; +export type ImportableNode = { + nodeInstanceId: string; + title: string; + spaceId: number; + spaceName: string; + groupId: string; + selected: boolean; + /** From source Content (latest last_modified across variants). Set when loaded from getPublishedNodesForGroups. */ + createdAt?: number; + modifiedAt?: number; + filePath?: string; +}; + +export type GroupWithNodes = { + groupId: string; + groupName?: string; + nodes: ImportableNode[]; +}; + export const VIEW_TYPE_DISCOURSE_CONTEXT = "discourse-context-view"; diff --git a/apps/obsidian/src/utils/conceptConversion.ts b/apps/obsidian/src/utils/conceptConversion.ts index 74e8853b6..1c7c55cc4 100644 --- a/apps/obsidian/src/utils/conceptConversion.ts +++ b/apps/obsidian/src/utils/conceptConversion.ts @@ -5,6 +5,7 @@ import type { SupabaseContext } from "./supabaseContext"; import type { LocalConceptDataInput } from "@repo/database/inputTypes"; import type { ObsidianDiscourseNodeData } from "./syncDgNodesToSupabase"; import type { Json } from "@repo/database/dbTypes"; +import DiscourseGraphPlugin from ".."; /** * Get extra data (author, timestamps) from file metadata @@ -37,7 +38,7 @@ export const discourseNodeSchemaToLocalConcept = ({ node; return { space_id: context.spaceId, - name: name, + name, source_local_id: id, is_schema: true, author_local_id: accountLocalId, @@ -92,7 +93,11 @@ export const relatedConcepts = (concept: LocalConceptDataInput): string[] => { }; /** - * Recursively order concepts by dependency + * Recursively order concepts by dependency so that dependents (e.g. instances) + * come after their dependencies (e.g. schemas). When we look up a related + * concept by id in `remainder`, we use the same id that appears in + * schema_represented_by_local_id or local_reference_content — so that id + * must equal some concept's source_local_id or it is reported as "missing". */ const orderConceptsRec = ( ordered: LocalConceptDataInput[], @@ -119,6 +124,13 @@ const orderConceptsRec = ( return missing; }; +/** + * Order concepts so dependencies (schemas) are before dependents (instances). + * Assumes every concept has source_local_id; concepts without it are excluded + * from the map (same as Roam). A node type is "missing" when an instance + * references schema_represented_by_local_id = X but no concept in the input + * has source_local_id === X (e.g. schema not included, or id vs nodeTypeId mismatch). + */ export const orderConceptsByDependency = ( concepts: LocalConceptDataInput[], ): { ordered: LocalConceptDataInput[]; missing: string[] } => { @@ -126,7 +138,7 @@ export const orderConceptsByDependency = ( const conceptById: { [key: string]: LocalConceptDataInput } = Object.fromEntries( concepts - .filter((c) => c.source_local_id) + .filter((c) => c.source_local_id != null && c.source_local_id !== "") .map((c) => [c.source_local_id!, c]), ); const ordered: LocalConceptDataInput[] = []; diff --git a/apps/obsidian/src/utils/fileChangeListener.ts b/apps/obsidian/src/utils/fileChangeListener.ts index f01de6270..c81dd923f 100644 --- a/apps/obsidian/src/utils/fileChangeListener.ts +++ b/apps/obsidian/src/utils/fileChangeListener.ts @@ -80,7 +80,7 @@ export class FileChangeListener { /** * Check if a file is a DG node (has nodeTypeId in frontmatter that matches a node type in settings) */ - private isDiscourseNode(file: TAbstractFile): boolean { + private shouldSyncFile(file: TAbstractFile): boolean { if (!(file instanceof TFile)) { return false; } @@ -91,13 +91,17 @@ export class FileChangeListener { } const cache = this.plugin.app.metadataCache.getFileCache(file); - const nodeTypeId = cache?.frontmatter?.nodeTypeId as string | undefined; + const frontmatter = cache?.frontmatter; + const nodeTypeId = frontmatter?.nodeTypeId as string | undefined; if (!nodeTypeId || typeof nodeTypeId !== "string") { return false; } - // Verify that the nodeTypeId matches one of the node types in settings + if (frontmatter?.importedFromSpaceUri) { + return false; + } + return !!getNodeTypeById(this.plugin, nodeTypeId); } @@ -115,7 +119,7 @@ export class FileChangeListener { this.pendingCreates.add(file.path); - if (this.isDiscourseNode(file)) { + if (this.shouldSyncFile(file)) { this.queueChange(file.path, "title"); this.queueChange(file.path, "content"); this.pendingCreates.delete(file.path); @@ -126,7 +130,7 @@ export class FileChangeListener { * Handle file modification event */ private handleFileModify(file: TAbstractFile): void { - if (!this.isDiscourseNode(file)) { + if (!this.shouldSyncFile(file)) { return; } @@ -151,6 +155,10 @@ export class FileChangeListener { * Handle file rename event */ private handleFileRename(file: TAbstractFile, oldPath: string): void { + if (!this.shouldSyncFile(file)) { + return; + } + console.log(`File renamed: ${oldPath} -> ${file.path}`); this.queueChange(file.path, "title", oldPath); } @@ -159,7 +167,7 @@ export class FileChangeListener { * Handle metadata changes (placeholder for relation metadata) */ private handleMetadataChange(file: TFile): void { - if (!this.isDiscourseNode(file)) { + if (!this.shouldSyncFile(file)) { return; } diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts new file mode 100644 index 000000000..7eb8090c0 --- /dev/null +++ b/apps/obsidian/src/utils/importNodes.ts @@ -0,0 +1,1430 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { Json } from "@repo/database/dbTypes"; +import matter from "gray-matter"; +import { App, TFile } from "obsidian"; +import type { DGSupabaseClient } from "@repo/database/lib/client"; +import type DiscourseGraphPlugin from "~/index"; +import { getLoggedInClient, getSupabaseContext } from "./supabaseContext"; +import type { DiscourseNode, ImportableNode } from "~/types"; +import { QueryEngine } from "~/services/QueryEngine"; + +export const getAvailableGroups = async ( + client: DGSupabaseClient, +): Promise<{ group_id: string }[]> => { + const { data, error } = await client + .from("group_membership") + .select("group_id") + .eq("member_id", (await client.auth.getUser()).data.user?.id || ""); + + if (error) { + console.error("Error fetching groups:", error); + throw new Error(`Failed to fetch groups: ${error.message}`); + } + + return data || []; +}; + +export const getPublishedNodesForGroups = async ({ + client, + groupIds, + currentSpaceId, +}: { + client: DGSupabaseClient; + groupIds: string[]; + currentSpaceId: number; +}): Promise< + Array<{ + source_local_id: string; + space_id: number; + text: string; + createdAt: number; + modifiedAt: number; + filePath: string | undefined; + }> +> => { + if (groupIds.length === 0) { + return []; + } + + // Query my_contents (RLS applied); exclude current space. Get both variants so we can use + // the latest last_modified per node and prefer "direct" for text (title). + const { data, error } = await client + .from("my_contents") + .select( + "source_local_id, space_id, text, created, last_modified, variant, metadata", + ) + .neq("space_id", currentSpaceId); + + if (error) { + console.error("Error fetching published nodes:", error); + throw new Error(`Failed to fetch published nodes: ${error.message}`); + } + + if (!data || data.length === 0) { + return []; + } + + type Row = { + source_local_id: string | null; + space_id: number | null; + text: string | null; + created: string | null; + last_modified: string | null; + variant: string | null; + metadata: Json; + }; + + const key = (r: Row) => `${r.space_id ?? ""}\t${r.source_local_id ?? ""}`; + const groups = new Map(); + for (const row of data as Row[]) { + if (row.source_local_id == null || row.space_id == null) continue; + const k = key(row); + if (!groups.has(k)) groups.set(k, []); + groups.get(k)!.push(row); + } + + const nodes: Array<{ + source_local_id: string; + space_id: number; + text: string; + createdAt: number; + modifiedAt: number; + filePath: string | undefined; + }> = []; + + for (const rows of groups.values()) { + const withDate = rows.filter( + (r) => r.last_modified != null && r.text != null, + ); + if (withDate.length === 0) continue; + const latest = withDate.reduce((a, b) => + (a.last_modified ?? "") >= (b.last_modified ?? "") ? a : b, + ); + const direct = rows.find((r) => r.variant === "direct"); + const text = direct?.text ?? latest.text ?? ""; + const createdAt = latest.created + ? new Date(latest.created + "Z").valueOf() + : 0; + const modifiedAt = latest.last_modified + ? new Date(latest.last_modified + "Z").valueOf() + : 0; + const filePath: string | undefined = + direct && + typeof direct.metadata === "object" && + typeof (direct.metadata as Record).filePath === "string" + ? (direct.metadata as Record).filePath + : undefined; + nodes.push({ + source_local_id: latest.source_local_id!, + space_id: latest.space_id!, + text, + createdAt, + modifiedAt, + filePath, + }); + } + + return nodes; +}; + +export const getLocalNodeInstanceIds = ( + plugin: DiscourseGraphPlugin, +): Set => { + const allFiles = plugin.app.vault.getMarkdownFiles(); + const nodeInstanceIds = new Set(); + + for (const file of allFiles) { + const cache = plugin.app.metadataCache.getFileCache(file); + const frontmatter = cache?.frontmatter; + + if (frontmatter?.nodeInstanceId) { + nodeInstanceIds.add(frontmatter.nodeInstanceId as string); + } + } + + return nodeInstanceIds; +}; + +export const getSpaceNameFromId = async ( + client: DGSupabaseClient, + spaceId: number, +): Promise => { + const { data, error } = await client + .from("Space") + .select("name") + .eq("id", spaceId) + .maybeSingle(); + + if (error || !data) { + console.error("Error fetching space name:", error); + return `space-${spaceId}`; + } + + return data.name; +}; + +export const getSpaceNameIdFromUri = async ( + client: DGSupabaseClient, + spaceUri: string, +): Promise<{ spaceName: string; spaceId: number }> => { + const { data, error } = await client + .from("Space") + .select("name, id") + .eq("url", spaceUri) + .maybeSingle(); + + if (error || !data) { + console.error("Error fetching space name:", error); + return { spaceName: "", spaceId: -1 }; + } + + return { spaceName: data.name, spaceId: data.id }; +}; + +export const getSpaceNameFromIds = async ( + client: DGSupabaseClient, + spaceIds: number[], +): Promise> => { + if (spaceIds.length === 0) { + return new Map(); + } + + const { data, error } = await client + .from("Space") + .select("id, name") + .in("id", spaceIds); + + if (error) { + console.error("Error fetching space names:", error); + return new Map(); + } + + const spaceMap = new Map(); + (data || []).forEach((space) => { + spaceMap.set(space.id, space.name); + }); + + return spaceMap; +}; + +export const getSpaceUris = async ( + client: DGSupabaseClient, + spaceIds: number[], +): Promise> => { + if (spaceIds.length === 0) { + return new Map(); + } + + const { data, error } = await client + .from("Space") + .select("id, url") + .in("id", spaceIds); + + if (error) { + console.error("Error fetching space urls:", error); + return new Map(); + } + + const spaceMap = new Map(); + (data || []).forEach((space) => { + spaceMap.set(space.id, space.url); + }); + + return spaceMap; +}; + +export const fetchNodeContent = async ({ + client, + spaceId, + nodeInstanceId, + variant, +}: { + client: DGSupabaseClient; + spaceId: number; + nodeInstanceId: string; + variant: "direct" | "full"; +}): Promise => { + const { data, error } = await client + .from("my_contents") + .select("text") + .eq("source_local_id", nodeInstanceId) + .eq("space_id", spaceId) + .eq("variant", variant) + .maybeSingle(); + + if (error || !data || data.text == null) { + console.error( + `Error fetching node content (${variant}):`, + error || "No data", + ); + return null; + } + + return data.text; +}; + +export const fetchNodeContentWithMetadata = async ({ + client, + spaceId, + nodeInstanceId, + variant, +}: { + client: DGSupabaseClient; + spaceId: number; + nodeInstanceId: string; + variant: "direct" | "full"; +}): Promise<{ + content: string; + createdAt: number; + modifiedAt: number; +} | null> => { + const { data, error } = await client + .from("my_contents") + .select("text, created, last_modified") + .eq("source_local_id", nodeInstanceId) + .eq("space_id", spaceId) + .eq("variant", variant) + .maybeSingle(); + + if (error || !data || data.text == null) { + console.error( + `Error fetching node content with metadata (${variant}):`, + error || "No data", + ); + return null; + } + + return { + content: data.text, + createdAt: data.created + ? new Date(data.created + "Z").valueOf() + : 0, + modifiedAt: data.last_modified + ? new Date(data.last_modified + "Z").valueOf() + : 0, + }; +}; + +/** + * Fetches both direct (title) and full (body + dates) variants in one query. + * Used by importSelectedNodes to avoid two round-trips to the content table. + */ +const fetchNodeContentForImport = async ({ + client, + spaceId, + nodeInstanceId, +}: { + client: DGSupabaseClient; + spaceId: number; + nodeInstanceId: string; +}): Promise<{ + fileName: string; + content: string; + createdAt: number; + modifiedAt: number; + filePath?: string; +} | null> => { + const { data, error } = await client + .from("my_contents") + .select("text, created, last_modified, variant, metadata") + .eq("source_local_id", nodeInstanceId) + .eq("space_id", spaceId) + .in("variant", ["direct", "full"]); + + if (error) { + console.error("Error fetching node content for import:", error); + return null; + } + + const rows = (data ?? []) as Array<{ + text: string | null; + created: string | null; + last_modified: string | null; + variant: string | null; + metadata: Json; + }>; + const direct = rows.find((r) => r.variant === "direct"); + const full = rows.find((r) => r.variant === "full"); + + if ( + !direct?.text || + !full?.text || + full.created == null || + full.last_modified == null + ) { + if (!direct?.text) { + console.warn(`No direct variant found for node ${nodeInstanceId}`); + } + if (!full?.text) { + console.warn(`No full variant found for node ${nodeInstanceId}`); + } + return null; + } + + const filePath: string | undefined = + typeof direct.metadata === "object" && + typeof (direct.metadata as Record).filePath === "string" + ? (direct.metadata as Record).filePath + : undefined; + return { + fileName: direct.text, + content: full.text, + createdAt: new Date(full.created + "Z").valueOf(), + modifiedAt: new Date(full.last_modified + "Z").valueOf(), + filePath, + }; +}; + +/** + * Fetches created/last_modified from the source space Content (my_contents) for an imported node. + * Used by the discourse context view to show "last modified in original vault". + */ +export const getSourceContentDates = async ({ + plugin, + nodeInstanceId, + spaceUri, +}: { + plugin: DiscourseGraphPlugin; + nodeInstanceId: string; + spaceUri: string; +}): Promise<{ createdAt: string; modifiedAt: string } | null> => { + const client = await getLoggedInClient(plugin); + if (!client) return null; + const { spaceId } = await getSpaceNameIdFromUri(client, spaceUri); + if (spaceId < 0) return null; + const { data, error } = await client + .from("my_contents") + .select("created, last_modified") + .eq("source_local_id", nodeInstanceId) + .eq("space_id", spaceId) + .eq("variant", "direct") + .maybeSingle(); + if (error || !data) return null; + return { + createdAt: data.created ?? new Date(0).toISOString(), + modifiedAt: data.last_modified ?? new Date(0).toISOString(), + }; +}; + +const fetchFileReferences = async ({ + client, + spaceId, + nodeInstanceId, +}: { + client: DGSupabaseClient; + spaceId: number; + nodeInstanceId: string; +}): Promise< + Array<{ + filepath: string; + filehash: string; + created: number; + last_modified: number; + }> +> => { + const { data, error } = await client + .from("FileReference") + .select("filepath, filehash, created, last_modified") + .eq("space_id", spaceId) + .eq("source_local_id", nodeInstanceId); + + if (error) { + console.error("Error fetching file references:", error); + return []; + } + + return data.map(({ filepath, filehash, created, last_modified }) => ({ + filepath, + filehash, + created: created ? new Date(created + "Z").valueOf() : 0, + last_modified: last_modified ? new Date(last_modified + "Z").valueOf() : 0, + })); +}; + +const downloadFileFromStorage = async ({ + client, + filehash, +}: { + client: DGSupabaseClient; + filehash: string; +}): Promise => { + try { + const { data, error } = await client.storage + .from("assets") + .download(filehash); + + if (error) { + console.warn(`Error downloading file ${filehash}:`, error); + return null; + } + + if (!data) { + console.warn(`No data returned for file ${filehash}`); + return null; + } + + return await data.arrayBuffer(); + } catch (error) { + console.error(`Exception downloading file ${filehash}:`, error); + return null; + } +}; + +/** Normalize path for lookup: strip leading "./", collapse slashes. Shared so pathMapping keys match link paths. */ +const normalizePathForLookup = (p: string): string => + p.replace(/^\.\//, "").replace(/\/+/g, "/").trim(); + +const updateMarkdownAssetLinks = ({ + content, + oldPathToNewPath, + targetFile, + app, + originalNodePath, +}: { + content: string; + oldPathToNewPath: Map; + targetFile: TFile; + app: App; + originalNodePath?: string; +}): string => { + if (oldPathToNewPath.size === 0) { + return content; + } + + // Create a set of all new paths for quick lookup (used by findImportedAssetFile) + const newPaths = new Set(oldPathToNewPath.values()); + + let updatedContent = content; + + const noteDir = targetFile.path.includes("/") + ? targetFile.path.replace(/\/[^/]*$/, "") + : ""; + + /** Path of targetFile relative to the current note, for use in links. Obsidian resolves relative links from the note's directory. */ + const getRelativeLinkPath = (assetPath: string): string => { + const noteParts = noteDir ? noteDir.split("/").filter(Boolean) : []; + const targetParts = assetPath.split("/").filter(Boolean); + let i = 0; + while ( + i < noteParts.length && + i < targetParts.length && + noteParts[i] === targetParts[i] + ) { + i++; + } + const ups = noteParts.length - i; + const down = targetParts.slice(i); + const segments = [...Array(ups).fill(".."), ...down]; + return segments.join("/"); + }; + + // Resolve a path with ".." and "." segments relative to a base directory (vault-relative). + const resolvePathRelativeToBase = ( + baseDir: string, + relativePath: string, + ): string => { + const baseParts = baseDir ? baseDir.split("/").filter(Boolean) : []; + const pathParts = relativePath.replace(/\/+/g, "/").trim().split("/"); + const result = [...baseParts]; + for (const part of pathParts) { + if (part === "..") { + result.pop(); + } else if (part !== "." && part !== "") { + result.push(part); + } + } + return result.join("/"); + }; + + // Canonical form for matching link paths to oldPath (vault-relative, no import prefix). + const getLinkCanonicalForMatch = (linkPath: string): string => { + const resolved = resolvePathRelativeToBase(noteDir, linkPath); + if (resolved.startsWith("import/")) { + const segments = resolved.split("/"); + return segments.length > 2 ? segments.slice(2).join("/") : resolved; + } + return resolved; + }; + + // Resolve link relative to the source note's directory (for "path from current file" when imported note is flattened). + const getCanonicalFromOriginalNote = ( + linkPath: string, + ): string | undefined => { + if (!originalNodePath) return undefined; + const originalNoteDir = originalNodePath.includes("/") + ? originalNodePath.replace(/\/[^/]*$/, "") + : ""; + return normalizePathForLookup( + resolvePathRelativeToBase(originalNoteDir, linkPath), + ); + }; + + // Look up new path by link as written in content: use canonical form (resolve relative + strip import prefix). + const getNewPathForLink = (linkPath: string): string | undefined => { + const canonical = normalizePathForLookup( + getLinkCanonicalForMatch(linkPath), + ); + const byCanonical = oldPathToNewPath.get(canonical); + if (byCanonical) return byCanonical; + const byRaw = oldPathToNewPath.get(normalizePathForLookup(linkPath)); + if (byRaw) return byRaw; + // "Path from current file" in source: link was relative to source note; pathMapping keys are source vault-relative. + const fromOriginal = getCanonicalFromOriginalNote(linkPath); + return fromOriginal ? oldPathToNewPath.get(fromOriginal) : undefined; + }; + + // Helper to find file for a link path, checking if it's one of our imported assets + const findImportedAssetFile = (linkPath: string): TFile | null => { + // Try to resolve the link + const resolvedFile = app.metadataCache.getFirstLinkpathDest( + linkPath, + targetFile.path, + ); + + if (resolvedFile && newPaths.has(resolvedFile.path)) { + // This file is one of our imported assets + return resolvedFile; + } + + // Also check if the resolved file is in an assets folder (user may have renamed it) + if (resolvedFile && resolvedFile.path.includes("/assets/")) { + // Check if any of our new files match this one (by checking if path is similar) + for (const newPath of newPaths) { + const newFile = app.metadataCache.getFirstLinkpathDest( + newPath, + targetFile.path, + ); + if (newFile && newFile.path === resolvedFile.path) { + return resolvedFile; + } + } + } + + return null; + }; + + // Match wiki links: [[path]] or [[path|alias]] + const wikiLinkRegex = /\[\[([^\]]+)\]\]/g; + updatedContent = updatedContent.replace( + wikiLinkRegex, + (match, linkContent) => { + // Extract path and optional alias + const [linkPath, alias] = linkContent + .split("|") + .map((s: string) => s.trim()); + + // Skip external URLs + if (linkPath.startsWith("http://") || linkPath.startsWith("https://")) { + return match; + } + + // First, try to find if this link resolves to one of our imported assets + const importedAssetFile = findImportedAssetFile(linkPath); + if (importedAssetFile) { + const linkText = getRelativeLinkPath(importedAssetFile.path); + if (alias) { + return `[[${linkText}|${alias}]]`; + } + return `[[${linkText}]]`; + } + + // Direct lookup from pathMapping (record built when we downloaded each asset) + const newPath = getNewPathForLink(linkPath); + if (newPath) { + const newFile = app.metadataCache.getFirstLinkpathDest( + newPath, + targetFile.path, + ); + if (newFile) { + const linkText = getRelativeLinkPath(newFile.path); + if (alias) { + return `[[${linkText}|${alias}]]`; + } + return `[[${linkText}]]`; + } + } + + return match; + }, + ); + + // Match markdown image links: ![alt](path) or ![alt](path "title") + const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; + updatedContent = updatedContent.replace( + markdownImageRegex, + (match, alt, linkPath) => { + // Remove optional title from linkPath: "path" or "path title" + const cleanPath = linkPath.replace(/\s+"[^"]*"$/, "").trim(); + + // Skip external URLs + if (cleanPath.startsWith("http://") || cleanPath.startsWith("https://")) { + return match; + } + + // First, try to find if this link resolves to one of our imported assets + const importedAssetFile = findImportedAssetFile(cleanPath); + if (importedAssetFile) { + const linkText = getRelativeLinkPath(importedAssetFile.path); + return `![${alt}](${linkText})`; + } + + // Direct lookup from pathMapping (record built when we downloaded each asset) + const newPath = getNewPathForLink(cleanPath); + if (newPath) { + const newFile = app.metadataCache.getFirstLinkpathDest( + newPath, + targetFile.path, + ); + if (newFile) { + const linkText = getRelativeLinkPath(newFile.path); + return `![${alt}](${linkText})`; + } + } + + return match; + }, + ); + + return updatedContent; +}; + +/** Path of an asset relative to the note's directory (vault-relative). If asset is not under note dir, returns full path. */ +const getAssetPathRelativeToNote = ( + assetFilePath: string, + originalNodePath: string, +): string => { + const noteDir = originalNodePath.includes("/") + ? originalNodePath.replace(/\/[^/]*$/, "") + : ""; + if (!noteDir || !assetFilePath.startsWith(`${noteDir}/`)) { + return assetFilePath; + } + return assetFilePath.slice(noteDir.length + 1); +}; + +const importAssetsForNode = async ({ + plugin, + client, + spaceId, + nodeInstanceId, + spaceName, + targetMarkdownFile, + originalNodePath, +}: { + plugin: DiscourseGraphPlugin; + client: DGSupabaseClient; + spaceId: number; + nodeInstanceId: string; + spaceName: string; + targetMarkdownFile: TFile; + /** Source vault path of the note (e.g. from Content metadata filePath). Used to place assets under import/{space}/ relative to note. */ + originalNodePath?: string; +}): Promise<{ + success: boolean; + pathMapping: Map; // old path -> new path + errors: string[]; +}> => { + const pathMapping = new Map(); + const errors: string[] = []; + const stat = { + ctime: targetMarkdownFile.stat.ctime, + mtime: targetMarkdownFile.stat.mtime, + }; + + const setPathMapping = (oldPath: string, newPath: string): void => { + pathMapping.set(oldPath, newPath); + pathMapping.set(normalizePathForLookup(oldPath), newPath); + }; + + // Fetch FileReference records for the node + const fileReferences = await fetchFileReferences({ + client, + spaceId, + nodeInstanceId, + }); + + if (fileReferences.length === 0) { + return { success: true, pathMapping, errors }; + } + + const importBasePath = `import/${sanitizeFileName(spaceName)}`; + + // Get existing asset mappings from frontmatter + const cache = plugin.app.metadataCache.getFileCache(targetMarkdownFile); + const frontmatter = (cache?.frontmatter as Record) || {}; + const importedAssetsRaw = frontmatter.importedAssets; + const importedAssets: Record = + importedAssetsRaw && + typeof importedAssetsRaw === "object" && + !Array.isArray(importedAssetsRaw) + ? (importedAssetsRaw as Record) + : {}; + // importedAssets format: { filehash: vaultPath } + + // Process each file reference + for (const fileRef of fileReferences) { + try { + const { filepath, filehash } = fileRef; + + // Check if we already have a file for this hash + const existingAssetPath: string | undefined = importedAssets[filehash]; + let existingFile: TFile | null = null; + + if (existingAssetPath) { + // Check if the file still exists at the stored path + const file = plugin.app.vault.getAbstractFileByPath(existingAssetPath); + if (file && file instanceof TFile) { + existingFile = file; + } + } + + let overwritePath: string | undefined; + if (existingFile) { + const refLastModifiedMs = fileRef.last_modified || 0; + const localModifiedAfterRef = + refLastModifiedMs > 0 && existingFile.stat.mtime > refLastModifiedMs; + if (!localModifiedAfterRef) { + setPathMapping(filepath, existingFile.path); + continue; + } + overwritePath = existingFile.path; + } + + // Target path: import/{spaceName}/{path relative to note}. If sourceNotePath is set and asset + // is under the note's directory, use that relative path so assets sit under import/{space}/. + const pathForImport = + originalNodePath !== undefined + ? getAssetPathRelativeToNote(filepath, originalNodePath) + : filepath; + const sanitizedAssetPath = pathForImport + .split("/") + .map(sanitizeFileName) + .join("/"); + const targetPath = + overwritePath ?? `${importBasePath}/${sanitizedAssetPath}`; + + // Ensure all parent folders exist before writing + const pathParts = targetPath.split("/"); + for (let i = 1; i < pathParts.length - 1; i++) { + const folderPath = pathParts.slice(0, i + 1).join("/"); + if (!(await plugin.app.vault.adapter.exists(folderPath))) { + await plugin.app.vault.createFolder(folderPath); + } + } + + // If local mtime is newer than fileRef.last_modified, overwrite with DB version. + if (await plugin.app.vault.adapter.exists(targetPath)) { + const file = plugin.app.vault.getAbstractFileByPath(targetPath); + if (file && file instanceof TFile) { + const localMtimeMs = file.stat.mtime; + const refLastModifiedMs = fileRef.last_modified || 0; + const localModifiedAfterRef = + refLastModifiedMs > 0 && localMtimeMs > refLastModifiedMs; + const remoteIsNewer = + refLastModifiedMs > 0 && refLastModifiedMs > localMtimeMs; + if (!localModifiedAfterRef && !remoteIsNewer) { + setPathMapping(filepath, targetPath); + await plugin.app.fileManager.processFrontMatter( + targetMarkdownFile, + (fm) => { + const assetsRaw = (fm as Record) + .importedAssets; + const assets: Record = + assetsRaw && + typeof assetsRaw === "object" && + !Array.isArray(assetsRaw) + ? (assetsRaw as Record) + : {}; + assets[filehash] = targetPath; + (fm as Record).importedAssets = assets; + }, + stat, + ); + continue; + } + // Local file was modified OR remote is newer; overwrite with DB version + } + // Local file was modified since fileRef's last_modified; overwrite with DB version + } + } + + // File doesn't exist, download it + const fileContent = await downloadFileFromStorage({ + client, + filehash, + }); + + if (!fileContent) { + errors.push(`Failed to download file: ${filepath}`); + console.warn(`Failed to download file ${filepath} (hash: ${filehash})`); + continue; + } + + const options = { mtime: fileRef.last_modified, ctime: fileRef.created }; + // Save file to vault + const existingFileForOverwrite = + plugin.app.vault.getAbstractFileByPath(targetPath); + if ( + existingFileForOverwrite && + existingFileForOverwrite instanceof TFile + ) { + await plugin.app.vault.modifyBinary( + existingFileForOverwrite, + fileContent, + options, + ); + } else { + await plugin.app.vault.createBinary(targetPath, fileContent, options); + } + + // Update frontmatter to track this mapping + await plugin.app.fileManager.processFrontMatter( + targetMarkdownFile, + (fm) => { + const assetsRaw = (fm as Record).importedAssets; + const assets: Record = + assetsRaw && + typeof assetsRaw === "object" && + !Array.isArray(assetsRaw) + ? (assetsRaw as Record) + : {}; + assets[filehash] = targetPath; + (fm as Record).importedAssets = assets; + }, + stat, + ); + + // Track path mapping (raw + normalized key so updateMarkdownAssetLinks can lookup by link text) + setPathMapping(filepath, targetPath); + console.log(`Imported asset: ${filepath} -> ${targetPath}`); + } catch (error) { + const errorMsg = `Error importing asset ${fileRef.filepath}: ${error}`; + errors.push(errorMsg); + console.error(errorMsg, error); + } + } + + return { + success: errors.length === 0 || pathMapping.size > 0, + pathMapping, + errors, + }; +}; + +const sanitizeFileName = (fileName: string): string => { + // Remove invalid characters for file names + return fileName + .replace(/[<>:"/\\|?*]/g, "") + .replace(/\s+/g, " ") + .trim(); +}; + +type ParsedFrontmatter = { + nodeTypeId?: string; + nodeInstanceId?: string; + publishedToGroups?: string[]; + [key: string]: unknown; +}; + +const parseFrontmatter = ( + content: string, +): { frontmatter: ParsedFrontmatter; body: string } => { + const { data, content: body } = matter(content); + return { + frontmatter: (data ?? {}) as ParsedFrontmatter, + body: body ?? "", + }; +}; + +/** + * Parse literal_content from a Concept schema into fields for DiscourseNode. + * Handles both nested form { label, template, source_data: { format, color, tag } } + * and flat form { id, name, color, format, tag }. + */ +const parseSchemaLiteralContent = ( + literalContent: unknown, + fallbackName: string, +): Pick< + DiscourseNode, + "name" | "format" | "color" | "tag" | "template" | "keyImage" +> => { + const obj = + typeof literalContent === "string" + ? (JSON.parse(literalContent) as Record) + : (literalContent as Record) || {}; + const src = (obj.source_data as Record) || obj; + const name = (obj.name as string) || (obj.label as string) || fallbackName; + const formatFromSchema = + (src.format as string) || (obj.format as string) || ""; + const format = + formatFromSchema || `${name.slice(0, 3).toUpperCase()} - {content}`; + return { + name, + format, + color: (src.color as string) || (obj.color as string) || undefined, + tag: (src.tag as string) || (obj.tag as string) || undefined, + template: (obj.template as string) || undefined, + keyImage: + (src.keyImage as boolean) ?? (obj.keyImage as boolean) ?? undefined, + }; +}; + +const mapNodeTypeIdToLocal = async ({ + plugin, + client, + sourceSpaceId, + sourceNodeTypeId, +}: { + plugin: DiscourseGraphPlugin; + client: DGSupabaseClient; + sourceSpaceId: number; + sourceNodeTypeId: string; +}): Promise => { + // Find the schema in the source space with this nodeTypeId (my_concepts applies RLS) + const { data: schemaData } = await client + .from("my_concepts") + .select("name, literal_content") + .eq("space_id", sourceSpaceId) + .eq("is_schema", true) + .eq("source_local_id", sourceNodeTypeId) + .maybeSingle(); + + if (!schemaData?.name) { + return sourceNodeTypeId; + } + + const schemaName = schemaData.name; + + // Prefer match by node type ID (imported type may already exist locally with same id) + const matchById = plugin.settings.nodeTypes.find( + (nt) => nt.id === sourceNodeTypeId, + ); + if (matchById) { + return matchById.id; + } + + // Fall back to match by name + const matchingLocalNodeType = plugin.settings.nodeTypes.find( + (nt) => nt.name === schemaName, + ); + if (matchingLocalNodeType) { + return matchingLocalNodeType.id; + } + + // No matching local nodeType: create one from literal_content and add to settings + const parsed = parseSchemaLiteralContent( + schemaData.literal_content, + schemaName, + ); + + const now = new Date().getTime(); + + const newNodeType: DiscourseNode = { + id: sourceNodeTypeId, + name: parsed.name, + format: parsed.format, + color: parsed.color, + tag: parsed.tag, + template: parsed.template, + keyImage: parsed.keyImage, + created: now, + modified: now, + }; + plugin.settings.nodeTypes = [...plugin.settings.nodeTypes, newNodeType]; + await plugin.saveSettings(); + return newNodeType.id; +}; + +const processFileContent = async ({ + plugin, + client, + sourceSpaceId, + sourceSpaceUri, + rawContent, + originalFilePath, + filePath, + importedCreatedAt, + importedModifiedAt, +}: { + plugin: DiscourseGraphPlugin; + client: DGSupabaseClient; + sourceSpaceId: number; + sourceSpaceUri: string; + rawContent: string; + originalFilePath?: string; + filePath: string; + importedCreatedAt?: number; + importedModifiedAt?: number; +}): Promise<{ file: TFile; error?: string }> => { + // 1. Create or update the file with the fetched content first. + // On create, set file metadata (ctime/mtime) to original vault dates via vault adapter. + let file: TFile | null = plugin.app.vault.getFileByPath(filePath); + const stat = + importedCreatedAt !== undefined && importedModifiedAt !== undefined + ? { + ctime: importedCreatedAt, + mtime: importedModifiedAt, + } + : undefined; + if (!file) { + file = await plugin.app.vault.create(filePath, rawContent, stat); + } else { + await plugin.app.vault.modify(file, rawContent, stat); + } + + // 2. Parse frontmatter from rawContent (metadataCache is updated async and is + // often empty immediately after create/modify), then map nodeTypeId and update frontmatter. + const { frontmatter } = parseFrontmatter(rawContent); + const sourceNodeTypeId = frontmatter.nodeTypeId; + + let mappedNodeTypeId: string | undefined; + if (sourceNodeTypeId && typeof sourceNodeTypeId === "string") { + mappedNodeTypeId = await mapNodeTypeIdToLocal({ + plugin, + client, + sourceSpaceId, + sourceNodeTypeId, + }); + } + + await plugin.app.fileManager.processFrontMatter( + file, + (fm) => { + const record = fm as Record; + if (mappedNodeTypeId !== undefined) { + record.nodeTypeId = mappedNodeTypeId; + } + record.importedFromSpaceUri = sourceSpaceUri; + record.lastModified = importedModifiedAt; + }, + stat, + ); + + return { file }; +}; + +export const importSelectedNodes = async ({ + plugin, + selectedNodes, + onProgress, +}: { + plugin: DiscourseGraphPlugin; + selectedNodes: ImportableNode[]; + onProgress?: (current: number, total: number) => void; +}): Promise<{ success: number; failed: number }> => { + const client = await getLoggedInClient(plugin); + if (!client) { + throw new Error("Cannot get Supabase client"); + } + + const context = await getSupabaseContext(plugin); + if (!context) { + throw new Error("Cannot get Supabase context"); + } + + const queryEngine = new QueryEngine(plugin.app); + + let successCount = 0; + let failedCount = 0; + let processedCount = 0; + const totalNodes = selectedNodes.length; + + // Group nodes by space to create folders efficiently + const nodesBySpace = new Map(); + for (const node of selectedNodes) { + if (!nodesBySpace.has(node.spaceId)) { + nodesBySpace.set(node.spaceId, []); + } + nodesBySpace.get(node.spaceId)!.push(node); + } + + const spaceUris = await getSpaceUris(client, [...nodesBySpace.keys()]); + + // Process each space + for (const [spaceId, nodes] of nodesBySpace.entries()) { + const spaceName = await getSpaceNameFromId(client, spaceId); + const importFolderPath = `import/${sanitizeFileName(spaceName)}`; + const spaceUri = spaceUris.get(spaceId); + if (!spaceUri) { + console.warn(`Missing URI for space ${spaceId}`); + for (const _node of nodes) { + failedCount++; + processedCount++; + onProgress?.(processedCount, totalNodes); + } + continue; + } + + // Ensure the import folder exists + const folderExists = + await plugin.app.vault.adapter.exists(importFolderPath); + if (!folderExists) { + await plugin.app.vault.createFolder(importFolderPath); + } + + // Process each node in this space + for (const node of nodes) { + try { + // Check if file already exists by nodeInstanceId + importedFromSpaceUri + const existingFile = queryEngine.findExistingImportedFile( + node.nodeInstanceId, + spaceUri, + ); + + const nodeContent = await fetchNodeContentForImport({ + client, + spaceId, + nodeInstanceId: node.nodeInstanceId, + }); + + if (!nodeContent) { + failedCount++; + processedCount++; + onProgress?.(processedCount, totalNodes); + continue; + } + + const { + fileName, + content, + createdAt: contentCreatedAt, + modifiedAt: contentModifiedAt, + filePath, + } = nodeContent; + const createdAt = node.createdAt ?? contentCreatedAt; + const modifiedAt = node.modifiedAt ?? contentModifiedAt; + const originalNodePath: string | undefined = node.filePath; + + // Sanitize file name + const sanitizedFileName = sanitizeFileName(fileName); + let finalFilePath: string; + + if (existingFile) { + // Update existing file - use its current path + finalFilePath = existingFile.path; + } else { + // Create new file in the import folder + finalFilePath = `${importFolderPath}/${sanitizedFileName}.md`; + + // Check if file path already exists (edge case: same title but different nodeInstanceId) + let counter = 1; + while (await plugin.app.vault.adapter.exists(finalFilePath)) { + finalFilePath = `${importFolderPath}/${sanitizedFileName} (${counter}).md`; + counter++; + } + } + + // Process the file content (maps nodeTypeId, handles frontmatter, stores import timestamps) + // This updates existing file or creates new one + const result = await processFileContent({ + plugin, + client, + sourceSpaceId: spaceId, + sourceSpaceUri: spaceUri, + rawContent: content, + originalFilePath: filePath, + filePath: finalFilePath, + importedCreatedAt: createdAt, + importedModifiedAt: modifiedAt, + }); + + if (result.error) { + console.error( + `Error processing file content for node ${node.nodeInstanceId}:`, + result.error, + ); + failedCount++; + processedCount++; + onProgress?.(processedCount, totalNodes); + continue; + } + + const processedFile = result.file; + + // Import assets for this node (use originalNodePath so assets go under import/{space}/ relative to note) + const assetImportResult = await importAssetsForNode({ + plugin, + client, + spaceId, + nodeInstanceId: node.nodeInstanceId, + spaceName, + targetMarkdownFile: processedFile, + originalNodePath, + }); + + // Update markdown content with new asset paths if assets were imported + if (assetImportResult.pathMapping.size > 0) { + const currentContent = await plugin.app.vault.read(processedFile); + const updatedContent = updateMarkdownAssetLinks({ + content: currentContent, + oldPathToNewPath: assetImportResult.pathMapping, + targetFile: processedFile, + app: plugin.app, + originalNodePath, + }); + + // Only update if content changed + if (updatedContent !== currentContent) { + await plugin.app.vault.modify(processedFile, updatedContent); + } + } + + // Log asset import errors if any + if (assetImportResult.errors.length > 0) { + console.warn( + `Some assets failed to import for node ${node.nodeInstanceId}:`, + assetImportResult.errors, + ); + } + + // If title changed and file exists, rename it to match the new title + if (existingFile && processedFile.basename !== sanitizedFileName) { + const newPath = `${importFolderPath}/${sanitizedFileName}.md`; + let targetPath = newPath; + let counter = 1; + while (await plugin.app.vault.adapter.exists(targetPath)) { + targetPath = `${importFolderPath}/${sanitizedFileName} (${counter}).md`; + counter++; + } + await plugin.app.fileManager.renameFile(processedFile, targetPath); + } + + successCount++; + processedCount++; + onProgress?.(processedCount, totalNodes); + } catch (error) { + console.error(`Error importing node ${node.nodeInstanceId}:`, error); + failedCount++; + processedCount++; + onProgress?.(processedCount, totalNodes); + } + } + } + + return { success: successCount, failed: failedCount }; +}; + +/** + * Refresh a single imported file by fetching the latest content from the database + * Reuses the same logic as importSelectedNodes by treating it as a single-node import + */ +export const refreshImportedFile = async ({ + plugin, + file, + client, +}: { + plugin: DiscourseGraphPlugin; + file: TFile; + client?: DGSupabaseClient; +}): Promise<{ success: boolean; error?: string }> => { + const supabaseClient = client || (await getLoggedInClient(plugin)); + if (!supabaseClient) { + throw new Error("Cannot get Supabase client"); + } + const cache = plugin.app.metadataCache.getFileCache(file); + const frontmatter = cache?.frontmatter as Record | undefined; + if (!frontmatter?.importedFromSpaceUri || !frontmatter?.nodeInstanceId) { + return { + success: false, + error: "Missing frontmatter: importedFromSpaceUri or nodeInstanceId", + }; + } + if ( + typeof frontmatter.importedFromSpaceUri !== "string" || + typeof frontmatter.nodeInstanceId !== "string" + ) { + return { + success: false, + error: "Non-string frontmatter: importedFromSpaceUri or nodeInstanceId", + }; + } + const { spaceName, spaceId } = await getSpaceNameIdFromUri( + supabaseClient, + frontmatter.importedFromSpaceUri, + ); + if (spaceId === -1) { + return { success: false, error: "Could not get the space Id" }; + } + const metadataResp = await supabaseClient + .from("Content") + .select("metadata") + .eq("space_id", spaceId) + .eq("source_local_id", frontmatter.nodeInstanceId) + .eq("variant", "direct") + .maybeSingle(); + const metadata = metadataResp.data?.metadata; + const filePath: string | undefined = + typeof metadata === "object" && + typeof (metadata as Record).filePath === "string" + ? (metadata as Record).filePath + : undefined; + const result = await importSelectedNodes({ + plugin, + selectedNodes: [ + { + nodeInstanceId: frontmatter.nodeInstanceId, + title: file.basename, + spaceId, + spaceName, + filePath, + groupId: + (frontmatter.publishedToGroups as string[] | undefined)?.[0] ?? "", + selected: false, + }, + ], + }); + return { + success: result.success > 0, + error: result.failed > 0 ? "Failed to refresh imported file" : undefined, + }; +}; + +/** + * Refresh all imported files in the vault + */ +export const refreshAllImportedFiles = async ( + plugin: DiscourseGraphPlugin, +): Promise<{ + success: number; + failed: number; + errors: Array<{ file: string; error: string }>; +}> => { + const allFiles = plugin.app.vault.getMarkdownFiles(); + const importedFiles: TFile[] = []; + const client = await getLoggedInClient(plugin); + if (!client) { + throw new Error("Cannot get Supabase client"); + } + // Find all imported files + for (const file of allFiles) { + const cache = plugin.app.metadataCache.getFileCache(file); + const frontmatter = cache?.frontmatter; + if (frontmatter?.importedFromSpaceUri && frontmatter?.nodeInstanceId) { + importedFiles.push(file); + } + } + + if (importedFiles.length === 0) { + return { success: 0, failed: 0, errors: [] }; + } + + let successCount = 0; + let failedCount = 0; + const errors: Array<{ file: string; error: string }> = []; + + // Refresh each file + for (const file of importedFiles) { + const result = await refreshImportedFile({ plugin, file, client }); + if (result.success) { + successCount++; + } else { + failedCount++; + errors.push({ + file: file.path, + error: result.error || "Unknown error", + }); + } + } + + return { success: successCount, failed: failedCount, errors }; +}; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index ffe07b1b3..a44c9bf80 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -3,7 +3,9 @@ import type DiscourseGraphPlugin from "~/index"; import { NodeTypeModal } from "~/components/NodeTypeModal"; import ModifyNodeModal from "~/components/ModifyNodeModal"; import { BulkIdentifyDiscourseNodesModal } from "~/components/BulkIdentifyDiscourseNodesModal"; +import { ImportNodesModal } from "~/components/ImportNodesModal"; import { createDiscourseNode } from "./createNode"; +import { refreshAllImportedFiles } from "./importNodes"; import { VIEW_TYPE_MARKDOWN, VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants"; import { createCanvas } from "~/components/canvas/utils/tldraw"; import { createOrUpdateDiscourseEmbedding } from "./syncDgNodesToSupabase"; @@ -71,6 +73,64 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { }, }); + plugin.addCommand({ + id: "import-nodes-from-another-space", + name: "Import nodes from another space", + checkCallback: (checking: boolean) => { + if (!plugin.settings.syncModeEnabled) { + if (!checking) { + new Notice("Sync mode is not enabled", 3000); + } + return false; + } + if (!checking) { + new ImportNodesModal(plugin.app, plugin).open(); + } + return true; + }, + }); + + plugin.addCommand({ + id: "refresh-imported-nodes", + name: "Fetch latest content from imported nodes", + checkCallback: (checking: boolean) => { + if (!plugin.settings.syncModeEnabled) { + if (!checking) { + new Notice("Sync mode is not enabled", 3000); + } + return false; + } + if (!checking) { + void refreshAllImportedFiles(plugin) + .then((result) => { + if (result.failed > 0) { + new Notice( + `Refresh completed with some issues:\n${result.success} file(s) refreshed successfully\n${result.failed} file(s) failed`, + 5000, + ); + if (result.errors.length > 0) { + console.error("Refresh errors:", result.errors); + } + } else if (result.success > 0) { + new Notice( + `Successfully refreshed ${result.success} imported node(s)`, + 3000, + ); + } else { + new Notice("No imported files found to refresh", 3000); + } + }) + .catch((error) => { + const errorMessage = + error instanceof Error ? error.message : String(error); + new Notice(`Refresh failed: ${errorMessage}`, 5000); + console.error("Refresh failed:", error); + }); + } + return true; + }, + }); + plugin.addCommand({ id: "toggle-discourse-context", name: "Toggle discourse context", diff --git a/apps/obsidian/src/utils/syncDgNodesToSupabase.ts b/apps/obsidian/src/utils/syncDgNodesToSupabase.ts index 94ba12d13..5c8d3280c 100644 --- a/apps/obsidian/src/utils/syncDgNodesToSupabase.ts +++ b/apps/obsidian/src/utils/syncDgNodesToSupabase.ts @@ -217,6 +217,10 @@ const collectDiscourseNodesFromVault = async ( continue; } + if (frontmatter.importedFromSpaceUri) { + continue; + } + const nodeTypeId = frontmatter.nodeTypeId as string; if (!nodeTypeId) { continue; @@ -627,6 +631,11 @@ const collectDiscourseNodesFromPaths = async ( continue; } + if (frontmatter.importedFromSpaceUri) { + console.debug(`Skipping imported file: ${filePath}`); + continue; + } + const nodeTypeId = frontmatter.nodeTypeId as string; if (!nodeTypeId) { continue; diff --git a/apps/obsidian/src/utils/upsertNodesAsContentWithEmbeddings.ts b/apps/obsidian/src/utils/upsertNodesAsContentWithEmbeddings.ts index 877cdb386..50e3c5f76 100644 --- a/apps/obsidian/src/utils/upsertNodesAsContentWithEmbeddings.ts +++ b/apps/obsidian/src/utils/upsertNodesAsContentWithEmbeddings.ts @@ -54,7 +54,6 @@ const createNodeContentEntries = async ( created: node.created, last_modified: node.last_modified, scale: "document" as const, - metadata: node.frontmatter as Json, }; const entries: LocalContentDataInput[] = []; @@ -65,6 +64,7 @@ const createNodeContentEntries = async ( ...baseEntry, text: node.file.basename, variant: "direct", + metadata: { filePath: node.file.path }, }); } @@ -76,6 +76,7 @@ const createNodeContentEntries = async ( ...baseEntry, text: fullContent, variant: "full", + metadata: node.frontmatter as Json, }); } catch (error) { console.error(`Error reading file content for ${node.file.path}:`, error); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a715ce10d..4f7134235 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 mime-types: specifier: ^3.0.1 version: 3.0.2 @@ -310,7 +313,7 @@ importers: version: 3.5.2(react@18.2.0) roamjs-components: specifier: 0.86.4 - version: 0.86.4(ad0016961dcdb2b46ced8f4d10229b4e) + version: 0.86.4(5b78e88b35a4681c3fec2bd59ab560ac) tldraw: specifier: 2.4.6 version: 2.4.6(patch_hash=56e196052862c9a58a11b43e5e121384cd1d6548416afa0f16e9fbfbf0e4080d)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -362,7 +365,7 @@ importers: version: 0.17.14 tailwindcss: specifier: ^3.4.17 - version: 3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3)) + version: 3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.5.4)) tsx: specifier: ^4.19.2 version: 4.20.5 @@ -601,10 +604,10 @@ importers: version: link:../typescript-config tailwindcss: specifier: ^3.4.1 - version: 3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3)) + version: 3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.5.4)) tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3))) + version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.5.4))) packages/types: {} @@ -6173,11 +6176,13 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -6186,7 +6191,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-dirs@3.0.1: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} @@ -6931,7 +6936,7 @@ packages: lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} - deprecated: This package is deprecated. Use require("node:util").isDeepStrictEqual instead. + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. lodash.isequalwith@4.4.0: resolution: {integrity: sha512-dcZON0IalGBpRmJBmMkaoV7d3I80R2O+FrzsZyHdNSFrANq/cgDqKQNmAHE8UEj4+QYWwwhkQOVdLHiAopzlsQ==} @@ -7364,7 +7369,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} - deprecated: Use your platform"s native DOMException instead + deprecated: Use your platform's native DOMException instead node-fetch@2.6.7: resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} @@ -8859,12 +8864,12 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tar@7.5.2: resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tar@7.5.7: resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} @@ -12959,22 +12964,22 @@ snapshots: '@rushstack/eslint-patch@1.12.0': {} - '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.882.0)(@aws-sdk/client-s3@3.882.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.1)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3)))(archiver@5.3.2)(axios@0.27.2(debug@4.4.1))(debug@4.4.1)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3)))(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3))(zod@3.25.76)': + '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.882.0)(@aws-sdk/client-s3@3.882.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.1)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.5.4)))(archiver@5.3.2)(axios@0.27.2(debug@4.4.1))(debug@4.4.1)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.5.4)))(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.5.4))(zod@3.25.76)': dependencies: '@aws-sdk/client-lambda': 3.882.0 '@aws-sdk/client-s3': 3.882.0 - '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.1)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3)) + '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.1)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.5.4)) archiver: 5.3.2 axios: 0.27.2(debug@4.4.1) debug: 4.4.1(supports-color@8.1.1) dotenv: 16.6.1 esbuild: 0.17.14 patch-package: 6.5.1 - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3)) - ts-node: 10.9.2(@types/node@20.19.13)(typescript@5.9.3) + tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.5.4)) + ts-node: 10.9.2(@types/node@20.19.13)(typescript@5.5.4) zod: 3.25.76 - '@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.1)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3))': + '@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.1)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.5.4))': dependencies: '@playwright/test': 1.29.0 '@testing-library/react': 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -12984,7 +12989,7 @@ snapshots: debug: 4.4.1(supports-color@8.1.1) dotenv: 16.6.1 jsdom: 20.0.3 - ts-node: 10.9.2(@types/node@20.19.13)(typescript@5.9.3) + ts-node: 10.9.2(@types/node@20.19.13)(typescript@5.5.4) '@selderee/plugin-htmlparser2@0.11.0': dependencies: @@ -18222,14 +18227,6 @@ snapshots: postcss: 8.5.6 ts-node: 10.9.2(@types/node@20.19.13)(typescript@5.5.4) - postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3)): - dependencies: - lilconfig: 3.1.3 - yaml: 2.8.1 - optionalDependencies: - postcss: 8.5.6 - ts-node: 10.9.2(@types/node@20.19.13)(typescript@5.9.3) - postcss-nested@6.2.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -18986,12 +18983,12 @@ snapshots: dependencies: glob: 7.2.3 - roamjs-components@0.86.4(ad0016961dcdb2b46ced8f4d10229b4e): + roamjs-components@0.86.4(5b78e88b35a4681c3fec2bd59ab560ac): dependencies: '@blueprintjs/core': 3.50.4(patch_hash=51c5847e0a73a1be0cc263036ff64d8fada46f3b65831ed938dbca5eecf3edc0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@blueprintjs/datetime': 3.23.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@blueprintjs/select': 3.19.1(patch_hash=5b2821b0bf7274e9b64d7824648c596b9e73c61f421d699a6d4c494f12f62355)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.882.0)(@aws-sdk/client-s3@3.882.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.1)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3)))(archiver@5.3.2)(axios@0.27.2(debug@4.4.1))(debug@4.4.1)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3)))(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3))(zod@3.25.76) + '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.882.0)(@aws-sdk/client-s3@3.882.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.1)(dotenv@16.6.1)(jsdom@20.0.3)(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.5.4)))(archiver@5.3.2)(axios@0.27.2(debug@4.4.1))(debug@4.4.1)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.5.4)))(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.5.4))(zod@3.25.76) '@types/crypto-js': 4.1.1 '@types/cytoscape': 3.21.9 '@types/file-saver': 2.0.5 @@ -19589,10 +19586,6 @@ snapshots: dependencies: tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.5.4)) - tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3))): - dependencies: - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3)) - tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.5.4)): dependencies: '@alloc/quick-lru': 5.2.0 @@ -19620,33 +19613,6 @@ snapshots: transitivePeerDependencies: - ts-node - tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3)): - dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.6.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.3 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.7 - lilconfig: 3.1.3 - micromatch: 4.0.8 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.1 - postcss: 8.5.6 - postcss-import: 15.1.0(postcss@8.5.6) - postcss-js: 4.0.1(postcss@8.5.6) - postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3)) - postcss-nested: 6.2.0(postcss@8.5.6) - postcss-selector-parser: 6.1.2 - resolve: 1.22.10 - sucrase: 3.35.0 - transitivePeerDependencies: - - ts-node - tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -19893,24 +19859,6 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@20.19.13)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.19.13 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - ts-toolbelt@6.15.5: {} ts-toolbelt@9.6.0: {}