Scaffold Reading Notes app: React/Vite PWA client, Express server with Nextcloud Collectives integration

Notes are saved as markdown pages in a Nextcloud collective via the Collectives OCS API (page metadata/creation) and WebDAV (content read/append), following the same npm-workspaces + session-auth architecture as CityTracker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvmYFQSMqs2N3q2zUDNTVn
This commit is contained in:
Claudio Schaad 2026-08-26 20:07:40 +02:00
parent 61901071f7
commit 73ed2e2cf1
44 changed files with 10416 additions and 1 deletions

20
.env.example Normal file
View file

@ -0,0 +1,20 @@
# App login
SESSION_SECRET=change-me-to-a-long-random-string
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
SECURE_COOKIES=false
# Nextcloud connection
# Generate an app password in Nextcloud: Settings -> Security -> App passwords
NEXTCLOUD_URL=https://cloud.example.com
NEXTCLOUD_USERNAME=your-nextcloud-username
NEXTCLOUD_APP_PASSWORD=your-app-password
# Target collective (Nextcloud Collectives app)
COLLECTIVE_NAME=Your Collective Name
PARENT_PAGE_TITLE=Reading Notes
# Optional
PORT=3000
HOST_PORT=3000
BOOK_CACHE_TTL_MS=60000

22
.eslintrc.json Normal file
View file

@ -0,0 +1,22 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"env": {
"es2022": true,
"node": true,
"browser": true
},
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"ignorePatterns": ["dist", "node_modules"],
"rules": {
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }]
}
}

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
dist/
.env
*.local
.DS_Store
npm-debug.log*

27
Dockerfile Normal file
View file

@ -0,0 +1,27 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json shared/package.json
COPY server/package.json server/package.json
COPY client/package.json client/package.json
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
COPY shared/package.json shared/package.json
COPY server/package.json server/package.json
RUN npm ci --omit=dev --workspace=@notes/shared --workspace=@notes/server
COPY --from=build /app/shared/dist shared/dist
COPY --from=build /app/server/dist server/dist
COPY --from=build /app/client/dist client/dist
EXPOSE 3000
CMD ["node", "server/dist/index.js"]

View file

@ -1,3 +1,35 @@
# Notes
Note taking app with sync to a wiki / md
A small PWA for jotting down short notes while reading — e.g. an interesting sentence from a book. Each note can optionally be tagged with a book/topic (existing or new) and 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"). Untagged notes are appended to that parent page itself.
## Stack
npm workspaces monorepo:
- `shared/` — TypeScript types & Zod schemas shared by client and server
- `server/` — Express API: session auth, and a Nextcloud integration that talks to both the Collectives OCS API (page metadata/creation) and WebDAV (page content read/append)
- `client/` — React + Vite PWA
## Setup
1. Copy `.env.example` to `.env` and fill in:
- `SESSION_SECRET`, `ADMIN_USERNAME`, `ADMIN_PASSWORD` — the app's own login
- `NEXTCLOUD_URL`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_APP_PASSWORD` — generate an app password under Nextcloud Settings → Security → App passwords
- `COLLECTIVE_NAME` — the exact name of an existing collective the user has access to
2. Install dependencies: `npm install`
3. Run in dev mode: `npm run dev` (client on `http://localhost:5173`, proxies `/api` to the server on port 3000)
## Build & run
```
npm run build
npm run start
```
## Docker
```
docker compose up --build
```
Reads secrets from `.env` in the project root (same variables as above).

13
client/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#1f6feb" />
<title>Reading Notes</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

