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/notion-export-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": minor
---

Add `ok migrate notion [dir]` to clean up a Notion "Markdown & CSV" export in place. It normalizes internal links so they both render and resolve (decodes `%20`, handles targets with parentheses, and angle-wraps targets containing spaces), converts `<aside>` callouts to `> [!note]`, lifts database row properties into YAML frontmatter, extracts inline base64 images to files, and renders each database CSV as a markdown table (keeping the per-row pages). Dry-run by default; pass `--apply` to write. Every transform is idempotent, and the command refuses non-Notion directories unless `--force`.
44 changes: 44 additions & 0 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,50 @@ jobs:
echo "::warning::Slack announcement failed for ${RELEASE_TAG} (release is published; notification is best-effort)."
fi

# Announce STABLE releases to Discord
- name: Announce stable release to Discord
if: success() && steps.channel.outputs.channel == 'latest' && github.event_name != 'workflow_dispatch'
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ github.event.client_payload.release_tag || inputs.release_tag }}
VERSION: ${{ steps.channel.outputs.version }}
run: |
set -euo pipefail
if [[ -z "${DISCORD_WEBHOOK_URL:-}" ]]; then
echo "::notice::DISCORD_WEBHOOK_URL not set — skipping Discord announcement for ${RELEASE_TAG}."
exit 0
fi
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}"
NOTES="$(gh release view "$RELEASE_TAG" --json body --jq '.body' 2>/dev/null || true)"
NOTES_LIMIT=3500
if [[ "${#NOTES}" -gt "$NOTES_LIMIT" ]]; then
NOTES="${NOTES:0:$NOTES_LIMIT}…"
fi
payload="$(jq -n \
--arg version "$VERSION" \
--arg release_url "$RELEASE_URL" \
--arg notes "$NOTES" \
'{
username: "OpenKnowledge",
allowed_mentions: { parse: [] },
content: ("🎉 OpenKnowledge \($version) released — " + $release_url),
embeds: [
{ title: ("OpenKnowledge \($version) released"),
url: $release_url,
color: 3638527,
description: (
(if ($notes | length) > 0 then ($notes + "\n\n") else "" end)
+ "[Release notes & downloads](\($release_url))"
) }
]
}')"
if curl -sS --fail -X POST -H 'Content-Type: application/json' --data "$payload" "$DISCORD_WEBHOOK_URL"; then
echo "Posted stable-release announcement for ${RELEASE_TAG} to Discord."
else
echo "::warning::Discord announcement failed for ${RELEASE_TAG} (release is published; notification is best-effort)."
fi

# Lightweight discoverability for stuck-draft state when the promote
# step (or the build/upload step before it) fails. NOT a substitute
# for the full paging-design observability deferred in the PR body —
Expand Down
1 change: 1 addition & 0 deletions docs/content/integrations/openclaw.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: OpenClaw
description: Use OpenKnowledge with OpenClaw agents over MCP.
icon: custom/OpenClaw
---

OpenClaw is an agent gateway: it runs agents that connect to MCP servers. Register OpenKnowledge as one of those servers and every agent OpenClaw runs can read, search, and write your knowledge base through OK's tools — with full attribution, link-awareness, conflict-awareness, and a live browser preview. Inside an OK project, agents work markdown through those tools rather than native file access, so every edit stays attributed and previewable.
Expand Down
83 changes: 57 additions & 26 deletions docs/content/migrate/notion.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,54 @@ icon: LuFileInput

This guide will cover exporting your Notion workspace to markdown files so that you can access them from OpenKnowledge. There are several key differences between OpenKnowledge and Notion that make this export incomplete and those will be covered here.

## Install OpenKnowledge

Download the latest version of the OpenKnowledge desktop app for macOS.

<DownloadButton label="Download for macOS" />

and install the [ok CLI](/docs/reference/cli):

```bash
npm install -g @inkeep/open-knowledge
```

## Export your workspace from Notion

