diff --git a/backend/api/schemas/group.ts b/backend/api/schemas/group.ts index ea5e4d946..0d64e1cba 100644 --- a/backend/api/schemas/group.ts +++ b/backend/api/schemas/group.ts @@ -28,8 +28,8 @@ export async function registerSchemas(app: FastifyInstance): Promise { match: { type: 'string', description: - 'How the rule addresses pages. `TAG` (any of them) and `TAGALL` (all of them) match on `tags` and ignore `path`; every other kind compares `path` against the page path and ignores `tags`.', - enum: ['START', 'END', 'REGEX', 'TAG', 'TAGALL', 'EXACT'] + 'How the rule addresses pages. `TAG` (any of them) and `TAGALL` (all of them) match on `tags` and ignore `path`; every other kind compares `path` against the page path and ignores `tags`. `SUBTREE` is `path` and everything filed under it, which is `EXACT` and `START` in one rule without the pages that merely begin with it.', + enum: ['START', 'END', 'SUBTREE', 'REGEX', 'TAG', 'TAGALL', 'EXACT'] }, mode: { type: 'string', diff --git a/backend/db/schema.ts b/backend/db/schema.ts index 66461256b..5f725c0b1 100644 --- a/backend/db/schema.ts +++ b/backend/db/schema.ts @@ -67,7 +67,7 @@ export const approvalRules = pgTable( // -> A rule can be turned off without losing what it says, which is how an administrator suspends // suggestions on a section without having to write the rule again afterwards. isEnabled: boolean().notNull().default(true), - // -> One of START / EXACT / END / REGEX / TAG / TAGALL, the same set group page rules use. A + // -> One of START / EXACT / END / REGEX / TAG / TAGALL, matched the way group page rules are. A // varchar rather than an enum so that adding a mode does not need a migration; the API schema // is what rejects an unknown one. match: varchar({ length: 16 }).notNull().default('START'), diff --git a/backend/helpers/pageRules.ts b/backend/helpers/pageRules.ts index 539e220b7..106476aad 100644 --- a/backend/helpers/pageRules.ts +++ b/backend/helpers/pageRules.ts @@ -28,14 +28,20 @@ import type { GroupRule, GroupRuleMatch, GroupRuleMode } from '../models/groups. * * 1. MATCH TYPE, as bands. From weakest to strongest: * - * Path Starts With < Path Ends With < Path Matches Regex < - * Has Any Tag < Has All Tags < Path Is Exactly + * Path Starts With < Path Ends With < Path Is Exactly + Children < + * Path Matches Regex < Has Any Tag < Has All Tags < Path Is Exactly * * The order runs from the vaguest way of naming pages to the most precise: a prefix is a whole * branch of the tree, a tag is something somebody put ON the page to say what it is, and an * exact path is one page and nothing else. * - * Three BANDS, because path length below only settles a contest inside one of them: the three + * Path Is Exactly + Children is a prefix that stops at a folder boundary: `foo/bar` and + * everything filed under it, where Path Starts With would also take in `foo/barometer`. It + * therefore beats the two loose path kinds — it addresses one branch of the tree rather than + * whatever happens to begin or end that way — while a regex, which can say anything either of + * them can and more, still beats it. + * + * Three BANDS, because path length below only settles a contest inside one of them: the four * path-shaped kinds, then the two tag kinds, then Path Is Exactly. A tag rule therefore beats a * prefix rule however deep that prefix is — `confidential` is denied under `docs` as surely as * anywhere else, and a guests group denying the whole site can still be opened on the pages @@ -52,7 +58,7 @@ import type { GroupRule, GroupRuleMatch, GroupRuleMode } from '../models/groups. * * 3. MATCH TYPE AGAIN, to separate two kinds sharing a band at the same specificity: Has All Tags * beats Has Any Tag, since every tag in a list is a stronger claim than any one of them, and a - * regex beats a suffix beats a prefix. + * regex beats a branch beats a suffix beats a prefix. * * 4. MODE, when two rules are equally specific and of the same kind: * @@ -94,7 +100,15 @@ export interface RulePageRef { * Match kinds from weakest to strongest. The index IS the priority, so the order of this array is * the order documented above. */ -const MATCH_PRIORITY: GroupRuleMatch[] = ['START', 'END', 'REGEX', 'TAG', 'TAGALL', 'EXACT'] +const MATCH_PRIORITY: GroupRuleMatch[] = [ + 'START', + 'END', + 'SUBTREE', + 'REGEX', + 'TAG', + 'TAGALL', + 'EXACT' +] /** * Which band of the ordering each kind sits in, weakest first — step 1 above. @@ -105,6 +119,7 @@ const MATCH_PRIORITY: GroupRuleMatch[] = ['START', 'END', 'REGEX', 'TAG', 'TAGAL const MATCH_BAND: Record = { START: 0, END: 0, + SUBTREE: 0, REGEX: 0, TAG: 1, TAGALL: 1, @@ -201,6 +216,13 @@ export function ruleMatchesPage(rule: GroupRule, page: RulePageRef): boolean { return pagePath === rulePath case 'END': return pagePath.endsWith(rulePath) + case 'SUBTREE': { + // -> A page and everything filed under it, which `START` cannot say: a prefix of `foo/bar` + // also takes in `foo/barometer`, so saying this with prefixes takes two rules. The empty + // path is the root of the tree and therefore every page, the same as an empty `START` + const branch = rulePath.replace(/\/+$/, '') + return branch.length < 1 || pagePath === branch || pagePath.startsWith(`${branch}/`) + } case 'REGEX': try { return new RegExp(rulePath).test(pagePath) diff --git a/backend/locales/en.json b/backend/locales/en.json index c2fb0e45f..09daf5854 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -616,6 +616,7 @@ "admin.groups.ruleMatchExact": "Path Is Exactly...", "admin.groups.ruleMatchRegex": "Path Matches Regex...", "admin.groups.ruleMatchStart": "Path Starts With...", + "admin.groups.ruleMatchSubtree": "Path Is Exactly... + Children", "admin.groups.ruleMatchTag": "Has Any Tag...", "admin.groups.ruleMatchTagAll": "Has All Tags...", "admin.groups.rulePath": "Path", diff --git a/backend/models/groups.ts b/backend/models/groups.ts index d1b09d348..39a8d73bf 100644 --- a/backend/models/groups.ts +++ b/backend/models/groups.ts @@ -11,7 +11,7 @@ import type { FastifyRequest } from 'fastify' export const SYSTEM_PERMISSION = 'manage:system' /** How a rule addresses pages: `TAG` and `TAGALL` read `tags`, everything else reads `path`. */ -export type GroupRuleMatch = 'START' | 'END' | 'REGEX' | 'TAG' | 'TAGALL' | 'EXACT' +export type GroupRuleMatch = 'START' | 'END' | 'SUBTREE' | 'REGEX' | 'TAG' | 'TAGALL' | 'EXACT' /** Whether a matching rule grants, denies, or unconditionally grants its roles. */ export type GroupRuleMode = 'ALLOW' | 'DENY' | 'FORCEALLOW' diff --git a/blocks/block-infobox/component.js b/blocks/block-infobox/component.js index 66642c13b..f85211570 100644 --- a/blocks/block-infobox/component.js +++ b/blocks/block-infobox/component.js @@ -224,9 +224,15 @@ Website: https://montreal.ca text-align: center; } + /* + Padded on every side, so the picture reads as something sitting in the box rather than as a + lid on the rows below it. The bottom edge matters as much as the others: the first row of the + list draws no line over it — the card's own border is what closes the header above — so + without it the facts start immediately under the image. + */ figure { margin: 0; - padding: 12px 12px 0; + padding: 12px; text-align: center; } diff --git a/frontend/src/components/GroupEditOverlay.vue b/frontend/src/components/GroupEditOverlay.vue index bad2fdd15..b381c7056 100644 --- a/frontend/src/components/GroupEditOverlay.vue +++ b/frontend/src/components/GroupEditOverlay.vue @@ -403,6 +403,7 @@ :options="[ { label: t('admin.groups.ruleMatchStart'), value: 'START' }, { label: t('admin.groups.ruleMatchEnd'), value: 'END' }, + { label: t('admin.groups.ruleMatchSubtree'), value: 'SUBTREE' }, { label: t('admin.groups.ruleMatchRegex'), value: 'REGEX' }, { label: t('admin.groups.ruleMatchTag'), value: 'TAG' }, { label: t('admin.groups.ruleMatchTagAll'), value: 'TAGALL' }, @@ -439,7 +440,9 @@ standout v-model="rule.path" dense - :prefix="[`START`, `REGEX`, `EXACT`].includes(rule.match) ? `/` : null" + :prefix=" + [`START`, `SUBTREE`, `REGEX`, `EXACT`].includes(rule.match) ? `/` : null + " :suffix="rule.match === `REGEX` ? `/` : null" :aria-label="t(`admin.groups.rulePath`)" /> @@ -1265,7 +1268,7 @@ async function importRules() { id: uuid(), name: r.name || t('admin.groups.ruleUntitled'), mode: ['ALLOW', 'DENY', 'FORCEALLOW'].includes(r.mode) ? r.mode : 'DENY', - match: ['START', 'END', 'REGEX', 'TAG', 'TAGALL', 'EXACT'].includes(r.match) + match: ['START', 'END', 'SUBTREE', 'REGEX', 'TAG', 'TAGALL', 'EXACT'].includes(r.match) ? r.match : 'START', roles: r.roles || [], diff --git a/frontend/src/components/PageHistoryOverlay.vue b/frontend/src/components/PageHistoryOverlay.vue index ebaae4fd4..2e77c9d45 100644 --- a/frontend/src/components/PageHistoryOverlay.vue +++ b/frontend/src/components/PageHistoryOverlay.vue @@ -823,7 +823,8 @@ $timeline-turn: 16px; /* The subway line: one continuous rule behind the dots, drawn by the list rather than the items. */ &-timeline { position: relative; - padding: 1rem 0; + /* -> The extra bottom padding is what the line's turn stops short of; see `bottom` below */ + padding: 1rem 0 calc(#{$timeline-turn} + 1rem); /* The line: down behind the dots, then a quarter turn out to the left edge rather than stopping @@ -843,7 +844,12 @@ $timeline-turn: 16px; content: ''; position: absolute; top: 0; - bottom: 0; + /* + Held off the bottom edge: with enough entries to fill the drawer the turn would otherwise + run into the bottom of the overlay, reading as a line that was cut off rather than one that + ended. + */ + bottom: $timeline-turn; left: 0; width: calc(1rem + 14px + 1px); border-right: 2px solid $timeline-line;