26
client/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "@notes/client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json && vite build",
"clean": "rimraf dist"
},
"dependencies": {
"@notes/shared": "*",
"@tanstack/react-query": "^5.51.23",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"rimraf": "^5.0.9",
"typescript": "^5.5.4",
"vite": "^6.0.0",
"vite-plugin-pwa": "^1.3.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,32 @@
import struct
import zlib
import os
def make_png(path, size, rgb):
width = height = size
r, g, b = rgb
raw = bytearray()
for _ in range(height):
raw.append(0) # filter type 0 for each scanline
for _ in range(width):
raw += bytes([r, g, b])
compressed = zlib.compress(bytes(raw), 9)
def chunk(tag, data):
return struct.pack(">I", len(data)) + tag + data + struct.pack(
">I", zlib.crc32(tag + data) & 0xFFFFFFFF
)
sig = b"\x89PNG\r\n\x1a\n"
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
png = sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", compressed) + chunk(b"IEND", b"")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(png)
if __name__ == "__main__":
color = (31, 111, 235) # brand blue
make_png("public/icons/icon-192.png", 192, color)
make_png("public/icons/icon-512.png", 512, color)
print("Icons generated")

29
client/src/App.tsx Normal file
View file

@ -0,0 +1,29 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getSession } from "./api/auth";
import { LoginPage } from "./pages/LoginPage";
import { NoteFormPage } from "./pages/NoteFormPage";
export function App() {
const queryClient = useQueryClient();
const sessionQuery = useQuery({ queryKey: ["session"], queryFn: getSession });
if (sessionQuery.isLoading) {
return (
<main className="centered">
<p>Loading…</p>
</main>
);
}
if (!sessionQuery.data?.authenticated) {
return (
<LoginPage onLoggedIn={() => queryClient.invalidateQueries({ queryKey: ["session"] })} />
);
}
return (
<NoteFormPage
onLoggedOut={() => queryClient.invalidateQueries({ queryKey: ["session"] })}
/>
);
}

17
client/src/api/auth.ts Normal file
View file

@ -0,0 +1,17 @@
import type { LoginInput } from "@notes/shared";
import { apiFetch } from "./client";
export function login(input: LoginInput) {
return apiFetch<{ ok: boolean }>("/api/login", {
method: "POST",
body: JSON.stringify(input)
});
}
export function logout() {
return apiFetch<{ ok: boolean }>("/api/logout", { method: "POST" });
}
export function getSession() {
return apiFetch<{ authenticated: boolean }>("/api/session");
}

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

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

20
client/src/api/client.ts Normal file
View file

