More things
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
87
backend/src/models/EditProposal.js
Normal file
87
backend/src/models/EditProposal.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
1304
backend/src/seed-bulk.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user