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
24 changes: 22 additions & 2 deletions apps/web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -914,11 +914,31 @@
},
"editor": {
"emptyState": "Select a note or create a new one to get started.",
"titlePlaceholder": "Untitled"
"titlePlaceholder": "Untitled",
"saving": "Saving…",
"ready": "Ready",
"saved": "Saved {time}",
"stats": "{words} words · {read} min read",
"template": "Template",
"applyTemplate": "Apply template",
"exportMdLabel": ".md",
"exportMd": "Export as Markdown",
"exportHtmlLabel": ".html",
"exportHtml": "Export as HTML",
"focusMode": "Focus mode",
"exitFocusMode": "Exit focus mode",
"save": "Save",
"addTag": "Add tag…",
"removeTag": "Remove tag {tag}",
"loading": "Loading…",
"searchHint": "to search",
"focusHint": "focus mode",
"notePath": "Note path"
},
"context": {
"authRequiredError": "User not authenticated",
"defaultTitle": "Untitled"
"defaultTitle": "Untitled",
"cloudSyncImageError": "Turn on Cloud Sync to store note images. Text notes stay local."
}
},
"PasswordManager": {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3",
"@microsoft/clarity": "^1.0.2",
"@milkdown/crepe": "7.21.3",
"@milkdown/kit": "7.21.3",
"@monaco-editor/react": "^4.7.0",
"@peculiar/x509": "^2.0.0",
"@radix-ui/react-accordion": "^1.2.12",
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/app/notes/types/Note.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
export interface Note {
id: string;
title: string;
content: unknown; // JSON content from rich editor
// Markdown string. Legacy notes may still hold the old rich-editor tree
// (a JSON object); those are converted to markdown on read and re-saved as
// a string on next edit. Typed `unknown` so both shapes are handled safely.
content: unknown;
parentId: string | null;
icon?: string;
pinned?: boolean;
Expand Down
138 changes: 138 additions & 0 deletions apps/web/src/app/app/notes/utils/__tests__/noteContentUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import {
noteContentToMarkdown,
contentToMarkdown,
extractPlainText,
countWords,
readingTimeMinutes,
} from "../noteContentUtils";

// Helpers to build legacy rich-editor tree nodes concisely.
const text = (type: string, content: string, extra: Record<string, unknown> = {}) => ({
type,
content,
attributes: {},
...extra,
});
const container = (children: unknown[], attributes: Record<string, unknown> = {}) => ({
type: "container",
children,
attributes,
});

describe("noteContentToMarkdown", () => {
it("passes markdown strings through untouched", () => {
expect(noteContentToMarkdown("# Hello\n\nworld")).toBe("# Hello\n\nworld");
});

it("returns empty string for null/undefined/garbage", () => {
expect(noteContentToMarkdown(null)).toBe("");
expect(noteContentToMarkdown(undefined)).toBe("");
expect(noteContentToMarkdown(42)).toBe("");
});

it("converts a legacy tree of headings/paragraphs/quotes", () => {
const tree = container([
text("h1", "Title"),
text("p", "A paragraph."),
text("h2", "Sub"),
text("blockquote", "quoted"),
text("hr", ""),
]);
expect(noteContentToMarkdown(tree)).toBe(
"# Title\nA paragraph.\n## Sub\n> quoted\n---"
);
});

it("converts bullet list items", () => {
const tree = container([text("li", "one"), text("li", "two")]);
expect(noteContentToMarkdown(tree)).toBe("- one\n- two");
});

it("numbers ordered lists via listType attribute", () => {
const tree = container(
[text("li", "first"), text("li", "second")],
{ listType: "ordered" }
);
expect(noteContentToMarkdown(tree)).toBe("1. first\n2. second");
});

it("converts images and links", () => {
const tree = container([
text("img", "", { attributes: { src: "https://x/y.png", alt: "pic" } }),
text("a", "click", { attributes: { href: "https://x" } }),
]);
expect(noteContentToMarkdown(tree)).toBe("![pic](https://x/y.png)\n[click](https://x)");
});

it("converts inline-formatted children", () => {
const tree = container([
{
type: "p",
attributes: {},
children: [
{ content: "bold", bold: true },
{ content: " and " },
{ content: "code", code: true },
],
},
]);
expect(noteContentToMarkdown(tree)).toBe("**bold** and `code`");
});

it("converts code blocks", () => {
const tree = container([text("pre", "const a = 1")]);
expect(noteContentToMarkdown(tree)).toBe("```\nconst a = 1\n```");
});

it("converts a table to GFM", () => {
const tree = container([
{
type: "table",
attributes: {},
children: [
{
type: "tbody",
attributes: {},
children: [
{ type: "tr", attributes: {}, children: [text("p", "A"), text("p", "B")] },
{ type: "tr", attributes: {}, children: [text("p", "1"), text("p", "2")] },
],
},
],
},
]);
expect(noteContentToMarkdown(tree)).toBe(
"| A | B |\n| --- | --- |\n| 1 | 2 |"
);
});
});

describe("contentToMarkdown (export)", () => {
it("prepends the title as an h1", () => {
expect(contentToMarkdown("My Note", "body text")).toBe("# My Note\n\nbody text");
});
it("works with legacy trees too", () => {
const tree = container([text("p", "hi")]);
expect(contentToMarkdown("T", tree)).toBe("# T\n\nhi");
});
});

describe("extractPlainText", () => {
it("strips markdown syntax", () => {
const md = "# H\n\nsome **bold** and `code` and [link](https://x) and ![i](y.png)\n\n- a\n- b";
const plain = extractPlainText(md);
expect(plain).toContain("some bold and code and link");
expect(plain).not.toContain("**");
expect(plain).not.toContain("![");
expect(plain).not.toContain("#");
});
it("extracts text from legacy trees", () => {
const tree = container([text("h1", "Hello"), text("p", "world")]);
expect(extractPlainText(tree)).toBe("Hello world");
});
it("counts words and reading time", () => {
expect(countWords("one two three")).toBe(3);
expect(readingTimeMinutes(0)).toBe(1);
expect(readingTimeMinutes(400)).toBe(2);
});
});
Loading