Chapters: folders inside a notebook
A note is in exactly one chapter, or in none — which means the notebook itself. Chapters sit above the loose notes; tapping one opens its own list and the menu button becomes the way back out, which on a phone is the only way, since the list fills the screen. A chapter is not a wastebasket. Removing one never removes what it held: the notes come back to the notebook, where they can be seen and filed again. That is ON DELETE SET NULL, and it is also written out in the delete route, because it is the promise the whole thing rests on and should be visible where it happens rather than inferred from a schema. The dialog says so too, with the number of notes affected, and the suite removes a chapter holding three notes and asks the server for all three. Three rules keep the two levels honest. A note filed into a chapter leaves the notebook's own list, or filing it would appear to do nothing. A chapter belongs to its notebook, so the server refuses to file a note into a chapter of a book it is not in, and moving a note to another notebook clears its chapter. And separators belong to the list they were drawn in: a separator's order counts the notes before it, which means nothing across lists. Two bugs found while building it, both a day old and both affecting the desktop rather than the phone they were written for: - A submenu was clipped away. The context menu had been given `overflow-y: auto`, which cuts off anything flying out sideways — so the notebooks under "Move to..." were invisible and every click on them landed on the note list behind. The side was inverted too: `right: 100%` places a panel on the left. - A separator was a one-pixel hairline in the border colour with faint grey text on near-black. Present, and on a phone effectively invisible. And one test that never measured what it claimed. It asked whether a dragged note had become "the most recently changed" by reading the order on screen — but three notes made seconds apart share an `updated_at` second, so that order is a tie broken arbitrarily. It passed on luck for weeks and fell over on a run a fraction faster. It now compares the timestamps themselves, before and after the drag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e037f01dc2
commit
1757ec9af3
15 files changed
+1078
-78
No files matched your search
@@ -8,6 +8,34 @@ not from the output of `docker push`.
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
|
||||
**Added**
|
||||
- **Chapters — folders inside a notebook.** A note is in one or in none; tapping
|
||||
a chapter opens its own list, with a way back out. Removing a chapter never
|
||||
removes what it held: the notes come back to the notebook, and the dialog says
|
||||
so with the number affected. Separators now belong to the list they were drawn
|
||||
in, so a line drawn in one chapter does not appear in another.
|
||||
|
||||
**Changed**
|
||||
- **A notebook's design is its own.** A 45-degree stripe texture was baked into
|
||||
every cover at 1.5% white — meant to be imperceptible, visibly banding on the
|
||||
darker gradients, and a second pattern fighting the one actually chosen. And
|
||||
the same pair of rings sat in the middle of every notebook. The mark belongs
|
||||
to the design now, and the designs whose pattern already fills the face wear
|
||||
none.
|
||||
|
||||
**Fixed**
|
||||
- **A submenu was being clipped away on a wide screen.** The menu had been given
|
||||
`overflow-y: auto`, which cuts off anything flying out sideways — so the list
|
||||
of notebooks under *Move to…* was invisible and every click on it landed on
|
||||
the note list behind. The side was also inverted: `right: 100%` places a panel
|
||||
on the left.
|
||||
- **A separator was effectively invisible on a phone** — a one-pixel hairline in
|
||||
the border colour with faint grey text on near-black.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-12 — Two-part covers, and an app that can be installed
|
||||
|
||||
**Images** — web `sha256:1217b81b…`, server `sha256:3303d3dc…`
|
||||
|
||||
+150
-6
@@ -422,6 +422,36 @@ if (!tableColumns("users").includes("preferences")) {
|
||||
*
|
||||
* On `attachments` rather than in a table of its own: it is a property of the
|
||||
* file, it is deduplicated with the file, and it is freed with the file. */
|
||||
/**
|
||||
* Chapters — folders inside a notebook.
|
||||
*
|
||||
* A note sits in exactly one chapter, or in none, which is the notebook itself.
|
||||
* `ON DELETE SET NULL` is the whole design in one clause: removing a chapter
|
||||
* must never take notes with it. They come back to the notebook, where they can
|
||||
* be seen and filed again.
|
||||
*
|
||||
* Separate from notebooks rather than a self-reference on them: a notebook has a
|
||||
* cover, a sort order and separators, none of which a chapter has or wants.
|
||||
*/
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chapters (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
device_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chapters_notebook ON chapters(notebook_id);
|
||||
`);
|
||||
|
||||
if (!tableColumns("notes").includes("chapter_id")) {
|
||||
db.exec("ALTER TABLE notes ADD COLUMN chapter_id TEXT REFERENCES chapters(id) ON DELETE SET NULL");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_notes_chapter ON notes(chapter_id)");
|
||||
}
|
||||
|
||||
/* The pattern laid over a notebook's cover, chosen apart from its colour.
|
||||
* Its own column rather than more values in `cover_index`: they are two
|
||||
* decisions, and folding them into one number would mean renumbering every
|
||||
@@ -888,6 +918,10 @@ function cleanSeparators(input) {
|
||||
id: s.id.slice(0, 64),
|
||||
label: typeof s.label === "string" ? s.label.slice(0, MAX_SEPARATOR_LABEL) : "",
|
||||
order: Number.isFinite(Number(s.order)) ? Math.max(0, Math.round(Number(s.order))) : 0,
|
||||
/* Which list it was drawn in — a chapter of the notebook, or the notebook
|
||||
* itself. `order` counts the notes before it, and that only means anything
|
||||
* within one list. */
|
||||
chapterId: typeof s.chapterId === "string" && s.chapterId ? s.chapterId.slice(0, 64) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1025,7 +1059,7 @@ function isBigShrink(previous, next) {
|
||||
|
||||
/* ─── Note routes ─── */
|
||||
app.get("/api/notes", authMiddleware, (req, res) => {
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, sort_order, created_at, updated_at, version, pdf_attachment_id FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, starred, sort_order, created_at, updated_at, version, pdf_attachment_id FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
|
||||
res.json(notes);
|
||||
});
|
||||
|
||||
@@ -1106,13 +1140,25 @@ app.post("/api/notes/reorder", authMiddleware, (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/api/notes", authMiddleware, (req, res) => {
|
||||
const { type, title, content, notebookId, starred, pdfAttachmentId } = req.body;
|
||||
const { type, title, content, notebookId, chapterId, starred, pdfAttachmentId } = req.body;
|
||||
const id = uuid();
|
||||
const encContent = content ? encrypt(content) : null;
|
||||
|
||||
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, pdf_attachment_id, device_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, pdfAttachmentId || null, req.deviceId);
|
||||
/* A note made while a chapter is open belongs to that chapter. Checked
|
||||
* against the notebook, so it cannot be filed into a chapter of another. */
|
||||
let chapter = null;
|
||||
if (chapterId) {
|
||||
const found = db.prepare("SELECT notebook_id FROM chapters WHERE id = ? AND user_id = ?").get(chapterId, req.userId);
|
||||
if (!found) return res.status(404).json({ error: "Chapter not found" });
|
||||
if (found.notebook_id !== notebookId) {
|
||||
return res.status(400).json({ error: "That chapter is in a different notebook" });
|
||||
}
|
||||
chapter = chapterId;
|
||||
}
|
||||
|
||||
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, chapter_id, type, title, content_encrypted, starred, pdf_attachment_id, device_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(id, req.userId, notebookId, chapter, type || "text", title || "Untitled", encContent, starred ? 1 : 0, pdfAttachmentId || null, req.deviceId);
|
||||
|
||||
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'note', ?, 'create', ?)")
|
||||
.run(req.userId, id, req.deviceId);
|
||||
@@ -1154,10 +1200,33 @@ const replaceNoteAttachments = db.transaction((noteId, ids) => {
|
||||
});
|
||||
|
||||
app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
const { title, content, notebookId, starred, baseUpdatedAt, links, attachments } = req.body;
|
||||
const { title, content, notebookId, chapterId, starred, baseUpdatedAt, links, attachments } = req.body;
|
||||
const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||
if (!note) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
/* Which chapter the note is filed in. `null` means the notebook itself, so it
|
||||
* is meaningful and has to travel — which is why this cannot ride on COALESCE
|
||||
* like the fields above, where undefined and null mean the same thing.
|
||||
*
|
||||
* A chapter belongs to a notebook, so the two have to agree: filing a note
|
||||
* into a chapter of a book it is not in would leave it in a place that cannot
|
||||
* be reached from either. */
|
||||
let chapterUpdate;
|
||||
if (chapterId !== undefined) {
|
||||
if (chapterId === null) chapterUpdate = null;
|
||||
else {
|
||||
const chapter = db
|
||||
.prepare("SELECT notebook_id FROM chapters WHERE id = ? AND user_id = ?")
|
||||
.get(chapterId, req.userId);
|
||||
if (!chapter) return res.status(404).json({ error: "Chapter not found" });
|
||||
const target = notebookId !== undefined && notebookId !== null ? notebookId : note.notebook_id;
|
||||
if (chapter.notebook_id !== target) {
|
||||
return res.status(400).json({ error: "That chapter is in a different notebook" });
|
||||
}
|
||||
chapterUpdate = chapterId;
|
||||
}
|
||||
}
|
||||
|
||||
const conflict = isStaleWrite(note.updated_at, baseUpdatedAt);
|
||||
const encContent = content ? encrypt(content) : note.content_encrypted;
|
||||
|
||||
@@ -1186,6 +1255,14 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
WHERE id = ?`)
|
||||
.run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, req.deviceId, req.params.id);
|
||||
|
||||
if (chapterId !== undefined) {
|
||||
db.prepare("UPDATE notes SET chapter_id = ? WHERE id = ?").run(chapterUpdate, req.params.id);
|
||||
} else if (notebookId !== undefined && notebookId !== null && notebookId !== note.notebook_id) {
|
||||
// Moved to another notebook without naming a chapter: the old chapter is in
|
||||
// the book it left, so keeping it would file the note somewhere invisible.
|
||||
db.prepare("UPDATE notes SET chapter_id = NULL WHERE id = ?").run(req.params.id);
|
||||
}
|
||||
|
||||
if (Array.isArray(links)) replaceNoteLinks(req.userId, req.params.id, links);
|
||||
if (Array.isArray(attachments)) replaceNoteAttachments(req.params.id, attachments);
|
||||
|
||||
@@ -1589,6 +1666,73 @@ app.get("/api/attachments/:id", authMiddleware, (req, res) => {
|
||||
res.end(bytes);
|
||||
});
|
||||
|
||||
/* ─── Chapters ───
|
||||
*
|
||||
* Folders inside a notebook. A note is in one or in none; deleting a chapter
|
||||
* hands its notes back to the notebook rather than taking them with it.
|
||||
*/
|
||||
|
||||
const MAX_CHAPTER_NAME = 120;
|
||||
|
||||
app.get("/api/chapters", authMiddleware, (req, res) => {
|
||||
res.json(
|
||||
db
|
||||
.prepare("SELECT id, notebook_id, name, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
|
||||
.all(req.userId)
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/chapters", authMiddleware, (req, res) => {
|
||||
const { notebookId, name } = req.body || {};
|
||||
const label = String(name ?? "").trim();
|
||||
if (!label) return res.status(400).json({ error: "name required" });
|
||||
if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" });
|
||||
|
||||
// The notebook has to be this user's — otherwise a chapter could be hung off
|
||||
// someone else's, and every note filed into it would follow.
|
||||
const nb = db.prepare("SELECT id FROM notebooks WHERE id = ? AND user_id = ?").get(notebookId, req.userId);
|
||||
if (!nb) return res.status(404).json({ error: "Notebook not found" });
|
||||
|
||||
const order = db
|
||||
.prepare("SELECT COUNT(*) AS n FROM chapters WHERE notebook_id = ?")
|
||||
.get(notebookId).n;
|
||||
const id = uuid();
|
||||
db.prepare("INSERT INTO chapters (id, user_id, notebook_id, name, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.run(id, req.userId, notebookId, label, order, req.deviceId);
|
||||
broadcastToUser(req.userId, { type: "chapter_created", data: { id, notebookId, name: label } }, req.deviceId);
|
||||
res.json({ id, notebookId, name: label, sortOrder: order });
|
||||
});
|
||||
|
||||
app.put("/api/chapters/:id", authMiddleware, (req, res) => {
|
||||
const { name, sortOrder } = req.body || {};
|
||||
const chapter = db.prepare("SELECT id FROM chapters WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||
if (!chapter) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
let label;
|
||||
if (name !== undefined) {
|
||||
label = String(name ?? "").trim();
|
||||
if (!label) return res.status(400).json({ error: "name required" });
|
||||
if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" });
|
||||
}
|
||||
db.prepare("UPDATE chapters SET name = COALESCE(?, name), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), device_id = ? WHERE id = ?")
|
||||
.run(label ?? null, sortOrder ?? null, req.deviceId, req.params.id);
|
||||
broadcastToUser(req.userId, { type: "chapter_updated", data: { id: req.params.id, name: label } }, req.deviceId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.delete("/api/chapters/:id", authMiddleware, (req, res) => {
|
||||
const chapter = db.prepare("SELECT id FROM chapters WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||
if (!chapter) return res.status(404).json({ error: "Not found" });
|
||||
/* The notes are not deleted with it. Written out rather than left to the
|
||||
* foreign key, because it is the promise the feature rests on and it should
|
||||
* be visible here. */
|
||||
const freed = db.prepare("UPDATE notes SET chapter_id = NULL WHERE chapter_id = ? AND user_id = ?")
|
||||
.run(req.params.id, req.userId).changes;
|
||||
db.prepare("DELETE FROM chapters WHERE id = ?").run(req.params.id);
|
||||
broadcastToUser(req.userId, { type: "chapter_deleted", data: { id: req.params.id } }, req.deviceId);
|
||||
res.json({ success: true, notesFreed: freed });
|
||||
});
|
||||
|
||||
/* ─── The text inside a PDF ───
|
||||
*
|
||||
* Extracted by the device that has the file, never here. The server has no PDF
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* Chapters — folders inside a notebook.
|
||||
*
|
||||
* The claim the feature rests on is the one under test: a note filed into a
|
||||
* chapter is *in* it and not also loose in the notebook, and removing a chapter
|
||||
* never removes what it held. Both look fine on screen while being wrong on the
|
||||
* server, so the server is asked directly.
|
||||
*/
|
||||
import {
|
||||
api,
|
||||
check,
|
||||
createNote,
|
||||
createNotebook,
|
||||
launch,
|
||||
report,
|
||||
setTitle,
|
||||
withFreshUser,
|
||||
} from "./lib.mjs";
|
||||
|
||||
const browser = await launch();
|
||||
let session;
|
||||
try {
|
||||
session = await withFreshUser(browser, "chapters");
|
||||
const p = session.page;
|
||||
|
||||
const titles = () =>
|
||||
p.locator("[data-note-row] [data-note-title]").evaluateAll((els) => els.map((e) => e.textContent.trim()));
|
||||
const addChapter = async (name) => {
|
||||
await p.locator("[data-add-chapter]").click();
|
||||
await p.waitForTimeout(600);
|
||||
await p.getByLabel("Chapter name").fill(name);
|
||||
await p.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await p.waitForTimeout(2000);
|
||||
};
|
||||
const moveTo = async (noteTitle, target) => {
|
||||
await p.locator("[data-note-row]").filter({ hasText: noteTitle }).first().click({ button: "right", force: true });
|
||||
await p.waitForTimeout(600);
|
||||
await p.locator("[data-submenu-toggle]").first().click();
|
||||
await p.waitForTimeout(500);
|
||||
await p.locator("[data-submenu] button").filter({ hasText: target }).first().click();
|
||||
await p.waitForTimeout(2500);
|
||||
};
|
||||
|
||||
await createNotebook(p, "Haus");
|
||||
await p.locator("button").filter({ hasText: "Haus" }).first().click();
|
||||
await p.waitForTimeout(700);
|
||||
for (const name of ["Zählerstand", "Stromrechnung"]) {
|
||||
await createNote(p);
|
||||
await setTitle(p, name);
|
||||
await p.waitForTimeout(600);
|
||||
}
|
||||
await p.waitForTimeout(1000);
|
||||
|
||||
/* ── making one ── */
|
||||
check((await p.locator("[data-add-chapter]").count()) === 1, "a notebook can be given a chapter");
|
||||
await addChapter("Rechnungen");
|
||||
check((await p.locator("[data-chapter-row]").count()) === 1, "and it appears in the list");
|
||||
|
||||
const server = (await api(p, "GET", "/chapters")).body;
|
||||
check(server.length === 1 && server[0].name === "Rechnungen", "the server has it", JSON.stringify(server));
|
||||
|
||||
/* ── filing a note ── */
|
||||
await moveTo("Stromrechnung", "Rechnungen");
|
||||
check(
|
||||
(await titles()).join(",") === "Zählerstand",
|
||||
"a note filed into a chapter leaves the notebook's own list",
|
||||
(await titles()).join(", ")
|
||||
);
|
||||
|
||||
await p.locator("[data-chapter-row]").first().click();
|
||||
await p.waitForTimeout(1200);
|
||||
check((await titles()).join(",") === "Stromrechnung", "and is inside the chapter", (await titles()).join(", "));
|
||||
check((await p.locator("[data-chapter-back]").count()) === 1, "with a way back out");
|
||||
|
||||
const filed = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Stromrechnung");
|
||||
check(!!filed?.chapter_id, "the server filed it too", String(filed?.chapter_id));
|
||||
|
||||
/* A note made inside a chapter belongs to it, without being moved. */
|
||||
await createNote(p);
|
||||
await setTitle(p, "Wasserrechnung");
|
||||
await p.waitForTimeout(1500);
|
||||
const made = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Wasserrechnung");
|
||||
check(made?.chapter_id === filed?.chapter_id, "a note made inside a chapter is filed there", String(made?.chapter_id));
|
||||
|
||||
await p.locator("[data-chapter-back]").click();
|
||||
await p.waitForTimeout(1200);
|
||||
check(
|
||||
(await titles()).join(",") === "Zählerstand",
|
||||
"and going back shows the notebook again, without the chapter's notes",
|
||||
(await titles()).join(", ")
|
||||
);
|
||||
|
||||
/* ── a chapter is not a wastebasket ──
|
||||
* Removing one must never remove what it held: the notes come back to the
|
||||
* notebook, where they can be seen and filed again. */
|
||||
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
|
||||
await p.waitForTimeout(600);
|
||||
await p.getByText("Remove chapter", { exact: true }).click();
|
||||
await p.waitForTimeout(700);
|
||||
check(/stay/i.test(await p.locator("body").innerText()), "removing one says out loud that the notes stay");
|
||||
await p.getByRole("button", { name: "Remove", exact: true }).click();
|
||||
await p.waitForTimeout(2500);
|
||||
|
||||
check((await p.locator("[data-chapter-row]").count()) === 0, "the chapter is gone");
|
||||
const after = (await titles()).sort().join(",");
|
||||
check(
|
||||
after === "Stromrechnung,Wasserrechnung,Zählerstand",
|
||||
"and every note it held is back in the notebook",
|
||||
after
|
||||
);
|
||||
const freed = (await api(p, "GET", "/notes")).body;
|
||||
check(
|
||||
freed.every((n) => !n.chapter_id),
|
||||
"with nothing on the server still pointing at a chapter that is gone",
|
||||
JSON.stringify(freed.map((n) => [n.title, n.chapter_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);
|
||||
}
|
||||
+29
-7
@@ -134,13 +134,33 @@ try {
|
||||
);
|
||||
|
||||
/* 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");
|
||||
"last changed", or the two orders would fight each other.
|
||||
|
||||
Asked of the timestamps rather than of the order on screen. Three notes made
|
||||
seconds apart routinely share an `updated_at` second — the column has that
|
||||
resolution — and then "which is newest" is a tie broken arbitrarily. The
|
||||
check passed on luck for weeks and started failing on a run that was a
|
||||
fraction faster. */
|
||||
const stamps = async () =>
|
||||
Object.fromEntries((await api(p, "GET", "/notes")).body.map((n) => [n.title, n.updated_at]));
|
||||
|
||||
await sortBy("manual");
|
||||
await p.waitForTimeout(600);
|
||||
const beforeStamps = await stamps();
|
||||
const order = await titles();
|
||||
await drag(rows.nth(2), rows.nth(0));
|
||||
const afterStamps = await stamps();
|
||||
check(
|
||||
(await titles())[0] === "Gamma",
|
||||
"a dragged note did not become the most recently changed one",
|
||||
(await titles()).join(", ")
|
||||
Object.keys(beforeStamps).every((t) => beforeStamps[t] === afterStamps[t]),
|
||||
"dragging a note does not mark it as changed",
|
||||
`${JSON.stringify(beforeStamps)} → ${JSON.stringify(afterStamps)}`
|
||||
);
|
||||
check(
|
||||
(await titles()).join(",") !== order.join(","),
|
||||
"though it did move",
|
||||
`${order.join(",")} → ${(await titles()).join(",")}`
|
||||
);
|
||||
|
||||
await sortBy("manual");
|
||||
await p.waitForTimeout(800);
|
||||
|
||||
@@ -160,6 +180,8 @@ try {
|
||||
);
|
||||
|
||||
/* ── separators ── */
|
||||
// The arrangement as it stands now: rows have been dragged since `arranged`.
|
||||
const beforeSeparator = await titles();
|
||||
await p.locator("[data-add-separator]").click();
|
||||
await p.waitForTimeout(1800);
|
||||
check((await p.locator("[data-separator]").count()) === 1, "a separator can be added");
|
||||
@@ -194,9 +216,9 @@ try {
|
||||
|
||||
const withSeparator = await titles();
|
||||
check(
|
||||
withSeparator.join(",") === arranged.join(","),
|
||||
withSeparator.join(",") === beforeSeparator.join(","),
|
||||
"and adding one did not disturb the arrangement",
|
||||
`${arranged.join(",")} vs ${withSeparator.join(",")}`
|
||||
`${beforeSeparator.join(",")} vs ${withSeparator.join(",")}`
|
||||
);
|
||||
|
||||
// Separators only mean something in a hand-made order; in "newest first" the
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
|
||||
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
|
||||
URL="${INKNOTE_URL:-http://localhost:3000}"
|
||||
SUITES=("$@")
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise offline sync)
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters offline sync)
|
||||
|
||||
mkdir -p shots
|
||||
if [ ! -d node_modules ]; then
|
||||
|
||||
+142
-18
@@ -12,6 +12,7 @@ import { DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx";
|
||||
import { I } from "./ui/icons.jsx";
|
||||
import { makeTheme, FONT_UI } from "./ui/theme";
|
||||
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
|
||||
import { NameDialog } from "./ui/NameDialog.jsx";
|
||||
import { ContextMenu } from "./ui/ContextMenu.jsx";
|
||||
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
|
||||
import { AccountMenu } from "./ui/AccountMenu.jsx";
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
canReorder,
|
||||
sortModesFor,
|
||||
sortNotes,
|
||||
separatorsFor,
|
||||
withNewSeparator,
|
||||
} from "./features/notes/noteSort";
|
||||
import { EditorPane } from "./features/notes/EditorPane.jsx";
|
||||
@@ -61,6 +63,10 @@ export default function App() {
|
||||
|
||||
const [activeFolder, setActiveFolder] = useState("dashboard");
|
||||
const [activeNote, setActiveNote] = useState(null);
|
||||
/* Which chapter of the open notebook is being looked into; null is the
|
||||
notebook itself. A chapter is a folder inside a notebook, so this is a
|
||||
second step of "where the user is", not a second notebook. */
|
||||
const [activeChapter, setActiveChapter] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const [showNewNb, setShowNewNb] = useState(false);
|
||||
@@ -82,6 +88,8 @@ export default function App() {
|
||||
const [renamingSeparator, setRenamingSeparator] = useState(null);
|
||||
const [historyFor, setHistoryFor] = useState(null);
|
||||
const [ctxMenu, setCtxMenu] = useState(null);
|
||||
// { title, value, onSave } while a chapter is being named or renamed.
|
||||
const [namingChapter, setNamingChapter] = useState(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState(null);
|
||||
// Bumped on a version restore to force a fresh editor on the restored body.
|
||||
const [restoreEpoch, setRestoreEpoch] = useState(0);
|
||||
@@ -101,8 +109,8 @@ export default function App() {
|
||||
whether a hand-made arrangement is even in effect. Notebooks keep the HTML5
|
||||
hook: they are dragged with a mouse, in the sidebar and on the dashboard. */
|
||||
const rearrangeNotes = useCallback(
|
||||
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId),
|
||||
[data, activeFolder]
|
||||
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, activeChapter),
|
||||
[data, activeFolder, activeChapter]
|
||||
);
|
||||
|
||||
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
|
||||
@@ -151,6 +159,7 @@ export default function App() {
|
||||
const openFolder = useCallback(
|
||||
(id) => {
|
||||
setActiveFolder(id);
|
||||
setActiveChapter(null);
|
||||
setActiveNote(null);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
@@ -186,6 +195,16 @@ export default function App() {
|
||||
|
||||
/* ── Derived note lists ── */
|
||||
const activeNotes = useMemo(() => notes.filter((n) => !n.trashed), [notes]);
|
||||
/* Chapters of the notebook in hand, with how many notes each holds — the
|
||||
count is what makes a chapter worth opening rather than a guess. */
|
||||
const folderChapters = useMemo(() => {
|
||||
const list = data.chapters.filter((c) => c.notebookId === activeFolder);
|
||||
return list.map((c) => ({
|
||||
...c,
|
||||
noteCount: notes.filter((n) => !n.trashed && n.chapterId === c.id).length,
|
||||
}));
|
||||
}, [data.chapters, activeFolder, notes]);
|
||||
const openChapter = folderChapters.find((c) => c.id === activeChapter) || null;
|
||||
const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
|
||||
const isTrashView = activeFolder === "trash";
|
||||
|
||||
@@ -211,14 +230,25 @@ export default function App() {
|
||||
/* ── Separators ──
|
||||
* Lines the user draws between notes. They belong to the notebook's own
|
||||
* arrangement, so they are stored on it and only ever shown in manual order. */
|
||||
const separators = activeNotebook?.separators || [];
|
||||
const allSeparators = activeNotebook?.separators || [];
|
||||
/* Only the ones drawn in the list being looked at — the notebook itself, or
|
||||
the chapter that is open. */
|
||||
const separators = useMemo(
|
||||
() => separatorsFor(allSeparators, activeChapter),
|
||||
[allSeparators, activeChapter]
|
||||
);
|
||||
const writeSeparators = (next) =>
|
||||
activeNotebook && void data.updateNotebook(activeNotebook.id, { separators: next });
|
||||
/* Written back whole: the notebook stores one list, and the ones belonging to
|
||||
the other chapters have to survive a change made in this one. */
|
||||
const writeVisible = (visible) =>
|
||||
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== activeChapter), ...visible]);
|
||||
|
||||
const addSeparator = () => writeSeparators(withNewSeparator(separators, `sep-${Date.now().toString(36)}`));
|
||||
const addSeparator = () =>
|
||||
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", activeChapter));
|
||||
const renameSeparator = (id, label) =>
|
||||
writeSeparators(separators.map((s) => (s.id === id ? { ...s, label } : s)));
|
||||
const removeSeparator = (id) => writeSeparators(separators.filter((s) => s.id !== id));
|
||||
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s)));
|
||||
const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id));
|
||||
|
||||
const separatorContextMenu = (e, separator) => {
|
||||
e.preventDefault();
|
||||
@@ -254,7 +284,11 @@ export default function App() {
|
||||
if (activeFolder === "trash") list = trashedNotes;
|
||||
else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred);
|
||||
else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes;
|
||||
else list = activeNotes.filter((n) => n.folderId === activeFolder);
|
||||
else if (activeChapter) list = activeNotes.filter((n) => n.chapterId === activeChapter);
|
||||
/* In the notebook itself, a note filed into a chapter is *in* that chapter
|
||||
and not loose here — otherwise it would appear twice and moving it would
|
||||
look like it had done nothing. */
|
||||
else list = activeNotes.filter((n) => n.folderId === activeFolder && !n.chapterId);
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
// A search reads every note's text, so results keep the index's relevance
|
||||
@@ -268,7 +302,7 @@ export default function App() {
|
||||
return list.filter((n) => n.title.toLowerCase().includes(q));
|
||||
}
|
||||
return sortNotes(list, sortMode);
|
||||
}, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes, sortMode]);
|
||||
}, [activeFolder, activeChapter, searchQuery, search.results, activeNotes, trashedNotes, sortMode]);
|
||||
|
||||
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
|
||||
@@ -404,7 +438,11 @@ export default function App() {
|
||||
|
||||
const createNote = () =>
|
||||
withNotebook("New note", async (folderId) => {
|
||||
const note = await data.createNote(folderId);
|
||||
/* A note made while a chapter is open belongs to that chapter — but only
|
||||
if it is a chapter of the notebook being written into, which it is not
|
||||
when the notebook had to be asked for. */
|
||||
const chapterId = folderId === activeFolder ? activeChapter : null;
|
||||
const note = await data.createNote(folderId, chapterId);
|
||||
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
|
||||
openNote(note.id);
|
||||
});
|
||||
@@ -503,16 +541,86 @@ export default function App() {
|
||||
});
|
||||
};
|
||||
|
||||
/** "Move to..." lists every notebook except the one the note is already in. */
|
||||
/**
|
||||
* "Move to..." — the other notebooks, and the chapters of this one.
|
||||
*
|
||||
* Both in one list because both answer the same question: where should this
|
||||
* note live. A chapter of the notebook it is already in is one step; another
|
||||
* notebook is another, and takes it out of whatever chapter it was in, since
|
||||
* a chapter belongs to the book it is in.
|
||||
*/
|
||||
const moveSubmenu = (note) => {
|
||||
const targets = notebooks
|
||||
.filter((nb) => nb.id !== note.folderId)
|
||||
.map((nb) => ({
|
||||
label: nb.name,
|
||||
colorDot: coverBackground(nb.cover, nb.coverPattern),
|
||||
action: () => data.moveNote(note.id, nb.id),
|
||||
}));
|
||||
return targets.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
|
||||
const here = data.chapters.filter((c) => c.notebookId === note.folderId && c.id !== note.chapterId);
|
||||
const targets = [
|
||||
...(note.chapterId
|
||||
? [{ label: "The notebook itself", action: () => data.moveNote(note.id, note.folderId, null) }]
|
||||
: []),
|
||||
...here.map((c) => ({
|
||||
label: c.name,
|
||||
icon: I.books,
|
||||
action: () => data.moveNote(note.id, note.folderId, c.id),
|
||||
})),
|
||||
...(here.length || note.chapterId ? [{ divider: true }] : []),
|
||||
...notebooks
|
||||
.filter((nb) => nb.id !== note.folderId)
|
||||
.map((nb) => ({
|
||||
label: nb.name,
|
||||
colorDot: coverBackground(nb.cover, nb.coverPattern),
|
||||
action: () => data.moveNote(note.id, nb.id, null),
|
||||
})),
|
||||
];
|
||||
// A lone divider is not a menu.
|
||||
const usable = targets.filter((t) => !t.divider);
|
||||
return usable.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
|
||||
};
|
||||
|
||||
/* ── Chapters ──
|
||||
* Folders inside a notebook. Named on creation, because a chapter with no name
|
||||
* is a row nobody can tell from the next one — unlike a separator, which is a
|
||||
* line and reads perfectly well unnamed. */
|
||||
const addChapter = () => {
|
||||
if (!activeNotebook) return;
|
||||
setNamingChapter({ title: "New chapter", value: "", onSave: (name) => data.createChapter(activeNotebook.id, name) });
|
||||
};
|
||||
|
||||
const chapterContextMenu = (e, chapter) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCtxMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
items: [
|
||||
{
|
||||
label: "Rename",
|
||||
icon: I.edit,
|
||||
action: () =>
|
||||
setNamingChapter({
|
||||
title: "Rename chapter",
|
||||
value: chapter.name,
|
||||
onSave: (name) => data.renameChapter(chapter.id, name),
|
||||
}),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
label: "Remove chapter",
|
||||
icon: I.trash,
|
||||
danger: true,
|
||||
action: () =>
|
||||
setConfirmDialog({
|
||||
title: "Remove chapter?",
|
||||
message: `"${chapter.name}" is removed. Its ${chapter.noteCount} note${
|
||||
chapter.noteCount === 1 ? "" : "s"
|
||||
} stay — they go back to the notebook.`,
|
||||
confirmLabel: "Remove",
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
if (activeChapter === chapter.id) setActiveChapter(null);
|
||||
void data.deleteChapter(chapter.id);
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const noteContextMenu = (e, note) => {
|
||||
@@ -663,6 +771,11 @@ export default function App() {
|
||||
onStartRenameSeparator={setRenamingSeparator}
|
||||
onRenameSeparator={renameSeparator}
|
||||
onSeparatorContextMenu={separatorContextMenu}
|
||||
chapters={folderChapters}
|
||||
openChapter={openChapter}
|
||||
onOpenChapter={setActiveChapter}
|
||||
onAddChapter={activeNotebook ? addChapter : null}
|
||||
onChapterContextMenu={chapterContextMenu}
|
||||
onReorder={rearrangeNotes}
|
||||
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
||||
onSelectNote={openNote}
|
||||
@@ -787,6 +900,17 @@ export default function App() {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{namingChapter && (
|
||||
<NameDialog
|
||||
title={namingChapter.title}
|
||||
value={namingChapter.value}
|
||||
label="Chapter name"
|
||||
confirmLabel={namingChapter.value ? "Rename" : "Create"}
|
||||
onSave={namingChapter.onSave}
|
||||
onClose={() => setNamingChapter(null)}
|
||||
dark={dark}
|
||||
/>
|
||||
)}
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
title={confirmDialog.title}
|
||||
|
||||
@@ -174,6 +174,54 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A chapter, in the list of the notebook it belongs to.
|
||||
*
|
||||
* Shown above the loose notes rather than mixed in with them: a chapter is a
|
||||
* place, and the notes are what is in this one. The count is what makes it worth
|
||||
* opening — a row saying only "Invoices" tells you nothing about whether to.
|
||||
*/
|
||||
function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
|
||||
return (
|
||||
<button
|
||||
data-chapter-row={chapter.id}
|
||||
onClick={() => onOpen(chapter.id)}
|
||||
onContextMenu={(e) => onContextMenu(e, chapter)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.borderLight}`,
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
marginBottom: 4,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", color: T.accent, flexShrink: 0 }}>{I.books}</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{chapter.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>{chapter.noteCount}</span>
|
||||
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>›</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How this notebook's notes are ordered.
|
||||
*
|
||||
@@ -370,6 +418,11 @@ export function NoteList({
|
||||
onStartRenameSeparator,
|
||||
onRenameSeparator,
|
||||
onSeparatorContextMenu,
|
||||
chapters,
|
||||
openChapter,
|
||||
onOpenChapter,
|
||||
onAddChapter,
|
||||
onChapterContextMenu,
|
||||
onReorder,
|
||||
onToggleSidebar,
|
||||
onSelectNote,
|
||||
@@ -383,6 +436,11 @@ export function NoteList({
|
||||
browsers never fire from touch — on a phone the arrangement could not be
|
||||
changed at all, with nothing on screen to say why. */
|
||||
const dnd = useReorderDrag(!!reorderable, onReorder);
|
||||
/* Chapters divide a notebook, so they are offered in one — not in "All Notes"
|
||||
or the trash, which are views over notebooks rather than a place to file
|
||||
anything, and not inside a chapter, because a chapter holds notes. */
|
||||
const canAddChapter = !!onAddChapter && !openChapter;
|
||||
const shownChapters = openChapter ? [] : chapters || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -407,8 +465,13 @@ export function NoteList({
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
{/* Inside a chapter this is the way back out to the notebook — which
|
||||
on a phone is the only way, since the chapter fills the screen. */}
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
onClick={openChapter ? () => onOpenChapter(null) : onToggleSidebar}
|
||||
title={openChapter ? "Back to the notebook" : "Menu"}
|
||||
aria-label={openChapter ? "Back to the notebook" : "Menu"}
|
||||
data-chapter-back={openChapter ? "on" : undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -419,10 +482,10 @@ export function NoteList({
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color: T.muted,
|
||||
color: openChapter ? T.accent : T.muted,
|
||||
}}
|
||||
>
|
||||
{I.menu}
|
||||
{openChapter ? I.back : I.menu}
|
||||
</button>
|
||||
<h2
|
||||
style={{
|
||||
@@ -436,7 +499,7 @@ export function NoteList({
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
{openChapter ? openChapter.name : title}
|
||||
</h2>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||
@@ -490,7 +553,7 @@ export function NoteList({
|
||||
onQueryChange("");
|
||||
e.currentTarget.blur();
|
||||
}}
|
||||
placeholder={`Search in ${title}...`}
|
||||
placeholder={`Search in ${openChapter ? openChapter.name : title}...`}
|
||||
aria-label="Search in this notebook"
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -508,15 +571,40 @@ export function NoteList({
|
||||
|
||||
{/* Just the one control. The sentence that used to sit here explained the
|
||||
order the sort menu already names, on every visit, forever. */}
|
||||
{reorderable && (
|
||||
{(reorderable || canAddChapter) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 6,
|
||||
padding: "5px 10px",
|
||||
borderBottom: `1px solid ${T.borderLight}`,
|
||||
}}
|
||||
>
|
||||
{/* A chapter divides the notebook; a separator divides a list. They
|
||||
sit together because both are "add something that is not a note",
|
||||
and apart from the + button, which makes notes. */}
|
||||
{canAddChapter && (
|
||||
<button
|
||||
onClick={onAddChapter}
|
||||
data-add-chapter
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "4px 10px",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color: T.muted,
|
||||
fontSize: 11.5,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
+ Chapter
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onAddSeparator}
|
||||
data-add-separator
|
||||
@@ -585,6 +673,18 @@ export function NoteList({
|
||||
something there: in "newest first" the notes would slide past a
|
||||
line on every edit. They are not shown in the other orders, and
|
||||
are still in place on the way back. */}
|
||||
{shownChapters.map((chapter) => (
|
||||
<ChapterRow
|
||||
key={chapter.id}
|
||||
T={T}
|
||||
chapter={chapter}
|
||||
onOpen={onOpenChapter}
|
||||
onContextMenu={onChapterContextMenu}
|
||||
/>
|
||||
))}
|
||||
{shownChapters.length > 0 && notes.length > 0 && (
|
||||
<div style={{ height: 1, background: T.borderLight, margin: "8px 4px 10px" }} />
|
||||
)}
|
||||
{(reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry)).map((entry) => {
|
||||
const showDropLine = reorderable && dnd.overId === entry.id;
|
||||
/* The whole row, indicator included, is the drop target.
|
||||
|
||||
@@ -174,10 +174,23 @@ export function reindexEntries(
|
||||
export const END_ID = "__end__";
|
||||
|
||||
/** A new separator, added at the top where it can be seen and then dragged. */
|
||||
export const withNewSeparator = (separators: Separator[], id: string, label = ""): Separator[] => [
|
||||
{ id, label, order: 0 },
|
||||
...separators,
|
||||
];
|
||||
export const withNewSeparator = (
|
||||
separators: Separator[],
|
||||
id: string,
|
||||
label = "",
|
||||
chapterId: string | null = null
|
||||
): Separator[] => [{ id, label, order: 0, chapterId }, ...separators];
|
||||
|
||||
/**
|
||||
* The separators belonging to one list.
|
||||
*
|
||||
* A separator's `order` counts the notes before it, which only means anything
|
||||
* inside the list it was drawn in — the notebook itself, or one of its chapters.
|
||||
* Showing all of a notebook's separators in every chapter would put lines in
|
||||
* places nobody drew them.
|
||||
*/
|
||||
export const separatorsFor = (separators: Separator[], chapterId: string | null): Separator[] =>
|
||||
separators.filter((s) => (s.chapterId ?? null) === chapterId);
|
||||
|
||||
/**
|
||||
* The full notebook renumbered after a note was dragged onto another.
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as searchIndex from "../search";
|
||||
import { sameStrokeDoc } from "../features/drawing/strokes.js";
|
||||
import { reindexEntries } from "../features/notes/noteSort";
|
||||
import { extractOnImport } from "../features/pdf/pdfText";
|
||||
import type { Backlink, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
|
||||
import type { Backlink, Chapter, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
|
||||
|
||||
/** How often the whole account is reconciled with the server. */
|
||||
const FULL_SYNC_MS = 30_000;
|
||||
@@ -36,7 +36,7 @@ export interface NoteData {
|
||||
reload: () => Promise<void>;
|
||||
reset: () => void;
|
||||
|
||||
createNote: (folderId: string) => Promise<NoteMeta>;
|
||||
createNote: (folderId: string, chapterId?: string | null) => Promise<NoteMeta>;
|
||||
importPdf: (folderId: string, file: File) => Promise<NoteMeta>;
|
||||
updateNote: (id: string, edit: NoteEdit) => void;
|
||||
setNoteContent: (id: string, content: NoteContent) => void;
|
||||
@@ -45,12 +45,16 @@ export interface NoteData {
|
||||
restoreNote: (id: string) => Promise<void>;
|
||||
permanentDeleteNote: (id: string) => Promise<void>;
|
||||
emptyTrash: () => Promise<void>;
|
||||
moveNote: (id: string, folderId: string) => Promise<void>;
|
||||
moveNote: (id: string, folderId: string, chapterId?: string | null) => Promise<void>;
|
||||
/** Drag-to-reorder within a notebook; moves notes and separators alike. */
|
||||
rearrange: (notebookId: string, dragId: string, targetId: string) => void;
|
||||
rearrange: (notebookId: string, dragId: string, targetId: string, chapterId?: string | null) => void;
|
||||
|
||||
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
|
||||
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
|
||||
chapters: Chapter[];
|
||||
createChapter: (notebookId: string, name: string) => Promise<Chapter>;
|
||||
renameChapter: (id: string, name: string) => Promise<void>;
|
||||
deleteChapter: (id: string) => Promise<void>;
|
||||
deleteNotebook: (id: string) => Promise<void>;
|
||||
reorderNotebooks: (dragId: string, targetId: string) => void;
|
||||
}
|
||||
@@ -67,6 +71,7 @@ export interface NoteData {
|
||||
export function useNoteData(user: User | null, activeNote: string | null): NoteData {
|
||||
const [notes, setNotes] = useState<NoteMeta[]>([]);
|
||||
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
|
||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||
const [noteContents, setNoteContents] = useState<Record<string, NoteContent>>({});
|
||||
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
||||
|
||||
@@ -79,12 +84,14 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setNotebooks(await sync.getNotebooks());
|
||||
setChapters(await sync.getChapters());
|
||||
setNotes(await sync.getNotes());
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setNotes([]);
|
||||
setNotebooks([]);
|
||||
setChapters([]);
|
||||
setNoteContents({});
|
||||
setBacklinks([]);
|
||||
}, []);
|
||||
@@ -185,8 +192,8 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
|
||||
/* ── Notes ── */
|
||||
|
||||
const createNote = useCallback(async (folderId: string) => {
|
||||
const note = await sync.createNote(folderId);
|
||||
const createNote = useCallback(async (folderId: string, chapterId: string | null = null) => {
|
||||
const note = await sync.createNote(folderId, chapterId);
|
||||
setNotes((p) => [note, ...p]);
|
||||
setNoteContents((p) => ({ ...p, [note.id]: EMPTY_CONTENT }));
|
||||
return note;
|
||||
@@ -266,9 +273,9 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
setNotes((p) => p.filter((n) => !n.trashed));
|
||||
}, []);
|
||||
|
||||
const moveNote = useCallback(async (id: string, folderId: string) => {
|
||||
await sync.moveNote(id, folderId);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId } : n)));
|
||||
const moveNote = useCallback(async (id: string, folderId: string, chapterId: string | null = null) => {
|
||||
await sync.moveNote(id, folderId, chapterId);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId, chapterId } : n)));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -279,31 +286,82 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
* taken from the store rather than from whatever the list is showing: a search
|
||||
* or a filter must never be able to renumber the notes it is hiding.
|
||||
*/
|
||||
const rearrange = useCallback((notebookId: string, dragId: string, targetId: string) => {
|
||||
/* `chapterId` scopes the arrangement to one list: the notebook itself, or one
|
||||
of its chapters. A note's place is only meaningful beside the notes it is
|
||||
shown with, and the separators of the other chapters must come through the
|
||||
write untouched. */
|
||||
const rearrange = useCallback(
|
||||
(notebookId: string, dragId: string, targetId: string, chapterId: string | null = null) => {
|
||||
const notebook = notebooksRef.current.find((nb) => nb.id === notebookId);
|
||||
if (!notebook) return;
|
||||
const inNotebook = notesRef.current.filter((n) => !n.trashed && n.folderId === notebookId);
|
||||
const inNotebook = notesRef.current.filter(
|
||||
(n) => !n.trashed && n.folderId === notebookId && (n.chapterId ?? null) === chapterId
|
||||
);
|
||||
const all = notebook.separators || [];
|
||||
const mine = all.filter((sep) => (sep.chapterId ?? null) === chapterId);
|
||||
const others = all.filter((sep) => (sep.chapterId ?? null) !== chapterId);
|
||||
|
||||
const next = reindexEntries(inNotebook, notebook.separators || [], dragId, targetId);
|
||||
const next = reindexEntries(inNotebook, mine, dragId, targetId);
|
||||
if (!next) return;
|
||||
const merged = [...others, ...next.separators.map((sep) => ({ ...sep, chapterId }))];
|
||||
|
||||
const position = new Map(next.noteIds.map((id, i) => [id, i]));
|
||||
setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n)));
|
||||
void sync.reorderNotes(next.noteIds);
|
||||
|
||||
// Only written when it actually changed: a plain note drag leaves the
|
||||
// separators alone, and a pointless notebook write would sync for nothing.
|
||||
const before = JSON.stringify(notebook.separators || []);
|
||||
if (JSON.stringify(next.separators) !== before) {
|
||||
setNotebooks((p) =>
|
||||
p.map((nb) => (nb.id === notebookId ? { ...nb, separators: next.separators } : nb))
|
||||
);
|
||||
void sync.updateNotebook(notebookId, { separators: next.separators });
|
||||
/* Only written when it actually changed.
|
||||
*
|
||||
* A plain note drag leaves the separators alone, and a write for nothing
|
||||
* costs more than a request: it reloads the notes, which reshuffles any that
|
||||
* share an `updatedAt` second — so "last changed" quietly came out in a
|
||||
* different order after every drag.
|
||||
*
|
||||
* Compared as a set rather than as an array, because the merge above puts
|
||||
* the other chapters' separators first and that says nothing about whether
|
||||
* anything changed. */
|
||||
const fingerprint = (list: typeof all) =>
|
||||
list
|
||||
.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.chapterId ?? ""}`)
|
||||
.sort()
|
||||
.join("\n");
|
||||
const before = fingerprint(all);
|
||||
if (fingerprint(merged) !== before) {
|
||||
setNotebooks((p) => p.map((nb) => (nb.id === notebookId ? { ...nb, separators: merged } : nb)));
|
||||
void sync.updateNotebook(notebookId, { separators: merged });
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/* ── Notebooks ── */
|
||||
|
||||
/* ── Chapters ──
|
||||
*
|
||||
* Folders inside a notebook. Deleting one never deletes its notes: they come
|
||||
* back to the notebook, which is why the local copy is refreshed rather than
|
||||
* having the removal applied by hand here.
|
||||
*/
|
||||
|
||||
const createChapter = useCallback(async (notebookId: string, name: string) => {
|
||||
const chapter = await sync.createChapter(notebookId, name);
|
||||
setChapters((p) => [...p, chapter]);
|
||||
return chapter;
|
||||
}, []);
|
||||
|
||||
const renameChapter = useCallback(async (id: string, name: string) => {
|
||||
setChapters((p) => p.map((c) => (c.id === id ? { ...c, name } : c)));
|
||||
await sync.updateChapter(id, { name });
|
||||
}, []);
|
||||
|
||||
const deleteChapter = useCallback(
|
||||
async (id: string) => {
|
||||
await sync.deleteChapter(id);
|
||||
setChapters((p) => p.filter((c) => c.id !== id));
|
||||
setNotes((p) => p.map((n) => (n.chapterId === id ? { ...n, chapterId: null } : n)));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
|
||||
const nb = await sync.createNotebook(fields);
|
||||
setNotebooks((p) => [...p, nb]);
|
||||
@@ -355,6 +413,10 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
rearrange,
|
||||
createNotebook,
|
||||
updateNotebook,
|
||||
chapters,
|
||||
createChapter,
|
||||
renameChapter,
|
||||
deleteChapter,
|
||||
deleteNotebook,
|
||||
reorderNotebooks,
|
||||
};
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
* pushes queued changes to the server when a connection is available.
|
||||
*/
|
||||
|
||||
import type { NoteMeta, NoteContent, Notebook, StrokeDoc, QueueEntry } from "./types";
|
||||
import type { Chapter, NoteMeta, NoteContent, Notebook, StrokeDoc, QueueEntry } from "./types";
|
||||
|
||||
const DB_NAME = "inknote";
|
||||
const DB_VERSION = 3;
|
||||
const DB_VERSION = 4;
|
||||
|
||||
type StoreName = "notebooks" | "notes" | "noteContents" | "syncQueue" | "meta" | "attachments";
|
||||
type StoreName = "notebooks" | "chapters" | "notes" | "noteContents" | "syncQueue" | "meta" | "attachments";
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
|
||||
@@ -53,6 +53,12 @@ function open(): Promise<IDBDatabase> {
|
||||
if (!d.objectStoreNames.contains("attachments")) {
|
||||
d.createObjectStore("attachments", { keyPath: "id" });
|
||||
}
|
||||
|
||||
// Chapters — folders inside a notebook.
|
||||
if (!d.objectStoreNames.contains("chapters")) {
|
||||
const ch = d.createObjectStore("chapters", { keyPath: "id" });
|
||||
ch.createIndex("notebookId", "notebookId");
|
||||
}
|
||||
};
|
||||
|
||||
req.onsuccess = (e) => {
|
||||
@@ -101,6 +107,29 @@ export async function deleteMeta(key: string): Promise<void> {
|
||||
await reqToPromise(s.delete(key));
|
||||
}
|
||||
|
||||
/* ─── Chapters ─── */
|
||||
|
||||
export async function getAllChapters(): Promise<Chapter[]> {
|
||||
const s = await store("chapters", "readonly");
|
||||
return reqToPromise<Chapter[]>(s.getAll());
|
||||
}
|
||||
|
||||
export async function putChapter(c: Chapter): Promise<void> {
|
||||
const s = await store("chapters", "readwrite");
|
||||
await reqToPromise(s.put(c));
|
||||
}
|
||||
|
||||
export async function putChapters(list: Chapter[]): Promise<void> {
|
||||
const s = await store("chapters", "readwrite");
|
||||
for (const c of list) s.put(c);
|
||||
await txDone(s.transaction);
|
||||
}
|
||||
|
||||
export async function deleteChapter(id: string): Promise<void> {
|
||||
const s = await store("chapters", "readwrite");
|
||||
await reqToPromise(s.delete(id));
|
||||
}
|
||||
|
||||
/* ─── Notebooks ─── */
|
||||
export async function getAllNotebooks(): Promise<Notebook[]> {
|
||||
const s = await store("notebooks", "readonly");
|
||||
|
||||
+171
-7
@@ -8,6 +8,7 @@
|
||||
import * as db from "./offlineStore";
|
||||
import type {
|
||||
Backlink,
|
||||
Chapter,
|
||||
ManagedUser,
|
||||
NoteContent,
|
||||
NoteMeta,
|
||||
@@ -78,10 +79,18 @@ interface ServerNotebook {
|
||||
separators?: string | null;
|
||||
}
|
||||
|
||||
interface ServerChapter {
|
||||
id: string;
|
||||
notebook_id: string;
|
||||
name: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
interface ServerNote {
|
||||
id: string;
|
||||
title: string;
|
||||
notebook_id: string | null;
|
||||
chapter_id?: string | null;
|
||||
starred?: number | boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
@@ -108,6 +117,14 @@ export interface NoteUpdate {
|
||||
title?: string;
|
||||
content?: string;
|
||||
notebookId?: string | null;
|
||||
/**
|
||||
* Which chapter the note is filed in; `null` means the notebook itself.
|
||||
*
|
||||
* Meaningful as null, so it must not be dropped the way an absent field is —
|
||||
* "take it out of its chapter" and "leave it where it is" are different
|
||||
* instructions and both have to be sendable.
|
||||
*/
|
||||
chapterId?: string | null;
|
||||
starred?: boolean;
|
||||
links?: string[];
|
||||
/** Attachments the body embeds, so ones nothing uses can be released. */
|
||||
@@ -404,6 +421,14 @@ export async function fullSync(): Promise<void> {
|
||||
try {
|
||||
await flushQueue();
|
||||
|
||||
// Chapters first: the notes arriving below point at them.
|
||||
try {
|
||||
await fetchChapters();
|
||||
} catch (e) {
|
||||
if (isAuthError(e)) throw e;
|
||||
// An older server has no /chapters. Everything else still syncs.
|
||||
}
|
||||
|
||||
const localNbs = await db.getAllNotebooks();
|
||||
const serverNbs = await api.get<ServerNotebook[]>("/notebooks");
|
||||
const nbs: Notebook[] = serverNbs.map((nb) => ({
|
||||
@@ -504,7 +529,13 @@ type QueuedAction =
|
||||
};
|
||||
}
|
||||
| { action: "deleteNotebook"; data: { id: string } }
|
||||
| { action: "createNote"; data: { localId: string; title: string; folderId: string | null } }
|
||||
| { action: "createChapter"; data: { localId: string; notebookId: string; name: string } }
|
||||
| { action: "updateChapter"; data: { id: string; name?: string; sortOrder?: number } }
|
||||
| { action: "deleteChapter"; data: { id: string } }
|
||||
| {
|
||||
action: "createNote";
|
||||
data: { localId: string; title: string; folderId: string | null; chapterId?: string | null };
|
||||
}
|
||||
| { action: "updateNote"; data: { id: string; updates: NoteUpdate } }
|
||||
| { action: "updateDrawing"; data: { id: string; drawingStrokes: StrokeDoc | null } }
|
||||
| { action: "trashNote"; data: { id: string } }
|
||||
@@ -607,12 +638,40 @@ async function processEntry(entry: QueueEntry): Promise<void> {
|
||||
case "deleteNotebook":
|
||||
await api.del(`/notebooks/${a.data.id}`);
|
||||
break;
|
||||
case "createChapter": {
|
||||
const d = a.data;
|
||||
const r = await api.post<{ id: string }>("/chapters", { notebookId: d.notebookId, name: d.name });
|
||||
if (r.id !== d.localId) {
|
||||
const local = (await db.getAllChapters()).find((c) => c.id === d.localId);
|
||||
await db.deleteChapter(d.localId);
|
||||
await db.putChapter({
|
||||
id: r.id,
|
||||
notebookId: d.notebookId,
|
||||
name: d.name,
|
||||
sortOrder: local?.sortOrder ?? 0,
|
||||
});
|
||||
// Notes filed into it while it was local, and anything still queued
|
||||
// behind this create — both address a name only this device knew.
|
||||
for (const n of await db.getAllNotes()) {
|
||||
if (n.chapterId === d.localId) await db.putNote({ ...n, chapterId: r.id });
|
||||
}
|
||||
await db.repointQueue(d.localId, r.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "updateChapter":
|
||||
await api.put(`/chapters/${a.data.id}`, { name: a.data.name, sortOrder: a.data.sortOrder });
|
||||
break;
|
||||
case "deleteChapter":
|
||||
await api.del(`/chapters/${a.data.id}`);
|
||||
break;
|
||||
case "createNote": {
|
||||
const d = a.data;
|
||||
const content = await db.getNoteContent(d.localId);
|
||||
const r = await api.post<{ id: string }>("/notes", {
|
||||
title: d.title,
|
||||
notebookId: d.folderId,
|
||||
chapterId: d.chapterId ?? null,
|
||||
content: content?.content || "",
|
||||
});
|
||||
if (r.id !== d.localId) {
|
||||
@@ -715,6 +774,93 @@ export async function getNotebooks(): Promise<Notebook[]> {
|
||||
return db.getAllNotebooks();
|
||||
}
|
||||
|
||||
/* ─── Chapters ───
|
||||
*
|
||||
* Folders inside a notebook. They ride the same road as notebooks: written
|
||||
* locally first so the app works offline, queued when there is no connection,
|
||||
* and repointed when the server names a locally created one something else.
|
||||
*/
|
||||
|
||||
/** The local copy, in the order they were made. */
|
||||
export async function getChapters(): Promise<Chapter[]> {
|
||||
return (await db.getAllChapters()).sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
||||
}
|
||||
|
||||
export async function fetchChapters(): Promise<Chapter[]> {
|
||||
const rows = await api.get<ServerChapter[]>("/chapters");
|
||||
const chapters: Chapter[] = rows.map((c) => ({
|
||||
id: c.id,
|
||||
notebookId: c.notebook_id,
|
||||
name: c.name,
|
||||
sortOrder: c.sort_order || 0,
|
||||
}));
|
||||
const local = await db.getAllChapters();
|
||||
// Anything made offline is kept: its create is still in the queue, and the
|
||||
// server cannot know about it yet.
|
||||
const server = new Set(chapters.map((c) => c.id));
|
||||
const pending = local.filter((c) => c._pendingCreate && !server.has(c.id));
|
||||
await db.putChapters([...chapters, ...pending]);
|
||||
return [...chapters, ...pending];
|
||||
}
|
||||
|
||||
export async function createChapter(notebookId: string, name: string): Promise<Chapter> {
|
||||
const sortOrder = (await db.getAllChapters()).filter((c) => c.notebookId === notebookId).length;
|
||||
if (_online && api.token) {
|
||||
try {
|
||||
const r = await api.post<{ id: string }>("/chapters", { notebookId, name });
|
||||
const chapter: Chapter = { id: r.id, notebookId, name, sortOrder };
|
||||
await db.putChapter(chapter);
|
||||
notifyData("chapter", chapter);
|
||||
return chapter;
|
||||
} catch (e) {
|
||||
if (isAuthError(e)) throw e;
|
||||
}
|
||||
}
|
||||
const lid = "ch_" + Math.random().toString(36).slice(2, 10);
|
||||
const chapter: Chapter = { id: lid, notebookId, name, sortOrder, _pendingCreate: true };
|
||||
await db.putChapter(chapter);
|
||||
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name } });
|
||||
notifyData("chapter", chapter);
|
||||
notifyStatus("pending");
|
||||
return chapter;
|
||||
}
|
||||
|
||||
export async function updateChapter(id: string, updates: { name?: string; sortOrder?: number }): Promise<void> {
|
||||
const chapter = (await db.getAllChapters()).find((c) => c.id === id);
|
||||
if (!chapter) return;
|
||||
await db.putChapter({ ...chapter, ...updates });
|
||||
notifyData("chapter", { ...chapter, ...updates });
|
||||
if (_online && api.token) {
|
||||
try {
|
||||
await api.put(`/chapters/${id}`, updates);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (isAuthError(e)) throw e;
|
||||
}
|
||||
}
|
||||
await enqueue({ action: "updateChapter", data: { id, ...updates } });
|
||||
notifyStatus("pending");
|
||||
}
|
||||
|
||||
/** Removes a chapter. Its notes are not removed — they return to the notebook. */
|
||||
export async function deleteChapter(id: string): Promise<void> {
|
||||
await db.deleteChapter(id);
|
||||
for (const n of await db.getAllNotes()) {
|
||||
if (n.chapterId === id) await db.putNote({ ...n, chapterId: null });
|
||||
}
|
||||
notifyData("chapter", null);
|
||||
if (_online && api.token) {
|
||||
try {
|
||||
await api.del(`/chapters/${id}`);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (isAuthError(e)) throw e;
|
||||
}
|
||||
}
|
||||
await enqueue({ action: "deleteChapter", data: { id } });
|
||||
notifyStatus("pending");
|
||||
}
|
||||
|
||||
export async function createNotebook({
|
||||
name,
|
||||
cover,
|
||||
@@ -909,11 +1055,16 @@ export async function loadNoteContent(
|
||||
return shapeContent(await db.getNoteContent(id));
|
||||
}
|
||||
|
||||
export async function createNote(folderId: string | null): Promise<NoteMeta> {
|
||||
/** A note made while a chapter is open belongs to that chapter. */
|
||||
export async function createNote(
|
||||
folderId: string | null,
|
||||
chapterId: string | null = null
|
||||
): Promise<NoteMeta> {
|
||||
const ts = new Date().toISOString();
|
||||
const base = {
|
||||
title: "Untitled Note",
|
||||
folderId,
|
||||
chapterId,
|
||||
starred: false,
|
||||
trashed: false,
|
||||
createdAt: ts,
|
||||
@@ -925,6 +1076,7 @@ export async function createNote(folderId: string | null): Promise<NoteMeta> {
|
||||
const r = await api.post<{ id: string }>("/notes", {
|
||||
title: base.title,
|
||||
notebookId: folderId,
|
||||
chapterId,
|
||||
content: "",
|
||||
});
|
||||
const note: NoteMeta = { id: r.id, ...base };
|
||||
@@ -941,7 +1093,7 @@ export async function createNote(folderId: string | null): Promise<NoteMeta> {
|
||||
const note: NoteMeta = { id: lid, ...base, _pendingCreate: true };
|
||||
await db.putNote(note);
|
||||
await db.putNoteContent(lid, "", null);
|
||||
await enqueue({ action: "createNote", data: { localId: lid, title: base.title, folderId } });
|
||||
await enqueue({ action: "createNote", data: { localId: lid, title: base.title, folderId, chapterId } });
|
||||
notifyData("note", note);
|
||||
notifyStatus("pending");
|
||||
return note;
|
||||
@@ -1041,17 +1193,29 @@ export async function toggleNoteStar(id: string): Promise<boolean> {
|
||||
return updated.starred;
|
||||
}
|
||||
|
||||
export async function moveNote(id: string, folderId: string | null): Promise<void> {
|
||||
/**
|
||||
* Files a note in a notebook, and optionally in a chapter of it.
|
||||
*
|
||||
* Moving to another notebook clears the chapter unless a new one is named: a
|
||||
* chapter belongs to the book it is in, so keeping it would file the note
|
||||
* somewhere that cannot be reached from either.
|
||||
*/
|
||||
export async function moveNote(
|
||||
id: string,
|
||||
folderId: string | null,
|
||||
chapterId: string | null = null
|
||||
): Promise<void> {
|
||||
const note = await db.getNote(id);
|
||||
if (!note) return;
|
||||
const updated: NoteMeta = { ...note, folderId, updatedAt: new Date().toISOString() };
|
||||
const updated: NoteMeta = { ...note, folderId, chapterId, updatedAt: new Date().toISOString() };
|
||||
await db.putNote(updated);
|
||||
notifyData("note", updated);
|
||||
|
||||
const updates: NoteUpdate = { notebookId: folderId, chapterId };
|
||||
if (_online && api.token && !updated._pendingCreate) {
|
||||
api.put(`/notes/${id}`, { notebookId: folderId }).catch(() => {});
|
||||
api.put(`/notes/${id}`, updates).catch(() => {});
|
||||
} else {
|
||||
await enqueue({ action: "updateNote", data: { id, updates: { notebookId: folderId } } });
|
||||
await enqueue({ action: "updateNote", data: { id, updates } });
|
||||
notifyStatus("pending");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface NoteMeta {
|
||||
/** Attachment holding the PDF, for notes of type "pdf". */
|
||||
pdfAttachmentId?: string | null;
|
||||
folderId: string | null;
|
||||
/** Chapter within the notebook; null means the notebook itself. */
|
||||
chapterId?: string | null;
|
||||
starred: boolean;
|
||||
trashed: boolean;
|
||||
createdAt: string;
|
||||
@@ -84,6 +86,28 @@ export interface Separator {
|
||||
id: string;
|
||||
label: string;
|
||||
order: number;
|
||||
/**
|
||||
* The list it was drawn in: a chapter of the notebook, or null for the
|
||||
* notebook itself. `order` counts the notes before it, which only means
|
||||
* anything within one list.
|
||||
*/
|
||||
chapterId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A folder inside a notebook.
|
||||
*
|
||||
* A note is in exactly one chapter, or in none — which means the notebook
|
||||
* itself. Deleting a chapter never deletes its notes; they come back to the
|
||||
* notebook, where they can be seen and filed again.
|
||||
*/
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
notebookId: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
/** Set while a chapter created offline has not reached the server yet. */
|
||||
_pendingCreate?: boolean;
|
||||
}
|
||||
|
||||
export interface Notebook {
|
||||
|
||||
@@ -67,8 +67,11 @@ export function ContextMenu({ x, y, items, onClose, dark }) {
|
||||
padding: 4,
|
||||
minWidth: 190,
|
||||
maxWidth: "calc(100vw - 16px)",
|
||||
maxHeight: "calc(100vh - 16px)",
|
||||
overflowY: "auto",
|
||||
/* No `overflow` here. A submenu flies out sideways, and any scrolling
|
||||
ancestor clips it away — which is exactly what happened: the list of
|
||||
notebooks was cut off and every click on it landed on the note list
|
||||
behind. The submenu does its own scrolling instead, and these menus
|
||||
are half a dozen items long. */
|
||||
fontFamily: "'DM Sans',sans-serif",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -150,7 +153,9 @@ function SubMenu({ item, T, onClose }) {
|
||||
if (!row) return;
|
||||
// 180 is this submenu's minimum width; going off the right edge means it
|
||||
// belongs on the other side.
|
||||
setSide(row.right + 180 > window.innerWidth - 8 ? "left" : "right");
|
||||
// `left: 100%` places it to the right of the row, and `right: 100%` to the
|
||||
// left — so the property is the opposite of the side it ends up on.
|
||||
setSide(row.right + 180 > window.innerWidth - 8 ? "right" : "left");
|
||||
}, [open]);
|
||||
|
||||
const panel = (
|
||||
@@ -211,7 +216,7 @@ function SubMenu({ item, T, onClose }) {
|
||||
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
|
||||
{item.label}
|
||||
<span style={{ marginLeft: "auto", fontSize: 10, opacity: 0.5 }}>
|
||||
{inline && open ? "▾" : side === "left" ? "◂" : "▸"}
|
||||
{inline && open ? "▾" : side === "right" ? "◂" : "▸"}
|
||||
</span>
|
||||
</div>
|
||||
{open && panel}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Asking for one name.
|
||||
*
|
||||
* A real dialog rather than `window.prompt`, for the same reasons the link
|
||||
* dialog is one: the browser's box cannot be styled, cannot be reached by the
|
||||
* tests, and on a phone it arrives as a system alert with the app greyed out
|
||||
* behind it.
|
||||
*
|
||||
* Deliberately small. Naming a chapter is one field and two buttons, and
|
||||
* anything more would be a form.
|
||||
*/
|
||||
export function NameDialog({ title, value = "", label = "Name", confirmLabel = "Save", onSave, onClose, dark }) {
|
||||
const [name, setName] = useState(value);
|
||||
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",
|
||||
field: dark ? "#0d1117" : "#f6f8fa",
|
||||
}),
|
||||
[dark]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
onSave(trimmed);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-name-dialog
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 4000,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 380,
|
||||
background: T.bg,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 16,
|
||||
padding: 22,
|
||||
fontFamily: "'DM Sans',sans-serif",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: "0 0 14px", fontSize: 16, fontWeight: 700, color: T.text }}>{title}</h3>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
aria-label={label}
|
||||
placeholder={label}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") save();
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 14px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: T.field,
|
||||
color: T.text,
|
||||
fontSize: 14,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 18 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: "transparent",
|
||||
color: T.text,
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!name.trim()}
|
||||
style={{
|
||||
padding: "8px 18px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: name.trim() ? T.accent : T.border,
|
||||
color: "#fff",
|
||||
cursor: name.trim() ? "pointer" : "default",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -431,7 +431,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 |
|
||||
| `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 |
|
||||
| `organise` | 50 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
|
||||
| `organise` | 52 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
|
||||
| `chapters` | 13 | a note filed into a chapter leaves the notebook's list, and removing the chapter gives every note back |
|
||||
| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
|
||||
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |
|
||||
|
||||
@@ -940,6 +941,40 @@ rows behind.
|
||||
|
||||
---
|
||||
|
||||
## Chapters
|
||||
|
||||
Folders inside a notebook. A note is in exactly one chapter, or in none — which
|
||||
means the notebook itself. Chapters are listed above the loose notes; tapping one
|
||||
opens its own list, and the menu button becomes a way back out, which on a phone
|
||||
is the only way, since the list fills the screen.
|
||||
|
||||
**A chapter is not a wastebasket.** Removing one never removes what it held: the
|
||||
notes come back to the notebook, where they can be seen and filed again. That is
|
||||
`ON DELETE SET NULL` on `notes.chapter_id`, and it is also written out explicitly
|
||||
in the delete route — it is the promise the whole feature rests on, and it should
|
||||
be visible where it happens rather than inferred from a schema. The dialog says
|
||||
so too, with the number of notes affected.
|
||||
|
||||
Three rules that keep the two levels honest:
|
||||
|
||||
- **A note filed into a chapter leaves the notebook's own list.** Showing it in
|
||||
both would mean filing it appeared to do nothing.
|
||||
- **A chapter belongs to its notebook.** Filing a note into a chapter of a book
|
||||
it is not in is refused by the server; moving a note to another notebook clears
|
||||
its chapter, since the old one is in the book it just left.
|
||||
- **Separators belong to the list they were drawn in.** A separator's `order`
|
||||
counts the notes before it, which only means anything inside one list, so each
|
||||
carries the chapter it belongs to — `null` for the notebook itself. The
|
||||
notebook still stores one list of them, and a change made in one chapter writes
|
||||
the others back untouched.
|
||||
|
||||
A note made while a chapter is open is filed there without being moved. A chapter
|
||||
is named when it is created, unlike a separator: a separator is a line and reads
|
||||
perfectly well unnamed, while an unnamed chapter is a row nobody can tell from
|
||||
the next one.
|
||||
|
||||
---
|
||||
|
||||
## Sorting a notebook
|
||||
|
||||
Each notebook remembers how its notes are ordered, and remembers it on the
|
||||
|
||||
Reference in new issue
Block a user