1. In Notion, click on your workspace in the top left and go to **Settings**.
2. Under **General**, scroll down to **Export**.
3. Set **Export format** to **Markdown & CSV** and leave all the default settings.
4. Download and **unzip** the export folder and move it to your desired location.

## Clean up the export with `ok migrate notion`

The [ok CLI](/docs/reference/cli) ships a cleanup command that resolves format issues with the exported content. By default, the command prints what it would change and writes nothing until you pass `--apply`.

```bash
# Preview what would change (writes nothing)
ok migrate notion /path/to/unzipped-export

# Apply the changes
ok migrate notion /path/to/unzipped-export --apply
```

Every transform is **idempotent**, so re-running is safe. See the tables below for a full list of differences between the Notion export and what OpenKnowledge supports.

**Options:**

| Flag | Effect |
| --- | --- |
| `--apply` | Write the changes (default is a dry-run preview). |
| `--strip-base64` | Delete inline base64 images instead of extracting them to files. |
| `--remove-csv` | Delete each `_all.csv` once its table page exists (destructive). |
| `--only <list>` / `--skip <list>` | Run or skip specific transforms: `links`, `frontmatter`, `callouts`, `images`, `tables`. |
| `--force` | Run even if the folder isn't detected as a Notion export. |
| `--json` | Print a machine-readable report. |

<Callout type="info">
The command refuses to touch a folder that doesn't look like a Notion export unless you pass `--force`.
</Callout>

## Open the export in OpenKnowledge

### macOS Desktop app
Expand All @@ -21,7 +62,7 @@ Open OpenKnowledge, choose **Open folder on disk**, select the unzipped export f

### Linux, Windows, Intel Mac

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

## What exports cleanly

Expand All @@ -35,31 +76,21 @@ Install the [ok CLI](/docs/reference/cli), `cd` into the unzipped folder, and ru

## Where the two differ

A number of Notion features don't survive the Markdown & CSV export, or land as plain text.

| Notion feature | What happens |
| --- | --- |
| **Databases** (tables, boards, calendars, galleries, timelines) | Each exports as a stub page, a `<name>_all.csv` of the whole table, and a folder holding one markdown file per row. The per-row pages are first-class docs, but there is no support for viewing complete CSV tables. |
| **Relations, rollups, formulas** | Not represented in the Markdown & CSV export. |
| **Database row properties** | Exported as plain `Key: Value` lines under the page title, not YAML frontmatter. |
| **Callouts** | Exported as raw `<aside>` HTML. The content is preserved, but it isn't converted to OpenKnowledge's native callout (`> [!note]`), so it doesn't pick up callout styling. |
| **Toggles and toggle headings** | Nested content is preserved, but the collapsible toggle wrapper is lost. |
| **Synced blocks** | Written out as ordinary content wherever they appear; the link between copies is gone. |
| **Web bookmarks and embeds** (Figma, video, PDF, etc.) | Converted to plain links. The rich preview card or inline embed is gone. Uploaded files download alongside the page. |
| **Links between pages** | Notion percent-encodes every link target (every exported filename ends in ` <id>`). Fixable in bulk after import by decoding `%20` and trimming the trailing ` <id>`. |
| **Pasted or inline images** | Some images export inline as base64 `data:` URIs instead of downloaded files. They don't render and add very large lines to the file. Downloaded `![alt](file.png)` images are unaffected. |
| **Page icons and cover images** | Not exported. |
| **Comments, page history, and backlinks** | Not included in the export. |

## Clean up after import (optional)

Most of the rough edges above are mechanical to fix, and the AI agent connected to your project can do it in bulk through OpenKnowledge's [MCP tools](/docs/reference/mcp). Paste any of these prompts to your agent:

