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:
parent
3da4dec452
commit
be421d83cb
12 changed files with 74 additions and 74 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
6
client/src/api/topics.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { Topic } from "@notes/shared";
|
||||||
|
import { apiFetch } from "./client";
|
||||||
|
|
||||||
|
export function fetchTopics() {
|
||||||
|
return apiFetch<Topic[]>("/api/topics");
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
@ -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" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
14
server/src/routes/topics.ts
Normal file
14
server/src/routes/topics.ts
Normal 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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue