Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/soft-attachments-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": minor
---

Add a project setting for attachment upload placement, including Settings controls and upload routing for content-root and current-folder attachment directories.
2 changes: 1 addition & 1 deletion docs/content/get-started/meta.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"title": "Get Started",
"icon": "LuRocket",
"pages": ["overview", "quickstart"]
"pages": ["overview", "quickstart", "obsidian"]
}
70 changes: 70 additions & 0 deletions docs/content/get-started/obsidian.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
title: Using with Obsidian
description: How to open an existing Obsidian vault in OpenKnowledge, which markdown syntax carries over, and where the two differ.
---

OpenKnowledge and Obsidian are both editors over a folder of plain markdown — [the files are the database](/docs/reference/core-concepts#the-file-system-is-the-database). So there is nothing to import or convert: point OpenKnowledge at your existing vault and your notes, links, tags, and frontmatter come across as they are.

<Callout type="info">
OpenKnowledge works **directly on your markdown files** — it never moves, renames, or rewrites them just for opening a vault. It keeps its own state in a `.ok/` directory and ignores Obsidian's `.obsidian/` folder, so your Obsidian setup stays exactly where it is.
</Callout>

## Open your vault

### MacOS Desktop app

Open OpenKnowledge, choose **Open folder on disk**, and select your existing vault folder, then proceed through the **Setup** screen. OpenKnowledge uses the folder in place — it doesn't copy or move your notes.

### Linux, Windows, Intel Mac

Install the [ok CLI](/docs/reference/cli), `cd` into your vault, and run `ok init && ok start --open`.

## What carries over

Most of what you write in Obsidian renders and round-trips in OpenKnowledge unchanged. Existing syntax is **preserved byte-for-byte** when a file is saved, so opening a vault is non-destructive.

| Obsidian syntax | Example | In OpenKnowledge |
| --- | --- | --- |
| Wiki links | `[[Page]]`, `[[Page\|alias]]`, `[[Page#Heading]]` | Resolved to the target doc; feed backlinks and the graph. Preserved as written. |
| Markdown links | `[text](./page.md)` | Fully supported and the [recommended form](/docs/reference/core-concepts#links-and-backlinks) for new links. Wiki links keep working as a superset. |
| YAML frontmatter | `---` block at the top | Open-shape — any keys. `aliases`, `tags`, `cssclasses`, dates, and lists are preserved. Edit them in the [Properties panel](/docs/features/editor#properties). |
| Tags | `#tag`, or `tags:` in frontmatter | Both forms are indexed and searchable. |
| Callouts | `> [!note]`, `> [!warning]`, foldable `> [!tip]-` | Rendered with icons. 15 types including Obsidian's set (`success`, `question`, `failure`, `bug`, `example`, `quote`, …); aliases like `summary` / `tldr` fold to `abstract`. |
| Highlights | `==highlighted==` | Rendered as a highlight mark. |
| Comments | `%%hidden note%%` | Hidden in the editor, kept in the file. (`<!-- -->` works too.) |
| Math | `$inline$`, `$$block$$` | Rendered as LaTeX math. |
| Mermaid | ` ```mermaid ` fenced blocks | Rendered as diagrams. |
| Footnotes | `[^1]` with `[^1]: …` | Supported, including named footnotes. |
| Asset embeds | `![[image.png]]`, `![[file.pdf#page=3]]` | Images and PDFs render inline. See [Assets and embeds](/docs/features/assets-and-embeds). |

## Where the two differ

A handful of Obsidian features are not supported, or behave differently. None of them corrupt your files — the syntax is preserved on disk; it just isn't interpreted the same way.

| Feature | What happens in OpenKnowledge |
| --- | --- |
| **Note transclusion** — `![[Some Note]]` to embed another note's *content* | Renders as a working link to the note rather than inlining its content — the target is one click away, just not pulled in. Only file assets (images, PDFs) embed inline; documents do not transclude. |
| **Block references** — `^block-id` and `[[Page#^block-id]]` | Not recognized. Heading links (`[[Page#Heading]]`) resolve, but a `#^block-id` fragment won't point at a block. |
| **The `.obsidian/` folder** | Ignored. OpenKnowledge does not read Obsidian's theme, hotkeys, plugin config, or vault preferences. Configure OpenKnowledge in [`.ok/config.yml`](/docs/reference/configuration) instead. |
| **Community plugins** | Don't run. OpenKnowledge is a separate app, not an Obsidian plugin host. Many features people reach for plugins for in Obsidian — callouts, tags, math, Mermaid, embeds — are built in here, but plugin-specific syntax (for example Dataview queries) is left as plain text. |
| **Canvas (`.canvas`) and Bases (`.base`)** | Open in a read-only text view rather than as interactive canvases or bases. They're still indexed and linkable from your notes. |

<Callout type="info">
OpenKnowledge's defaults match Obsidian's defaults — wiki links and attachments stored alongside your notes — so a standard vault works as-is. There's no migration step today; if you have a vault with heavily non-default settings, [let us know](https://github.com/inkeep/open-knowledge/issues).
</Callout>

## Agent skills, built in

Getting an AI agent to work *well* inside an Obsidian vault usually means adding an agent-skills layer on top — a set of `SKILL.md` instructions that teach the agent your conventions and how to read and write the vault. OpenKnowledge builds that layer in, so there's nothing to bolt on:

- **A project skill is installed for you.** `ok init` (or the desktop setup) drops in a project-local skill that teaches your agent OpenKnowledge's tools and conventions — no hand-written `CLAUDE.md` to maintain.
- **Agents get first-class tools, not just file access.** The [MCP server](/docs/reference/mcp) gives any MCP-capable agent — Claude Code, Cursor, Codex, OpenCode — typed `read` / `write` / `search` / `links` tools over the same files, so edits are consistent, attributed, and keep your link graph healthy.

## See also

- [Quickstart](/docs/get-started/quickstart): the five-minute setup
- [Core concepts](/docs/reference/core-concepts): links, backlinks, and the file-system-as-database model
- [Editor](/docs/features/editor): WYSIWYG, source mode, and the graph, backlinks, and properties panels
- [Timeline and recovery](/docs/features/timeline-and-recovery): version history and point-in-time rollback
- [Ignore patterns](/docs/features/ignore-patterns): keep folders out of the editor, search, and agents
- [Assets and embeds](/docs/features/assets-and-embeds): how images and file references behave
1 change: 1 addition & 0 deletions docs/content/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ The **Scope** column says which file a setting belongs in: **project** → `.ok/
| Field | Type | Default | Scope | Description |
| --- | --- | --- | --- | --- |
| `content.dir` | string | `"."` | project | Content directory, relative to project root. Defaults to `.` (the project root) even when `.ok/` is scaffolded at the git working-tree root from a sub-folder, so opened folder and content scope align by default. To narrow scope to a sub-folder, uncomment `content.dir` in `.ok/config.yml` post-init. Excluded paths live in `.okignore`; see [Ignore patterns](/docs/features/ignore-patterns). |
| `content.attachmentFolderPath` | string | `"./"` | project | Where pasted and dropped assets are stored. `"./"` colocates beside the current document; `"/"` targets the content root; `"./subdir"` targets a subfolder under the current folder; `"folder"` targets a fixed folder under the content root. |
| `appearance.theme` | `"light" \| "dark" \| "system"` | (unset) | user | Editor theme. |
| `appearance.sidebar.showHiddenFiles` | boolean | `false` | project-local | Show files whose path segments start with `.`. The sidebar otherwise lists every file on disk under the content directory; tooling internals (`.git/`, `.ok/`, `node_modules/`) stay hidden regardless. Toggled from the sidebar's right-click menu or the macOS **View → Show Hidden Files** item. |
| `autoSync.enabled` | `boolean \| null` | `null` | project-local | Per-machine git auto-sync toggle. `null` means "unanswered"; the editor's onboarding modal triggers on first remote-detected open. |
Expand Down
7 changes: 2 additions & 5 deletions packages/app/src/components/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
MARKDOWN_FILE_ICON_VIEWBOX,
} from '@/components/file-entry-icon';
import {
appendSidebarUploadFields,
collectTreeFolderPathsFromDocuments,
computeTreeAncestorPaths,
computeTreeDropDestinationPath,
Expand All @@ -88,7 +89,6 @@ import {
treePathSignature,
treePathToAppPath,
uploadedPathForSidebarDrop,
uploadParentDocNameForFolderDrop,
} from '@/components/file-tree-adapter';
import {
createFileTreeStyle,
Expand Down Expand Up @@ -2387,10 +2387,7 @@ export function FileTree({
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
formData.append(
'parentDocName',
uploadParentDocNameForFolderDrop(parentDir, file.name || 'upload'),
);
appendSidebarUploadFields(formData, parentDir, file.name || 'upload');

try {
const res = await fetch('/api/upload', { method: 'POST', body: formData });
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/components/file-tree-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
import type { ContextMenuItem, FileTreeDropTarget } from '@pierre/trees';
import { OK_SIDEBAR_DRAG_MIME } from '@/lib/sidebar-drag';
import {
appendSidebarUploadFields,
collectTreeFolderPathsFromDocuments,
computeTreeAncestorPaths,
computeTreeDropDestinationPath,
Expand Down Expand Up @@ -151,6 +152,11 @@ describe('file-tree-adapter', () => {
expect(uploadParentDocNameForFolderDrop('docs/', 'clip.pdf')).toBe('docs/clip.pdf');
expect(uploadParentDocNameForFolderDrop('', 'clip.pdf')).toBe('clip.pdf');

const formData = new FormData();
appendSidebarUploadFields(formData, 'docs', 'clip.pdf');
expect(formData.get('parentDocName')).toBe('docs/clip.pdf');
expect(formData.get('placement')).toBe('parent-dir');

expect(uploadedPathForSidebarDrop('docs', { src: 'clip.pdf' })).toBe('docs/clip.pdf');
expect(
uploadedPathForSidebarDrop('docs', { src: 'ignored.pdf', path: '/assets/clip.pdf' }),
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/components/file-tree-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,15 @@ export function uploadParentDocNameForFolderDrop(
return parent ? `${parent}/${fileName}` : fileName;
}

export function appendSidebarUploadFields(
formData: FormData,
parentFolderPath: string,
fileName: string,
): void {
formData.append('parentDocName', uploadParentDocNameForFolderDrop(parentFolderPath, fileName));
formData.append('placement', 'parent-dir');
}

export function uploadedPathForSidebarDrop(
parentFolderPath: string,
success: UploadAssetSuccess,
Expand Down
138 changes: 89 additions & 49 deletions packages/app/src/components/settings/SettingsDialogBody.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test } from 'bun:test';
import {
CONFIG_DOC_NAME_PROJECT,
CONFIG_DOC_NAME_USER,
type Config,
type ConfigBinding,
Expand All @@ -9,7 +10,9 @@ import {
import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, useTheme } from 'next-themes';
import type { ReactNode } from 'react';
import { TooltipProvider } from '@/components/ui/tooltip';
import { ConfigContext, type ConfigContextValue } from '@/lib/config-context';
import { emitConfigValidationRejected } from '@/lib/config-validation-events';
import { expectVisualClassTokens } from '@/test-utils/visual-contract';
import { SettingsDialogBody } from './SettingsDialogBody';
Expand Down Expand Up @@ -40,23 +43,51 @@ function makeBinding(config: Config = ConfigSchema.parse({})): {
return { binding, patches };
}

describe('SettingsDialogBody preferences runtime', () => {
afterEach(() => {
cleanup();
});
function makeConfigContextValue(projectBinding: ConfigBinding = makeBinding().binding) {
const config = ConfigSchema.parse({});
return {
userBinding: null,
userSynced: true,
projectBinding,
projectLocalBinding: null,
okignoreBinding: null,
okignoreSynced: true,
userConfig: config,
projectConfig: config,
projectSynced: true,
projectLocalConfig: config,
projectLocalSynced: true,
merged: config,
} satisfies ConfigContextValue;
}

test('renders editor.wordWrap in the Preferences section', () => {
const { binding } = makeBinding();
const { container } = render(
function SettingsContextProvider({ children }: { children: ReactNode }) {
return <ConfigContext value={makeConfigContextValue()}>{children}</ConfigContext>;
}

function renderPreferences(binding: ConfigBinding) {
return render(
<SettingsContextProvider>
<TooltipProvider>
<SettingsDialogBody
activeId="preferences"
userBinding={binding}
okignoreBinding={null}
okignoreSynced={false}
/>
</TooltipProvider>,
);
</TooltipProvider>
</SettingsContextProvider>,
);
}

describe('SettingsDialogBody preferences runtime', () => {
afterEach(() => {
cleanup();
});

test('renders editor.wordWrap in the Preferences section', () => {
const { binding } = makeBinding();
const { container } = renderPreferences(binding);

expect(screen.getByRole('heading', { name: 'Preferences' })).toBeDefined();
expect(screen.getByText('Word wrap')).toBeDefined();
Expand All @@ -81,16 +112,7 @@ describe('SettingsDialogBody preferences runtime', () => {
test('commits editor.wordWrap changes through binding.patch', async () => {
const user = userEvent.setup();
const { binding, patches } = makeBinding();
render(
<TooltipProvider>
<SettingsDialogBody
activeId="preferences"
userBinding={binding}
okignoreBinding={null}
okignoreSynced={false}
/>
</TooltipProvider>,
);
renderPreferences(binding);

const wordWrapSwitch = screen.getByRole('switch', { name: 'Word wrap' });
await user.click(wordWrapSwitch);
Expand All @@ -104,16 +126,7 @@ describe('SettingsDialogBody preferences runtime', () => {
test('commits appearance.preview.autoOpen changes through binding.patch', async () => {
const user = userEvent.setup();
const { binding, patches } = makeBinding();
render(
<TooltipProvider>
<SettingsDialogBody
activeId="preferences"
userBinding={binding}
okignoreBinding={null}
okignoreSynced={false}
/>
</TooltipProvider>,
);
renderPreferences(binding);

const autoOpenSwitch = screen.getByRole('switch', { name: 'Open preview when agent edits' });
expect(autoOpenSwitch.getAttribute('aria-checked')).toBe('true');
Expand All @@ -138,16 +151,7 @@ describe('SettingsDialogBody preferences runtime', () => {

test('surfaces L3 config-validation rejections on the matching user field', async () => {
const { binding } = makeBinding();
const { container } = render(
<TooltipProvider>
<SettingsDialogBody
activeId="preferences"
userBinding={binding}
okignoreBinding={null}
okignoreSynced={false}
/>
</TooltipProvider>,
);
const { container } = renderPreferences(binding);

const wordWrapField = container.querySelector('[data-field="editor.wordWrap"]');
expect(wordWrapField).toBeTruthy();
Expand Down Expand Up @@ -178,6 +182,40 @@ describe('SettingsDialogBody preferences runtime', () => {
});
expectVisualClassTokens(wordWrapField?.className, ['animate-settings-flash']);
});

test('surfaces L3 config-validation rejections on the project attachment field', async () => {
const { binding } = makeBinding();
const { container } = renderPreferences(binding);

const attachmentField = container.querySelector('[data-field="content.attachmentFolderPath"]');
expect(attachmentField).toBeTruthy();

act(() => {
emitConfigValidationRejected({
v: 1,
ch: 'config-validation-rejected',
seq: 2,
docName: CONFIG_DOC_NAME_PROJECT,
error: {
code: 'SCHEMA_INVALID',
issues: [
{
path: ['content', 'attachmentFolderPath'],
message: 'Invalid attachment folder path',
issueCode: 'invalid_path',
},
],
},
});
});

await waitFor(() => {
expect(
within(screen.getByTestId('settings-attachments')).getByRole('alert').textContent,
).toBe('Invalid attachment folder path');
});
expectVisualClassTokens(attachmentField?.className, ['animate-settings-flash']);
});
});

let themeStorageKeySeq = 0;
Expand All @@ -196,15 +234,17 @@ function renderPreferencesWithTheme(binding: ConfigBinding) {
enableSystem
storageKey={`ok-theme-v1-test-${themeStorageKeySeq}`}
>
<TooltipProvider>
<SettingsDialogBody
activeId="preferences"
userBinding={binding}
okignoreBinding={null}
okignoreSynced={false}
/>
<ThemeProbe />
</TooltipProvider>
<SettingsContextProvider>
<TooltipProvider>
<SettingsDialogBody
activeId="preferences"
userBinding={binding}
okignoreBinding={null}
okignoreSynced={false}
/>
<ThemeProbe />
</TooltipProvider>
</SettingsContextProvider>
</ThemeProvider>,
);
}
Expand All @@ -220,7 +260,7 @@ describe('SettingsDialogBody theme toggle — optimistic apply', () => {
cleanup();
});

test('clicking Dark flips next-themes immediately (no ConfigProvider) and still persists via binding.patch', async () => {
test('clicking Dark flips next-themes immediately and still persists via binding.patch', async () => {
const user = userEvent.setup();
const { binding, patches } = makeBinding();
const { container } = renderPreferencesWithTheme(binding);
Expand Down
Loading