Marks, lines, and a table that breaks
A long table lay straight across the gap between two sheets with a row cut in half, and a shorter one jumped to the next sheet whole leaving the rest of the first blank. Tables are broken between their rows now, which is where a table breaks on paper. The spacer there is a tr with one borderless cell: a div inside a tbody is not something the browser keeps. Its own spacer rows are subtracted again on the next measurement and counted into the shift every block below inherits — miss that and everything under a broken table is measured a page too high. A dialog closed in the middle of an edit if a selection was dragged past its edge: the click lands on the nearest common ancestor of press and release, which is the backdrop, and every dialog read that as "clicked beside me". They remember where the press began now. All ten. A note and a chapter can wear a mark of their own beside the name, chosen from a short list searched by what a thing is for rather than from the system emoji keyboard — a different thing on every machine, and three thousand pictures for a decision that is "which of these is the invoice notebook". A separator takes its own colour and one of five shapes, and a chapter can be divided like the notebook it sits in: both places a chapter is shown draw it with one component now, so it cannot be divisible in the column and not in the unfolded block. Also: a chapter's colour could not be cleared, because the update rode on COALESCE and null there means "leave it alone". A table's header row is legible on the dark theme. Three entries in the table's own menu were still German. Opening a notebook opens its first note, on a wide screen only — on a phone the list is the screen. Fourteen suites green: 364 browser checks, 241 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c312e53c2
commit
f1d3e38f8f
29 files changed
+1335
-149
No files matched your search
+63
-10
@@ -456,6 +456,19 @@ db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_chapters_notebook ON chapters(notebook_id);
|
||||
`);
|
||||
|
||||
/* A mark of its own, so a row can be found by its shape rather than by reading
|
||||
* every title. One short string — an emoji is one or two code points, and
|
||||
* anything longer than a handful is not a mark. */
|
||||
if (!tableColumns("notes").includes("icon")) {
|
||||
db.exec("ALTER TABLE notes ADD COLUMN icon TEXT");
|
||||
}
|
||||
if (!tableColumns("chapters").includes("icon")) {
|
||||
db.exec("ALTER TABLE chapters ADD COLUMN icon TEXT");
|
||||
}
|
||||
if (!tableColumns("notes").includes("icon_color")) {
|
||||
db.exec("ALTER TABLE notes ADD COLUMN icon_color TEXT");
|
||||
}
|
||||
|
||||
/* Added after chapters shipped, so an existing table needs it too. */
|
||||
if (!tableColumns("chapters").includes("color")) {
|
||||
db.exec("ALTER TABLE chapters ADD COLUMN color TEXT");
|
||||
@@ -927,6 +940,9 @@ const SORT_MODES = ["manual", "updated", "oldest", "created", "title"];
|
||||
/* An upper bound on the arrangement, so a malformed client cannot store an
|
||||
* unbounded blob on a notebook. */
|
||||
const MAX_SEPARATORS = 200;
|
||||
|
||||
/** The ways a separator's rule can be drawn. Anything else is left off. */
|
||||
const SEPARATOR_STYLES = ["solid", "dashed", "dotted", "double", "thick"];
|
||||
const MAX_SEPARATOR_LABEL = 80;
|
||||
|
||||
/** Keeps only what a separator is allowed to be. */
|
||||
@@ -943,6 +959,10 @@ function cleanSeparators(input) {
|
||||
* 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,
|
||||
// Presentation, and it comes from a browser: a colour has to look like one
|
||||
// and a style has to be one we draw, or it is simply left off.
|
||||
color: cleanColor(s.color),
|
||||
style: SEPARATOR_STYLES.includes(s.style) ? s.style : null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1080,14 +1100,14 @@ function isBigShrink(previous, next) {
|
||||
|
||||
/* ─── Note routes ─── */
|
||||
app.get("/api/notes", authMiddleware, (req, res) => {
|
||||
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);
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, icon, icon_color, 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);
|
||||
});
|
||||
|
||||
app.get("/api/notes/trash", authMiddleware, (req, res) => {
|
||||
// `chapter_id` travels with it: a note restored from the bin belongs back in
|
||||
// the chapter it was filed in, not loose in the notebook.
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, starred, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, icon, icon_color, starred, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
|
||||
res.json(notes);
|
||||
});
|
||||
|
||||
@@ -1234,7 +1254,7 @@ const replaceNoteAttachments = db.transaction((noteId, ids) => {
|
||||
});
|
||||
|
||||
app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
const { title, content, notebookId, chapterId, starred, baseUpdatedAt, links, attachments } = req.body;
|
||||
const { title, content, notebookId, chapterId, starred, icon, iconColor, 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" });
|
||||
|
||||
@@ -1289,6 +1309,15 @@ 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);
|
||||
|
||||
/* Cleared as deliberately as it is set: null means "take the mark off", which
|
||||
COALESCE above cannot express. */
|
||||
if (icon !== undefined) {
|
||||
db.prepare("UPDATE notes SET icon = ? WHERE id = ?").run(cleanIcon(icon), req.params.id);
|
||||
}
|
||||
if (iconColor !== undefined) {
|
||||
db.prepare("UPDATE notes SET icon_color = ? WHERE id = ?").run(cleanColor(iconColor), 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) {
|
||||
@@ -1713,16 +1742,30 @@ const MAX_CHAPTER_NAME = 120;
|
||||
const cleanColor = (value) =>
|
||||
typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : null;
|
||||
|
||||
/**
|
||||
* A row's own mark: one emoji, or nothing.
|
||||
*
|
||||
* Length is counted in code points, not in UTF-16 units — a single emoji is
|
||||
* routinely two of the latter, and a flag or a family several more. Cut at eight
|
||||
* so one grapheme with its modifiers fits and a pasted sentence does not.
|
||||
*/
|
||||
const cleanIcon = (value) => {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
return [...trimmed].slice(0, 8).join("");
|
||||
};
|
||||
|
||||
app.get("/api/chapters", authMiddleware, (req, res) => {
|
||||
res.json(
|
||||
db
|
||||
.prepare("SELECT id, notebook_id, name, color, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
|
||||
.prepare("SELECT id, notebook_id, name, color, icon, 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, color } = req.body || {};
|
||||
const { notebookId, name, color, icon } = 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" });
|
||||
@@ -1736,8 +1779,8 @@ app.post("/api/chapters", authMiddleware, (req, res) => {
|
||||
.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, color, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
.run(id, req.userId, notebookId, label, cleanColor(color), order, req.deviceId);
|
||||
db.prepare("INSERT INTO chapters (id, user_id, notebook_id, name, color, icon, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run(id, req.userId, notebookId, label, cleanColor(color), cleanIcon(icon), order, req.deviceId);
|
||||
broadcastToUser(req.userId, { type: "chapter_created", data: { id, notebookId, name: label } }, req.deviceId);
|
||||
res.json({ id, notebookId, name: label, sortOrder: order });
|
||||
});
|
||||
@@ -1761,7 +1804,7 @@ app.post("/api/chapters/reorder", authMiddleware, (req, res) => {
|
||||
});
|
||||
|
||||
app.put("/api/chapters/:id", authMiddleware, (req, res) => {
|
||||
const { name, sortOrder, color } = req.body || {};
|
||||
const { name, sortOrder, color, icon } = 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" });
|
||||
|
||||
@@ -1771,8 +1814,18 @@ app.put("/api/chapters/:id", authMiddleware, (req, res) => {
|
||||
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), color = COALESCE(?, color), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), device_id = ? WHERE id = ?")
|
||||
.run(label ?? null, color === undefined ? null : cleanColor(color), sortOrder ?? null, req.deviceId, req.params.id);
|
||||
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);
|
||||
|
||||
/* Colour and mark are set and *cleared* by the same field, so neither can ride
|
||||
* on COALESCE the way the name does: null means "take it off", and COALESCE
|
||||
* reads null as "leave it alone" — so choosing "None" quietly did nothing. */
|
||||
if (color !== undefined) {
|
||||
db.prepare("UPDATE chapters SET color = ? WHERE id = ?").run(cleanColor(color), req.params.id);
|
||||
}
|
||||
if (icon !== undefined) {
|
||||
db.prepare("UPDATE chapters SET icon = ? WHERE id = ?").run(cleanIcon(icon), req.params.id);
|
||||
}
|
||||
broadcastToUser(req.userId, { type: "chapter_updated", data: { id: req.params.id, name: label } }, req.deviceId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user