Skip to content

Commit d17d369

Browse files
committed
feat: export notes as markdown
1 parent 4a6cca5 commit d17d369

12 files changed

Lines changed: 559 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
## Unreleased
44

5+
## [0.5.2] - 2026-06-08
6+
7+
### Markdown Export
8+
9+
- Added single-note Markdown export from the note detail page for readable backup and sharing.
10+
- Exported notes now use minimal frontmatter with title, optional tags, optional source type, and local export time only.
11+
- Localized exported Markdown section headings so Simplified Chinese and English UI modes generate matching document labels.
12+
- Added a shared Markdown export helper with filename sanitization, safe YAML values, and robust code fence handling.
13+
- Reused shadcn/ui Button primitives for note detail actions and added shadcn/sonner-based export failure feedback.
14+
- Added regression coverage for Markdown generation, localized export labels, i18n coverage, and note detail UI wiring.
15+
516
## [0.5.1] - 2026-06-06
617

718
### Desktop Updates

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"react-router-dom": "^7.14.2",
3232
"remark-gfm": "^4.0.1",
3333
"sigma": "^3.0.3",
34+
"sonner": "^2.0.7",
3435
"tailwind-merge": "^3.5.0",
3536
"tailwindcss": "^4.2.4",
3637
"tw-animate-css": "^1.4.0"

client/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ThemeProvider } from '@/providers/ThemeProvider.tsx';
66
import '@/i18n';
77
import { Layout } from '@/components/Layout.tsx';
88
import { AuthGate } from '@/components/AuthGate.tsx';
9+
import { Toaster } from '@/components/ui/sonner';
910
import { TooltipProvider } from '@/components/ui/tooltip';
1011

1112
const DashboardPage = lazy(() => import('@/pages/Dashboard.tsx').then((module) => ({ default: module.Dashboard })));
@@ -54,6 +55,7 @@ export default function App() {
5455
</Route>
5556
</Routes>
5657
</BrowserRouter>
58+
<Toaster position="top-right" richColors />
5759
</TooltipProvider>
5860
</ThemeProvider>
5961
</QueryClientProvider>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Toaster as Sonner, type ToasterProps } from "sonner"
2+
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
3+
import { useTheme } from "@/providers/useTheme"
4+
5+
const Toaster = ({ ...props }: ToasterProps) => {
6+
const { themeName } = useTheme()
7+
const sonnerTheme: ToasterProps["theme"] = themeName === "dawn-haze" ? "light" : "dark"
8+
9+
return (
10+
<Sonner
11+
theme={sonnerTheme}
12+
className="toaster group"
13+
icons={{
14+
success: (
15+
<CircleCheckIcon className="size-4" />
16+
),
17+
info: (
18+
<InfoIcon className="size-4" />
19+
),
20+
warning: (
21+
<TriangleAlertIcon className="size-4" />
22+
),
23+
error: (
24+
<OctagonXIcon className="size-4" />
25+
),
26+
loading: (
27+
<Loader2Icon className="size-4 animate-spin" />
28+
),
29+
}}
30+
style={
31+
{
32+
"--normal-bg": "var(--popover)",
33+
"--normal-text": "var(--popover-foreground)",
34+
"--normal-border": "var(--border)",
35+
"--border-radius": "var(--radius)",
36+
} as React.CSSProperties
37+
}
38+
toastOptions={{
39+
classNames: {
40+
toast: "cn-toast",
41+
},
42+
}}
43+
{...props}
44+
/>
45+
)
46+
}
47+
48+
export { Toaster }

client/src/i18n/en.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,16 @@
150150
"memory": "Memory"
151151
}
152152
},
153+
"note_detail": {
154+
"back_to_notes": "Back to notes",
155+
"export_markdown": "Export Markdown",
156+
"export_failed": "Markdown export failed",
157+
"markdown_section": {
158+
"summary": "Summary",
159+
"key_conclusions": "Key Conclusions",
160+
"code_snippets": "Code Snippets"
161+
}
162+
},
153163
"conversations_total": "{{count}} conversations total",
154164
"search_results_count": "{{count}} results found",
155165
"import_success": "Imported {{count}}",