- **Decode internal links.** _"In every markdown file, decode `%20` and other percent-escapes in relative `.md` link targets so the links resolve."_ Do this first so the next step can rewrite links.
- **Drop the Notion ID from filenames.** _"Rename every page to remove the trailing ` <32-character hex id>` from its filename, and verify the links that point to it."_
- **Promote row properties to frontmatter.** _"On pages exported from databases, turn the `Key: Value` lines under the title into a YAML frontmatter block."_ They then show up in the [Properties panel](/docs/features/editor#properties).
- **Convert callouts.** _"Replace each `<aside>…</aside>` block with an OpenKnowledge callout (`> [!note]`)."_
- **Remove inline base64 images.** _"Find `[](data:image/...;base64,...)` blobs and remove them — they don't render and bloat the file."_
A number of Notion features don't survive the Markdown & CSV export, or land as plain text. Many can be fixed by the [`ok migrate notion <folder-path>`](#clean-up-the-export-with-ok-migrate-notion) command.

| Notion feature | Exported result | Fixed by CLI migration command |
| --- | --- | --- |
| **Databases** (tables, boards, calendars, galleries, timelines) | Each database exports as a `<name>_all.csv` file and a folder holding one markdown file per row. Full-page databases also get a stub page. | ✅ Creates a Markdown table page for every CSV. The `.csv` files are not deleted unless you pass `--remove-csv`. |
| **Database row properties** | Exported as plain `Key: Value` lines under the page title, not YAML frontmatter. | ✅ Lifted into YAML frontmatter so they show in the [Properties panel](/docs/features/editor#properties). |
| **Callouts** | Exported as raw `<aside>` HTML, so they don't pick up OpenKnowledge's callout styling. | ✅ Converted to native `> [!note]` callouts. |
| **Links between pages** | Link targets are percent-encoded, and once decoded contain spaces. | ✅ Decoded and angle-wrapped so they render and resolve. |
| **Pasted or inline images** | Some images export inline as un-rendered base64 `data:` URIs instead of files. | ✅ Extracted to image files (or dropped with `--strip-base64`). |
| **Relations, rollups, formulas** | Not represented in the Markdown & CSV export. | — |
| **Toggles and toggle headings** | Nested content is preserved, but the collapsible toggle wrapper is lost. | — |
| **Synced blocks** | Written out as ordinary content wherever they appear; the link between copies is gone. | — |
| **Web bookmarks and embeds** (Figma, video, PDF, etc.) | Converted to plain links. The rich preview card or inline embed is gone. Uploaded files download alongside the page. | — |
| **Page icons and cover images** | Not exported. | — |
| **Comments, page history, and backlinks** | Not included in the export. | — |

## See also

Expand Down
2 changes: 2 additions & 0 deletions docs/src/components/icons/brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { CursorIcon } from './cursor';
import { GitHubIcon } from './github';
import { McpIcon } from './mcp';
import { ObsidianIcon } from './obsidian';
import { OpenClawIcon } from './openclaw';
import { OpenCodeIcon } from './opencode';

export const brandIcons = {
Claude: ClaudeIcon,
Cursor: CursorIcon,
Codex: CodexIcon,
OpenCode: OpenCodeIcon,
OpenClaw: OpenClawIcon,
GitHub: GitHubIcon,
Obsidian: ObsidianIcon,
MCP: McpIcon,
Expand Down
46 changes: 46 additions & 0 deletions docs/src/components/icons/openclaw.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { SVGProps } from 'react';

export function OpenClawIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
role="img"
aria-label="OpenClaw icon"
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
fill="none"
viewBox="0 0 120 120"
{...props}
>
<title>OpenClaw icon</title>
<mask id="openclaw-eyes" maskUnits="userSpaceOnUse" x={0} y={0} width={120} height={120}>
<rect width={120} height={120} fill="white" />
<circle cx={45} cy={35} r={6} fill="black" />
<circle cx={75} cy={35} r={6} fill="black" />
</mask>
<g fill="currentColor" mask="url(#openclaw-eyes)">
{/* body */}
<path d="M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z" />
{/* left claw */}
<path d="M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z" />
{/* right claw */}
<path d="M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z" />
</g>
{/* antennae */}
<path
d="M45 15 Q35 5 30 8"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
/>
<path
d="M75 15 Q85 5 90 8"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
/>
</svg>
);
}
3 changes: 3 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { diagnoseCommand } from './commands/diagnose.ts';
import { embeddingsCommand } from './commands/embeddings/index.ts';
import { initCommand } from './commands/init.ts';
import { mcpCommand } from './commands/mcp.ts';
import { migrateCommand } from './commands/migrate.ts';
import { openCommand } from './commands/open.ts';
import { previewCommand } from './commands/preview.ts';
import { psCommand } from './commands/ps.ts';
Expand Down Expand Up @@ -128,6 +129,8 @@ program.addCommand(initCommand());

program.addCommand(seedCommand());

program.addCommand(migrateCommand());

program.addCommand(coworkCommand(), { hidden: true });

program.addCommand(repairSkillsCommand());
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/src/commands/migrate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { makeTree } from './migrate/notion/mktree.test-helper.ts';
import { migrateCommand } from './migrate.ts';

const ID = '30545f35b5ad80a38049d283dae66763';
const R1 = '11111111111111111111111111111111';
const R2 = '22222222222222222222222222222222';

async function run(args: string[]): Promise<{ code: number; stdout: string }> {
process.exitCode = 0;
const originalLog = console.log;
const originalErr = console.error;
let stdout = '';
console.log = (msg?: unknown) => {
stdout += `${String(msg)}\n`;
};
console.error = () => {};
try {
await migrateCommand().parseAsync(['notion', ...args], { from: 'user' });
} finally {
console.log = originalLog;
console.error = originalErr;
}
const code = process.exitCode ?? 0;
process.exitCode = 0; // do not leak into the test runner's own exit
return { code, stdout };
}

afterEach(() => {
process.exitCode = 0;
});

describe('ok migrate notion — exit codes', () => {
test('exit 2 for an unknown --only transform', async () => {
const root = makeTree({ 'page.md': '# P\n' });
expect((await run([root, '--only', 'bogus'])).code).toBe(2);
});

test('exit 2 for a directory that is not a Notion export', async () => {
const root = makeTree({ 'notes.md': '# Notes\n\nPlain.\n' });
expect((await run([root])).code).toBe(2);
});

test('exit 2 with a machine-readable refusal on --json', async () => {
const root = makeTree({ 'notes.md': '# Notes\n' });
const { code, stdout } = await run([root, '--json']);
expect(code).toBe(2);
expect(JSON.parse(stdout)).toMatchObject({ refused: true, reason: 'not-a-notion-export' });
});

test('exit 1 when there is nothing to do', async () => {
const root = makeTree({ [`Clean ${ID}.md`]: '# Clean\n\nNo links here.\n' });
expect((await run([root])).code).toBe(1);
});

test('exit 0 for a dry-run that has changes to preview', async () => {
const root = makeTree({ [`Home ${ID}.md`]: '# Home\n\n[x](Foo%20Bar.md)\n' });
expect((await run([root])).code).toBe(0);
});

test('exit 3 when --apply completes with ambiguous title links', async () => {
const root = makeTree({
[`DB ${ID}_all.csv`]: 'Title,X\nNotes,1\nNotes,2\n',
[`DB/Notes ${R1}.md`]: '# Notes\n',
[`DB/Notes ${R2}.md`]: '# Notes\n',
});
expect((await run([root, '--apply'])).code).toBe(3);
});

test('exit 3 when --apply produces a wide table (>15 columns)', async () => {
const header = Array.from({ length: 16 }, (_, i) => `C${i}`).join(',');
const row = Array.from({ length: 16 }, (_, i) => `v${i}`).join(',');
const root = makeTree({ [`DB ${ID}_all.csv`]: `${header}\n${row}\n` });
expect((await run([root, '--apply'])).code).toBe(3);
});
});
Loading