Sort a notebook, import Markdown back, and stop the page shrinking in Draw
The paper measurably shrank on switching to Draw — 733px to 460px on the same screen. The editor was a flex item at its default `flex: 0 1 auto`, so its width was its widest row's: the toolbar. Swapping a dozen formatting buttons for six drawing ones collapsed it. It now fills the pane, so the width is a property of the window. Each notebook remembers how its notes are ordered, on the server rather than per device. The manual order has to survive being looked away from, so `reindex` renumbers every note in the notebook rather than the filtered view, and dragging is switched off while a search narrows the list. Reordering has its own route: `PUT /notes/:id` would bump `updated_at` and snapshot a version, so a drag would move a note to the top of "last changed" and spend a slot of its history. Markdown and our own ZIP archives come back as notes, with their images re-uploaded as attachments. A drawing returns as the PNG it was exported as — vectors are not recoverable from pixels — and the import says so rather than leaving it to be discovered. Writing the parser turned up a real bug in the exporter: blocks were emitted with no blank line between them, so a list after a list became one list and a quote after an item was read as part of it. Creating or importing from "All Notes" now asks which notebook instead of silently taking the first one. The web-link prompt is a real dialog: a bare domain becomes https, javascript: is refused with a reason. The PDF viewer's hide-drawing control is a labelled button rather than a filled-or-hollow dot, and the export icon points down into a tray like every other download. 186 unit tests, 179 browser checks across eleven suites, including a new `offline` suite that loads the app cold with the network off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3acb8b66a4
commit
0a0864c58b
27 files changed
+2962
-86
No files matched your search
+44
-1
@@ -339,6 +339,14 @@ if (!tableColumns("notebooks").includes("max_versions")) {
|
|||||||
db.exec("ALTER TABLE notebooks ADD COLUMN max_versions INTEGER");
|
db.exec("ALTER TABLE notebooks ADD COLUMN max_versions INTEGER");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* How a notebook's notes are ordered on screen. Stored on the server rather
|
||||||
|
* than per device: it is a property of the notebook, and finding a notebook
|
||||||
|
* sorted differently on the phone than on the desktop is its own small
|
||||||
|
* annoyance. Null means the app's default, newest first. */
|
||||||
|
if (!tableColumns("notebooks").includes("sort_mode")) {
|
||||||
|
db.exec("ALTER TABLE notebooks ADD COLUMN sort_mode TEXT");
|
||||||
|
}
|
||||||
|
|
||||||
if (!tableColumns("notes").includes("pdf_attachment_id")) {
|
if (!tableColumns("notes").includes("pdf_attachment_id")) {
|
||||||
db.exec("ALTER TABLE notes ADD COLUMN pdf_attachment_id TEXT");
|
db.exec("ALTER TABLE notes ADD COLUMN pdf_attachment_id TEXT");
|
||||||
}
|
}
|
||||||
@@ -769,8 +777,11 @@ app.post("/api/notebooks", authMiddleware, (req, res) => {
|
|||||||
res.json({ id, name, coverIndex });
|
res.json({ id, name, coverIndex });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The orders the note list offers. Anything else is refused rather than stored. */
|
||||||
|
const SORT_MODES = ["manual", "updated", "oldest", "created", "title"];
|
||||||
|
|
||||||
app.put("/api/notebooks/:id", authMiddleware, (req, res) => {
|
app.put("/api/notebooks/:id", authMiddleware, (req, res) => {
|
||||||
const { name, coverIndex, sortOrder, maxVersions } = req.body;
|
const { name, coverIndex, sortOrder, maxVersions, sortMode } = req.body;
|
||||||
const nb = db.prepare("SELECT * FROM notebooks WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
const nb = db.prepare("SELECT * FROM notebooks WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||||
if (!nb) return res.status(404).json({ error: "Not found" });
|
if (!nb) return res.status(404).json({ error: "Not found" });
|
||||||
|
|
||||||
@@ -784,11 +795,18 @@ app.put("/api/notebooks/:id", authMiddleware, (req, res) => {
|
|||||||
versions = Math.min(Math.round(n), MAX_VERSIONS_CEILING);
|
versions = Math.min(Math.round(n), MAX_VERSIONS_CEILING);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sortMode !== undefined && sortMode !== null && !SORT_MODES.includes(sortMode)) {
|
||||||
|
return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` });
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare("UPDATE notebooks SET name = COALESCE(?, name), cover_index = COALESCE(?, cover_index), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), version = version + 1 WHERE id = ?")
|
db.prepare("UPDATE notebooks SET name = COALESCE(?, name), cover_index = COALESCE(?, cover_index), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), version = version + 1 WHERE id = ?")
|
||||||
.run(name, coverIndex, sortOrder, req.params.id);
|
.run(name, coverIndex, sortOrder, req.params.id);
|
||||||
if (maxVersions !== undefined) {
|
if (maxVersions !== undefined) {
|
||||||
db.prepare("UPDATE notebooks SET max_versions = ? WHERE id = ?").run(versions, req.params.id);
|
db.prepare("UPDATE notebooks SET max_versions = ? WHERE id = ?").run(versions, req.params.id);
|
||||||
}
|
}
|
||||||
|
if (sortMode !== undefined) {
|
||||||
|
db.prepare("UPDATE notebooks SET sort_mode = ? WHERE id = ?").run(sortMode, req.params.id);
|
||||||
|
}
|
||||||
|
|
||||||
broadcastToUser(req.userId, { type: "notebook_updated", data: { id: req.params.id, name, coverIndex } }, req.deviceId);
|
broadcastToUser(req.userId, { type: "notebook_updated", data: { id: req.params.id, name, coverIndex } }, req.deviceId);
|
||||||
res.json({ success: true, defaultMaxVersions: MAX_VERSIONS_PER_NOTE });
|
res.json({ success: true, defaultMaxVersions: MAX_VERSIONS_PER_NOTE });
|
||||||
@@ -942,6 +960,31 @@ app.get("/api/notes/:id", authMiddleware, (req, res) => {
|
|||||||
res.json(note);
|
res.json(note);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's own order for a notebook's notes.
|
||||||
|
*
|
||||||
|
* Deliberately not part of `PUT /api/notes/:id`. That route bumps `updated_at`
|
||||||
|
* and snapshots a version — so dragging a note into place would move it to the
|
||||||
|
* top of "last changed" and burn a slot of its history, for a change that is not
|
||||||
|
* to the note at all. This writes `sort_order` and nothing else.
|
||||||
|
*/
|
||||||
|
const MAX_REORDER = 5000;
|
||||||
|
app.post("/api/notes/reorder", authMiddleware, (req, res) => {
|
||||||
|
const ids = Array.isArray(req.body?.ids) ? req.body.ids.slice(0, MAX_REORDER) : null;
|
||||||
|
if (!ids) return res.status(400).json({ error: "ids must be an array" });
|
||||||
|
|
||||||
|
const write = db.transaction(() => {
|
||||||
|
const update = db.prepare("UPDATE notes SET sort_order = ? WHERE id = ? AND user_id = ?");
|
||||||
|
ids.forEach((id, index) => {
|
||||||
|
if (typeof id === "string" && id) update.run(index, id, req.userId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
write();
|
||||||
|
|
||||||
|
broadcastToUser(req.userId, { type: "notes_reordered", data: { count: ids.length } }, req.deviceId);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/notes", authMiddleware, (req, res) => {
|
app.post("/api/notes", authMiddleware, (req, res) => {
|
||||||
const { type, title, content, notebookId, starred, pdfAttachmentId } = req.body;
|
const { type, title, content, notebookId, starred, pdfAttachmentId } = req.body;
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
|
|||||||
+4
-4
@@ -100,7 +100,7 @@ try {
|
|||||||
|
|
||||||
/* ── single note as Markdown ── */
|
/* ── single note as Markdown ── */
|
||||||
await openExportDialog(p);
|
await openExportDialog(p);
|
||||||
await p.getByRole("button", { name: /Markdown/ }).click();
|
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
||||||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||||
await p.waitForTimeout(600);
|
await p.waitForTimeout(600);
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ try {
|
|||||||
|
|
||||||
await clearDownloads(p);
|
await clearDownloads(p);
|
||||||
await openExportDialog(p);
|
await openExportDialog(p);
|
||||||
await p.getByRole("button", { name: /Markdown/ }).click();
|
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
||||||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||||
await p.waitForTimeout(800);
|
await p.waitForTimeout(800);
|
||||||
files = await downloads(p);
|
files = await downloads(p);
|
||||||
@@ -184,7 +184,7 @@ try {
|
|||||||
await p.waitForTimeout(500);
|
await p.waitForTimeout(500);
|
||||||
check((await p.getByRole("button", { name: /PNG-Bild/ }).count()) > 0, "PNG is offered for the drawing");
|
check((await p.getByRole("button", { name: /PNG-Bild/ }).count()) > 0, "PNG is offered for the drawing");
|
||||||
check(
|
check(
|
||||||
(await p.getByRole("button", { name: /Markdown/ }).count()) === 0,
|
(await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).count()) === 0,
|
||||||
"Markdown is not offered for a drawing, since it cannot hold one"
|
"Markdown is not offered for a drawing, since it cannot hold one"
|
||||||
);
|
);
|
||||||
await p.getByRole("button", { name: /PNG-Bild/ }).click();
|
await p.getByRole("button", { name: /PNG-Bild/ }).click();
|
||||||
@@ -208,7 +208,7 @@ try {
|
|||||||
await p.getByRole("button", { name: /Nur den Notiztext/ }).click();
|
await p.getByRole("button", { name: /Nur den Notiztext/ }).click();
|
||||||
await p.waitForTimeout(500);
|
await p.waitForTimeout(500);
|
||||||
await clearDownloads(p);
|
await clearDownloads(p);
|
||||||
await p.getByRole("button", { name: /Markdown/ }).click();
|
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
||||||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||||
await p.waitForTimeout(800);
|
await p.waitForTimeout(800);
|
||||||
files = await downloads(p);
|
files = await downloads(p);
|
||||||
|
|||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
/* Reading without a connection.
|
||||||
|
*
|
||||||
|
* The claim being tested is the one the app makes by being a PWA at all: a note
|
||||||
|
* you have seen is a note you can still read on a train. That has two halves,
|
||||||
|
* and both have to hold — the application itself has to load from the cache, and
|
||||||
|
* the notes have to already be on the device rather than one request away.
|
||||||
|
*
|
||||||
|
* "Offline" here means the browser context is switched offline, which is what
|
||||||
|
* losing a connection actually looks like to the page: requests fail, the
|
||||||
|
* service worker still answers, IndexedDB still works.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
check,
|
||||||
|
createNote,
|
||||||
|
createNotebook,
|
||||||
|
launch,
|
||||||
|
report,
|
||||||
|
setTitle,
|
||||||
|
typeInEditor,
|
||||||
|
withFreshUser,
|
||||||
|
} from "./lib.mjs";
|
||||||
|
|
||||||
|
const browser = await launch();
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await withFreshUser(browser, "offline");
|
||||||
|
const p = session.page;
|
||||||
|
const context = p.context();
|
||||||
|
|
||||||
|
await createNotebook(p, "Unterwegs");
|
||||||
|
await p.locator("button").filter({ hasText: "Unterwegs" }).first().click();
|
||||||
|
await p.waitForTimeout(700);
|
||||||
|
|
||||||
|
for (const [title, body] of [
|
||||||
|
["Reiseplan", "Abfahrt um sieben Uhr vom Hauptbahnhof."],
|
||||||
|
["Einkauf", "Milch, Brot, Kaffee."],
|
||||||
|
]) {
|
||||||
|
await createNote(p);
|
||||||
|
await setTitle(p, title);
|
||||||
|
await typeInEditor(p, body);
|
||||||
|
await p.waitForTimeout(900);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The service worker has to be in charge before it can answer anything. */
|
||||||
|
const controlled = await p.evaluate(async () => {
|
||||||
|
if (!("serviceWorker" in navigator)) return "unsupported";
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
return reg.active ? "active" : "no active worker";
|
||||||
|
});
|
||||||
|
check(controlled === "active", "a service worker is installed and running", controlled);
|
||||||
|
|
||||||
|
/* Bodies reach the device on demand, so a note never opened would not be
|
||||||
|
readable offline. The background backfill exists for exactly that; give it
|
||||||
|
a moment and then confirm every note is really in local storage. */
|
||||||
|
await p.waitForTimeout(4000);
|
||||||
|
const cached = await p.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const open = indexedDB.open("inknote");
|
||||||
|
open.onerror = () => resolve({ error: String(open.error) });
|
||||||
|
open.onsuccess = () => {
|
||||||
|
const idb = open.result;
|
||||||
|
if (!idb.objectStoreNames.contains("noteContents")) return resolve({ error: "no store" });
|
||||||
|
const tx = idb.transaction("noteContents", "readonly");
|
||||||
|
const all = tx.objectStore("noteContents").getAll();
|
||||||
|
all.onsuccess = () =>
|
||||||
|
resolve({
|
||||||
|
count: all.result.length,
|
||||||
|
texts: all.result.map((r) => String(r.content || "")),
|
||||||
|
});
|
||||||
|
all.onerror = () => resolve({ error: String(all.error) });
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
check(!cached.error, "the local copy of the notes can be read", cached.error || "");
|
||||||
|
check(cached.count >= 2, "every note's body is on the device, not only the open one", `${cached.count} bodies`);
|
||||||
|
check(
|
||||||
|
cached.texts.some((t) => t.includes("Hauptbahnhof")),
|
||||||
|
"including one that was written earlier and then left"
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── pull the plug ── */
|
||||||
|
await context.setOffline(true);
|
||||||
|
await p.waitForTimeout(500);
|
||||||
|
|
||||||
|
const blocked = await p.evaluate(async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/notes", { cache: "no-store" });
|
||||||
|
return "still answering";
|
||||||
|
} catch {
|
||||||
|
return "blocked";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
check(blocked === "blocked", "the network really is gone", blocked);
|
||||||
|
|
||||||
|
/* A cold start: not a soft re-render, a full page load with no connection. */
|
||||||
|
await p.reload({ waitUntil: "domcontentloaded" }).catch(() => {});
|
||||||
|
await p.waitForTimeout(4000);
|
||||||
|
|
||||||
|
const loaded = await p.locator("body").innerText();
|
||||||
|
check(loaded.length > 40, "the app itself loads from the cache with no connection", `${loaded.length} chars`);
|
||||||
|
check(!/Sign In/i.test(loaded), "and the session is still there — no login screen offline");
|
||||||
|
|
||||||
|
await p.locator("button").filter({ hasText: "Unterwegs" }).first().click();
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
const rows = await p.locator("[data-note-row]").count();
|
||||||
|
check(rows >= 2, "the notebook still lists its notes", `${rows} rows`);
|
||||||
|
|
||||||
|
await p.locator("[data-note-row]").filter({ hasText: "Reiseplan" }).first().click();
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
const body = await p.locator(".ProseMirror").innerText();
|
||||||
|
check(body.includes("Hauptbahnhof"), "and a note opens with its text", JSON.stringify(body.slice(0, 60)));
|
||||||
|
|
||||||
|
/* Searching is local by design — the server holds only ciphertext — so it has
|
||||||
|
to keep working when the server is not there at all. */
|
||||||
|
await p.getByPlaceholder("Search notes...").fill("Kaffee");
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
const hits = await p.locator("[data-note-row]").count();
|
||||||
|
check(hits === 1, "search still finds a note by its text, offline", `${hits} hits`);
|
||||||
|
await p.getByPlaceholder("Search notes...").fill("");
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
|
||||||
|
/* Writing offline is queued, not lost. */
|
||||||
|
await p.locator("[data-note-row]").filter({ hasText: "Einkauf" }).first().click();
|
||||||
|
await p.waitForTimeout(1800);
|
||||||
|
await p.locator(".ProseMirror").click();
|
||||||
|
await p.keyboard.press("Control+End");
|
||||||
|
await p.keyboard.type(" Und Butter.");
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
|
||||||
|
const status = await p.locator("body").innerText();
|
||||||
|
check(/offline|Pending|Wartet|Änderungen/i.test(status), "the app says it is offline rather than pretending");
|
||||||
|
|
||||||
|
/* ── back on ── */
|
||||||
|
await context.setOffline(false);
|
||||||
|
await p.waitForTimeout(1000);
|
||||||
|
await p.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||||
|
await p.waitForTimeout(8000);
|
||||||
|
|
||||||
|
const server = (await api(p, "GET", "/notes")).body;
|
||||||
|
const einkauf = server.find((n) => n.title === "Einkauf");
|
||||||
|
const stored = await api(p, "GET", `/notes/${einkauf.id}`);
|
||||||
|
check(
|
||||||
|
String(stored.body.content).includes("Butter"),
|
||||||
|
"and the edit made offline reaches the server once there is a connection",
|
||||||
|
String(stored.body.content).slice(0, 80)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
check(false, "suite ran to the end", e.message);
|
||||||
|
} finally {
|
||||||
|
await session?.cleanup();
|
||||||
|
const code = report();
|
||||||
|
await browser.close();
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/* Sorting, importing, and the two dialogs that replaced a guess and a prompt.
|
||||||
|
*
|
||||||
|
* The load-bearing claim here is the one about sorting: an arrangement the user
|
||||||
|
* made by hand has to survive being looked away from. Searching imposes its own
|
||||||
|
* order, and the danger is that going back to "own order" shows something
|
||||||
|
* nobody arranged — because the reorder was computed against a filtered list.
|
||||||
|
* That is checked by arranging, searching, and coming back.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
check,
|
||||||
|
createNote,
|
||||||
|
createNotebook,
|
||||||
|
launch,
|
||||||
|
report,
|
||||||
|
setTitle,
|
||||||
|
typeInEditor,
|
||||||
|
withFreshUser,
|
||||||
|
} from "./lib.mjs";
|
||||||
|
|
||||||
|
const browser = await launch();
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await withFreshUser(browser, "organise");
|
||||||
|
const p = session.page;
|
||||||
|
|
||||||
|
const titles = () =>
|
||||||
|
p.locator("[data-note-row]").evaluateAll((rows) =>
|
||||||
|
rows.map((r) => r.querySelector("span:nth-child(2)")?.textContent?.trim() || "")
|
||||||
|
);
|
||||||
|
const sortBy = async (mode) => {
|
||||||
|
await p.locator("[data-sort-button]").click();
|
||||||
|
await p.waitForTimeout(300);
|
||||||
|
await p.locator(`[data-sort-option="${mode}"]`).click();
|
||||||
|
await p.waitForTimeout(900);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── three notes, made in a known order ── */
|
||||||
|
await createNotebook(p, "Ordnung");
|
||||||
|
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
|
||||||
|
await p.waitForTimeout(700);
|
||||||
|
|
||||||
|
for (const name of ["Alpha", "Beta", "Gamma"]) {
|
||||||
|
await createNote(p);
|
||||||
|
await setTitle(p, name);
|
||||||
|
await typeInEditor(p, `Inhalt von ${name}. `);
|
||||||
|
await p.waitForTimeout(700);
|
||||||
|
}
|
||||||
|
await p.waitForTimeout(1200);
|
||||||
|
|
||||||
|
check((await titles()).length === 3, "three notes to arrange", (await titles()).join(", "));
|
||||||
|
|
||||||
|
/* ── the built-in orders ── */
|
||||||
|
await sortBy("updated");
|
||||||
|
check((await titles())[0] === "Gamma", "newest first by default", (await titles()).join(", "));
|
||||||
|
|
||||||
|
await sortBy("oldest");
|
||||||
|
check((await titles())[0] === "Alpha", "oldest first reverses it", (await titles()).join(", "));
|
||||||
|
|
||||||
|
await sortBy("title");
|
||||||
|
check((await titles()).join(",") === "Alpha,Beta,Gamma", "A–Z sorts by title", (await titles()).join(", "));
|
||||||
|
|
||||||
|
/* ── it is remembered, not just applied ── */
|
||||||
|
await p.reload({ waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
check(
|
||||||
|
(await titles()).join(",") === "Alpha,Beta,Gamma",
|
||||||
|
"and it survives a reload, because it belongs to the notebook",
|
||||||
|
(await titles()).join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
const notebooks = await api(p, "GET", "/notebooks");
|
||||||
|
check(notebooks.body[0].sort_mode === "title", "the notebook itself carries the choice", String(notebooks.body[0].sort_mode));
|
||||||
|
|
||||||
|
/* ── the user's own order ── */
|
||||||
|
await sortBy("manual");
|
||||||
|
const beforeDrag = await titles();
|
||||||
|
check(beforeDrag.length === 3, "manual order starts from the notes it has", beforeDrag.join(", "));
|
||||||
|
|
||||||
|
// Drag the last note onto the first.
|
||||||
|
const rows = p.locator("[data-note-row]");
|
||||||
|
const last = rows.nth(2);
|
||||||
|
const first = rows.nth(0);
|
||||||
|
await last.hover();
|
||||||
|
await p.mouse.down();
|
||||||
|
await first.hover();
|
||||||
|
await first.hover(); // a second move, so the drop target registers
|
||||||
|
await p.mouse.up();
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
|
||||||
|
const arranged = await titles();
|
||||||
|
check(
|
||||||
|
arranged[0] === beforeDrag[2],
|
||||||
|
"dragging a note to the top puts it there",
|
||||||
|
`${beforeDrag.join(",")} → ${arranged.join(",")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
/* The point of the whole feature. */
|
||||||
|
await p.getByPlaceholder("Search notes...").fill("Beta");
|
||||||
|
await p.waitForTimeout(1200);
|
||||||
|
const searched = await titles();
|
||||||
|
check(searched.length < 3, "searching narrows the list", searched.join(", "));
|
||||||
|
|
||||||
|
await p.getByPlaceholder("Search notes...").fill("");
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
check(
|
||||||
|
(await titles()).join(",") === arranged.join(","),
|
||||||
|
"and clearing the search shows exactly the arrangement again",
|
||||||
|
`${arranged.join(",")} vs ${(await titles()).join(",")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Reordering must not look like editing: a dragged note keeps its place in
|
||||||
|
"last changed", or the two orders would fight each other. */
|
||||||
|
await sortBy("updated");
|
||||||
|
check(
|
||||||
|
(await titles())[0] === "Gamma",
|
||||||
|
"a dragged note did not become the most recently changed one",
|
||||||
|
(await titles()).join(", ")
|
||||||
|
);
|
||||||
|
await sortBy("manual");
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
|
||||||
|
/* ── exporting a note and importing it back ── */
|
||||||
|
await p.locator("[data-note-row]").filter({ hasText: "Beta" }).first().click();
|
||||||
|
await p.waitForTimeout(1200);
|
||||||
|
await p.locator(".ProseMirror").click();
|
||||||
|
await p.keyboard.press("Control+End");
|
||||||
|
await p.keyboard.press("Enter");
|
||||||
|
// Through the toolbar rather than by typing "- [x] ": inside a bullet item
|
||||||
|
// that sequence stays literal text, which is a quirk of the editor's input
|
||||||
|
// rules and not what this suite is about.
|
||||||
|
await p.getByTitle("Task list").click();
|
||||||
|
await p.waitForTimeout(400);
|
||||||
|
await p.keyboard.type("erledigt");
|
||||||
|
await p.waitForTimeout(600);
|
||||||
|
await p.locator('.ProseMirror input[type="checkbox"]').first().click();
|
||||||
|
await p.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const exported = await p.evaluate(async () => {
|
||||||
|
const captured = [];
|
||||||
|
const realCreate = URL.createObjectURL.bind(URL);
|
||||||
|
URL.createObjectURL = (blob) => {
|
||||||
|
captured.push(blob);
|
||||||
|
return realCreate(blob);
|
||||||
|
};
|
||||||
|
window.__captured = captured;
|
||||||
|
HTMLAnchorElement.prototype.click = function () {};
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
check(exported, "downloads can be captured");
|
||||||
|
|
||||||
|
await p.getByTitle("Export").click();
|
||||||
|
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||||||
|
await p.waitForTimeout(600);
|
||||||
|
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
|
||||||
|
const markdown = await p.evaluate(async () => {
|
||||||
|
const blob = window.__captured?.[0];
|
||||||
|
return blob ? await blob.text() : "";
|
||||||
|
});
|
||||||
|
check(markdown.includes("- [x] erledigt"), "the export carries the ticked box", JSON.stringify(markdown.slice(-60)));
|
||||||
|
check(markdown.startsWith("# Beta"), "and the title as a heading");
|
||||||
|
|
||||||
|
await p.locator("[data-export-dialog] button").filter({ hasText: /Schließen|Fertig|Close/ }).first().click().catch(() => {});
|
||||||
|
await p.keyboard.press("Escape");
|
||||||
|
await p.waitForTimeout(600);
|
||||||
|
|
||||||
|
/* Import it back as a second note. A real file chooser cannot be driven, so
|
||||||
|
the file is handed to the input directly — the code under test is what
|
||||||
|
happens after a file is chosen. */
|
||||||
|
const before = (await api(p, "GET", "/notes")).body.length;
|
||||||
|
await p.evaluate((md) => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
document.body.appendChild(input);
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(new File([md], "Beta.md", { type: "text/markdown" }));
|
||||||
|
input.files = dt.files;
|
||||||
|
window.__importInput = input;
|
||||||
|
}, markdown);
|
||||||
|
|
||||||
|
const chooser = p.waitForEvent("filechooser");
|
||||||
|
await p.locator("[data-import-button]").click();
|
||||||
|
const fc = await chooser;
|
||||||
|
await fc.setFiles({ name: "Beta.md", mimeType: "text/markdown", buffer: Buffer.from(markdown) });
|
||||||
|
await p.waitForTimeout(4000);
|
||||||
|
|
||||||
|
const importedText = await p.locator("body").innerText();
|
||||||
|
check(/importiert/i.test(importedText), "the import reports what it did", importedText.slice(0, 200).replace(/\n/g, " "));
|
||||||
|
await p.getByRole("button", { name: /Fertig/ }).click();
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
|
||||||
|
const after = (await api(p, "GET", "/notes")).body.length;
|
||||||
|
check(after === before + 1, "one note more than before", `${before} → ${after}`);
|
||||||
|
|
||||||
|
const importedNote = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Beta" && n.id);
|
||||||
|
check(!!importedNote, "the imported note kept its title from the heading");
|
||||||
|
|
||||||
|
await p.locator("[data-note-row]").filter({ hasText: "Beta" }).last().click();
|
||||||
|
await p.waitForTimeout(1800);
|
||||||
|
const boxes = await p.locator('.ProseMirror input[type="checkbox"]').count();
|
||||||
|
check(boxes > 0, "the task box came back as a real checkbox, not as text", String(boxes));
|
||||||
|
const checked = await p.locator('.ProseMirror input[type="checkbox"]:checked').count();
|
||||||
|
check(checked > 0, "and it came back ticked", String(checked));
|
||||||
|
|
||||||
|
/* ── the link dialog, which used to be a browser prompt ── */
|
||||||
|
await p.locator(".ProseMirror").click();
|
||||||
|
await p.keyboard.press("Control+End");
|
||||||
|
await p.keyboard.type("Zielwort");
|
||||||
|
await p.keyboard.down("Shift");
|
||||||
|
for (let i = 0; i < 8; i++) await p.keyboard.press("ArrowLeft");
|
||||||
|
await p.keyboard.up("Shift");
|
||||||
|
await p.getByTitle("Web link").click();
|
||||||
|
await p.waitForTimeout(600);
|
||||||
|
|
||||||
|
const field = p.getByLabel("Link address");
|
||||||
|
check(await field.isVisible(), "the link dialog is a real dialog in the page");
|
||||||
|
|
||||||
|
await field.fill("javascript:alert(1)");
|
||||||
|
await p.getByRole("button", { name: /Einfügen/ }).click();
|
||||||
|
await p.waitForTimeout(400);
|
||||||
|
check(
|
||||||
|
await field.isVisible(),
|
||||||
|
"a javascript: address is refused rather than inserted"
|
||||||
|
);
|
||||||
|
|
||||||
|
await field.fill("example.com");
|
||||||
|
await p.getByRole("button", { name: /Einfügen/ }).click();
|
||||||
|
await p.waitForTimeout(1800);
|
||||||
|
const href = await p.locator(".ProseMirror a[href]").first().getAttribute("href");
|
||||||
|
check(href === "https://example.com", "a bare domain is taken as https", String(href));
|
||||||
|
|
||||||
|
/* ── which notebook, when there is no notebook ── */
|
||||||
|
await createNotebook(p, "Zweites Buch");
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
await p.locator("button").filter({ hasText: "All Notes" }).first().click();
|
||||||
|
await p.waitForTimeout(900);
|
||||||
|
await p.getByTitle("New Note").click();
|
||||||
|
await p.waitForTimeout(900);
|
||||||
|
|
||||||
|
const books0 = (await api(p, "GET", "/notebooks")).body;
|
||||||
|
const picker = p.locator("[data-notebook-option]");
|
||||||
|
check((await picker.count()) === 2, "creating a note from All Notes asks where it should go", String(await picker.count()));
|
||||||
|
|
||||||
|
// By name, not by position: two notebooks created without dragging share a
|
||||||
|
// sort order, so "the last one" is not a stable way to mean a particular book.
|
||||||
|
const target = books0.find((b) => b.name === "Zweites Buch");
|
||||||
|
await p.locator(`[data-notebook-option="${target.id}"]`).click();
|
||||||
|
await p.getByRole("button", { name: /Weiter/ }).click();
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
|
||||||
|
// By title, not by position: the list is ordered by a timestamp with one
|
||||||
|
// second of resolution, and several notes here share that second.
|
||||||
|
const fresh = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Untitled Note");
|
||||||
|
check(
|
||||||
|
fresh?.notebook_id === target.id,
|
||||||
|
"and it lands in the notebook that was chosen, not the first one",
|
||||||
|
`${fresh?.notebook_id} vs ${target.id}`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
check(false, "suite ran to the end", e.message);
|
||||||
|
} finally {
|
||||||
|
await session?.cleanup();
|
||||||
|
const code = report();
|
||||||
|
await browser.close();
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
+1
-1
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
|
|||||||
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
|
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
|
||||||
URL="${INKNOTE_URL:-http://localhost:3000}"
|
URL="${INKNOTE_URL:-http://localhost:3000}"
|
||||||
SUITES=("$@")
|
SUITES=("$@")
|
||||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle)
|
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise offline)
|
||||||
|
|
||||||
mkdir -p shots
|
mkdir -p shots
|
||||||
if [ ! -d node_modules ]; then
|
if [ ! -d node_modules ]; then
|
||||||
|
|||||||
+124
-30
@@ -18,9 +18,12 @@ import { AccountMenu } from "./ui/AccountMenu.jsx";
|
|||||||
import { AuthScreen } from "./ui/AuthScreen.jsx";
|
import { AuthScreen } from "./ui/AuthScreen.jsx";
|
||||||
import { AdminPanel } from "./ui/AdminPanel.jsx";
|
import { AdminPanel } from "./ui/AdminPanel.jsx";
|
||||||
import { ExportDialog } from "./ui/ExportDialog.jsx";
|
import { ExportDialog } from "./ui/ExportDialog.jsx";
|
||||||
|
import { NotebookPicker, rememberNotebook } from "./ui/NotebookPicker.jsx";
|
||||||
|
import { ImportDialog } from "./ui/ImportDialog.jsx";
|
||||||
import { Sidebar } from "./ui/Sidebar.jsx";
|
import { Sidebar } from "./ui/Sidebar.jsx";
|
||||||
import { Dashboard } from "./features/notebooks/Dashboard.jsx";
|
import { Dashboard } from "./features/notebooks/Dashboard.jsx";
|
||||||
import { NoteList } from "./features/notes/NoteList.jsx";
|
import { NoteList } from "./features/notes/NoteList.jsx";
|
||||||
|
import { DEFAULT_SORT, canReorder, sortNotes } from "./features/notes/noteSort";
|
||||||
import { EditorPane } from "./features/notes/EditorPane.jsx";
|
import { EditorPane } from "./features/notes/EditorPane.jsx";
|
||||||
import { HistoryModal } from "./features/notes/HistoryModal.jsx";
|
import { HistoryModal } from "./features/notes/HistoryModal.jsx";
|
||||||
import { DrawingLayer } from "./features/drawing/DrawingLayer.jsx";
|
import { DrawingLayer } from "./features/drawing/DrawingLayer.jsx";
|
||||||
@@ -54,6 +57,14 @@ export default function App() {
|
|||||||
const [showAdmin, setShowAdmin] = useState(false);
|
const [showAdmin, setShowAdmin] = useState(false);
|
||||||
// { notes, label } — set while the export dialog is up.
|
// { notes, label } — set while the export dialog is up.
|
||||||
const [exporting, setExporting] = useState(null);
|
const [exporting, setExporting] = useState(null);
|
||||||
|
// { title, run } — set while the app is asking which notebook to use.
|
||||||
|
const [pickingNotebook, setPickingNotebook] = useState(null);
|
||||||
|
// Set while files are being imported; holds the progress and then the result.
|
||||||
|
const [importing, setImporting] = useState(null);
|
||||||
|
/* Sort order for the views that are not notebooks. Those have nowhere to
|
||||||
|
store a choice — "All Notes" is not a thing that can carry a setting — so it
|
||||||
|
lasts for the session and no longer. */
|
||||||
|
const [virtualSort, setVirtualSort] = useState({});
|
||||||
const [historyFor, setHistoryFor] = useState(null);
|
const [historyFor, setHistoryFor] = useState(null);
|
||||||
const [ctxMenu, setCtxMenu] = useState(null);
|
const [ctxMenu, setCtxMenu] = useState(null);
|
||||||
const [confirmDialog, setConfirmDialog] = useState(null);
|
const [confirmDialog, setConfirmDialog] = useState(null);
|
||||||
@@ -68,6 +79,7 @@ export default function App() {
|
|||||||
const { notes, notebooks, noteContents, backlinks } = data;
|
const { notes, notebooks, noteContents, backlinks } = data;
|
||||||
const search = useSearchIndex(user, notes, searchQuery);
|
const search = useSearchIndex(user, notes, searchQuery);
|
||||||
const dnd = useNotebookDnd(data.reorderNotebooks);
|
const dnd = useNotebookDnd(data.reorderNotebooks);
|
||||||
|
const noteDnd = useNotebookDnd(data.reorderNotes);
|
||||||
|
|
||||||
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
|
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
|
||||||
|
|
||||||
@@ -153,6 +165,23 @@ export default function App() {
|
|||||||
const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
|
const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
|
||||||
const isTrashView = activeFolder === "trash";
|
const isTrashView = activeFolder === "trash";
|
||||||
|
|
||||||
|
const activeNotebook = notebooks.find((nb) => nb.id === activeFolder) || null;
|
||||||
|
/* A real notebook remembers its order on the server; a view keeps one for the
|
||||||
|
session. Either way the note list is told a single mode. */
|
||||||
|
const sortMode = activeNotebook
|
||||||
|
? activeNotebook.sortMode || DEFAULT_SORT
|
||||||
|
: virtualSort[activeFolder] || DEFAULT_SORT;
|
||||||
|
|
||||||
|
const setSortMode = useCallback(
|
||||||
|
(mode) => {
|
||||||
|
if (activeNotebook) void data.updateNotebook(activeNotebook.id, { sortMode: mode });
|
||||||
|
else setVirtualSort((p) => ({ ...p, [activeFolder]: mode }));
|
||||||
|
},
|
||||||
|
[activeNotebook, activeFolder, data]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSearching = searchQuery.trim().length > 0;
|
||||||
|
|
||||||
const filteredNotes = useMemo(() => {
|
const filteredNotes = useMemo(() => {
|
||||||
let list;
|
let list;
|
||||||
if (activeFolder === "trash") list = trashedNotes;
|
if (activeFolder === "trash") list = trashedNotes;
|
||||||
@@ -171,8 +200,8 @@ export default function App() {
|
|||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
return list.filter((n) => n.title.toLowerCase().includes(q));
|
return list.filter((n) => n.title.toLowerCase().includes(q));
|
||||||
}
|
}
|
||||||
return [...list].sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
return sortNotes(list, sortMode);
|
||||||
}, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes]);
|
}, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes, sortMode]);
|
||||||
|
|
||||||
const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null;
|
const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null;
|
||||||
// The editor opens as soon as the metadata is known; the body fills in when
|
// The editor opens as soon as the metadata is known; the body fills in when
|
||||||
@@ -234,46 +263,84 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/* ── Actions ── */
|
/* ── Actions ── */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Picks a file and imports it as a PDF note.
|
* Runs something that needs a notebook to put a note in.
|
||||||
|
*
|
||||||
|
* "All Notes", "Starred" and the dashboard are views, not places. The app used
|
||||||
|
* to answer that silently with the first notebook, so a note or an imported
|
||||||
|
* file landed somewhere the user had not chosen and could only find by knowing
|
||||||
|
* that rule. Now it asks — once, remembering the answer as the default.
|
||||||
|
*/
|
||||||
|
const withNotebook = useCallback(
|
||||||
|
(title, run) => {
|
||||||
|
if (!VIRTUAL_FOLDERS.includes(activeFolder)) {
|
||||||
|
rememberNotebook(activeFolder);
|
||||||
|
return run(activeFolder);
|
||||||
|
}
|
||||||
|
if (notebooks.length === 0) return;
|
||||||
|
// Nothing to ask about when there is only one place it could go.
|
||||||
|
if (notebooks.length === 1) return run(notebooks[0].id);
|
||||||
|
setPickingNotebook({ title, run });
|
||||||
|
},
|
||||||
|
[activeFolder, notebooks]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a file chooser.
|
||||||
*
|
*
|
||||||
* The input is created and thrown away per use rather than living in the
|
* The input is created and thrown away per use rather than living in the
|
||||||
* markup: a hidden input that persists keeps its previous selection, and
|
* markup: a hidden input that persists keeps its previous selection, and
|
||||||
* choosing the same file twice in a row would then fire no change event.
|
* choosing the same file twice in a row would then fire no change event.
|
||||||
*/
|
*/
|
||||||
const importPdf = () => {
|
const chooseFiles = (accept, multiple, onChosen) => {
|
||||||
const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder;
|
|
||||||
if (!folderId) return;
|
|
||||||
|
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "application/pdf,.pdf";
|
input.accept = accept;
|
||||||
input.onchange = async () => {
|
input.multiple = multiple;
|
||||||
const file = input.files?.[0];
|
input.onchange = () => {
|
||||||
if (!file) return;
|
const files = [...(input.files || [])];
|
||||||
try {
|
if (files.length) onChosen(files);
|
||||||
const note = await data.importPdf(folderId, file);
|
|
||||||
if (activeFolder === "dashboard") setActiveFolder(folderId);
|
|
||||||
openNote(note.id);
|
|
||||||
} catch (e) {
|
|
||||||
setConfirmDialog({
|
|
||||||
title: "Import fehlgeschlagen",
|
|
||||||
message: e?.message || "Die Datei konnte nicht importiert werden.",
|
|
||||||
confirmLabel: "OK",
|
|
||||||
onConfirm: () => setConfirmDialog(null),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const createNote = async () => {
|
const reportFailure = (message) =>
|
||||||
const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder;
|
setConfirmDialog({
|
||||||
if (!folderId) return;
|
title: "Import fehlgeschlagen",
|
||||||
const note = await data.createNote(folderId);
|
message,
|
||||||
if (activeFolder === "dashboard") setActiveFolder(folderId);
|
confirmLabel: "OK",
|
||||||
openNote(note.id);
|
onConfirm: () => setConfirmDialog(null),
|
||||||
};
|
});
|
||||||
|
|
||||||
|
const importPdf = () =>
|
||||||
|
withNotebook("PDF importieren", (folderId) =>
|
||||||
|
chooseFiles("application/pdf,.pdf", false, async ([file]) => {
|
||||||
|
try {
|
||||||
|
const note = await data.importPdf(folderId, file);
|
||||||
|
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
|
||||||
|
openNote(note.id);
|
||||||
|
} catch (e) {
|
||||||
|
reportFailure(e?.message || "Die Datei konnte nicht importiert werden.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Markdown files, or a ZIP written by this app's export. */
|
||||||
|
const importMarkdown = () =>
|
||||||
|
withNotebook("Notizen importieren", (folderId) =>
|
||||||
|
chooseFiles(".md,.markdown,.txt,.zip,text/markdown,application/zip", true, (files) => {
|
||||||
|
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
|
||||||
|
setImporting({ files, folderId });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const createNote = () =>
|
||||||
|
withNotebook("Neue Notiz", async (folderId) => {
|
||||||
|
const note = await data.createNote(folderId);
|
||||||
|
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
|
||||||
|
openNote(note.id);
|
||||||
|
});
|
||||||
|
|
||||||
/** Runs a destructive action behind a confirmation prompt. */
|
/** Runs a destructive action behind a confirmation prompt. */
|
||||||
const confirm = (dialog) =>
|
const confirm = (dialog) =>
|
||||||
@@ -518,11 +585,16 @@ export default function App() {
|
|||||||
isTrashView={isTrashView}
|
isTrashView={isTrashView}
|
||||||
trashedCount={trashedNotes.length}
|
trashedCount={trashedNotes.length}
|
||||||
snippetFor={search.snippetFor}
|
snippetFor={search.snippetFor}
|
||||||
|
sortMode={sortMode}
|
||||||
|
onSortModeChange={setSortMode}
|
||||||
|
reorderable={canReorder(sortMode, isSearching) && !!activeNotebook}
|
||||||
|
dnd={noteDnd}
|
||||||
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
||||||
onSelectNote={openNote}
|
onSelectNote={openNote}
|
||||||
onNoteContextMenu={noteContextMenu}
|
onNoteContextMenu={noteContextMenu}
|
||||||
onCreateNote={createNote}
|
onCreateNote={createNote}
|
||||||
onImportPdf={importPdf}
|
onImportPdf={importPdf}
|
||||||
|
onImportMarkdown={importMarkdown}
|
||||||
onEmptyTrash={emptyTrash}
|
onEmptyTrash={emptyTrash}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -581,6 +653,28 @@ export default function App() {
|
|||||||
dark={dark}
|
dark={dark}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{pickingNotebook && (
|
||||||
|
<NotebookPicker
|
||||||
|
notebooks={notebooks}
|
||||||
|
title={pickingNotebook.title}
|
||||||
|
dark={dark}
|
||||||
|
onClose={() => setPickingNotebook(null)}
|
||||||
|
onPick={(id) => {
|
||||||
|
const { run } = pickingNotebook;
|
||||||
|
setPickingNotebook(null);
|
||||||
|
run(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{importing && (
|
||||||
|
<ImportDialog
|
||||||
|
files={importing.files}
|
||||||
|
folderId={importing.folderId}
|
||||||
|
dark={dark}
|
||||||
|
onDone={() => data.reload()}
|
||||||
|
onClose={() => setImporting(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{exporting && (
|
{exporting && (
|
||||||
<ExportDialog
|
<ExportDialog
|
||||||
notes={exporting.notes}
|
notes={exporting.notes}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { LinkDialog } from "../ui/LinkDialog.jsx";
|
||||||
|
|
||||||
/* Icons the main app does not already provide, drawn to match its stroke style. */
|
/* Icons the main app does not already provide, drawn to match its stroke style. */
|
||||||
const strokeProps = {
|
const strokeProps = {
|
||||||
width: 15,
|
width: 15,
|
||||||
@@ -51,9 +53,10 @@ const AtIcon = () => (
|
|||||||
* Reads its active states straight off the editor, so the buttons reflect the
|
* Reads its active states straight off the editor, so the buttons reflect the
|
||||||
* cursor even when formatting changed by keyboard shortcut.
|
* cursor even when formatting changed by keyboard shortcut.
|
||||||
*/
|
*/
|
||||||
export function EditorToolbar({ editor, T, icons, compact }) {
|
export function EditorToolbar({ editor, T, icons, compact, dark }) {
|
||||||
const [, force] = useState(0);
|
const [, force] = useState(0);
|
||||||
const [showTable, setShowTable] = useState(false);
|
const [showTable, setShowTable] = useState(false);
|
||||||
|
const [linkDialog, setLinkDialog] = useState(null);
|
||||||
|
|
||||||
// ProseMirror state changes do not go through React; subscribe so the active
|
// ProseMirror state changes do not go through React; subscribe so the active
|
||||||
// states stay in sync with the cursor.
|
// states stay in sync with the cursor.
|
||||||
@@ -111,19 +114,12 @@ export function EditorToolbar({ editor, T, icons, compact }) {
|
|||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
|
|
||||||
const promptLink = () => {
|
/* The dialog is opened rather than a prompt shown; the validation and the
|
||||||
const previous = editor.getAttributes("link").href || "";
|
* "remove" affordance live inside it. See LinkDialog for why. */
|
||||||
const url = window.prompt("Link URL (leave empty to remove)", previous);
|
const openLinkDialog = () => setLinkDialog({ url: editor.getAttributes("link").href || "" });
|
||||||
if (url === null) return;
|
const closeLinkDialog = () => {
|
||||||
if (url === "") {
|
setLinkDialog(null);
|
||||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
editor.chain().focus().run(); // give the caret back to the note
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!/^(https?|mailto):/i.test(url)) {
|
|
||||||
window.alert("Only http(s) and mailto links are allowed.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -259,9 +255,24 @@ export function EditorToolbar({ editor, T, icons, compact }) {
|
|||||||
>
|
>
|
||||||
<AtIcon />
|
<AtIcon />
|
||||||
</button>
|
</button>
|
||||||
<button style={btn(editor.isActive("link"))} title="Web link" onClick={promptLink}>
|
<button style={btn(editor.isActive("link"))} title="Web link" data-link-button onClick={openLinkDialog}>
|
||||||
<LinkIcon />
|
<LinkIcon />
|
||||||
</button>
|
</button>
|
||||||
|
{linkDialog && (
|
||||||
|
<LinkDialog
|
||||||
|
initialUrl={linkDialog.url}
|
||||||
|
dark={dark}
|
||||||
|
onClose={closeLinkDialog}
|
||||||
|
onRemove={() => {
|
||||||
|
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||||
|
setLinkDialog(null);
|
||||||
|
}}
|
||||||
|
onSubmit={(href) => {
|
||||||
|
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
|
||||||
|
setLinkDialog(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -212,7 +212,19 @@ export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(...children(doc, options));
|
/* A blank line between top-level blocks.
|
||||||
|
*
|
||||||
|
* Without one, Markdown runs adjacent blocks together: a bullet list written
|
||||||
|
* straight after another list becomes more items of the first, and a quote or
|
||||||
|
* a code fence on the line after a list item is read as part of that item.
|
||||||
|
* That is not a quirk of one reader — it is what the format says, so the file
|
||||||
|
* rendered wrongly everywhere, and reading it back in produced a document that
|
||||||
|
* did not match the one exported. */
|
||||||
|
const blocks = (doc.content || []).flatMap((n) => {
|
||||||
|
const rendered = block(n, options);
|
||||||
|
return rendered.length && rendered.some((l) => l !== "") ? [...rendered, ""] : [];
|
||||||
|
});
|
||||||
|
lines.push(...blocks);
|
||||||
|
|
||||||
if (options.hasDrawing) {
|
if (options.hasDrawing) {
|
||||||
lines.push("", "> ✏️ Diese Notiz enthält eine Zeichnung, die Markdown nicht abbilden kann.");
|
lines.push("", "> ✏️ Diese Notiz enthält eine Zeichnung, die Markdown nicht abbilden kann.");
|
||||||
|
|||||||
@@ -435,6 +435,16 @@ export function NoteEditor({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
/* Fill the pane rather than the toolbar.
|
||||||
|
*
|
||||||
|
* As a flex item with the default `flex: 0 1 auto` this box was as wide
|
||||||
|
* as its widest row — the toolbar. Switching to Draw swaps a dozen
|
||||||
|
* formatting buttons for six drawing ones, the toolbar's natural width
|
||||||
|
* collapses, and the page took it with it: the paper measurably shrank
|
||||||
|
* from 733px to 460px on the same screen. Growing to the pane makes the
|
||||||
|
* width a property of the window, which is what it always looked like. */
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
background: isMobileViewport
|
background: isMobileViewport
|
||||||
? T.bg
|
? T.bg
|
||||||
: dark
|
: dark
|
||||||
@@ -464,6 +474,7 @@ export function NoteEditor({
|
|||||||
T={{ ...T, danger: "#e5534b" }}
|
T={{ ...T, danger: "#e5534b" }}
|
||||||
icons={I}
|
icons={I}
|
||||||
compact={isMobileViewport}
|
compact={isMobileViewport}
|
||||||
|
dark={dark}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,8 +1,104 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { I } from "../../ui/icons.jsx";
|
import { I } from "../../ui/icons.jsx";
|
||||||
import { fmtDate } from "../../ui/format";
|
import { fmtDate } from "../../ui/format";
|
||||||
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
|
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
|
||||||
|
import { SORT_LABELS, SORT_MODES } from "./noteSort";
|
||||||
|
|
||||||
/** The middle column: every note in the selected folder, newest or best match first. */
|
const iconButton = (T) => ({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: T.muted,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How this notebook's notes are ordered.
|
||||||
|
*
|
||||||
|
* The chosen order is stored with the notebook, so it is the same on every
|
||||||
|
* device and still there next week — a reference book stays alphabetical, a
|
||||||
|
* journal stays newest-first, without either being re-chosen on each visit.
|
||||||
|
*/
|
||||||
|
function SortMenu({ T, mode, onChange, disabled }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const box = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (e) => {
|
||||||
|
if (!box.current?.contains(e.target)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", close);
|
||||||
|
return () => document.removeEventListener("pointerdown", close);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative" }} ref={box}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
disabled={disabled}
|
||||||
|
title={disabled ? "Sortierung" : `Sortierung: ${SORT_LABELS[mode]}`}
|
||||||
|
aria-label="Sort notes"
|
||||||
|
data-sort-button={mode}
|
||||||
|
style={{ ...iconButton(T), opacity: disabled ? 0.4 : 1, color: open ? T.accent : T.muted }}
|
||||||
|
>
|
||||||
|
{I.sort}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 38,
|
||||||
|
right: 0,
|
||||||
|
minWidth: 190,
|
||||||
|
background: T.card,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 6,
|
||||||
|
zIndex: 300,
|
||||||
|
boxShadow: "0 10px 26px rgba(0,0,0,.28)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SORT_MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
data-sort-option={m}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(m);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
width: "100%",
|
||||||
|
padding: "7px 10px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "none",
|
||||||
|
background: m === mode ? T.accentSoft : "transparent",
|
||||||
|
color: m === mode ? T.accent : T.text,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 12 }}>{m === mode ? "✓" : ""}</span>
|
||||||
|
{SORT_LABELS[m]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The middle column: every note in the selected folder, in the chosen order. */
|
||||||
export function NoteList({
|
export function NoteList({
|
||||||
T,
|
T,
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -12,11 +108,16 @@ export function NoteList({
|
|||||||
isTrashView,
|
isTrashView,
|
||||||
trashedCount,
|
trashedCount,
|
||||||
snippetFor,
|
snippetFor,
|
||||||
|
sortMode,
|
||||||
|
onSortModeChange,
|
||||||
|
reorderable,
|
||||||
|
dnd,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
onSelectNote,
|
onSelectNote,
|
||||||
onNoteContextMenu,
|
onNoteContextMenu,
|
||||||
onCreateNote,
|
onCreateNote,
|
||||||
onImportPdf,
|
onImportPdf,
|
||||||
|
onImportMarkdown,
|
||||||
onEmptyTrash,
|
onEmptyTrash,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -41,7 +142,7 @@ export function NoteList({
|
|||||||
borderBottom: `1px solid ${T.borderLight}`,
|
borderBottom: `1px solid ${T.borderLight}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||||
<button
|
<button
|
||||||
onClick={onToggleSidebar}
|
onClick={onToggleSidebar}
|
||||||
style={{
|
style={{
|
||||||
@@ -59,28 +160,35 @@ export function NoteList({
|
|||||||
>
|
>
|
||||||
{I.menu}
|
{I.menu}
|
||||||
</button>
|
</button>
|
||||||
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}>
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: T.text,
|
||||||
|
fontFamily: FONT_SERIF,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 4 }}>
|
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||||
|
{!isTrashView && <SortMenu T={T} mode={sortMode} onChange={onSortModeChange} />}
|
||||||
{!isTrashView && (
|
{!isTrashView && (
|
||||||
<button
|
<button
|
||||||
onClick={onImportPdf}
|
onClick={onImportMarkdown}
|
||||||
title="Import PDF"
|
title="Notizen importieren (Markdown oder ZIP)"
|
||||||
style={{
|
data-import-button
|
||||||
display: "flex",
|
style={iconButton(T)}
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 10,
|
|
||||||
border: `1px solid ${T.border}`,
|
|
||||||
background: "transparent",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: T.muted,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{I.import}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isTrashView && (
|
||||||
|
<button onClick={onImportPdf} title="Import PDF" style={iconButton(T)}>
|
||||||
{I.pdf}
|
{I.pdf}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -129,6 +237,23 @@ export function NoteList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Said once, where it matters: the arrangement is being edited, and the
|
||||||
|
search box would otherwise silently take that ability away. */}
|
||||||
|
{sortMode === "manual" && !isTrashView && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "5px 16px",
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: T.faint,
|
||||||
|
borderBottom: `1px solid ${T.borderLight}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reorderable
|
||||||
|
? "Eigene Reihenfolge — zum Umsortieren ziehen"
|
||||||
|
: "Eigene Reihenfolge — Suche aktiv, Ziehen pausiert"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}>
|
<div style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}>
|
||||||
{notes.length === 0 ? (
|
{notes.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
@@ -172,10 +297,16 @@ export function NoteList({
|
|||||||
) : (
|
) : (
|
||||||
notes.map((note) => {
|
notes.map((note) => {
|
||||||
const snippet = snippetFor(note.id);
|
const snippet = snippetFor(note.id);
|
||||||
|
const isTarget = reorderable && dnd?.overId === note.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={note.id}
|
key={note.id}
|
||||||
data-note-row={note.id}
|
data-note-row={note.id}
|
||||||
|
draggable={reorderable}
|
||||||
|
onDragStart={reorderable ? (e) => dnd.onDragStart(e, note.id) : undefined}
|
||||||
|
onDragOver={reorderable ? (e) => dnd.onDragOver(e, note.id) : undefined}
|
||||||
|
onDrop={reorderable ? (e) => dnd.onDrop(e, note.id) : undefined}
|
||||||
|
onDragEnd={reorderable ? dnd.onDragEnd : undefined}
|
||||||
onClick={() => onSelectNote(note.id)}
|
onClick={() => onSelectNote(note.id)}
|
||||||
onContextMenu={(e) => onNoteContextMenu(e, note)}
|
onContextMenu={(e) => onNoteContextMenu(e, note)}
|
||||||
style={{
|
style={{
|
||||||
@@ -185,8 +316,9 @@ export function NoteList({
|
|||||||
padding: "12px 14px",
|
padding: "12px 14px",
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
|
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
|
||||||
|
borderTop: isTarget ? `2px solid ${T.accent}` : undefined,
|
||||||
background: activeNote === note.id ? T.accentSoft : "transparent",
|
background: activeNote === note.id ? T.accentSoft : "transparent",
|
||||||
cursor: "pointer",
|
cursor: reorderable ? "grab" : "pointer",
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
}}
|
}}
|
||||||
@@ -194,7 +326,9 @@ export function NoteList({
|
|||||||
<div
|
<div
|
||||||
style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 3, width: "100%" }}
|
style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 3, width: "100%" }}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 13 }}>{note.trashed ? "🗑️" : "📝"}</span>
|
<span style={{ fontSize: 13 }}>
|
||||||
|
{note.trashed ? "🗑️" : note.type === "pdf" ? "📄" : "📝"}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { canReorder, reindex, sortNotes } from "./noteSort";
|
||||||
|
import type { NoteMeta } from "../../types";
|
||||||
|
|
||||||
|
const note = (id: string, over: Partial<NoteMeta> = {}): NoteMeta => ({
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
folderId: "nb",
|
||||||
|
starred: false,
|
||||||
|
trashed: false,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = (list: NoteMeta[]) => list.map((n) => n.id);
|
||||||
|
|
||||||
|
describe("sortNotes", () => {
|
||||||
|
const notes = [
|
||||||
|
note("b", { updatedAt: "2026-05-02T00:00:00Z", createdAt: "2026-01-03T00:00:00Z", sortOrder: 2 }),
|
||||||
|
note("a", { updatedAt: "2026-05-03T00:00:00Z", createdAt: "2026-01-01T00:00:00Z", sortOrder: 1 }),
|
||||||
|
note("c", { updatedAt: "2026-05-01T00:00:00Z", createdAt: "2026-01-02T00:00:00Z", sortOrder: 0 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("puts the most recently changed first", () => {
|
||||||
|
expect(ids(sortNotes(notes, "updated"))).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverses that for oldest", () => {
|
||||||
|
expect(ids(sortNotes(notes, "oldest"))).toEqual(["c", "b", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts by creation date, which is not the same as by change date", () => {
|
||||||
|
expect(ids(sortNotes(notes, "created"))).toEqual(["b", "c", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the stored positions when manual", () => {
|
||||||
|
expect(ids(sortNotes(notes, "manual"))).toEqual(["c", "a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the list it was given", () => {
|
||||||
|
const original = [...notes];
|
||||||
|
sortNotes(notes, "title");
|
||||||
|
expect(notes).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts titles the way German expects, ignoring case and numbering", () => {
|
||||||
|
const list = [
|
||||||
|
note("3", { title: "Übung 10" }),
|
||||||
|
note("1", { title: "übung 2" }),
|
||||||
|
note("2", { title: "Alpha" }),
|
||||||
|
];
|
||||||
|
expect(sortNotes(list, "title").map((n) => n.title)).toEqual(["Alpha", "übung 2", "Übung 10"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders notes that have never been dragged newest first, not arbitrarily", () => {
|
||||||
|
// Everything imported at once carries position 0; without a tie-break the
|
||||||
|
// list would come out in whatever order the store happened to return.
|
||||||
|
const fresh = [
|
||||||
|
note("old", { updatedAt: "2026-01-01T00:00:00Z" }),
|
||||||
|
note("new", { updatedAt: "2026-06-01T00:00:00Z" }),
|
||||||
|
note("mid", { updatedAt: "2026-03-01T00:00:00Z" }),
|
||||||
|
];
|
||||||
|
expect(ids(sortNotes(fresh, "manual"))).toEqual(["new", "mid", "old"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is stable across repeated sorts of an entirely tied list", () => {
|
||||||
|
const tied = [note("z"), note("m"), note("a")];
|
||||||
|
const once = ids(sortNotes(tied, "manual"));
|
||||||
|
expect(ids(sortNotes([...tied].reverse(), "manual"))).toEqual(once);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reindex", () => {
|
||||||
|
const all = [
|
||||||
|
note("a", { sortOrder: 0 }),
|
||||||
|
note("b", { sortOrder: 1 }),
|
||||||
|
note("c", { sortOrder: 2 }),
|
||||||
|
note("d", { sortOrder: 3 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("moves a note onto the position of the one it was dropped on", () => {
|
||||||
|
expect(reindex(all, "d", "b")).toEqual(["a", "d", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works upwards as well as downwards", () => {
|
||||||
|
expect(reindex(all, "a", "c")).toEqual(["b", "c", "a", "d"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports nothing to do when a note is dropped on itself", () => {
|
||||||
|
expect(reindex(all, "b", "b")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports nothing to do for a note that is not in the notebook", () => {
|
||||||
|
expect(reindex(all, "ghost", "b")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* The point of the whole design: an arrangement has to survive being looked
|
||||||
|
* away from. This is the case that used to go wrong in note apps — reorder
|
||||||
|
* against a filtered list and the hidden notes keep positions that now
|
||||||
|
* collide with the visible ones. */
|
||||||
|
it("keeps the arrangement intact when the view was filtered in between", () => {
|
||||||
|
const arranged = reindex(all, "d", "a")!;
|
||||||
|
const positioned = all.map((n) => ({ ...n, sortOrder: arranged.indexOf(n.id) }));
|
||||||
|
|
||||||
|
// The user searches, sees only two notes, then goes back to manual order.
|
||||||
|
const filtered = positioned.filter((n) => n.id === "b" || n.id === "d");
|
||||||
|
expect(ids(sortNotes(filtered, "manual"))).toEqual(["d", "b"]);
|
||||||
|
|
||||||
|
// Clearing the search must show exactly what was arranged.
|
||||||
|
expect(ids(sortNotes(positioned, "manual"))).toEqual(arranged);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renumbers every note in the notebook, not only the ones on screen", () => {
|
||||||
|
const order = reindex(all, "c", "a")!;
|
||||||
|
expect(order).toHaveLength(all.length);
|
||||||
|
expect(new Set(order)).toEqual(new Set(ids(all)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canReorder", () => {
|
||||||
|
it("allows dragging only in manual order", () => {
|
||||||
|
expect(canReorder("manual", false)).toBe(true);
|
||||||
|
expect(canReorder("updated", false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses while a search is narrowing the list", () => {
|
||||||
|
expect(canReorder("manual", true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* The order notes appear in inside a notebook.
|
||||||
|
*
|
||||||
|
* Kept as a pure function so the awkward part can be tested: the promise that
|
||||||
|
* switching away to another order — or searching, which imposes its own — and
|
||||||
|
* then switching back leaves the user's own arrangement exactly as they left it.
|
||||||
|
*
|
||||||
|
* That promise is only keepable if the manual order lives in the notes and is
|
||||||
|
* never re-derived from whatever happens to be on screen. Hence `reindex`, which
|
||||||
|
* takes the *whole* notebook and refuses to renumber a filtered subset: doing so
|
||||||
|
* would give a hidden note the same position as a visible one and quietly
|
||||||
|
* scramble everything that was not being looked at.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NoteMeta, SortMode } from "../../types";
|
||||||
|
|
||||||
|
export const DEFAULT_SORT: SortMode = "updated";
|
||||||
|
|
||||||
|
export const SORT_LABELS: Record<SortMode, string> = {
|
||||||
|
manual: "Eigene Reihenfolge",
|
||||||
|
updated: "Zuletzt geändert",
|
||||||
|
oldest: "Am ältesten",
|
||||||
|
created: "Zuletzt erstellt",
|
||||||
|
title: "Titel A–Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SORT_MODES = Object.keys(SORT_LABELS) as SortMode[];
|
||||||
|
|
||||||
|
const time = (value: string | undefined) => {
|
||||||
|
const t = Date.parse(value ?? "");
|
||||||
|
return Number.isNaN(t) ? 0 : t;
|
||||||
|
};
|
||||||
|
|
||||||
|
const byTitle = (a: NoteMeta, b: NoteMeta) =>
|
||||||
|
(a.title || "").localeCompare(b.title || "", "de", { sensitivity: "base", numeric: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts a list without mutating it.
|
||||||
|
*
|
||||||
|
* Every comparison ends in a tie-break that cannot itself tie: two notes created
|
||||||
|
* in the same second, or two untouched imports that all carry position 0, must
|
||||||
|
* still come out in the same order on every render, or the list shuffles under
|
||||||
|
* the cursor as React re-renders.
|
||||||
|
*/
|
||||||
|
export function sortNotes(notes: NoteMeta[], mode: SortMode = DEFAULT_SORT): NoteMeta[] {
|
||||||
|
const stable = (a: NoteMeta, b: NoteMeta) => a.id.localeCompare(b.id);
|
||||||
|
|
||||||
|
const compare: Record<SortMode, (a: NoteMeta, b: NoteMeta) => number> = {
|
||||||
|
// Notes that have never been dragged share position 0; newest first within
|
||||||
|
// that group, so a fresh note appears at the top rather than at random.
|
||||||
|
manual: (a, b) =>
|
||||||
|
(a.sortOrder ?? 0) - (b.sortOrder ?? 0) || time(b.updatedAt) - time(a.updatedAt) || stable(a, b),
|
||||||
|
updated: (a, b) => time(b.updatedAt) - time(a.updatedAt) || stable(a, b),
|
||||||
|
oldest: (a, b) => time(a.updatedAt) - time(b.updatedAt) || stable(a, b),
|
||||||
|
created: (a, b) => time(b.createdAt) - time(a.createdAt) || stable(a, b),
|
||||||
|
title: (a, b) => byTitle(a, b) || stable(a, b),
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...notes].sort(compare[mode] ?? compare[DEFAULT_SORT]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full notebook renumbered after a note was dragged onto another.
|
||||||
|
*
|
||||||
|
* `all` must be every note in the notebook, not the filtered view. Returns the
|
||||||
|
* ids in their new order, or null when the move changes nothing — which spares
|
||||||
|
* the caller a pointless write.
|
||||||
|
*/
|
||||||
|
export function reindex(all: NoteMeta[], dragId: string, targetId: string): string[] | null {
|
||||||
|
if (dragId === targetId) return null;
|
||||||
|
const ordered = sortNotes(all, "manual");
|
||||||
|
const from = ordered.findIndex((n) => n.id === dragId);
|
||||||
|
const to = ordered.findIndex((n) => n.id === targetId);
|
||||||
|
if (from < 0 || to < 0) return null;
|
||||||
|
|
||||||
|
const [moved] = ordered.splice(from, 1);
|
||||||
|
if (!moved) return null;
|
||||||
|
ordered.splice(to, 0, moved);
|
||||||
|
return ordered.map((n) => n.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the manual order may be changed right now.
|
||||||
|
*
|
||||||
|
* Dragging while a search is narrowing the list would reorder against a view the
|
||||||
|
* hidden notes are not part of — so it is simply not offered, rather than being
|
||||||
|
* offered and then producing an order nobody arranged.
|
||||||
|
*/
|
||||||
|
export const canReorder = (mode: SortMode, filtering: boolean) => mode === "manual" && !filtering;
|
||||||
@@ -75,7 +75,7 @@ export function PdfNotesPanel({ note, dark, onUpdate, onOpenNote, searchLinkTarg
|
|||||||
<span style={{ display: "flex", alignItems: "center", gap: 5, paddingRight: 4, flexShrink: 0 }}>
|
<span style={{ display: "flex", alignItems: "center", gap: 5, paddingRight: 4, flexShrink: 0 }}>
|
||||||
{I.text} Notizen
|
{I.text} Notizen
|
||||||
</span>
|
</span>
|
||||||
{editor && <EditorToolbar editor={editor} T={T} icons={I} compact />}
|
{editor && <EditorToolbar editor={editor} T={T} icons={I} compact dark={dark} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: "auto", minHeight: 0, padding: "10px 14px" }}>
|
<div style={{ flex: 1, overflowY: "auto", minHeight: 0, padding: "10px 14px" }}>
|
||||||
|
|||||||
@@ -464,14 +464,32 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{/* A labelled control, not a dot. Hiding the ink changes what the page
|
||||||
data-pdf-hide-ink={hideInk ? "on" : "off"}
|
shows, so it has to be findable without being clicked to find out —
|
||||||
onClick={() => setHideInk((v) => !v)}
|
and its state has to be readable at a glance rather than from the
|
||||||
title={hideInk ? "Zeichnung einblenden" : "Zeichnung ausblenden"}
|
difference between a filled and a hollow circle.
|
||||||
style={{ ...barButton, color: hideInk ? T.accent : T.muted }}
|
Only shown once there is a drawing: until then it would offer to hide
|
||||||
>
|
nothing. */}
|
||||||
{hideInk ? "◌" : "●"}
|
{totalStrokes(ink) > 0 && (
|
||||||
</button>
|
<button
|
||||||
|
data-pdf-hide-ink={hideInk ? "on" : "off"}
|
||||||
|
onClick={() => setHideInk((v) => !v)}
|
||||||
|
title={hideInk ? "Zeichnung wieder einblenden" : "Zeichnung ausblenden"}
|
||||||
|
style={{
|
||||||
|
...barButton,
|
||||||
|
width: "auto",
|
||||||
|
padding: "0 10px",
|
||||||
|
gap: 6,
|
||||||
|
border: `1px solid ${hideInk ? T.accent + "66" : T.border}`,
|
||||||
|
background: hideInk ? T.accent + "1a" : "transparent",
|
||||||
|
color: hideInk ? T.accent : T.muted,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hideInk ? I.eyeOff : I.eye} {hideInk ? "Zeichnung aus" : "Zeichnung an"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
|
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
<button onClick={() => stepZoom(-1)} title="Kleiner" style={barButton}>
|
<button onClick={() => stepZoom(-1)} title="Kleiner" style={barButton}>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import * as sync from "../syncEngine";
|
import * as sync from "../syncEngine";
|
||||||
import * as searchIndex from "../search";
|
import * as searchIndex from "../search";
|
||||||
import { sameStrokeDoc } from "../features/drawing/strokes.js";
|
import { sameStrokeDoc } from "../features/drawing/strokes.js";
|
||||||
|
import { reindex } from "../features/notes/noteSort";
|
||||||
import type { Backlink, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
|
import type { Backlink, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
|
||||||
|
|
||||||
/** How often the whole account is reconciled with the server. */
|
/** How often the whole account is reconciled with the server. */
|
||||||
@@ -44,6 +45,8 @@ export interface NoteData {
|
|||||||
permanentDeleteNote: (id: string) => Promise<void>;
|
permanentDeleteNote: (id: string) => Promise<void>;
|
||||||
emptyTrash: () => Promise<void>;
|
emptyTrash: () => Promise<void>;
|
||||||
moveNote: (id: string, folderId: string) => Promise<void>;
|
moveNote: (id: string, folderId: string) => Promise<void>;
|
||||||
|
/** Drag-to-reorder within a notebook; only meaningful in manual sort. */
|
||||||
|
reorderNotes: (dragId: string, targetId: string) => void;
|
||||||
|
|
||||||
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
|
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
|
||||||
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
|
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
|
||||||
@@ -261,6 +264,25 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
|||||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId } : n)));
|
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId } : n)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts one note where another was, in the user's own order.
|
||||||
|
*
|
||||||
|
* The new positions are computed over *every* note in that notebook, taken
|
||||||
|
* from the store rather than from whatever the list is currently showing. A
|
||||||
|
* search or a filter must never be able to renumber the notes it is hiding.
|
||||||
|
*/
|
||||||
|
const reorderNotes = useCallback((dragId: string, targetId: string) => {
|
||||||
|
const dragged = notesRef.current.find((n) => n.id === dragId);
|
||||||
|
if (!dragged) return;
|
||||||
|
const inNotebook = notesRef.current.filter((n) => !n.trashed && n.folderId === dragged.folderId);
|
||||||
|
const order = reindex(inNotebook, dragId, targetId);
|
||||||
|
if (!order) return;
|
||||||
|
|
||||||
|
const position = new Map(order.map((id, i) => [id, i]));
|
||||||
|
setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n)));
|
||||||
|
void sync.reorderNotes(order);
|
||||||
|
}, []);
|
||||||
|
|
||||||
/* ── Notebooks ── */
|
/* ── Notebooks ── */
|
||||||
|
|
||||||
const createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
|
const createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
|
||||||
@@ -311,6 +333,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
|||||||
permanentDeleteNote,
|
permanentDeleteNote,
|
||||||
emptyTrash,
|
emptyTrash,
|
||||||
moveNote,
|
moveNote,
|
||||||
|
reorderNotes,
|
||||||
createNotebook,
|
createNotebook,
|
||||||
updateNotebook,
|
updateNotebook,
|
||||||
deleteNotebook,
|
deleteNotebook,
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* Bringing Markdown back in.
|
||||||
|
*
|
||||||
|
* Notes leave this app as `.md` files, and as a `.zip` when there are pictures
|
||||||
|
* or drawings beside them. Both come back here. A `.zip` written by the exporter
|
||||||
|
* makes the round trip nearly whole: the text, the tables, the task boxes, the
|
||||||
|
* embedded images — re-uploaded as attachments so they are encrypted at rest
|
||||||
|
* again like anything else — and the drawing, which returns as the PNG it was
|
||||||
|
* exported as.
|
||||||
|
*
|
||||||
|
* The one thing that cannot come back as it left is a drawing's *strokes*. A
|
||||||
|
* PNG is pixels; the vectors that produced it are not recoverable from it. The
|
||||||
|
* drawing therefore returns as a picture in the note — visible, exportable,
|
||||||
|
* printable, but no longer editable with the pen. That is stated in the result
|
||||||
|
* rather than glossed over, because silently downgrading someone's data is the
|
||||||
|
* kind of surprise that is only discovered months later.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as sync from "../syncEngine";
|
||||||
|
import * as db from "../offlineStore";
|
||||||
|
import { serializeNoteContent, type DocNode } from "../editor/content";
|
||||||
|
import { markdownToNote } from "./markdownImport";
|
||||||
|
import { readZip, type ZipFile } from "./zipRead";
|
||||||
|
import type { NoteMeta } from "../types";
|
||||||
|
|
||||||
|
export interface ImportProgress {
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
notes: NoteMeta[];
|
||||||
|
/** Notes whose drawing came back as a picture instead of editable strokes. */
|
||||||
|
flattenedDrawings: number;
|
||||||
|
/** Images referenced by a note but not present in the archive. */
|
||||||
|
missingImages: number;
|
||||||
|
/** Files that could not be read at all, by name. */
|
||||||
|
failed: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKDOWN = /\.(md|markdown|txt)$/i;
|
||||||
|
const IMAGE_EXT: Record<string, string> = {
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
webp: "image/webp",
|
||||||
|
gif: "image/gif",
|
||||||
|
avif: "image/avif",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mimeOf = (name: string) => IMAGE_EXT[name.split(".").pop()?.toLowerCase() ?? ""] ?? null;
|
||||||
|
|
||||||
|
const baseName = (path: string) =>
|
||||||
|
path
|
||||||
|
.split("/")
|
||||||
|
.pop()!
|
||||||
|
.replace(/\.[^.]+$/, "");
|
||||||
|
|
||||||
|
/** Resolves `../bilder/x.png` against the folder the note file sits in. */
|
||||||
|
function resolvePath(from: string, target: string): string {
|
||||||
|
if (/^[a-z]+:/i.test(target)) return target; // absolute URL — not an archive path
|
||||||
|
const parts = from.split("/").slice(0, -1);
|
||||||
|
for (const segment of decodeURIComponent(target).split("/")) {
|
||||||
|
if (segment === "." || segment === "") continue;
|
||||||
|
if (segment === "..") parts.pop();
|
||||||
|
else parts.push(segment);
|
||||||
|
}
|
||||||
|
return parts.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── documents ── */
|
||||||
|
|
||||||
|
/** Depth-first walk that may replace a node with another (or with nothing). */
|
||||||
|
function mapNodes(node: DocNode, fn: (n: DocNode) => DocNode | null): DocNode | null {
|
||||||
|
const mapped = fn(node);
|
||||||
|
if (!mapped) return null;
|
||||||
|
if (Array.isArray(mapped.content)) {
|
||||||
|
mapped.content = mapped.content.map((c) => mapNodes(c, fn)).filter((c): c is DocNode => c !== null);
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The exporter's stand-in for a drawing, so it can be swapped for the real PNG. */
|
||||||
|
const isDrawingMarker = (n: DocNode) =>
|
||||||
|
n.type === "blockquote" && JSON.stringify(n.content ?? "").includes("Zeichnung");
|
||||||
|
|
||||||
|
/* ── one note ── */
|
||||||
|
|
||||||
|
interface Sources {
|
||||||
|
/** Archive path → bytes, for resolving relative image references. */
|
||||||
|
files: Map<string, Uint8Array>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadPicture(bytes: Uint8Array, name: string): Promise<string | null> {
|
||||||
|
const mime = mimeOf(name) ?? "image/png";
|
||||||
|
try {
|
||||||
|
// A copy, because a view onto the archive buffer would carry the whole
|
||||||
|
// archive into the Blob.
|
||||||
|
const copy = new Uint8Array(bytes.byteLength);
|
||||||
|
copy.set(bytes);
|
||||||
|
const blob = new Blob([copy.buffer], { type: mime });
|
||||||
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => resolve(String(r.result));
|
||||||
|
r.onerror = () => reject(r.error);
|
||||||
|
r.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
const { id } = await sync.uploadAttachment(dataUrl, name);
|
||||||
|
// Seed the offline cache from bytes already in hand, so the picture shows
|
||||||
|
// immediately instead of being fetched back from the server.
|
||||||
|
await db.putAttachmentBlob(id, blob).catch(() => {});
|
||||||
|
return id;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importOne(
|
||||||
|
markdownPath: string,
|
||||||
|
source: string,
|
||||||
|
folderId: string,
|
||||||
|
sources: Sources,
|
||||||
|
result: ImportResult
|
||||||
|
): Promise<NoteMeta | null> {
|
||||||
|
const parsed = markdownToNote(source);
|
||||||
|
const title = parsed.title || baseName(markdownPath) || "Importiert";
|
||||||
|
|
||||||
|
/* Pictures first: the document has to name attachment ids, not archive paths. */
|
||||||
|
const uploaded = new Map<string, string>();
|
||||||
|
for (const reference of new Set(parsed.imagePaths)) {
|
||||||
|
const path = resolvePath(markdownPath, reference);
|
||||||
|
const bytes = sources.files.get(path);
|
||||||
|
if (!bytes) {
|
||||||
|
result.missingImages++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = await uploadPicture(bytes, path);
|
||||||
|
if (id) uploaded.set(reference, id);
|
||||||
|
else result.missingImages++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The drawing, if the archive carried one. Two layouts exist: a notebook
|
||||||
|
* export puts it in `zeichnungen/`, a single-note export writes it beside the
|
||||||
|
* file as `<name>-zeichnung.png`. */
|
||||||
|
const base = baseName(markdownPath);
|
||||||
|
const drawingPath = [`zeichnungen/${base}.png`, `${base}-zeichnung.png`].find((p) => sources.files.has(p));
|
||||||
|
const drawingId = drawingPath ? await uploadPicture(sources.files.get(drawingPath)!, drawingPath) : null;
|
||||||
|
if (drawingId) result.flattenedDrawings++;
|
||||||
|
|
||||||
|
let doc = mapNodes(parsed.doc, (n) => {
|
||||||
|
if (n.type === "image") {
|
||||||
|
const id = n.attrs?.src ? uploaded.get(n.attrs.src) : null;
|
||||||
|
// An image whose file is not in the archive keeps its address: if it was a
|
||||||
|
// web URL it still works, and if it was a missing file the note at least
|
||||||
|
// records what used to be there.
|
||||||
|
return id ? { ...n, attrs: { ...n.attrs, attachmentId: id, src: null } } : n;
|
||||||
|
}
|
||||||
|
// The exporter leaves a note saying the drawing lies beside the file. Now it
|
||||||
|
// is back inside the note, so the note about it would be wrong.
|
||||||
|
if (drawingId && isDrawingMarker(n)) {
|
||||||
|
return { type: "image", attrs: { attachmentId: drawingId, alt: "Zeichnung", src: null } };
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
})!;
|
||||||
|
|
||||||
|
// No marker to replace (a hand-written archive, or an older export) — the
|
||||||
|
// drawing still belongs in the note, so it goes at the end.
|
||||||
|
if (drawingId && !JSON.stringify(doc).includes(drawingId)) {
|
||||||
|
doc = {
|
||||||
|
...doc,
|
||||||
|
content: [
|
||||||
|
...(doc.content ?? []),
|
||||||
|
{ type: "image", attrs: { attachmentId: drawingId, alt: "Zeichnung", src: null } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = await sync.createNote(folderId);
|
||||||
|
await sync.updateNoteTitle(note.id, title);
|
||||||
|
await sync.updateNoteContent(
|
||||||
|
note.id,
|
||||||
|
serializeNoteContent(doc),
|
||||||
|
[],
|
||||||
|
[...uploaded.values(), ...(drawingId ? [drawingId] : [])]
|
||||||
|
);
|
||||||
|
return { ...note, title };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── entry point ── */
|
||||||
|
|
||||||
|
const textOf = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports the chosen files into a notebook.
|
||||||
|
*
|
||||||
|
* Each `.md` becomes one note; each `.zip` is unpacked and every Markdown file
|
||||||
|
* inside becomes one. A file that cannot be read is named in the result rather
|
||||||
|
* than aborting the rest — importing forty notes should not fail because the
|
||||||
|
* thirty-first is damaged.
|
||||||
|
*/
|
||||||
|
export async function importFiles(
|
||||||
|
files: File[],
|
||||||
|
folderId: string,
|
||||||
|
onProgress?: (p: ImportProgress) => void
|
||||||
|
): Promise<ImportResult> {
|
||||||
|
const result: ImportResult = { notes: [], flattenedDrawings: 0, missingImages: 0, failed: [] };
|
||||||
|
|
||||||
|
/* Everything is unpacked first, so the total is honest: a single ZIP can hold
|
||||||
|
forty notes, and a progress bar that only counts files chosen would sit at
|
||||||
|
"1 of 1" for a minute. */
|
||||||
|
const jobs: Array<{ path: string; source: string; sources: Sources }> = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
if (/\.zip$/i.test(file.name)) {
|
||||||
|
const members: ZipFile[] = await readZip(await file.arrayBuffer());
|
||||||
|
const map = new Map(members.map((m) => [m.name, m.bytes]));
|
||||||
|
const sources: Sources = { files: map };
|
||||||
|
const markdown = members.filter((m) => MARKDOWN.test(m.name));
|
||||||
|
for (const m of markdown) {
|
||||||
|
// Our own archives carry an index file. It is a table of contents, not
|
||||||
|
// a note, and importing it would add a phantom note linking to files
|
||||||
|
// that no longer exist. A lone README is taken at face value, since
|
||||||
|
// then it is the only thing in there to import.
|
||||||
|
if (markdown.length > 1 && /^readme\.md$/i.test(m.name)) continue;
|
||||||
|
jobs.push({ path: m.name, source: textOf(m.bytes), sources });
|
||||||
|
}
|
||||||
|
} else if (MARKDOWN.test(file.name)) {
|
||||||
|
jobs.push({ path: file.name, source: await file.text(), sources: { files: new Map() } });
|
||||||
|
} else {
|
||||||
|
result.failed.push(file.name);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[import] ${file.name}:`, err);
|
||||||
|
result.failed.push(file.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [i, job] of jobs.entries()) {
|
||||||
|
onProgress?.({ done: i, total: jobs.length, label: baseName(job.path) });
|
||||||
|
try {
|
||||||
|
const note = await importOne(job.path, job.source, folderId, job.sources, result);
|
||||||
|
if (note) result.notes.push(note);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[import] ${job.path}:`, err);
|
||||||
|
result.failed.push(job.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onProgress?.({ done: jobs.length, total: jobs.length, label: "" });
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { noteToMarkdown } from "../export/markdown";
|
||||||
|
import { inlineNodes, markdownToNote } from "./markdownImport";
|
||||||
|
import type { DocNode } from "../editor/content";
|
||||||
|
|
||||||
|
const doc = (md: string) => markdownToNote(md).doc;
|
||||||
|
const first = (md: string) => doc(md).content![0]!;
|
||||||
|
const plain = (n: DocNode): string => {
|
||||||
|
if (n.text) return n.text;
|
||||||
|
return (n.content ?? []).map(plain).join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("blocks", () => {
|
||||||
|
it("reads headings, capping them at the three levels the editor has", () => {
|
||||||
|
expect(first("# Titel\n\nrumpf").type).toBe("paragraph"); // level 1 becomes the title
|
||||||
|
expect(first("## Zwei")).toMatchObject({ type: "heading", attrs: { level: 2 } });
|
||||||
|
expect(first("##### Fünf")).toMatchObject({ type: "heading", attrs: { level: 3 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a fenced code block verbatim, markup and all", () => {
|
||||||
|
const block = first("```js\nconst a = **not bold**;\n```");
|
||||||
|
expect(block).toMatchObject({ type: "codeBlock", attrs: { language: "js" } });
|
||||||
|
expect(plain(block)).toBe("const a = **not bold**;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a horizontal rule", () => {
|
||||||
|
expect(first("---").type).toBe("horizontalRule");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a blockquote, including the blocks inside it", () => {
|
||||||
|
const quote = first("> ## Kopf\n> Text");
|
||||||
|
expect(quote.type).toBe("blockquote");
|
||||||
|
expect(quote.content![0]!.type).toBe("heading");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins the lines of a paragraph", () => {
|
||||||
|
expect(plain(first("erste Zeile\nzweite Zeile"))).toBe("erste Zeile zweite Zeile");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops a paragraph at the block that follows it", () => {
|
||||||
|
const content = doc("Absatz\n## Überschrift").content!;
|
||||||
|
expect(content.map((n) => n.type)).toEqual(["paragraph", "heading"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lists", () => {
|
||||||
|
it("reads a bullet list", () => {
|
||||||
|
const list = first("- eins\n- zwei");
|
||||||
|
expect(list.type).toBe("bulletList");
|
||||||
|
expect(list.content).toHaveLength(2);
|
||||||
|
expect(plain(list.content![1]!)).toBe("zwei");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads an ordered list and keeps where it starts", () => {
|
||||||
|
expect(first("3. drei\n4. vier")).toMatchObject({ type: "orderedList", attrs: { start: 3 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a task list with its boxes", () => {
|
||||||
|
const list = first("- [x] erledigt\n- [ ] offen");
|
||||||
|
expect(list.type).toBe("taskList");
|
||||||
|
expect(list.content![0]).toMatchObject({ type: "taskItem", attrs: { checked: true } });
|
||||||
|
expect(list.content![1]).toMatchObject({ type: "taskItem", attrs: { checked: false } });
|
||||||
|
expect(plain(list.content![0]!)).toBe("erledigt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits where a list stops having checkboxes, rather than losing them", () => {
|
||||||
|
// The editor has no node for "a list where some items have a box", so the
|
||||||
|
// run ends and a second list begins. The alternative — one list with a
|
||||||
|
// literal "[x]" in the text — throws away what the boxes meant.
|
||||||
|
const content = doc("- [x] erledigt\n- einfach").content!;
|
||||||
|
expect(content.map((n) => n.type)).toEqual(["taskList", "bulletList"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nests by indentation", () => {
|
||||||
|
const outer = first("- oben\n - innen");
|
||||||
|
const inner = outer.content![0]!.content!.find((n) => n.type === "bulletList");
|
||||||
|
expect(inner).toBeDefined();
|
||||||
|
expect(plain(inner!)).toBe("innen");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tables", () => {
|
||||||
|
it("reads a table with a header row", () => {
|
||||||
|
const table = first("| A | B |\n| --- | --- |\n| 1 | 2 |");
|
||||||
|
expect(table.type).toBe("table");
|
||||||
|
expect(table.content).toHaveLength(2);
|
||||||
|
expect(table.content![0]!.content![0]!.type).toBe("tableHeader");
|
||||||
|
expect(plain(table.content![1]!.content![1]!)).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognises the empty header the exporter writes for a headerless table", () => {
|
||||||
|
const table = first("| | |\n| --- | --- |\n| 1 | 2 |");
|
||||||
|
expect(table.content).toHaveLength(1);
|
||||||
|
expect(table.content![0]!.content![0]!.type).toBe("tableCell");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pads a short row so every row has the same number of cells", () => {
|
||||||
|
const table = first("| A | B |\n| --- | --- |\n| 1 |");
|
||||||
|
expect(table.content![1]!.content).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inline", () => {
|
||||||
|
const marksOf = (md: string) => inlineNodes(md).map((n) => (n as any).marks?.map((m: any) => m.type) ?? []);
|
||||||
|
|
||||||
|
it("reads bold, italic, strikethrough and code", () => {
|
||||||
|
expect(marksOf("**b**")).toEqual([["bold"]]);
|
||||||
|
expect(marksOf("*i*")).toEqual([["italic"]]);
|
||||||
|
expect(marksOf("~~s~~")).toEqual([["strike"]]);
|
||||||
|
expect(marksOf("`c`")).toEqual([["code"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nests marks", () => {
|
||||||
|
expect(marksOf("**_beides_**")).toEqual([["bold", "italic"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves underscores inside a word alone", () => {
|
||||||
|
// snake_case_names are the most common thing a naive parser mangles.
|
||||||
|
expect(plain({ content: inlineNodes("snake_case_name") } as DocNode)).toBe("snake_case_name");
|
||||||
|
expect(marksOf("snake_case_name")).toEqual([[]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not read markup inside a code span", () => {
|
||||||
|
expect(plain({ content: inlineNodes("`**roh**`") } as DocNode)).toBe("**roh**");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a link", () => {
|
||||||
|
const [node] = inlineNodes("[Beispiel](https://example.com)");
|
||||||
|
expect((node as any).marks[0]).toMatchObject({ type: "link", attrs: { href: "https://example.com" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads an image and reports its path", () => {
|
||||||
|
const parsed = markdownToNote("");
|
||||||
|
expect(parsed.imagePaths).toEqual(["bilder/abc.png"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the HTML underline the exporter falls back to", () => {
|
||||||
|
expect(marksOf("<u>x</u>")).toEqual([["underline"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a lone asterisk as a character, not an unterminated mark", () => {
|
||||||
|
expect(plain({ content: inlineNodes("2 * 3 = 6") } as DocNode)).toBe("2 * 3 = 6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes the exporter's escapes without touching a Windows path", () => {
|
||||||
|
expect(plain({ content: inlineNodes("a \\* b") } as DocNode)).toBe("a * b");
|
||||||
|
expect(plain({ content: inlineNodes("C:\\temp\\neu") } as DocNode)).toBe("C:\\temp\\neu");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the title", () => {
|
||||||
|
it("comes from a leading level-one heading", () => {
|
||||||
|
expect(markdownToNote("# Mein Titel\n\nText").title).toBe("Mein Titel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the timestamp line the exporter writes under it", () => {
|
||||||
|
const parsed = markdownToNote("# T\n\n*07.08.2026, 12:00:00*\n\nText");
|
||||||
|
expect(parsed.doc.content).toHaveLength(1);
|
||||||
|
expect(plain(parsed.doc.content![0]!)).toBe("Text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is absent when the file does not start with one", () => {
|
||||||
|
expect(markdownToNote("Nur Text").title).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a level-one heading further down as a heading", () => {
|
||||||
|
const parsed = markdownToNote("Text\n\n# Später");
|
||||||
|
expect(parsed.title).toBeNull();
|
||||||
|
expect(parsed.doc.content!.map((n) => n.type)).toEqual(["paragraph", "heading"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("an empty file", () => {
|
||||||
|
it("still produces a document the editor can open", () => {
|
||||||
|
expect(markdownToNote("").doc).toEqual({ type: "doc", content: [{ type: "paragraph" }] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* The pair matters more than either half: what leaves has to be able to come
|
||||||
|
* back. These run a document through the exporter and read the result. */
|
||||||
|
describe("round trip", () => {
|
||||||
|
const roundTrip = (source: DocNode) => markdownToNote(noteToMarkdown(source)).doc;
|
||||||
|
|
||||||
|
it("brings back headings, lists, tasks, quotes, code and rules", () => {
|
||||||
|
const source: DocNode = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Kopf" }] },
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "Ein Absatz." }] },
|
||||||
|
{
|
||||||
|
type: "bulletList",
|
||||||
|
content: [
|
||||||
|
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "eins" }] }] },
|
||||||
|
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "zwei" }] }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "taskList",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "taskItem",
|
||||||
|
attrs: { checked: true },
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "getan" }] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "blockquote",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "zitiert" }] }],
|
||||||
|
},
|
||||||
|
{ type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "const a = 1;" }] },
|
||||||
|
{ type: "horizontalRule" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const back = roundTrip(source);
|
||||||
|
expect(back.content!.map((n) => n.type)).toEqual([
|
||||||
|
"heading",
|
||||||
|
"paragraph",
|
||||||
|
"bulletList",
|
||||||
|
"taskList",
|
||||||
|
"blockquote",
|
||||||
|
"codeBlock",
|
||||||
|
"horizontalRule",
|
||||||
|
]);
|
||||||
|
expect(plain(back)).toContain("getan");
|
||||||
|
expect(back.content![5]!.content![0]!.text).toBe("const a = 1;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("brings back the marks on a sentence", () => {
|
||||||
|
const source: DocNode = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "fett", marks: [{ type: "bold" }] } as DocNode,
|
||||||
|
{ type: "text", text: " und " },
|
||||||
|
{ type: "text", text: "kursiv", marks: [{ type: "italic" }] } as DocNode,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const back = roundTrip(source);
|
||||||
|
expect(plain(back)).toBe("fett und kursiv");
|
||||||
|
const marks = back.content![0]!.content!.map((n) => (n as any).marks?.[0]?.type);
|
||||||
|
expect(marks).toEqual(["bold", undefined, "italic"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("brings back a table with its header", () => {
|
||||||
|
const cell = (type: string, text: string): DocNode => ({
|
||||||
|
type,
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
||||||
|
});
|
||||||
|
const source: DocNode = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
content: [
|
||||||
|
{ type: "tableRow", content: [cell("tableHeader", "A"), cell("tableHeader", "B")] },
|
||||||
|
{ type: "tableRow", content: [cell("tableCell", "1"), cell("tableCell", "2")] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const back = roundTrip(source);
|
||||||
|
const table = back.content![0]!;
|
||||||
|
expect(table.type).toBe("table");
|
||||||
|
expect(table.content![0]!.content![0]!.type).toBe("tableHeader");
|
||||||
|
expect(plain(table)).toContain("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a title through the round trip instead of doubling it into the body", () => {
|
||||||
|
const source: DocNode = {
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "Inhalt" }] }],
|
||||||
|
};
|
||||||
|
const md = noteToMarkdown(source, { title: "Einkaufsliste", updatedAt: "2026-08-07T10:00:00Z" });
|
||||||
|
const parsed = markdownToNote(md);
|
||||||
|
expect(parsed.title).toBe("Einkaufsliste");
|
||||||
|
expect(plain(parsed.doc)).toBe("Inhalt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a picture's path so it can be matched back to the archive", () => {
|
||||||
|
const source: DocNode = {
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "image", attrs: { attachmentId: "abc123", alt: "Foto" } }],
|
||||||
|
};
|
||||||
|
const md = noteToMarkdown(source, { attachmentPath: (id) => `bilder/${id}.png` });
|
||||||
|
expect(markdownToNote(md).imagePaths).toEqual(["bilder/abc123.png"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
/**
|
||||||
|
* Markdown → a note document.
|
||||||
|
*
|
||||||
|
* The counterpart to `export/markdown.ts`. Notes leave this app as Markdown so
|
||||||
|
* they are not trapped in it; being able to bring them back is the other half of
|
||||||
|
* that promise — and it is what makes an archive a backup rather than a souvenir.
|
||||||
|
*
|
||||||
|
* This is a small, deliberate subset: exactly the constructs the exporter emits,
|
||||||
|
* plus the ones any hand-written note is likely to use. It is not CommonMark and
|
||||||
|
* does not try to be. A full parser is thousands of lines and a permanent
|
||||||
|
* maintenance surface, and the failure mode here is mild — an unrecognised
|
||||||
|
* construct arrives as the literal text that was written, which is still the
|
||||||
|
* user's words, in the user's order, and can be fixed in the editor in seconds.
|
||||||
|
*
|
||||||
|
* Kept free of the DOM and of TipTap so it can be unit tested as what it is: a
|
||||||
|
* function from a string to a document.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DocNode } from "../editor/content";
|
||||||
|
|
||||||
|
export interface ImportedNote {
|
||||||
|
/** Taken from a leading `# Heading`, which is what the exporter writes. */
|
||||||
|
title: string | null;
|
||||||
|
doc: DocNode;
|
||||||
|
/** Image sources found in the document, in the order they appear. */
|
||||||
|
imagePaths: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── inline ── */
|
||||||
|
|
||||||
|
interface Mark {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (value: string, marks: Mark[] = []): DocNode =>
|
||||||
|
marks.length ? ({ type: "text", text: value, marks } as DocNode) : { type: "text", text: value };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undoes `escapeText`/`escapeLineStart` from the exporter.
|
||||||
|
*
|
||||||
|
* Only backslash-escapes of characters Markdown actually gives meaning to are
|
||||||
|
* removed. A Windows path such as `C:\temp\new` keeps its backslashes, because
|
||||||
|
* `\t` and `\n` are not Markdown escapes and turning them into anything else
|
||||||
|
* would corrupt the text.
|
||||||
|
*/
|
||||||
|
const unescape = (s: string) => s.replace(/\\([\\`*_[\]|~<>#+.-])/g, "$1");
|
||||||
|
|
||||||
|
/** One `[label](target)` or ``, respecting nested brackets in the label. */
|
||||||
|
function matchLinkAt(src: string, at: number): { label: string; target: string; end: number } | null {
|
||||||
|
let i = at;
|
||||||
|
if (src[i] === "!") i++;
|
||||||
|
if (src[i] !== "[") return null;
|
||||||
|
i++;
|
||||||
|
|
||||||
|
let depth = 1;
|
||||||
|
const start = i;
|
||||||
|
for (; i < src.length; i++) {
|
||||||
|
if (src[i] === "\\") {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (src[i] === "[") depth++;
|
||||||
|
else if (src[i] === "]") {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (depth !== 0 || src[i + 1] !== "(") return null;
|
||||||
|
const label = src.slice(start, i);
|
||||||
|
|
||||||
|
const close = src.indexOf(")", i + 2);
|
||||||
|
if (close === -1) return null;
|
||||||
|
return { label, target: src.slice(i + 2, close).trim(), end: close + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delimited inline spans, longest marker first so `**` is never read as two `*`.
|
||||||
|
*
|
||||||
|
* `_` is only honoured at a word boundary: `snake_case_name` is an identifier,
|
||||||
|
* not emphasis, and treating it as emphasis is the single most common way a
|
||||||
|
* naive Markdown parser mangles technical notes.
|
||||||
|
*/
|
||||||
|
const DELIMITERS: Array<{ open: string; mark: string; wordBoundary?: boolean }> = [
|
||||||
|
{ open: "~~", mark: "strike" },
|
||||||
|
{ open: "**", mark: "bold" },
|
||||||
|
{ open: "__", mark: "bold", wordBoundary: true },
|
||||||
|
{ open: "*", mark: "italic" },
|
||||||
|
{ open: "_", mark: "italic", wordBoundary: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isWordChar = (c: string | undefined) => !!c && /[\p{L}\p{N}]/u.test(c);
|
||||||
|
|
||||||
|
export function inlineNodes(src: string, marks: Mark[] = [], images?: string[]): DocNode[] {
|
||||||
|
const out: DocNode[] = [];
|
||||||
|
let buffer = "";
|
||||||
|
const flush = () => {
|
||||||
|
if (buffer) {
|
||||||
|
out.push(text(unescape(buffer), marks));
|
||||||
|
buffer = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < src.length) {
|
||||||
|
const c = src[i]!;
|
||||||
|
|
||||||
|
if (c === "\\" && i + 1 < src.length) {
|
||||||
|
buffer += c + src[i + 1];
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code spans win over everything: their content is literal by definition.
|
||||||
|
if (c === "`") {
|
||||||
|
const end = src.indexOf("`", i + 1);
|
||||||
|
if (end > i) {
|
||||||
|
flush();
|
||||||
|
out.push(text(src.slice(i + 1, end), [...marks, { type: "code" }]));
|
||||||
|
i = end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === "<") {
|
||||||
|
const u = /^<u>([\s\S]*?)<\/u>/.exec(src.slice(i));
|
||||||
|
if (u) {
|
||||||
|
flush();
|
||||||
|
out.push(...inlineNodes(u[1]!, [...marks, { type: "underline" }], images));
|
||||||
|
i += u[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === "!" || c === "[") {
|
||||||
|
const link = matchLinkAt(src, i);
|
||||||
|
if (link) {
|
||||||
|
flush();
|
||||||
|
if (c === "!") {
|
||||||
|
images?.push(link.target);
|
||||||
|
out.push({
|
||||||
|
type: "image",
|
||||||
|
attrs: { src: link.target, alt: unescape(link.label) || null, attachmentId: null },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
out.push(
|
||||||
|
...inlineNodes(link.label, [...marks, { type: "link", attrs: { href: link.target } }], images)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
i = link.end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const delimiter = DELIMITERS.find((d) => {
|
||||||
|
if (!src.startsWith(d.open, i)) return false;
|
||||||
|
if (d.wordBoundary && isWordChar(src[i - 1])) return false;
|
||||||
|
if (marks.some((m) => m.type === d.mark)) return false; // already inside one
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (delimiter) {
|
||||||
|
const end = src.indexOf(delimiter.open, i + delimiter.open.length);
|
||||||
|
// An opener with no closer is just a character the user typed.
|
||||||
|
if (end > i + delimiter.open.length) {
|
||||||
|
flush();
|
||||||
|
out.push(
|
||||||
|
...inlineNodes(
|
||||||
|
src.slice(i + delimiter.open.length, end),
|
||||||
|
[...marks, { type: delimiter.mark }],
|
||||||
|
images
|
||||||
|
)
|
||||||
|
);
|
||||||
|
i = end + delimiter.open.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += c;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paragraph = (line: string, images?: string[]): DocNode => {
|
||||||
|
const content = inlineNodes(line, [], images);
|
||||||
|
return content.length ? { type: "paragraph", content } : { type: "paragraph" };
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── blocks ── */
|
||||||
|
|
||||||
|
const RULE = /^ {0,3}(-{3,}|\*{3,}|_{3,})\s*$/;
|
||||||
|
const HEADING = /^ {0,3}(#{1,6})\s+(.*)$/;
|
||||||
|
const FENCE = /^ {0,3}(`{3,}|~{3,})\s*(\S*)/;
|
||||||
|
const BULLET = /^(\s*)([-*+])\s+(.*)$/;
|
||||||
|
const ORDERED = /^(\s*)(\d+)[.)]\s+(.*)$/;
|
||||||
|
const TASK = /^\[([ xX])\]\s*(.*)$/;
|
||||||
|
const TABLE_DIVIDER = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
|
||||||
|
|
||||||
|
const indentOf = (s: string) => s.replace(/\t/g, " ").match(/^ */)![0]!.length;
|
||||||
|
|
||||||
|
/** Splits a table row on unescaped pipes. */
|
||||||
|
function tableCells(line: string): string[] {
|
||||||
|
const cells: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
if (line[i] === "\\") {
|
||||||
|
current += line[i]! + (line[i + 1] ?? "");
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line[i] === "|") {
|
||||||
|
cells.push(current);
|
||||||
|
current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += line[i];
|
||||||
|
}
|
||||||
|
cells.push(current);
|
||||||
|
// A row written `| a | b |` yields an empty cell at each end.
|
||||||
|
if (cells[0]!.trim() === "") cells.shift();
|
||||||
|
if (cells.length && cells[cells.length - 1]!.trim() === "") cells.pop();
|
||||||
|
return cells.map((c) => c.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBlocks(lines: string[], images: string[]): DocNode[] {
|
||||||
|
const out: DocNode[] = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i]!;
|
||||||
|
|
||||||
|
if (!line.trim()) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* fenced code — taken verbatim, including anything that looks like markup */
|
||||||
|
const fence = FENCE.exec(line);
|
||||||
|
if (fence) {
|
||||||
|
const marker = fence[1]!;
|
||||||
|
const language = fence[2] || null;
|
||||||
|
const body: string[] = [];
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && !lines[i]!.trimStart().startsWith(marker)) (body.push(lines[i]!), i++);
|
||||||
|
i++; // closing fence, if there is one
|
||||||
|
out.push({
|
||||||
|
type: "codeBlock",
|
||||||
|
attrs: { language },
|
||||||
|
...(body.length ? { content: [{ type: "text", text: body.join("\n") }] } : {}),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RULE.test(line)) {
|
||||||
|
out.push({ type: "horizontalRule" });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = HEADING.exec(line);
|
||||||
|
if (heading) {
|
||||||
|
// The editor only offers three levels; deeper ones would be dropped
|
||||||
|
// entirely by the schema, so they are folded into the deepest it has.
|
||||||
|
const level = Math.min(3, heading[1]!.length);
|
||||||
|
out.push({ type: "heading", attrs: { level }, content: inlineNodes(heading[2]!.trim(), [], images) });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\s*>/.test(line)) {
|
||||||
|
const inner: string[] = [];
|
||||||
|
while (i < lines.length) {
|
||||||
|
const current = lines[i]!;
|
||||||
|
const marked = /^\s*>/.test(current);
|
||||||
|
// Markdown lets a quoted paragraph run on without the `>`, but only as a
|
||||||
|
// paragraph: a line that opens a block of its own is outside the quote.
|
||||||
|
// Swallowing those used to pull the code fence and everything after it
|
||||||
|
// into the blockquote.
|
||||||
|
if (!marked && (!inner.length || !current.trim() || startsBlock(current, lines[i + 1]))) break;
|
||||||
|
inner.push(current.replace(/^\s*>\s?/, ""));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
out.push({ type: "blockquote", content: parseBlocks(inner, images) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* table — a header row followed by the `|---|` divider */
|
||||||
|
if (line.includes("|") && i + 1 < lines.length && TABLE_DIVIDER.test(lines[i + 1]!)) {
|
||||||
|
const rows: string[][] = [tableCells(line)];
|
||||||
|
i += 2;
|
||||||
|
while (i < lines.length && lines[i]!.includes("|") && lines[i]!.trim()) {
|
||||||
|
rows.push(tableCells(lines[i]!));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
const width = Math.max(...rows.map((r) => r.length));
|
||||||
|
// The exporter writes an all-empty header row for a headerless table;
|
||||||
|
// recognising that keeps a round trip from growing a header it never had.
|
||||||
|
const headerIsReal = rows[0]!.some((c) => c !== "");
|
||||||
|
const cell = (value: string, header: boolean): DocNode => ({
|
||||||
|
type: header ? "tableHeader" : "tableCell",
|
||||||
|
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||||
|
content: [paragraph(value, images)],
|
||||||
|
});
|
||||||
|
const body = headerIsReal ? rows : rows.slice(1);
|
||||||
|
out.push({
|
||||||
|
type: "table",
|
||||||
|
content: body.map((row, index) => ({
|
||||||
|
type: "tableRow",
|
||||||
|
content: Array.from({ length: width }, (_, c) => cell(row[c] ?? "", headerIsReal && index === 0)),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bullet = BULLET.exec(line);
|
||||||
|
const ordered = !bullet && ORDERED.exec(line);
|
||||||
|
if (bullet || ordered) {
|
||||||
|
const consumed = parseList(lines, i, images);
|
||||||
|
out.push(consumed.node);
|
||||||
|
i = consumed.next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* paragraph — runs to the next blank line or block opener */
|
||||||
|
const buffer: string[] = [];
|
||||||
|
while (i < lines.length && lines[i]!.trim() && !startsBlock(lines[i]!, lines[i + 1])) {
|
||||||
|
buffer.push(lines[i]!.trim());
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
// The line opens a block we did not consume above; keep it as text rather
|
||||||
|
// than looping forever on it.
|
||||||
|
buffer.push(lines[i]!.trim());
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
// A trailing double space is Markdown's hard line break.
|
||||||
|
out.push(paragraph(buffer.join(" "), images));
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a line begins a block, so a paragraph must stop before it. */
|
||||||
|
function startsBlock(line: string, next: string | undefined): boolean {
|
||||||
|
return (
|
||||||
|
HEADING.test(line) ||
|
||||||
|
FENCE.test(line) ||
|
||||||
|
RULE.test(line) ||
|
||||||
|
/^\s*>/.test(line) ||
|
||||||
|
BULLET.test(line) ||
|
||||||
|
ORDERED.test(line) ||
|
||||||
|
(line.includes("|") && !!next && TABLE_DIVIDER.test(next))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One list and everything nested inside it.
|
||||||
|
*
|
||||||
|
* Nesting is by indentation, which is how the exporter writes it. Items marked
|
||||||
|
* `- [ ]` make a task list, plain ones a bullet list, and a run that changes
|
||||||
|
* from one to the other ends and a new list begins — which is exactly what the
|
||||||
|
* editor holds, since those are two different nodes.
|
||||||
|
*/
|
||||||
|
function parseList(lines: string[], start: number, images: string[]): { node: DocNode; next: number } {
|
||||||
|
const first = BULLET.exec(lines[start]!) || ORDERED.exec(lines[start]!);
|
||||||
|
const baseIndent = indentOf(first![1]!);
|
||||||
|
const isOrdered = !BULLET.test(lines[start]!);
|
||||||
|
|
||||||
|
const items: Array<{ body: string[]; task: RegExpExecArray | null }> = [];
|
||||||
|
let i = start;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i]!;
|
||||||
|
if (!line.trim()) {
|
||||||
|
// A blank line ends the list unless the next line continues it.
|
||||||
|
const following = lines[i + 1];
|
||||||
|
if (!following || !following.trim() || indentOf(following) < baseIndent) break;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = BULLET.exec(line) || ORDERED.exec(line);
|
||||||
|
const indent = indentOf(line);
|
||||||
|
|
||||||
|
if (match && indent === baseIndent && BULLET.test(line) !== isOrdered) {
|
||||||
|
const rest = match[3]!;
|
||||||
|
const task = TASK.exec(rest);
|
||||||
|
// A checkbox list and a plain one are different nodes in the editor, so a
|
||||||
|
// run of items has to be all one or all the other. Without this, a bullet
|
||||||
|
// list written directly above a task list absorbed it and the boxes came
|
||||||
|
// back as literal "[x]" text.
|
||||||
|
if (items.length && !!task !== !!items[0]!.task) break;
|
||||||
|
items.push({ body: [rest], task });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (indent > baseIndent && items.length) {
|
||||||
|
// Deeper: part of the item above, with this list's indent removed.
|
||||||
|
items[items.length - 1]!.body.push(line.slice(baseIndent));
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every item is the same kind by construction — the loop above stops where
|
||||||
|
// that changes — so the first one decides.
|
||||||
|
const allTasks = items.length > 0 && !!items[0]!.task;
|
||||||
|
const itemType = allTasks ? "taskItem" : "listItem";
|
||||||
|
|
||||||
|
const node: DocNode = {
|
||||||
|
type: allTasks ? "taskList" : isOrdered ? "orderedList" : "bulletList",
|
||||||
|
...(isOrdered && !allTasks ? { attrs: { start: Number(first![2]) || 1 } } : {}),
|
||||||
|
content: items.map((it) => {
|
||||||
|
const body = [...it.body];
|
||||||
|
if (allTasks) body[0] = it.task![2]!;
|
||||||
|
return {
|
||||||
|
type: itemType,
|
||||||
|
...(allTasks ? { attrs: { checked: it.task![1]!.toLowerCase() === "x" } } : {}),
|
||||||
|
content: parseBlocks(body, images),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { node, next: i };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── entry point ── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a Markdown file as a note.
|
||||||
|
*
|
||||||
|
* A leading `# Heading` becomes the title — that is what the exporter writes, so
|
||||||
|
* a note exported and imported again keeps its name instead of gaining a
|
||||||
|
* duplicate heading in its body. The italic timestamp line the exporter adds
|
||||||
|
* underneath is dropped for the same reason.
|
||||||
|
*/
|
||||||
|
export function markdownToNote(source: string): ImportedNote {
|
||||||
|
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
||||||
|
const images: string[] = [];
|
||||||
|
|
||||||
|
let title: string | null = null;
|
||||||
|
let from = 0;
|
||||||
|
while (from < lines.length && !lines[from]!.trim()) from++;
|
||||||
|
|
||||||
|
const heading = lines[from] ? HEADING.exec(lines[from]!) : null;
|
||||||
|
if (heading && heading[1]!.length === 1) {
|
||||||
|
title = inlineText(heading[2]!.trim());
|
||||||
|
from++;
|
||||||
|
let after = from;
|
||||||
|
while (after < lines.length && !lines[after]!.trim()) after++;
|
||||||
|
// The exporter's date line, e.g. `*07.08.2026, 12:00:00*`.
|
||||||
|
if (lines[after] && /^\*[^*]+\*$/.test(lines[after]!.trim())) from = after + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = parseBlocks(lines.slice(from), images);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
doc: { type: "doc", content: content.length ? content : [{ type: "paragraph" }] },
|
||||||
|
imagePaths: images,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline Markdown reduced to its plain text — for titles, which carry no marks. */
|
||||||
|
export function inlineText(src: string): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const walk = (nodes: DocNode[]) => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.text) parts.push(n.text);
|
||||||
|
else if (n.content) walk(n.content);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(inlineNodes(src));
|
||||||
|
return parts.join("").trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createZipBytes } from "../export/zip";
|
||||||
|
import { readZip } from "./zipRead";
|
||||||
|
|
||||||
|
const bytes = (u: Uint8Array) => {
|
||||||
|
const copy = new Uint8Array(u.byteLength);
|
||||||
|
copy.set(u);
|
||||||
|
return copy.buffer;
|
||||||
|
};
|
||||||
|
const text = (u: Uint8Array) => new TextDecoder().decode(u);
|
||||||
|
|
||||||
|
describe("readZip", () => {
|
||||||
|
it("reads back what the exporter wrote", async () => {
|
||||||
|
const archive = createZipBytes([
|
||||||
|
{ name: "Notiz.md", data: "# Notiz\n\nInhalt" },
|
||||||
|
{ name: "bilder/abc.png", data: new Uint8Array([137, 80, 78, 71]) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const files = await readZip(bytes(archive));
|
||||||
|
expect(files.map((f) => f.name)).toEqual(["Notiz.md", "bilder/abc.png"]);
|
||||||
|
expect(text(files[0]!.bytes)).toBe("# Notiz\n\nInhalt");
|
||||||
|
expect([...files[1]!.bytes]).toEqual([137, 80, 78, 71]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps names with non-ASCII characters intact", async () => {
|
||||||
|
const archive = createZipBytes([{ name: "Übung – Größe.md", data: "x" }]);
|
||||||
|
const [file] = await readZip(bytes(archive));
|
||||||
|
expect(file!.name).toBe("Übung – Größe.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads an empty file without confusing it for the end of the archive", async () => {
|
||||||
|
const archive = createZipBytes([
|
||||||
|
{ name: "leer.md", data: "" },
|
||||||
|
{ name: "danach.md", data: "hier" },
|
||||||
|
]);
|
||||||
|
const files = await readZip(bytes(archive));
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
expect(text(files[1]!.bytes)).toBe("hier");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses something that is not a ZIP", async () => {
|
||||||
|
await expect(readZip(new TextEncoder().encode("nur Text").buffer)).rejects.toThrow(/keine ZIP/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list for an archive with no entries", async () => {
|
||||||
|
expect(await readZip(bytes(createZipBytes([])))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Extracted bytes must not be views onto the archive: handing one to a Blob
|
||||||
|
* would carry the entire archive along with every picture. */
|
||||||
|
it("hands back bytes of exactly the file's length", async () => {
|
||||||
|
const archive = createZipBytes([
|
||||||
|
{ name: "a.md", data: "aaaa" },
|
||||||
|
{ name: "b.md", data: "bb" },
|
||||||
|
]);
|
||||||
|
const files = await readZip(bytes(archive));
|
||||||
|
expect(files[0]!.bytes.length).toBe(4);
|
||||||
|
expect(files[1]!.bytes.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* A minimal ZIP reader.
|
||||||
|
*
|
||||||
|
* The counterpart to `export/zip.ts`. Our own archives are stored (uncompressed),
|
||||||
|
* but an archive that has been through a file manager, a mail client or a
|
||||||
|
* re-zip on the way back is usually deflated, so both are handled. Inflating
|
||||||
|
* uses the browser's own `DecompressionStream` rather than a bundled inflater —
|
||||||
|
* it is in every engine this app targets, and it costs nothing to ship.
|
||||||
|
*
|
||||||
|
* The central directory is read rather than the local headers: a local header
|
||||||
|
* may declare zero lengths and put the real ones in a data descriptor *after*
|
||||||
|
* the data, which cannot be parsed forwards without decompressing blindly. The
|
||||||
|
* directory always has the true values.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
export interface ZipFile {
|
||||||
|
/** Path inside the archive, with forward slashes. */
|
||||||
|
name: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EOCD = 0x06054b50;
|
||||||
|
const CENTRAL = 0x02014b50;
|
||||||
|
|
||||||
|
/** Finds the end-of-central-directory record, scanning back over any comment. */
|
||||||
|
function findEocd(view: DataView): number {
|
||||||
|
const from = Math.max(0, view.byteLength - 22 - 0xffff);
|
||||||
|
for (let i = view.byteLength - 22; i >= from; i--) {
|
||||||
|
if (view.getUint32(i, true) === EOCD) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inflateRaw(bytes: Uint8Array): Promise<Uint8Array> {
|
||||||
|
if (typeof DecompressionStream === "undefined") {
|
||||||
|
throw new Error("Dieses Archiv ist komprimiert; der Browser kann es nicht entpacken.");
|
||||||
|
}
|
||||||
|
const copy = new Uint8Array(bytes.byteLength);
|
||||||
|
copy.set(bytes);
|
||||||
|
const stream = new Blob([copy.buffer]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
|
||||||
|
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every file in the archive. Directory entries are skipped; encrypted and
|
||||||
|
* otherwise unreadable members are skipped rather than failing the whole import,
|
||||||
|
* because one bad member should not cost the user the other forty notes.
|
||||||
|
*/
|
||||||
|
export async function readZip(buffer: ArrayBuffer): Promise<ZipFile[]> {
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
const all = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
const eocd = findEocd(view);
|
||||||
|
if (eocd < 0) throw new Error("Das ist keine ZIP-Datei.");
|
||||||
|
|
||||||
|
const count = view.getUint16(eocd + 10, true);
|
||||||
|
let at = view.getUint32(eocd + 16, true);
|
||||||
|
|
||||||
|
const files: ZipFile[] = [];
|
||||||
|
for (let n = 0; n < count && at + 46 <= view.byteLength; n++) {
|
||||||
|
if (view.getUint32(at, true) !== CENTRAL) break;
|
||||||
|
|
||||||
|
const flags = view.getUint16(at + 8, true);
|
||||||
|
const method = view.getUint16(at + 10, true);
|
||||||
|
const compressedSize = view.getUint32(at + 20, true);
|
||||||
|
const nameLength = view.getUint16(at + 28, true);
|
||||||
|
const extraLength = view.getUint16(at + 30, true);
|
||||||
|
const commentLength = view.getUint16(at + 32, true);
|
||||||
|
const localAt = view.getUint32(at + 42, true);
|
||||||
|
const name = decoder.decode(all.subarray(at + 46, at + 46 + nameLength));
|
||||||
|
at += 46 + nameLength + extraLength + commentLength;
|
||||||
|
|
||||||
|
if (name.endsWith("/")) continue; // a folder, not a file
|
||||||
|
if (flags & 0x0001) continue; // encrypted — nothing we can do with it
|
||||||
|
|
||||||
|
// The local header repeats the name and extra field, at its own lengths.
|
||||||
|
const localNameLength = view.getUint16(localAt + 26, true);
|
||||||
|
const localExtraLength = view.getUint16(localAt + 28, true);
|
||||||
|
const from = localAt + 30 + localNameLength + localExtraLength;
|
||||||
|
const raw = all.subarray(from, from + compressedSize);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (method === 0) {
|
||||||
|
files.push({ name, bytes: raw.slice() });
|
||||||
|
} else if (method === 8) {
|
||||||
|
files.push({ name, bytes: await inflateRaw(raw) });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* an unreadable member is left out; the rest of the archive still imports */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ interface ServerNotebook {
|
|||||||
cover_index?: number;
|
cover_index?: number;
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
max_versions?: number | null;
|
max_versions?: number | null;
|
||||||
|
sort_mode?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServerNote {
|
interface ServerNote {
|
||||||
@@ -83,6 +84,7 @@ interface ServerNote {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
deleted_at?: string;
|
deleted_at?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
sort_order?: number;
|
||||||
pdf_attachment_id?: string | null;
|
pdf_attachment_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,6 +405,7 @@ export async function fullSync(): Promise<void> {
|
|||||||
cover: nb.cover_index || 0,
|
cover: nb.cover_index || 0,
|
||||||
sortOrder: nb.sort_order || 0,
|
sortOrder: nb.sort_order || 0,
|
||||||
maxVersions: nb.max_versions ?? null,
|
maxVersions: nb.max_versions ?? null,
|
||||||
|
sortMode: (nb.sort_mode as Notebook["sortMode"]) ?? null,
|
||||||
}));
|
}));
|
||||||
const serverNbIds = new Set(nbs.map((nb) => nb.id));
|
const serverNbIds = new Set(nbs.map((nb) => nb.id));
|
||||||
const unsyncedNbs = localNbs.filter((nb) => nb._pendingCreate && !serverNbIds.has(nb.id));
|
const unsyncedNbs = localNbs.filter((nb) => nb._pendingCreate && !serverNbIds.has(nb.id));
|
||||||
@@ -454,6 +457,7 @@ function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta {
|
|||||||
createdAt: n.created_at,
|
createdAt: n.created_at,
|
||||||
updatedAt: n.updated_at || n.deleted_at || n.created_at,
|
updatedAt: n.updated_at || n.deleted_at || n.created_at,
|
||||||
type: n.type === "pdf" ? "pdf" : "text",
|
type: n.type === "pdf" ? "pdf" : "text",
|
||||||
|
sortOrder: n.sort_order ?? 0,
|
||||||
pdfAttachmentId: n.pdf_attachment_id ?? null,
|
pdfAttachmentId: n.pdf_attachment_id ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -465,7 +469,14 @@ type QueuedAction =
|
|||||||
| { action: "createNotebook"; data: { localId: string; name: string; cover: number; sortOrder: number } }
|
| { action: "createNotebook"; data: { localId: string; name: string; cover: number; sortOrder: number } }
|
||||||
| {
|
| {
|
||||||
action: "updateNotebook";
|
action: "updateNotebook";
|
||||||
data: { id: string; name?: string; cover?: number; sortOrder?: number; maxVersions?: number | null };
|
data: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
cover?: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
maxVersions?: number | null;
|
||||||
|
sortMode?: Notebook["sortMode"];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
| { action: "deleteNotebook"; data: { id: string } }
|
| { action: "deleteNotebook"; data: { id: string } }
|
||||||
| { action: "createNote"; data: { localId: string; title: string; folderId: string | null } }
|
| { action: "createNote"; data: { localId: string; title: string; folderId: string | null } }
|
||||||
@@ -550,6 +561,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
|
|||||||
coverIndex: a.data.cover,
|
coverIndex: a.data.cover,
|
||||||
sortOrder: a.data.sortOrder,
|
sortOrder: a.data.sortOrder,
|
||||||
...(a.data.maxVersions !== undefined ? { maxVersions: a.data.maxVersions } : {}),
|
...(a.data.maxVersions !== undefined ? { maxVersions: a.data.maxVersions } : {}),
|
||||||
|
...(a.data.sortMode !== undefined ? { sortMode: a.data.sortMode } : {}),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "deleteNotebook":
|
case "deleteNotebook":
|
||||||
@@ -706,6 +718,7 @@ export async function updateNotebook(id: string, updates: Partial<Notebook>): Pr
|
|||||||
// null is meaningful here — it clears the notebook's own setting — so it
|
// null is meaningful here — it clears the notebook's own setting — so it
|
||||||
// has to travel, while undefined must stay away and change nothing.
|
// has to travel, while undefined must stay away and change nothing.
|
||||||
...(updates.maxVersions !== undefined ? { maxVersions: updates.maxVersions } : {}),
|
...(updates.maxVersions !== undefined ? { maxVersions: updates.maxVersions } : {}),
|
||||||
|
...(updates.sortMode !== undefined ? { sortMode: updates.sortMode } : {}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -720,6 +733,7 @@ export async function updateNotebook(id: string, updates: Partial<Notebook>): Pr
|
|||||||
cover: updates.cover,
|
cover: updates.cover,
|
||||||
sortOrder: updates.sortOrder,
|
sortOrder: updates.sortOrder,
|
||||||
maxVersions: updates.maxVersions,
|
maxVersions: updates.maxVersions,
|
||||||
|
sortMode: updates.sortMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
notifyStatus("pending");
|
notifyStatus("pending");
|
||||||
@@ -763,6 +777,29 @@ export async function getNotes(): Promise<NoteMeta[]> {
|
|||||||
return db.getAllNotes();
|
return db.getAllNotes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the user's own order for a set of notes.
|
||||||
|
*
|
||||||
|
* Its own route, not a note update: `PUT /notes/:id` bumps `updated_at` and
|
||||||
|
* snapshots a version, so dragging a note would move it to the top of "last
|
||||||
|
* changed" and spend a slot of its history on a change that is not to the note.
|
||||||
|
*
|
||||||
|
* Applied locally first, so a drag holds even offline, and queued as a plain
|
||||||
|
* note update would be wrong for the same reason — it is sent when there is a
|
||||||
|
* connection and simply re-derived from the local order otherwise.
|
||||||
|
*/
|
||||||
|
export async function reorderNotes(orderedIds: string[]): Promise<void> {
|
||||||
|
const notes = await db.getAllNotes();
|
||||||
|
for (let i = 0; i < orderedIds.length; i++) {
|
||||||
|
const note = notes.find((n) => n.id === orderedIds[i]);
|
||||||
|
if (note && note.sortOrder !== i) await db.putNote({ ...note, sortOrder: i });
|
||||||
|
}
|
||||||
|
notifyData("notes", null);
|
||||||
|
if (_online && api.token) {
|
||||||
|
api.post("/notes/reorder", { ids: orderedIds }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const shapeContent = (r: (ServerNoteBody & Partial<NoteContent>) | undefined | null): NoteContent => ({
|
const shapeContent = (r: (ServerNoteBody & Partial<NoteContent>) | undefined | null): NoteContent => ({
|
||||||
content: r?.content || "",
|
content: r?.content || "",
|
||||||
drawingData: r?.drawingData || null,
|
drawingData: r?.drawingData || null,
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export interface NoteMeta {
|
|||||||
trashed: boolean;
|
trashed: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
/** Position in the notebook's own order, used when sorting is set to manual. */
|
||||||
|
sortOrder?: number;
|
||||||
/** Set while a note created offline has not reached the server yet. */
|
/** Set while a note created offline has not reached the server yet. */
|
||||||
_pendingCreate?: boolean;
|
_pendingCreate?: boolean;
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,14 @@ export interface NoteContent {
|
|||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a notebook's notes are ordered.
|
||||||
|
*
|
||||||
|
* "manual" is the user's own arrangement, kept in each note's `sortOrder`; the
|
||||||
|
* rest are derived from the notes themselves and need nothing stored.
|
||||||
|
*/
|
||||||
|
export type SortMode = "manual" | "updated" | "oldest" | "created" | "title";
|
||||||
|
|
||||||
export interface Notebook {
|
export interface Notebook {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -71,6 +81,8 @@ export interface Notebook {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
/** Versions kept per note here; null means the server's default. */
|
/** Versions kept per note here; null means the server's default. */
|
||||||
maxVersions?: number | null;
|
maxVersions?: number | null;
|
||||||
|
/** Null means the app default, newest first. */
|
||||||
|
sortMode?: SortMode | null;
|
||||||
_pendingCreate?: boolean;
|
_pendingCreate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { importFiles } from "../import/importNotes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an import and says plainly what came of it.
|
||||||
|
*
|
||||||
|
* The report is the point. An import can succeed in the sense that no error was
|
||||||
|
* thrown and still not be what the user expected — a drawing arrives as a flat
|
||||||
|
* picture, an image the archive did not contain cannot be restored. Those are
|
||||||
|
* stated afterwards rather than left to be discovered, because the moment to
|
||||||
|
* learn that a drawing is no longer editable is now, while the original export
|
||||||
|
* is still on disk.
|
||||||
|
*/
|
||||||
|
export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
|
||||||
|
const [progress, setProgress] = useState({ done: 0, total: 0, label: "" });
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const started = useRef(false);
|
||||||
|
|
||||||
|
const T = useMemo(
|
||||||
|
() => ({
|
||||||
|
bg: dark ? "#161b22" : "#fff",
|
||||||
|
border: dark ? "#30363d" : "#d0d7de",
|
||||||
|
text: dark ? "#e6edf3" : "#1f2328",
|
||||||
|
muted: dark ? "#8b949e" : "#656d76",
|
||||||
|
accent: dark ? "#388bfd" : "#0969da",
|
||||||
|
warning: dark ? "#d29922" : "#9a6700",
|
||||||
|
danger: dark ? "#f85149" : "#cf222e",
|
||||||
|
track: dark ? "#21262d" : "#e5e7eb",
|
||||||
|
}),
|
||||||
|
[dark]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// React runs effects twice in development; importing twice would create
|
||||||
|
// every note a second time.
|
||||||
|
if (started.current) return;
|
||||||
|
started.current = true;
|
||||||
|
|
||||||
|
importFiles(files, folderId, setProgress)
|
||||||
|
.then((r) => {
|
||||||
|
setResult(r);
|
||||||
|
onDone?.(r);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e?.message || "Der Import ist fehlgeschlagen."));
|
||||||
|
}, [files, folderId, onDone]);
|
||||||
|
|
||||||
|
const done = result || error;
|
||||||
|
const pct = progress.total ? Math.round((progress.done / progress.total) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.5)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
}}
|
||||||
|
onClick={done ? onClose : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: T.bg,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 24,
|
||||||
|
width: 440,
|
||||||
|
maxWidth: "92vw",
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
boxShadow: "0 20px 40px rgba(0,0,0,0.3)",
|
||||||
|
fontFamily: "'DM Sans',sans-serif",
|
||||||
|
color: T.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 14,
|
||||||
|
fontFamily: "'Newsreader',Georgia,serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{done ? "Import abgeschlossen" : "Notizen werden importiert…"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!done && (
|
||||||
|
<>
|
||||||
|
<div style={{ height: 6, borderRadius: 3, background: T.track, overflow: "hidden" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
height: "100%",
|
||||||
|
background: T.accent,
|
||||||
|
transition: "width .2s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: T.muted, marginTop: 8 }}>
|
||||||
|
{progress.total ? `${progress.done} von ${progress.total}` : "Archiv wird gelesen…"}
|
||||||
|
{progress.label ? ` — ${progress.label}` : ""}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div style={{ fontSize: 13, color: T.danger }}>{error}</div>}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div style={{ fontSize: 13, lineHeight: 1.7 }}>
|
||||||
|
<div>
|
||||||
|
<strong>{result.notes.length}</strong>{" "}
|
||||||
|
{result.notes.length === 1 ? "Notiz importiert" : "Notizen importiert"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.flattenedDrawings > 0 && (
|
||||||
|
<div style={{ color: T.warning, marginTop: 10, fontSize: 12.5 }}>
|
||||||
|
{result.flattenedDrawings === 1
|
||||||
|
? "Eine Zeichnung kam als Bild zurück"
|
||||||
|
: `${result.flattenedDrawings} Zeichnungen kamen als Bilder zurück`}{" "}
|
||||||
|
— im Archiv liegen sie als PNG, und aus Pixeln lassen sich die Striche nicht zurückrechnen.
|
||||||
|
Sie sind sichtbar und exportierbar, aber nicht mehr mit dem Stift zu bearbeiten.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.missingImages > 0 && (
|
||||||
|
<div style={{ color: T.warning, marginTop: 10, fontSize: 12.5 }}>
|
||||||
|
{result.missingImages} {result.missingImages === 1 ? "Bild lag" : "Bilder lagen"} nicht im
|
||||||
|
Archiv. Die Stelle in der Notiz bleibt erhalten, das Bild fehlt.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.failed.length > 0 && (
|
||||||
|
<div style={{ color: T.danger, marginTop: 10, fontSize: 12.5 }}>
|
||||||
|
Nicht gelesen: {result.failed.join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 20 }}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={!done}
|
||||||
|
style={{
|
||||||
|
padding: "8px 20px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "none",
|
||||||
|
background: done ? T.accent : T.track,
|
||||||
|
color: done ? "#fff" : T.muted,
|
||||||
|
cursor: done ? "pointer" : "default",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{done ? "Fertig" : "Läuft…"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaching a web address to the selected text.
|
||||||
|
*
|
||||||
|
* This replaces `window.prompt`. That dialog cannot be styled, cannot be
|
||||||
|
* reached by a phone keyboard's paste button on every browser, gives no room to
|
||||||
|
* say *why* an address was refused, and — worst here — it is modal to the whole
|
||||||
|
* browser, so it steals focus from the editor and takes the selection with it on
|
||||||
|
* some engines. A real dialog also lets an existing link be removed with a
|
||||||
|
* button instead of the folklore of "leave the box empty".
|
||||||
|
*
|
||||||
|
* Only `http`, `https` and `mailto` are allowed. `javascript:` in an href is a
|
||||||
|
* script that runs with the page's own privileges when a note is clicked, and
|
||||||
|
* notes are exported, shared and re-imported; the check lives here and in the
|
||||||
|
* editor schema both, because either one alone is one mistake from a hole.
|
||||||
|
*/
|
||||||
|
const ALLOWED = /^(https?|mailto):/i;
|
||||||
|
|
||||||
|
export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark }) {
|
||||||
|
const [url, setUrl] = useState(initialUrl);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
const T = useMemo(
|
||||||
|
() => ({
|
||||||
|
bg: dark ? "#161b22" : "#fff",
|
||||||
|
border: dark ? "#30363d" : "#d0d7de",
|
||||||
|
text: dark ? "#e6edf3" : "#1f2328",
|
||||||
|
muted: dark ? "#8b949e" : "#656d76",
|
||||||
|
accent: dark ? "#388bfd" : "#0969da",
|
||||||
|
danger: dark ? "#f85149" : "#cf222e",
|
||||||
|
field: dark ? "#0d1117" : "#f6f8fa",
|
||||||
|
}),
|
||||||
|
[dark]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const submit = (e) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
const value = url.trim();
|
||||||
|
if (!value) {
|
||||||
|
setError("Bitte eine Adresse eingeben — oder den Link entfernen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A bare "example.com" is what people type; assume https rather than
|
||||||
|
// refusing something whose meaning is perfectly clear.
|
||||||
|
const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`;
|
||||||
|
if (!ALLOWED.test(candidate)) {
|
||||||
|
setError("Nur http-, https- und mailto-Adressen sind erlaubt.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit(candidate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const button = (kind) => ({
|
||||||
|
padding: "8px 18px",
|
||||||
|
borderRadius: 10,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: kind === "primary" ? "none" : `1px solid ${T.border}`,
|
||||||
|
background: kind === "primary" ? T.accent : "transparent",
|
||||||
|
color: kind === "primary" ? "#fff" : kind === "danger" ? T.danger : T.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.5)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: T.bg,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 24,
|
||||||
|
width: 430,
|
||||||
|
maxWidth: "92vw",
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
boxShadow: "0 20px 40px rgba(0,0,0,0.3)",
|
||||||
|
fontFamily: "'DM Sans',sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: T.text,
|
||||||
|
marginBottom: 14,
|
||||||
|
fontFamily: "'Newsreader',Georgia,serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initialUrl ? "Link bearbeiten" : "Link einfügen"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
aria-label="Link address"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUrl(e.target.value);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="url"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${error ? T.danger : T.border}`,
|
||||||
|
background: T.field,
|
||||||
|
color: T.text,
|
||||||
|
fontSize: 13,
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ minHeight: 18, marginTop: 6, fontSize: 11.5, color: error ? T.danger : T.muted }}>
|
||||||
|
{error || "http, https oder mailto"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 12 }}>
|
||||||
|
{initialUrl && (
|
||||||
|
<button type="button" onClick={onRemove} style={{ ...button("danger"), marginRight: "auto" }}>
|
||||||
|
Link entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={onClose} style={button()}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" style={button("primary")}>
|
||||||
|
{initialUrl ? "Ändern" : "Einfügen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { COVERS } from "./constants.jsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks which notebook something should go into.
|
||||||
|
*
|
||||||
|
* "All Notes", "Starred" and the dashboard are views, not places — a note
|
||||||
|
* created while one of them is open has no notebook to belong to. The app used
|
||||||
|
* to answer that silently with `notebooks[0]`, so a note or an imported PDF
|
||||||
|
* landed in whichever notebook happened to sort first. Nothing was lost, but
|
||||||
|
* finding it again meant knowing that rule.
|
||||||
|
*
|
||||||
|
* The last choice is remembered, so the common case — always the same notebook —
|
||||||
|
* is one Enter away, while the answer stays visible instead of being guessed.
|
||||||
|
*/
|
||||||
|
const LAST_KEY = "inknote_last_notebook";
|
||||||
|
|
||||||
|
export const rememberNotebook = (id) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_KEY, id);
|
||||||
|
} catch {
|
||||||
|
/* private mode: the default simply is not remembered */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const recalledNotebook = () => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(LAST_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotebookPicker({ notebooks, title, confirmLabel = "Weiter", onPick, onClose, dark }) {
|
||||||
|
const remembered = recalledNotebook();
|
||||||
|
const [selected, setSelected] = useState(
|
||||||
|
() => notebooks.find((nb) => nb.id === remembered)?.id || notebooks[0]?.id || null
|
||||||
|
);
|
||||||
|
|
||||||
|
const T = useMemo(
|
||||||
|
() => ({
|
||||||
|
bg: dark ? "#161b22" : "#fff",
|
||||||
|
border: dark ? "#30363d" : "#d0d7de",
|
||||||
|
text: dark ? "#e6edf3" : "#1f2328",
|
||||||
|
muted: dark ? "#8b949e" : "#656d76",
|
||||||
|
accent: dark ? "#388bfd" : "#0969da",
|
||||||
|
row: dark ? "#0d1117" : "#f6f8fa",
|
||||||
|
}),
|
||||||
|
[dark]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
if (e.key === "Enter" && selected) confirm();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
if (!selected) return;
|
||||||
|
rememberNotebook(selected);
|
||||||
|
onPick(selected);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.5)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: T.bg,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 24,
|
||||||
|
width: 400,
|
||||||
|
maxWidth: "92vw",
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
boxShadow: "0 20px 40px rgba(0,0,0,0.3)",
|
||||||
|
fontFamily: "'DM Sans',sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: T.text,
|
||||||
|
marginBottom: 4,
|
||||||
|
fontFamily: "'Newsreader',Georgia,serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: T.muted, marginBottom: 14 }}>In welches Notizbuch soll es?</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Notebook"
|
||||||
|
style={{ maxHeight: 280, overflowY: "auto", display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
|
>
|
||||||
|
{notebooks.map((nb) => {
|
||||||
|
const active = nb.id === selected;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={nb.id}
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
data-notebook-option={nb.id}
|
||||||
|
onClick={() => setSelected(nb.id)}
|
||||||
|
onDoubleClick={confirm}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
width: "100%",
|
||||||
|
padding: "9px 12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${active ? T.accent + "66" : "transparent"}`,
|
||||||
|
background: active ? T.accent + "1a" : T.row,
|
||||||
|
color: T.text,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 3,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: COVERS[nb.cover % COVERS.length].bg,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{nb.name}
|
||||||
|
</span>
|
||||||
|
{nb.id === remembered && (
|
||||||
|
<span style={{ fontSize: 10, color: T.muted }}>zuletzt benutzt</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 18 }}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: "8px 18px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
background: "transparent",
|
||||||
|
color: T.text,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={!selected}
|
||||||
|
style={{
|
||||||
|
padding: "8px 18px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "none",
|
||||||
|
background: selected ? T.accent : T.border,
|
||||||
|
color: "#fff",
|
||||||
|
cursor: selected ? "pointer" : "default",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -470,6 +470,9 @@ export const I = {
|
|||||||
<path d="M14 18v-6h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2z" />
|
<path d="M14 18v-6h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
/* Export saves a file to the device, so the arrow points down into the tray.
|
||||||
|
* It used to point up and out, which is the shape every other program uses
|
||||||
|
* for uploading or sharing — the opposite of what this button does. */
|
||||||
export: (
|
export: (
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
@@ -482,8 +485,75 @@ export const I = {
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
<polyline points="16 6 12 2 8 6" />
|
<polyline points="8 11 12 15 16 11" />
|
||||||
<line x1="12" y1="2" x2="12" y2="15" />
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
/* Reading a file in — the mirror of `export`, for the Markdown importer. */
|
||||||
|
import: (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="16 7 12 3 8 7" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
eye: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
eyeOff: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
|
||||||
|
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||||||
|
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
sort: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="4" y1="7" x2="16" y2="7" />
|
||||||
|
<line x1="4" y1="12" x2="13" y2="12" />
|
||||||
|
<line x1="4" y1="17" x2="10" y2="17" />
|
||||||
|
<polyline points="17 14 20 17 23 14" />
|
||||||
|
<line x1="20" y1="17" x2="20" y2="7" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
users: (
|
users: (
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
|
|||||||
- **Star/Favorite** — Quick access to important notes
|
- **Star/Favorite** — Quick access to important notes
|
||||||
- **PDF Reading & Annotation** — Import a PDF, read it, select its text, draw on it, write notes beside it, and export it with or without your marks
|
- **PDF Reading & Annotation** — Import a PDF, read it, select its text, draw on it, write notes beside it, and export it with or without your marks
|
||||||
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
|
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
|
||||||
|
- **Import** — Markdown files, or a ZIP this app exported, come back as notes with their images
|
||||||
|
- **Sorting** — Each notebook remembers how its notes are ordered, including an arrangement made by hand
|
||||||
- **Dark & Light Mode** — Full theme support with refined color schemes
|
- **Dark & Light Mode** — Full theme support with refined color schemes
|
||||||
|
|
||||||
### Sync & Storage
|
### Sync & Storage
|
||||||
@@ -302,7 +304,7 @@ failure distinguished by comparing an error's `.message` to a magic string.
|
|||||||
Failures now carry a typed `SyncError.code`; `.message` still holds the same
|
Failures now carry a typed `SyncError.code`; `.message` still holds the same
|
||||||
string, so the remaining JavaScript callers are unaffected.
|
string, so the remaining JavaScript callers are unaffected.
|
||||||
|
|
||||||
The 81 unit tests concentrate on the same places, and several exist because the
|
The 186 unit tests concentrate on the same places, and several exist because the
|
||||||
bug they cover actually happened:
|
bug they cover actually happened:
|
||||||
|
|
||||||
- a queue entry that fails must stay queued, with an attempt counter
|
- a queue entry that fails must stay queued, with an attempt counter
|
||||||
@@ -334,6 +336,8 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
|
|||||||
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
|
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
|
||||||
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
|
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
|
||||||
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
||||||
|
| `organise` | 24 | a hand-made order survives a search; a note exported and imported comes back whole |
|
||||||
|
| `offline` | 12 | a cold page load with the network off still opens notes and searches them |
|
||||||
|
|
||||||
Each suite creates a throwaway account through the admin API and deletes it
|
Each suite creates a throwaway account through the admin API and deletes it
|
||||||
afterwards, so runs leave the database as they found it. They need an admin to
|
afterwards, so runs leave the database as they found it. They need an admin to
|
||||||
@@ -354,7 +358,7 @@ needs. Everything else lives behind a hook:
|
|||||||
| `useSearchIndex` | the local index and the background backfill that fills it |
|
| `useSearchIndex` | the local index and the background backfill that fills it |
|
||||||
| `useResponsive` | phone vs desktop, sidebar and which pane a phone shows |
|
| `useResponsive` | phone vs desktop, sidebar and which pane a phone shows |
|
||||||
| `useNavigationHistory` | mapping the current location onto browser history |
|
| `useNavigationHistory` | mapping the current location onto browser history |
|
||||||
| `useNotebookDnd` | drag-to-reorder, shared by sidebar and dashboard |
|
| `useNotebookDnd` | drag-to-reorder, shared by the sidebar, the dashboard and the note list |
|
||||||
| `usePwaInstall` | the browser's install offer |
|
| `usePwaInstall` | the browser's install offer |
|
||||||
|
|
||||||
`useNoteData` deliberately has no confirmation prompts in it: whether to ask
|
`useNoteData` deliberately has no confirmation prompts in it: whether to ask
|
||||||
@@ -703,6 +707,70 @@ rows behind.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Sorting a notebook
|
||||||
|
|
||||||
|
Each notebook remembers how its notes are ordered, and remembers it on the
|
||||||
|
server rather than per device — the order is a property of the notebook, and
|
||||||
|
finding one sorted differently on the phone than on the desktop is its own small
|
||||||
|
annoyance. A reference book can stay alphabetical while a journal stays
|
||||||
|
newest-first.
|
||||||
|
|
||||||
|
| Mode | Order |
|
||||||
|
| --- | --- |
|
||||||
|
| `manual` | the user's own arrangement, dragged into place |
|
||||||
|
| `updated` | most recently changed first *(the default)* |
|
||||||
|
| `oldest` | least recently changed first |
|
||||||
|
| `created` | most recently created first |
|
||||||
|
| `title` | A–Z, case- and umlaut-aware, with numbers read as numbers |
|
||||||
|
|
||||||
|
**The manual order has to survive being looked away from.** Searching imposes
|
||||||
|
its own order — relevance — and switching back has to show exactly what was
|
||||||
|
arranged. That is only keepable if the arrangement lives in the notes and is
|
||||||
|
never re-derived from what is on screen, so two rules hold it up:
|
||||||
|
|
||||||
|
- `reindex` renumbers **every note in the notebook**, taken from the store, not
|
||||||
|
the list being displayed. Renumbering a filtered subset would give a hidden
|
||||||
|
note the same position as a visible one and quietly scramble the rest.
|
||||||
|
- Dragging is switched off while a search is narrowing the list, and the list
|
||||||
|
says so rather than silently ignoring the drag.
|
||||||
|
|
||||||
|
Reordering also has its own route, `POST /api/notes/reorder`. Going through
|
||||||
|
`PUT /api/notes/:id` would bump `updated_at` and snapshot a version — so
|
||||||
|
dragging a note would move it to the top of "last changed" and spend a slot of
|
||||||
|
its history on a change that is not to the note at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Import
|
||||||
|
|
||||||
|
The other half of the promise that notes are not hostage to this program. A
|
||||||
|
`.md` file becomes a note; a `.zip` this app exported comes back nearly whole —
|
||||||
|
text, tables, task boxes, and the images, re-uploaded as attachments so they are
|
||||||
|
encrypted at rest again like anything else.
|
||||||
|
|
||||||
|
The parser is a deliberate subset: what the exporter emits, plus what a
|
||||||
|
hand-written note is likely to use. It is not CommonMark and does not try to be.
|
||||||
|
A full parser is thousands of lines and a permanent maintenance surface, and the
|
||||||
|
failure mode here is mild — an unrecognised construct arrives as the literal
|
||||||
|
text that was written, which is still the user's words in the user's order.
|
||||||
|
|
||||||
|
Two things do not come back, and the import says so afterwards rather than
|
||||||
|
leaving them to be discovered:
|
||||||
|
|
||||||
|
- **A drawing returns as a picture, not as strokes.** It was exported as a PNG,
|
||||||
|
and vectors cannot be recovered from pixels. It stays visible, printable and
|
||||||
|
exportable, but the pen can no longer edit it.
|
||||||
|
- **An image the archive does not contain cannot be restored.** The place it sat
|
||||||
|
in the note is kept, so what used to be there is at least recorded.
|
||||||
|
|
||||||
|
Writing the exporter also turned up a real bug in it: blocks were emitted
|
||||||
|
without a blank line between them, so a list written straight after another list
|
||||||
|
became more items of the first, and a quote or code fence on the next line was
|
||||||
|
read as part of the item above. That is not one reader's quirk — it is what the
|
||||||
|
format says — so the file rendered wrongly everywhere, not only here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Export
|
## Export
|
||||||
|
|
||||||
Notes should not be hostage to this program, so everything can leave it — from a
|
Notes should not be hostage to this program, so everything can leave it — from a
|
||||||
|
|||||||
Reference in new issue
Block a user