client/src/i18n/zh.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,16 @@
150150
"memory": "任务记忆"
151151
}
152152
},
153+
"note_detail": {
154+
"back_to_notes": "返回笔记列表",
155+
"export_markdown": "导出 Markdown",
156+
"export_failed": "导出 Markdown 失败",
157+
"markdown_section": {
158+
"summary": "摘要",
159+
"key_conclusions": "关键结论",
160+
"code_snippets": "代码片段"
161+
}
162+
},
153163
"conversations_total": "共 {{count}} 条对话",
154164
"search_results_count": "找到 {{count}} 条相关结果",
155165
"import_success": "导入 {{count}} 条",

client/src/lib/markdown-export.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
type NoteCodeSnippet = {
2+
language?: unknown;
3+
description?: unknown;
4+
code?: unknown;
5+
};
6+
7+
type NormalizedCodeSnippet = {
8+
language: string;
9+
description: string;
10+
code: string;
11+
};
12+
13+
export type NoteMarkdownExportInput = {
14+
id?: unknown;
15+
title?: unknown;
16+
summary?: unknown;
17+
key_conclusions?: unknown;
18+
code_snippets?: unknown;
19+
source_type?: unknown;
20+
tags?: unknown;
21+
};
22+
23+
type NoteMarkdownExportOptions = {
24+
exportedAt?: Date | string;
25+
labels?: Partial<NoteMarkdownExportLabels>;
26+
};
27+
28+
export type NoteMarkdownExport = {
29+
filename: string;
30+
content: string;
31+
};
32+
33+
type NoteMarkdownExportLabels = {
34+
summary: string;
35+
keyConclusions: string;
36+
codeSnippets: string;
37+
};
38+
39+
const DEFAULT_LABELS: NoteMarkdownExportLabels = {
40+
summary: "Summary",
41+
keyConclusions: "Key Conclusions",
42+
codeSnippets: "Code Snippets",
43+
};
44+
45+
function cleanString(value: unknown) {
46+
return typeof value === "string" ? value.trim() : "";
47+
}
48+
49+
function cleanStringArray(value: unknown) {
50+
if (!Array.isArray(value)) return [];
51+
return value
52+
.map((item) => cleanString(item))
53+
.filter((item) => item.length > 0);
54+
}
55+
56+
function yamlScalar(value: string) {
57+
if (/^[A-Za-z0-9_-]+(?: [A-Za-z0-9_-]+)*$/.test(value)) {
58+
return value;
59+
}
60+
61+
return `"${value
62+
.replace(/\\/g, "\\\\")
63+
.replace(/\n/g, "\\n")
64+
.replace(/\r/g, "\\r")
65+
.replace(/"/g, '\\"')}"`;
66+
}
67+
68+
function pad2(value: number) {
69+
return String(value).padStart(2, "0");
70+
}
71+
72+
function toDate(value: Date | string | undefined) {
73+
if (value instanceof Date) return value;
74+
if (typeof value === "string") {
75+
const parsed = new Date(value);
76+
if (!Number.isNaN(parsed.getTime())) return parsed;
77+
}
78+
return new Date();
79+
}
80+
81+
function formatLocalExportedAt(value: Date | string | undefined) {
82+
const date = toDate(value);
83+
const offsetMinutes = -date.getTimezoneOffset();
84+
const offsetSign = offsetMinutes >= 0 ? "+" : "-";
85+
const absoluteOffset = Math.abs(offsetMinutes);
86+
const offsetHours = Math.floor(absoluteOffset / 60);
87+
const offsetRemainderMinutes = absoluteOffset % 60;
88+
89+
return [
90+
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
91+
`${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`,
92+
`GMT${offsetSign}${pad2(offsetHours)}:${pad2(offsetRemainderMinutes)}`,
93+
].join(" ");
94+
}
95+
96+
function slugify(value: string) {
97+
return value
98+
.toLowerCase()
99+
.replace(/[^a-z0-9]+/g, "-")
100+
.replace(/^-+|-+$/g, "")
101+
.replace(/-{2,}/g, "-");
102+
}
103+
104+
function maxBacktickRun(value: string) {
105+
return Math.max(0, ...Array.from(value.matchAll(/`+/g), (match) => match[0].length));
106+
}
107+
108+
function codeFenceFor(code: string) {
109+
return "`".repeat(Math.max(3, maxBacktickRun(code) + 1));
110+
}
111+
112+
function normalizeLanguage(value: unknown) {
113+
const language = cleanString(value).split(/\s+/)[0];
114+
return language || "text";
115+
}
116+
117+
function normalizeCodeSnippets(value: unknown): NormalizedCodeSnippet[] {
118+
if (!Array.isArray(value)) return [];
119+
return value
120+
.filter((item): item is NoteCodeSnippet => Boolean(item && typeof item === "object"))
121+
.map((item) => ({
122+
language: normalizeLanguage(item.language),
123+
description: cleanString(item.description),
124+
code: typeof item.code === "string" ? item.code : "",
125+
}))
126+
.filter((item) => item.code.length > 0);
127+
}
128+
129+
function buildFrontmatter(input: {
130+
title: string;
131+
tags: string[];
132+
sourceType: string;
133+
exportedAt: string;
134+
}) {
135+
const lines = ["---", `title: ${yamlScalar(input.title)}`];
136+
137+
if (input.tags.length > 0) {
138+
lines.push("tags:");
139+
for (const tag of input.tags) {
140+
lines.push(` - ${yamlScalar(tag)}`);
141+
}
142+
}
143+
144+
if (input.sourceType) {
145+
lines.push(`source_type: ${yamlScalar(input.sourceType)}`);
146+
}
147+
148+
lines.push(`exported_at: ${yamlScalar(input.exportedAt)}`, "---");
149+
return lines.join("\n");
150+
}
151+
152+
export function createNoteMarkdownExport(
153+
note: NoteMarkdownExportInput,
154+
options: NoteMarkdownExportOptions = {},
155+
): NoteMarkdownExport {
156+
const id = typeof note.id === "number" || typeof note.id === "string" ? String(note.id) : "note";
157+
const title = cleanString(note.title) || `Note ${id}`;
158+
const summary = cleanString(note.summary);
159+
const keyConclusions = cleanStringArray(note.key_conclusions);
160+
const codeSnippets = normalizeCodeSnippets(note.code_snippets);
161+
const tags = cleanStringArray(note.tags);
162+
const sourceType = cleanString(note.source_type);
163+
const exportedAt = formatLocalExportedAt(options.exportedAt);
164+
const labels = { ...DEFAULT_LABELS, ...options.labels };
165+
const slug = slugify(title);
166+
const filename = slug ? `${id}-${slug}.md` : `note-${id}.md`;
167+
168+
const sections = [
169+
buildFrontmatter({ title, tags, sourceType, exportedAt }),
170+
`# ${title}`,
171+
];
172+
173+
if (summary) {
174+
sections.push(`## ${labels.summary}\n\n${summary}`);
175+
}
176+
177+
if (keyConclusions.length > 0) {
178+
sections.push(`## ${labels.keyConclusions}\n\n${keyConclusions.map((item) => `- ${item}`).join("\n")}`);
179+
}
180+
181+
if (codeSnippets.length > 0) {
182+
const snippets = codeSnippets.map((snippet) => {
183+
const fence = codeFenceFor(snippet.code);
184+
const heading = snippet.description ? `### ${snippet.description}\n\n` : "";
185+
return `${heading}${fence}${snippet.language}\n${snippet.code}\n${fence}`;
186+
});
187+
sections.push(`## ${labels.codeSnippets}\n\n${snippets.join("\n\n")}`);
188+
}
189+
190+
return {
191+
filename,
192+
content: `${sections.join("\n\n")}\n`,
193+
};
194+
}
195+
196+
export function downloadMarkdownFile(exported: NoteMarkdownExport) {
197+
const blob = new Blob([exported.content], { type: "text/markdown;charset=utf-8" });
198+
const url = URL.createObjectURL(blob);
199+
const link = document.createElement("a");
200+
link.href = url;
201+
link.download = exported.filename;
202+
document.body.appendChild(link);
203+
link.click();
204+
link.remove();
205+
URL.revokeObjectURL(url);
206+
}

client/src/lib/notify.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { toast } from "sonner";
2+
3+
export const notify = {
4+
error(message: string) {
5+
toast.error(message);
6+
},
7+
success(message: string) {
8+
toast.success(message);
9+
},
10+
info(message: string) {
11+
toast.info(message);
12+
},
13+
warning(message: string) {
14+
toast.warning(message);
15+
},
16+
};

0 commit comments

Comments
 (0)