Rename Book to Topic throughout the app

The book/topic field was really always a general-purpose tag, and now
that notes can come from more than just books (camera-scanned pages,
free text), "book" was misleading in the UI as well as the code.
Renames files, functions, variables, the API route (/api/books ->
/api/topics), the note payload field, and the cache TTL env var
(BOOK_CACHE_TTL_MS -> TOPIC_CACHE_TTL_MS) for consistency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3DGg1pvmvf81zXs4pk1J7
This commit is contained in:
Claudio Schaad 2026-08-27 18:55:51 +02:00
parent 3da4dec452
commit be421d83cb
12 changed files with 74 additions and 74 deletions

View file

@ -17,4 +17,4 @@ PARENT_PAGE_TITLE=Reading Notes
# Optional # Optional
PORT=3000 PORT=3000
HOST_PORT=3000 HOST_PORT=3000
BOOK_CACHE_TTL_MS=60000 TOPIC_CACHE_TTL_MS=60000

View file

@ -1,6 +1,6 @@
# Notes # Notes
A small PWA for jotting down short notes while reading — e.g. an interesting sentence from a book. Each note is tagged with a book/topic (existing or new) and can optionally include a page number. Notes are saved as markdown directly into pages of a Nextcloud **Collective**, one page per book/topic, nested under a parent page (default: "Reading Notes"). A note's text can be typed by hand or filled in by scanning a photo of the page (on-device OCR via `tesseract.js` — no image or text ever leaves the device for this step). A small PWA for jotting down short notes while reading — e.g. an interesting sentence from a book. Each note is tagged with a topic (existing or new) and can optionally include a page number. Notes are saved as markdown directly into pages of a Nextcloud **Collective**, one page per topic, nested under a parent page (default: "Reading Notes"). A note's text can be typed by hand or filled in by scanning a photo of the page (on-device OCR via `tesseract.js` — no image or text ever leaves the device for this step).
## Stack ## Stack

View file

@ -1,6 +0,0 @@
import type { Book } from "@notes/shared";
import { apiFetch } from "./client";
export function fetchBooks() {
return apiFetch<Book[]>("/api/books");
}

6
client/src/api/topics.ts Normal file
View file

@ -0,0 +1,6 @@
import type { Topic } from "@notes/shared";
import { apiFetch } from "./client";
export function fetchTopics() {
return apiFetch<Topic[]>("/api/topics");
}

View file

