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

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