From 13b659662e729e2185305a86805e64c9474d8d5c Mon Sep 17 00:00:00 2001 From: Aran Roig Date: Thu, 1 Oct 2026 02:29:21 +0200 Subject: [PATCH] More things --- AGENTS.md | 188 ++- backend/package.json | 3 +- backend/src/index.js | 5 + backend/src/models/Article.js | 33 +- backend/src/models/EditProposal.js | 87 ++ backend/src/routes/admin.js | 174 ++- backend/src/routes/articles.js | 615 ++++++-- backend/src/seed-bulk.js | 1304 +++++++++++++++++ frontend/README.md | 8 +- frontend/app/assets/css/_admin.scss | 29 + frontend/app/assets/css/_articles.scss | 106 +- frontend/app/assets/css/_cards.scss | 10 + frontend/app/assets/css/_editor.scss | 43 +- frontend/app/assets/css/_home.scss | 34 +- frontend/app/assets/css/_layout.scss | 18 +- frontend/app/assets/css/_profile.scss | 6 +- frontend/app/assets/css/_proposals.scss | 214 +++ frontend/app/assets/css/_settings.scss | 5 +- frontend/app/assets/css/_states.scss | 34 + frontend/app/assets/css/_tokens.scss | 2 + frontend/app/assets/css/main.scss | 1 + frontend/app/components/AppHeader.vue | 52 +- .../app/components/ArticleActionsMenu.vue | 35 +- frontend/app/components/ArticleBookStack.vue | 96 +- frontend/app/components/ArticleCard.vue | 4 +- frontend/app/components/ArticleForm.vue | 181 ++- .../app/components/ArticleLanguageMenu.vue | 2 +- frontend/app/components/ArticlePane.vue | 37 +- frontend/app/components/ProfileModal.vue | 53 +- frontend/app/components/ProposalRow.vue | 225 +++ frontend/app/components/ProposalSentModal.vue | 62 + frontend/app/components/RecentlyViewed.vue | 2 +- frontend/app/components/SearchModal.vue | 26 +- frontend/app/components/SettingsModal.vue | 57 +- frontend/app/composables/useAppLocale.ts | 7 +- frontend/app/composables/useArticleLibrary.ts | 11 +- frontend/app/composables/useArticleTabs.ts | 118 +- .../app/composables/usePendingRequests.ts | 39 + frontend/app/composables/useRecentlyViewed.ts | 36 +- frontend/app/middleware/open-article.ts | 67 +- .../app/pages/{admin.vue => admin/index.vue} | 6 +- frontend/app/pages/admin/proposals.vue | 231 +++ frontend/app/pages/articles.vue | 428 +++--- frontend/app/pages/index.vue | 135 +- .../app/pages/wiki/[lang]/[slug]/index.vue | 15 +- frontend/app/pages/wiki/[slug]/index.vue | 47 +- frontend/app/plugins/auth.ts | 3 +- frontend/app/utils/languages.ts | 16 + frontend/app/utils/routes.ts | 25 +- frontend/i18n/locales/ca.json | 77 +- frontend/i18n/locales/de.json | 77 +- frontend/i18n/locales/en.json | 77 +- frontend/i18n/locales/es.json | 77 +- frontend/i18n/locales/fr.json | 77 +- 54 files changed, 4409 insertions(+), 911 deletions(-) create mode 100644 backend/src/models/EditProposal.js create mode 100644 backend/src/seed-bulk.js create mode 100644 frontend/app/assets/css/_proposals.scss create mode 100644 frontend/app/components/ProposalRow.vue create mode 100644 frontend/app/components/ProposalSentModal.vue create mode 100644 frontend/app/composables/usePendingRequests.ts rename frontend/app/pages/{admin.vue => admin/index.vue} (97%) create mode 100644 frontend/app/pages/admin/proposals.vue diff --git a/AGENTS.md b/AGENTS.md index 2fe029e..d6fab5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,10 +40,13 @@ Plain JavaScript, ESM (`"type": "module"`, `.js` files with relative `.js` impor - `src/index.js` — app setup: CORS, JSON body, `/health`, routers, 404 handler, central error handler (Mongoose `ValidationError`/`CastError` → 400), graceful shutdown. After - the connection it calls `User.ensureAnAdmin()` so somebody can always open the dashboard. + the connection it calls `User.ensureAnAdmin()` so somebody can always open the dashboard, + and `Article.replaceReservedLanguageIndex()` so an older database can write an article in + any language (see the language gotcha below). - `src/config/db.js` — `connectDB` / `disconnectDB`. - `src/models/` — Mongoose schemas with `timestamps: true`. `Article` (slug, title, - summary, content, tags + text index), `Item` (simple demo resource), + summary, content, tags + text index that points its reserved `language_override` + at a field no article carries — see the language gotcha below), `Item` (simple demo resource), `User` (username + `passwordHash`, `select: false`; `setPassword`/`checkPassword` via bcrypt, `toPublic()` for responses, and `credentialProblem()` as the single place account rules are checked). The profile picture lives on `User.avatar` as a @@ -52,6 +55,15 @@ Plain JavaScript, ESM (`"type": "module"`, `.js` files with relative `.js` impor `User.role` is `'admin'` or `'member'` (`ROLES`); `User.hasNone()` and `User.ensureAnAdmin()` cover who gets the former — see the roles gotcha below. + `EditProposal` — a member's request for a change. `kind` is `'edit'` — a page that + exists, named by its `article` `_id` — or `'create'`, a page the wiki does not have + yet: `article` stays null until an approval writes one, and `slug`/`baseSlug` keep + the address the request asks for and the version group a proposed translation joins. + Either way it holds the whole proposed state (title/content/tags/language), a `base` + snapshot of the article as it read when the request was sent (the diff's other half, + empty for a creation), a `status` (`pending`/`approved`/`rejected`), who sent it + (`createdBy` + the name recorded alongside, so it reads after an account closes) and + who decided it. - `src/routes/` — one Express `Router` per resource, mounted in `index.js`. - `src/routes/auth.js` — `POST /api/auth/register` (creates the account and returns its session; the wiki's **first** account is created as an admin), @@ -60,12 +72,34 @@ Plain JavaScript, ESM (`"type": "module"`, `.js` files with relative `.js` impor all behind `requireAuth`: `PUT /api/auth/me` (rename), `PUT /api/auth/password` (needs the current one), `PUT /api/auth/avatar` (data URL, `""` clears), `PUT /api/auth/locale` (interface language, one of `SUPPORTED_LOCALES`). +- `src/routes/articles.js` — reads open, writes by role. An **admin**'s save applies + straight: `POST /api/articles` creates through `createArticle()` and + `PUT /api/articles/slug/:slug` writes through `applyArticleEdit()` (which re-slugs on + a title change and refuses a language the version group already has); both answer the + wiki's rules through `resolveCreation()` and `requestError()`, so a create and an + approved request cannot drift apart. A **member's** same save answers `202` with + `{ proposal }` instead — an `EditProposal` upserted per (article, member, pending), + or, for a page that does not exist yet, per (member, pending, asked-for slug): saving + again *revises* the waiting request, and a draft that retitles itself sends the + `proposalId` the first answer carried so it stays one request. + `DELETE` by slug runs `requireAuth, requireAdmin` alone, and settles (deletes) that + article's proposals with it. - `src/routes/admin.js` — mounted at `/api/admin`, everything behind `requireAuth, requireAdmin`: `GET /api/admin/users` (every account, newest first, with `createdAt`), `PUT /api/admin/users/:id/role` (`role` is `'admin'` or `'member'`; an admin's own role is the one they cannot change), and `DELETE /api/admin/users/:id` (closes an account for good — never their own, by the same rule; the articles it wrote stay, since articles name no author). + The edit-request shelf: `GET /api/admin/proposals` (every request, decided ones + included, each with its proposer, its `base`/proposed text and the article as it + stands now), `GET /api/admin/proposals/count` (just `{ pending }` — how many + still wait, which is what the sidebar's badge on the Requests link wears), + `PUT /api/admin/proposals/:id/approve` (an edit is written through + `applyArticleEdit`, a creation through `createArticle` — so a page that appeared + meanwhile, or a version group that grew the language asked for, is refused the same + honest way and the request stays waiting; a gone article or an already-decided + request is an error) and `PUT /api/admin/proposals/:id/reject` (refuses; the page + stays put). - `src/middleware/auth.js` — `signToken(user)`, `requireAuth`, which reads `Authorization: Bearer ` and sets `req.user = { id, username }`; anything else is answered with 401 `{ message }`. `requireAdmin` runs after it and reads @@ -80,10 +114,25 @@ Conventions to follow: - Articles are addressed by **slug**, not by Mongo id (`/api/articles/slug/:slug`). `slugify()` / `uniqueSlug()` (in `routes/articles.js`) generate uniquified slugs (`-2`, `-3`, ... suffixes); POST requires `title`, PUT re-slugs when the title changes. -- The list endpoint (`GET /api/articles`) selects `-content` (no body) and supports - `?q=` (regex search on title/summary/tags) and `?tag=`. +- The list endpoint (`GET /api/articles`) selects `-content` (no body) and answers + `{ articles, total, hasMore }`. It filters by article, not by document — `?q=` + (regex on title/tags), `?tag=` a tag any version wears, `?lang=` a language any + version is written in — and `?limit=` / `?offset=` page it **by article** (the + identity, not the document), so `total` counts each article once however many + languages it exists in. `?prefer=` (a reader's interface language) picks which + version stands for an article — that language's, else English, else the canonical + one — but never decides which articles answer. `?preferFirst` additionally **ranks** + the answers, leading with the articles that speak that language themselves (the + Ctrl+K search popup asks for it; the shelf and home pages do not). Asked for no + `limit`, the whole shelf answers at once. +- `GET /api/articles/facets` answers `{ total, topics, languages }` counted over the + whole shelf (topics lowercased, both tallied by popularity). The `/articles` page + draws its topic folders and language chips from this, not from the cards it + happens to have on screen. - Reads are public; the writes (`POST /api/articles`, `PUT`/`DELETE` by slug) run - through `requireAuth`. + through `requireAuth`. Of those, a `PUT` applies straight only for an admin and a + `DELETE` is admin-only — a member's `PUT` is answered `202` with a proposal instead + (see the roles gotcha below). ## Frontend (`frontend/`) @@ -96,7 +145,7 @@ properties so themes swap at runtime via `data-theme`/`data-accent` attributes (set pre-paint by an inline script in `nuxt.config.ts`). UI wording is localized with `@nuxtjs/i18n` (`frontend/i18n/`): `i18n.config.ts` plus -one JSON catalog per language in `i18n/locales/` (all 309 keys, kept in parity with +one JSON catalog per language in `i18n/locales/` (all 350 keys, kept in parity with `en.json`). `strategy: 'no_prefix'` — the interface language never appears in a URL (`/wiki//` is the *article's* language) — with cookie detection (`mathew-locale`, `fallbackLocale: 'en'`) so the server renders in the right language @@ -105,33 +154,52 @@ carry ``/`` markup are drawn with `v-html` through `th()` (`useAppLocale`), which escapes interpolated values. Adding a language = add a catalog copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`. -- `app/pages/` — `/` (search), `/wiki/[slug]` (article + TOC), `/wiki/[slug]/edit`, `/new`, - `/admin` (the dashboard: every account and its role, plus the buttons that hand the - role on or take it back — an admin alone sees anything but a refusal here). +- `app/pages/` — `/` (search), `/wiki/[lang]/[slug]` (one language version of an + article — the address every version has, English included — drawn as a page of + the open book), `/wiki/[slug]` (an address naming no language: the middleware + turns it onto the version the slug speaks, so what is left of this route is + saying the wiki has nothing there), `/wiki/[slug]/edit`, `/new`, + `/admin` (the account dashboard: every account and its role, plus the buttons that + hand the role on or take it back) and `/admin/proposals` (the edit-request desk: + the requests members have sent — approve, reject, and a + details view of each change as two pages, sent-state beside proposed) — the two + admin surfaces, each reached by its own admin-only sidebar link; an + admin alone sees anything but a refusal on either. The two editor routes wrap their form in `AuthGate`, so it shows only with a session. - `app/components/` — PascalCase SFCs (header, editor, MarkdownView, TocNav, etc.). `AuthGate.vue` is the editor's stand-in for a signed-out visitor; `AuthModal.vue` is the popup that signs them in; `ProfileModal.vue` is the account popup the - sidebar's account row opens (picture, username, password, sign out). `ConfirmModal.vue` + sidebar's account row opens (picture, username, password, interface language, + sign out). `ConfirmModal.vue` is the yes/no popup any destructive step asks through (the editor's discard, the - dashboard's delete). All popups (teleported overlays, closed by esc/backdrop/ + dashboard's delete); `ProposalSentModal.vue` is the success popup that confirms a + member's filed request — dismissing it is what leaves the editor. All popups (teleported overlays, closed by esc/backdrop/ navigation like the other popups — opening one calls `hide()` on the others, they never stack). A row of actions folds into a ⋮ menu built on the shared - `.kebab*` styles: `ArticleActionsMenu.vue` on an article's title row, - `AccountActionsMenu.vue` on a row of the dashboard (the role and the account). -- `app/composables/` — `useTopics` (topics = distinct tags computed client-side from the - full article list, shared via `useState`), `useRecentlyViewed` (localStorage), + `.kebab*` styles: `ArticleActionsMenu.vue` on an article's title row (its Delete + item is drawn for admins alone), `AccountActionsMenu.vue` on a row of the + dashboard (the role and the account). `ProposalRow.vue` is the edit-request desk's row: + who wants what (a change to a page, or a page the wiki does not have yet — a creation + is named by its proposed title with a dashed "new article" mark, since there is no + address to link to until it is granted), the status pill, the approve/reject buttons, + and the details diff — the article as sent against the text proposed, both through + `MarkdownView`, where a creation reads its asked-for address instead of a page that + is not there. +- `app/composables/` — `useRecentlyViewed` (localStorage), `useAuth` (the session: token in the `mathew-session` cookie so the server sees it too, `signIn`/`signUp`/`signOut`, `authHeaders()` for write calls, `restoreSession()` asking `/auth/me` who a stored token belongs to, plus `updateUsername`/ `changePassword`/`setAvatar` for the profile popup, and `isAdmin` — whether the - account holds the admin role, which is what the sidebar's Admin link and the - dashboard are drawn from), `useSignInPopup` (open state of + account holds the admin role, which is what the sidebar's two admin links (Admin + and Requests) and the two admin pages are drawn from), `useSignInPopup` (open state of that popup, shaped like `useSettings`/`useSearch`), `useProfileMenu` (same, for the profile popup), `useArticleActionsMenu` (same, for an article's ⋮ menu), and `useAccountActionsMenu` (same, for a dashboard row's ⋮ — it holds the **id** of the row that is open, so one row's menu is open at a time rather than a - boolean per row), and `useAppLocale` (the interface language: `APP_LANGUAGES` — + boolean per row), `usePendingRequests` (how many edit requests wait — shared + state the sidebar draws as a badge on its Requests link and the requests desk + keeps in step: `refresh()` asks the API's count, `report(n)` lays the desk's + own loaded tally on it), and `useAppLocale` (the interface language: `APP_LANGUAGES` — the five supported UI languages with their own names —, `choose(code)` to turn the UI (`setLocale` also keeps the `mathew-locale` cookie in step), `applyAccount(code)` to let an account overrule the device, and `th()` for messages drawn as HTML). @@ -149,13 +217,46 @@ copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`. - The **interface language** is not the article language: `/wiki/fr/…` addresses the French *article*, never the UI. UI precedence: the signed-in account's `locale` (applied at startup and on signing in) > the `mathew-locale` cookie (written by `setLocale`, read - on the server pre-render) > browser > `en`. The settings popup always turns this device; - with a session it also saves to the account — an account that refuses (dead session) - doesn't undo the switch. Error messages that come back from the API stay English; only - the frontend's own fallbacks are translated. + on the server pre-render) > browser > `en`. The profile popup always turns this + device; with a session it also saves to the account — an account that refuses + (dead session) doesn't undo the switch. Error messages that come back from the API + stay English; only the frontend's own fallbacks are translated. +- **Every version has an address, English included**: `/wiki//`, where the + identity is the canonical version's slug — so `/wiki/en/bayes-theorem` and + `/wiki/fr/bayes-theorem` are one article two ways, and a version's own stored slug + (`bayes-theorem-fr`) appears in no address. `versionHref(identity, lang)` + (`app/utils/routes.ts`) is the one place that writes such an address; the + `open-article` middleware is the one place that reads them and knows where a version + lives, so the short `/wiki/` form — a wiki link, an old bookmark, a graph node — + is answered by resolving the document the slug speaks and turning the address onto its + version (302 on the server; on the client the navigation to the short address is + abandoned rather than committed, so it never reaches the history and Back still leads to + the page the link was pressed on). The short form opens an article in the reader's own + language when the article speaks it — English failing that (`preferredLanguage` in + `app/utils/languages.ts`, and the home/shelf/search lists pass the same choice to the + API as `?prefer=` so a card shows the version it will open) — while an address that + does name a language is honoured as asked, which is how the version switcher keeps its + promise. A slug the wiki has nothing at keeps its address and says so. +- **Changing an article's language turns its page, it does not open another.** The book's + stack (`useArticleTabs`) is keyed by article *identity*, not by document slug: + `openVersion(article)` finds that article's page and moves its `lang`/`slug`/`title` + over, so the row keeps its shape and the same page shows the other language (the pane + starts at the top of the new text). The tab also carries the document's `_id`, so a + canonical retitle — same document, new identity — follows the page to its new address + instead of opening a second one beside it. `tabHref`/`tabAt`/`closeTab` all speak + identity (+ language); `useArticleLibrary.put` files a document both under its stored + slug and under its `lang:identity` version key, so the short and versioned addresses are + the same page of the shelf; and `useRecentlyViewed` remembers identity + language, so an + article read in two languages is still one article remembered once, at the version it + was last opened at. - Never call `t()` in `withDefaults` static prop defaults — they don't react to locale changes. Use `props.x ?? t(...)` in the template/computed instead (see `AuthGate`, `ConfirmModal`). +- `useAppLocale` takes the composer from `useNuxtApp().$i18n`, **not** `useI18n()`: a Nuxt + plugin has no component instance, and the auth plugin applies the account's language from + one. `useI18n()` there throws *"Must be called at the top of a `setup` function"* — a 500 on + every request from an account with a saved language. Composables a plugin needs are taken + before its first `await`. - Message catalogs are compiled: literal `{`/`@` break the compiler, so the editor's LaTeX seed lives as a JS constant in `ArticleForm.vue` and only its words are catalog keys. - Reading is open; **writing takes an account**. `POST /api/articles` and `PUT`/`DELETE` @@ -173,10 +274,53 @@ copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`. own role, so there is always at least one admin left to open the page. The same menu deletes an account — through `ConfirmModal`, and never the admin's own, for the same reason. Articles outlive the account that wrote them. +- **A member's save is not a write — neither a page nor a new one.** `POST /api/articles` + and `PUT /api/articles/slug/:slug` check the account's role: an admin's save applies at + once; a member's is filed as an `EditProposal` and answered `202 { proposal }` — the live + article does not move, and a requested page does not appear. Only one pending request per + (article, member): saving again while one waits *revises* it, and the request records a + `base` snapshot of the article as it read when sent, so the dashboard can diff it and warn + when the page has since moved. A *creation* is held the same way with no article to hang it + on: it keeps the address it asks for (`slug`, plus `baseSlug` when it translates an existing + article), is found by that address while it waits, and takes the `proposalId` its first answer + carried so a retitled draft revises the same request instead of filing a second one for a + page the wiki has twice over. Approving runs the very code a direct admin write runs by — + `applyArticleEdit()` for an edit (so a retitle re-slugs, and a taken language is refused), + `createArticle()` for a creation, which records the article it brought about so the decided + row links to it; the edit-request desk's diff lays a creation against the empty address it asks for. + Rejecting leaves everything alone, and both decided and waiting requests stay on the + edit-request desk's list as the record. **Delete is the one thing no member does at all** — `DELETE` + runs `requireAdmin` and, when it succeeds, deletes that article's proposals with it. + `ArticleForm.vue` reads the `202`: instead of navigating to the (unchanged) article, or one + that does not exist yet, it marks the draft sent and opens `ProposalSentModal` over the + form — so the button cannot be pressed twice on an already-sent draft — and dismissing + that popup (button, esc, backdrop) leaves the editor: to the version that was sent, or + for a creation to the article it translates, else the search page. - Profile pictures are stored as data URLs on the account (the browser shrinks them to a 256px square before upload); `express.json` allows 1.5 MB bodies so one fits a request. A 401 from a profile update means the session died — the popup signs out and opens the sign-in popup over it. - Changing an article's title can change its slug; old URLs 404 (no redirects). +- **Any language writes, but MongoDB reserves the name.** A text index reads a document's + `language` field as *which language to analyse it in*, and refuses a value it has no + analyzer for — `ca`, `ja`, `ko`, `ar`, `zh`, `pl`, `uk`, `fa`, `hi` among them, which is + why creating an article in one of those used to answer `500 language override unsupported`. + The article schema's text index points that reserved `language_override` at + `articleLanguage`, a field no article carries, so the wiki's own `language` stays the + wiki's business. Index options cannot change in place (MongoDB answers the second request + with "an equivalent index already exists"), so `index.js` drops a stale text index at + startup through `Article.replaceReservedLanguageIndex()` — one boot repairs an older + database, and any text index added later has to set `language_override` the same way. +- **`/articles` is filed by topic, not a flat shelf.** With no `?tag=` in the + address the page shows one folder per topic (drawn from `/articles/facets`); + a folder is a real link to `?tag=` and opens onto the articles wearing + it. **An open folder's shelf arrives a batch at a time.** 24 cards come down + with the route, and the row under the grid (`IntersectionObserver`, 600px of + reach) asks for the rest — so a topic or language filter is the API's to + apply (`?tag=&lang=`): the page holds no whole shelf to filter itself, and + changing the filter drops the batches read for the old one. That is also why + the folder tallies come from `/articles/facets` rather than from the cards on + screen — counting the loaded batch would let "40 topics" and the tallies + grow as the reader scrolls. - Frontend dev server may fall back to port 3001 if 3000 is taken. - `frontend/types/markdown-it-texmath.d.ts` provides types for the untyped texmath package. diff --git a/backend/package.json b/backend/package.json index fe78f6f..160f72c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,8 @@ "scripts": { "start": "node src/index.js", "dev": "node --watch src/index.js", - "seed": "node src/seed.js" + "seed": "node src/seed.js", + "seed:bulk": "node src/seed-bulk.js" }, "license": "MIT", "dependencies": { diff --git a/backend/src/index.js b/backend/src/index.js index 6b930da..b963258 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -7,6 +7,7 @@ import itemsRouter from "./routes/items.js"; import articlesRouter from "./routes/articles.js"; import authRouter from "./routes/auth.js"; import adminRouter from "./routes/admin.js"; +import Article from "./models/Article.js"; import User from "./models/User.js"; const app = express(); @@ -51,6 +52,10 @@ const port = process.env.PORT || 5000; try { await connectDB(); + // A text index built before the language override was set (models/Article.js) + // is still in the way of writing an article in most languages: replace it. + const rebuilt = await Article.replaceReservedLanguageIndex(); + if (rebuilt.length) console.log(`Rebuilt the article text index: ${rebuilt.join(", ")}`); // Accounts opened before there were roles: the oldest of them gets one, so // somebody can always open the dashboard and pass the role to anyone else. const promoted = await User.ensureAnAdmin(); diff --git a/backend/src/models/Article.js b/backend/src/models/Article.js index 68e37de..fe8d149 100644 --- a/backend/src/models/Article.js +++ b/backend/src/models/Article.js @@ -44,8 +44,37 @@ const articleSchema = new mongoose.Schema( { timestamps: true } ); -// Text index for search across title and tags -articleSchema.index({ title: "text", tags: "text" }); +/* Text index for search across title and tags. + `language` means something else to MongoDB: a text index reads that field as + the language to analyse the document in, and refuses to write a document + whose value it does not recognise — which rules out Catalan, Polish, Korean, + Arabic and the rest of the codes this wiki writes articles in. Pointing the + override at a field no article carries keeps the wiki's own `language` out + of the index's way, so an article in any language saves. */ +articleSchema.index({ title: "text", tags: "text" }, { language_override: "articleLanguage" }); + +/* An index's options cannot be changed in place — MongoDB answers the second + request with "an equivalent index already exists" — so a database built + before the override above carries the old text index, whose writes still + fail. Drop those and let the schema's own indexes be rebuilt. Answers the + names it removed: nothing at all when the database is already up to date. */ +articleSchema.statics.replaceReservedLanguageIndex = async function replaceReservedLanguageIndex() { + let existing; + try { + existing = await this.collection.indexes(); + } catch (err) { + // a database with no articles collection yet has no index to replace, and + // asking for its list is MongoDB's `ns does not exist` — nothing to do here + if (err.codeName === "NamespaceNotFound" || err.code === 26) return []; + throw err; + } + const stale = existing.filter( + (index) => index.key?._fts === "text" && (index.language_override ?? "language") === "language" + ); + for (const index of stale) await this.collection.dropIndex(index.name); + if (stale.length) await this.createIndexes(); + return stale.map((index) => index.name); +}; const Article = mongoose.model("Article", articleSchema); diff --git a/backend/src/models/EditProposal.js b/backend/src/models/EditProposal.js new file mode 100644 index 0000000..ddb182e --- /dev/null +++ b/backend/src/models/EditProposal.js @@ -0,0 +1,87 @@ +import mongoose from "mongoose"; + +export const PROPOSAL_STATUSES = ["pending", "approved", "rejected"]; + +/* When a member saves the wiki the change does not land on it: it is held here + as a request for an admin to approve or reject. A request changes a page that + exists (`kind: "edit"`), or asks for one that does not yet (`kind: "create"`) + — an article, or a new language version of an existing one — and carries no + article until an admin's approval writes it. One pending request per article + and account — saving again while one waits revises it rather than stacking a + second copy, and the same draft a create editor holds revises itself the same + way. The account's name is kept beside its id so a request stays readable + after the account itself is closed. */ + +export const PROPOSAL_KINDS = ["edit", "create"]; + +const editProposalSchema = new mongoose.Schema( + { + // Whether the request rewrites a page or asks for a new one to exist. + kind: { + type: String, + enum: { + values: PROPOSAL_KINDS, + message: `Kind is one of: ${PROPOSAL_KINDS.join(", ")}.`, + }, + default: "edit", + }, + // The document the request wants to change, by id rather than slug: a + // retitle moves the slug out from under it. Null while the page is only + // being asked for; approval fills it in, so a decided request still names + // the article it brought about. + article: { + type: mongoose.Schema.Types.ObjectId, + ref: "Article", + default: null, + index: true, + }, + // What a create asks the new page to be: the address it wants (the title + // slugified, or `-` for a translation — the address is + // what tells one draft from another while both wait), and the identity of + // the article it joins as a version, if it translates one. + slug: { type: String, default: null, lowercase: true, trim: true }, + baseSlug: { type: String, default: null, lowercase: true, trim: true }, + // The text the editor was looking at — the other half of every diff on the + // dashboard, and how a reviewer spots an article that moved underneath. + // Empty for a creation: the page it asks for has nothing written on it. + base: { + title: { type: String, default: "" }, + content: { type: String, default: "" }, + tags: { type: [String], default: [] }, + }, + // The whole state being proposed, so approving is one application, not a patch + title: { + type: String, + required: [true, "Title is required"], + trim: true, + }, + content: { type: String, default: "" }, + tags: { type: [String], default: [] }, + language: { type: String, default: "en", lowercase: true, trim: true }, + status: { + type: String, + enum: { + values: PROPOSAL_STATUSES, + message: `Status is one of: ${PROPOSAL_STATUSES.join(", ")}.`, + }, + default: "pending", + index: true, + }, + createdBy: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + createdByUsername: { type: String, default: "" }, + decidedBy: { type: mongoose.Schema.Types.ObjectId, ref: "User", default: null }, + decidedByUsername: { type: String, default: "" }, + decidedAt: { type: Date, default: null }, + }, + { timestamps: true } +); + +// who has what waiting on which article — the filter the upsert saves through +editProposalSchema.index({ article: 1, createdBy: 1, status: 1 }); +// ... and which address, for a request that names no article yet: the slug it +// asks for tells one member's waiting drafts apart from another's +editProposalSchema.index({ createdBy: 1, status: 1, kind: 1, slug: 1 }); + +const EditProposal = mongoose.model("EditProposal", editProposalSchema); + +export default EditProposal; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index c6f6a71..ba0c216 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -1,11 +1,15 @@ import { Router } from "express"; +import Article from "../models/Article.js"; +import EditProposal from "../models/EditProposal.js"; import User, { ROLES } from "../models/User.js"; import { requireAdmin, requireAuth } from "../middleware/auth.js"; +import { applyArticleEdit, createArticle } from "./articles.js"; const router = Router(); -/* The back office of the wiki: who holds an account, and what each of them may - do. Everything here takes an admin — `requireAdmin` reads the role off the +/* The back office of the wiki: who holds an account, what each of them may + do, and the edit requests members have sent in for an admin to decide. + Everything here takes an admin — `requireAdmin` reads the role off the account, so a session outlives its own promotion the moment it is undone. */ /** The shape the dashboard is given for one account: the public fields, plus @@ -76,4 +80,170 @@ router.delete("/users/:id", requireAuth, requireAdmin, async (req, res, next) => } }); +/* --- edit requests ------------------------------------------------------- + What a member's save became instead of a write (see routes/articles.js), + read here newest first with everything a reviewer needs beside it. */ + +/** + * Every proposal with its proposer, decider, and the article as it stands now — + * the review needs all three: who asked, whether it was answered, and whether + * the page moved after the request was sent. Names come from the accounts while + * they exist and from what each request recorded otherwise. A request for a new + * page names no article at all until an approval writes one (`kind` says so, and + * `slug`/`baseSlug` hold the address it asked for), and an approved one names + * the article it brought about. + */ +async function proposalRows(proposals) { + const userIds = [ + ...new Set(proposals.flatMap((p) => [p.createdBy, p.decidedBy]).filter(Boolean)), + ]; + const users = await User.find({ _id: { $in: userIds } }).select("username avatar").lean(); + const userById = new Map(users.map((u) => [String(u._id), u])); + const nameOf = (id, stored) => + id ? (userById.get(String(id))?.username ?? stored ?? "") : null; + + const articleIds = [...new Set(proposals.map((p) => p.article).filter(Boolean).map(String))]; + const articles = await Article.find({ _id: { $in: articleIds } }) + .select("slug baseSlug title language content tags") + .lean(); + const articleById = new Map(articles.map((a) => [String(a._id), a])); + + return proposals.map((p) => { + const article = articleById.get(String(p.article)) ?? null; + return { + id: String(p._id), + kind: p.kind === "create" ? "create" : "edit", + status: p.status, + createdAt: p.createdAt, + decidedAt: p.decidedAt ?? null, + // what a request for a new page asks the article to be called at, and + // which one it joins as a version — the reviewer reads the address from here + slug: p.slug ?? null, + baseSlug: p.baseSlug ?? null, + proposed: { + title: p.title, + content: p.content ?? "", + tags: p.tags ?? [], + language: p.language ?? "en", + }, + base: { + title: p.base?.title ?? "", + content: p.base?.content ?? "", + tags: p.base?.tags ?? [], + }, + proposer: { + username: nameOf(p.createdBy, p.createdByUsername) || p.createdByUsername, + avatar: userById.get(String(p.createdBy))?.avatar ?? "", + }, + decidedByUsername: nameOf(p.decidedBy, p.decidedByUsername), + article: article && { + id: String(article._id), + slug: article.slug, + identity: article.baseSlug || article.slug, + title: article.title, + language: article.language ?? "en", + content: article.content ?? "", + tags: article.tags ?? [], + }, + }; + }); +} + +// GET /api/admin/proposals — every edit request members have made, decided +// ones included: the dashboard shows the waiting pile and its history. +router.get("/proposals", requireAuth, requireAdmin, async (req, res, next) => { + try { + const proposals = await EditProposal.find().sort({ createdAt: -1 }).lean(); + res.json({ proposals: await proposalRows(proposals) }); + } catch (err) { + next(err); + } +}); + +// GET /api/admin/proposals/count — how many requests still wait. The sidebar +// wears this on the door to the desk, so an admin sees the pile without opening +// it; only the number is asked for, not the requests themselves. +router.get("/proposals/count", requireAuth, requireAdmin, async (req, res, next) => { + try { + const pending = await EditProposal.countDocuments({ status: "pending" }); + res.json({ pending }); + } catch (err) { + next(err); + } +}); + +/** Mark a request decided, recording which admin answered it and when. */ +async function decide(proposal, status, admin) { + proposal.status = status; + proposal.decidedBy = admin.id; + proposal.decidedByUsername = admin.username; + proposal.decidedAt = new Date(); + await proposal.save(); +} + +// PUT /api/admin/proposals/:id/approve — grant the request. An edit is written +// onto the article, through the same rules a direct admin edit runs by; a +// creation is written as a new article, through the rules a direct admin create +// runs by — so a page that has appeared in the meantime, or a version group +// that has grown the language asked for, is refused the same honest way. +router.put("/proposals/:id/approve", requireAuth, requireAdmin, async (req, res, next) => { + try { + const proposal = await EditProposal.findById(req.params.id); + if (!proposal) return res.status(404).json({ message: "No such edit request." }); + if (proposal.status !== "pending") { + return res.status(400).json({ message: "That edit request is already decided." }); + } + + if (proposal.kind === "create") { + const created = await createArticle({ + title: proposal.title, + content: proposal.content, + tags: proposal.tags, + language: proposal.language, + baseSlug: proposal.baseSlug, + }); + // the record says which article the request brought about + proposal.article = created._id; + await decide(proposal, "approved", req.user); + return res.json({ message: "Article created.", article: created }); + } + + const article = await Article.findById(proposal.article); + if (!article) { + return res.status(404).json({ message: "The article that request changes is gone." }); + } + + const language = + proposal.language && proposal.language !== article.language ? proposal.language : undefined; + const updated = await applyArticleEdit(article, { + title: proposal.title, + content: proposal.content, + tags: proposal.tags, + language, + }); + + await decide(proposal, "approved", req.user); + res.json({ message: "Change applied.", article: updated }); + } catch (err) { + next(err); + } +}); + +// PUT /api/admin/proposals/:id/reject — refuse the change; the article stays +// exactly as it is and the request stays on the record as a refusal. +router.put("/proposals/:id/reject", requireAuth, requireAdmin, async (req, res, next) => { + try { + const proposal = await EditProposal.findById(req.params.id); + if (!proposal) return res.status(404).json({ message: "No such edit request." }); + if (proposal.status !== "pending") { + return res.status(400).json({ message: "That edit request is already decided." }); + } + + await decide(proposal, "rejected", req.user); + res.json({ message: "Edit request rejected." }); + } catch (err) { + next(err); + } +}); + export default router; diff --git a/backend/src/routes/articles.js b/backend/src/routes/articles.js index dc888a3..1449ead 100644 --- a/backend/src/routes/articles.js +++ b/backend/src/routes/articles.js @@ -1,11 +1,15 @@ import { Router } from "express"; import Article from "../models/Article.js"; -import { requireAuth } from "../middleware/auth.js"; +import EditProposal from "../models/EditProposal.js"; +import User from "../models/User.js"; +import { requireAdmin, requireAuth } from "../middleware/auth.js"; const router = Router(); // Reading the wiki is open to everyone; the write endpoints at the bottom of // this file take an account — see routes/auth.js for how one is obtained. +// Editing further: an admin writes straight to the page, a member's change is +// held as a proposal an admin approves (see models/EditProposal.js). // Convert a title (or any string) into a URL-safe slug export function slugify(str) { @@ -38,6 +42,82 @@ function languageCode(value) { return code || "en"; } +// A value made safe to drop into a RegExp +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// The most any one answer may carry. `?limit=` past it is answered at this size. +const MAX_PAGE_SIZE = 100; + +// A count from the query string: `fallback` when it is missing or nonsense. +function positiveCount(value, fallback) { + const n = Number.parseInt(String(value ?? ""), 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +/** + * How much of the shelf one answer carries: `limit` articles, from `offset` + * on. Asked for neither, the whole shelf answers at once — what the readers + * that want all of it (the home page's search, say) ask for. + */ +function pageBounds(query) { + const offset = positiveCount(query.offset, 0); + const limit = positiveCount(query.limit, 0); + return { offset, limit: limit ? Math.min(limit, MAX_PAGE_SIZE) : Infinity }; +} + +// Every version of the articles these identities name, translations included. +function versionsFilter(identities) { + return { $or: [{ slug: { $in: identities } }, { baseSlug: { $in: identities } }] }; +} + +/** + * The documents that say which articles the shelf shows: each condition is + * answered over an article's versions rather than over single documents — a + * tag any version wears, a language any version is written in — so an article + * tagged only in the language it was written in still shows when a tag and + * another language are asked for together. + */ +async function matchingVersions(conditions) { + if (!conditions.length) { + return Article.find().select("slug baseSlug").sort({ updatedAt: -1 }).lean(); + } + const passes = await Promise.all( + conditions.map((c) => Article.find(c).select("slug baseSlug").sort({ updatedAt: -1 }).lean()) + ); + const [first, ...rest] = passes; + const also = rest.map((p) => new Set(p.map((a) => a.baseSlug || a.slug))); + return first.filter((a) => also.every((ids) => ids.has(a.baseSlug || a.slug))); +} + +/** + * The version an article is shown as: the one written in the language asked + * for, else the reader's own language if the article speaks it (English as the + * wiki's common tongue failing that), else the canonical version, else the + * first of them — the version whose last edit puts the article in the shelf's + * order. + */ +function representative(versions, wantedLang, preferredLang, matchedSlugs) { + if (wantedLang) { + const inWanted = versions.find((a) => languageCode(a.language) === wantedLang); + if (inWanted) return inWanted; + } + if (preferredLang) { + const inPreferred = versions.find((a) => languageCode(a.language) === preferredLang); + if (inPreferred) return inPreferred; + if (preferredLang !== "en") { + const inEnglish = versions.find((a) => languageCode(a.language) === "en"); + if (inEnglish) return inEnglish; + } + } + return ( + versions.find((a) => !a.baseSlug) ?? + versions.find((a) => matchedSlugs.has(a.slug)) ?? + versions[0] + ); +} + // The language versions of one article: every document sharing its identity — // the canonical (original) version's slug. Translations carry that slug as // `baseSlug`; the canonical document answers to it as its own `slug`. @@ -64,58 +144,147 @@ async function resolveVersion(slug, lang) { return Article.findOne({ baseSlug: identity, language: wanted }); } -// GET /api/articles?q=&tag=&lang= - list articles (without content body). +// GET /api/articles?q=&tag=&lang=&prefer=&preferFirst=&limit=&offset= - a page +// of the shelf (without content body), newest edit first, with how many +// articles match. // One entry per article regardless of how many languages it exists in: the // canonical version answers with every language version attached, and with -// ?lang= the version written in that language answers instead. +// ?lang= the version written in that language answers instead. `?prefer=` is +// the reader's own language: it picks which version an article stands with — +// that language's, else English, else the canonical one — but never decides +// which articles answer. `?preferFirst` additionally ranks the answers: +// articles written in the preferred language lead the ones only shown through +// a fallback. router.get("/", async (req, res, next) => { try { - const { q, tag, lang } = req.query; - const filter = {}; - if (typeof q === "string" && q.trim()) { - const safe = q.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const rx = new RegExp(safe, "i"); - filter.$or = [{ title: rx }, { tags: rx }]; - } - if (typeof tag === "string" && tag.trim()) { - filter.tags = tag.trim().toLowerCase(); - } + const { q, tag, lang, prefer, preferFirst } = req.query; const wantedLang = typeof lang === "string" && lang.trim() ? languageCode(lang) : ""; + const preferredLang = typeof prefer === "string" && prefer.trim() ? languageCode(prefer) : ""; + const rankPreferredFirst = + preferFirst !== undefined && + preferFirst !== "false" && + preferFirst !== "0" && + Boolean(preferredLang); + const needle = typeof q === "string" ? q.trim() : ""; + const wantedTag = typeof tag === "string" ? tag.trim() : ""; + + const conditions = []; + if (needle) { + const rx = new RegExp(escapeRegExp(needle), "i"); + conditions.push({ $or: [{ title: rx }, { tags: rx }] }); + } + if (wantedTag) conditions.push({ tags: new RegExp(`^${escapeRegExp(wantedTag)}$`, "i") }); if (wantedLang) { // articles written before the language field existed are English - filter.language = wantedLang === "en" ? { $in: ["en", null] } : wantedLang; + conditions.push({ language: wantedLang === "en" ? { $in: ["en", null] } : wantedLang }); } - const matched = await Article.find(filter).select("-content").sort({ updatedAt: -1 }); - if (!matched.length) return res.json([]); - - // pull in the versions of every matched article so each can be counted - // as one article and carry the full list of its languages + const matched = await matchingVersions(conditions); const identities = [...new Set(matched.map((a) => a.baseSlug || a.slug))]; - const groupDocs = await Article.find({ - $or: [{ slug: { $in: identities } }, { baseSlug: { $in: identities } }], - }) + if (!identities.length) return res.json({ articles: [], total: 0, hasMore: false }); + + // Every version of every article on show, metadata only: enough to say + // which version stands for an article and when it was last edited, so the + // page is cut here — before any article's text is read at all. + const members = await Article.find(versionsFilter(identities)) + .select("slug baseSlug language updatedAt") + .sort({ updatedAt: -1 }) + .lean(); + const versionsByIdentity = new Map(identities.map((id) => [id, []])); + for (const a of members) versionsByIdentity.get(a.baseSlug || a.slug)?.push(a); + + const matchedSlugs = new Set(matched.map((a) => a.slug)); + const shelf = identities + .map((id) => { + const versions = versionsByIdentity.get(id) ?? []; + const shown = representative(versions, wantedLang, preferredLang, matchedSlugs); + return shown + ? { + id, + shownAt: new Date(shown.updatedAt), + shownSlug: shown.slug, + shownLang: languageCode(shown.language), + } + : null; + }) + .filter(Boolean) + .sort((a, b) => b.shownAt - a.shownAt); + + // `?preferFirst` (the search popup's ask): articles that actually speak the + // preferred language stand before ones the shelf shows in a fallback. The + // sort is stable, so the newest-edit order holds within each group — and + // it runs before the page is cut, so a language-match cannot be crowded + // out of a short batch by fallbacks that merely read as fresher. + if (rankPreferredFirst) { + shelf.sort( + (a, b) => Number(b.shownLang === preferredLang) - Number(a.shownLang === preferredLang) + ); + } + + const { offset, limit } = pageBounds(req.query); + const page = shelf.slice(offset, offset + limit); + + // Now the page's articles in full apart from their text, so a whole shelf + // never crosses the wire for the sake of one batch of cards. + const pageIdentities = page.map((entry) => entry.id); + const groupDocs = await Article.find(versionsFilter(pageIdentities)) .select("-content") .sort({ updatedAt: -1 }); // not lean: hydration fills in old defaults + const groupByIdentity = new Map(pageIdentities.map((id) => [id, []])); + for (const a of groupDocs) groupByIdentity.get(a.baseSlug || a.slug)?.push(a); - const versionsByIdentity = new Map(identities.map((id) => [id, []])); - for (const a of groupDocs) { - versionsByIdentity.get(a.baseSlug || a.slug)?.push(a); - } - const matchedSlugs = new Set(matched.map((a) => a.slug)); - - const entries = identities.map((id) => { - const group = versionsByIdentity.get(id) ?? []; - const asVersion = (a) => ({ ...a.toObject(), versions: group }); - if (wantedLang) { - const inWanted = group.find((a) => languageCode(a.language) === wantedLang); - if (inWanted) return asVersion(inWanted); - } - const rep = group.find((a) => !a.baseSlug) ?? group.find((a) => matchedSlugs.has(a.slug)) ?? group[0]; - return asVersion(rep); + const entries = page.map((entry) => { + const versions = groupByIdentity.get(entry.id) ?? []; + const shown = versions.find((a) => a.slug === entry.shownSlug) ?? versions[0]; + return { ...shown.toObject(), versions }; + }); + + res.json({ + articles: entries, + total: shelf.length, + hasMore: offset + page.length < shelf.length, + }); + } catch (err) { + next(err); + } +}); + +// GET /api/articles/facets - how the shelf is built, counted over every +// article rather than the page currently on show: how many there are, the +// topics they wear and the languages they are written in. An article counts +// once per topic however many versions carry it, and once per language. The +// shelf's topic list and language chips read this, so their counts stay true +// while its cards arrive a batch at a time. +router.get("/facets", async (req, res, next) => { + try { + const docs = await Article.find().select("slug baseSlug tags language").lean(); + const articles = new Map(); // identity -> the topics and languages it wears + for (const doc of docs) { + const identity = doc.baseSlug || doc.slug; + const article = articles.get(identity) ?? { topics: new Set(), langs: new Set() }; + for (const t of doc.tags ?? []) { + const name = String(t).trim().toLowerCase(); + if (name) article.topics.add(name); + } + article.langs.add(languageCode(doc.language)); + articles.set(identity, article); + } + + const topics = new Map(); + const languages = new Map(); + for (const { topics: worn, langs } of articles.values()) { + for (const t of worn) topics.set(t, (topics.get(t) ?? 0) + 1); + for (const code of langs) languages.set(code, (languages.get(code) ?? 0) + 1); + } + + const byPopularity = (counts) => + [...counts.entries()].sort(([a, ac], [b, bc]) => bc - ac || a.localeCompare(b)); + + res.json({ + total: articles.size, + topics: byPopularity(topics).map(([name, count]) => ({ name, count })), + languages: byPopularity(languages).map(([code, count]) => ({ code, count })), }); - entries.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); - res.json(entries); } catch (err) { next(err); } @@ -142,9 +311,12 @@ router.get("/random", async (req, res, next) => { // Links inside article content, as the graph sees them: wiki links // ([[Target]], with optional #section and |display) and markdown links -// pointing at /wiki/. Global so String#matchAll resumes per scan. +// pointing at a wiki address — /wiki/, or the full address of one +// language version, /wiki//. The language part is read off and the +// article behind the link is the node, whichever version was linked. +// Global so String#matchAll resumes per scan. const WIKILINK_RX = /\[\[([^\[\]|#]+)(?:#[^\[\]|]*)?(?:\|[^\[\]]*)?\]\]/g; -const WIKI_URL_RX = /\]\((?:https?:\/\/[^/\s)]+)?\/?wiki\/([A-Za-z0-9][A-Za-z0-9_-]*)/g; +const WIKI_URL_RX = /\]\((?:https?:\/\/[^/\s)]+)?\/?wiki\/(?:[a-z]{2}\/)?([A-Za-z0-9][A-Za-z0-9_-]*)/g; // GET /api/articles/graph - the wiki's link map for the graph view: one node // per article — versions of the same article share a node — and one undirected @@ -221,137 +393,300 @@ router.get("/slug/:slug", async (req, res, next) => { } }); +/** + * The answer a request needs beyond the error handler's usual: a status of its + * own, which the central handler in index.js reads off the error. + */ +function requestError(status, message) { + const err = new Error(message); + err.status = status; + throw err; +} + +/** + * A create request read out and checked against the wiki: the text the new + * document carries, the version group it belongs to (a translation names the + * article it translates; a fresh article joins one when its name is already + * taken in another language), the address it asks for, and a language that + * group does not already have. `createArticle` writes from this and a member's + * proposal records it, so both ask the wiki the same thing. + */ +async function resolveCreation(body = {}) { + const { title, content = "", tags = [], baseSlug } = body; + const language = languageCode(body.language); + if (!title || !String(title).trim()) requestError(400, "Title is required"); + + let identity = null; + if (baseSlug) { + const base = await Article.findOne({ slug: slugify(baseSlug) }); + if (!base) requestError(400, "The article being translated does not exist"); + identity = base.baseSlug || base.slug; + } else { + // Two articles must not share a name in different languages: when the + // slug is taken by an article written in another language, the document + // joins it as a version rather than standing beside it as a twin. (The + // same name in the same language stays the old `-2` suffixed sibling.) + const byName = await Article.findOne({ slug: slugify(body.slug || title) }); + if (byName && languageCode(byName.language) !== language) { + identity = byName.baseSlug || byName.slug; + } + } + if (identity) { + const clash = await Article.findOne({ + $or: [{ slug: identity }, { baseSlug: identity }], + language, + }); + if (clash) requestError(400, `This article already has a version in ${language}`); + } + + const requested = body.slug + ? slugify(body.slug) + : identity + ? `${identity}-${language}` // versions sit side by side: slug-fr, slug-de, ... + : slugify(title); + return { title, content, tags, language, identity, slug: requested }; +} + +/** + * Write a new article onto the wiki. Shared by the POST below — an admin's own + * create, which lands at once — and by approving a member's request for one + * (admin routes), which is created by these very rules. + */ +export async function createArticle(body = {}) { + const creation = await resolveCreation(body); + return Article.create({ + title: creation.title, + content: creation.content, + tags: creation.tags, + language: creation.language, + slug: await uniqueSlug(creation.slug), + baseSlug: creation.identity, + }); +} + // POST /api/articles - create an article (sign-in required; reading is public, // writing the wiki takes an account). Passing `baseSlug` makes the new document // another language version of an existing article rather than a fresh one: it // joins the original's version group and takes a slug beside it (slug-fr, …). +// An admin's create is written at once; a member's is held as a proposal for an +// admin to approve — 202 says no page was created. router.post("/", requireAuth, async (req, res, next) => { try { - const { title, content = "", tags = [], baseSlug } = req.body || {}; - const language = languageCode(req.body?.language); - if (!title || !String(title).trim()) { - return res.status(400).json({ message: "Title is required" }); + const account = await User.findById(req.user.id).select("username role"); + if (!account) return res.status(401).json({ message: "No such account any more." }); + + if (account.role !== "admin") { + // the request is weighed against the wiki first: a title or language that + // could not be honoured is told to the editor now, not to an admin + // reading the request later + const creation = await resolveCreation(req.body || {}); + const proposal = await submitCreationProposal( + account, + creation, + (req.body || {}).proposalId + ); + return res.status(202).json({ proposal }); } - let identity = null; - if (baseSlug) { - const base = await Article.findOne({ slug: slugify(baseSlug) }); - if (!base) { - return res.status(400).json({ message: "The article being translated does not exist" }); - } - identity = base.baseSlug || base.slug; - } else { - // Two articles must not share a name in different languages: when the - // slug is taken by an article written in another language, the document - // joins it as a version rather than standing beside it as a twin. (The - // same name in the same language stays the old `-2` suffixed sibling.) - const byName = await Article.findOne({ - slug: slugify(req.body.slug || title), - }); - if (byName && languageCode(byName.language) !== language) { - identity = byName.baseSlug || byName.slug; - } - } - if (identity) { - const clash = await Article.findOne({ - $or: [{ slug: identity }, { baseSlug: identity }], - language, - }); - if (clash) { - return res - .status(400) - .json({ message: `This article already has a version in ${language}` }); - } - } - - const requested = req.body.slug - ? slugify(req.body.slug) - : identity - ? `${identity}-${language}` // versions sit side by side: slug-fr, slug-de, ... - : slugify(title); - const slug = await uniqueSlug(requested); - const article = await Article.create({ - title, - content, - tags, - language, - slug, - baseSlug: identity, - }); - res.status(201).json(article); + res.status(201).json(await createArticle(req.body || {})); } catch (err) { next(err); } }); -// PUT /api/articles/slug/:slug - update an article (sign-in required) +/** + * Write an edit onto the article document: the fields given, a re-slug when + * the title moved, and a language the version group already has is refused. + * Throws with `.status` for the central error handler to answer with. + * Shared by the direct write below and by approving a proposal (admin routes). + */ +export async function applyArticleEdit(article, body = {}) { + const { title, content, tags, language } = body; + if (title !== undefined && !String(title).trim()) { + requestError(400, "Title is required"); + } + + const update = {}; + if (title !== undefined) update.title = title; + if (content !== undefined) update.content = content; + if (tags !== undefined) update.tags = tags; + + // Moving this version to another language must not collide with a + // version the article already has in that language. + if (language !== undefined) { + const code = languageCode(language); + if (code !== article.language) { + const identity = article.baseSlug || article.slug; + const clash = await Article.findOne({ + $or: [{ slug: identity }, { baseSlug: identity }], + slug: { $ne: article.slug }, + language: code, + }); + if (clash) { + requestError(400, `This article already has a version in ${code}`); + } + update.language = code; + } + } + + // Re-slug when the title changes and no explicit slug was provided + if (title !== undefined) { + const requested = body.slug ? slugify(body.slug) : slugify(title); + if (requested && requested !== article.slug) { + update.slug = await uniqueSlug(requested, article.slug); + } + } + + const updated = await Article.findOneAndUpdate( + { slug: article.slug }, + update, + { new: true, runValidators: true } + ); + + // The version group's identity is the canonical article's slug: when the + // canonical is renamed, its translations follow it to the new slug. + if (updated && !article.baseSlug && update.slug) { + await Article.updateMany({ baseSlug: article.slug }, { baseSlug: update.slug }); + } + + return updated; +} + +/** + * A member's edit becomes a proposal instead of a write. The whole proposed + * state is kept, next to the article as it stands right now; saving again + * while the request waits revises it rather than queueing a second copy. + */ +async function submitProposal(article, account, body) { + const proposed = { + title: body.title !== undefined ? String(body.title).trim() : article.title, + content: body.content !== undefined ? String(body.content) : article.content, + tags: body.tags !== undefined ? body.tags : article.tags, + language: + body.language !== undefined ? languageCode(body.language) : languageCode(article.language), + }; + return EditProposal.findOneAndUpdate( + { article: article._id, createdBy: account._id, status: "pending" }, + { + $set: { + ...proposed, + base: { + title: article.title, + content: article.content, + tags: article.tags ?? [], + }, + decidedBy: null, + decidedByUsername: "", + decidedAt: null, + }, + $setOnInsert: { + article: article._id, + createdBy: account._id, + createdByUsername: account.username, + }, + }, + { new: true, upsert: true, setDefaultsOnInsert: true, runValidators: true } + ); +} + +/** + * A member's create becomes a request for a page that does not exist yet: the + * whole state of the article they want, with the address it asks for and the + * version group it joins, if it translates one. The editor holds one draft, so + * saving again while the request waits revises it — by the request's own id + * when the editor carries one (a draft retitled since it was sent asks for a + * different address, and is still the same piece of work), else by the address + * it asks for. + */ +async function submitCreationProposal(account, creation, proposalId) { + const state = { + title: creation.title, + content: creation.content, + tags: creation.tags, + language: creation.language, + slug: creation.slug, + baseSlug: creation.identity, + }; + + if (proposalId) { + const waiting = await EditProposal.findById(proposalId); + if ( + waiting?.kind === "create" && + waiting.status === "pending" && + waiting.createdBy?.equals(account._id) + ) { + Object.assign(waiting, state); + await waiting.save(); + return waiting; + } + // an id that is not this account's own waiting request is not trusted: the + // draft is filed as a fresh one rather than dropped + } + + // The address is what the request is found by, so the filter alone gives a + // new one its `slug`: writing it here as well would conflict with itself. + return EditProposal.findOneAndUpdate( + { article: null, kind: "create", createdBy: account._id, status: "pending", slug: creation.slug }, + { + $set: { + title: creation.title, + content: creation.content, + tags: creation.tags, + language: creation.language, + baseSlug: creation.identity, + decidedBy: null, + decidedByUsername: "", + decidedAt: null, + }, + $setOnInsert: { + kind: "create", + article: null, + createdBy: account._id, + createdByUsername: account.username, + }, + }, + { new: true, upsert: true, setDefaultsOnInsert: true, runValidators: true } + ); +} + +// PUT /api/articles/slug/:slug - update an article (sign-in required). +// An admin writes through; a member's change is held as a proposal an admin +// approves later — 202 says the page itself has not moved. router.put("/slug/:slug", requireAuth, async (req, res, next) => { try { - const { title, content, tags, language } = req.body || {}; + const { title } = req.body || {}; if (title !== undefined && !String(title).trim()) { return res.status(400).json({ message: "Title is required" }); } const article = await Article.findOne({ slug: req.params.slug }); if (!article) return res.status(404).json({ message: "Article not found" }); - const update = {}; - if (title !== undefined) update.title = title; - if (content !== undefined) update.content = content; - if (tags !== undefined) update.tags = tags; + const account = await User.findById(req.user.id).select("username role"); + if (!account) return res.status(401).json({ message: "No such account any more." }); - // Moving this version to another language must not collide with a - // version the article already has in that language. - if (language !== undefined) { - const code = languageCode(language); - if (code !== article.language) { - const identity = article.baseSlug || article.slug; - const clash = await Article.findOne({ - $or: [{ slug: identity }, { baseSlug: identity }], - slug: { $ne: article.slug }, - language: code, - }); - if (clash) { - return res - .status(400) - .json({ message: `This article already has a version in ${code}` }); - } - update.language = code; - } + if (account.role !== "admin") { + const proposal = await submitProposal(article, account, req.body || {}); + return res.status(202).json({ proposal }); } - // Re-slug when the title changes and no explicit slug was provided - if (title !== undefined) { - const requested = req.body.slug ? slugify(req.body.slug) : slugify(title); - if (requested && requested !== article.slug) { - update.slug = await uniqueSlug(requested, article.slug); - } - } - - const updated = await Article.findOneAndUpdate( - { slug: req.params.slug }, - update, - { new: true, runValidators: true } - ); - - // The version group's identity is the canonical article's slug: when the - // canonical is renamed, its translations follow it to the new slug. - if (updated && !article.baseSlug && update.slug) { - await Article.updateMany({ baseSlug: article.slug }, { baseSlug: update.slug }); - } - - res.json(updated); + res.json(await applyArticleEdit(article, req.body || {})); } catch (err) { next(err); } }); -// DELETE /api/articles/slug/:slug - delete an article (sign-in required). -// Deleting the canonical version leaves its translations readable: they still -// recognise each other through the baseSlug they share. -router.delete("/slug/:slug", requireAuth, async (req, res, next) => { +// DELETE /api/articles/slug/:slug - delete an article, admins alone. Writing +// through a proposal is how a member's change lands; deleting is the one thing +// nobody does to the wiki without holding the role. Deleting the canonical +// version leaves its translations readable: they still recognise each other +// through the baseSlug they share. +router.delete("/slug/:slug", requireAuth, requireAdmin, async (req, res, next) => { try { const deleted = await Article.findOneAndDelete({ slug: req.params.slug }); if (!deleted) return res.status(404).json({ message: "Article not found" }); + // requests for a deleted article are settled with it: nothing is left to + // approve, and a reviewer should not be shown a page that has gone + await EditProposal.deleteMany({ article: deleted._id }); res.status(204).send(); } catch (err) { next(err); diff --git a/backend/src/seed-bulk.js b/backend/src/seed-bulk.js new file mode 100644 index 0000000..295f4c9 --- /dev/null +++ b/backend/src/seed-bulk.js @@ -0,0 +1,1304 @@ +// Bulk seed for load-testing the frontend: 100 short but real mathematics +// articles, cross-linked with [[wiki links]] so the graph view has a proper +// map to draw, tagged across many topics so the sidebar fills out, and with +// timestamps scattered over the last few months so list orderings look lived-in. +// +// Idempotent: an article whose slug already exists is skipped, never touched. +// +// npm run seed:bulk in backend/ +import "dotenv/config"; +import mongoose from "mongoose"; +import { connectDB, disconnectDB } from "./config/db.js"; +import Article from "./models/Article.js"; +import { slugify } from "./routes/articles.js"; + +// Deterministic PRNG (mulberry32) — the link picks and timestamps are the +// same on every run, so a re-seed after a wipe reproduces the same wiki. +function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +const rand = mulberry32(20260930); + +// title, tags, intro (inline wiki links allowed), display formula, +// three key facts, related article titles +const topics = [ + { + title: "Group theory", + tags: ["algebra", "groups"], + intro: "Group theory studies **groups** — sets equipped with one associative operation having an identity and inverses. It is the language in which symmetry is stated precisely, from the [[Symmetric group|permutations of a deck of cards]] to the structure of solutions of polynomial equations in [[Galois theory]].", + tex: "(G,\\ \\cdot):\\quad a\\cdot(b\\cdot c)=(a\\cdot b)\\cdot c,\\qquad e\\cdot a=a\\cdot e=a,\\qquad a\\cdot a^{-1}=e", + facts: [ + "A group is *abelian* when its operation commutes — see [[Abelian group]].", + "The order of every subgroup divides the order of the group: [[Lagrange's theorem]].", + "Every group acts on itself by left multiplication (Cayley's theorem), so abstract groups and permutation groups describe the same objects.", + ], + see: ["Abelian group", "Symmetric group", "Lagrange's theorem", "Group homomorphism"], + }, + { + title: "Abelian group", + tags: ["algebra", "groups"], + intro: "An **abelian group** is a group whose operation commutes: $ab = ba$ for all elements. Named after Niels Henrik Abel, these are the groups where the order of combining does not matter — $(\\mathbb{Z}, +)$ yes, [[Symmetric group|symmetric groups]] from $S_3$ onward no.", + tex: "ab = ba \\quad \\text{for all } a,b \\in G", + facts: [ + "The classification of finitely generated abelian groups is complete: each is a product of cyclic groups.", + "Subgroups, quotients and products of abelian groups stay abelian; in fact [[Quotient group|quotients]] $G/N$ capture exactly the abelian images of $G$.", + "Commutativity is what turns group theory close to [[Linear algebra]]: vector spaces are abelian groups with scalars.", + ], + see: ["Group theory", "Quotient group", "Modular arithmetic"], + }, + { + title: "Symmetric group", + tags: ["algebra", "groups", "combinatorics"], + intro: "The **symmetric group** $S_n$ is the group of all permutations of $n$ objects, with composition as the operation; it has $n!$ elements. It is the prototype of a finite [[Group theory|group]], and its study of [[Catalan numbers|shuffle]] patterns reaches deep into [[Combinatorics]].", + tex: "|S_n| = n!, \\qquad S_n = \\{ \\sigma : \\{1, \\dots, n\\} \\to \\{1, \\dots, n\\} \\text{ bijective} \\}", + facts: [ + "Every permutation factors into disjoint cycles, uniquely up to order.", + "The even permutations form the alternating group $A_n$, a subgroup of index 2.", + "For $n \\ge 5$, $A_n$ is simple — the reason general polynomials of degree five cannot be solved by radicals ([[Galois theory]]).", + ], + see: ["Group theory", "Lagrange's theorem", "Group homomorphism"], + }, + { + title: "Lagrange's theorem", + tags: ["algebra", "groups"], + intro: "**Lagrange's theorem** says the order of any subgroup $H$ of a finite group $G$ divides the order of $G$; the quotient is the index $[G:H]$. The proof counts [[Coset|cosets]] — here, the size of each orbit of $H$ acting on $G$.", + tex: "|G| = [G : H] \\, |H|", + facts: [ + "The order of every element divides $|G|$, so $a^{|G|} = e$ — a group-theoretic cousin of [[Fermat's little theorem]].", + "Groups of prime order are cyclic and have no proper nontrivial subgroups.", + "The converse fails: $A_4$ has order 12 but no subgroup of order 6 ([[Sylow theorems]] salvage a partial converse).", + ], + see: ["Group theory", "Sylow theorems", "Group homomorphism"], + }, + { + title: "Sylow theorems", + tags: ["algebra", "groups"], + intro: "The **Sylow theorems** describe the largest $p$-power subgroups of a finite group: they exist, they are all conjugate, and their number satisfies a congruence. Together with [[Lagrange's theorem]] they are the main tool for reading a group's order.", + tex: "|G| = p^k m, \\ p \\nmid m \\implies \\exists\\, P \\le G,\\ |P| = p^k, \\qquad n_p \\equiv 1 \\pmod{p}", + facts: [ + "A group of order $p^k$ is a $p$-group; its center is never trivial.", + "The congruence $n_p \\equiv 1 \\pmod p$ often forces a Sylow subgroup to be normal.", + "The classification of finite simple groups — completed over decades — tells us every group is built from simple ones ([[Symmetric group]]'s alternating subgroups among them).", + ], + see: ["Group theory", "Lagrange's theorem", "Group homomorphism"], + }, + { + title: "Quotient group", + tags: ["algebra", "groups"], + intro: "A **quotient group** $G/N$ collapses a normal subgroup $N$ to the identity and keeps the rest of $G$'s structure. It is the algebraic version of forgetting a detail — angles modulo $2\\pi$, integers modulo $n$ ([[Modular arithmetic]]).", + tex: "G/N = \\{ gN : g \\in G \\}, \\qquad (aN)(bN) = (ab)N", + facts: [ + "Normality of $N$ is exactly what makes the product of cosets well defined.", + "The first isomorphism theorem: $G / \\ker\\varphi \\cong \\operatorname{im}\\varphi$ for any [[Group homomorphism|homomorphism]] $\\varphi$.", + "$\\mathbb{R}/\\mathbb{Z}$ is the circle group — phases of [[Fourier analysis]] live on it.", + ], + see: ["Group homomorphism", "Modular arithmetic", "Abelian group"], + }, + { + title: "Group homomorphism", + tags: ["algebra", "groups", "foundations"], + intro: "A **group homomorphism** is a map between groups that respects the operation: $\\varphi(ab) = \\varphi(a)\\varphi(b)$. Structure-preserving maps are the morphisms that make [[Group theory]] a category — see [[Category theory]].", + tex: "\\varphi(ab) = \\varphi(a)\\,\\varphi(b), \\qquad \\varphi(e_G) = e_H, \\qquad \\varphi(a^{-1}) = \\varphi(a)^{-1}", + facts: [ + "Kernels are normal subgroups, and every normal subgroup is a kernel — [[Quotient group|quotients]] and homomorphisms classify each other.", + "Bijective homomorphisms are isomorphisms: two isomorphic groups are indistinguishable algebraically.", + "Characters — homomorphisms into the circle group — are the atoms of [[Fourier analysis]].", + ], + see: ["Group theory", "Quotient group", "Representation theory"], + }, + { + title: "Ring theory", + tags: ["algebra", "rings"], + intro: "**Ring theory** studies sets with two operations — an abelian addition and an associative multiplication distributing over it. The integers are the model example; [[Field theory]] asks when division joins the party.", + tex: "(R, +, \\cdot):\\quad (R,+) \\text{ abelian},\\quad a(b+c) = ab + ac, \\quad (ab)c = a(bc)", + facts: [ + "Polynomial rings $R[x]$ inherit nice properties from $R$; unique factorization descends from [[Fundamental theorem of arithmetic]].", + "Ideals are the kernels of ring maps, and quotient rings $R/I$ generalise [[Modular arithmetic|modulo arithmetic]].", + "Commutative ring theory is the local language of [[Elliptic curves|algebraic geometry]]'s coordinate rings.", + ], + see: ["Field theory", "Modular arithmetic", "Prime number"], + }, + { + title: "Field theory", + tags: ["algebra", "fields"], + intro: "A **field** is a ring in which every nonzero element has a multiplicative inverse — $\\mathbb{Q}$, $\\mathbb{R}$, $\\mathbb{C}$, and the finite fields of [[Modular arithmetic|prime order]]. Field theory measures how much you must enlarge a field to split a polynomial, which is the substance of [[Galois theory]].", + tex: "\\mathbb{Q} \\subset \\mathbb{R} \\subset \\mathbb{C}, \\qquad [\\mathbb{C} : \\mathbb{R}] = 2", + facts: [ + "Every field has a characteristic: either 0 or a prime $p$ ([[Fundamental theorem of arithmetic]] makes composite characteristics impossible).", + "Finite fields exist exactly for prime-power orders, written $\\mathbb{F}_{p^n}$, and their multiplicative groups are cyclic.", + "The [[Riemann zeta function]] and $p$-adic fields ([[p-adic numbers]]) meet in the local-global philosophy of number theory.", + ], + see: ["Galois theory", "Ring theory", "p-adic numbers"], + }, + { + title: "Galois theory", + tags: ["algebra", "fields", "history"], + intro: "**Galois theory** attaches a [[Group theory|group]] of field automorphisms to a polynomial and reads its solvability from the group's shape. Évariste Galois sketched it the night before the duel that killed him, in 1832.", + tex: "\\operatorname{Gal}(E/F) = \\{ \\sigma : E \\to E \\text{ automorphism},\\ \\sigma|_F = \\mathrm{id} \\}", + facts: [ + "A polynomial is solvable by radicals exactly when its Galois group is a solvable group.", + "$\\operatorname{Gal}(\\mathbb{Q}(\\sqrt[n]{1})/\\mathbb{Q}) \\cong (\\mathbb{Z}/n)^{\\times}$ — which constructions with compass and straightedge are possible follows ([[Non-Euclidean geometry]]'s parallel postscript).", + "The inverse problem — which groups occur as Galois groups over $\\mathbb{Q}$ — remains open.", + ], + see: ["Field theory", "Symmetric group", "Non-Euclidean geometry"], + }, + { + title: "Representation theory", + tags: ["algebra", "linear algebra"], + intro: "**Representation theory** realiseds abstract [[Group theory|groups]] as matrices acting on [[Vector space|vector spaces]], turning algebra problems into [[Linear algebra]] problems. It is the standard physics language for symmetry.", + tex: "\\rho : G \\to \\mathrm{GL}(V), \\qquad \\rho(gh) = \\rho(g)\\,\\rho(h)", + facts: [ + "Characters $\\chi_\\rho(g) = \\operatorname{tr} \\rho(g)$ are class functions, and for finite groups they classify representations completely.", + "Maschke's theorem: in characteristic zero every representation splits into irreducibles.", + "[[Fourier analysis]] is the representation theory of the circle group — and of finite abelian groups, see [[Abelian group]].", + ], + see: ["Group homomorphism", "Linear algebra", "Fourier analysis"], + }, + { + title: "Lie algebra", + tags: ["algebra", "geometry"], + intro: "A **Lie algebra** is a vector space with an antisymmetric bracket $[\\cdot,\\cdot]$ satisfying the Jacobi identity — the infinitesimal shadow of a [[Manifold|Lie group]], linearised at the identity.", + tex: "[x,y] = -[y,x], \\qquad [x,[y,z]] + [y,[z,x]] + [z,[x,y]] = 0", + facts: [ + "The bracket is the commutator $[A,B] = AB - BA$ on matrices; matrix groups and their tangent Lie algebras talk to each other constantly.", + "Semisimple Lie algebras are completely classified by Dynkin diagrams.", + "The [[Spectral theorem]] and Cartan subalgebras share the theme: find a basis where the operators are diagonal.", + ], + see: ["Representation theory", "Manifold", "Symmetric group"], + }, + { + title: "Prime number", + tags: ["number theory", "foundations"], + intro: "A **prime** is an integer greater than 1 divisible only by 1 and itself. Primes are the atoms of arithmetic by the [[Fundamental theorem of arithmetic]], and their distribution is the subject of the [[Prime number theorem]] and the [[Riemann hypothesis]].", + tex: "p > 1, \\qquad p \\mid ab \\implies p \\mid a \\ \\text{or}\\ p \\mid b", + facts: [ + "There are infinitely many primes — Euclid's proof is among the oldest arguments still in use.", + "The twin prime conjecture (infinitely many $p$ with $p+2$ prime) was advanced dramatically by Zhang and the Polymath project in 2013–14.", + "Primes of special forms power public-key cryptography — see [[RSA]].", + ], + see: ["Fundamental theorem of arithmetic", "Prime number theorem", "Riemann zeta function"], + }, + { + title: "Fundamental theorem of arithmetic", + tags: ["number theory", "algebra"], + intro: "Every integer greater than 1 factors into **primes**, and does so uniquely up to order. The theorem fails charmingly in some [[Ring theory|ring]]s of integers — $6 = 2 \\cdot 3 = (1 + \\sqrt{-5})(1 - \\sqrt{-5})$ in $\\mathbb{Z}[\\sqrt{-5}]$.", + tex: "n = p_1^{e_1} p_2^{e_2} \\cdots p_k^{e_k}, \\qquad e_i \\ge 1", + facts: [ + "Unique factorisation is equivalent to the prime-divides property: $p \\mid ab \\Rightarrow p \\mid a$ or $p \\mid b$.", + "The Euclidean algorithm ([[Euclidean algorithm]]) proves it without any machinery beyond division with remainder.", + "Restoring uniqueness leads to ideals, UFDs, and class groups ([[Ring theory]]).", + ], + see: ["Prime number", "Euclidean algorithm", "Ring theory"], + }, + { + title: "Modular arithmetic", + tags: ["number theory", "algebra"], + intro: "**Modular arithmetic** does arithmetic on remainders modulo $n$: clocks, check digits, and the finite fields of coding theory. Written $a \\equiv b \\pmod n$ when $n$ divides $a - b$.", + tex: "a \\equiv b \\pmod{n} \\iff n \\mid (a - b), \\qquad \\mathbb{Z}/n\\mathbb{Z}", + facts: [ + "$(\\mathbb{Z}/n\\mathbb{Z})^{\\times}$ is the group of units modulo $n$; its size is Euler's totient $\\varphi(n)$ — a cousin of the [[Golden ratio]]'s namesake constant, unrelated in value.", + "Solving simultaneous congruences is the [[Chinese remainder theorem]].", + "Powers modulo $n$ compute in $O(\\log n)$ steps by repeated squaring — the workhorse of [[RSA]].", + ], + see: ["Fermat's little theorem", "Chinese remainder theorem", "Quotient group"], + }, + { + title: "Euclidean algorithm", + tags: ["number theory", "algorithms"], + intro: "The **Euclidean algorithm** computes the greatest common divisor of two integers by repeated division: $\\gcd(a,b) = \\gcd(b, a \\bmod b)$, terminating in $O(\\log)$ steps. It is arguably the oldest nontrivial algorithm still taught.", + tex: "\\gcd(a, b) = \\gcd(b,\\ a - \\lfloor a/b \\rfloor b), \\qquad \\gcd(a, 0) = a", + facts: [ + "The back-substitution version writes $\\gcd(a,b)$ as an integer combination $ax + by$ — Bézout's identity.", + "It proves unique factorisation of integers ([[Fundamental theorem of arithmetic]]) and extends to polynomial rings and $\\mathbb{Z}[i]$, the Gaussian integers.", + "Convergents of [[Continued fractions|continued fractions]] (see the [[Golden ratio]]) fall out of running the algorithm on consecutive Fibonacci numbers.", + ], + see: ["Fundamental theorem of arithmetic", "Modular arithmetic", "Prime number"], + }, + { + title: "Fermat's little theorem", + tags: ["number theory"], + intro: "**Fermat's little theorem**: if $p$ is prime and $p \\nmid a$, then $a^{p-1} \\equiv 1 \\pmod p$. A special case of [[Lagrange's theorem]] applied to the unit group modulo $p$ ([[Modular arithmetic]]).", + tex: "a^{p-1} \\equiv 1 \\pmod{p} \\quad (\\gcd(a,p)=1)", + facts: [ + "Equivalently $a^p \\equiv a \\pmod p$ for every $a$ — no coprimality needed.", + "The converse fails: pseudoprimes like 341 fool the test; Carmichael numbers fool it completely.", + "It generalises to Euler's theorem with $\\varphi(n)$ in place of $p-1$, the backbone of [[RSA]].", + ], + see: ["Modular arithmetic", "Prime number", "RSA"], + }, + { + title: "Chinese remainder theorem", + tags: ["number theory", "algebra"], + intro: "The **Chinese remainder theorem** says coprime moduli can be solved independently and recombined: congruences modulo $m$ and modulo $n$ amount to one congruence modulo $mn$.", + tex: "\\mathbb{Z}/mn\\mathbb{Z} \\ \\cong\\ \\mathbb{Z}/m\\mathbb{Z} \\times \\mathbb{Z}/n\\mathbb{Z} \\qquad (\\gcd(m,n)=1)", + facts: [ + "Constructively: glue residues with explicit coefficients from the [[Euclidean algorithm]].", + "Computer algebra splits big modular computations into small parallel ones and reassembles the answer.", + "Ring-theoretically it is the [[Quotient group|quotient]] statement $R/(IJ) \\cong R/I \\times R/J$ for comaximal ideals ([[Ring theory]]).", + ], + see: ["Modular arithmetic", "Euclidean algorithm", "Ring theory"], + }, + { + title: "Quadratic reciprocity", + tags: ["number theory"], + intro: "**Quadratic reciprocity** links the solvability of $x^2 \\equiv p \\pmod q$ and $x^2 \\equiv q \\pmod p$ for odd primes — \"the laws of nature are written in the language of primes\", as Gauss called it. He gave eight proofs.", + tex: "\\left(\\frac{p}{q}\\right)\\left(\\frac{q}{p}\\right) = (-1)^{\\frac{p-1}{2}\\frac{q-1}{2}}", + facts: [ + "The Legendre symbol $\\left(\\frac{a}{p}\\right)$ records whether $a$ is a square mod $p$; Euler's criterion computes it by exponentiation ([[Fermat's little theorem]]).", + "Supplementary laws cover $-1$ and $2$ as numerators.", + "It is the first case of the Langlands program, which recasts number theory as [[Representation theory]] of algebraic groups.", + ], + see: ["Modular arithmetic", "Prime number", "p-adic numbers"], + }, + { + title: "Prime number theorem", + tags: ["number theory", "analysis"], + intro: "The **prime number theorem** says the number of primes up to $x$ is asymptotic to $x / \\ln x$, and to the logarithmic integral. It describes the average density of [[Prime number|primes]], proved independently by Hadamard and de la Vallée Poussin in 1896 using [[Complex analysis]].", + tex: "\\pi(x) \\sim \\frac{x}{\\ln x}, \\qquad \\pi(x) = \\#\\{p \\le x\\}", + facts: [ + "Equivalently, the $n$-th prime is $\\sim n \\ln n$.", + "The error term in the approximation is controlled by the zeros of the [[Riemann zeta function]] — the [[Riemann hypothesis]] would give the best possible error.", + "An elementary (complex-free) proof was found by Selberg and Erdős in 1949.", + ], + see: ["Riemann zeta function", "Prime number", "Riemann hypothesis"], + }, + { + title: "Riemann zeta function", + tags: ["number theory", "analysis", "complex analysis"], + intro: "The **Riemann zeta function** $\\zeta(s) = \\sum n^{-s}$, continued to the complex plane ([[Analytic continuation]]), encodes the primes in its Euler product and its zeros.", + tex: "\\zeta(s) = \\sum_{n=1}^{\\infty} \\frac{1}{n^s} = \\prod_{p\\ \\mathrm{prime}} \\frac{1}{1 - p^{-s}}, \\qquad \\Re(s) > 1", + facts: [ + "The Euler product is the analytic form of the [[Fundamental theorem of arithmetic]].", + "$\\zeta$ satisfies a functional equation relating $s$ to $1-s$; $\\zeta(-n)$ values produce the Bernoulli numbers.", + "The nontrivial zeros govern the fluctuations in the [[Prime number theorem]]; where exactly they lie is the [[Riemann hypothesis]].", + ], + see: ["Riemann hypothesis", "Analytic continuation", "Prime number theorem"], + }, + { + title: "Riemann hypothesis", + tags: ["number theory", "conjectures"], + intro: "The **Riemann hypothesis** states that every nontrivial zero of $\\zeta$ ([[Riemann zeta function]]) has real part $\\tfrac12$. Posed in 1859, unproved, and the most famous open problem in mathematics.", + tex: "\\zeta(s) = 0,\\ \\text{nontrivial} \\implies \\Re(s) = \\tfrac{1}{2}", + facts: [ + "Equivalently, $\\pi(x) = \\operatorname{li}(x) + O(\\sqrt{x}\\ln x)$ — square-root-level noise in the [[Prime number theorem]].", + "Verified numerically for the first trillions of zeros, all on the critical line.", + "Hundreds of theorems begin \"assuming RH\" — from tighter prime gaps to bounds throughout multiplicative number theory ([[Analytic continuation]]'s domain).", + ], + see: ["Riemann zeta function", "Prime number theorem", "Complex analysis"], + }, + { + title: "Fermat's last theorem", + tags: ["number theory", "conjectures", "history"], + intro: "**Fermat's last theorem**: $x^n + y^n = z^n$ has no positive-integer solutions for $n > 2$. Fermat claimed a proof in the margin of his copy of Diophantus; Wiles supplied the real one in 1995.", + tex: "x^n + y^n = z^n \\ \\text{has no solution in } \\mathbb{Z}_{>0},\\quad n > 2", + facts: [ + "It suffices to prove $n = 4$ and odd prime exponents; Euler handled $n=3$, Fermat himself $n=4$ by infinite descent.", + "Wiles proved the modularity theorem for semistable [[Elliptic curves|elliptic curves]] — the Taniyama–Shimura–Weil conjecture in the case Ribet showed implies FLT.", + "The margin was 2.5 cm; the proof runs over a hundred pages ([[Modular arithmetic]]'s modest cousin).", + ], + see: ["Elliptic curves", "Prime number", "Modular arithmetic"], + }, + { + title: "Elliptic curves", + tags: ["number theory", "geometry", "algebra"], + intro: "An **elliptic curve** is a smooth cubic $y^2 = x^3 + ax + b$ with a marked point at infinity; its rational points form an abelian group ([[Abelian group]]). Central to [[Fermat's last theorem]], to the congruent number problem, and to modern cryptography.", + tex: "E : y^2 = x^3 + ax + b, \\qquad \\operatorname{disc} = -16(4a^3 + 27b^2) \\ne 0", + facts: [ + "The chord-and-tangent law makes $E(\\mathbb{Q})$ a finitely generated abelian group — Mordell–Weil.", + "Counting points of $E$ over finite fields ([[Modular arithmetic]]) gives $a_p$, assembled by Hasse–Weil into an $L$-function: modular according to Wiles et al.", + "Elliptic-curve cryptography (ECDH, ECDSA) is the other family besides [[RSA]]; side channels and 'bad curve' parameters are its practical weaknesses.", + ], + see: ["Fermat's last theorem", "Abelian group", "p-adic numbers"], + }, + { + title: "p-adic numbers", + tags: ["number theory", "analysis"], + intro: "The **$p$-adic numbers** $\\mathbb{Q}_p$ complete the rationals with respect to a notion of size where higher powers of $p$ are *small*: two numbers are close when their difference is divisible by a large power of $p$ ([[Modular arithmetic]] is arithmetic modulo finite powers; $\\mathbb{Q}_p$ is the limit).", + tex: "|x|_p = p^{-v_p(x)}, \\qquad |x - y|_p < \\varepsilon \\iff x \\equiv y \\pmod{p^N}", + facts: [ + "Ostrowski's theorem: the usual absolute value and the $p$-adic ones are the *only* ways to measure size on $\\mathbb{Q}$ compatibly.", + "$\\mathbb{Q}_p$ is a [[Metric space|complete metric space]] in which every triangle is isosceles — the ultrametric inequality replaces the ordinary one.", + "Local-global principles ([[Hasse principle]]) ask whether solutions over every $\\mathbb{Q}_p$ and $\\mathbb{R}$ glue to a solution over $\\mathbb{Q}$; failure is measured by class groups of [[Elliptic curves]].", + ], + see: ["Metric space", "Modular arithmetic", "Field theory"], + }, + { + title: "Linear algebra", + tags: ["linear algebra", "foundations"], + intro: "**Linear algebra** is the study of [[Vector space|vector spaces]] and the maps between them. Finite-dimensional problems reduce to matrices; infinite-dimensional ones ([[Functional analysis]]) keep the geometry and lose the matrices.", + tex: "T : V \\to W, \\qquad T(av + bw) = a\\,T(v) + b\\,T(w)", + facts: [ + "Rank–nullity: $\\dim V = \\operatorname{rank} T + \\operatorname{nullity} T$.", + "Every matrix factors as $U\\Sigma V^{*}$ — the [[Singular value decomposition]] — the workhorse of data science.", + "The [[Spectral theorem]] says symmetric/Hermitian operators have orthonormal eigenbases, making them diagonal [[Eigenvalues and eigenvectors|eigen-decomposable]].", + ], + see: ["Vector space", "Determinant", "Eigenvalues and eigenvectors"], + }, + { + title: "Vector space", + tags: ["linear algebra", "foundations"], + intro: "A **vector space** is a set of vectors addable and scalable over a field, subject to the usual laws. Functions, matrices, polynomials, and solutions of linear PDEs ([[Partial differential equations]]) are all vector spaces; [[Linear algebra]] is the study of them.", + tex: "V \\text{ over } F: \\quad u+v \\in V, \\quad \\alpha u \\in V, \\quad \\text{with basis } \\{e_i\\},\\ x = \\sum_i x_i e_i", + facts: [ + "Bases exist (the proof invokes the [[Axiom of choice]] in general), and any two bases of a given space have the same cardinality — the dimension.", + "The dual space $V^*$ consists of linear functionals; double duality $V \\cong V^{**}$ is canonical, $V \\cong V^*$ is not.", + "Quotient spaces $V/W$ mirror [[Quotient group|quotient groups]]: collapse a subspace, keep the structure.", + ], + see: ["Linear algebra", "Inner product space", "Banach space"], + }, + { + title: "Determinant", + tags: ["linear algebra"], + intro: "The **determinant** assigns to each square matrix a scalar measuring how its linear map scales volumes — and whether the map is invertible at all. Multilinearity and alternation pin it down completely.", + tex: "\\det(AB) = \\det A \\, \\det B, \\qquad A^{-1} = \\frac{1}{\\det A}\\,\\operatorname{adj}(A)", + facts: [ + "Laplace expansion computes by minors; the Leibniz formula sums over the [[Symmetric group|permutations]] with signs.", + "$\\det A = \\prod_i \\lambda_i$ — the product of [[Eigenvalues and eigenvectors|eigenvalues]] — and $\\operatorname{tr} A = \\sum_i \\lambda_i$.", + "Change of variables in integrals multiplies by the Jacobian's determinant — see [[Lebesgue integral|integration theory]].", + ], + see: ["Eigenvalues and eigenvectors", "Linear algebra", "Jordan normal form"], + }, + { + title: "Eigenvalues and eigenvectors", + tags: ["linear algebra", "spectral theory"], + intro: "An **eigenvector** of a linear map is a direction that the map only stretches: $Tv = \\lambda v$, with $\\lambda$ the **eigenvalue**. They are the coordinates in which linear dynamics ([[Ordinary differential equations|linear ODEs]], quantum mechanics, [[Chaos theory|stability]]) become scalar.", + tex: "T v = \\lambda v, \\qquad \\det(A - \\lambda I) = 0", + facts: [ + "The characteristic polynomial's roots are the eigenvalues; over $\\mathbb{C}$ every operator has at least one.", + "Symmetric/Hermitian operators have real eigenvalues and orthogonal eigenvectors: [[Spectral theorem]].", + "The spectral radius $\\rho(A)$ governs the growth of powers $A^k$ and thus the stability of iteration ([[Dynamical systems]]).", + ], + see: ["Spectral theorem", "Jordan normal form", "Singular value decomposition"], + }, + { + title: "Jordan normal form", + tags: ["linear algebra"], + intro: "The **Jordan normal form** is the best diagonalisation a non-diagonalisable matrix admits: a block-diagonal matrix with eigenvalues on the diagonal and ones on the superdiagonal. It classifies matrices up to [[Similarity (linear algebra)|conjugacy]] — the rational canonical form handles arbitrary fields.", + tex: "A = P J P^{-1}, \\qquad J = \\bigoplus_i \\begin{pmatrix} \\lambda_i & 1 & & \\\\ & \\lambda_i & \\ddots & \\\\ & & \\ddots & 1 \\\\ & & & \\lambda_i \\end{pmatrix}", + facts: [ + "A matrix is diagonalisable iff its minimal polynomial has no repeated roots ([[Field theory]]'s splitting machinery applies).", + "Jordan blocks are the unbreakable units of [[Linear algebra]] in the same sense primes are for [[Fundamental theorem of arithmetic|arithmetic]].", + "Matrix exponentials $e^{tJ}$ read off directly from Jordan form — the general solution of linear [[Ordinary differential equations|ODE systems]].", + ], + see: ["Eigenvalues and eigenvectors", "Determinant", "Ordinary differential equations"], + }, + { + title: "Singular value decomposition", + tags: ["linear algebra", "numerical analysis"], + intro: "The **singular value decomposition** $A = U \\Sigma V^{*}$ factors any matrix — rectangular included — into a rotation, a stretch by nonnegative **singular values**, and another rotation. It is the numerical analyst's favourite factorisation.", + tex: "A = U \\Sigma V^{*}, \\qquad \\sigma_1 \\ge \\sigma_2 \\ge \\cdots \\ge 0", + facts: [ + "The Eckart–Young theorem: truncating to the $k$ largest singular values gives the best rank-$k$ approximation — PCA, recommender systems, image compression.", + "Singular values are the square roots of [[Eigenvalues and eigenvectors|eigenvalues]] of $A^*A$.", + "The condition number $\\sigma_{\\max}/\\sigma_{\\min}$ predicts how much a linear solve amplifies round-off ([[Numerical analysis]]).", + ], + see: ["Eigenvalues and eigenvectors", "Numerical analysis", "Inner product space"], + }, + { + title: "Inner product space", + tags: ["linear algebra", "analysis"], + intro: "An **inner product space** is a [[Vector space]] with a notion of angle: $\\langle \\cdot, \\cdot \\rangle$ gives lengths via $\\|x\\| = \\sqrt{\\langle x, x \\rangle}$ and orthogonality via $\\langle x, y \\rangle = 0$. Completing one yields a [[Hilbert space]].", + tex: "\\langle x, y \\rangle = \\overline{\\langle y, x \\rangle}, \\qquad |\\langle x, y \\rangle| \\le \\|x\\|\\,\\|y\\| \\ \\text{(Cauchy–Schwarz)}", + facts: [ + "Gram–Schmidt turns any basis into an orthonormal one — the algorithmic heart of least squares and QR factorisation.", + "Parseval's identity: energy computed in coordinates equals energy computed as coefficients — the reason [[Fourier analysis]] works.", + "The Riesz representation theorem identifies a Hilbert space with its dual; the [[Spectral theorem]] lives on this stage.", + ], + see: ["Hilbert space", "Linear algebra", "Fourier analysis"], + }, + { + title: "Category theory", + tags: ["category theory", "foundations"], + intro: "**Category theory** abstracts mathematics to objects and arrows, recasting constructions — products, quotients, [[Quotient group|free objects]] — as universal properties. It is the grammar shared by [[Group theory]], [[Topological space|topology]] and logic.", + tex: "\\mathrm{Hom}_{\\mathcal{C}}(F X, Y) \\ \\cong\\ \\mathrm{Hom}_{\\mathcal{D}}(X, G Y)", + facts: [ + "Functors are maps between categories; natural transformations are maps between functors (see [[Functor|functors]]) — the famous slogan: 'categories are for objects, functors are for arrows, natural transformations are for...'.", + "Adjoint functors ($F \\dashv G$) occur everywhere: free groups, [[Tensor|tensor products]], quantifiers in logic.", + "Yoneda's lemma says an object is known completely by how everything maps into it.", + ], + see: ["Group homomorphism", "Set theory", "Topological space"], + }, + { + title: "Topological space", + tags: ["topology", "foundations"], + intro: "A **topological space** is a set with a distinguished family of open sets closed under arbitrary unions and finite intersections — continuity and closeness without distances. Every [[Metric space]] is one; every [[Manifold]] is one with local coordinates.", + tex: "\\varnothing, X \\in \\tau;\\quad \\bigcup_{i} U_i \\in \\tau, \\quad U_1 \\cap U_2 \\in \\tau", + facts: [ + "A map is continuous when preimages of opens are open — a definition that works where no metric does.", + "Separation axioms ([[Hausdorff space|Hausdorff]] and friends) say how well points and closed sets can be told apart.", + "Compactness ([[Compact space]]) and connectedness ([[Connected space]]) are the two properties everything splits into.", + ], + see: ["Metric space", "Compact space", "Connected space"], + }, + { + title: "Compact space", + tags: ["topology"], + intro: "A **compact space** is one where every open cover has a finite subcover — the topological distillation of 'closed and bounded'. In metric spaces it is sequential compactness; in $\\mathbb{R}^n$ it is Heine–Borel.", + tex: "X = \\bigcup_{i} U_i \\implies X = U_{i_1} \\cup \\cdots \\cup U_{i_n}", + facts: [ + "Continuous images of compact spaces are compact: the extreme value theorem is one corollary.", + "Tychonoff's theorem — arbitrary products of compact spaces are compact — is equivalent to the [[Axiom of choice]].", + "Compactness turns pointwise results into uniform ones: a continuous function on a compact space is uniformly continuous.", + ], + see: ["Topological space", "Hausdorff space", "Metric space"], + }, + { + title: "Connected space", + tags: ["topology"], + intro: "A **connected space** cannot be split into two disjoint nonempty open parts. Path-connectedness — joining any two points by a path — implies connectedness, and for open sets in $\\mathbb{R}^n$ the two agree. [[Fractals]] test intuitions: the topologist's sine curve is connected but not path-connected.", + tex: "X = A \\sqcup B,\\ A,B\\ \\text{open} \\implies A = \\varnothing \\text{ or } B = \\varnothing", + facts: [ + "Continuous images of connected sets are connected — the intermediate value theorem restated.", + "Connected components partition every space; path components may be finer.", + "The [[Fundamental group]] only sees one connected piece at a time.", + ], + see: ["Topological space", "Metric space", "Fundamental group"], + }, + { + title: "Hausdorff space", + tags: ["topology"], + intro: "A **Hausdorff space** (T₂) separates any two distinct points by disjoint open neighbourhoods. It is the mildest separation condition that most analysis needs — limits are unique exactly when it holds.", + tex: "x \\ne y \\implies \\exists\\, U \\ni x,\\ V \\ni y, \\quad U \\cap V = \\varnothing", + facts: [ + "Compact subsets of Hausdorff spaces are closed; compact Hausdorff spaces are the well-behaved core of [[Functional analysis]] via $C(X)$ and Riesz–Markov measures.", + "Quotients can destroy Hausdorffness even when the original space is fine — [[Quotient group|gluing]] needs closed equivalence relations.", + "Metrisable spaces are Hausdorff; the converse needs paracompactness ([[Topological space]]'s finer machinery).", + ], + see: ["Topological space", "Compact space", "Metric space"], + }, + { + title: "Metric space", + tags: ["topology", "analysis"], + intro: "A **metric space** is a set with a distance function: positive, symmetric, and obeying the triangle inequality. Completeness ([[Banach space]]) and [[Compact space|compactness]] are defined through it, and its open balls generate a [[Topological space|topology]].", + tex: "d(x,z) \\le d(x,y) + d(y,z), \\qquad d(x,y) = 0 \\iff x = y", + facts: [ + "The Banach fixed-point theorem: a contraction on a complete metric space has a unique fixed point — proofs of ODE existence ([[Ordinary differential equations]]) run on it.", + "Ultrametrics ([[p-adic numbers]]) replace the triangle inequality with $d(x,z) \\le \\max(d(x,y), d(y,z))$: every triangle is isosceles.", + "Kuratowski's embedding puts any metric space into a space of bounded functions — see [[Banach space]].", + ], + see: ["Topological space", "Banach space", "Brouwer fixed-point theorem"], + }, + { + title: "Fundamental group", + tags: ["topology", "algebra"], + intro: "The **fundamental group** $\\pi_1(X, x_0)$ records loops up to continuous deformation, with concatenation as product. It is the first and most geometric of the algebraic invariants — [[Homology group|homology]] generalises it to higher dimensions.", + tex: "\\pi_1(S^1) \\cong \\mathbb{Z}, \\qquad \\pi_1(S^n) = 0 \\ (n \\ge 2)", + facts: [ + "Homotopy invariance: deforming the space does not change $\\pi_1$ — hence the name topological invariant.", + "$\\pi_1$ of a graph is a free group, rank $1 - V + E$ — [[Graph theory]] meets [[Group theory]].", + "The Poincaré conjecture — a closed 3-manifold with trivial $\\pi_1$ is the 3-sphere — was proved by Perelman in 2003 via Ricci flow ([[Riemannian geometry]]).", + ], + see: ["Homology group", "Covering space", "Manifold"], + }, + { + title: "Covering space", + tags: ["topology"], + intro: "A **covering space** $\\tilde X \\to X$ is a space that maps onto $X$ like a local copy in evenly spread sheets: $\\mathbb{R} \\to S^1$ by $t \\mapsto e^{2\\pi i t}$. Coverings are classified by subgroups of the [[Fundamental group]].", + tex: "p : \\tilde{X} \\to X, \\qquad \\exists\\, U \\ni x :\\ p^{-1}(U) = \\bigsqcup_{\\alpha} V_\\alpha,\\quad p|_{V_\\alpha} \\cong U", + facts: [ + "The universal cover is simply connected and its deck transformations form $\\pi_1(X)$.", + "Analytic continuation along loops ([[Analytic continuation]]) is monodromy — covering-space language in disguise.", + "Galois connections between subgroup lattices mirror classification of coverings — see [[Galois theory]].", + ], + see: ["Fundamental group", "Topological space", "Analytic continuation"], + }, + { + title: "Homology group", + tags: ["topology", "algebra"], + intro: "**Homology groups** count holes: cycles that bound nothing. Defined from a chain complex $C_{n+1} \\xrightarrow{\\partial_{n+1}} C_n \\xrightarrow{\\partial_n} C_{n-1}$ with $\\partial^2 = 0$, they are abelian groups ([[Abelian group]]) — easier than the [[Fundamental group]] and computable from a triangulation.", + tex: "H_n(X) = \\ker \\partial_n \\,/\\, \\operatorname{im} \\partial_{n+1}", + facts: [ + "$H_0$ counts connected components ([[Connected space]]), $H_1$ counts independent loops, and for surfaces the Euler characteristic $\\chi = \\sum (-1)^n \\operatorname{rank} H_n$ recovers [[Euler characteristic]].", + "The abelianisation of $\\pi_1$ is $H_1$ (Hurewicz).", + "Persistent homology runs this on point clouds at many scales — topology in data analysis.", + ], + see: ["Fundamental group", "Euler characteristic", "de Rham cohomology"], + }, + { + title: "Euler characteristic", + tags: ["topology", "geometry"], + intro: "The **Euler characteristic** $\\chi = V - E + F$ is a topological invariant of a surface or complex: 2 for the sphere, 0 for the torus, $2 - 2g$ for the genus-$g$ surface. Euler spotted its invariance long than topology existed.", + tex: "\\chi = V - E + F = \\sum_{n} (-1)^n \\operatorname{rank} H_n(X)", + facts: [ + "The Gauss–Bonnet theorem ([[Gauss-Bonnet theorem]]) ties $\\chi$ to total curvature.", + "Triangulate a map on the sphere and you get $V - E + F = 2$ — the seed of the [[Four color theorem]].", + "For a [[Manifold]] of odd dimension, $\\chi = 0$.", + ], + see: ["Homology group", "Gauss-Bonnet theorem", "Four color theorem"], + }, + { + title: "Brouwer fixed-point theorem", + tags: ["topology", "analysis"], + intro: "**Brouwer's fixed-point theorem**: every continuous map of a closed ball to itself has a fixed point. Stirring a cup of coffee always leaves at least one point of liquid where it started.", + tex: "f : D^n \\to D^n \\ \\text{continuous} \\implies \\exists\\, x,\\ f(x) = x", + facts: [ + "Equivalent to the no-retraction theorem: the ball cannot be continuously retracted onto its sphere — which follows from the [[Fundamental group|algebra]] $\\pi_1(S^1) \\cong \\mathbb{Z}$.", + "It underlies the Nash equilibrium theorem and the standard existence proofs in general equilibrium theory.", + "The infinite-dimensional version fails outright for the norm topology; Schauder's theorem rescues it with compactness ([[Compact space]]) — see [[Functional analysis]].", + ], + see: ["Topological space", "Functional analysis", "Metric space"], + }, + { + title: "Manifold", + tags: ["geometry", "topology"], + intro: "A **manifold** is a space that looks locally like $\\mathbb{R}^n$: a [[Topological space]] covered by coordinate charts that overlap smoothly. The sphere, spacetime, configuration spaces of robots — all manifolds; the place where calculus ([[Real analysis]]) generalises to geometry.", + tex: "\\exists\\, \\text{charts } \\varphi_\\alpha : U_\\alpha \\xrightarrow{\\sim} \\mathbb{R}^n, \\qquad \\varphi_\\beta \\circ \\varphi_\\alpha^{-1} \\in C^\\infty", + facts: [ + "Dimension is an invariant of a connected manifold (invariance of domain).", + "Tangent vectors, vector fields and [[Differential forms|forms]] are defined chart-invariantly via the tangent bundle.", + "Classification of surfaces: closed connected surfaces are spheres with handles — classified by genus and orientability, matching [[Euler characteristic]].", + ], + see: ["Topological space", "Differential geometry", "Fundamental group"], + }, + { + title: "Differential geometry", + tags: ["geometry"], + intro: "**Differential geometry** uses calculus on [[Manifold|manifolds]]: curves, surfaces, and their bending measured by connections and curvature. Gauss and Riemann built it; Einstein used it for general relativity.", + tex: "\\kappa = \\left\\| \\frac{d\\mathbf{T}}{ds} \\right\\|, \\qquad \\text{curvature of a space curve}", + facts: [ + "Gauss's Theorema Egregium: Gaussian curvature of a surface is intrinsic — measurable without leaving the surface ([[Curvature]]).", + "Geodesics are locally length-minimising curves; the calculus of variations produces them ([[Calculus of variations]] — the variational structure).", + "[[Differential forms]] and the exterior derivative give a coordinate-free version of vector calculus on manifolds.", + ], + see: ["Manifold", "Riemannian geometry", "Curvature"], + }, + { + title: "Riemannian geometry", + tags: ["geometry"], + intro: "**Riemannian geometry** equips manifolds with a smoothly varying inner product on tangent spaces — a metric tensor measuring lengths and angles. Curvature of the metric is the subject; general relativity is its physics application.", + tex: "ds^2 = g_{ij}\\, dx^i dx^j, \\qquad \\text{length} = \\int \\sqrt{g_{ij} \\dot x^i \\dot x^j}\\, dt", + facts: [ + "The Levi-Civita connection is the unique torsion-free connection preserving the metric.", + "Ricci flow $\\partial_t g = -2\\,\\mathrm{Ric}$ evolves metrics — Perelman's tool for the Poincaré conjecture ([[Fundamental group]]'s crown).", + "Positive curvature is rigid in surprising ways: Bonnet–Myers says compact $\\Rightarrow$ finite $\\pi_1$ ([[Fundamental group]]).", + ], + see: ["Curvature", "Manifold", "Differential geometry"], + }, + { + title: "Curvature", + tags: ["geometry"], + intro: "**Curvature** measures how far a space is from being flat. For surfaces, Gaussian curvature $K = \\kappa_1 \\kappa_2$ is the product of principal curvatures — positive at a dome, negative at a saddle, zero on a cylinder.", + tex: "K = \\kappa_1 \\kappa_2, \\qquad \\text{flat} \\iff R^{\\rho}_{\\ \\sigma\\mu\\nu} \\equiv 0", + facts: [ + "Gauss's Theorema Egregium: $K$ is intrinsic — determined by the first fundamental form alone, so ants can measure it ([[Riemannian geometry]]).", + "Integrating curvature over a closed surface gives $2\\pi\\chi$ — the [[Gauss-Bonnet theorem]].", + "Sectional curvariants generalise $K$; Ricci and scalar curvature are its contractions ([[Riemannian geometry]]).", + ], + see: ["Gauss-Bonnet theorem", "Riemannian geometry", "Differential geometry"], + }, + { + title: "Gauss-Bonnet theorem", + tags: ["geometry", "topology"], + intro: "The **Gauss–Bonnet theorem** equates geometry and topology: the total Gaussian curvature of a closed surface is $2\\pi$ times its Euler characteristic — no matter how you bend it.", + tex: "\\int_M K \\, dA = 2\\pi \\chi(M)", + facts: [ + "A crumpled, bent, or wrinkled sphere still has total curvature $4\\pi$ — [[Curvature]] is flexible, $\\chi$ ([[Euler characteristic]]) is not.", + "The discrete version applies to polyhedra, giving Descartes' angular-defect formula.", + "It is a 2D case of the Chern–Gauss–Bonnet theorem and, far higher, of the index theorem ([[de Rham cohomology]]'s analytic cousins).", + ], + see: ["Curvature", "Euler characteristic", "Riemannian geometry"], + }, + { + title: "Non-Euclidean geometry", + tags: ["geometry", "foundations", "history"], + intro: "**Non-Euclidean geometry** drops Euclid's parallel postulate: through a point off a line, many parallels (hyperbolic) or none (elliptic). Its discovery by Gauss, Bolyai, and Lobachevsky shook the idea that geometry is a priori knowledge — see [[Pythagorean theorem]] for the flat case.", + tex: "\\text{hyperbolic: } \\frac{x^2 + y^2}{(1 - r^2)^2} \\quad \\text{Poincaré disk metric}", + facts: [ + "Hyperbolic triangles have angle sum strictly less than $\\pi$; the deficit is proportional to area.", + "Consistency of hyperbolic geometry (Beltrami–Klein–Poincaré models) settled the 2000-year-old parallel-postulate problem ([[Set theory]]'s consistency questions foreshadowed).", + "M. C. Escher's Circle Limit prints are honest hyperbolic tilings.", + ], + see: ["Pythagorean theorem", "Manifold", "Riemannian geometry"], + }, + { + title: "Differential forms", + tags: ["geometry", "analysis"], + intro: "**Differential forms** are antisymmetric tensor fields that can be integrated: $dx$, $x\\,dy \\wedge dz$. The exterior derivative $d$ unifies gradient, curl, and divergence, and $d^2 = 0$ is the source of all the vector-calculus identities.", + tex: "d(\\omega \\wedge \\eta) = d\\omega \\wedge \\eta + (-1)^{\\deg \\omega} \\omega \\wedge d\\eta, \\qquad d^2 = 0", + facts: [ + "Stokes' theorem $\\int_M d\\omega = \\int_{\\partial M} \\omega$ is the fundamental theorem of calculus ([[Fundamental theorem of calculus]]) in full dress.", + "Curl-free $\\Rightarrow$ exact questions are measured by [[de Rham cohomology]].", + "Forms are the natural language of symplectic geometry and of electromagnetism ($F$, $dF = 0$, $d{*}F = J$).", + ], + see: ["de Rham cohomology", "Manifold", "Fundamental theorem of calculus"], + }, + { + title: "de Rham cohomology", + tags: ["geometry", "topology"], + intro: "**de Rham cohomology** computes topological holes from analysis: $H^k_{dR}(M)$ is the space of closed $k$-forms modulo exact ones ([[Differential forms|forms]], $d^2=0$). The Poincaré lemma says locally every closed form is exact.", + tex: "H^k_{dR}(M) = \\ker(d : \\Omega^k \\to \\Omega^{k+1}) \\,/\\, \\operatorname{im}(d : \\Omega^{k-1} \\to \\Omega^k)", + facts: [ + "de Rham's theorem: $H^k_{dR}(M) \\cong H^k_{\\mathrm{sing}}(M; \\mathbb{R})$ — analysis recovers topology ([[Homology group]]).", + "$\\dim H^k_{dR}$ are the Betti numbers, assembling into $\\chi$ ([[Euler characteristic]]).", + "Hodge theory supplies exactly one harmonic representative per class on a compact Riemannian manifold ([[Riemannian geometry]], [[Hilbert space]] methods).", + ], + see: ["Differential forms", "Homology group", "Hilbert space"], + }, + { + title: "Fractals", + tags: ["geometry", "dynamical systems"], + intro: "**Fractals** are sets with detail at every scale and dimension in the fractional sense: the Hausdorff dimension of the Cantor set is $\\log 2 / \\log 3 \\approx 0.63$. Mandelbrot named them; [[Chaos theory]] made them physics.", + tex: "\\dim_H(\\text{Cantor}) = \\frac{\\log 2}{\\log 3}, \\qquad \\dim_H(\\text{Koch}) = \\frac{\\log 4}{\\log 3} \\approx 1.26", + facts: [ + "Self-similarity: the set is a union of scaled copies of itself; iterated function systems generate exactly these.", + "The Mandelbrot set — connected components of $c$ for which $z_{n+1} = z_n^2 + c$ stays bounded — has fractal boundary, yet is conjecturally locally connected.", + "Boundaries of basins of attraction in the [[Logistic map]]'s chaotic regime and Julia sets are fractals in action.", + ], + see: ["Chaos theory", "Logistic map", "Metric space"], + }, + { + title: "Real analysis", + tags: ["analysis", "foundations"], + intro: "**Real analysis** is rigorous calculus on the real line: limits, continuity, differentiation, integration, built on the completeness of $\\mathbb{R}$. It is where [[Sequence|sequences]] and [[Series|series]] are handled honestly and where the [[Fundamental theorem of calculus]] is proved.", + tex: "\\sup S \\in \\mathbb{R} \\ \\text{for every bounded nonempty } S \\quad \\text{(least upper bound property)}", + facts: [ + "Completeness distinguishes $\\mathbb{R}$ from $\\mathbb{Q}$ and rescues fixed-point and existence arguments ([[Metric space]]).", + "Uniform vs pointwise convergence is the great dividing line: only uniform convergence preserves continuity and integrals ([[Series]]).", + "Its limitations pushed integration to [[Measure theory]] and the [[Lebesgue integral]].", + ], + see: ["Sequence", "Series", "Metric space"], + }, + { + title: "Sequence", + tags: ["analysis", "sequences"], + intro: "A **sequence** is a function from the natural numbers; its study is the first level of [[Real analysis]]. Convergence means the terms eventually stay within any tolerance; a sequence that would converge if only the terms moved less is Cauchy, and completeness guarantees such terms do move less.", + tex: "\\lim_{n \\to \\infty} a_n = L \\iff \\forall \\varepsilon > 0,\\ \\exists N,\\ n > N \\Rightarrow |a_n - L| < \\varepsilon", + facts: [ + "Monotone + bounded $\\Rightarrow$ convergent (monotone convergence theorem).", + "Bolzano–Weierstrass: every bounded sequence has a convergent subsequence — compactness in $\\mathbb{R}$ ([[Compact space]]).", + "The Fibonacci sequence's ratios tend to the [[Golden ratio]].", + ], + see: ["Series", "Real analysis", "Golden ratio"], + }, + { + title: "Series", + tags: ["analysis", "sequences"], + intro: "An **infinite series** $\\sum a_n$ is the limit of its partial sums. Convergence tests — comparison, ratio, root, integral — are the day-to-day toolkit; absolute convergence lets you reorder terms, conditional convergence does not (Riemann's rearrangement theorem).", + tex: "\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}, \\qquad \\sum_{n=0}^{\\infty} x^n = \\frac{1}{1-x},\\ |x| < 1", + facts: [ + "The harmonic series diverges; the $p$-series converges exactly for $p > 1$ ([[Riemann zeta function]]).", + "Power series converge inside a disc of radius $R = 1/\\limsup |a_n|^{1/n}$ ([[Cauchy–Hadamard]]) and are holomorphic there ([[Complex analysis]]).", + "Alternating conditionally convergent series can be rearranged to any sum — see [[Series]] rearrangement, the theorem.", + ], + see: ["Sequence", "Taylor series", "Riemann zeta function"], + }, + { + title: "Taylor series", + tags: ["analysis", "sequences", "calculus"], + intro: "The **Taylor series** of $f$ at $a$ is the power series built from the derivatives of $f$: $\\sum f^{(n)}(a)(x-a)^n / n!$. Smooth functions need not equal their Taylor series ([[Real analysis]]'s caution), but analytic functions — [[Complex analysis]]'s stock — always do.", + tex: "f(x) = \\sum_{n=0}^{\\infty} \\frac{f^{(n)}(a)}{n!} (x - a)^n, \\qquad e^x = \\sum_{n=0}^{\\infty} \\frac{x^n}{n!}", + facts: [ + "Taylor's theorem bounds the remainder: $R_n = O((x-a)^{n+1})$ with the Lagrange form.", + "$e^{i\\theta} = \\cos\\theta + i \\sin\\theta$ by comparing Taylor series — the proof of [[Euler's identity]].", + "Analytic continuation ([[Analytic continuation]]) grows a holomorphic function from one Taylor series outward.", + ], + see: ["Series", "Derivative", "Complex analysis"], + }, + { + title: "Banach space", + tags: ["analysis", "functional analysis"], + intro: "A **Banach space** is a complete [[Normed space|normed vector space]] — distance without inner product, complete ([[Metric space]]). Function spaces of analysis live here; [[Hilbert space|Hilbert spaces]] are the inner-product subclasses.", + tex: "\\|x + y\\| \\le \\|x\\| + \\|y\\|, \\qquad \\text{every Cauchy sequence converges}", + facts: [ + "The big existence theorems: Hahn–Banach (extend functionals), open mapping, closed graph, uniform boundedness.", + "Compactness is rare in infinite dimensions — the closed unit ball is compact iff the space is finite-dimensional (Riesz's lemma).", + "$L^p$ spaces ($1 \\le p \\le \\infty$) built on the [[Lebesgue integral]] are Banach; $L^2$ is the prototypical [[Hilbert space]].", + ], + see: ["Hilbert space", "Functional analysis", "Metric space"], + }, + { + title: "Hilbert space", + tags: ["analysis", "functional analysis"], + intro: "A **Hilbert space** is a complete [[Inner product space]]: infinite-dimensional Euclidean geometry. Quantum states live in one; so do square-integrable functions of [[Fourier analysis]].", + tex: "\\langle f, g \\rangle = \\int f \\overline{g}, \\qquad \\|f\\|^2 = \\sum_n |\\langle f, e_n \\rangle|^2 \\ \\text{(Parseval)}", + facts: [ + "Orthogonal projections onto closed subspaces exist and are unique — least-squares in infinite dimensions.", + "Riesz representation: every continuous linear functional is an inner product with a unique vector.", + "Orthonormal bases replace coordinates; separable infinite-dimensional Hilbert spaces are all $L^2$ ([[Lebesgue integral]]) — see [[Banach space]].", + ], + see: ["Inner product space", "Fourier analysis", "Spectral theorem"], + }, + { + title: "Measure theory", + tags: ["analysis"], + intro: "**Measure theory** assigns sizes to sets consistently: a measure is countably additive on disjoint unions. It fixes the Riemann integral's bad exchange of limits and integrals and gives probability its axioms ([[Probability theory]]).", + tex: "\\mu\\!\\left(\\bigsqcup_{i=1}^{\\infty} A_i\\right) = \\sum_{i=1}^{\\infty} \\mu(A_i)", + facts: [ + "Lebesgue measure on $\\mathbb{R}^n$ extends length/area/volume to a huge $\\sigma$-algebra, but not to all sets (Vitali sets need the [[Axiom of choice]]).", + "The dominated and monotone convergence theorems make exchanging limits and integrals routine ([[Real analysis]]'s fix).", + "Hausdorff dimension is a measure-theoretic notion ([[Fractals]]).", + ], + see: ["Lebesgue integral", "Probability theory", "Real analysis"], + }, + { + title: "Lebesgue integral", + tags: ["analysis"], + intro: "The **Lebesgue integral** integrates by slicing the *range* of a function rather than its domain — $\\int f = \\int_0^\\infty \\mu\\{f > t\\}\\,dt$ in one view. Where Riemann integrates over $x$-intervals, Lebesgue integrates over level sets ([[Measure theory]]).", + tex: "\\int f\\, d\\mu = \\sup\\left\\{ \\int s\\, d\\mu : 0 \\le s \\le f,\\ s\\ \\text{simple} \\right\\}", + facts: [ + "It strictly extends the Riemann integral: the Dirichlet function (indicator of $\\mathbb{Q}$) integrates to 0.", + "Completeness of $L^1$, $L^2$ ([[Hilbert space]]) under the integral norm is what makes Fourier theory work ([[Fourier analysis]]).", + "Change of variables pushes measures, not just intervals ([[Determinant]]'s Jacobian).", + ], + see: ["Measure theory", "Hilbert space", "Fourier analysis"], + }, + { + title: "Functional analysis", + tags: ["analysis"], + intro: "**Functional analysis** studies infinite-dimensional [[Banach space|space]]s of functions and the operators between them — analysis seen from above. Quantum mechanics, PDE theory ([[Partial differential equations]]) and approximation theory all speak it.", + tex: "T : X \\to Y \\ \\text{bounded} \\iff \\|T\\| = \\sup_{\\|x\\| \\le 1} \\|Tx\\| < \\infty", + facts: [ + "Hahn–Banach, open mapping, and closed graph are the three theorems every proof eventually cites.", + "Weak topologies restore compactness ([[Compact space]]) where norm compactness fails.", + "The [[Spectral theorem]] for self-adjoint operators is its central structural result.", + ], + see: ["Banach space", "Hilbert space", "Spectral theorem"], + }, + { + title: "Spectral theorem", + tags: ["analysis", "linear algebra"], + intro: "The **spectral theorem** says self-adjoint (Hermitian) operators are diagonalisable by an orthonormal [[Eigenvalues and eigenvectors|eigenbasis]], and in infinite dimensions with a projection-valued measure replacing the sum of projections. Symmetric matrices, position and momentum in quantum mechanics, [[Fourier analysis]] — one theorem.", + tex: "A = \\sum_i \\lambda_i\\, |e_i\\rangle\\langle e_i| \\quad (\\text{finite dim}), \\qquad A = \\int \\lambda\\, dE_\\lambda \\quad (\\infty\\text{-dim})", + facts: [ + "Normal matrices ($AA^* = A^*A$) are exactly the unitarily diagonalisable ones.", + "Compact self-adjoint operators have real eigenvalues with finite multiplicities accumulating only at 0.", + "It underlies PCA ([[Singular value decomposition]]) and the Stone theorem linking unitary groups to self-adjoint generators ([[Ordinary differential equations|flows]]).", + ], + see: ["Eigenvalues and eigenvectors", "Hilbert space", "Functional analysis"], + }, + { + title: "Complex analysis", + tags: ["complex analysis", "foundations"], + intro: "**Complex analysis** is the theory of differentiable functions of a complex variable — and differentiable once with continuous derivative is infinitely differentiable and analytic ([[Taylor series]]). The rigidity is the subject.", + tex: "f'(z_0) = \\lim_{h \\to 0} \\frac{f(z_0 + h) - f(z_0)}{h} \\quad (h \\in \\mathbb{C}), \\qquad \\frac{\\partial f}{\\partial \\bar z} = 0", + facts: [ + "Holomorphic functions satisfy the Cauchy–Riemann equations and are automatically conformal where $f' \\ne 0$ ([[Conformal map]]).", + "A bounded entire function is constant (Liouville) — a one-line proof of the fundamental theorem of algebra.", + "The [[Cauchy integral theorem]] and its residue machinery ([[Residue theorem]]) compute real integrals and count zeros.", + ], + see: ["Cauchy integral theorem", "Analytic continuation", "Taylor series"], + }, + { + title: "Cauchy integral theorem", + tags: ["complex analysis"], + intro: "**Cauchy's integral theorem**: the integral of a holomorphic function over a closed curve in a simply connected domain is zero — the integrand has a primitive, and path independence rules. The [[Fundamental theorem of calculus]] becomes geometry here.", + tex: "\\oint_\\gamma f(z)\\, dz = 0 \\qquad (f \\text{ holomorphic on simply connected domain},\\ \\gamma \\text{ closed})", + facts: [ + "Cauchy's integral formula $f(a) = \\frac{1}{2\\pi i} \\oint \\frac{f(z)}{z-a}dz$ recovers derivatives from boundary values — holomorphic functions are determined by their boundary ([[Analytic continuation]]).", + "Homotopy and winding numbers decide which closed curves make the integral vanish ([[Covering space]]'s cousin: $\\mathbb{C}^* \\simeq S^1$).", + "It is the seed of [[Residue theorem]]-powered computation.", + ], + see: ["Residue theorem", "Complex analysis", "Fundamental theorem of calculus"], + }, + { + title: "Residue theorem", + tags: ["complex analysis"], + intro: "The **residue theorem** evaluates closed contour integrals as $2\\pi i$ times the sum of residues at enclosed poles — singularities are bookkeeping units of integration. It is the workhorse that turns real improper integrals into algebra.", + tex: "\\oint_\\gamma f(z)\\,dz = 2\\pi i \\sum_{z_k \\in \\mathrm{int}(\\gamma)} \\operatorname{Res}(f, z_k)", + facts: [ + "At a simple pole, $\\operatorname{Res} = \\lim_{z \\to a}(z-a) f(z)$; the coefficient of $(z-a)^{-1}$ in the Laurent series.", + "It proves $\\sum 1/n^2 = \\pi^2/6$ by integrating $\\pi\\cot(\\pi z)/z^2$ ([[Series]]).", + "The argument principle ($\\frac{1}{2\\pi i}\\oint f'/f$ counts zeros minus poles) is the residue theorem counting — Rouché's theorem follows, and with it the open mapping theorem ([[Complex analysis]]).", + ], + see: ["Cauchy integral theorem", "Complex analysis", "Series"], + }, + { + title: "Analytic continuation", + tags: ["complex analysis"], + intro: "**Analytic continuation** extends a holomorphic function beyond its original domain by overlapping [[Taylor series]], and along paths by chains of discs. Monodromy — dependence on the path — is measured by coverings ([[Covering space]]) and explains $\\log$'s multi-valuedness.", + tex: "\\zeta(s) = 2^s \\pi^{s-1} \\sin\\!\\left(\\frac{\\pi s}{2}\\right) \\Gamma(1-s) \\zeta(1-s)", + facts: [ + "The continuation is unique where the domain is connected — identity theorem.", + "The functional equation above continues the [[Riemann zeta function]] meromorphically to the whole plane with one pole at $s=1$.", + "Natural boundaries (e.g. $\\sum z^{n!}$) are curves continuation cannot cross.", + ], + see: ["Complex analysis", "Riemann zeta function", "Covering space"], + }, + { + title: "Riemann mapping theorem", + tags: ["complex analysis"], + intro: "The **Riemann mapping theorem**: any simply connected proper open subset of $\\mathbb{C}$ is conformally the unit disc. One map, unique up to rotation, straightens any blob ([[Conformal map]], [[Complex analysis]]).", + tex: "\\exists!\\ f : \\Omega \\xrightarrow{\\ \\sim\\ } \\mathbb{D}, \\qquad f(z_0) = 0,\\ f'(z_0) > 0", + facts: [ + "Conformal maps preserve angles and solve Laplace's equation by transport — fluid flow and electrostatics in 2D ([[Partial differential equations]]).", + "Pompeiu and Koebe variants and the Carathéodory extension (boundary correspondence) deepen it.", + "Poincaré's analogue fails in several complex variables: the ball and the polydisc are biholomorphically distinct.", + ], + see: ["Conformal map", "Complex analysis", "Partial differential equations"], + }, + { + title: "Conformal map", + tags: ["complex analysis", "geometry"], + intro: "A **conformal map** preserves angles: holomorphic with nonzero derivative. In two dimensions conformal invariance is an embarrassment of structure — [[Riemann mapping theorem|Riemann mapping]], [[Complex analysis]]-powered solutions of boundary-value problems.", + tex: "w = f(z), \\ f'(z) \\ne 0 \\implies \\text{angles preserved}, \\quad \\Delta_u = |f'|^2 \\, (\\Delta v) \\circ f", + facts: [ + "The Möbius transformations $z \\mapsto (az+b)/(cz+d)$ are the conformal automorphisms of the Riemann sphere; they act transitively on triples of points.", + "The upper half-plane $\\mathbb{H}$ model of hyperbolic geometry ([[Non-Euclidean geometry]]) is conformal: angles hyperbolic = Euclidean.", + "Schwarz–Christoffel maps polygons to discs by explicit integrals — the tool behind mesh generation ([[Numerical analysis]]).", + ], + see: ["Riemann mapping theorem", "Complex analysis", "Non-Euclidean geometry"], + }, + { + title: "Fourier analysis", + tags: ["analysis", "harmonic analysis"], + intro: "**Fourier analysis** decomposes functions into pure frequencies: $\\hat f(\\xi) = \\int f(x) e^{-2\\pi i x \\xi} dx$. Smoothness of $f$ is decay of $\\hat f$; convolution becomes multiplication; differentiation becomes multiplication by $\\xi$ ([[Ordinary differential equations|solving the heat equation]]).", + tex: "f(x) = \\sum_{n=-\\infty}^{\\infty} c_n e^{2\\pi i n x}, \\qquad c_n = \\int_0^1 f(x) e^{-2\\pi i n x}\\, dx", + facts: [ + "On $L^2$ the exponentials form an orthonormal [[Hilbert space|basis]] — Parseval says energy is conserved in frequency.", + "The uncertainty principle: $f$ and $\\hat f$ cannot both be sharply concentrated ([[Probability theory]]'s Gaussians are the optimisers, see [[Normal distribution]]).", + "Representation theory of abelian groups ([[Abelian group]], circle group characters) is Fourier analysis abstracted; nonabelian Fourier analysis uses [[Representation theory|matrix coefficients]].", + ], + see: ["Hilbert space", "Normal distribution", "Representation theory"], + }, + { + title: "Set theory", + tags: ["set theory", "foundations"], + intro: "**Set theory** is the foundation mathematics is usually formalised on: everything is a set, membership is the only relation. It also has substantive content — cardinalities ([[Cardinal number]]), the [[Axiom of choice]], and the independence phenomena born with Gödel and Cohen.", + tex: "x \\in A, \\qquad \\varnothing, \\quad A \\cup B, \\quad \\mathcal{P}(A), \\quad \\bigcup A", + facts: [ + "Cantor's diagonal argument: $|A| < |\\mathcal{P}(A)|$ — there is no largest infinity.", + "The continuum hypothesis ($\\aleph_1 = 2^{\\aleph_0}$?) is independent of ZFC: Gödel showed consistency with choice + CH, Cohen by forcing showed consistency of its negation ([[Axiom of choice]]).", + "Well-orderings, ordinals and transfinite induction organise infinite sets — [[Ordinals|ordinals]].", + ], + see: ["Axiom of choice", "Cardinal number", "Category theory"], + }, + { + title: "Ordinary differential equations", + tags: ["differential equations"], + intro: "An **ordinary differential equation** relates a function of one variable to its derivatives: $y' = f(t, y)$. Existence and uniqueness ([[Picard–Lindelöf]], a [[Metric space|fixed-point]] argument) decide whether initial data determine a future.", + tex: "y'(t) = f(t, y(t)), \\qquad y(t_0) = y_0", + facts: [ + "Linear systems with constant coefficients solve via matrix exponentials and [[Eigenvalues and eigenvectors|eigenvalues]].", + "Stability is spectral: eigenvalues in the left half-plane $\\Rightarrow$ decay ([[Dynamical systems]]' linearisation theorem).", + "Second-order linear ODEs with polynomial coefficients (Legendre, Bessel, hypergeometric) have solutions defined by [[Series|power series]].", + ], + see: ["Partial differential equations", "Eigenvalues and eigenvectors", "Dynamical systems"], + }, + { + title: "Partial differential equations", + tags: ["differential equations"], + intro: "**Partial differential equations** relate functions of several variables to their partial derivatives. Elliptic (Laplace), parabolic (heat), and hyperbolic (wave) are the three archetypes — boundary and initial conditions decide what they mean ([[Ordinary differential equations]] is the one-variable case).", + tex: "\\Delta u = 0, \\qquad u_t = \\alpha \\Delta u, \\qquad u_{tt} = c^2 \\Delta u", + facts: [ + "Maximum principles for elliptic equations: interior extrema force constancy ([[Harmonic functions]] are smooth [[Analytic continuation|analytic]]).", + "Weak solutions ([[Distributions|distributions]] on [[Hilbert space|test-function spaces]]) let non-differentiable functions solve equations; Sobolev spaces ($L^2$-based [[Hilbert space]]) frame the theory.", + "Characteristic surfaces propagate singularities for hyperbolic equations ([[Wave equation]]).", + ], + see: ["Heat equation", "Wave equation", "Ordinary differential equations"], + }, + { + title: "Heat equation", + tags: ["differential equations", "physics"], + intro: "The **heat equation** $u_t = \\alpha \\Delta u$ models diffusion: temperature, dye, black-body radiation, and (via a change of variables) option prices ([[Brownian motion]] is its probabilistic twin — the Kolmogorov backward equation).", + tex: "\\frac{\\partial u}{\\partial t} = \\alpha \\nabla^2 u, \\qquad u(x,0) = f(x) \\implies u = G_t * f", + facts: [ + "The fundamental solution is the [[Normal distribution|Gaussian kernel]] $G_t(x) = (4\\pi\\alpha t)^{-n/2} e^{-|x|^2/4\\alpha t}$ — the [[Central limit theorem]]'s density in continuous clothing.", + "It is infinitely smoothing: any $L^2$ initial data become smooth immediately ([[Partial differential equations]]).", + "Reversing time is catastrophically ill-posed ([[Numerical analysis]]) — and one of Hadamard's examples of non-well-posed problems.", + ], + see: ["Brownian motion", "Partial differential equations", "Normal distribution"], + }, + { + title: "Wave equation", + tags: ["differential equations", "physics"], + intro: "The **wave equation** $u_{tt} = c^2 \\Delta u$ models vibrations and propagation without dispersion: sound, light, strings, and gravitational waves ripples in spacetime curvature ([[Riemannian geometry]]'s Lorentzian cousin).", + tex: "\\frac{\\partial^2 u}{\\partial t^2} = c^2 \\nabla^2 u, \\qquad u(x,t) = \\tfrac{1}{2}[f(x - ct) + f(x + ct)] + \\cdots \\ (\\text{d'Alembert, } 1D)", + facts: [ + "Finite propagation speed: disturbances travel inside the light cone — unlike the heat equation ([[Heat equation]]).", + "Huygens' principle (sharp signals) holds for odd dimensions $\\ge 3$; in even dimensions waves have tails.", + "Fourier transforms turn it into a family of oscillators — dispersion relation $\\omega = c|k|$ ([[Fourier analysis]]).", + ], + see: ["Partial differential equations", "Fourier analysis", "Heat equation"], + }, + { + title: "Navier-Stokes equations", + tags: ["differential equations", "physics", "conjectures"], + intro: "The **Navier–Stokes equations** are Newton's law for viscous fluids: $\\partial_t u + (u \\cdot \\nabla)u = -\\nabla p + \\nu \\Delta u$, $\\nabla \\cdot u = 0$. Whether smooth solutions in 3D can blow up is a Millennium Prize problem ([[Partial differential equations]]' open heart).", + tex: "\\partial_t u + (u \\cdot \\nabla) u = -\\frac{1}{\\rho}\\nabla p + \\nu \\Delta u, \\qquad \\nabla \\cdot u = 0", + facts: [ + "Turbulence — the cascade of eddies across scales (Kolmogorov $k^{-5/3}$) — is the physics mystery attached.", + "Weak solutions (Leray–Hopf) exist globally in 3D; uniqueness is open. In 2D everything is fine.", + "The inviscid limit $\\nu \\to 0$ connects to the [[Euler equations|Euler equations]] and boundary-layer singularities; numerically it drives large-eddy simulation ([[Numerical analysis]]).", + ], + see: ["Partial differential equations", "Numerical analysis", "Differential forms"], + }, + { + title: "Dynamical systems", + tags: ["dynamical systems"], + intro: "A **dynamical system** is a space with a rule for evolving states in time — iterated maps or flows of [[Ordinary differential equations|ODEs]]. Attractors, bifurcations, ergodicity; the qualitative study of deterministic change.", + tex: "x_{n+1} = f(x_n), \\qquad \\dot x = X(x)", + facts: [ + "Linear stability of fixed points comes from [[Eigenvalues and eigenvectors|eigenvalues]]; when they cross the imaginary axis, bifurcations occur ([[Hopf|Hopf bifurcation]]).", + "Ergodic theory studies measure-preserving systems — entropy ([[Entropy|measure-theoretic entropy]]) is its invariant.", + "Smale's horseshoe embeds symbolic dynamics ([[Shift space|shift spaces]]): a compact invariant set with the full complexity of sequence space.", + ], + see: ["Chaos theory", "Ordinary differential equations", "Logistic map"], + }, + { + title: "Chaos theory", + tags: ["dynamical systems", "chaos"], + intro: "**Chaos** is deterministic sensitivity to initial conditions: nearby states diverge exponentially (positive Lyapunov exponent) yet stay in a bounded attractor, often a fractal ([[Fractals]]). Weather prediction's fundamental horizon.", + tex: "\\lambda = \\lim_{t \\to \\infty} \\frac{1}{t} \\ln \\frac{\\|\\delta x(t)\\|}{\\|\\delta x(0)\\|} > 0", + facts: [ + "Chaos needs at least dimension 3 for flows ([[Poincaré|Poincaré's three-body insight]]) — planar flows cannot be chaotic ([[Poincaré–Bendixson theorem]]).", + "The period-doubling route to chaos in the [[Logistic map]] carries the universal Feigenbaum constant $\\delta \\approx 4.669$.", + "Mixing and ergodicity ([[Dynamical systems]]) make chaos statistically predictable: individual orbits not, averages yes.", + ], + see: ["Logistic map", "Fractals", "Dynamical systems"], + }, + { + title: "Logistic map", + tags: ["dynamical systems", "chaos"], + intro: "The **logistic map** $x_{n+1} = r x_n (1 - x_n)$ models a population with a carrying capacity — and as $r$ grows it walks through period doubling to chaos, the simplest system that does so ([[Chaos theory]]).", + tex: "x_{n+1} = r\\, x_n (1 - x_n), \\qquad r \\in [0, 4]", + facts: [ + "Fixed point $x^* = 1 - 1/r$ loses stability at $r = 3$; period $2^k$ windows at $r_n \\to r_\\infty \\approx 3.57$ with [[Chaos theory|Feigenbaum]] universality.", + "At $r = 4$ the map is conjugate to the tent map and to the angle-doubling map $\\theta \\mapsto 2\\theta$: chaotic, with an invariant density $1/(\\pi\\sqrt{x(1-x)})$ ([[Measure theory]]).", + "The Lyapunov exponent at $r=4$ is $\\ln 2$ — the information creation rate per step ([[Entropy]]).", + ], + see: ["Chaos theory", "Dynamical systems", "Fractals"], + }, + { + title: "Probability theory", + tags: ["probability", "foundations"], + intro: "**Probability theory**, axiomatised by Kolmogorov on [[Measure theory]], assigns numbers in $[0,1]$ to events with additivity and a total mass of 1. Random variables are measurable functions; expectations are integrals ([[Lebesgue integral]]).", + tex: "P(\\Omega) = 1, \\qquad P\\!\\left(\\bigsqcup_i A_i\\right) = \\sum_i P(A_i), \\qquad \\mathbb{E}[X] = \\int X\\, dP", + facts: [ + "Conditioning and Bayes' rule ([[Bayes' theorem]]) update beliefs; independence factors $P$.", + "Weak laws ([[Law of large numbers]]) and limit shapes ([[Central limit theorem]]) describe sums of many trials.", + "Stochastic processes add time: [[Markov chains]], [[Martingales]], [[Brownian motion]].", + ], + see: ["Measure theory", "Bayes' theorem", "Central limit theorem"], + }, + { + title: "Central limit theorem", + tags: ["probability", "statistics"], + intro: "The **central limit theorem**: the (properly scaled) sum of many independent, finite-variance random variables converges to the [[Normal distribution]] — regardless of the summands' distribution. Universality by averaging.", + tex: "\\frac{X_1 + \\cdots + X_n - n\\mu}{\\sigma \\sqrt{n}} \\xrightarrow{\\ d\\ } \\mathcal{N}(0, 1)", + facts: [ + "Fourier proof: characteristic functions $\\varphi_{S_n}(t/n)^n \\to e^{-t^2/2}$ ([[Fourier analysis]]).", + "Finite variance is essential; heavy-tailed sums converge instead to stable laws ([[Lévy flights|Lévy]], generalising the normal).", + "It pairs with the [[Law of large numbers]]: the average's location *and* its fluctuation, respectively.", + ], + see: ["Law of large numbers", "Normal distribution", "Probability theory"], + }, + { + title: "Law of large numbers", + tags: ["probability", "statistics"], + intro: "The **law of large numbers**: sample averages converge to expectations. Weakly in probability, strongly almost surely ([[Kolmogorov]]); it is the reason frequencies settle and casinos profit ([[Probability theory]]).", + tex: "\\frac{X_1 + \\cdots + X_n}{n} \\xrightarrow{\\ \\text{a.s.}\\ } \\mathbb{E}[X_1]$", + facts: [ + "The strong law needs $\\mathbb{E}|X| < \\infty$ and independence (or suitable mixing).", + "Chebyshev's inequality gives the weak law in one line ([[Probability theory]]'s variance bookkeeping).", + "It does not say averages converge quickly — that refinement is the central limit theorem ([[Central limit theorem]]) and large-deviations theory.", + ], + see: ["Central limit theorem", "Probability theory", "Martingales"], + }, + { + title: "Markov chains", + tags: ["probability", "stochastic processes"], + intro: "A **Markov chain** is a process whose future depends on the past only through the present: $P(X_{n+1} \\mid X_n, \\dots) = P(X_{n+1} \\mid X_n)$. Transition matrices carry all of it; finite state spaces make it [[Linear algebra]].", + tex: "P(X_{n+1} = j \\mid X_n = i) = P_{ij}, \\qquad \\mu^{(n)} = \\mu^{(0)} P^n", + facts: [ + "Stationary distributions are left eigenvectors with eigenvalue 1; convergence to them (mixing) needs irreducibility and aperiodicity ([[Eigenvalues and eigenvectors]]).", + "Reversibility (detailed balance) is the condition behind MCMC ([[Monte Carlo method]]) and equilibrium physics.", + "PageRank is the stationary distribution of a giant [[Graph theory|graph]] walk ([[Random walk]]).", + ], + see: ["Random walk", "Monte Carlo method", "Graph theory"], + }, + { + title: "Martingales", + tags: ["probability", "stochastic processes"], + intro: "A **martingale** is a fair game: given the past, the expected next value equals the present ($\\mathbb{E}[X_{n+1} \\mid \\mathcal{F}_n] = X_n$). Submartingales drift up, supermartingales down; the optional stopping theorem says fair games cannot be beaten with stopping strategies.", + tex: "\\mathbb{E}[\\,X_{n+1} \\mid \\mathcal{F}_n\\,] = X_n", + facts: [ + "Doob's martingale convergence theorem: $L^1$-bounded submartingales converge a.s. ([[Measure theory|convergence machinery]]).", + "Optional stopping yields gambler's ruin, and a short proof of the [[Law of large numbers]].", + "Itô integrals are martingales ([[Brownian motion]]); change of measure (Girsanov) reweights them — the mathematics of option pricing ([[Probability theory]]).", + ], + see: ["Brownian motion", "Probability theory", "Law of large numbers"], + }, + { + title: "Brownian motion", + tags: ["probability", "stochastic processes", "physics"], + intro: "**Brownian motion** is the mathematical model of pollen jittering in water, later of stock prices and of diffusion. Continuous paths, independent stationary increments, $B_t - B_s \\sim \\mathcal{N}(0, t)$ ([[Normal distribution]]) — and nowhere differentiable ([[Fractals]]: dimension 2).", + tex: "B_t - B_s \\sim \\mathcal{N}(0,\\, t-s), \\quad \\text{increments independent}, \\qquad \\text{paths continuous}, \\nexists B'_t$", + facts: [ + "Its transition density solves the [[Heat equation]] — Fokker–Planck/Kolmogorov equations bridge probability and PDE ([[Partial differential equations]]).", + "Itô calculus integrates with respect to it ($dW^2 = dt$); [[Martingales]] and Girsanov's theorem price derivatives.", + "Donsker's invariance principle: it is the scaling limit of the [[Random walk]] — the [[Central limit theorem]] as a process.", + ], + see: ["Random walk", "Heat equation", "Martingales"], + }, + { + title: "Poisson process", + tags: ["probability", "stochastic processes"], + intro: "The **Poisson process** counts random arrivals that occur independently at a constant rate: the number of events in time $t$ is $\\mathrm{Poisson}(\\lambda t)$, inter-arrival times exponential. Calls, decays, meteor strikes ([[Probability theory]]).", + tex: "P(N_t = k) = e^{-\\lambda t} \\frac{(\\lambda t)^k}{k!}, \\qquad \\tau_i \\sim \\mathrm{Exp}(\\lambda)", + facts: [ + "Thinning, superposition, and conditioning on totals preserve Poisson-ness — the process is extremely stable under operations.", + "It is the only counting process with independent stationary increments on discrete counts ([[Markov chains]]: birth process with rate $\\lambda$).", + "Poisson approximation: many rare, weakly dependent events look Poisson ([[Law of large numbers]]'s opposite regime).", + ], + see: ["Probability theory", "Markov chains", "Law of large numbers"], + }, + { + title: "Random walk", + tags: ["probability", "combinatorics"], + intro: "A **random walk** adds up independent steps: $S_n = X_1 + \\cdots + X_n$. On $\\mathbb{Z}$ and $\\mathbb{Z}^2$ it is recurrent (returns home with probability 1); from $\\mathbb{Z}^3$ onward it escapes — 'a drunk man will find his way home, a drunk bird may get lost' (Pólya).", + tex: "S_n = \\sum_{i=1}^{n} X_i, \\qquad P(X_i = \\pm 1) = \\tfrac{1}{2}", + facts: [ + "Pólya's recurrence theorem: recurrent in dimensions 1–2, transient in 3+ ([[Markov chains]]' classification).", + "Hitting-time and gambler's-ruin probabilities come from harmonic functions on graphs — discrete [[Laplace equation|potential theory]] ([[Partial differential equations]]' cousin).", + "Scaled up, walks converge to [[Brownian motion]] (Donsker); on [[Graph theory|graphs]] they sample, mix, and rank ([[Markov chains]]).", + ], + see: ["Brownian motion", "Graph theory", "Markov chains"], + }, + { + title: "Combinatorics", + tags: ["combinatorics", "foundations"], + intro: "**Combinatorics** counts, arranges, and selects: permutations, subsets, partitions, colourings. Once considered recreational, it is now central through [[Graph theory]], coding, complexity ([[P versus NP problem]]), and the probabilistic method ([[Probability theory]] proving existence).", + tex: "\\binom{n}{k} = \\frac{n!}{k!(n-k)!}, \\qquad n! = |S_n| \\ \\text{(see [[Symmetric group]])}", + facts: [ + "The probabilistic method: show a random object satisfies a property with positive probability — existence without construction ([[Erdos]]).", + "Ramsey theory ([[Ramsey theory]]) proves order is unavoidable in large structures.", + "Generating functions ([[Generating function]]) encode counting sequences as analytic objects.", + ], + see: ["Graph theory", "Generating function", "Binomial theorem"], + }, + { + title: "Binomial theorem", + tags: ["combinatorics", "algebra"], + intro: "The **binomial theorem** expands powers of a sum: $(x + y)^n = \\sum_k \\binom{n}{k} x^k y^{n-k}$. Pascal's triangle tabulates the coefficients; they count [[Combinatorics|k-subsets of an n-set]].", + tex: "(x + y)^n = \\sum_{k=0}^{n} \\binom{n}{k} x^k y^{n-k}", + facts: [ + "Pascal's identity $\\binom{n}{k} = \\binom{n-1}{k-1} + \\binom{n-1}{k}$ generates the triangle; the row sums are $2^n$.", + "It generalises to multinomial coefficients and to non-integer exponents via [[Series|infinite series]] (Newton).", + "The proof is a [[Group homomorphism|counting]] of choices: pick which $k$ factors donate $x$.", + ], + see: ["Catalan numbers", "Stirling numbers", "Combinatorics"], + }, + { + title: "Catalan numbers", + tags: ["combinatorics", "sequences"], + intro: "The **Catalan numbers** $C_n = \\frac{1}{n+1}\\binom{2n}{n}$ count parenthesizations of $n+1$ factors, Dyck paths, triangulations of a convex polygon, binary trees, stack-sortable permutations — an unreasonable number of things ([[Combinatorics]]).", + tex: "C_n = \\frac{1}{n+1}\\binom{2n}{n} = 1, 1, 2, 5, 14, 42, 132, \\ldots", + facts: [ + "Generating function: $C(x) = \\frac{1 - \\sqrt{1 - 4x}}{2x}$ — square-root singularity, growth $\\sim 4^n n^{-3/2}$ ([[Generating function]]).", + "Noncrossing partitions and planar trees carry Catalan structure; associahedra ([[Polytopes|polytopes]]) encode the associativity relations.", + "The reflection principle for Dyck paths proves the formula bijectively.", + ], + see: ["Binomial theorem", "Generating function", "Combinatorics"], + }, + { + title: "Stirling numbers", + tags: ["combinatorics", "sequences"], + intro: "**Stirling numbers** count permutations by cycles $[n\\ k]$ and set partitions into blocks $\\{n\\ k\\}$. They are the transition coefficients between powers of $x$ and falling factorials ([[Combinatorics]]' change of basis).", + tex: "x^{\\underline{n}} = \\sum_k s(n,k)\\, x^k, \\qquad x^n = \\sum_k \\left\\{ {n \\atop k} \\right\\} x^{\\underline{k}}", + facts: [ + "Signed Stirling numbers of the first kind give coefficients of $(x-1)(x-2)\\cdots(x-n)$.", + "$\\{n\\ k\\}$ satisfy the same Pascal-style recursion as cycles: block element $n$ joins or starts.", + "They appear in [[Taylor series|finite-difference calculus]] and in the analysis of hash tables ([[Numerical analysis|average-case analysis]]).", + ], + see: ["Binomial theorem", "Symmetric group", "Combinatorics"], + }, + { + title: "Generating function", + tags: ["combinatorics", "analysis"], + intro: "A **generating function** $A(x) = \\sum a_n x^n$ packages a counting sequence as a formal or analytic object; operations on the function mirror constructions on the objects. The bridge between [[Combinatorics]] and [[Complex analysis]].", + tex: "A(x) = \\sum_{n \\ge 0} a_n x^n; \\qquad [x^n]\\, A(x)B(x) = \\sum_{k} a_k b_{n-k}", + facts: [ + "The partition function's generating function $\\prod_k (1 - x^k)^{-1}$ let Hardy and Ramanujan read off $p(n) \\sim \\frac{1}{4n\\sqrt{3}} e^{\\pi\\sqrt{2n/3}}$ by singularity analysis ([[Residue theorem]] technique).", + "Catalan numbers' algebraic generating function ([[Catalan numbers]]) satisfies $C = 1 + xC^2$ — recursive specification made polynomial.", + "Analytic combinatorics extracts asymptotics from the location and type of singularities ([[Analytic continuation]]).", + ], + see: ["Catalan numbers", "Complex analysis", "Combinatorics"], + }, + { + title: "Graph theory", + tags: ["graph theory", "combinatorics"], + intro: "**Graph theory** studies networks of vertices and edges: Euler's bridges (1736) started it. Colourings ([[Four color theorem]]), connectivity, matchings, and walks ([[Random walk]]) are its classical themes; networks are its modern omnipresence.", + tex: "G = (V, E), \\qquad \\sum_{v} \\deg(v) = 2|E|", + facts: [ + "The handshaking lemma forces an even number of odd-degree vertices; Eulerian circuits require all degrees even and one component.", + "Trees are connected acyclic graphs; [[Spanning tree|spanning trees]] sit inside every connected graph, counted by Kirchhoff's matrix-tree theorem ([[Determinant]]).", + "Spectra of adjacency/Laplacian matrices ([[Eigenvalues and eigenvectors]]) measure expansion, mixing ([[Markov chains]]), and clustering.", + ], + see: ["Spanning tree", "Four color theorem", "Ramsey theory"], + }, + { + title: "Ramsey theory", + tags: ["combinatorics", "graph theory"], + intro: "**Ramsey theory** proves complete disorder is impossible: any sufficiently large structure contains a large ordered substructure. $R(3,3) = 6$ — among six people there are three mutual friends or three mutual strangers ([[Graph theory]]).", + tex: "R(s, t) < \\infty, \\qquad K_N \\to (K_s)^2_2 \\ \\text{for}\\ N \\ge R(s,t)", + facts: [ + "Even $R(5,5)$ is unknown: it lies between 43 and 46.", + "The probabilistic method ([[Combinatorics|Erdos]]) gives exponential lower bounds while the upper bounds are constructive — a rare gap between existence and construction.", + "Hindman's and van der Waerden's theorems are arithmetic-Ramsey statements about sums and progressions ([[Number theory]]'s additive side).", + ], + see: ["Graph theory", "Combinatorics", "Four color theorem"], + }, + { + title: "Four color theorem", + tags: ["graph theory", "combinatorics", "history"], + intro: "The **four color theorem**: every map on the plane can be coloured with four colours so adjacent regions differ. Conjectured 1852, proved 1976 by Appel and Haken with a computer check of ~1,900 reducible configurations — the first famous machine-assisted proof ([[Graph theory]]).", + tex: "\\chi(G) \\le 4 \\qquad (G \\text{ planar})", + facts: [ + "In graph language: every planar graph is 4-colourable; the dual map graph ([[Planar graph|planar duality]]) is where the combinatorics lives.", + "A simpler quadratic-time algorithm exists today (Robertson–Sanders–Seymour–Thomas, 1997).", + "Kempe's 1879 proof — the first 'proof' accepted for a decade — was flawed, but his chain-recoloring idea survives in the modern discharging method.", + ], + see: ["Graph theory", "Spanning tree", "Ramsey theory"], + }, + { + title: "Spanning tree", + tags: ["graph theory"], + intro: "A **spanning tree** of a connected graph is a tree touching every vertex with exactly $|V| - 1$ edges. Minimum spanning trees (Kruskal, Prim) are among the oldest greedy algorithms ([[Numerical analysis|algorithmics]]' foundation).", + tex: "\\tau(G) = \\det L^{(v)} \\qquad \\text{(Kirchhoff's matrix-tree theorem, any cofactor of } L)", + facts: [ + "Kruskal's algorithm is greedy-optimal; its correctness is the matroid greedy theorem ([[Combinatorics|matroids]] generalise trees this way).", + "The number of spanning trees of $K_n$ is $n^{n-2}$ (Cayley) — [[Determinant]]'s most delightful use.", + "Minimum spanning trees are the backbone of clustering and network design, and of subgradient methods in optimisation ([[Monte Carlo method|stochastic optimisation]]).", + ], + see: ["Graph theory", "Determinant", "Four color theorem"], + }, + { + title: "Information theory", + tags: ["information theory", "probability"], + intro: "**Information theory**, founded by Shannon (1948), measures information in bits and asks how far sources and channels can be compressed and transmitted. Entropy ([[Entropy]]) is the source's limit; mutual information is the channel's.", + tex: "H = -\\sum_i p_i \\log_2 p_i, \\qquad C = \\max_{p(x)} I(X; Y)", + facts: [ + "Shannon's source coding theorem: $H$ bits per symbol is the optimal average compression length of a memoryless source.", + "Channel capacity theorem: below capacity, error-correcting codes ([[Hamming codes|coding theory]]) exist; above it, impossible.", + "Kolmogorov complexity redefines 'information in an object' via Turing machines ([[Turing machine]]'s cousin); algorithmic information theory ([[Levin|Levin's universal distribution]]).", + ], + see: ["Entropy", "Probability theory", "RSA"], + }, + { + title: "Entropy", + tags: ["information theory", "probability"], + intro: "**Entropy** $H = -\\sum p_i \\log p_i$ measures the uncertainty of a distribution, in bits. It is maximised by the uniform distribution, and it is also the thermodynamic entropy ([[Statistical mechanics|Boltzmann's $S = k \\log W$]]).", + tex: "H(X) = -\\sum_{x} p(x) \\log_2 p(x), \\qquad H(X,Y) \\le H(X) + H(Y)", + facts: [ + "Conditioning reduces entropy: $H(X \\mid Y) \\le H(X)$; equality iff independent ([[Probability theory]]).", + "Relative entropy (KL divergence) measures distance between distributions and underlies maximum-entropy inference ([[Bayes' theorem]]'s cousin).", + "The entropy power inequality ([[Central limit theorem]] in information form): the CLT can be read as entropy monotonically increasing under convolution.", + ], + see: ["Information theory", "Probability theory", "Bayes' theorem"], + }, + { + title: "RSA", + tags: ["cryptography", "number theory"], + intro: "**RSA** (1977, Rivest–Shamir–Adleman) encrypts by exponentiation modulo a product of two large primes and decrypts with a related exponent known only to the holder of the factorisation. Security is assumed equivalent to the hardness of factoring ([[Modular arithmetic]]).", + tex: "c \\equiv m^e \\pmod{N}, \\qquad m \\equiv c^d \\pmod{N}, \\quad ed \\equiv 1 \\pmod{\\varphi(N)}", + facts: [ + "Correctness is Euler's theorem ([[Fermat's little theorem]]'s generalisation): $m^{ed} \\equiv m \\pmod N$.", + "The private key needs $\\varphi(N) = (p-1)(q-1)$, hence the factors; Shor's quantum algorithm breaks it — [[Quantum computing|post-quantum]] alternatives ([[Elliptic curves|elliptic-curve cryptography]], lattices) already deploy.", + "Plain RSA is malleable; OAEP padding and careful implementation are part of the cryptosystem proper (padding-oracle attacks).", + ], + see: ["Modular arithmetic", "Fermat's little theorem", "Prime number"], + }, + { + title: "Numerical analysis", + tags: ["numerical analysis", "applied math"], + intro: "**Numerical analysis** designs and analyses algorithms that compute approximately, in floating point: conditioning, stability, and error bounds. Every serious computation — solving [[Ordinary differential equations|ODEs]], [[Linear algebra|linear systems]], integrating [[Series|series]] — lives here.", + tex: "\\hat{x} = x + \\delta x, \\quad \\frac{\\|\\delta x\\|}{\\|x\\|} \\le \\kappa(A)\\, \\frac{\\|\\delta b\\|}{\\|b\\|}, \\qquad \\kappa = \\frac{\\sigma_{\\max}}{\\sigma_{\\min}}", + facts: [ + "Conditioning is the problem's; stability is the algorithm's. The [[Singular value decomposition]] quantifies the first for linear algebra.", + "Stable algorithms: partial pivoting for Gaussian elimination, Householder reflections; backward error analysis explains why they work.", + "Round-off, truncation, and data error trade against each other: optimal step sizes and stopping rules are this trade ([[Monte Carlo method|convergence rates]]).", + ], + see: ["Singular value decomposition", "Monte Carlo method", "Ordinary differential equations"], + }, + { + title: "Monte Carlo method", + tags: ["numerical analysis", "probability"], + intro: "**Monte Carlo methods** estimate quantities with random sampling: integrate by averaging random draws, sum by importance sampling, optimise by simulated annealing. Error $O(n^{-1/2})$, independent of dimension — its superpower in high dimensions ([[Central limit theorem]]).", + tex: "\\hat{I} = \\frac{1}{n}\\sum_{i=1}^{n} f(X_i), \\qquad \\text{se} = \\frac{\\hat\\sigma}{\\sqrt{n}}$", + facts: [ + "It estimates $\\pi$ by throwing darts; it prices derivatives, integrates posterior distributions ([[Bayes' theorem]]), renders light transport.", + "Markov chain Monte Carlo (Metropolis–Hastings, Gibbs) samples dependent chains from targets — see [[Markov chains]] and detailed balance.", + "Variance reduction — antithetic variates, control variates, importance sampling ([[Entropy]]-optimal measures) — buys accuracy without more samples ([[Numerical analysis]]).", + ], + see: ["Probability theory", "Markov chains", "Numerical analysis"], + }, +]; + +async function seedBulk() { + await connectDB(); + + const existing = new Set(await Article.find({}).distinct("slug")); + const now = Date.now(); + const windowMs = 120 * 24 * 60 * 60 * 1000; // spread over the last ~4 months + + // Titles whose slugs collide with what is already in the database are + // skipped rather than suffixed — a second run should add nothing. + const docs = []; + const skipped = []; + for (const topic of topics) { + const slug = slugify(topic.title); + if (existing.has(slug)) { + skipped.push(slug); + continue; + } + const created = new Date(now - rand() * windowMs); + const updated = new Date(created.getTime() + rand() * (now - created.getTime())); + docs.push({ + slug, + title: topic.title, + content: render(topic), + tags: topic.tags, + language: "en", + baseSlug: null, + createdAt: created, + updatedAt: updated, + }); + } + + // insertMany skips per-document unique violations rather than aborting the + // batch — belt and braces next to the existing-slug check above. + const res = await Article.insertMany(docs, { ordered: false }); + console.log( + `Bulk seed complete: ${res.length} inserted, ${skipped.length} skipped (slug already present), ${topics.length} topics in file` + ); + await disconnectDB(); +} + +function render(t) { + const parts = [ + `# ${t.title}`, + "", + t.intro, + "", + "$$", + ` ${t.tex}`, + "$$", + "", + "## Key facts", + "", + ...t.facts.map((f) => `- ${f}`), + "", + "## Related", + "", + ...[...new Set(t.see.map(stripNote))].map((s) => `- [[${s}]]`), + ]; + return parts.join("\n") + "\n"; +} + +// "Functor|functors" or "note-style trailing — see X": keep the link target only +function stripNote(target) { + return target.split("|")[0].trim(); +} + +seedBulk().catch(async (err) => { + console.error("Bulk seed failed:", err.message); + await mongoose.disconnect().catch(() => {}); + process.exit(1); +}); diff --git a/frontend/README.md b/frontend/README.md index b83315b..0f10108 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -29,8 +29,9 @@ npm run dev # → http://localhost:3000 | Route | What it does | | --- | --- | | `/` | Search + recently updated articles | -| `/wiki/` | Rendered article with table of contents | -| `/wiki//edit` | Markdown editor with live preview | +| `/wiki//` | One language version of an article, with its table of contents — every version has an address of its own, English included (`/wiki/en/…`) | +| `/wiki/` | The short form, naming no language: it is turned onto the address of the version the slug speaks | +| `/wiki///edit` — `/wiki//edit` | Markdown editor with live preview | | `/new` | Create a new article | ## Markdown + LaTeX @@ -46,7 +47,8 @@ Handy macros: `\R`, `\N`, `\Z`, `\Q`, `\C` for the number sets. | Method | Endpoint | | --- | --- | -| GET | `/api/articles?q=&tag=` — search / filter, no content body | +| GET | `/api/articles?q=&tag=&lang=&limit=&offset=` — search / filter, paged **by article** (`{ articles, total, hasMore }`; no `limit` means the whole shelf, and `?lang=` answers each article as the version written in it) | +| GET | `/api/articles/facets` — `{ total, topics, languages }` counted over the whole shelf | | GET | `/api/articles/slug/:slug` — one article | | POST | `/api/articles` — create (`title` required; slug auto-generated & uniquified) | | PUT | `/api/articles/slug/:slug` — update | diff --git a/frontend/app/assets/css/_admin.scss b/frontend/app/assets/css/_admin.scss index 0a4dc8c..7af8be8 100644 --- a/frontend/app/assets/css/_admin.scss +++ b/frontend/app/assets/css/_admin.scss @@ -1,3 +1,32 @@ +@use 'mixins' as *; + +// ---------- The dashboard's two tabs: the requests and the accounts ---------- +// The same segmented control the sign-in popup and the settings switch wear +// (the mixins in _mixins.scss); the panels they open are the two sections. +.admin-tabs { @include segmented; margin-bottom: 22px; } + +.admin-tab { + @include segmented-tab(7px 14px, 0.84rem); + display: inline-flex; + align-items: center; + gap: 7px; +} + +// how many requests are still waiting, so the pile is visible even while the +// admin is reading the accounts — the tally's shape from the sidebar's count +.admin-tab-count { + min-width: 18px; + height: 18px; + padding: 0 5px; + display: inline-grid; + place-items: center; + border-radius: 9px; + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); + font-size: 0.7rem; + font-weight: 700; +} + // ---------- Admin dashboard: the list of accounts ---------- // One row per account, read left to right: who, what they may do, when they // arrived, and the one thing an admin can change about them. diff --git a/frontend/app/assets/css/_articles.scss b/frontend/app/assets/css/_articles.scss index deaef92..c02856c 100644 --- a/frontend/app/assets/css/_articles.scss +++ b/frontend/app/assets/css/_articles.scss @@ -3,11 +3,68 @@ // ---------- Articles index ---------- .topic-tally { font-size: 0.85rem; color: var(--muted); } -.topic-dropdown { - @include dropdown-panel; - left: 0; - right: 0; - max-height: 280px; +// ---------- Folders: the shelf filed by topic ---------- +.folder-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 14px; +} + +.folder-card { + display: flex; + align-items: center; + gap: 12px; + padding: 15px 18px; + border-radius: 14px; + border: 1px solid var(--border); + background: var(--surface); + text-decoration: none; + color: inherit; + box-shadow: var(--shadow-sm); + transition: transform 0.18s ease, border-color 0.18s, box-shadow 0.18s; + + svg { flex: none; color: var(--accent); opacity: 0.85; transition: opacity 0.15s; } + + &:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + box-shadow: var(--shadow-lg); + svg { opacity: 1; } + } + + &:focus-visible { box-shadow: var(--ring); } +} + +.folder-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.95rem; + font-weight: 700; + letter-spacing: -0.01em; + // the facet tally lowercases every topic; folders wear their names capitalized + text-transform: capitalize; +} + +.folder-count { flex: none; margin-left: auto; } + +// the folder grid's loading row: a shorter skeleton than an article card's +.folder-skeleton { height: 60px; border-radius: 14px; @include shimmer; } + +// closing a folder: the quiet way back onto the topics +.back-link { + display: inline-flex; + align-items: center; + gap: 7px; + margin-bottom: 14px; + color: var(--muted); + font-size: 0.85rem; + font-weight: 650; + text-decoration: none; + transition: color 0.15s; + + &:hover { color: var(--accent); } } .topic-option { @@ -18,45 +75,6 @@ &:hover, &.active { background: color-mix(in srgb, var(--accent) 12%, transparent); } } -.topic-dropdown-none { - padding: 8px 12px; - margin: 0; - font-size: 0.85rem; - color: var(--muted); -} - -.topic-search-wrap { - position: relative; - margin-bottom: 14px; - - svg { - position: absolute; - left: 14px; - top: 50%; - translate: 0 -50%; - color: var(--faint); - pointer-events: none; - } -} - -.topic-search { - width: 100%; - height: 40px; - padding: 0 14px 0 40px; - border-radius: 12px; - border: 1px solid var(--border); - background: var(--surface); - box-shadow: var(--shadow-sm); - font-size: 0.9rem; - transition: border-color 0.15s, box-shadow 0.2s; - - &:focus { - outline: none; - border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent); - } -} - .topic-count { padding: 1px 7px; border-radius: 999px; diff --git a/frontend/app/assets/css/_cards.scss b/frontend/app/assets/css/_cards.scss index a986815..844eb2e 100644 --- a/frontend/app/assets/css/_cards.scss +++ b/frontend/app/assets/css/_cards.scss @@ -7,6 +7,16 @@ gap: 20px; } +// the shelf's last line: reaching it brings the next batch of cards in, and +// while that batch is on its way it says so +.shelf-sentinel { + display: grid; + place-items: center; + padding-top: 24px; + color: var(--faint); + font-size: 0.85rem; +} + .article-card { display: flex; flex-direction: column; diff --git a/frontend/app/assets/css/_editor.scss b/frontend/app/assets/css/_editor.scss index fb03f4a..241198f 100644 --- a/frontend/app/assets/css/_editor.scss +++ b/frontend/app/assets/css/_editor.scss @@ -90,6 +90,11 @@ // editor body .editor-body { + // One height for the writing field and the preview side by side: each holds + // its text in a box of this height and scrolls it inside, so a long article + // never makes the editor taller than the field the writer is looking at. + --editor-pane-h: 480px; + border: 1px solid var(--border); border-radius: 15px; background: var(--bg-elev); @@ -141,6 +146,7 @@ .editor-grid { display: grid; grid-template-columns: 1fr 1fr; + height: var(--editor-pane-h); &.mode-write { grid-template-columns: 1fr; @@ -158,11 +164,11 @@ textarea.md-input { display: block; width: 100%; - min-height: 480px; + height: 100%; padding: 22px; border: 0; background: transparent; - resize: vertical; + resize: none; font-size: 0.9rem; line-height: 1.75; tab-size: 2; @@ -176,13 +182,24 @@ textarea.md-input { border-left: 1px solid var(--border); padding: 22px 26px; min-width: 0; + overflow-y: auto; + // and reaching the end of a long preview doesn't start scrolling the page + overscroll-behavior: contain; overflow-wrap: break-word; } .preview-label { @include eyebrow; margin-bottom: 14px; } @media (max-width: 900px) { - .editor-grid { grid-template-columns: 1fr; } + .editor-grid { + grid-template-columns: 1fr; + height: auto; + } + + // stacked, the two panes keep the height the split gives them — each still + // scrolls its own text rather than stretching the editor + .write-pane, + .preview-pane { height: var(--editor-pane-h); } .preview-pane { border-left: 0 !important; border-top: 1px solid var(--border); } } @@ -265,6 +282,21 @@ textarea.md-input { @include popup-anim('confirm-popup', '.confirm-modal', 0.18s, transform 0.18s ease, 10px); +// sent-request popup — the discard popup's shape in a success temper: it +// confirms a proposal filed with the admins, and closing it ends the edit +.sent-check { + display: grid; + place-items: center; + width: 46px; + height: 46px; + margin-bottom: 14px; + border-radius: 50%; + background: color-mix(in srgb, var(--success) 14%, transparent); + color: var(--success); + font-size: 1.3rem; + font-weight: 800; +} + // fullscreen editor .editor-body.is-fullscreen { position: fixed; @@ -278,9 +310,10 @@ textarea.md-input { .editor-toolbar { flex: 0 0 auto; } - .editor-grid { flex: 1; min-height: 0; } + .editor-grid { flex: 1; height: auto; min-height: 0; } - .write-pane, .preview-pane { min-height: 0; overflow-y: auto; } + // the screen decides how tall a pane is here, not --editor-pane-h + .write-pane, .preview-pane { height: auto; min-height: 0; overflow-y: auto; } textarea.md-input { height: 100%; min-height: 0; resize: none; } } diff --git a/frontend/app/assets/css/_home.scss b/frontend/app/assets/css/_home.scss index 9f998db..ca6c414 100644 --- a/frontend/app/assets/css/_home.scss +++ b/frontend/app/assets/css/_home.scss @@ -8,10 +8,24 @@ inset: -120px -20% auto -20%; height: 480px; pointer-events: none; - background: - radial-gradient(38% 60% at 30% 30%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 70%), - radial-gradient(34% 55% at 72% 22%, color-mix(in srgb, var(--accent-2) 14%, transparent), transparent 70%); - filter: blur(30px); + } +} + +// the index page hangs its hero off the middle of the reading surface: only +// `/` carries `.is-home`, so the stretch below stays here and the other pages +// that share `.home-main` (the shelf, the two admin desks) are untouched. +// `.app-main` already owns the viewport's height (flex: 1 beside the sidebar +// on desktop, under the top bar on mobile); the container grows into it and +// centres the page's block in what's left. +.app-main:has(.home-main.is-home) { + display: flex; + flex-direction: column; + + .container { + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; } } @@ -88,6 +102,18 @@ &:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); } } +// the hero box opens the search popup (the Ctrl/⌘+K one) instead of taking +// text, so it is a button wearing the search bar: the label sits where typed +// text would, in the placeholder's colour +.search-input.search-trigger { + font: inherit; + text-align: left; + cursor: pointer; + color: var(--faint); + + &:hover { border-color: var(--accent); } +} + .search-kbd { position: absolute; right: 16px; diff --git a/frontend/app/assets/css/_layout.scss b/frontend/app/assets/css/_layout.scss index ebf310d..d0e6f79 100644 --- a/frontend/app/assets/css/_layout.scss +++ b/frontend/app/assets/css/_layout.scss @@ -183,6 +183,15 @@ min-height: 39px; // 8+8 padding + 14.4px x 1.6 label line } + // the waiting count is not carried off with the labels: pinned to the + // icon's corner, it stays a dot on the rail + .sidebar-requests .requests-count { + position: absolute; + top: 2px; + right: 2px; + margin-left: 0; + } + .sidebar-nav .btn { min-height: 35px; // 7+7 padding + 13.12px x 1.6 label line } @@ -211,13 +220,14 @@ html.sidebar-collapsed .hide-collapsed { display: none; transition: none; } } -// ---------- "Open" articles: the count beside the button that shows them ---------- +// ---------- Sidebar tallies: articles open, requests waiting ---------- .sidebar-open.is-current { color: var(--accent); background: color-mix(in srgb, var(--accent) 9%, transparent); } -.tabs-count { +.tabs-count, +.requests-count { margin-left: auto; min-width: 18px; height: 18px; @@ -232,6 +242,10 @@ font-variant-numeric: tabular-nums; } +// the waiting pile is the very reason to open the desk, so its number stays +// on the collapsed rail — the link is the frame it rides the corner of +.sidebar-requests { position: relative; } + .logo { display: flex; align-items: center; diff --git a/frontend/app/assets/css/_profile.scss b/frontend/app/assets/css/_profile.scss index 38fc009..c20f6c8 100644 --- a/frontend/app/assets/css/_profile.scss +++ b/frontend/app/assets/css/_profile.scss @@ -3,11 +3,13 @@ // ---------- Profile popup ---------- // The account row in the sidebar opens it; it draws and closes in the same // shape as the settings popup. Holds the profile picture (a data URL kept on -// the account), the username, the password, and the way out. +// the account), the username, the password, the interface language, and the +// way out. Cut wider than the settings sheet so the language's five choices +// fit in one row, and taller so the sections need no scrolling. .profile-overlay { @include overlay(120); } .profile-modal { - @include sheet(460px, min(620px, calc(100vh - 110px))); + @include sheet(540px, min(720px, calc(100vh - 110px))); &:focus, &:focus-visible { outline: none; } } diff --git a/frontend/app/assets/css/_proposals.scss b/frontend/app/assets/css/_proposals.scss new file mode 100644 index 0000000..62a4951 --- /dev/null +++ b/frontend/app/assets/css/_proposals.scss @@ -0,0 +1,214 @@ +// ---------- Admin dashboard: the edit requests members have sent in ---------- +// One entry per request: who wants what, which parts of the page it touches, +// the reviewers' buttons while it waits — and, opened up, the change itself +// laid out as two pages side by side. + +.proposal-list { + display: grid; + gap: 10px; + margin: 0 0 4px; + padding: 0; + list-style: none; +} + +.proposal-entry { + padding: 12px 16px; + border-radius: 14px; + border: 1px solid var(--border); + background: var(--surface); + box-shadow: var(--shadow-sm); + + // a waiting request is the accent one, so the pile reads at a glance + &.is-pending { border-color: color-mix(in srgb, var(--accent) 34%, var(--border)); } + + // decided entries recede: the record stays, the call is already made + &.is-approved, + &.is-rejected { background: color-mix(in srgb, var(--surface-2) 45%, var(--surface)); } +} + +.proposal-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.proposal-summary { + flex: 1; + min-width: 0; + font-size: 0.9rem; + color: var(--muted); + + .proposal-proposer { color: var(--text); font-weight: 650; } + + .proposal-article-link { + color: var(--accent); + font-weight: 650; + text-decoration: none; + + &:hover { text-decoration: underline; text-underline-offset: 2px; } + } + + // a requested page has no address to follow yet: its title is written as + // plain text, not as a link to something that is not there + .proposal-new { color: var(--text); font-weight: 650; } +} + +// the mark on a request for a page the wiki does not have: dashed rather than +// filled, because the page is only a promise until somebody approves it +.proposal-kind { + flex: none; + padding: 2px 9px; + border: 1px dashed color-mix(in srgb, var(--accent) 45%, var(--border)); + border-radius: 999px; + color: var(--accent); + font-size: 0.66rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.proposal-status { + flex-shrink: 0; + padding: 3px 11px; + border-radius: 999px; + background: var(--surface-2); + color: var(--muted); + font-size: 0.75rem; + font-weight: 700; + + &.status-pending { + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); + } + + &.status-approved { + background: color-mix(in srgb, var(--success) 14%, transparent); + color: var(--success); + } + + &.status-rejected { + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); + } +} + +.proposal-date { flex-shrink: 0; font-size: 0.78rem; color: var(--faint); } + +.proposal-decided { flex-shrink: 0; font-size: 0.76rem; font-style: italic; color: var(--faint); } + +.proposal-actions { + margin-left: auto; + flex: none; + display: inline-flex; + align-items: center; + gap: 8px; +} + +// applying a proposal is the yes of the pair — solid, the way the wiki's +// other primary asks are; refusing stays the quiet ghost beside it +.btn-approve { + background: var(--success); + color: #fff; + box-shadow: 0 6px 18px -8px color-mix(in srgb, var(--success) 65%, transparent); + + &:hover { filter: brightness(1.08); } +} + +// the page moved after the request was sent — approving overwrites that +.proposal-stale { font-size: 0.78rem; font-style: italic; color: var(--danger); } + +.proposal-diff { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; + margin-top: 14px; + padding-top: 14px; + border-top: 1px dashed var(--border); +} + +.proposal-diff-col { min-width: 0; } + +.diff-heading { + margin: 0 0 10px; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + + &.is-before { color: var(--faint); } + &.is-after { color: var(--success); } +} + +.diff-title { + margin: 0 0 8px; + font-size: 1rem; + color: var(--text); + + del { color: var(--faint); text-decoration-color: color-mix(in srgb, var(--danger) 60%, transparent); } +} + +.diff-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0 0 12px; +} + +.diff-tag { + padding: 2px 9px; + border-radius: 7px; + background: var(--surface-2); + color: var(--muted); + font-size: 0.72rem; + font-weight: 600; +} + +// the two pages: tall articles scroll inside their frame rather than +// stretching the dashboard out without end +.diff-body { + max-height: 420px; + overflow-y: auto; + padding: 2px 16px 14px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-elev); +} + +// the half of a creation's diff where a page would go: the frame the other +// side fills with text, holding only the words that say it is not written yet +.diff-absent { + margin: 16px 0; + font-size: 0.85rem; + font-style: italic; + color: var(--faint); +} + +// the fold over the decided requests: a heading you open, nothing louder +.proposal-history-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 20px 0 12px; + padding: 4px 2px; + border: none; + background: none; + color: var(--muted); + font-size: 0.85rem; + font-weight: 700; + letter-spacing: -0.01em; + cursor: pointer; + + &:hover { color: var(--text); } + + svg { transition: transform 0.18s ease; } + svg.turned { transform: rotate(90deg); } +} + +// the request row's skeleton, in the shape of one entry +.proposal-skeleton { height: 74px; } + +@media (max-width: 720px) { + .proposal-diff { grid-template-columns: 1fr; } + .proposal-actions { margin-left: 0; } +} diff --git a/frontend/app/assets/css/_settings.scss b/frontend/app/assets/css/_settings.scss index abb1004..6fdaf33 100644 --- a/frontend/app/assets/css/_settings.scss +++ b/frontend/app/assets/css/_settings.scss @@ -32,8 +32,9 @@ .settings-group-hint { font-size: 0.8rem; color: var(--muted); } -// segmented light / dark switch -.theme-switch { @include segmented; } +// segmented control: the settings' light / dark switch and the profile's +// language row; wraps so a narrow screen never clips the last choice +.theme-switch { @include segmented; flex-wrap: wrap; } .theme-option { display: inline-flex; diff --git a/frontend/app/assets/css/_states.scss b/frontend/app/assets/css/_states.scss index 6046d66..0d5545e 100644 --- a/frontend/app/assets/css/_states.scss +++ b/frontend/app/assets/css/_states.scss @@ -16,6 +16,14 @@ h3 { margin: 0 0 6px; color: var(--text); font-size: 1.15rem; } p { margin: 0 auto 22px; max-width: 42ch; font-size: 0.93rem; } + + // two ways forward from a dead end, side by side + .empty-actions { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 12px; + } } .banner { @@ -37,6 +45,32 @@ border-radius: 5px; font-size: 0.85em; } + + // a rule the same size and shape, in another temper: green once a change + // has landed somewhere, blue for what the page wants you to know first + &.banner-success { + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); + background: color-mix(in srgb, var(--success) 7%, var(--surface)); + + .glyph { color: var(--success); } + } + + &.banner-info { + border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); + background: color-mix(in srgb, var(--accent) 6%, var(--surface)); + + .glyph { color: var(--accent); } + } + + .banner-link { + color: var(--accent); + font-weight: 650; + text-decoration: underline; + text-underline-offset: 2px; + white-space: nowrap; + + &:hover { color: var(--accent-strong); } + } } .skeleton { border-radius: 16px; height: 172px; @include shimmer; } diff --git a/frontend/app/assets/css/_tokens.scss b/frontend/app/assets/css/_tokens.scss index c7df3f3..85b78e0 100644 --- a/frontend/app/assets/css/_tokens.scss +++ b/frontend/app/assets/css/_tokens.scss @@ -19,6 +19,7 @@ --accent-strong: #3d4dd6; --accent-2: #0ea5c9; --danger: #d1344b; + --success: #189a5c; --code-bg: #f4f6fa; --shadow-sm: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%); --shadow-lg: 0 4px 12px rgb(16 24 40 / 8%), 0 16px 40px -12px rgb(16 24 40 / 18%); @@ -58,6 +59,7 @@ --accent-strong: #99a5ff; --accent-2: #38d3ee; --danger: #f47085; + --success: #3fce8b; --code-bg: #0c0f16; --shadow-sm: 0 1px 2px rgb(0 0 0 / 30%); --shadow-lg: 0 4px 16px rgb(0 0 0 / 40%), 0 24px 48px -16px rgb(0 0 0 / 55%); diff --git a/frontend/app/assets/css/main.scss b/frontend/app/assets/css/main.scss index a1fa353..75a9dab 100644 --- a/frontend/app/assets/css/main.scss +++ b/frontend/app/assets/css/main.scss @@ -20,6 +20,7 @@ @use 'langs'; // badges, chips, version & language menus @use 'states'; // empty, banner, skeleton @use 'admin'; // the accounts dashboard +@use 'proposals'; // the dashboard's edit requests from members @use 'article'; // article page, ⋮ menu, TOC, recently viewed @use 'prose'; // rendered markdown, highlight.js, KaTeX @use 'editor'; // editor, cheatsheet, confirm, fullscreen diff --git a/frontend/app/components/AppHeader.vue b/frontend/app/components/AppHeader.vue index 41a4edb..69b4706 100644 --- a/frontend/app/components/AppHeader.vue +++ b/frontend/app/components/AppHeader.vue @@ -2,8 +2,9 @@ const { collapsed, toggleSidebar, restoreSidebar } = useSidebar() const { show: openSearch } = useSearch() const { show: openSettings } = useSettings() -const { tabs, lastSlug } = useArticleTabs() +const { tabs, lastIdentity } = useArticleTabs() const { user, isAdmin, signOut } = useAuth() +const { pending: waitingRequests, refresh: refreshPending } = usePendingRequests() const { show: openSignIn } = useSignInPopup() const { show: openProfile } = useProfileMenu() const { t } = useI18n() @@ -15,21 +16,43 @@ const router = useRouter() lays their pages side by side, older ones parked with only their spines showing. The stack stays open all session, but the view of it is only drawn in the reader — so this button is the way back: press it wherever you are - (the graph, the list, the editor) and every article you have open comes back - into view, stacked as you left it. */ -const inReader = computed(() => wikiSlugFromPath(route.path) !== null) + (the graph, the list, the editor, a missing article) and every article you + have open comes back into view, stacked as you left it. In view only when the + route names one of the open pages: a missing article reads like the reader + but holds none of them, so the button still turns back from there. */ +const inReader = computed(() => tabAt(tabs.value, route.path) !== null) function showOpenArticles() { if (inReader.value) return // the pages are already spread out in view /* turn back to the page that was last in view — every tab knows its own address, language versions included */ const target = - tabs.value.find((t) => t.slug === lastSlug.value) ?? tabs.value[tabs.value.length - 1] + tabs.value.find((t) => t.identity === lastIdentity.value) ?? tabs.value[tabs.value.length - 1] if (target) void router.push(tabHref(target)) } -/* the shortcut hint beside the search bar — "/" keeps focus on the home - page's inline box there, so the popup's universal key is Ctrl/⌘+K */ +/* How many requests are waiting is an admin's business: ask once the session + is known to be one — and when a session stops being one, the door goes blank + with the links — and again on every navigation, so the number on the door is + no older than the page under it. While an admin decides at the desk, the + desk itself keeps the count exact (see usePendingRequests). */ +watch( + isAdmin, + () => { + if (import.meta.client) void refreshPending() + }, + { immediate: true } +) +watch( + () => route.path, + () => { + if (import.meta.client && isAdmin.value) void refreshPending() + } +) + +/* the shortcut hint beside the search bar — refined on the client to the + device's chord ("/" opens the popup too, just not while a field holds + the caret) */ const kbdHint = ref('/') onMounted(() => { kbdHint.value = /Mac|iPhone|iPad/i.test(navigator.userAgent) ? '⌘K' : 'Ctrl K' @@ -100,6 +123,7 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC @@ -109,6 +133,20 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC {{ t('sidebar.admin') }} + + + {{ t('sidebar.requests') }} + {{ waitingRequests }} + diff --git a/frontend/app/components/ArticleActionsMenu.vue b/frontend/app/components/ArticleActionsMenu.vue index e624ff2..cf1aec9 100644 --- a/frontend/app/components/ArticleActionsMenu.vue +++ b/frontend/app/components/ArticleActionsMenu.vue @@ -24,6 +24,9 @@ const emit = defineEmits<{ const route = useRoute() const { t } = useI18n() +/* deleting is an admin's call: a member's changes to an article go through + proposals, and dropping the page outright is not among what they may do */ +const { isAdmin } = useAuth() const { open, hide } = useArticleActionsMenu() const { open: langMenuOpen } = useLanguageMenu() const { open: searchOpen } = useSearch() @@ -106,22 +109,24 @@ watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) => :editor-slug="editorSlug" /> -
+
diff --git a/frontend/app/components/ArticleBookStack.vue b/frontend/app/components/ArticleBookStack.vue index a8dc046..8511442 100644 --- a/frontend/app/components/ArticleBookStack.vue +++ b/frontend/app/components/ArticleBookStack.vue @@ -4,8 +4,10 @@ A page turned to slides over the older ones, which park along the left edge with only their spines showing; click anywhere on a page — spine or body — to turn back to it, or × to close it. Routes stay the source of - truth — every page is a plain /wiki/ URL — and useArticleTabs keeps - the stack of pages. + truth — every page is a plain /wiki// URL — and useArticleTabs + keeps the stack of pages, one page per article however many languages the + article speaks: turning one to another language turns this page over, it + does not add one to the row. Pages further along the book than the one in view wait off the right edge of the desk, where you would have to scroll sideways to reach them. Their @@ -31,7 +33,7 @@ const { open: actionsMenuOpen } = useArticleActionsMenu() const { collapsed } = useSidebar() const activeTab = computed(() => tabAt(tabs.value, route.path)) -const activeSlug = computed(() => activeTab.value?.slug ?? null) +const activeIdentity = computed(() => activeTab.value?.identity ?? null) const visible = computed(() => isReaderPath(route.path) && tabs.value.length > 0) const trackEl = ref(null) @@ -44,8 +46,8 @@ function liesFlat() { /** Which page of the book is the one in view, or -1 when the route names * none of them. Only the pages beyond it can be waiting for you. */ const openIndex = computed(() => { - const slug = activeSlug.value - return slug === null ? -1 : tabs.value.findIndex((t) => t.slug === slug) + const identity = activeIdentity.value + return identity === null ? -1 : tabs.value.findIndex((t) => t.identity === identity) }) /** @@ -57,10 +59,10 @@ const openIndex = computed(() => { async function alignToActive(behavior: ScrollBehavior = 'smooth') { await nextTick() const track = trackEl.value - const slug = activeSlug.value - if (!track || !slug || !liesFlat()) return + const identity = activeIdentity.value + if (!track || !identity || !liesFlat()) return - const page = track.querySelector(`[data-slug="${CSS.escape(slug)}"]`) + const page = track.querySelector(`[data-tab="${CSS.escape(identity)}"]`) const i = Number(page?.dataset.index) if (!page || !Number.isFinite(i)) return @@ -77,10 +79,10 @@ async function alignToActive(behavior: ScrollBehavior = 'smooth') { off the row, not guessed at: slide the book and a spine leaves the stack as its page comes into view, and joins it again as the page slides out. */ -/** The slugs of the pages whose spines are in the dock. */ -const waitingSlugs = ref([]) +/** The identities of the pages whose spines are in the dock. */ +const waitingPages = ref([]) /** Those pages, in the order they were opened. */ -const waiting = computed(() => tabs.value.filter((t) => waitingSlugs.value.includes(t.slug))) +const waiting = computed(() => tabs.value.filter((t) => waitingPages.value.includes(t.identity))) /** * Fill the dock with the pages beyond the one in view whose spines have no @@ -91,22 +93,24 @@ function measureDock() { const track = trackEl.value const open = openIndex.value if (!track || open === -1 || !liesFlat()) { - waitingSlugs.value = [] + waitingPages.value = [] return } const edge = track.getBoundingClientRect().right const spine = parseFloat(getComputedStyle(track).getPropertyValue('--spine-w')) || 0 - const held = new Set(waitingSlugs.value) + const held = new Set(waitingPages.value) for (const page of Array.from(track.querySelectorAll('.book-pane'))) { const i = Number(page.dataset.index) - const slug = page.dataset.slug - if (!slug || !Number.isFinite(i) || i <= open) continue + const identity = page.dataset.tab + if (!identity || !Number.isFinite(i) || i <= open) continue const left = page.getBoundingClientRect().left - if (left > edge - 6) held.add(slug) - else if (left < edge - spine - 10) held.delete(slug) + if (left > edge - 6) held.add(identity) + else if (left < edge - spine - 10) held.delete(identity) } - waitingSlugs.value = tabs.value.filter((t, i) => i > open && held.has(t.slug)).map((t) => t.slug) + waitingPages.value = tabs.value + .filter((t, i) => i > open && held.has(t.identity)) + .map((t) => t.identity) } /* the row moves on scroll, so the dock is measured once a frame while it does */ @@ -121,7 +125,7 @@ function watchDock() { onBeforeUnmount(() => cancelAnimationFrame(measuring)) /* turning to an article slides the book round to it */ -watch(activeSlug, () => void alignToActive('smooth')) +watch(activeIdentity, () => void alignToActive('smooth')) /* and the row changing shape — however it changed — changes which pages are waiting out beyond the edge of the desk */ watch(tabs, () => void nextTick(measureDock)) @@ -162,18 +166,21 @@ watch( { immediate: true } ) -function turnTo(slug: string) { - const tab = tabs.value.find((t) => t.slug === slug) - if (tab && slug !== activeSlug.value) void router.push(tabHref(tab)) +function turnTo(identity: string) { + const tab = tabs.value.find((t) => t.identity === identity) + if (tab && identity !== activeIdentity.value) void router.push(tabHref(tab)) } -async function removeArticle(slug: string) { - const title = tabs.value.find((t) => t.slug === slug)?.title ?? slug - if (!window.confirm(t('book.deleteConfirm', { title }))) return +/* the page's × deletes the very version it holds — the document its address + asks for, which for a translation is its own — and closes the page */ +async function removeArticle(identity: string) { + const tab = tabs.value.find((t) => t.identity === identity) + if (!tab) return + if (!window.confirm(t('book.deleteConfirm', { title: tab.title }))) return try { - await $fetch(`${api}/articles/slug/${encodeURIComponent(slug)}`, { method: 'DELETE' }) - forget(slug) - closeTab(slug) + await $fetch(`${api}/articles/slug/${encodeURIComponent(tab.slug)}`, { method: 'DELETE' }) + forget(tab.slug) + closeTab(identity) } catch { window.alert(t('book.deleteFailed')) } @@ -185,7 +192,7 @@ function onGlobalKeydown(e: KeyboardEvent) { if (e.key !== 'Escape') return if (searchOpen.value || settingsOpen.value || profileOpen.value || langMenuOpen.value || actionsMenuOpen.value) return - const current = tabAt(tabs.value, route.path)?.slug + const current = tabAt(tabs.value, route.path)?.identity if (current) closeTab(current) } onMounted(() => window.addEventListener('keydown', onGlobalKeydown)) @@ -202,18 +209,18 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown)) >