@ -1,20 +1,20 @@
import { useRef, useState, type ChangeEvent, type FormEvent } from "react"; import { useRef, useState, type ChangeEvent, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchBooks } from "../api/books"; import { fetchTopics } from "../api/topics";
import { saveNote } from "../api/notes"; import { saveNote } from "../api/notes";
import { logout } from "../api/auth"; import { logout } from "../api/auth";
import { recognizeText } from "../lib/ocr"; import { recognizeText } from "../lib/ocr";
import { ImageCropper } from "../components/ImageCropper"; import { ImageCropper } from "../components/ImageCropper";
const NEW_BOOK_OPTION = "__new__"; const NEW_TOPIC_OPTION = "__new__";
export function NoteFormPage({ onLoggedOut }: { onLoggedOut: () => void }) { export function NoteFormPage({ onLoggedOut }: { onLoggedOut: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const booksQuery = useQuery({ queryKey: ["books"], queryFn: fetchBooks }); const topicsQuery = useQuery({ queryKey: ["topics"], queryFn: fetchTopics });
const [book, setBook] = useState(""); const [topic, setTopic] = useState("");
const [isAddingNewBook, setIsAddingNewBook] = useState(false); const [isAddingNewTopic, setIsAddingNewTopic] = useState(false);
const [newBookTitle, setNewBookTitle] = useState(""); const [newTopicTitle, setNewTopicTitle] = useState("");
const [page, setPage] = useState(""); const [page, setPage] = useState("");
const [text, setText] = useState(""); const [text, setText] = useState("");
const [isScanning, setIsScanning] = useState(false); const [isScanning, setIsScanning] = useState(false);
@ -27,7 +27,7 @@ export function NoteFormPage({ onLoggedOut }: { onLoggedOut: () => void }) {
onSuccess: () => { onSuccess: () => {
setText(""); setText("");
setPage(""); setPage("");
queryClient.invalidateQueries({ queryKey: ["books"] }); queryClient.invalidateQueries({ queryKey: ["topics"] });
} }
}); });
@ -36,14 +36,14 @@ export function NoteFormPage({ onLoggedOut }: { onLoggedOut: () => void }) {
onSuccess: onLoggedOut onSuccess: onLoggedOut
}); });
function handleBookSelect(value: string) { function handleTopicSelect(value: string) {
if (value === NEW_BOOK_OPTION) { if (value === NEW_TOPIC_OPTION) {
setIsAddingNewBook(true); setIsAddingNewTopic(true);
setBook(""); setTopic("");
} else { } else {
setIsAddingNewBook(false); setIsAddingNewTopic(false);
setNewBookTitle(""); setNewTopicTitle("");
setBook(value); setTopic(value);
} }
} }
@ -71,12 +71,12 @@ export function NoteFormPage({ onLoggedOut }: { onLoggedOut: () => void }) {
function handleSubmit(e: FormEvent) { function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
const trimmedBook = (isAddingNewBook ? newBookTitle : book).trim(); const trimmedTopic = (isAddingNewTopic ? newTopicTitle : topic).trim();
const trimmedText = text.trim(); const trimmedText = text.trim();
if (!trimmedBook || !trimmedText) return; if (!trimmedTopic || !trimmedText) return;
saveNoteMutation.mutate({ saveNoteMutation.mutate({
book: trimmedBook, topic: trimmedTopic,
page: page ? Number(page) : undefined, page: page ? Number(page) : undefined,
text: trimmedText text: trimmedText
}); });
@ -132,34 +132,34 @@ export function NoteFormPage({ onLoggedOut }: { onLoggedOut: () => void }) {
</label> </label>
<label> <label>
Book / topic Topic
<select <select
value={isAddingNewBook ? NEW_BOOK_OPTION : book} value={isAddingNewTopic ? NEW_TOPIC_OPTION : topic}
onChange={(e) => handleBookSelect(e.target.value)} onChange={(e) => handleTopicSelect(e.target.value)}
disabled={booksQuery.isLoading} disabled={topicsQuery.isLoading}
required required
> >
<option value="" disabled> <option value="" disabled>
{booksQuery.isLoading ? "Loading…" : "Select a book / topic"} {topicsQuery.isLoading ? "Loading…" : "Select a topic"}
</option> </option>
{booksQuery.data?.map((b) => ( {topicsQuery.data?.map((t) => (
<option key={b.title} value={b.title}> <option key={t.title} value={t.title}>
{b.title} {t.title}
</option> </option>
))} ))}
<option value={NEW_BOOK_OPTION}>+ Add new book / topic…</option> <option value={NEW_TOPIC_OPTION}>+ Add new topic…</option>
</select> </select>
{isAddingNewBook && ( {isAddingNewTopic && (
<input <input
value={newBookTitle} value={newTopicTitle}
onChange={(e) => setNewBookTitle(e.target.value)} onChange={(e) => setNewTopicTitle(e.target.value)}
placeholder="New book / topic name" placeholder="New topic name"
autoFocus autoFocus
required required
/> />
)} )}
{booksQuery.isError && ( {topicsQuery.isError && (
<span className="error">Couldn't load existing books/topics — you can still add a new one.</span> <span className="error">Couldn't load existing topics — you can still add a new one.</span>
)} )}
</label> </label>

View file

@ -19,5 +19,5 @@ export const config = {
collectiveName: required("COLLECTIVE_NAME"), collectiveName: required("COLLECTIVE_NAME"),
parentPageTitle: process.env.PARENT_PAGE_TITLE ?? "Reading Notes", parentPageTitle: process.env.PARENT_PAGE_TITLE ?? "Reading Notes",
bookCacheTtlMs: Number(process.env.BOOK_CACHE_TTL_MS ?? 60_000) topicCacheTtlMs: Number(process.env.TOPIC_CACHE_TTL_MS ?? 60_000)
}; };

View file

@ -6,7 +6,7 @@ import { config } from "./config.js";
import { sessionMiddleware } from "./auth/session.js"; import { sessionMiddleware } from "./auth/session.js";
import { requireAuth } from "./auth/requireAuth.js"; import { requireAuth } from "./auth/requireAuth.js";
import { authRoutes } from "./auth/authRoutes.js"; import { authRoutes } from "./auth/authRoutes.js";
import { booksRoutes } from "./routes/books.js"; import { topicsRoutes } from "./routes/topics.js";
import { notesRoutes } from "./routes/notes.js"; import { notesRoutes } from "./routes/notes.js";
const app = express(); const app = express();
@ -15,7 +15,7 @@ app.use(express.json());
app.use(sessionMiddleware); app.use(sessionMiddleware);
app.use("/api", authRoutes); app.use("/api", authRoutes);
app.use("/api/books", requireAuth, booksRoutes); app.use("/api/topics", requireAuth, topicsRoutes);
app.use("/api/notes", requireAuth, notesRoutes); app.use("/api/notes", requireAuth, notesRoutes);
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));

View file

@ -10,7 +10,7 @@ import {
let collectiveIdPromise: Promise<number> | null = null; let collectiveIdPromise: Promise<number> | null = null;
let parentPagePromise: Promise<PageInfoDto> | null = null; let parentPagePromise: Promise<PageInfoDto> | null = null;
let cachedBooks: Map<string, PageInfoDto> | null = null; let cachedTopics: Map<string, PageInfoDto> | null = null;
let cachedAt = 0; let cachedAt = 0;
function getCollectiveId(): Promise<number> { function getCollectiveId(): Promise<number> {
@ -50,9 +50,9 @@ export function getParentPage(): Promise<PageInfoDto> {
return parentPagePromise; return parentPagePromise;
} }
async function refreshBooks(force = false): Promise<Map<string, PageInfoDto>> { async function refreshTopics(force = false): Promise<Map<string, PageInfoDto>> {
if (!force && cachedBooks && Date.now() - cachedAt < config.bookCacheTtlMs) { if (!force && cachedTopics && Date.now() - cachedAt < config.topicCacheTtlMs) {
return cachedBooks; return cachedTopics;
} }
const collectiveId = await getCollectiveId(); const collectiveId = await getCollectiveId();
@ -66,24 +66,24 @@ async function refreshBooks(force = false): Promise<Map<string, PageInfoDto>> {
} }
} }
cachedBooks = map; cachedTopics = map;
cachedAt = Date.now(); cachedAt = Date.now();
return map; return map;
} }
export async function listBooks(): Promise<{ title: string }[]> { export async function listTopics(): Promise<{ title: string }[]> {
const map = await refreshBooks(); const map = await refreshTopics();
return [...map.keys()].sort((a, b) => a.localeCompare(b)).map((title) => ({ title })); return [...map.keys()].sort((a, b) => a.localeCompare(b)).map((title) => ({ title }));
} }
export async function getOrCreateBookPage(title: string): Promise<PageInfoDto> { export async function getOrCreateTopicPage(title: string): Promise<PageInfoDto> {
let map = await refreshBooks(); let map = await refreshTopics();
let page = map.get(title); let page = map.get(title);
if (page) { if (page) {
return page; return page;
} }
map = await refreshBooks(true); map = await refreshTopics(true);
page = map.get(title); page = map.get(title);
if (page) { if (page) {
return page; return page;
@ -94,6 +94,6 @@ export async function getOrCreateBookPage(title: string): Promise<PageInfoDto> {
const created = await createPage(collectiveId, parent.id, title); const created = await createPage(collectiveId, parent.id, title);
map.set(created.title, created); map.set(created.title, created);
cachedBooks = map; cachedTopics = map;
return created; return created;
} }

View file

@ -1,14 +0,0 @@
import { Router } from "express";
import { listBooks } from "../nextcloud/bookCache.js";
export const booksRoutes = Router();
booksRoutes.get("/", async (_req, res) => {
try {
const books = await listBooks();
res.json(books);
} catch (err) {
console.error("Failed to list books", err);
res.status(502).json({ error: "Failed to load books from Nextcloud" });
}
});

View file

@ -1,6 +1,6 @@
import { Router } from "express"; import { Router } from "express";
import { noteInputSchema } from "@notes/shared"; import { noteInputSchema } from "@notes/shared";
import { getOrCreateBookPage } from "../nextcloud/bookCache.js"; import { getOrCreateTopicPage } from "../nextcloud/topicCache.js";
import { appendEntry } from "../nextcloud/webdavClient.js"; import { appendEntry } from "../nextcloud/webdavClient.js";
import { formatEntry } from "../nextcloud/noteFormatter.js"; import { formatEntry } from "../nextcloud/noteFormatter.js";
@ -13,10 +13,10 @@ notesRoutes.post("/", async (req, res) => {
return; return;
} }
const { book, page, text } = parsed.data; const { topic, page, text } = parsed.data;
try { try {
const targetPage = await getOrCreateBookPage(book); const targetPage = await getOrCreateTopicPage(topic);
const entry = formatEntry(text, page); const entry = formatEntry(text, page);
await appendEntry(targetPage, entry); await appendEntry(targetPage, entry);
res.status(201).json({ ok: true }); res.status(201).json({ ok: true });

View file

@ -0,0 +1,14 @@
import { Router } from "express";
import { listTopics } from "../nextcloud/topicCache.js";
export const topicsRoutes = Router();
topicsRoutes.get("/", async (_req, res) => {
try {
const topics = await listTopics();
res.json(topics);
} catch (err) {
console.error("Failed to list topics", err);
res.status(502).json({ error: "Failed to load topics from Nextcloud" });
}
});

View file

@ -7,13 +7,13 @@ export const loginInputSchema = z.object({
export type LoginInput = z.infer<typeof loginInputSchema>; export type LoginInput = z.infer<typeof loginInputSchema>;
export const noteInputSchema = z.object({ export const noteInputSchema = z.object({
book: z.string().trim().min(1).max(200), topic: z.string().trim().min(1).max(200),
page: z.number().int().positive().max(1_000_000).optional(), page: z.number().int().positive().max(1_000_000).optional(),
text: z.string().trim().min(1).max(5000) text: z.string().trim().min(1).max(5000)
}); });
export type NoteInput = z.infer<typeof noteInputSchema>; export type NoteInput = z.infer<typeof noteInputSchema>;
export const bookSchema = z.object({ export const topicSchema = z.object({
title: z.string() title: z.string()
}); });
export type Book = z.infer<typeof bookSchema>; export type Topic = z.infer<typeof topicSchema>;