mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(memory-workspace): detect Obsidian vault registration before deep link (#2638)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0e4729e7f2
commit
e05cab9bb2
@@ -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<string> = new Set(['gmail']);
|
||||
|
||||
/**
|
||||
* Trigger the `obsidian://open?path=<abs>` 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<unknown | null> {
|
||||
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<GraphExportResponse | null>(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 (
|
||||
<div className="space-y-4" data-testid="memory-workspace">
|
||||
<MemorySources syncableToolkits={SYNCABLE_TOOLKITS} pollIntervalMs={5000} onToast={onToast} />
|
||||
@@ -358,18 +286,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
)}
|
||||
</button>
|
||||
{graph && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleViewVault(graph.content_root_abs)}
|
||||
data-testid="memory-open-in-obsidian"
|
||||
className="inline-flex items-center gap-2 rounded-lg
|
||||
bg-violet-500 px-4 py-2 text-sm font-semibold text-white
|
||||
shadow-sm transition-colors hover:bg-violet-600
|
||||
focus:outline-none focus:ring-2 focus:ring-violet-300"
|
||||
title={`obsidian://open?path=${graph.content_root_abs}`}>
|
||||
<ExternalLinkIcon />
|
||||
{t('workspace.viewVault')}
|
||||
</button>
|
||||
<ObsidianVaultSection contentRootAbs={graph.content_root_abs} onToast={onToast} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -491,25 +408,6 @@ function BrainIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLinkIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<path d="M14 3h7v7" />
|
||||
<path d="M10 14L21 3" />
|
||||
<path d="M21 14v7H3V3h7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Inline "open your memory vault in Obsidian" control for the Memory tab.
|
||||
*
|
||||
* The vault is the on-disk `<workspace>/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 `<workspace>/memory_tree/content/` (from graph export). */
|
||||
contentRootAbs: string;
|
||||
onToast?: (toast: Omit<ToastNotification, 'id'>) => 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<boolean | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [configDir, setConfigDir] = useState<string>(readConfigDirOverride);
|
||||
|
||||
/** Build + fire the `obsidian://` deep link. Resolves to an error or null. */
|
||||
const fireDeepLink = useCallback(async (): Promise<unknown | null> => {
|
||||
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 (
|
||||
<div className="flex flex-col items-end gap-2" data-testid="obsidian-vault-section">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleViewVault}
|
||||
disabled={checking}
|
||||
data-testid="memory-open-in-obsidian"
|
||||
className="inline-flex items-center gap-2 rounded-lg
|
||||
bg-violet-500 px-4 py-2 text-sm font-semibold text-white
|
||||
shadow-sm transition-colors hover:bg-violet-600
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
focus:outline-none focus:ring-2 focus:ring-violet-300"
|
||||
title={`obsidian://open?path=${contentRootAbs}`}>
|
||||
<ExternalLinkIcon />
|
||||
{checking ? t('workspace.checkingVault') : t('workspace.viewVault')}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
data-testid="obsidian-vault-guidance"
|
||||
className="w-full max-w-xl rounded-lg border border-violet-200 bg-violet-50 p-4
|
||||
text-sm dark:border-violet-500/30 dark:bg-violet-500/10">
|
||||
<p className="text-neutral-700 dark:text-neutral-200">{helpText}</p>
|
||||
|
||||
<code
|
||||
className="mt-2 block break-all rounded bg-white/70 px-2 py-1 font-mono text-xs
|
||||
text-neutral-600 dark:bg-neutral-900/60 dark:text-neutral-300"
|
||||
data-testid="obsidian-vault-path">
|
||||
{contentRootAbs}
|
||||
</code>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reveal}
|
||||
data-testid="obsidian-reveal"
|
||||
className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs font-semibold
|
||||
text-neutral-700 hover:bg-neutral-50 dark:border-neutral-600
|
||||
dark:bg-neutral-800 dark:text-neutral-200">
|
||||
{t('workspace.revealFolder')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAnyway}
|
||||
data-testid="obsidian-open-anyway"
|
||||
className="rounded-md border border-violet-300 bg-white px-3 py-1.5 text-xs font-semibold
|
||||
text-violet-700 hover:bg-violet-50 dark:border-violet-500/40
|
||||
dark:bg-neutral-800 dark:text-violet-300">
|
||||
{t('workspace.openAnyway')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={installObsidian}
|
||||
data-testid="obsidian-install"
|
||||
className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs font-semibold
|
||||
text-neutral-700 hover:bg-neutral-50 dark:border-neutral-600
|
||||
dark:bg-neutral-800 dark:text-neutral-200">
|
||||
{t('workspace.installObsidian')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(v => !v)}
|
||||
data-testid="obsidian-advanced-toggle"
|
||||
className="mt-3 text-xs font-medium text-violet-600 hover:underline dark:text-violet-300">
|
||||
{t('workspace.obsidianAdvanced')}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<label
|
||||
htmlFor="obsidian-config-dir"
|
||||
className="block text-xs font-medium text-neutral-600 dark:text-neutral-300">
|
||||
{t('workspace.obsidianConfigDirLabel')}
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
id="obsidian-config-dir"
|
||||
type="text"
|
||||
value={configDir}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveConfigDir}
|
||||
disabled={checking}
|
||||
data-testid="obsidian-config-dir-save"
|
||||
className="rounded-md bg-violet-500 px-3 py-1 text-xs font-semibold text-white
|
||||
hover:bg-violet-600 disabled:opacity-50">
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('workspace.obsidianConfigDirHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLinkIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<path d="M14 3h7v7" />
|
||||
<path d="M10 14L21 3" />
|
||||
<path d="M21 14v7H3V3h7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(<ObsidianVaultSection contentRootAbs={ROOT} onToast={onToast} />);
|
||||
|
||||
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(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
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(<ObsidianVaultSection contentRootAbs={ROOT} onToast={onToast} />);
|
||||
|
||||
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(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
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(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
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(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
// 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(<ObsidianVaultSection contentRootAbs={ROOT} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('obsidian-vault-guidance')).toBeInTheDocument());
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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': 'وضع عرض الرسم البياني للذاكرة',
|
||||
|
||||
@@ -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': 'মেমোরি গ্রাফ ভিউ মোড',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'मेमोरी ग्राफ व्यू मोड',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '메모리 그래프 보기 모드',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Режим просмотра графа памяти',
|
||||
|
||||
@@ -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': '记忆图谱视图模式',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 `<workspace>/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<ObsidianVaultStatus> {
|
||||
console.debug(
|
||||
'[memory-tree-rpc] memoryTreeObsidianVaultStatus: entry override=%s',
|
||||
obsidianConfigDir ? 'set' : 'none'
|
||||
);
|
||||
const resp = await callCoreRpc<ObsidianVaultStatus | ResultEnvelope<ObsidianVaultStatus>>({
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
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<PathBuf> = 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]);
|
||||
|
||||
|
||||
@@ -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<GraphEdge>,
|
||||
/// Absolute path to the on-disk `<workspace>/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 `<workspace>/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<String>,
|
||||
) -> Result<RpcOutcome<ObsidianVaultStatusResponse>, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
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<RegisteredController> {
|
||||
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 <workspace>/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<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_obsidian_vault_status(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
struct Req {
|
||||
#[serde(default)]
|
||||
obsidian_config_dir: Option<String>,
|
||||
}
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let req = parse_value::<Req>(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<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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=<abs>` 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<String, VaultEntry>,
|
||||
}
|
||||
|
||||
#[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<PathBuf> {
|
||||
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<String> = 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user