@ -0,0 +1,20 @@
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
credentials: "include",
headers: {
"Content-Type": "application/json",
...init?.headers
}
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Request failed (${res.status})`);
}
if (res.status === 204) {
return undefined as T;
}
return (await res.json()) as T;
}

9
client/src/api/notes.ts Normal file
View file

@ -0,0 +1,9 @@
import type { NoteInput } from "@notes/shared";
import { apiFetch } from "./client";
export function saveNote(input: NoteInput) {
return apiFetch<{ ok: boolean }>("/api/notes", {
method: "POST",
body: JSON.stringify(input)
});
}

22
client/src/main.tsx Normal file
View file

@ -0,0 +1,22 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "./App";
import "./styles.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false
}
}
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

View file

@ -0,0 +1,51 @@
import { useState, type FormEvent } from "react";
import { useMutation } from "@tanstack/react-query";
import { login } from "../api/auth";
export function LoginPage({ onLoggedIn }: { onLoggedIn: () => void }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const loginMutation = useMutation({
mutationFn: login,
onSuccess: onLoggedIn
});
function handleSubmit(e: FormEvent) {
e.preventDefault();
loginMutation.mutate({ username, password });
}
return (
<main className="centered">
<form className="card" onSubmit={handleSubmit}>
<h1>Reading Notes</h1>
<label>
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
{loginMutation.isError && (
<p className="error">{(loginMutation.error as Error).message}</p>
)}
<button type="submit" disabled={loginMutation.isPending}>
{loginMutation.isPending ? "Signing in…" : "Sign in"}
</button>
</form>
</main>
);
}

View file

@ -0,0 +1,97 @@
import { useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchBooks } from "../api/books";
import { saveNote } from "../api/notes";
import { logout } from "../api/auth";
export function NoteFormPage({ onLoggedOut }: { onLoggedOut: () => void }) {
const queryClient = useQueryClient();
const booksQuery = useQuery({ queryKey: ["books"], queryFn: fetchBooks });
const [book, setBook] = useState("");
const [page, setPage] = useState("");
const [text, setText] = useState("");
const saveNoteMutation = useMutation({
mutationFn: saveNote,
onSuccess: () => {
setText("");
setPage("");
queryClient.invalidateQueries({ queryKey: ["books"] });
}
});
const logoutMutation = useMutation({
mutationFn: logout,
onSuccess: onLoggedOut
});
function handleSubmit(e: FormEvent) {
e.preventDefault();
const trimmedBook = book.trim();
const trimmedText = text.trim();
if (!trimmedText) return;
saveNoteMutation.mutate({
book: trimmedBook || undefined,
page: page ? Number(page) : undefined,
text: trimmedText
});
}
return (
<main className="centered">
<form className="card" onSubmit={handleSubmit}>
<div className="header-row">
<h1>New note</h1>
<button type="button" className="link" onClick={() => logoutMutation.mutate()}>
Sign out
</button>
</div>
<label>
Note
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={5}
placeholder="An interesting sentence…"
required
/>
</label>
<label>
Book / topic (optional)
<input
list="book-options"
value={book}
onChange={(e) => setBook(e.target.value)}
placeholder="Select or type a new one"
/>
<datalist id="book-options">
{booksQuery.data?.map((b) => <option key={b.title} value={b.title} />)}
</datalist>
</label>
<label>
Page (optional)
<input
type="number"
min={1}
value={page}
onChange={(e) => setPage(e.target.value)}
/>
</label>
{saveNoteMutation.isError && (
<p className="error">{(saveNoteMutation.error as Error).message}</p>
)}
{saveNoteMutation.isSuccess && <p className="success">Saved.</p>}
<button type="submit" disabled={saveNoteMutation.isPending}>
{saveNoteMutation.isPending ? "Saving…" : "Save note"}
</button>
</form>
</main>
);
}

121
client/src/styles.css Normal file
View file

@ -0,0 +1,121 @@
:root {
color-scheme: light dark;
--bg: #f5f5f7;
--card-bg: #ffffff;
--text: #1a1a1a;
--muted: #6b6b6b;
--accent: #1f6feb;
--border: #d9d9df;
--error: #d1242f;
--success: #1a7f37;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--card-bg: #161b22;
--text: #e6edf3;
--muted: #9198a1;
--border: #30363d;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.centered {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.card {
width: 100%;
max-width: 420px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.75rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
}
h1 {
margin: 0;
font-size: 1.4rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.9rem;
color: var(--muted);
}
input,
textarea {
font: inherit;
color: var(--text);
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.55rem 0.65rem;
}
textarea {
resize: vertical;
}
button {
font: inherit;
font-weight: 600;
padding: 0.65rem 1rem;
border: none;
border-radius: 8px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
button.link {
background: none;
color: var(--muted);
font-weight: 400;
padding: 0;
text-decoration: underline;
}
.error {
color: var(--error);
font-size: 0.9rem;
margin: 0;
}
.success {
color: var(--success);
font-size: 0.9rem;
margin: 0;
}

1
client/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

16
client/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"resolveJsonModule": true
},
"include": ["src"]
}

42
client/vite.config.ts Normal file
View file

@ -0,0 +1,42 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["icons/icon-192.png", "icons/icon-512.png"],
manifest: {
name: "Reading Notes",
short_name: "Notes",
description: "Jot down short notes while reading, saved straight to Nextcloud.",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#1f6feb",
icons: [
{
src: "icons/icon-192.png",
sizes: "192x192",
type: "image/png"
},
{
src: "icons/icon-512.png",
sizes: "512x512",
type: "image/png"
}
]
}
})
],
server: {
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true
}
}
}
});

17
docker-compose.yml Normal file
View file

@ -0,0 +1,17 @@
services:
notes:
build: .
image: notes:latest
restart: unless-stopped
ports:
- "${HOST_PORT:-3000}:3000"
environment:
SESSION_SECRET: ${SESSION_SECRET}
ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
SECURE_COOKIES: ${SECURE_COOKIES:-false}
NEXTCLOUD_URL: ${NEXTCLOUD_URL}
NEXTCLOUD_USERNAME: ${NEXTCLOUD_USERNAME}
NEXTCLOUD_APP_PASSWORD: ${NEXTCLOUD_APP_PASSWORD}
COLLECTIVE_NAME: ${COLLECTIVE_NAME}
PARENT_PAGE_TITLE: ${PARENT_PAGE_TITLE:-Reading Notes}

9264
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "notes",
"private": true,
"version": "1.0.0",
"workspaces": [
"shared",
"server",
"client"
],
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev -w server\" \"npm run dev -w client\"",
"build": "npm run build -w shared && npm run build -w client && npm run build -w server",
"start": "npm run start -w server",
"lint": "eslint . --ext .ts,.tsx",
"clean": "npm run clean -w shared && npm run clean -w server && npm run clean -w client"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"concurrently": "^8.2.2",
"eslint": "^8.57.0",
"prettier": "^3.3.3",
"rimraf": "^5.0.9",
"typescript": "^5.5.4"
}
}

28
server/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "@notes/server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"clean": "rimraf dist"
},
"dependencies": {
"@notes/shared": "*",
"argon2": "^0.40.3",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"express-session": "^1.18.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.0",
"@types/node": "^20.14.15",
"rimraf": "^5.0.9",
"tsx": "^4.19.0",
"typescript": "^5.5.4"
}
}

View file

@ -0,0 +1,39 @@
import argon2 from "argon2";
import { Router } from "express";
import { loginInputSchema } from "@notes/shared";
import { config } from "../config.js";
const adminPasswordHashPromise = argon2.hash(config.adminPassword);
export const authRoutes = Router();
authRoutes.post("/login", async (req, res) => {
const parsed = loginInputSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid credentials payload" });
return;
}
const { username, password } = parsed.data;
const hash = await adminPasswordHashPromise;
const usernameMatches = username === config.adminUsername;
const passwordMatches = await argon2.verify(hash, password);
if (!usernameMatches || !passwordMatches) {
res.status(401).json({ error: "Invalid username or password" });
return;
}
req.session.authenticated = true;
res.json({ ok: true });
});
authRoutes.post("/logout", (req, res) => {
req.session.destroy(() => {
res.json({ ok: true });
});
});
authRoutes.get("/session", (req, res) => {
res.json({ authenticated: Boolean(req.session.authenticated) });
});

View file

@ -0,0 +1,9 @@
import type { NextFunction, Request, Response } from "express";
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
if (req.session.authenticated) {
next();
return;
}
res.status(401).json({ error: "Not authenticated" });
}

View file

@ -0,0 +1,21 @@
import session from "express-session";
import { config } from "../config.js";
declare module "express-session" {
interface SessionData {
authenticated?: boolean;
}
}
export const sessionMiddleware = session({
name: "notes.sid",
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: "lax",
secure: config.secureCookies,
maxAge: 30 * 24 * 60 * 60 * 1000
}
});

23
server/src/config.ts Normal file
View file

@ -0,0 +1,23 @@
function required(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export const config = {
port: Number(process.env.PORT ?? 3000),
sessionSecret: required("SESSION_SECRET"),
adminUsername: required("ADMIN_USERNAME"),
adminPassword: required("ADMIN_PASSWORD"),
secureCookies: process.env.SECURE_COOKIES === "true",
nextcloudUrl: required("NEXTCLOUD_URL").replace(/\/+$/, ""),
nextcloudUsername: required("NEXTCLOUD_USERNAME"),
nextcloudAppPassword: required("NEXTCLOUD_APP_PASSWORD"),
collectiveName: required("COLLECTIVE_NAME"),
parentPageTitle: process.env.PARENT_PAGE_TITLE ?? "Reading Notes",
bookCacheTtlMs: Number(process.env.BOOK_CACHE_TTL_MS ?? 60_000)
};

7
server/src/env.ts Normal file
View file

@ -0,0 +1,7 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.resolve(__dirname, "../../.env") });

31
server/src/index.ts Normal file
View file

@ -0,0 +1,31 @@
import "./env.js";
import path from "node:path";
import { fileURLToPath } from "node:url";
import express from "express";
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 { notesRoutes } from "./routes/notes.js";
const app = express();
app.use(express.json());
app.use(sessionMiddleware);
app.use("/api", authRoutes);
app.use("/api/books", requireAuth, booksRoutes);
app.use("/api/notes", requireAuth, notesRoutes);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const defaultClientDist = path.resolve(__dirname, "../../client/dist");
const clientDist = path.resolve(process.env.CLIENT_DIST_PATH ?? defaultClientDist);
app.use(express.static(clientDist));
app.get("*", (_req, res) => {
res.sendFile(path.join(clientDist, "index.html"));
});
app.listen(config.port, () => {
console.log(`Notes server listening on port ${config.port}`);
});

View file

@ -0,0 +1,8 @@
import { config } from "../config.js";
export function basicAuthHeader(): string {
const credentials = Buffer.from(
`${config.nextcloudUsername}:${config.nextcloudAppPassword}`
).toString("base64");
return `Basic ${credentials}`;
}

View file

@ -0,0 +1,99 @@
import { config } from "../config.js";
import {
createPage,
findRootPage,
listCollectives,
listPages,
type PageInfoDto
} from "./collectivesClient.js";
let collectiveIdPromise: Promise<number> | null = null;
let parentPagePromise: Promise<PageInfoDto> | null = null;
let cachedBooks: Map<string, PageInfoDto> | null = null;
let cachedAt = 0;
function getCollectiveId(): Promise<number> {
if (!collectiveIdPromise) {
collectiveIdPromise = listCollectives().then((collectives) => {
const match = collectives.find((c) => c.name === config.collectiveName);
if (!match) {
const available = collectives.map((c) => c.name).join(", ");
throw new Error(
`Collective "${config.collectiveName}" not found. Available collectives: ${available}`
);
}
return match.id;
});
}
return collectiveIdPromise;
}
export function getParentPage(): Promise<PageInfoDto> {
if (!parentPagePromise) {
parentPagePromise = (async () => {
const collectiveId = await getCollectiveId();
const pages = await listPages(collectiveId);
const root = findRootPage(pages);
if (!root) {
throw new Error(`Could not find root page for collective "${config.collectiveName}"`);
}
const existing = pages.find(
(p) => p.parentId === root.id && p.title === config.parentPageTitle
);
if (existing) {
return existing;
}
return createPage(collectiveId, root.id, config.parentPageTitle);
})();
}
return parentPagePromise;
}
async function refreshBooks(force = false): Promise<Map<string, PageInfoDto>> {
if (!force && cachedBooks && Date.now() - cachedAt < config.bookCacheTtlMs) {
return cachedBooks;
}
const collectiveId = await getCollectiveId();
const parent = await getParentPage();
const pages = await listPages(collectiveId);
const map = new Map<string, PageInfoDto>();
for (const page of pages) {
if (page.parentId === parent.id) {
map.set(page.title, page);
}
}
cachedBooks = map;
cachedAt = Date.now();
return map;
}
export async function listBooks(): Promise<{ title: string }[]> {
const map = await refreshBooks();
return [...map.keys()].sort((a, b) => a.localeCompare(b)).map((title) => ({ title }));
}
export async function getOrCreateBookPage(title: string): Promise<PageInfoDto> {
let map = await refreshBooks();
let page = map.get(title);
if (page) {
return page;
}
map = await refreshBooks(true);
page = map.get(title);
if (page) {
return page;
}
const collectiveId = await getCollectiveId();
const parent = await getParentPage();
const created = await createPage(collectiveId, parent.id, title);
map.set(created.title, created);
cachedBooks = map;
return created;
}

View file

@ -0,0 +1,63 @@
import { config } from "../config.js";
import { basicAuthHeader } from "./auth.js";
export interface CollectiveDto {
id: number;
name: string;
}
export interface PageInfoDto {
id: number;
parentId: number;
title: string;
fileName: string;
filePath: string;
collectivePath: string | null;
}
const API_BASE = `${config.nextcloudUrl}/ocs/v2.php/apps/collectives/api/v1.0`;
async function ocsFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
...init?.headers,
"OCS-APIRequest": "true",
Accept: "application/json",
Authorization: basicAuthHeader()
}
});
if (!res.ok) {
throw new Error(`Collectives API request failed (${res.status} ${res.statusText}): ${path}`);
}
const body = (await res.json()) as { ocs: { data: T } };
return body.ocs.data;
}
export async function listCollectives(): Promise<CollectiveDto[]> {
const data = await ocsFetch<{ collectives: CollectiveDto[] }>("/collectives");
return data.collectives;
}
export async function listPages(collectiveId: number): Promise<PageInfoDto[]> {
const data = await ocsFetch<{ pages: PageInfoDto[] }>(`/collectives/${collectiveId}/pages`);
return data.pages;
}
export async function createPage(
collectiveId: number,
parentId: number,
title: string
): Promise<PageInfoDto> {
const data = await ocsFetch<{ page: PageInfoDto }>(
`/collectives/${collectiveId}/pages/${parentId}?title=${encodeURIComponent(title)}`,
{ method: "POST" }
);
return data.page;
}
export function findRootPage(pages: PageInfoDto[]): PageInfoDto | undefined {
return pages.find((page) => page.parentId === 0);
}

View file

@ -0,0 +1,9 @@
export function formatEntry(text: string, page?: number): string {
const date = new Date().toISOString().slice(0, 10);
const quoted = text
.split("\n")
.map((line) => `> ${line}`)
.join("\n");
const meta = page ? `— p. ${page}, ${date}` : `— ${date}`;
return `${quoted}\n\n${meta}\n\n---`;
}

View file

@ -0,0 +1,51 @@
import { config } from "../config.js";
import { basicAuthHeader } from "./auth.js";
import type { PageInfoDto } from "./collectivesClient.js";
function buildDavUrl(page: PageInfoDto): string {
const joined = [page.collectivePath, page.filePath, page.fileName]
.filter((part): part is string => Boolean(part))
.join("/");
const encodedPath = joined
.split("/")
.filter(Boolean)
.map(encodeURIComponent)
.join("/");
return `${config.nextcloudUrl}/remote.php/dav/files/${encodeURIComponent(config.nextcloudUsername)}/${encodedPath}`;
}
async function readFile(page: PageInfoDto): Promise<string> {
const res = await fetch(buildDavUrl(page), {
headers: { Authorization: basicAuthHeader() }
});
if (res.status === 404) {
return "";
}
if (!res.ok) {
throw new Error(`WebDAV read failed (${res.status} ${res.statusText}) for page ${page.id}`);
}
return await res.text();
}
async function writeFile(page: PageInfoDto, content: string): Promise<void> {
const res = await fetch(buildDavUrl(page), {
method: "PUT",
headers: {
Authorization: basicAuthHeader(),
"Content-Type": "text/markdown"
},
body: content
});
if (!res.ok) {
throw new Error(`WebDAV write failed (${res.status} ${res.statusText}) for page ${page.id}`);
}
}
export async function appendEntry(page: PageInfoDto, entryMarkdown: string): Promise<void> {
const current = await readFile(page);
const trimmed = current.replace(/\s+$/, "");
const updated = trimmed.length > 0 ? `${trimmed}\n\n${entryMarkdown}\n` : `${entryMarkdown}\n`;
await writeFile(page, updated);
}

View file

@ -0,0 +1,14 @@
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

@ -0,0 +1,27 @@
import { Router } from "express";
import { noteInputSchema } from "@notes/shared";
import { getOrCreateBookPage, getParentPage } from "../nextcloud/bookCache.js";
import { appendEntry } from "../nextcloud/webdavClient.js";
import { formatEntry } from "../nextcloud/noteFormatter.js";
export const notesRoutes = Router();
notesRoutes.post("/", async (req, res) => {
const parsed = noteInputSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid note payload", details: parsed.error.flatten() });
return;
}
const { book, page, text } = parsed.data;
try {
const targetPage = book ? await getOrCreateBookPage(book) : await getParentPage();
const entry = formatEntry(text, page);
await appendEntry(targetPage, entry);
res.status(201).json({ ok: true });
} catch (err) {
console.error("Failed to save note", err);
res.status(502).json({ error: "Failed to save note to Nextcloud" });
}
});

16
server/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src"]
}

18
shared/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "@notes/shared",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"clean": "rimraf dist"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5.5.4"
}
}

1
shared/src/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./schemas.js";

19
shared/src/schemas.ts Normal file
View file

@ -0,0 +1,19 @@
import { z } from "zod";
export const loginInputSchema = z.object({
username: z.string().trim().min(1),
password: z.string().min(1)
});
export type LoginInput = z.infer<typeof loginInputSchema>;
export const noteInputSchema = z.object({
book: z.string().trim().min(1).max(200).optional(),
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({
title: z.string()
});
export type Book = z.infer<typeof bookSchema>;

16
shared/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}