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
PORT=3000
HOST_PORT=3000
BOOK_CACHE_TTL_MS=60000
TOPIC_CACHE_TTL_MS=60000

View file

@ -1,6 +1,6 @@
# 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

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

View file

@ -19,5 +19,5 @@ export const config = {
collectiveName: required("COLLECTIVE_NAME"),
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 { requireAuth } from "./auth/requireAuth.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";
const app = express();
@ -15,7 +15,7 @@ app.use(express.json());
app.use(sessionMiddleware);
app.use("/api", authRoutes);
app.use("/api/books", requireAuth, booksRoutes);
app.use("/api/topics", requireAuth, topicsRoutes);
app.use("/api/notes", requireAuth, notesRoutes);
const __dirname = path.dirname(fileURLToPath(import.meta.url));

View file

@ -10,7 +10,7 @@ import {
let collectiveIdPromise: Promise<number> | 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;
function getCollectiveId(): Promise<number> {
@ -50,9 +50,9 @@ export function getParentPage(): Promise<PageInfoDto> {
return parentPagePromise;
}
async function refreshBooks(force = false): Promise<Map<string, PageInfoDto>> {
if (!force && cachedBooks && Date.now() - cachedAt < config.bookCacheTtlMs) {
return cachedBooks;
async function refreshTopics(force = false): Promise<Map<string, PageInfoDto>> {
if (!force && cachedTopics && Date.now() - cachedAt < config.topicCacheTtlMs) {
return cachedTopics;
}
const collectiveId = await getCollectiveId();
@ -66,24 +66,24 @@ async function refreshBooks(force = false): Promise<Map<string, PageInfoDto>> {
}
}
cachedBooks = map;
cachedTopics = map;
cachedAt = Date.now();
return map;
}
export async function listBooks(): Promise<{ title: string }[]> {
const map = await refreshBooks();
export async function listTopics(): Promise<{ title: string }[]> {
const map = await refreshTopics();
return [...map.keys()].sort((a, b) => a.localeCompare(b)).map((title) => ({ title }));
}
export async function getOrCreateBookPage(title: string): Promise<PageInfoDto> {
let map = await refreshBooks();
export async function getOrCreateTopicPage(title: string): Promise<PageInfoDto> {
let map = await refreshTopics();
let page = map.get(title);
if (page) {
return page;
}
map = await refreshBooks(true);
map = await refreshTopics(true);
page = map.get(title);
if (page) {
return page;
@ -94,6 +94,6 @@ export async function getOrCreateBookPage(title: string): Promise<PageInfoDto> {
const created = await createPage(collectiveId, parent.id, title);
map.set(created.title, created);
cachedBooks = map;
cachedTopics = map;
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 { noteInputSchema } from "@notes/shared";
import { getOrCreateBookPage } from "../nextcloud/bookCache.js";
import { getOrCreateTopicPage } from "../nextcloud/topicCache.js";
import { appendEntry } from "../nextcloud/webdavClient.js";
import { formatEntry } from "../nextcloud/noteFormatter.js";
@ -13,10 +13,10 @@ notesRoutes.post("/", async (req, res) => {
return;
}
const { book, page, text } = parsed.data;
const { topic, page, text } = parsed.data;
try {
const targetPage = await getOrCreateBookPage(book);
const targetPage = await getOrCreateTopicPage(topic);
const entry = formatEntry(text, page);
await appendEntry(targetPage, entry);
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 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(),
text: z.string().trim().min(1).max(5000)
});
export type NoteInput = z.infer<typeof noteInputSchema>;
export const bookSchema = z.object({
export const topicSchema = z.object({
title: z.string()
});
export type Book = z.infer<typeof bookSchema>;
export type Topic = z.infer<typeof topicSchema>;