More things

This commit is contained in:
2026-10-01 02:29:21 +02:00
parent e1df4d3ae1
commit 13b659662e
54 changed files with 4409 additions and 911 deletions

View File

@@ -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();

View File

@@ -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);

View File

@@ -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 `<identity>-<language>` 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;

View File

@@ -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;

View File

@@ -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/<slug>. Global so String#matchAll resumes per scan.
// pointing at a wiki address — /wiki/<slug>, or the full address of one
// language version, /wiki/<lang>/<slug>. 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);

1304
backend/src/seed-bulk.js Normal file

File diff suppressed because it is too large Load Diff