From e05cab9bb269b0cd2becd86bdcf5bf031451d720 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 26 May 2026 02:25:21 +0530 Subject: [PATCH] fix(memory-workspace): detect Obsidian vault registration before deep link (#2638) Co-authored-by: Claude Opus 4.7 (1M context) --- .../intelligence/MemoryWorkspace.tsx | 106 +----- .../intelligence/ObsidianVaultSection.tsx | 307 +++++++++++++++++ .../__tests__/MemoryWorkspace.test.tsx | 29 +- .../__tests__/ObsidianVaultSection.test.tsx | 125 +++++++ app/src/lib/i18n/chunks/ar-3.ts | 12 + app/src/lib/i18n/chunks/bn-3.ts | 12 + app/src/lib/i18n/chunks/de-3.ts | 12 + app/src/lib/i18n/chunks/en-3.ts | 12 + app/src/lib/i18n/chunks/es-3.ts | 12 + app/src/lib/i18n/chunks/fr-3.ts | 12 + app/src/lib/i18n/chunks/hi-3.ts | 12 + app/src/lib/i18n/chunks/id-3.ts | 12 + app/src/lib/i18n/chunks/it-3.ts | 12 + app/src/lib/i18n/chunks/ko-3.ts | 12 + app/src/lib/i18n/chunks/pt-3.ts | 12 + app/src/lib/i18n/chunks/ru-3.ts | 12 + app/src/lib/i18n/chunks/zh-CN-3.ts | 12 + app/src/lib/i18n/en.ts | 12 + .../utils/tauriCommands/memoryTree.test.ts | 39 +++ app/src/utils/tauriCommands/memoryTree.ts | 47 +++ gitbooks/features/obsidian-wiki/README.md | 8 +- src/openhuman/memory/ops/documents.rs | 45 ++- src/openhuman/memory/read_rpc.rs | 135 +++++++- src/openhuman/memory/schema.rs | 61 ++++ src/openhuman/memory_store/content/mod.rs | 1 + .../memory_store/content/obsidian_registry.rs | 321 ++++++++++++++++++ 26 files changed, 1274 insertions(+), 118 deletions(-) create mode 100644 app/src/components/intelligence/ObsidianVaultSection.tsx create mode 100644 app/src/components/intelligence/__tests__/ObsidianVaultSection.test.tsx create mode 100644 src/openhuman/memory_store/content/obsidian_registry.rs diff --git a/app/src/components/intelligence/MemoryWorkspace.tsx b/app/src/components/intelligence/MemoryWorkspace.tsx index 51eaaefc1..569e958c4 100644 --- a/app/src/components/intelligence/MemoryWorkspace.tsx +++ b/app/src/components/intelligence/MemoryWorkspace.tsx @@ -29,7 +29,6 @@ import { useCallback, useEffect, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import type { ToastNotification } from '../../types/intelligence'; -import { openUrl } from '../../utils/openUrl'; import { type GraphExportResponse, type GraphMode, @@ -38,10 +37,9 @@ import { memoryTreeResetTree, memoryTreeWipeAll, } from '../../utils/tauriCommands'; -import { revealWorkspacePath } from '../../utils/tauriCommands/workspacePaths'; import { MemoryGraph } from './MemoryGraph'; import { MemorySources } from './MemorySources'; -import { MEMORY_CONTENT_WORKSPACE_PATH } from './memoryWorkspacePaths'; +import { ObsidianVaultSection } from './ObsidianVaultSection'; import { VaultPanel } from './VaultPanel'; import { WhatsAppMemorySection } from './WhatsAppMemorySection'; @@ -62,34 +60,6 @@ interface MemoryWorkspaceProps { */ const SYNCABLE_TOOLKITS: ReadonlySet = new Set(['gmail']); -/** - * Trigger the `obsidian://open?path=` deep link via the OS shell. - * - * We deliberately route through `openUrl` (which delegates to - * `tauri-plugin-opener`) rather than setting `window.location.href`. - * The webview-host intent handler intercepts in-app navigations and - * does NOT punt custom schemes to the OS, so a direct - * `window.location.href = "obsidian://…"` either no-ops or navigates - * the React app away from the Memory tab. The opener plugin hands the - * URL straight to the system handler so Obsidian launches as a - * separate process. - * - * Returns `null` on success, or the underlying error otherwise. The - * caller decides how to surface the outcome (toast, fallback, …); an - * unsurfaced no-op is the bug #2281 originally reported. - */ -async function openVaultInObsidian(contentRootAbs: string): Promise { - const url = `obsidian://open?path=${encodeURIComponent(contentRootAbs)}`; - console.debug('[ui-flow][memory-workspace] open vault in Obsidian url=%s', url); - try { - await openUrl(url); - return null; - } catch (err) { - console.error('[ui-flow][memory-workspace] openUrl failed', err); - return err; - } -} - export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { const { t } = useT(); const [graph, setGraph] = useState(null); @@ -242,48 +212,6 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { } }, [onToast, mode]); - // #2281: clicking "View Vault" must never silently no-op. Some - // users don't have Obsidian installed, in which case the OS shell - // accepts the `obsidian://` URL and does nothing visible. We always - // emit a toast that names the vault path AND offers a "Reveal - // Folder" action so the user has an OS-native way to inspect the - // vault even without Obsidian. - const handleViewVault = useCallback( - async (contentRootAbs: string) => { - const revealHandler = () => { - void (async () => { - try { - await revealWorkspacePath(MEMORY_CONTENT_WORKSPACE_PATH); - } catch (err) { - console.error('[ui-flow][memory-workspace] revealWorkspacePath failed', err); - onToast?.({ - type: 'error', - title: t('workspace.revealVaultFailed'), - message: err instanceof Error ? err.message : String(err), - }); - } - })(); - }; - const err = await openVaultInObsidian(contentRootAbs); - if (err === null) { - onToast?.({ - type: 'info', - title: t('workspace.openingVaultTitle'), - message: `${t('workspace.openingVaultMessage')} ${contentRootAbs}`, - action: { label: t('workspace.revealFolder'), handler: revealHandler }, - }); - } else { - onToast?.({ - type: 'error', - title: t('workspace.openVaultFailedTitle'), - message: `${t('workspace.openVaultFailedMessage')} ${contentRootAbs}`, - action: { label: t('workspace.revealFolder'), handler: revealHandler }, - }); - } - }, - [onToast, t] - ); - return (
@@ -358,18 +286,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { )} {graph && ( - + )}
@@ -491,25 +408,6 @@ function BrainIcon() { ); } -function ExternalLinkIcon() { - return ( - - ); -} - function Spinner() { return ( /memory_tree/content/` folder. Opening + * it uses an `obsidian://open?path=…` deep link — but that scheme only + * resolves folders Obsidian has already registered as a vault (it cannot + * register one, and a `.obsidian/` folder on disk is not enough). So a + * first-time click would otherwise land on Obsidian's *"Unable to find a vault + * for the URL"* error. + * + * Flow (progressive disclosure): + * - Click → check registration via `memory_tree_obsidian_vault_status`. + * - Registered → fire the deep link (success toast). Done. + * - Not registered → expand inline guidance instead of firing a doomed link: + * Reveal Folder · Open anyway · Install Obsidian · Advanced ▸ config-dir + * override. + * + * Detection is best-effort — Obsidian can live somewhere we can't probe + * (Flatpak/Snap/portable). "Open anyway" and the config-dir override are the + * escape hatches for that case; a false "not registered" never blocks the user. + */ +import { useCallback, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { ToastNotification } from '../../types/intelligence'; +import { openUrl } from '../../utils/openUrl'; +import { memoryTreeObsidianVaultStatus } from '../../utils/tauriCommands'; +import { revealWorkspacePath } from '../../utils/tauriCommands/workspacePaths'; +import { MEMORY_CONTENT_WORKSPACE_PATH } from './memoryWorkspacePaths'; + +/** localStorage key for the optional Obsidian config-dir override. */ +const CONFIG_DIR_KEY = 'openhuman.obsidian.configDir'; +const OBSIDIAN_DOWNLOAD_URL = 'https://obsidian.md/download'; + +interface ObsidianVaultSectionProps { + /** Absolute path to `/memory_tree/content/` (from graph export). */ + contentRootAbs: string; + onToast?: (toast: Omit) => void; +} + +function readConfigDirOverride(): string { + try { + return localStorage.getItem(CONFIG_DIR_KEY) ?? ''; + } catch { + return ''; + } +} + +export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultSectionProps) { + const { t } = useT(); + const [checking, setChecking] = useState(false); + const [expanded, setExpanded] = useState(false); + // null = not probed yet / probe failed; otherwise last `config_found`. + const [configFound, setConfigFound] = useState(null); + const [showAdvanced, setShowAdvanced] = useState(false); + const [configDir, setConfigDir] = useState(readConfigDirOverride); + + /** Build + fire the `obsidian://` deep link. Resolves to an error or null. */ + const fireDeepLink = useCallback(async (): Promise => { + const url = `obsidian://open?path=${encodeURIComponent(contentRootAbs)}`; + console.debug('[ui-flow][obsidian-vault] firing deep link'); + try { + await openUrl(url); + return null; + } catch (err) { + console.error('[ui-flow][obsidian-vault] openUrl failed', err); + return err; + } + }, [contentRootAbs]); + + const reveal = useCallback(() => { + void (async () => { + try { + await revealWorkspacePath(MEMORY_CONTENT_WORKSPACE_PATH); + } catch (err) { + console.error('[ui-flow][obsidian-vault] revealWorkspacePath failed', err); + onToast?.({ + type: 'error', + title: t('workspace.revealVaultFailed'), + message: err instanceof Error ? err.message : String(err), + }); + } + })(); + }, [onToast, t]); + + const toastOpenOutcome = useCallback( + (err: unknown | null) => { + onToast?.( + err === null + ? { + type: 'info', + title: t('workspace.openingVaultTitle'), + message: `${t('workspace.openingVaultMessage')} ${contentRootAbs}`, + action: { label: t('workspace.revealFolder'), handler: reveal }, + } + : { + type: 'error', + title: t('workspace.openVaultFailedTitle'), + message: `${t('workspace.openVaultFailedMessage')} ${contentRootAbs}`, + action: { label: t('workspace.revealFolder'), handler: reveal }, + } + ); + }, + [onToast, t, contentRootAbs, reveal] + ); + + /** Fire the deep link unconditionally — the "Open anyway" escape hatch. */ + const openAnyway = useCallback(() => { + void (async () => { + toastOpenOutcome(await fireDeepLink()); + })(); + }, [fireDeepLink, toastOpenOutcome]); + + const installObsidian = useCallback(() => { + // https URL → openUrl falls back to window.open if the IPC bridge isn't + // ready, so this normally reaches the download page. Swallow + log any + // rejection so it can't surface as an unhandled promise rejection. + void (async () => { + try { + await openUrl(OBSIDIAN_DOWNLOAD_URL); + } catch (err) { + console.error('[ui-flow][obsidian-vault] openUrl(download) failed', err); + } + })(); + }, []); + + const handleViewVault = useCallback(() => { + void (async () => { + setChecking(true); + try { + const override = configDir.trim(); + const status = await memoryTreeObsidianVaultStatus(override || undefined); + console.debug( + '[ui-flow][obsidian-vault] status registered=%s config_found=%s', + status.registered, + status.config_found + ); + setConfigFound(status.config_found); + + if (status.registered) { + // Known vault — open it directly. + const err = await fireDeepLink(); + toastOpenOutcome(err); + // Registered but the IPC/scheme still failed → surface guidance; + // on success collapse any stale panel from a prior failed check. + setExpanded(err !== null); + return; + } + + // Not registered — guide the user instead of firing a doomed link. + setExpanded(true); + } catch (err) { + // Detection itself failed — degrade gracefully to the guidance panel. + console.error('[ui-flow][obsidian-vault] status check failed', err); + setConfigFound(null); + setExpanded(true); + } finally { + setChecking(false); + } + })(); + }, [configDir, fireDeepLink, toastOpenOutcome]); + + const saveConfigDir = useCallback(() => { + const trimmed = configDir.trim(); + try { + if (trimmed) localStorage.setItem(CONFIG_DIR_KEY, trimmed); + else localStorage.removeItem(CONFIG_DIR_KEY); + } catch (err) { + console.warn('[ui-flow][obsidian-vault] persist config dir failed', err); + } + // Re-run the check with the new override applied. + handleViewVault(); + }, [configDir, handleViewVault]); + + const helpText = + configFound === false + ? t('workspace.obsidianNotFoundHelp') + : t('workspace.vaultNotRegisteredHelp'); + + return ( +
+ + + {expanded && ( +
+

{helpText}

+ + + {contentRootAbs} + + +
+ + + +
+ + + + {showAdvanced && ( +
+ +
+ setConfigDir(e.target.value)} + placeholder={t('workspace.obsidianConfigDirPlaceholder')} + spellCheck={false} + data-testid="obsidian-config-dir-input" + className="flex-1 rounded-md border border-neutral-300 bg-white px-2 py-1 font-mono text-xs + text-neutral-800 focus:outline-none focus:ring-1 focus:ring-violet-300 + dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-100" + /> + +
+

+ {t('workspace.obsidianConfigDirHint')} +

+
+ )} +
+ )} +
+ ); +} + +function ExternalLinkIcon() { + return ( + + ); +} diff --git a/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx b/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx index 8e5d3cf7c..b9add6cc1 100644 --- a/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx +++ b/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx @@ -15,6 +15,7 @@ vi.mock('../../../utils/tauriCommands', () => ({ memoryTreeFlushNow: vi.fn(), memoryTreeWipeAll: vi.fn(), memoryTreeResetTree: vi.fn(), + memoryTreeObsidianVaultStatus: vi.fn(), })); vi.mock('../../../services/memorySyncService', () => ({ @@ -45,13 +46,19 @@ vi.mock('../../../utils/tauriCommands/workspacePaths', () => ({ }), })); -const { memoryTreeGraphExport, memoryTreeFlushNow, memoryTreeWipeAll, memoryTreeResetTree } = - (await import('../../../utils/tauriCommands')) as unknown as { - memoryTreeGraphExport: Mock; - memoryTreeFlushNow: Mock; - memoryTreeWipeAll: Mock; - memoryTreeResetTree: Mock; - }; +const { + memoryTreeGraphExport, + memoryTreeFlushNow, + memoryTreeWipeAll, + memoryTreeResetTree, + memoryTreeObsidianVaultStatus, +} = (await import('../../../utils/tauriCommands')) as unknown as { + memoryTreeGraphExport: Mock; + memoryTreeFlushNow: Mock; + memoryTreeWipeAll: Mock; + memoryTreeResetTree: Mock; + memoryTreeObsidianVaultStatus: Mock; +}; const { listConnections, syncConnection } = (await import('../../../lib/composio/composioApi')) as unknown as { @@ -115,6 +122,14 @@ describe('MemoryWorkspace (graph view)', () => { openUrl.mockResolvedValue(undefined); openWorkspacePath.mockResolvedValue(undefined); revealWorkspacePath.mockResolvedValue(undefined); + // Default: the content root is already a registered Obsidian vault, so a + // View-Vault click opens it directly (the not-registered guidance branch + // is covered in ObsidianVaultSection.test.tsx). + memoryTreeObsidianVaultStatus.mockResolvedValue({ + registered: true, + config_found: true, + content_root_abs: '/tmp/workspace/memory_tree/content', + }); }); it('renders the SVG graph once the export RPC resolves', async () => { diff --git a/app/src/components/intelligence/__tests__/ObsidianVaultSection.test.tsx b/app/src/components/intelligence/__tests__/ObsidianVaultSection.test.tsx new file mode 100644 index 000000000..dbf17a689 --- /dev/null +++ b/app/src/components/intelligence/__tests__/ObsidianVaultSection.test.tsx @@ -0,0 +1,125 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import { ObsidianVaultSection } from '../ObsidianVaultSection'; + +vi.mock('../../../utils/tauriCommands', () => ({ memoryTreeObsidianVaultStatus: vi.fn() })); + +vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn().mockResolvedValue(undefined) })); + +vi.mock('../../../utils/tauriCommands/workspacePaths', () => ({ + revealWorkspacePath: vi.fn().mockResolvedValue(undefined), +})); + +const { memoryTreeObsidianVaultStatus } = + (await import('../../../utils/tauriCommands')) as unknown as { + memoryTreeObsidianVaultStatus: Mock; + }; + +const { openUrl } = (await import('../../../utils/openUrl')) as unknown as { openUrl: Mock }; + +const { revealWorkspacePath } = + (await import('../../../utils/tauriCommands/workspacePaths')) as unknown as { + revealWorkspacePath: Mock; + }; + +const ROOT = '/tmp/workspace/memory_tree/content'; +const DEEP_LINK = 'obsidian://open?path=' + encodeURIComponent(ROOT); + +function status(over: Partial<{ registered: boolean; config_found: boolean }> = {}) { + return { registered: false, config_found: true, content_root_abs: ROOT, ...over }; +} + +describe('ObsidianVaultSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + openUrl.mockResolvedValue(undefined); + revealWorkspacePath.mockResolvedValue(undefined); + }); + + it('registered vault → opens the deep link directly, no guidance shown', async () => { + memoryTreeObsidianVaultStatus.mockResolvedValue(status({ registered: true })); + const onToast = vi.fn(); + renderWithProviders(); + + fireEvent.click(screen.getByTestId('memory-open-in-obsidian')); + + await waitFor(() => expect(openUrl).toHaveBeenCalledWith(DEEP_LINK)); + expect(screen.queryByTestId('obsidian-vault-guidance')).toBeNull(); + await waitFor(() => expect(onToast).toHaveBeenCalled()); + expect(onToast.mock.calls[0][0].type).toBe('info'); + }); + + it('unregistered vault → no deep link, shows guidance with the vault path', async () => { + memoryTreeObsidianVaultStatus.mockResolvedValue(status()); + renderWithProviders(); + + fireEvent.click(screen.getByTestId('memory-open-in-obsidian')); + + await waitFor(() => expect(screen.getByTestId('obsidian-vault-guidance')).toBeInTheDocument()); + expect(openUrl).not.toHaveBeenCalled(); + expect(screen.getByTestId('obsidian-vault-path')).toHaveTextContent(ROOT); + }); + + it('"Open anyway" fires the deep link even when unregistered', async () => { + memoryTreeObsidianVaultStatus.mockResolvedValue(status()); + const onToast = vi.fn(); + renderWithProviders(); + + fireEvent.click(screen.getByTestId('memory-open-in-obsidian')); + const openAnyway = await screen.findByTestId('obsidian-open-anyway'); + fireEvent.click(openAnyway); + + await waitFor(() => expect(openUrl).toHaveBeenCalledWith(DEEP_LINK)); + }); + + it('"Reveal Folder" in the guidance panel reveals the content root', async () => { + memoryTreeObsidianVaultStatus.mockResolvedValue(status()); + renderWithProviders(); + + fireEvent.click(screen.getByTestId('memory-open-in-obsidian')); + fireEvent.click(await screen.findByTestId('obsidian-reveal')); + + await waitFor(() => expect(revealWorkspacePath).toHaveBeenCalledWith('memory_tree/content')); + }); + + it('config not found → Install Obsidian opens the download page', async () => { + memoryTreeObsidianVaultStatus.mockResolvedValue(status({ config_found: false })); + renderWithProviders(); + + fireEvent.click(screen.getByTestId('memory-open-in-obsidian')); + fireEvent.click(await screen.findByTestId('obsidian-install')); + + await waitFor(() => expect(openUrl).toHaveBeenCalledWith('https://obsidian.md/download')); + }); + + it('Advanced config-dir override persists to localStorage and re-checks with it', async () => { + memoryTreeObsidianVaultStatus.mockResolvedValue(status()); + renderWithProviders(); + + // First click → not registered → guidance. + fireEvent.click(screen.getByTestId('memory-open-in-obsidian')); + fireEvent.click(await screen.findByTestId('obsidian-advanced-toggle')); + + const input = await screen.findByTestId('obsidian-config-dir-input'); + fireEvent.change(input, { target: { value: '/custom/obsidian' } }); + fireEvent.click(screen.getByTestId('obsidian-config-dir-save')); + + await waitFor(() => + expect(memoryTreeObsidianVaultStatus).toHaveBeenLastCalledWith('/custom/obsidian') + ); + expect(localStorage.getItem('openhuman.obsidian.configDir')).toBe('/custom/obsidian'); + }); + + it('detection failure degrades gracefully to the guidance panel', async () => { + memoryTreeObsidianVaultStatus.mockRejectedValue(new Error('rpc down')); + renderWithProviders(); + + fireEvent.click(screen.getByTestId('memory-open-in-obsidian')); + + await waitFor(() => expect(screen.getByTestId('obsidian-vault-guidance')).toBeInTheDocument()); + expect(openUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/lib/i18n/chunks/ar-3.ts b/app/src/lib/i18n/chunks/ar-3.ts index 2043875c3..b03c579fb 100644 --- a/app/src/lib/i18n/chunks/ar-3.ts +++ b/app/src/lib/i18n/chunks/ar-3.ts @@ -40,6 +40,18 @@ const ar3: TranslationMap = { 'workspace.openVaultFailedMessage': 'استخدم Reveal Folder لفتح دليل المخزن مباشرة. مسار المدفن:', 'workspace.revealVaultFailed': "Couldn't reveal vault folder", 'workspace.revealFolder': 'كشف المجلد', + 'workspace.checkingVault': 'Checking…', + 'workspace.vaultNotRegisteredHelp': + 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.', + 'workspace.obsidianNotFoundHelp': + "We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.", + 'workspace.openAnyway': 'Open in Obsidian anyway', + 'workspace.installObsidian': 'Install Obsidian', + 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', + 'workspace.obsidianConfigDirLabel': 'Obsidian config folder', + 'workspace.obsidianConfigDirHint': + 'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.', + 'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian', 'workspace.graphLoadFailed': 'فشل تحميل الرسم البياني للذاكرة', 'workspace.loadingGraph': 'جارٍ تحميل الرسم البياني للذاكرة...', 'workspace.graphViewMode': 'وضع عرض الرسم البياني للذاكرة', diff --git a/app/src/lib/i18n/chunks/bn-3.ts b/app/src/lib/i18n/chunks/bn-3.ts index 0b6fa015e..aa0a9c670 100644 --- a/app/src/lib/i18n/chunks/bn-3.ts +++ b/app/src/lib/i18n/chunks/bn-3.ts @@ -41,6 +41,18 @@ const bn3: TranslationMap = { 'ভল্ট ডিরেক্টরিটি সরাসরি খুলতে রিভিল ফোল্ডার ব্যবহার করুন। ভল্ট পাথ:', 'workspace.revealVaultFailed': "Couldn't reveal vault folder", 'workspace.revealFolder': 'ফোল্ডার প্রকাশ করুন', + 'workspace.checkingVault': 'Checking…', + 'workspace.vaultNotRegisteredHelp': + 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.', + 'workspace.obsidianNotFoundHelp': + "We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.", + 'workspace.openAnyway': 'Open in Obsidian anyway', + 'workspace.installObsidian': 'Install Obsidian', + 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', + 'workspace.obsidianConfigDirLabel': 'Obsidian config folder', + 'workspace.obsidianConfigDirHint': + 'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.', + 'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian', 'workspace.graphLoadFailed': 'মেমোরি গ্রাফ লোড করতে ব্যর্থ', 'workspace.loadingGraph': 'মেমোরি গ্রাফ লোড হচ্ছে...', 'workspace.graphViewMode': 'মেমোরি গ্রাফ ভিউ মোড', diff --git a/app/src/lib/i18n/chunks/de-3.ts b/app/src/lib/i18n/chunks/de-3.ts index 095c92b0b..a7b84bb8a 100644 --- a/app/src/lib/i18n/chunks/de-3.ts +++ b/app/src/lib/i18n/chunks/de-3.ts @@ -43,6 +43,18 @@ const de3: TranslationMap = { 'Nutze „Ordner anzeigen“, um das Vault-Verzeichnis direkt zu öffnen. Vault-Pfad:', 'workspace.revealVaultFailed': 'Vault-Ordner konnte nicht angezeigt werden', 'workspace.revealFolder': 'Ordner anzeigen', + 'workspace.checkingVault': 'Checking…', + 'workspace.vaultNotRegisteredHelp': + 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.', + 'workspace.obsidianNotFoundHelp': + "We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.", + 'workspace.openAnyway': 'Open in Obsidian anyway', + 'workspace.installObsidian': 'Install Obsidian', + 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', + 'workspace.obsidianConfigDirLabel': 'Obsidian config folder', + 'workspace.obsidianConfigDirHint': + 'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.', + 'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian', 'workspace.graphLoadFailed': 'Speicherdiagramm konnte nicht geladen werden', 'workspace.loadingGraph': 'Speicherdiagramm wird geladen...', 'workspace.graphViewMode': 'Speicherdiagramm-Ansichtsmodus', diff --git a/app/src/lib/i18n/chunks/en-3.ts b/app/src/lib/i18n/chunks/en-3.ts index bf1bd3b9b..001e5d097 100644 --- a/app/src/lib/i18n/chunks/en-3.ts +++ b/app/src/lib/i18n/chunks/en-3.ts @@ -41,6 +41,18 @@ const en3: TranslationMap = { 'Use Reveal Folder to open the vault directory directly. Vault path:', 'workspace.revealVaultFailed': "Couldn't reveal vault folder", 'workspace.revealFolder': 'Reveal Folder', + 'workspace.checkingVault': 'Checking…', + 'workspace.vaultNotRegisteredHelp': + 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.', + 'workspace.obsidianNotFoundHelp': + "We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.", + 'workspace.openAnyway': 'Open in Obsidian anyway', + 'workspace.installObsidian': 'Install Obsidian', + 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', + 'workspace.obsidianConfigDirLabel': 'Obsidian config folder', + 'workspace.obsidianConfigDirHint': + 'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.', + 'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian', 'workspace.graphLoadFailed': 'Failed to load memory graph', 'workspace.loadingGraph': 'Loading memory graph...', 'workspace.graphViewMode': 'Memory graph view mode', diff --git a/app/src/lib/i18n/chunks/es-3.ts b/app/src/lib/i18n/chunks/es-3.ts index c6ae35191..05cf86d15 100644 --- a/app/src/lib/i18n/chunks/es-3.ts +++ b/app/src/lib/i18n/chunks/es-3.ts @@ -42,6 +42,18 @@ const es3: TranslationMap = { 'Utilice Revelar carpeta para abrir el directorio del almacén directamente. Ruta de la bóveda:', 'workspace.revealVaultFailed': "Couldn't reveal vault folder", 'workspace.revealFolder': 'Revelar carpeta', + 'workspace.checkingVault': 'Checking…', + 'workspace.vaultNotRegisteredHelp': + 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.', + 'workspace.obsidianNotFoundHelp': + "We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.", + 'workspace.openAnyway': 'Open in Obsidian anyway', + 'workspace.installObsidian': 'Install Obsidian', + 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', + 'workspace.obsidianConfigDirLabel': 'Obsidian config folder', + 'workspace.obsidianConfigDirHint': + 'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.', + 'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian', 'workspace.graphLoadFailed': 'No se pudo cargar el grafo de memoria', 'workspace.loadingGraph': 'Cargando grafo de memoria...', 'workspace.graphViewMode': 'Modo de vista del grafo de memoria', diff --git a/app/src/lib/i18n/chunks/fr-3.ts b/app/src/lib/i18n/chunks/fr-3.ts index 2d5d36276..cf3e9ee48 100644 --- a/app/src/lib/i18n/chunks/fr-3.ts +++ b/app/src/lib/i18n/chunks/fr-3.ts @@ -43,6 +43,18 @@ const fr3: TranslationMap = { 'Utilisez Reveal Folder pour ouvrir directement le répertoire du coffre-fort. Chemin du coffre-fort :', 'workspace.revealVaultFailed': "Couldn't reveal vault folder", 'workspace.revealFolder': 'Révéler le dossier', + 'workspace.checkingVault': 'Checking…', + 'workspace.vaultNotRegisteredHelp': + 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.', + 'workspace.obsidianNotFoundHelp': + "We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.", + 'workspace.openAnyway': 'Open in Obsidian anyway', + 'workspace.installObsidian': 'Install Obsidian', + 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', + 'workspace.obsidianConfigDirLabel': 'Obsidian config folder', + 'workspace.obsidianConfigDirHint': + 'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.', + 'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian', 'workspace.graphLoadFailed': 'Échec du chargement du graphe de mémoire', 'workspace.loadingGraph': 'Chargement du graphe de mémoire…', 'workspace.graphViewMode': 'Mode de vue du graphe de mémoire', diff --git a/app/src/lib/i18n/chunks/hi-3.ts b/app/src/lib/i18n/chunks/hi-3.ts index 14f4d06f1..863074540 100644 --- a/app/src/lib/i18n/chunks/hi-3.ts +++ b/app/src/lib/i18n/chunks/hi-3.ts @@ -41,6 +41,18 @@ const hi3: TranslationMap = { 'वॉल्ट निर्देशिका को सीधे खोलने के लिए रिवील फोल्डर का उपयोग करें। तिजोरी पथ:', 'workspace.revealVaultFailed': "Couldn't reveal vault folder", 'workspace.revealFolder': 'फ़ोल्डर प्रकट करें', + 'workspace.checkingVault': 'Checking…', + 'workspace.vaultNotRegisteredHelp': + 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.', + 'workspace.obsidianNotFoundHelp': + "We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.", + 'workspace.openAnyway': 'Open in Obsidian anyway', + 'workspace.installObsidian': 'Install Obsidian', + 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', + 'workspace.obsidianConfigDirLabel': 'Obsidian config folder', + 'workspace.obsidianConfigDirHint': + 'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.', + 'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian', 'workspace.graphLoadFailed': 'मेमोरी ग्राफ लोड नहीं हो पाया', 'workspace.loadingGraph': 'मेमोरी ग्राफ लोड हो रहा है...', 'workspace.graphViewMode': 'मेमोरी ग्राफ व्यू मोड', diff --git a/app/src/lib/i18n/chunks/id-3.ts b/app/src/lib/i18n/chunks/id-3.ts index 91b1b485d..835b67046 100644 --- a/app/src/lib/i18n/chunks/id-3.ts +++ b/app/src/lib/i18n/chunks/id-3.ts @@ -42,6 +42,18 @@ const id3: TranslationMap = { 'Gunakan Tampilkan Folder untuk membuka direktori vault secara langsung. Path vault:', 'workspace.revealVaultFailed': 'Tidak dapat menampilkan folder vault', 'workspace.revealFolder': 'Tampilkan Folder', + 'workspace.checkingVault': 'Checking…', + 'workspace.vaultNotRegisteredHelp': + 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.', + 'workspace.obsidianNotFoundHelp': + "We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.", + 'workspace.openAnyway': 'Open in Obsidian anyway', + 'workspace.installObsidian': 'Install Obsidian', + 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', + 'workspace.obsidianConfigDirLabel': 'Obsidian config folder', + 'workspace.obsidianConfigDirHint': + 'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.', + 'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian', 'workspace.graphLoadFailed': 'Gagal memuat grafik memori', 'workspace.loadingGraph': 'Memuat grafik memori...', 'workspace.graphViewMode': 'Mode tampilan grafik memori', diff --git a/app/src/lib/i18n/chunks/it-3.ts b/app/src/lib/i18n/chunks/it-3.ts index ed0f7cd95..1508f73e6 100644 --- a/app/src/lib/i18n/chunks/it-3.ts +++ b/app/src/lib/i18n/chunks/it-3.ts @@ -43,6 +43,18 @@ const it3: TranslationMap = { 'Utilizzare Reveal Folder per aprire direttamente la directory del vault. Percorso del vault:', 'workspace.revealVaultFailed': "Couldn't reveal vault folder", 'workspace.revealFolder': 'Rivela cartella', + 'workspace.checkingVault': 'Checking…', + 'workspace.vaultNotRegisteredHelp': + 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.', + 'workspace.obsidianNotFoundHelp': + "We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.", + 'workspace.openAnyway': 'Open in Obsidian anyway', + 'workspace.installObsidian': 'Install Obsidian', + 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', + 'workspace.obsidianConfigDirLabel': 'Obsidian config folder', + 'workspace.obsidianConfigDirHint': + 'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.', + 'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian', 'workspace.graphLoadFailed': 'Impossibile caricare il grafo di memoria', 'workspace.loadingGraph': 'Caricamento grafo di memoria...', 'workspace.graphViewMode': 'Modalità di visualizzazione grafo di memoria', diff --git a/app/src/lib/i18n/chunks/ko-3.ts b/app/src/lib/i18n/chunks/ko-3.ts index e0439b070..3f15af8ca 100644 --- a/app/src/lib/i18n/chunks/ko-3.ts +++ b/app/src/lib/i18n/chunks/ko-3.ts @@ -41,6 +41,18 @@ const ko3: TranslationMap = { '폴더 표시를 사용하여 볼트 디렉터리를 직접 엽니다. 볼트 경로:', 'workspace.revealVaultFailed': "Couldn't reveal vault folder", 'workspace.revealFolder': '폴더 공개', + 'workspace.checkingVault': 'Checking…', + 'workspace.vaultNotRegisteredHelp': + 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.', + 'workspace.obsidianNotFoundHelp': + "We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.", + 'workspace.openAnyway': 'Open in Obsidian anyway', + 'workspace.installObsidian': 'Install Obsidian', + 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', + 'workspace.obsidianConfigDirLabel': 'Obsidian config folder', + 'workspace.obsidianConfigDirHint': + 'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.', + 'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian', 'workspace.graphLoadFailed': '메모리 그래프를 불러오지 못했습니다', 'workspace.loadingGraph': '메모리 그래프를 불러오는 중...', 'workspace.graphViewMode': '메모리 그래프 보기 모드', diff --git a/app/src/lib/i18n/chunks/pt-3.ts b/app/src/lib/i18n/chunks/pt-3.ts index 0c4ec0baf..ad45081d5 100644 --- a/app/src/lib/i18n/chunks/pt-3.ts +++ b/app/src/lib/i18n/chunks/pt-3.ts @@ -42,6 +42,18 @@ const pt3: TranslationMap = { 'Use Reveal Folder para abrir o diretório do vault diretamente. Caminho do cofre:', 'workspace.revealVaultFailed': "Couldn't reveal vault folder", 'workspace.revealFolder': 'Revelar pasta', + 'workspace.checkingVault': 'Checking…', + 'workspace.vaultNotRegisteredHelp': + 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.', + 'workspace.obsidianNotFoundHelp': + "We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.", + 'workspace.openAnyway': 'Open in Obsidian anyway', + 'workspace.installObsidian': 'Install Obsidian', + 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', + 'workspace.obsidianConfigDirLabel': 'Obsidian config folder', + 'workspace.obsidianConfigDirHint': + 'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.', + 'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian', 'workspace.graphLoadFailed': 'Falha ao carregar grafo de memória', 'workspace.loadingGraph': 'Carregando grafo de memória...', 'workspace.graphViewMode': 'Modo de visualização do grafo de memória', diff --git a/app/src/lib/i18n/chunks/ru-3.ts b/app/src/lib/i18n/chunks/ru-3.ts index b17ca4c52..faa100f8f 100644 --- a/app/src/lib/i18n/chunks/ru-3.ts +++ b/app/src/lib/i18n/chunks/ru-3.ts @@ -41,6 +41,18 @@ const ru3: TranslationMap = { 'Используйте Reveal Folder, чтобы напрямую открыть каталог хранилища. Путь к хранилищу:', 'workspace.revealVaultFailed': "Couldn't reveal vault folder", 'workspace.revealFolder': 'Показать папку', + 'workspace.checkingVault': 'Checking…', + 'workspace.vaultNotRegisteredHelp': + 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.', + 'workspace.obsidianNotFoundHelp': + "We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.", + 'workspace.openAnyway': 'Open in Obsidian anyway', + 'workspace.installObsidian': 'Install Obsidian', + 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', + 'workspace.obsidianConfigDirLabel': 'Obsidian config folder', + 'workspace.obsidianConfigDirHint': + 'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.', + 'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian', 'workspace.graphLoadFailed': 'Не удалось загрузить граф памяти', 'workspace.loadingGraph': 'Загрузка графа памяти...', 'workspace.graphViewMode': 'Режим просмотра графа памяти', diff --git a/app/src/lib/i18n/chunks/zh-CN-3.ts b/app/src/lib/i18n/chunks/zh-CN-3.ts index 312fe048f..bc50cb978 100644 --- a/app/src/lib/i18n/chunks/zh-CN-3.ts +++ b/app/src/lib/i18n/chunks/zh-CN-3.ts @@ -40,6 +40,18 @@ const zhCN3: TranslationMap = { 'workspace.openVaultFailedMessage': '使用"显示文件夹"直接打开存储库目录。存储库路径:', 'workspace.revealVaultFailed': '无法显示存储库文件夹', 'workspace.revealFolder': '显示文件夹', + 'workspace.checkingVault': 'Checking…', + 'workspace.vaultNotRegisteredHelp': + 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.', + 'workspace.obsidianNotFoundHelp': + "We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.", + 'workspace.openAnyway': 'Open in Obsidian anyway', + 'workspace.installObsidian': 'Install Obsidian', + 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', + 'workspace.obsidianConfigDirLabel': 'Obsidian config folder', + 'workspace.obsidianConfigDirHint': + 'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.', + 'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian', 'workspace.graphLoadFailed': '无法加载记忆图谱', 'workspace.loadingGraph': '正在加载记忆图谱...', 'workspace.graphViewMode': '记忆图谱视图模式', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 967b710b5..ae3b02545 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1586,6 +1586,18 @@ const en: TranslationMap = { 'Use Reveal Folder to open the vault directory directly. Vault path:', 'workspace.revealVaultFailed': "Couldn't reveal vault folder", 'workspace.revealFolder': 'Reveal Folder', + 'workspace.checkingVault': 'Checking…', + 'workspace.vaultNotRegisteredHelp': + 'Obsidian only opens folders you\'ve added as a vault. In Obsidian, choose "Open folder as vault" and pick the folder below — you only need to do this once. Then click View Vault again.', + 'workspace.obsidianNotFoundHelp': + "We couldn't find Obsidian on this device. Install it, or — if it's installed somewhere non-standard — set its config folder under Advanced.", + 'workspace.openAnyway': 'Open in Obsidian anyway', + 'workspace.installObsidian': 'Install Obsidian', + 'workspace.obsidianAdvanced': 'Obsidian installed elsewhere?', + 'workspace.obsidianConfigDirLabel': 'Obsidian config folder', + 'workspace.obsidianConfigDirHint': + 'Path to the folder containing obsidian.json (e.g. ~/.config/obsidian). Leave blank to auto-detect.', + 'workspace.obsidianConfigDirPlaceholder': '~/.config/obsidian', 'workspace.graphLoadFailed': 'Failed to load memory graph', 'workspace.loadingGraph': 'Loading memory graph...', 'workspace.graphViewMode': 'Memory graph view mode', diff --git a/app/src/utils/tauriCommands/memoryTree.test.ts b/app/src/utils/tauriCommands/memoryTree.test.ts index 68a5cfd2c..18de2ebc2 100644 --- a/app/src/utils/tauriCommands/memoryTree.test.ts +++ b/app/src/utils/tauriCommands/memoryTree.test.ts @@ -17,6 +17,7 @@ import { memoryTreeGraphExport, memoryTreeListChunks, memoryTreeListSources, + memoryTreeObsidianVaultStatus, memoryTreeRecall, memoryTreeResetTree, memoryTreeSearch, @@ -380,3 +381,41 @@ describe('memoryTreeBackfillStatus', () => { expect(out.pending_jobs).toBe(0); }); }); + +describe('memoryTreeObsidianVaultStatus', () => { + test('dispatches with the config-dir override when one is provided', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ + result: { + registered: false, + config_found: true, + content_root_abs: '/ws/memory_tree/content', + }, + logs: ['memory_tree::read: obsidian_vault_status registered=false config_found=true'], + }); + + const out = await memoryTreeObsidianVaultStatus('/custom/obsidian'); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.memory_tree_obsidian_vault_status', + params: { obsidian_config_dir: '/custom/obsidian' }, + }); + expect(out.registered).toBe(false); + expect(out.config_found).toBe(true); + }); + + test('omits the override param and unwraps a bare-value response', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ + registered: true, + config_found: true, + content_root_abs: '/ws/memory_tree/content', + }); + + const out = await memoryTreeObsidianVaultStatus(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.memory_tree_obsidian_vault_status', + params: {}, + }); + expect(out.registered).toBe(true); + }); +}); diff --git a/app/src/utils/tauriCommands/memoryTree.ts b/app/src/utils/tauriCommands/memoryTree.ts index adc83a400..8e30bb23f 100644 --- a/app/src/utils/tauriCommands/memoryTree.ts +++ b/app/src/utils/tauriCommands/memoryTree.ts @@ -604,6 +604,53 @@ export async function memoryTreeGraphExport( return out; } +/** Response shape for `memory_tree_obsidian_vault_status`. */ +export interface ObsidianVaultStatus { + /** + * True when the content root (or an ancestor) is already a registered + * Obsidian vault, so `obsidian://open?path=` will actually resolve. + */ + registered: boolean; + /** + * True when an `obsidian.json` was found and parsed (Obsidian is set up). + * Lets the UI offer "Open folder as vault" vs. "Install Obsidian". + */ + config_found: boolean; + /** Absolute filesystem path to `/memory_tree/content/`. */ + content_root_abs: string; +} + +/** + * Best-effort check of whether the memory-tree content root is a registered + * Obsidian vault. Called before firing the `obsidian://open?path=` deep link, + * which only resolves vaults already in Obsidian's `obsidian.json` registry — + * it cannot register a new vault on its own. + * + * `obsidianConfigDir` optionally overrides where the core looks for + * `obsidian.json` (non-standard installs: Flatpak / Snap / portable). Backed + * by `openhuman.memory_tree_obsidian_vault_status`. + */ +export async function memoryTreeObsidianVaultStatus( + obsidianConfigDir?: string +): Promise { + console.debug( + '[memory-tree-rpc] memoryTreeObsidianVaultStatus: entry override=%s', + obsidianConfigDir ? 'set' : 'none' + ); + const resp = await callCoreRpc>({ + method: 'openhuman.memory_tree_obsidian_vault_status', + // Only send the override when present so the core uses its default probe. + params: obsidianConfigDir ? { obsidian_config_dir: obsidianConfigDir } : {}, + }); + const out = unwrapResult(resp); + console.debug( + '[memory-tree-rpc] memoryTreeObsidianVaultStatus: exit registered=%s config_found=%s', + out.registered, + out.config_found + ); + return out; +} + /** * #1574 §4b: per-model embedding re-embed backfill status. The AI settings * panel polls this after an embedder change to warn that semantic recall diff --git a/gitbooks/features/obsidian-wiki/README.md b/gitbooks/features/obsidian-wiki/README.md index 10875f12b..d6042ccd5 100644 --- a/gitbooks/features/obsidian-wiki/README.md +++ b/gitbooks/features/obsidian-wiki/README.md @@ -27,7 +27,13 @@ The `summaries/` folder is laid out hierarchically, by date for the global tree, ## Open the vault -In the desktop app, the **Memory** tab has a **"View vault in Obsidian"** button. It uses an `obsidian://open?path=...` deep link, so you need Obsidian installed. +In the desktop app, the **Memory** tab has a **"View vault in Obsidian"** button. It uses an `obsidian://open?path=...` deep link, which only resolves once the folder is **registered** as a vault in Obsidian — the deep link can't register it for you. So the first time: + +1. Click **View vault in Obsidian**. If the folder isn't a registered vault yet, OpenHuman shows inline guidance instead of silently failing. +2. In Obsidian, choose **"Open folder as vault"** and pick the path shown — you only need to do this once. +3. Click **View vault in Obsidian** again; it now opens straight into the vault. + +If Obsidian is installed somewhere non-standard (Flatpak/Snap/portable), use **Open in Obsidian anyway**, or point OpenHuman at its config folder under **Advanced** so detection works. Don't have Obsidian? The guidance links to the download page, and **Reveal Folder** always opens the vault directory in your OS file manager. You can also open the folder in any editor, it's just Markdown. Links between files use standard `[[wiki-link]]` syntax, so Obsidian's graph view, backlinks, and tag explorer all work out of the box. diff --git a/src/openhuman/memory/ops/documents.rs b/src/openhuman/memory/ops/documents.rs index d11f8b82a..1b89c80c5 100644 --- a/src/openhuman/memory/ops/documents.rs +++ b/src/openhuman/memory/ops/documents.rs @@ -500,7 +500,43 @@ mod tests { use super::*; - fn ensure_memory_client() { + /// Held for the whole test: pins `OPENHUMAN_WORKSPACE` at a stable, + /// never-torn-down workspace under `TEST_ENV_LOCK`. These tests call + /// `memory_init` → `current_workspace_dir` → `Config::load_or_init`, which + /// reads that process-global env var; without this, a concurrent + /// env-mutating test can swap the var and tear down its tempdir mid-call, + /// yielding `SQLITE_IOERR` / config atomic-replace `ENOENT`. + struct WorkspaceEnvGuard { + _lock: std::sync::MutexGuard<'static, ()>, + previous: Option, + } + + impl WorkspaceEnvGuard { + fn set(path: &std::path::Path) -> Self { + let lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let previous = std::env::var_os("OPENHUMAN_WORKSPACE"); + std::env::set_var("OPENHUMAN_WORKSPACE", path); + Self { + _lock: lock, + previous, + } + } + } + + impl Drop for WorkspaceEnvGuard { + fn drop(&mut self) { + if let Some(previous) = self.previous.as_ref() { + std::env::set_var("OPENHUMAN_WORKSPACE", previous); + } else { + std::env::remove_var("OPENHUMAN_WORKSPACE"); + } + } + } + + #[must_use] + fn ensure_memory_client() -> WorkspaceEnvGuard { static WORKSPACE: OnceLock = OnceLock::new(); let workspace = WORKSPACE.get_or_init(|| { let tmp = TempDir::new().expect("tempdir"); @@ -509,7 +545,10 @@ mod tests { std::mem::forget(tmp); path }); + // Pin the env BEFORE init so config load/save targets the stable dir. + let env_guard = WorkspaceEnvGuard::set(workspace); let _ = crate::openhuman::memory::global::init(workspace.clone()); + env_guard } fn unique_namespace(prefix: &str) -> String { @@ -538,7 +577,7 @@ mod tests { let _serial = crate::openhuman::memory::ops::GLOBAL_MEMORY_TEST_LOCK .lock() .await; - ensure_memory_client(); + let _env = ensure_memory_client(); let namespace = unique_namespace("memory-docs-direct"); let key = format!( "note{}", @@ -619,7 +658,7 @@ mod tests { let _serial = crate::openhuman::memory::ops::GLOBAL_MEMORY_TEST_LOCK .lock() .await; - ensure_memory_client(); + let _env = ensure_memory_client(); let namespace = unique_namespace("memory-docs-envelope"); let key = format!("env{}", &uuid::Uuid::new_v4().as_simple().to_string()[..12]); diff --git a/src/openhuman/memory/read_rpc.rs b/src/openhuman/memory/read_rpc.rs index 3d489f400..338b8de7b 100644 --- a/src/openhuman/memory/read_rpc.rs +++ b/src/openhuman/memory/read_rpc.rs @@ -32,6 +32,7 @@ use serde::{Deserialize, Serialize}; use crate::openhuman::config::Config; use crate::openhuman::memory_store::chunks::store::{self as chunk_store, with_connection}; use crate::openhuman::memory_store::chunks::types::SourceKind; +use crate::openhuman::memory_store::content::obsidian_registry; use crate::openhuman::memory_store::content::read as content_read; use crate::openhuman::memory_tree::retrieval::types::NodeKind; use crate::openhuman::memory_tree::score::store as score_store; @@ -964,9 +965,12 @@ pub struct GraphExportResponse { #[serde(default)] pub edges: Vec, /// Absolute path to the on-disk `/memory_tree/content/` root. - /// UIs use this to open files via the `obsidian://open?path=...` deep - /// link — Obsidian resolves arbitrary absolute paths without requiring - /// the vault to be registered. + /// UIs use this both to point an `obsidian://open?path=...` deep link at + /// the vault and as the folder the user adds via "Open folder as vault". + /// That deep link only resolves once this folder (or an ancestor) is a + /// *registered* Obsidian vault — the scheme cannot register a new vault on + /// its own, so the UI first calls [`obsidian_vault_status_rpc`] and guides + /// the user to add it when it isn't. pub content_root_abs: String, } @@ -1005,6 +1009,67 @@ pub async fn graph_export_rpc( Ok(RpcOutcome::single_log(resp, log)) } +/// Response shape for [`obsidian_vault_status_rpc`]. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ObsidianVaultStatusResponse { + /// `true` when the content root (or an ancestor) is already a registered + /// Obsidian vault, so `obsidian://open?path=` will actually resolve. + pub registered: bool, + /// `true` when an `obsidian.json` was found and parsed (Obsidian is set + /// up). Lets the UI offer "Open folder as vault" vs. "Install Obsidian". + pub config_found: bool, + /// Absolute path to `/memory_tree/content/` — the folder the + /// user adds to Obsidian, and the target of the deep link. + pub content_root_abs: String, +} + +/// `memory_tree_obsidian_vault_status` — best-effort check of whether the +/// memory-tree content root is a registered Obsidian vault. +/// +/// The Memory tab calls this before firing the `obsidian://open?path=` deep +/// link: that scheme only resolves vaults already present in Obsidian's +/// `obsidian.json`, so opening an unregistered folder lands on *"Unable to +/// find a vault for the URL"*. `obsidian_config_dir` optionally overrides +/// where we look for `obsidian.json` (non-standard installs: Flatpak / Snap / +/// portable). Never errors and never hits the network — a probe miss simply +/// reports `registered = false` and the UI degrades to "open anyway" + reveal. +pub async fn obsidian_vault_status_rpc( + config: &Config, + obsidian_config_dir: Option, +) -> Result, String> { + let cfg = config.clone(); + let resp = tokio::task::spawn_blocking(move || -> ObsidianVaultStatusResponse { + let content_root = cfg.memory_tree_content_root(); + // Treat a blank/whitespace override as "no override" — otherwise + // `Path::new("")` resolves to `.` and would probe a stray local + // `./obsidian.json`. The UI omits the field when empty, but the RPC + // is a public controller so normalize defensively here. + let extra = obsidian_config_dir + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(std::path::Path::new); + let reg = obsidian_registry::vault_registration_status(&content_root, extra); + ObsidianVaultStatusResponse { + registered: reg.registered, + config_found: reg.config_found, + content_root_abs: content_root.to_string_lossy().to_string(), + } + }) + .await + .map_err(|e| format!("obsidian_vault_status join error: {e}"))?; + + // Redact the absolute path (embeds the user's home / username) — log only + // the booleans and a stable hash, matching `graph_export_rpc`. + let log = format!( + "memory_tree::read: obsidian_vault_status registered={} config_found={} root_hash={}", + resp.registered, + resp.config_found, + crate::openhuman::memory::util::redact::redact(&resp.content_root_abs), + ); + Ok(RpcOutcome::single_log(resp, log)) +} + /// Tree mode: summary nodes joined to their owning tree for the /// human-readable scope. Edges are encoded implicitly via /// `GraphNode.parent_id`. @@ -2381,4 +2446,68 @@ mod tests { assert_eq!(composio_count, 0); assert_eq!(other_count, 1); } + + #[tokio::test] + async fn obsidian_status_registered_when_override_config_lists_content_root() { + let (_tmp, cfg) = test_config(); + let content_root = cfg.memory_tree_content_root(); + // A separate dir standing in for a non-standard Obsidian config + // location, with an obsidian.json that registers the content root. + let cfg_dir = TempDir::new().unwrap(); + let body = format!( + "{{ \"vaults\": {{ \"id0\": {{ \"path\": {}, \"open\": true }} }} }}", + serde_json::to_string(&content_root.to_string_lossy().to_string()).unwrap() + ); + std::fs::write(cfg_dir.path().join("obsidian.json"), body).unwrap(); + + let outcome = + obsidian_vault_status_rpc(&cfg, Some(cfg_dir.path().to_string_lossy().to_string())) + .await + .unwrap(); + + assert!(outcome.value.registered); + assert!(outcome.value.config_found); + assert_eq!( + outcome.value.content_root_abs, + content_root.to_string_lossy().to_string() + ); + // The log reports the booleans but redacts the absolute path (it + // embeds the user's home / username). + assert!( + outcome.logs[0].contains("registered=true"), + "log: {}", + outcome.logs[0] + ); + assert!( + !outcome.logs[0].contains(content_root.to_str().unwrap()), + "log leaked content root: {}", + outcome.logs[0] + ); + } + + #[tokio::test] + async fn obsidian_status_not_registered_for_empty_override_dir() { + let (_tmp, cfg) = test_config(); + // Empty override dir → no obsidian.json there → content root is not a + // registered vault. (A temp content root can't be under any real host + // vault either, so this stays false regardless of the dev machine.) + let cfg_dir = TempDir::new().unwrap(); + let outcome = + obsidian_vault_status_rpc(&cfg, Some(cfg_dir.path().to_string_lossy().to_string())) + .await + .unwrap(); + assert!(!outcome.value.registered); + } + + #[tokio::test] + async fn obsidian_status_blank_override_is_treated_as_none() { + // A whitespace-only override must be normalized to None rather than + // resolving to "." and probing a stray local ./obsidian.json. The temp + // content root isn't under any real host vault, so this stays false. + let (_tmp, cfg) = test_config(); + let outcome = obsidian_vault_status_rpc(&cfg, Some(" ".to_string())) + .await + .unwrap(); + assert!(!outcome.value.registered); + } } diff --git a/src/openhuman/memory/schema.rs b/src/openhuman/memory/schema.rs index 0af3ad0a4..579d3733e 100644 --- a/src/openhuman/memory/schema.rs +++ b/src/openhuman/memory/schema.rs @@ -40,6 +40,7 @@ pub fn all_controller_schemas() -> Vec { schemas("chunk_score"), schemas("delete_chunk"), schemas("graph_export"), + schemas("obsidian_vault_status"), schemas("flush_now"), schemas("wipe_all"), schemas("reset_tree"), @@ -106,6 +107,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("graph_export"), handler: handle_graph_export, }, + RegisteredController { + schema: schemas("obsidian_vault_status"), + handler: handle_obsidian_vault_status, + }, RegisteredController { schema: schemas("flush_now"), handler: handle_flush_now, @@ -603,6 +608,49 @@ pub fn schemas(function: &str) -> ControllerSchema { }, ], }, + "obsidian_vault_status" => ControllerSchema { + namespace: NAMESPACE, + function: "obsidian_vault_status", + description: "Best-effort check of whether the memory-tree content root is \ + already a registered Obsidian vault. `obsidian://open?path=` only \ + resolves vaults present in Obsidian's obsidian.json registry — it \ + cannot register a new one — so the Memory tab calls this before \ + firing the deep link and guides the user to 'Open folder as vault' \ + when it isn't registered. Never errors; a probe miss reports \ + registered=false.", + inputs: vec![FieldSchema { + name: "obsidian_config_dir", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional override for Obsidian's config directory (where \ + obsidian.json lives), for non-standard installs \ + (Flatpak / Snap / portable). Omitted ⇒ probe the standard per-OS \ + location plus known sandbox paths.", + required: false, + }], + outputs: vec![ + FieldSchema { + name: "registered", + ty: TypeSchema::Bool, + comment: "True when the content root (or an ancestor) is a registered \ + Obsidian vault, so the deep link will resolve.", + required: true, + }, + FieldSchema { + name: "config_found", + ty: TypeSchema::Bool, + comment: "True when an obsidian.json was found and parsed (Obsidian is \ + set up). Lets the UI offer add-as-vault vs. install.", + required: true, + }, + FieldSchema { + name: "content_root_abs", + ty: TypeSchema::String, + comment: "Absolute path to /memory_tree/content/ — the folder \ + to add to Obsidian and the deep-link target.", + required: true, + }, + ], + }, "trigger_digest" => ControllerSchema { namespace: NAMESPACE, function: "trigger_digest", @@ -842,6 +890,19 @@ fn handle_graph_export(params: Map) -> ControllerFuture { }) } +fn handle_obsidian_vault_status(params: Map) -> ControllerFuture { + Box::pin(async move { + #[derive(serde::Deserialize, Default)] + struct Req { + #[serde(default)] + obsidian_config_dir: Option, + } + let config = config_rpc::load_config_with_timeout().await?; + let req = parse_value::(Value::Object(params)).unwrap_or_default(); + to_json(read_rpc::obsidian_vault_status_rpc(&config, req.obsidian_config_dir).await?) + }) +} + fn handle_flush_now(_params: Map) -> ControllerFuture { Box::pin(async move { let config = config_rpc::load_config_with_timeout().await?; diff --git a/src/openhuman/memory_store/content/mod.rs b/src/openhuman/memory_store/content/mod.rs index 134a634ee..2e915d3cf 100644 --- a/src/openhuman/memory_store/content/mod.rs +++ b/src/openhuman/memory_store/content/mod.rs @@ -15,6 +15,7 @@ pub mod atomic; pub mod compose; pub mod obsidian; +pub mod obsidian_registry; pub mod paths; pub mod raw; pub mod read; diff --git a/src/openhuman/memory_store/content/obsidian_registry.rs b/src/openhuman/memory_store/content/obsidian_registry.rs new file mode 100644 index 000000000..2f4555e23 --- /dev/null +++ b/src/openhuman/memory_store/content/obsidian_registry.rs @@ -0,0 +1,321 @@ +//! Obsidian vault-*registration* detection. +//! +//! Sibling to [`super::obsidian`] (which writes the `.obsidian/` *defaults* +//! into the content root). This module answers a different question: is the +//! content root actually a vault Obsidian knows about? +//! +//! `obsidian://open?path=` only resolves against vaults already recorded +//! in Obsidian's `obsidian.json` registry — it can **not** register a new +//! vault, and a `.obsidian/` folder on disk is not enough. So before the +//! Memory tab fires that deep link we check whether the content root (or an +//! ancestor) is a registered vault. If it isn't, the UI guides the user to add +//! it once ("Open folder as vault") instead of firing a link Obsidian rejects +//! with *"Unable to find a vault for the URL"*. +//! +//! Detection is **best-effort**: Obsidian can live in non-standard locations +//! (Flatpak, Snap, custom `$XDG_CONFIG_HOME`, portable). A negative result must +//! never block the user — the caller still offers "open anyway" + "reveal +//! folder" + a config-dir override that feeds back in here as `extra`. + +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +/// Outcome of a registration probe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VaultRegistration { + /// `true` when some registered Obsidian vault's path equals or is an + /// ancestor of the content root. + pub registered: bool, + /// `true` when at least one candidate `obsidian.json` was found/read (even + /// if parsing it later fails — see the parse-error branch, which still + /// counts the file as found). Lets the UI distinguish "Obsidian is set up, + /// vault just not added yet" from "couldn't find Obsidian at all" (offer + /// install vs. offer add-as-vault). + pub config_found: bool, +} + +/// Minimal shape of Obsidian's `obsidian.json`. We only need each vault's +/// `path`; `ts`/`open` and any future keys are ignored by `serde`. +#[derive(Debug, Deserialize)] +struct ObsidianConfig { + #[serde(default)] + vaults: std::collections::HashMap, +} + +#[derive(Debug, Deserialize)] +struct VaultEntry { + path: String, +} + +/// Candidate `obsidian.json` locations, in priority order. `extra` (a +/// user-supplied override pointing at Obsidian's *config dir*) is checked +/// first so a power user can correct a non-standard install. +fn candidate_config_files(extra: Option<&Path>) -> Vec { + let mut out = Vec::new(); + + if let Some(dir) = extra { + // Accept either the config dir itself or its parent (users often + // can't tell whether the path should end in `obsidian/`). + out.push(dir.join("obsidian.json")); + out.push(dir.join("obsidian").join("obsidian.json")); + } + + // Standard per-OS config dir: `~/.config` (Linux), `~/Library/Application + // Support` (macOS), `%APPDATA%` (Windows). + if let Some(cfg) = dirs::config_dir() { + out.push(cfg.join("obsidian").join("obsidian.json")); + } + + // Linux sandbox installs keep their own config tree. Harmless to probe on + // other OSes — the paths simply won't exist. + if let Some(home) = dirs::home_dir() { + out.push(home.join(".var/app/md.obsidian.Obsidian/config/obsidian/obsidian.json")); // Flatpak + out.push(home.join("snap/obsidian/current/.config/obsidian/obsidian.json")); + // Snap + } + + out +} + +/// Best-effort: is `content_root` (or an ancestor) a registered Obsidian +/// vault? `extra_config_dir` optionally points at Obsidian's config dir for +/// non-standard installs. Never errors — probe failures report +/// `registered = false`. +pub fn vault_registration_status( + content_root: &Path, + extra_config_dir: Option<&Path>, +) -> VaultRegistration { + registration_in_files(content_root, &candidate_config_files(extra_config_dir)) +} + +/// Core of [`vault_registration_status`], split out so tests can supply an +/// explicit, isolated set of `obsidian.json` paths instead of depending on +/// whatever Obsidian config happens to exist on the host. +fn registration_in_files(content_root: &Path, files: &[PathBuf]) -> VaultRegistration { + let target = lexically_normalize(content_root); + let mut config_found = false; + + for path in files { + let body = match std::fs::read_to_string(path) { + Ok(b) => b, + Err(_) => continue, // missing/unreadable candidate — try the next. + }; + config_found = true; + + let parsed: ObsidianConfig = match serde_json::from_str(&body) { + Ok(p) => p, + Err(err) => { + // Redact the path — it embeds the user's home/username. + log::warn!( + "[content_store::obsidian_registry] parse {} failed: {err} — skipping", + crate::openhuman::memory::util::redact::redact(&path.display().to_string()) + ); + continue; + } + }; + + for entry in parsed.vaults.values() { + let vault = lexically_normalize(Path::new(&entry.path)); + // A malformed/empty vault path normalizes to "" and would otherwise + // match every content root (empty ancestor ⊂ anything) — skip it. + if vault.as_os_str().is_empty() { + continue; + } + if is_ancestor_or_equal(&vault, &target) { + log::debug!( + "[content_store::obsidian_registry] content root is a registered vault \ + (matched in {})", + crate::openhuman::memory::util::redact::redact(&path.display().to_string()) + ); + return VaultRegistration { + registered: true, + config_found: true, + }; + } + } + } + + log::debug!( + "[content_store::obsidian_registry] content root NOT registered (config_found={})", + config_found + ); + VaultRegistration { + registered: false, + config_found, + } +} + +/// Strip trailing separators so `/a/b` and `/a/b/` compare equal. Lexical +/// only — we deliberately do not canonicalize: the vault path may be on an +/// unmounted volume or use a symlink, and canonicalize would error or rewrite +/// it. Both inputs come from trusted local sources, so a textual compare is +/// the safe, dependency-free choice. +fn lexically_normalize(p: &Path) -> PathBuf { + let s = p.to_string_lossy(); + let trimmed = s.trim_end_matches(['/', '\\']); + if trimmed.is_empty() { + // Was a pure root like "/" — keep it. + PathBuf::from(s.as_ref()) + } else { + PathBuf::from(trimmed) + } +} + +/// `true` when `ancestor == descendant`, or `ancestor` is a path-prefix of +/// `descendant` on component boundaries (so `/a/b` contains `/a/b/c` but not +/// `/a/bc`). Case-sensitive — adequate for the Linux target; a false negative +/// on case-insensitive volumes only makes detection conservative (the caller +/// still offers "open anyway"). +fn is_ancestor_or_equal(ancestor: &Path, descendant: &Path) -> bool { + let a: Vec<_> = ancestor.components().collect(); + let d: Vec<_> = descendant.components().collect(); + // An empty ancestor must not match (it would otherwise be a prefix of + // everything); also bail when the ancestor is longer than the descendant. + if a.is_empty() || a.len() > d.len() { + return false; + } + a.iter().zip(d.iter()).all(|(x, y)| x == y) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + /// Write an `obsidian.json` containing `vault_paths` and return its path. + fn write_config(dir: &Path, vault_paths: &[&str]) -> PathBuf { + let entries: Vec = vault_paths + .iter() + .enumerate() + .map(|(i, p)| { + format!( + "\"id{i}\": {{ \"path\": {}, \"ts\": 1700000000000, \"open\": true }}", + serde_json::to_string(p).unwrap() + ) + }) + .collect(); + let body = format!("{{ \"vaults\": {{ {} }} }}", entries.join(", ")); + let path = dir.join("obsidian.json"); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(body.as_bytes()).unwrap(); + path + } + + #[test] + fn exact_match_is_registered() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("memory_tree/content"); + let cfg = write_config(tmp.path(), &[root.to_str().unwrap()]); + let got = registration_in_files(&root, &[cfg]); + assert_eq!( + got, + VaultRegistration { + registered: true, + config_found: true + } + ); + } + + #[test] + fn ancestor_vault_is_registered() { + // A vault rooted at the parent still "contains" the content root. + let tmp = tempfile::tempdir().unwrap(); + let parent = tmp.path().join("workspace"); + let root = parent.join("memory_tree/content"); + let cfg = write_config(tmp.path(), &[parent.to_str().unwrap()]); + assert!(registration_in_files(&root, &[cfg]).registered); + } + + #[test] + fn trailing_slash_does_not_matter() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("memory_tree/content"); + let with_slash = format!("{}/", root.to_str().unwrap()); + let cfg = write_config(tmp.path(), &[&with_slash]); + assert!(registration_in_files(&root, &[cfg]).registered); + } + + #[test] + fn unrelated_vault_is_not_registered_but_config_found() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("memory_tree/content"); + let cfg = write_config(tmp.path(), &["/some/other/vault"]); + let got = registration_in_files(&root, &[cfg]); + assert_eq!( + got, + VaultRegistration { + registered: false, + config_found: true + } + ); + } + + #[test] + fn empty_vault_path_does_not_match_every_root() { + // Regression: a malformed entry with an empty `path` must not + // normalize to "" and match every content root as an ancestor. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("memory_tree/content"); + let cfg = write_config(tmp.path(), &[""]); + let got = registration_in_files(&root, &[cfg]); + assert_eq!( + got, + VaultRegistration { + registered: false, + config_found: true + } + ); + } + + #[test] + fn sibling_prefix_is_not_a_false_match() { + // `/a/b/content` must NOT match a vault at `/a/b/content-archive`. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("content"); + let decoy = format!("{}-archive", root.to_str().unwrap()); + let cfg = write_config(tmp.path(), &[&decoy]); + assert!(!registration_in_files(&root, &[cfg]).registered); + } + + #[test] + fn missing_config_reports_not_found() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("memory_tree/content"); + let missing = tmp.path().join("does-not-exist.json"); + let got = registration_in_files(&root, &[missing]); + assert_eq!( + got, + VaultRegistration { + registered: false, + config_found: false + } + ); + } + + #[test] + fn malformed_config_is_skipped_not_fatal() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("memory_tree/content"); + let bad = tmp.path().join("obsidian.json"); + std::fs::write(&bad, b"{ this is not json ").unwrap(); + // config_found is true (we read it) but parse fails → not registered. + let got = registration_in_files(&root, &[bad]); + assert_eq!( + got, + VaultRegistration { + registered: false, + config_found: true + } + ); + } + + #[test] + fn second_candidate_wins_when_first_missing() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("memory_tree/content"); + let missing = tmp.path().join("nope.json"); + let real = write_config(tmp.path(), &[root.to_str().unwrap()]); + assert!(registration_in_files(&root, &[missing, real]).registered); + } +}