mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 02:21:48 +00:00
feat: glossary (wip) + improved permission picker + new library menu
This commit is contained in:
@@ -114,7 +114,7 @@ jobs:
|
||||
ghcr.io/requarks/wiki
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ env.REL_VERSION }}
|
||||
type=raw,value=3.0.0-beta
|
||||
type=raw,value=3.0.0-beta,enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Wiki.js
|
||||
org.opencontainers.image.description=Wiki.js - Next Generation Open Source Wiki
|
||||
@@ -155,9 +155,11 @@ jobs:
|
||||
# BUILD HELM/OCI CHARTS
|
||||
# ---------------------------------------------------
|
||||
- name: Set up Helm
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: azure/setup-helm@v5
|
||||
|
||||
- name: Package and push Helm chart
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
|
||||
@@ -572,7 +572,7 @@ that is already on it needs no permission from anybody.
|
||||
**Page rule permissions** are bound to paths, and to locales and sites: `read:pages`, `write:pages`,
|
||||
`review:pages`, `manage:pages`, `delete:pages`, `write:styles`, `write:scripts`, `read:source`,
|
||||
`read:history`, `read:assets`, `write:assets`, `manage:assets`, `read:comments`, `write:comments`,
|
||||
`manage:comments`, `manage:navigation` (`PAGE_PERMISSIONS` in `api/pages.ts`). A group grants them through **rules**:
|
||||
`manage:comments`, `manage:navigation`, `read:glossary`, `manage:glossary` (`PAGE_PERMISSIONS` in `models/groups.ts`). A group grants them through **rules**:
|
||||
each rule names some of them (`roles`) plus how it addresses pages (`match` + `path`, or tags) and
|
||||
what it does with them (`mode`: ALLOW / DENY / FORCEALLOW). Nothing is granted by default, and when
|
||||
several rules match, the most specific one wins — `helpers/pageRules.ts` documents the ordering.
|
||||
@@ -603,6 +603,22 @@ Consequences worth knowing:
|
||||
it rewrite the menu handed down to it. `api/navigation.ts` has the pair of checks
|
||||
(`mayManageNavAt`, `mayEditNavItems`), and the `inherited` route answers `canEditItems` so the
|
||||
editor knows which of its two halves to offer.
|
||||
- **`read:glossary` and `manage:glossary` ignore the path.** They are the page permissions about a
|
||||
site and a locale rather than a page (`LOCALE_PERMISSIONS` in `models/groups.ts`): every rule naming
|
||||
one that is scoped to the site and locale applies, whatever it matches, and mode alone decides —
|
||||
ALLOW < DENY < FORCE ALLOW. So a DENY written for one folder closes the whole glossary. Ask
|
||||
`groups.checkLocaleAccess(actor, permission, siteId, locale)`; `checkAccess` routes them there too.
|
||||
`manage:glossary` in a locale implies `read:glossary` there. Neither is in the Guests group's seeded
|
||||
DENY rule, where it would outrank any ALLOW an administrator wrote for guests later.
|
||||
- **A page that is not live is its editors' alone.** Live is `published`, or `scheduled` inside its
|
||||
window (`publishStartDate` inclusive, `publishEndDate` exclusive); the dates gate nothing on a
|
||||
`published` page, where `publishStartDate` is what a blog post is dated by. Any other page is seen —
|
||||
render, source, history, comments, a listing or search result — only by a signed-in session holding
|
||||
`write:pages` or `manage:pages` on it; `read:pages` and `read:source` are not enough, and a request
|
||||
without a session (an API key included) never sees one. `helpers/publishing.ts` is the rule:
|
||||
`liveCondition` for SQL, `maySeeUnpublished` per page, and `unpublishedFor(req)` in `api/pages.ts`
|
||||
is what a reader-facing `getPage` passes as `unpublished`. That option defaults to open because most
|
||||
callers are the wiki acting on its own pages, so a new route answering a reader has to pass it.
|
||||
- **An anonymous request is the guests group**, not an absence of groups: that is how a wiki opens
|
||||
reading, and suggesting edits, to the public. Deny guests explicitly where an account is genuinely
|
||||
required (`reviewerFor` in `api/approvals.ts` is the worked example).
|
||||
@@ -1074,8 +1090,8 @@ document is whether the client will run the app, and each has its own function:
|
||||
— and therefore the only half that is cached and handed on.
|
||||
- **`fragmentsForBrowser`** — for a browser that will. The head alone, describing the page as THAT
|
||||
requester may see it (`pages.describePageForRequest`), which makes exactly the cut the page route
|
||||
makes: `read:pages` per path, and any signed-in session sees an unpublished page while an API key is
|
||||
answered as the public is. Never cached, because the answer is one reader's. It carries no body (the
|
||||
makes: `read:pages` per path, and a page that is not live only for a session that may edit it (see
|
||||
[Permissions](#permissions)). Never cached, because the answer is one reader's. It carries no body (the
|
||||
app is about to draw the page properly, so a copy is bytes on every hard navigation for content the
|
||||
browser discards), never 404s (whether a path this reader may create is empty is the app's own flow
|
||||
to present) and adds no page-specific `noindex`.
|
||||
@@ -1190,6 +1206,28 @@ instead. A `num` placeholder that is not a number drops its whole snippet for th
|
||||
a prop that had to be kept out of a browser could not be used by a provider in the first place. This
|
||||
is why `api/analytics.ts` is the one module-prop surface with no `maskSensitiveProps` on the way out.
|
||||
|
||||
### Glossary
|
||||
|
||||
Terms per site and per locale, read and edited in an overlay opened from the header's **Library**
|
||||
menu. **`dev/specs/glossary.md` is the design**; `models/glossary.ts`, `api/glossary.ts`,
|
||||
`components/GlossaryOverlay.vue`.
|
||||
|
||||
- **A name means one term.** A term's name and its aliases share one namespace per site and locale,
|
||||
compared case-insensitively. The unique index covers term against term; aliases are checked by the
|
||||
model, which answers 409 naming the holder. `caseSensitive` is about matching in text, never about
|
||||
uniqueness.
|
||||
- **Related terms are two-way**: one row per pair in `glossaryTermRelations`, smaller id first, read
|
||||
from both sides. Saving a term's list replaces every pair touching it.
|
||||
- **A save names the `updatedAt` it was opened with** (`expectedUpdatedAt`), compared at millisecond
|
||||
precision inside the UPDATE; a mismatch is 409 with the term as it now stands. Dates are written
|
||||
from JS rather than `now()` so that what is stored is what the client was given.
|
||||
- **The definition is basic markdown, rendered in the browser** by `renderers/glossary.js` — the
|
||||
comment renderer's feature set without `nofollow ugc` or mentions. Never HTML.
|
||||
- **The frontend learns access from `GET /sites/:siteId/glossary/access`** (`stores/glossary.js`),
|
||||
once per site and session — the locales the session may read in and may edit in. Neither permission
|
||||
changes from page to page, and the per-page permissions are empty on every route that is not a page.
|
||||
- **`autoLink` is stored and not yet acted on**; the spec's §9 is the design it is waiting for.
|
||||
|
||||
### Comments
|
||||
|
||||
Two things wearing one name, and `models/comments.ts` is the seam between them. **Only one provider
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { CustomError } from '../helpers/common.ts'
|
||||
import { actorFrom, mayBypassPassword, mayOnPage, unlockedFor } from './pages.ts'
|
||||
import { actorFrom, mayBypassPassword, mayOnPage, unlockedFor, unpublishedFor } from './pages.ts'
|
||||
import type { ApprovalPageRef, ApprovalRulePatch, ReviewerScope } from '../models/approvals.ts'
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
|
||||
/**
|
||||
* The page a suggestion is about, with the source it would be edited from.
|
||||
*
|
||||
* Loaded the way the public page route loads it — an anonymous reader sees published pages only, and a
|
||||
* password still has to have been entered — so eligibility to suggest an edit never becomes a way to
|
||||
* Loaded the way the public page route loads it — a page that is not live is there only for whoever
|
||||
* may edit it, and a password still has to have been entered — so eligibility to suggest an edit never becomes a way to
|
||||
* read something that was not readable. The source itself is fetched regardless of who is asking,
|
||||
* because the caller has to be able to edit what they are looking at; the routes below only hand it
|
||||
* over once a rule says this actor may suggest edits to this page.
|
||||
*/
|
||||
async function loadSuggestablePage(req: FastifyRequest, siteId: string, pageId: string) {
|
||||
const actor = actorFrom(req)
|
||||
const page = await WIKI.models.pages.getPage({
|
||||
siteId,
|
||||
id: pageId,
|
||||
withContent: true,
|
||||
publicOnly: !actor,
|
||||
unpublished: unpublishedFor(req),
|
||||
unlocked: (id: string) => unlockedFor(req, id),
|
||||
withPassword: mayBypassPassword(req)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { mayOnPage } from './pages.ts'
|
||||
import { mayOnPage, unpublishedFor } from './pages.ts'
|
||||
import { normalizePagePath } from '../helpers/common.ts'
|
||||
|
||||
const siteIdParam = {
|
||||
@@ -186,7 +186,7 @@ async function routes(app: FastifyInstance) {
|
||||
siteId: req.params.siteId,
|
||||
blog,
|
||||
actor: WIKI.models.groups.actorForRequest(req),
|
||||
publicOnly: !req.session?.authenticated,
|
||||
unpublished: unpublishedFor(req),
|
||||
filter: {
|
||||
tag: req.query.tag,
|
||||
year: req.query.year,
|
||||
|
||||
+11
-4
@@ -1,6 +1,6 @@
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { maskSensitiveProps } from '../helpers/common.ts'
|
||||
import { mayOnPage } from './pages.ts'
|
||||
import { mayOnPage, unpublishedFor } from './pages.ts'
|
||||
import {
|
||||
ANNOTATION_NOTE_MAX_LENGTH,
|
||||
ANNOTATIONS_MAX,
|
||||
@@ -988,14 +988,21 @@ async function requireBuiltInPage(
|
||||
reply.notFound('This site does not use the built-in comments provider.')
|
||||
return null
|
||||
}
|
||||
const page = await WIKI.models.comments.pageRef(req.params.siteId, req.params.pageId)
|
||||
if (!page) {
|
||||
const row = await WIKI.models.comments.pageRef(req.params.siteId, req.params.pageId)
|
||||
if (!row) {
|
||||
reply.notFound('This page does not exist.')
|
||||
return null
|
||||
}
|
||||
// -> Carrying the site, since everything below asks a page rule about this page and a rule may be
|
||||
// limited to particular sites
|
||||
return { ...page, siteId: req.params.siteId }
|
||||
const { isLive, ...ref } = { ...row, siteId: req.params.siteId }
|
||||
// -> A page that is not live is not there for its discussion either, on the page view's own terms
|
||||
const unpublished = unpublishedFor(req)
|
||||
if (!isLive && !(unpublished && unpublished(ref))) {
|
||||
reply.notFound('This page does not exist.')
|
||||
return null
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { GLOSSARY_INPUT_PROPERTIES } from './schemas/glossary.ts'
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import type { GlossaryTermInput, GlossaryWriteResult } from '../models/glossary.ts'
|
||||
|
||||
const siteIdParam = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: { type: 'string', format: 'uuid' }
|
||||
},
|
||||
required: ['siteId']
|
||||
}
|
||||
|
||||
const termIdParam = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: { type: 'string', format: 'uuid' },
|
||||
termId: { type: 'string', format: 'uuid' }
|
||||
},
|
||||
required: ['siteId', 'termId']
|
||||
}
|
||||
|
||||
const localeQuery = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
locale: { type: 'string', minLength: 1, maxLength: 255 }
|
||||
},
|
||||
required: ['locale']
|
||||
}
|
||||
|
||||
const conflictResponse = {
|
||||
description:
|
||||
'Another term already holds one of the names (`nameTaken`), or somebody saved this term since it was opened (`stale`, with the term as it now stands).',
|
||||
$ref: 'GlossaryConflict#'
|
||||
}
|
||||
|
||||
/** Whether the caller may read this site's glossary in this locale — `read:glossary`, path ignored. */
|
||||
function mayRead(req: FastifyRequest, siteId: string, locale: string): boolean {
|
||||
return WIKI.models.groups.checkLocaleAccess(
|
||||
WIKI.models.groups.actorForRequest(req),
|
||||
'read:glossary',
|
||||
siteId,
|
||||
locale
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether the caller may edit this site's glossary in this locale — `manage:glossary`, path ignored. */
|
||||
function mayManage(req: FastifyRequest, siteId: string, locale: string): boolean {
|
||||
return WIKI.models.groups.checkLocaleAccess(
|
||||
WIKI.models.groups.actorForRequest(req),
|
||||
'manage:glossary',
|
||||
siteId,
|
||||
locale
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The checks every write to an existing term makes: the site and its switch, the term, then
|
||||
* `manage:glossary` in the term's own locale. A term the caller may not even read answers 404, as it
|
||||
* does on the read route.
|
||||
*
|
||||
* @returns The term's locale when the handler may go on; when not, the reply has been sent
|
||||
*/
|
||||
async function guardWrite(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
siteId: string,
|
||||
termId: string
|
||||
): Promise<string | null> {
|
||||
if (!WIKI.sites[siteId] || !WIKI.models.glossary.isEnabled(siteId)) {
|
||||
reply.notFound('This site has no glossary.')
|
||||
return null
|
||||
}
|
||||
const locale = await WIKI.models.glossary.localeOf(siteId, termId)
|
||||
if (!locale || !mayRead(req, siteId, locale)) {
|
||||
reply.notFound('This term does not exist.')
|
||||
return null
|
||||
}
|
||||
if (!mayManage(req, siteId, locale)) {
|
||||
reply.forbidden('You may not edit the glossary in this locale.')
|
||||
return null
|
||||
}
|
||||
return locale
|
||||
}
|
||||
|
||||
/**
|
||||
* The checks every locale-scoped read makes, in the order that leaks least: the site, then the
|
||||
* permission, then whether the locale is one the site offers.
|
||||
*
|
||||
* @returns Whether the handler may go on; when not, the reply has been sent
|
||||
*/
|
||||
function guardRead(req: FastifyRequest, reply: FastifyReply, siteId: string, locale: string) {
|
||||
if (!WIKI.sites[siteId]) {
|
||||
reply.notFound('Site does not exist.')
|
||||
return false
|
||||
}
|
||||
if (!mayRead(req, siteId, locale)) {
|
||||
reply.forbidden('You may not read the glossary in this locale.')
|
||||
return false
|
||||
}
|
||||
if (!WIKI.models.glossary.isActiveLocale(siteId, locale)) {
|
||||
reply.badRequest('This locale is not active on this site.')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Send a refused write as the status it stands for. */
|
||||
function sendRefusal(
|
||||
reply: FastifyReply,
|
||||
result: Exclude<GlossaryWriteResult, { ok: true }>
|
||||
): FastifyReply {
|
||||
switch (result.reason) {
|
||||
case 'notFound':
|
||||
return reply.notFound('This term does not exist.')
|
||||
case 'invalidRelated':
|
||||
return reply.badRequest(
|
||||
'A related term does not exist, is in another locale, or is the term itself.'
|
||||
)
|
||||
case 'nameTaken':
|
||||
return reply.code(409).send({
|
||||
ok: false,
|
||||
statusCode: 409,
|
||||
error: 'Conflict',
|
||||
message: `"${result.name}" is already used by the term "${result.holder.term}".`,
|
||||
reason: 'nameTaken',
|
||||
field: result.field,
|
||||
name: result.name,
|
||||
holder: result.holder
|
||||
})
|
||||
case 'stale':
|
||||
return reply.code(409).send({
|
||||
ok: false,
|
||||
statusCode: 409,
|
||||
error: 'Conflict',
|
||||
message: 'This term was changed by somebody else since it was opened.',
|
||||
reason: 'stale',
|
||||
current: result.current
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Glossary API Routes
|
||||
*
|
||||
* A site's glossary, one per locale. See `dev/specs/glossary.md`.
|
||||
*
|
||||
* **Reading** is `read:glossary` and **writing** `manage:glossary`, both PAGE permissions whose rule's
|
||||
* path is ignored: they are asked per site and locale. So no route here declares route-level
|
||||
* `permissions` (the hook reads the group-wide list only), and every one asks
|
||||
* `groups.checkLocaleAccess` in the handler — a write against the locale of the term it touches.
|
||||
*
|
||||
* With `features.glossary` off the reads answer as though there were no terms and the writes 404, so
|
||||
* that the switch hides the glossary without deleting it.
|
||||
*/
|
||||
async function routes(app: FastifyInstance) {
|
||||
/**
|
||||
* GLOSSARY ACCESS
|
||||
*/
|
||||
// -> No route-level permissions: what it reports IS the permission, per locale
|
||||
app.get<{ Params: { siteId: string } }>(
|
||||
'/sites/:siteId/glossary/access',
|
||||
{
|
||||
schema: {
|
||||
summary: 'What the caller may do with the glossary',
|
||||
description:
|
||||
"Whether the site has a glossary, and the active locales the caller may read it in and edit it in. This is what decides whether the interface offers the glossary at all; it is asked once per site and session rather than per page, since neither permission depends on the page.\n\n`read:glossary` and `manage:glossary` are page permissions whose rule's path is ignored: every rule naming one that is scoped to the site and the locale applies, ALLOW < DENY < FORCE ALLOW. `manage:glossary` in a locale implies `read:glossary` there.",
|
||||
tags: ['Glossary'],
|
||||
params: siteIdParam,
|
||||
response: {
|
||||
200: {
|
||||
description: 'Glossary access of the caller',
|
||||
type: 'object',
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
description: 'The site’s `features.glossary` switch.'
|
||||
},
|
||||
readableLocales: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'The active locales the caller may read the glossary in.'
|
||||
},
|
||||
manageableLocales: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'The active locales the caller may edit the glossary in.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const site = WIKI.sites[req.params.siteId]
|
||||
if (!site) {
|
||||
return reply.notFound('Site does not exist.')
|
||||
}
|
||||
const active: string[] = site.config?.locales?.active ?? []
|
||||
return {
|
||||
enabled: WIKI.models.glossary.isEnabled(req.params.siteId),
|
||||
readableLocales: active.filter((locale) => mayRead(req, req.params.siteId, locale)),
|
||||
manageableLocales: active.filter((locale) => mayManage(req, req.params.siteId, locale))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* LIST TERMS
|
||||
*/
|
||||
// -> No route-level permissions: `read:glossary` is a page rule. See the note above.
|
||||
app.get<{ Params: { siteId: string }; Querystring: { locale: string } }>(
|
||||
'/sites/:siteId/glossary',
|
||||
{
|
||||
schema: {
|
||||
summary: 'List the glossary terms of a locale',
|
||||
description:
|
||||
'Every term in the locale, A–Z, with what the glossary’s list shows. The definition and the rest come from the single-term route.\n\nNeeds `read:glossary` in the locale. Empty while the site’s glossary is switched off.',
|
||||
tags: ['Glossary'],
|
||||
params: siteIdParam,
|
||||
querystring: localeQuery,
|
||||
response: {
|
||||
200: {
|
||||
description: 'The terms of the locale, A–Z',
|
||||
type: 'array',
|
||||
items: { $ref: 'GlossaryTermSummary#' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
if (!guardRead(req, reply, req.params.siteId, req.query.locale)) {
|
||||
return reply
|
||||
}
|
||||
if (!WIKI.models.glossary.isEnabled(req.params.siteId)) {
|
||||
return []
|
||||
}
|
||||
return WIKI.models.glossary.list(req.params.siteId, req.query.locale)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* LIST CATEGORIES
|
||||
*/
|
||||
// -> No route-level permissions: `read:glossary` is a page rule. See the note above.
|
||||
app.get<{ Params: { siteId: string }; Querystring: { locale: string } }>(
|
||||
'/sites/:siteId/glossary/categories',
|
||||
{
|
||||
schema: {
|
||||
summary: 'List the glossary categories of a locale',
|
||||
description:
|
||||
'Every category a term in the locale carries, A–Z — what the form suggests.\n\nNeeds `read:glossary` in the locale. Empty while the site’s glossary is switched off.',
|
||||
tags: ['Glossary'],
|
||||
params: siteIdParam,
|
||||
querystring: localeQuery,
|
||||
response: {
|
||||
200: {
|
||||
description: 'Categories in use, A–Z',
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
if (!guardRead(req, reply, req.params.siteId, req.query.locale)) {
|
||||
return reply
|
||||
}
|
||||
if (!WIKI.models.glossary.isEnabled(req.params.siteId)) {
|
||||
return []
|
||||
}
|
||||
return WIKI.models.glossary.categories(req.params.siteId, req.query.locale)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* LOOK UP A TERM BY NAME
|
||||
*/
|
||||
// -> No route-level permissions: `read:glossary` is a page rule. See the note above.
|
||||
app.get<{ Params: { siteId: string }; Querystring: { locale: string; name: string } }>(
|
||||
'/sites/:siteId/glossary/lookup',
|
||||
{
|
||||
schema: {
|
||||
summary: 'Find a glossary term by name',
|
||||
description:
|
||||
'The term a name belongs to, as its own name or as one of its aliases, compared without regard to case. What a link to a term by name resolves through.\n\nNeeds `read:glossary` in the locale.',
|
||||
tags: ['Glossary'],
|
||||
params: siteIdParam,
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
locale: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
name: { type: 'string', minLength: 1, maxLength: 255 }
|
||||
},
|
||||
required: ['locale', 'name']
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'The term the name belongs to',
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
if (!guardRead(req, reply, req.params.siteId, req.query.locale)) {
|
||||
return reply
|
||||
}
|
||||
const id = WIKI.models.glossary.isEnabled(req.params.siteId)
|
||||
? await WIKI.models.glossary.lookup(req.params.siteId, req.query.locale, req.query.name)
|
||||
: null
|
||||
if (!id) {
|
||||
return reply.notFound('No term goes by this name.')
|
||||
}
|
||||
return { id }
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* GET A TERM
|
||||
*/
|
||||
// -> No route-level permissions: `read:glossary` is a page rule. See the note above.
|
||||
app.get<{ Params: { siteId: string; termId: string } }>(
|
||||
'/sites/:siteId/glossary/:termId',
|
||||
{
|
||||
schema: {
|
||||
summary: 'Get a glossary term',
|
||||
description:
|
||||
'A term in full: its definition, related terms (from both sides), documentation page as it now resolves, and references.\n\nNeeds `read:glossary` in the term’s locale.',
|
||||
tags: ['Glossary'],
|
||||
params: termIdParam,
|
||||
response: {
|
||||
200: {
|
||||
description: 'The term',
|
||||
$ref: 'GlossaryTerm#'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const term = WIKI.models.glossary.isEnabled(req.params.siteId)
|
||||
? await WIKI.models.glossary.get(req.params.siteId, req.params.termId)
|
||||
: null
|
||||
// -> One answer for a term that is not there and one the caller may not read: the difference
|
||||
// is not something to tell whoever is asking
|
||||
if (!term || !mayRead(req, req.params.siteId, term.locale)) {
|
||||
return reply.notFound('This term does not exist.')
|
||||
}
|
||||
return term
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* CREATE A TERM
|
||||
*/
|
||||
// -> No route-level permissions: `manage:glossary` is a page rule. See the note above.
|
||||
app.post<{ Params: { siteId: string }; Body: GlossaryTermInput & { locale: string } }>(
|
||||
'/sites/:siteId/glossary',
|
||||
{
|
||||
schema: {
|
||||
summary: 'Create a glossary term',
|
||||
description:
|
||||
'Needs `manage:glossary` in the locale.\n\nThe term’s name and its aliases must not be held by any other term of the locale, as a name or an alias, compared without regard to case; a clash answers 409 naming the term that holds it.\n\nThe locale is fixed here for good: a term in another locale is a different term.',
|
||||
tags: ['Glossary'],
|
||||
params: siteIdParam,
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['locale', 'term'],
|
||||
properties: {
|
||||
locale: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
...GLOSSARY_INPUT_PROPERTIES
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'The term as created',
|
||||
$ref: 'GlossaryTerm#'
|
||||
},
|
||||
409: conflictResponse
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { siteId } = req.params
|
||||
if (!WIKI.sites[siteId] || !WIKI.models.glossary.isEnabled(siteId)) {
|
||||
return reply.notFound('This site has no glossary.')
|
||||
}
|
||||
if (!mayManage(req, siteId, req.body.locale)) {
|
||||
return reply.forbidden('You may not edit the glossary in this locale.')
|
||||
}
|
||||
if (!WIKI.models.glossary.isActiveLocale(siteId, req.body.locale)) {
|
||||
return reply.badRequest('This locale is not active on this site.')
|
||||
}
|
||||
const result = await WIKI.models.glossary.create(
|
||||
siteId,
|
||||
req.body.locale,
|
||||
req.body,
|
||||
req.session?.user?.id ?? null
|
||||
)
|
||||
if (!result.ok) {
|
||||
return sendRefusal(reply, result)
|
||||
}
|
||||
await audit(req, 'glossary', 'createGlossaryTerm', {
|
||||
siteId,
|
||||
locale: result.term.locale,
|
||||
termId: result.term.id,
|
||||
term: result.term.term
|
||||
})
|
||||
return result.term
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* UPDATE A TERM
|
||||
*/
|
||||
// -> No route-level permissions: `manage:glossary` is a page rule. See the note above.
|
||||
app.put<{
|
||||
Params: { siteId: string; termId: string }
|
||||
Body: GlossaryTermInput & { expectedUpdatedAt: string }
|
||||
}>(
|
||||
'/sites/:siteId/glossary/:termId',
|
||||
{
|
||||
schema: {
|
||||
summary: 'Update a glossary term',
|
||||
description:
|
||||
'Needs `manage:glossary` in the term’s locale.\n\nReplaces the term, related terms included: a term taken out of `relatedTerms` loses this one from its own list too.\n\n`expectedUpdatedAt` is the `updatedAt` the term was opened with. When somebody has saved it since, nothing is written and the answer is 409 with the term as it now stands; re-sending with that `updatedAt` saves over it.',
|
||||
tags: ['Glossary'],
|
||||
params: termIdParam,
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['term', 'expectedUpdatedAt'],
|
||||
properties: {
|
||||
...GLOSSARY_INPUT_PROPERTIES,
|
||||
expectedUpdatedAt: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'The term as saved',
|
||||
$ref: 'GlossaryTerm#'
|
||||
},
|
||||
409: conflictResponse
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { siteId, termId } = req.params
|
||||
if (!(await guardWrite(req, reply, siteId, termId))) {
|
||||
return reply
|
||||
}
|
||||
const result = await WIKI.models.glossary.update(
|
||||
siteId,
|
||||
termId,
|
||||
req.body,
|
||||
req.body.expectedUpdatedAt,
|
||||
req.session?.user?.id ?? null
|
||||
)
|
||||
if (!result.ok) {
|
||||
return sendRefusal(reply, result)
|
||||
}
|
||||
await audit(req, 'glossary', 'updateGlossaryTerm', {
|
||||
siteId,
|
||||
locale: result.term.locale,
|
||||
termId,
|
||||
term: result.term.term,
|
||||
changedFields: result.changedFields
|
||||
})
|
||||
return result.term
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE A TERM
|
||||
*/
|
||||
// -> No route-level permissions: `manage:glossary` is a page rule. See the note above.
|
||||
app.delete<{ Params: { siteId: string; termId: string } }>(
|
||||
'/sites/:siteId/glossary/:termId',
|
||||
{
|
||||
schema: {
|
||||
summary: 'Delete a glossary term',
|
||||
description:
|
||||
'Needs `manage:glossary` in the term’s locale. The term leaves every other term’s related list with it.',
|
||||
tags: ['Glossary'],
|
||||
params: termIdParam,
|
||||
response: {
|
||||
200: {
|
||||
description: 'Term deleted',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { siteId, termId } = req.params
|
||||
if (!(await guardWrite(req, reply, siteId, termId))) {
|
||||
return reply
|
||||
}
|
||||
const deleted = await WIKI.models.glossary.remove(siteId, termId)
|
||||
if (!deleted) {
|
||||
return reply.notFound('This term does not exist.')
|
||||
}
|
||||
await audit(req, 'glossary', 'deleteGlossaryTerm', {
|
||||
siteId,
|
||||
locale: deleted.locale,
|
||||
termId,
|
||||
term: deleted.term
|
||||
})
|
||||
return { ok: true }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default routes
|
||||
@@ -15,6 +15,7 @@ async function routes(app: FastifyInstance) {
|
||||
await import('./schemas/comments.ts').then((m) => m.registerSchemas(app))
|
||||
await import('./schemas/extension.ts').then((m) => m.registerSchemas(app))
|
||||
await import('./schemas/flags.ts').then((m) => m.registerSchemas(app))
|
||||
await import('./schemas/glossary.ts').then((m) => m.registerSchemas(app))
|
||||
await import('./schemas/group.ts').then((m) => m.registerSchemas(app))
|
||||
await import('./schemas/hook.ts').then((m) => m.registerSchemas(app))
|
||||
await import('./schemas/icon.ts').then((m) => m.registerSchemas(app))
|
||||
@@ -43,6 +44,7 @@ async function routes(app: FastifyInstance) {
|
||||
app.register(import('./blogs.ts'))
|
||||
app.register(import('./bootstrap.ts'), { prefix: '/bootstrap' })
|
||||
app.register(import('./comments.ts'))
|
||||
app.register(import('./glossary.ts'))
|
||||
app.register(import('./groups.ts'), { prefix: '/groups' })
|
||||
app.register(import('./hooks.ts'), { prefix: '/hooks' })
|
||||
app.register(import('./icons.ts'), { prefix: '/icons' })
|
||||
|
||||
+34
-18
@@ -10,6 +10,7 @@ import {
|
||||
type SearchTagsMatch
|
||||
} from '../models/search.ts'
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { maySeeUnpublished, maySeeUnpublishedAnywhere } from '../helpers/publishing.ts'
|
||||
import { generatePathHash, normalizePagePath } from '../helpers/common.ts'
|
||||
import { limitAuthAttempts, limitRenders } from '../helpers/rateLimit.ts'
|
||||
|
||||
@@ -152,6 +153,26 @@ export function mayReadSource(req: FastifyRequest, page: RulePageRef): boolean {
|
||||
return SOURCE_PERMISSIONS.some((permission) => mayOnPage(req, permission, page))
|
||||
}
|
||||
|
||||
/**
|
||||
* What `getPage`'s `unpublished` is for this requester: whether a page that is not live — a draft, or
|
||||
* a scheduled page outside its window — may be handed to them.
|
||||
*
|
||||
* Only to a signed-in session holding `write:pages` or `manage:pages` on the page itself, which are
|
||||
* the people working on it. Reading it, its source included, is not enough. A request without a
|
||||
* session is held to live pages outright, API keys included — see `helpers/publishing.ts`.
|
||||
*/
|
||||
export function unpublishedFor(req: FastifyRequest): false | ((page: RulePageRef) => boolean) {
|
||||
if (!actorFrom(req)) {
|
||||
return false
|
||||
}
|
||||
const actor = WIKI.models.groups.actorForRequest(req)
|
||||
// -> Settled in SQL for the reader who may edit nothing, which is most of them
|
||||
if (!maySeeUnpublishedAnywhere(actor)) {
|
||||
return false
|
||||
}
|
||||
return (page) => maySeeUnpublished(actor, page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every page permission this requester holds at a path.
|
||||
*
|
||||
@@ -181,16 +202,15 @@ export function pagePermissionsFor(req: FastifyRequest, page: RulePageRef): stri
|
||||
/**
|
||||
* A page, as this requester is allowed to see it — or null when they are not allowed to see it at all.
|
||||
*
|
||||
* The gate for anything that hangs off a page but is not the page itself. An anonymous requester only
|
||||
* ever reaches a published page, and a password-protected one comes back with `isLocked` set until the
|
||||
* The gate for anything that hangs off a page but is not the page itself. A page that is not live is
|
||||
* only reached by whoever may edit it (`unpublishedFor`), and a password-protected one comes back with `isLocked` set until the
|
||||
* session has satisfied the unlock, which the caller is expected to refuse on.
|
||||
*/
|
||||
async function loadReadablePage(req: FastifyRequest, siteId: string, pageId: string) {
|
||||
const actor = actorFrom(req)
|
||||
const page = await WIKI.models.pages.getPage({
|
||||
siteId,
|
||||
id: pageId,
|
||||
publicOnly: !actor,
|
||||
unpublished: unpublishedFor(req),
|
||||
unlocked: (id: string) => unlockedFor(req, id)
|
||||
})
|
||||
// -> Not readable is indistinguishable from not there, for anything hanging off the page
|
||||
@@ -447,8 +467,6 @@ async function routes(app: FastifyInstance) {
|
||||
}
|
||||
},
|
||||
async (req) => {
|
||||
const actor = actorFrom(req)
|
||||
const permissions = actor?.permissions ?? []
|
||||
return WIKI.models.search.searchPages({
|
||||
siteId: req.params.siteId,
|
||||
query: req.query.query,
|
||||
@@ -464,13 +482,11 @@ async function routes(app: FastifyInstance) {
|
||||
orderByDirection: req.query.orderByDirection,
|
||||
offset: req.query.offset,
|
||||
limit: req.query.limit,
|
||||
publicOnly: !actor,
|
||||
// -> So that a page the caller could not open never shows up as a result
|
||||
actor: WIKI.models.groups.actorForRequest(req),
|
||||
// -> An unpublished page is only of interest to someone who could have written it
|
||||
includeDrafts: ['write:pages', 'manage:pages', 'manage:system'].some((p) =>
|
||||
permissions.includes(p)
|
||||
),
|
||||
// -> The page view's rule, asked per result: a page that is not live is only listed for
|
||||
// whoever may edit it, and never without a session
|
||||
unpublished: Boolean(actorFrom(req)),
|
||||
// -> Same rule as the page view: a protected page's text is for whoever holds the password, and
|
||||
// a search excerpt is that text. Its title and description are not covered, so the page is
|
||||
// still listed — see `hideProtectedContent`.
|
||||
@@ -488,7 +504,7 @@ async function routes(app: FastifyInstance) {
|
||||
schema: {
|
||||
summary: 'Get a page for inclusion',
|
||||
description:
|
||||
"What an include block needs to draw another page inside the one being read: its title and its stored render, addressed by path rather than by ID, since a path is what an author writes into the page.\n\nThe reader's own access decides the answer, exactly as it would if they opened the page themselves — an anonymous request only ever sees published pages, and a password-protected page comes back with `isLocked: true` and no body unless this session has already unlocked it. So an include can never show content its reader could not have reached on their own.",
|
||||
"What an include block needs to draw another page inside the one being read: its title and its stored render, addressed by path rather than by ID, since a path is what an author writes into the page.\n\nThe reader's own access decides the answer, exactly as it would if they opened the page themselves — a draft, or a scheduled page outside its publishing window, is only there for a signed-in requester holding `write:pages` or `manage:pages` on it, and a password-protected page comes back with `isLocked: true` and no body unless this session has already unlocked it. So an include can never show content its reader could not have reached on their own.",
|
||||
tags: ['Pages'],
|
||||
params: siteIdParam,
|
||||
querystring: {
|
||||
@@ -513,7 +529,6 @@ async function routes(app: FastifyInstance) {
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const actor = actorFrom(req)
|
||||
// -> The stored form of whatever the including page wrote, since that is what it is looked up
|
||||
// by. The site root is the `home` page.
|
||||
const path = normalizePagePath(req.query.path)
|
||||
@@ -521,7 +536,7 @@ async function routes(app: FastifyInstance) {
|
||||
siteId: req.params.siteId,
|
||||
hash: generatePathHash(path || 'home'),
|
||||
locale: req.query.locale,
|
||||
publicOnly: !actor,
|
||||
unpublished: unpublishedFor(req),
|
||||
unlocked: (pageId) => unlockedFor(req, pageId),
|
||||
withPassword: false
|
||||
})
|
||||
@@ -625,7 +640,7 @@ async function routes(app: FastifyInstance) {
|
||||
schema: {
|
||||
summary: 'Get a single page',
|
||||
description:
|
||||
"Addressed either by ID or by the hash of its path, which is how a page view asks for one. A hash only identifies a page within a locale, so `locale` picks between translations — the site's primary one when absent.\n\nReadable without a session, because a wiki is read by people who are not logged in — but an anonymous request only ever sees published pages. `withContent` is answered against `read:source` on the page — or `write:pages`, since the editor loads the source to edit it — which a group's rules grant to whoever they name, guests included.\n\nA password-protected page answers with its metadata and `isLocked: true`, its body withheld, until the session satisfies `POST …/unlock` — or unless the requester may edit the page, for whom the password is not a barrier.",
|
||||
"Addressed either by ID or by the hash of its path, which is how a page view asks for one. A hash only identifies a page within a locale, so `locale` picks between translations — the site's primary one when absent.\n\nReadable without a session, because a wiki is read by people who are not logged in. A page that is not live — a draft, or a scheduled page outside its publishing window — answers 404 to everybody but a signed-in requester holding `write:pages` or `manage:pages` on it, its source included. `withContent` is answered against `read:source` on the page — or `write:pages`, since the editor loads the source to edit it — which a group's rules grant to whoever they name, guests included.\n\nA password-protected page answers with its metadata and `isLocked: true`, its body withheld, until the session satisfies `POST …/unlock` — or unless the requester may edit the page, for whom the password is not a barrier.",
|
||||
tags: ['Pages'],
|
||||
params: {
|
||||
type: 'object',
|
||||
@@ -677,7 +692,9 @@ async function routes(app: FastifyInstance) {
|
||||
withContent: req.query.withContent
|
||||
? (target: RulePageRef) => mayReadSource(req, target)
|
||||
: false,
|
||||
publicOnly: !actor,
|
||||
// -> A draft, or a scheduled page outside its window, is not there for anybody who may not
|
||||
// edit it — which is what keeps its render and its source alike from a reader
|
||||
unpublished: unpublishedFor(req),
|
||||
// -> Answered once the page is known, since a hash does not say which page it is yet
|
||||
unlocked: (pageId) => unlockedFor(req, pageId),
|
||||
withPassword: mayBypassPassword(req)
|
||||
@@ -819,13 +836,12 @@ async function routes(app: FastifyInstance) {
|
||||
},
|
||||
async (req, reply) => {
|
||||
const isId = uuidValidate(req.params.pageIdOrHash)
|
||||
const actor = actorFrom(req)
|
||||
const page = await WIKI.models.pages.unlockPage({
|
||||
siteId: req.params.siteId,
|
||||
...(isId ? { id: req.params.pageIdOrHash } : { hash: req.params.pageIdOrHash }),
|
||||
locale: req.query.locale,
|
||||
password: req.body.password,
|
||||
publicOnly: !actor
|
||||
unpublished: unpublishedFor(req)
|
||||
})
|
||||
if (!page) {
|
||||
return reply.unauthorized('Incorrect password.')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { actorFrom, mayOnPage, unlockedFor } from './pages.ts'
|
||||
import { actorFrom, mayOnPage, unlockedFor, unpublishedFor } from './pages.ts'
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import type { RatingMode } from '../models/pageRatings.ts'
|
||||
import type { Page } from '../models/pages.ts'
|
||||
@@ -51,6 +51,7 @@ async function loadRateable(
|
||||
const page = await WIKI.models.pages.getPage({
|
||||
siteId: req.params.siteId,
|
||||
id: req.params.pageId,
|
||||
unpublished: unpublishedFor(req),
|
||||
unlocked: (id: string) => unlockedFor(req, id)
|
||||
})
|
||||
if (!page || !mayOnPage(req, 'read:pages', page)) {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
GLOSSARY_ALIASES_MAX,
|
||||
GLOSSARY_DEFINITION_MAX_LENGTH,
|
||||
GLOSSARY_NAME_MAX_LENGTH,
|
||||
GLOSSARY_REFERENCES_MAX,
|
||||
GLOSSARY_RELATED_MAX
|
||||
} from '../../models/glossary.ts'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
/** The properties of a term the sidebar lists, shared by the summary and the full term. */
|
||||
const summaryProperties = {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
term: { type: 'string' },
|
||||
expansion: {
|
||||
type: ['string', 'null'],
|
||||
description: 'The full form, when the term is an abbreviation.'
|
||||
},
|
||||
aliases: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description:
|
||||
'Alternate forms and inflections. Unique across every term of the locale, like the term itself.'
|
||||
},
|
||||
category: { type: ['string', 'null'] },
|
||||
caseSensitive: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether the term matches text only with its exact case. About matching in text, never about uniqueness: `REST` and `rest` cannot be two terms either way.'
|
||||
},
|
||||
autoLink: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether the term is linked automatically where it appears in page text. Stored, and not yet acted on.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a create or a save carries, as properties to spread into a route's body schema: the create adds
|
||||
* `locale`, the save `expectedUpdatedAt`. Properties rather than a registered schema, because a body
|
||||
* built from a `$ref` plus a property of its own needs `allOf`, and the coercion and defaults Fastify
|
||||
* applies are less predictable through one.
|
||||
*/
|
||||
export const GLOSSARY_INPUT_PROPERTIES = {
|
||||
term: { type: 'string', minLength: 1, maxLength: GLOSSARY_NAME_MAX_LENGTH },
|
||||
expansion: { type: ['string', 'null'], maxLength: GLOSSARY_NAME_MAX_LENGTH },
|
||||
definition: { type: 'string', maxLength: GLOSSARY_DEFINITION_MAX_LENGTH },
|
||||
aliases: {
|
||||
type: 'array',
|
||||
maxItems: GLOSSARY_ALIASES_MAX,
|
||||
items: { type: 'string', maxLength: GLOSSARY_NAME_MAX_LENGTH },
|
||||
description:
|
||||
'Repeats, and copies of the term itself, are dropped rather than refused. A name another term already holds, as its term or an alias, is refused with 409.'
|
||||
},
|
||||
relatedTerms: {
|
||||
type: 'array',
|
||||
maxItems: GLOSSARY_RELATED_MAX,
|
||||
items: { type: 'string', format: 'uuid' },
|
||||
description:
|
||||
'IDs of terms in the same site and locale. Replaces the whole list, from both sides: a term taken out of this list loses this one from its own.'
|
||||
},
|
||||
documentationPath: {
|
||||
type: ['string', 'null'],
|
||||
maxLength: GLOSSARY_NAME_MAX_LENGTH,
|
||||
description: 'A page path in the term’s own locale, without a locale prefix.'
|
||||
},
|
||||
documentationLabel: { type: ['string', 'null'], maxLength: GLOSSARY_NAME_MAX_LENGTH },
|
||||
references: {
|
||||
type: 'array',
|
||||
maxItems: GLOSSARY_REFERENCES_MAX,
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['url'],
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
maxLength: 2048,
|
||||
pattern: '^https?://',
|
||||
description: '`http:` or `https:` only.'
|
||||
},
|
||||
label: { type: 'string', maxLength: GLOSSARY_NAME_MAX_LENGTH }
|
||||
}
|
||||
}
|
||||
},
|
||||
caseSensitive: { type: 'boolean', default: false },
|
||||
autoLink: { type: 'boolean', default: true },
|
||||
category: { type: ['string', 'null'], maxLength: GLOSSARY_NAME_MAX_LENGTH }
|
||||
}
|
||||
|
||||
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* GLOSSARY TERM SUMMARY - A term as the glossary's list shows it
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'GlossaryTermSummary',
|
||||
type: 'object',
|
||||
properties: summaryProperties
|
||||
})
|
||||
|
||||
/**
|
||||
* GLOSSARY TERM - A term in full
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'GlossaryTerm',
|
||||
type: 'object',
|
||||
properties: {
|
||||
...summaryProperties,
|
||||
siteId: { type: 'string', format: 'uuid' },
|
||||
locale: { type: 'string' },
|
||||
definition: {
|
||||
type: 'string',
|
||||
description: 'Basic markdown, never HTML. Rendered by the client with raw HTML disabled.'
|
||||
},
|
||||
references: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string' },
|
||||
label: { type: 'string', description: 'Empty when the URL stands in for one.' }
|
||||
}
|
||||
}
|
||||
},
|
||||
relatedTerms: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Related terms, which is two-way: a term lists every term that lists it. A–Z by name.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
term: { type: 'string' }
|
||||
}
|
||||
}
|
||||
},
|
||||
documentation: {
|
||||
type: ['object', 'null'],
|
||||
description:
|
||||
'The page in the same locale that documents the term, looked up as it is read. Null when none is named; `exists` false when the named path has no page.',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
label: {
|
||||
type: ['string', 'null'],
|
||||
description: 'The link text. Null for the default, which the client supplies.'
|
||||
},
|
||||
title: { type: ['string', 'null'] },
|
||||
exists: { type: 'boolean' }
|
||||
}
|
||||
},
|
||||
author: {
|
||||
type: ['object', 'null'],
|
||||
description: 'Who saved it last. Null for an API key, or once the account is gone.',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string' }
|
||||
}
|
||||
},
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description:
|
||||
'At millisecond precision. A save names the value it was opened with as `expectedUpdatedAt`.'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GLOSSARY CONFLICT - Why a write answered 409
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'GlossaryConflict',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
statusCode: { type: 'integer' },
|
||||
error: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
reason: {
|
||||
type: 'string',
|
||||
enum: ['nameTaken', 'stale'],
|
||||
description:
|
||||
'`nameTaken`: another term already holds one of the names. `stale`: somebody saved the term after the caller opened it.'
|
||||
},
|
||||
field: {
|
||||
type: 'string',
|
||||
enum: ['term', 'aliases'],
|
||||
description: 'For `nameTaken`, which of the caller’s fields holds the name.'
|
||||
},
|
||||
name: { type: 'string', description: 'For `nameTaken`, the name that is held.' },
|
||||
holder: {
|
||||
type: 'object',
|
||||
description: 'For `nameTaken`, the term holding it.',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
term: { type: 'string' }
|
||||
}
|
||||
},
|
||||
current: {
|
||||
$ref: 'GlossaryTerm#',
|
||||
description: 'For `stale`, the term as it now stands.'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -107,6 +107,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
description:
|
||||
'Whether this site has comments at all. Which provider handles them is `comments.provider`; this turns every one of them off without losing that choice, and a page can still opt out on its own with `allowComments`.'
|
||||
},
|
||||
glossary: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether this site has a glossary. On by default. Off hides it everywhere — the Library menu has no Glossary row and the glossary routes answer as though there were no terms — and keeps every term for when it is turned back on. Who may read it is the `read:glossary` page permission, and who may edit it `manage:glossary`, both granted per locale.'
|
||||
},
|
||||
lastEditedBy: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
|
||||
+9
-11
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'
|
||||
import { TREE_ORDER_BY, type TreeItemType, type TreeOrderBy, type TreeRow } from '../models/tree.ts'
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { decodeTreePath, normalizeFolderPath } from '../helpers/common.ts'
|
||||
import { actorFrom } from './pages.ts'
|
||||
import { actorFrom, unpublishedFor } from './pages.ts'
|
||||
|
||||
interface TreeQuery {
|
||||
parentId?: string
|
||||
@@ -280,7 +280,7 @@ async function routes(app: FastifyInstance) {
|
||||
schema: {
|
||||
summary: 'Get the page graph',
|
||||
description:
|
||||
"Every page of one locale the caller may read, the folders they sit in, and the links between them — what the file manager's graph view draws.\n\nA page that is not published is included only for a caller who may also edit it (`write:pages`), and is marked with `isPublished: false`. A link is included only when the caller may see both of its ends. Links reaching a page in another locale bring that page along, so the far end can be named.",
|
||||
"Every page of one locale the caller may read, the folders they sit in, and the links between them — what the file manager's graph view draws.\n\nA page that is not live — a draft, or a scheduled page outside its publishing window — is included only for a signed-in caller who may also edit it (`write:pages` or `manage:pages`), and is marked with `isPublished: false`. A link is included only when the caller may see both of its ends. Links reaching a page in another locale bring that page along, so the far end can be named.",
|
||||
tags: ['Tree'],
|
||||
params: siteIdParam,
|
||||
querystring: {
|
||||
@@ -301,9 +301,9 @@ async function routes(app: FastifyInstance) {
|
||||
async (req) => {
|
||||
const siteId = req.params.siteId
|
||||
const actor = WIKI.models.groups.actorForRequest(req)
|
||||
// -> A draft belongs to the people working on it, and an API key is answered as the public is
|
||||
// -- the same line the page route draws, via `actorFrom`
|
||||
const isSession = actorFrom(req) !== null
|
||||
// -> A page that is not live belongs to the people working on it, and an API key is answered as
|
||||
// the public is -- the same line the page route draws
|
||||
const unpublished = unpublishedFor(req)
|
||||
return WIKI.models.pageGraph.graphFor({
|
||||
siteId,
|
||||
locale: req.query.locale || defaultLocale(siteId),
|
||||
@@ -312,12 +312,10 @@ async function routes(app: FastifyInstance) {
|
||||
if (!WIKI.models.groups.checkAccess(actor, 'read:pages', ref)) {
|
||||
return null
|
||||
}
|
||||
if (page.publishState === 'published') {
|
||||
if (page.isLive) {
|
||||
return 'published'
|
||||
}
|
||||
return isSession && WIKI.models.groups.checkAccess(actor, 'write:pages', ref)
|
||||
? 'draft'
|
||||
: null
|
||||
return unpublished && unpublished(ref) ? 'draft' : null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -398,7 +396,7 @@ async function routes(app: FastifyInstance) {
|
||||
siteId: req.params.siteId,
|
||||
path: req.query.path,
|
||||
locale: req.query.locale ?? defaultLocale(req.params.siteId),
|
||||
publicOnly: !req.session?.authenticated
|
||||
unpublished: unpublishedFor(req)
|
||||
})
|
||||
if (!level) {
|
||||
return reply.notFound('This folder does not exist.')
|
||||
@@ -513,7 +511,7 @@ async function routes(app: FastifyInstance) {
|
||||
orderBy: req.query.orderBy,
|
||||
orderByDirection: req.query.orderByDirection,
|
||||
depth: req.query.depth,
|
||||
publicOnly: !req.session?.authenticated
|
||||
unpublished: unpublishedFor(req)
|
||||
})
|
||||
// -> An index block is drawn inside a page, but it lists other pages: each one still has to be
|
||||
// the reader's to see
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { actorFrom, mayOnPage, unlockedFor } from './pages.ts'
|
||||
import { actorFrom, mayOnPage, unlockedFor, unpublishedFor } from './pages.ts'
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
|
||||
/**
|
||||
* The page being watched, as this requester is allowed to see it.
|
||||
*
|
||||
* Watching a page is a thing done TO a page, so it goes through the same gate as reading one: an
|
||||
* anonymous requester never gets here at all, and a page somebody may not read is answered as though
|
||||
* it were not there. A password is not part of it — the watcher is asking to be told when the page
|
||||
* anonymous requester never gets here at all, and a page somebody may not read — or may not see
|
||||
* before it is live — is answered as though it were not there. A password is not part of it — the watcher is asking to be told when the page
|
||||
* changes, not to read what it says.
|
||||
*/
|
||||
async function loadWatchablePage(req: FastifyRequest, siteId: string, pageId: string) {
|
||||
const page = await WIKI.models.pages.getPage({
|
||||
siteId,
|
||||
id: pageId,
|
||||
unpublished: unpublishedFor(req),
|
||||
unlocked: (id: string) => unlockedFor(req, id)
|
||||
})
|
||||
if (!page || !mayOnPage(req, 'read:pages', page)) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
CREATE TABLE "glossaryTermRelations" (
|
||||
"termId" uuid,
|
||||
"relatedId" uuid,
|
||||
CONSTRAINT "glossaryTermRelations_pkey" PRIMARY KEY("termId","relatedId"),
|
||||
CONSTRAINT "glossaryTermRelations_order_check" CHECK ("termId" < "relatedId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "glossaryTerms" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"siteId" uuid NOT NULL,
|
||||
"locale" varchar(255) NOT NULL,
|
||||
"term" varchar(255) NOT NULL,
|
||||
"expansion" varchar(255),
|
||||
"definition" text DEFAULT '' NOT NULL,
|
||||
"aliases" text[] DEFAULT ARRAY[]::text[] NOT NULL,
|
||||
"documentationPath" varchar(255),
|
||||
"documentationLabel" varchar(255),
|
||||
"references" jsonb DEFAULT '[]' NOT NULL,
|
||||
"caseSensitive" boolean DEFAULT false NOT NULL,
|
||||
"autoLink" boolean DEFAULT true NOT NULL,
|
||||
"category" varchar(255),
|
||||
"creatorId" uuid,
|
||||
"authorId" uuid,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "glossaryTermRelations_relatedId_idx" ON "glossaryTermRelations" ("relatedId");--> statement-breakpoint
|
||||
CREATE INDEX "glossaryTerms_site_locale_idx" ON "glossaryTerms" ("siteId","locale");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "glossaryTerms_site_locale_term_key" ON "glossaryTerms" ("siteId","locale",lower("term"));--> statement-breakpoint
|
||||
CREATE INDEX "glossaryTerms_documentation_idx" ON "glossaryTerms" ("siteId","locale","documentationPath") WHERE "documentationPath" IS NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "glossaryTermRelations" ADD CONSTRAINT "glossaryTermRelations_termId_glossaryTerms_id_fkey" FOREIGN KEY ("termId") REFERENCES "glossaryTerms"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "glossaryTermRelations" ADD CONSTRAINT "glossaryTermRelations_relatedId_glossaryTerms_id_fkey" FOREIGN KEY ("relatedId") REFERENCES "glossaryTerms"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "glossaryTerms" ADD CONSTRAINT "glossaryTerms_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "glossaryTerms" ADD CONSTRAINT "glossaryTerms_creatorId_users_id_fkey" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||
ALTER TABLE "glossaryTerms" ADD CONSTRAINT "glossaryTerms_authorId_users_id_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE SET NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import {
|
||||
bigint,
|
||||
boolean,
|
||||
bytea,
|
||||
check,
|
||||
customType,
|
||||
index,
|
||||
integer,
|
||||
@@ -365,6 +366,91 @@ export const commentAnnotations = pgTable(
|
||||
(table) => [index('commentAnnotations_commentId_idx').on(table.commentId, table.position)]
|
||||
)
|
||||
|
||||
// GLOSSARY ----------------------------
|
||||
|
||||
/**
|
||||
* One term of a site's glossary, in one locale. See `dev/specs/glossary.md`.
|
||||
*
|
||||
* **A name means one term.** The term and every alias share one namespace per site and locale,
|
||||
* compared with `lower()`. The unique index below is the database's half of that, term against term;
|
||||
* everything involving an alias is checked by the model, since an index cannot reach into an array
|
||||
* and compare it against another row's scalar.
|
||||
*
|
||||
* `caseSensitive` is about how the term is matched IN TEXT and never about uniqueness: `REST` and
|
||||
* `rest` cannot be two terms either way.
|
||||
*/
|
||||
export const glossaryTerms = pgTable(
|
||||
'glossaryTerms',
|
||||
{
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
siteId: uuid()
|
||||
.notNull()
|
||||
.references(() => sites.id, { onDelete: 'cascade' }),
|
||||
locale: varchar({ length: 255 }).notNull(),
|
||||
term: varchar({ length: 255 }).notNull(),
|
||||
/** The full form, when the term is an abbreviation. */
|
||||
expansion: varchar({ length: 255 }),
|
||||
/** Basic markdown source, rendered in the browser with raw HTML disabled. Never HTML. */
|
||||
definition: text().notNull().default(''),
|
||||
aliases: text()
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`ARRAY[]::text[]`),
|
||||
/** A page path in the term's own locale, with no locale prefix. Looked up when read. */
|
||||
documentationPath: varchar({ length: 255 }),
|
||||
/** Null for the default, "Read more", which is a locale string and not stored. */
|
||||
documentationLabel: varchar({ length: 255 }),
|
||||
/** `[{ url, label }]`, in the order given. */
|
||||
references: jsonb().notNull().default([]),
|
||||
caseSensitive: boolean().notNull().default(false),
|
||||
/** Whether the term is linked automatically in page text. Nothing reads it yet. */
|
||||
autoLink: boolean().notNull().default(true),
|
||||
category: varchar({ length: 255 }),
|
||||
creatorId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
/** Who saved it last. */
|
||||
authorId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
/** Also the concurrency token: a save names the one it was opened with. */
|
||||
updatedAt: timestamp().notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
index('glossaryTerms_site_locale_idx').on(table.siteId, table.locale),
|
||||
uniqueIndex('glossaryTerms_site_locale_term_key').on(
|
||||
table.siteId,
|
||||
table.locale,
|
||||
sql`lower(${table.term})`
|
||||
),
|
||||
// -> What a page move with `updateLinks` looks terms up by
|
||||
index('glossaryTerms_documentation_idx')
|
||||
.on(table.siteId, table.locale, table.documentationPath)
|
||||
.where(sql`"documentationPath" IS NOT NULL`)
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* Two related glossary terms. Related is two-way, so a pair is ONE row, read from both sides.
|
||||
*
|
||||
* The check stores the smaller id first, which is what makes "A is related to B" and "B is related
|
||||
* to A" the same row rather than two that could disagree. A join table rather than an array on the
|
||||
* term, so that deleting a term takes it out of every other term's list by cascade alone.
|
||||
*/
|
||||
export const glossaryTermRelations = pgTable(
|
||||
'glossaryTermRelations',
|
||||
{
|
||||
termId: uuid()
|
||||
.notNull()
|
||||
.references(() => glossaryTerms.id, { onDelete: 'cascade' }),
|
||||
relatedId: uuid()
|
||||
.notNull()
|
||||
.references(() => glossaryTerms.id, { onDelete: 'cascade' })
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.termId, table.relatedId] }),
|
||||
index('glossaryTermRelations_relatedId_idx').on(table.relatedId),
|
||||
check('glossaryTermRelations_order_check', sql`${table.termId} < ${table.relatedId}`)
|
||||
]
|
||||
)
|
||||
|
||||
// GROUPS ------------------------------
|
||||
export const groups = pgTable(
|
||||
'groups',
|
||||
|
||||
@@ -381,8 +381,7 @@ async function bodyForBlog(siteId: string, page: PageDescription): Promise<strin
|
||||
const { posts } = await WIKI.models.blogs.postsFor({
|
||||
siteId,
|
||||
blog,
|
||||
actor: WIKI.models.groups.actorForPublic(),
|
||||
publicOnly: true
|
||||
actor: WIKI.models.groups.actorForPublic()
|
||||
})
|
||||
const intro = blog.settings.intro ? `<p>${htmlEscape(blog.settings.intro)}</p>` : ''
|
||||
const items = posts
|
||||
|
||||
@@ -192,16 +192,29 @@ function outranks(a: number[], b: number[]): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/** Whether a rule addresses this page at all, ignoring what it then says about it. */
|
||||
export function ruleMatchesPage(rule: GroupRule, page: RulePageRef): boolean {
|
||||
/** A site and a locale, for the permissions that are about those rather than a page. */
|
||||
export interface RuleLocaleRef {
|
||||
siteId: string
|
||||
locale?: string
|
||||
}
|
||||
|
||||
/** Whether a rule speaks for this site and locale at all — its `sites` and `locales`, nothing else. */
|
||||
function ruleIsScopedTo(rule: GroupRule, scope: RuleLocaleRef): boolean {
|
||||
// -> A rule may be limited to particular sites; an empty list means every one of them
|
||||
if (rule.sites?.length > 0 && !rule.sites.includes(page.siteId)) {
|
||||
if (rule.sites?.length > 0 && !rule.sites.includes(scope.siteId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// -> And to particular locales, the same way. Unlike the site, a reference may leave the locale
|
||||
// out — an asset addressed by path alone — and such a page is not one a locale rule excludes
|
||||
if (rule.locales?.length > 0 && page.locale && !rule.locales.includes(page.locale)) {
|
||||
if (rule.locales?.length > 0 && scope.locale && !rule.locales.includes(scope.locale)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Whether a rule addresses this page at all, ignoring what it then says about it. */
|
||||
export function ruleMatchesPage(rule: GroupRule, page: RulePageRef): boolean {
|
||||
if (!ruleIsScopedTo(rule, page)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -270,6 +283,35 @@ export function resolvePageRule(
|
||||
return winner
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule that decides a LOCALE permission (`LOCALE_PERMISSIONS` in `models/groups.ts`), for which a
|
||||
* rule's `match`, `path` and `tags` mean nothing.
|
||||
*
|
||||
* Every rule naming the permission that is scoped to the site and locale applies. Steps 1 to 3 of the
|
||||
* ordering above rank rules by how precisely they address a PAGE, and with no page being asked about
|
||||
* every rule is as precise as every other — so step 4 alone decides: a DENY anywhere overrides every
|
||||
* ALLOW, and a FORCE ALLOW anywhere overrides every DENY. A rule written for `/docs` therefore speaks
|
||||
* for the whole locale, in both directions.
|
||||
*
|
||||
* @returns The deciding rule, or null when nothing names the permission — which means denied
|
||||
*/
|
||||
export function resolveLocaleRule(
|
||||
rules: GroupRule[],
|
||||
permission: string,
|
||||
scope: RuleLocaleRef
|
||||
): GroupRule | null {
|
||||
let winner: GroupRule | null = null
|
||||
for (const rule of rules) {
|
||||
if (!rule.roles?.includes(permission) || !ruleIsScopedTo(rule, scope)) {
|
||||
continue
|
||||
}
|
||||
if (!winner || MODE_PRIORITY.indexOf(rule.mode) > MODE_PRIORITY.indexOf(winner.mode)) {
|
||||
winner = rule
|
||||
}
|
||||
}
|
||||
return winner
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the caller's rules grant a permission on a page.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { sql, type SQL } from 'drizzle-orm'
|
||||
import type { PgColumn } from 'drizzle-orm/pg-core'
|
||||
import type { AccessActor } from '../models/groups.ts'
|
||||
import type { RulePageRef } from './pageRules.ts'
|
||||
|
||||
/**
|
||||
* Whether a page is open to its readers, and who may see it while it is not.
|
||||
*
|
||||
* A page is LIVE when it is `published`, or `scheduled` and inside its window — on or after
|
||||
* `publishStartDate` and before `publishEndDate`, either end left open when unset. A `draft` never
|
||||
* is. The dates gate a `scheduled` page only: on a `published` one `publishStartDate` is the date a
|
||||
* blog post is dated by, which an author may well set in the past or the future without meaning to
|
||||
* take the post down.
|
||||
*
|
||||
* A page that is not live is the business of the people working on it, and of nobody else: whoever
|
||||
* holds `write:pages` or `manage:pages` on it may see it, its source included, and every other
|
||||
* reader is answered as though it were not there. A request without a session never sees one —
|
||||
* anonymous readers and API keys alike — since a draft belongs to people, not to a token.
|
||||
*
|
||||
* Every read that answers a reader goes through these two, so that no route, listing or search can
|
||||
* disagree with the page view about whether a page is there.
|
||||
*/
|
||||
|
||||
type PublishColumns = {
|
||||
publishState: PgColumn | SQL
|
||||
publishStartDate: PgColumn | SQL
|
||||
publishEndDate: PgColumn | SQL
|
||||
}
|
||||
|
||||
/** What lets a requester see a page that is not live. */
|
||||
export const UNPUBLISHED_PERMISSIONS = ['write:pages', 'manage:pages']
|
||||
|
||||
/**
|
||||
* The SQL condition for a page being live now.
|
||||
*
|
||||
* Decided by the database so that it is one definition wherever a page is read. Compared against an
|
||||
* ISO string cast to `timestamp`, which is how drizzle writes the two columns: they hold UTC with no
|
||||
* zone, and comparing them to `now()` would read them in the session's zone instead.
|
||||
*/
|
||||
export function liveCondition(columns: PublishColumns): SQL {
|
||||
const now = new Date().toISOString()
|
||||
return sql`(${columns.publishState} = 'published' OR (${columns.publishState} = 'scheduled'
|
||||
AND (${columns.publishStartDate} IS NULL OR ${columns.publishStartDate} <= ${now}::timestamp)
|
||||
AND (${columns.publishEndDate} IS NULL OR ${columns.publishEndDate} > ${now}::timestamp)))`
|
||||
}
|
||||
|
||||
/** Whether this actor may see this page while it is not live. */
|
||||
export function maySeeUnpublished(actor: AccessActor, page: RulePageRef): boolean {
|
||||
return UNPUBLISHED_PERMISSIONS.some((permission) =>
|
||||
WIKI.models.groups.checkAccess(actor, permission, page)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this actor could see an unpublished page ANYWHERE.
|
||||
*
|
||||
* A pre-filter for listings, never the answer for a page: an actor for whom this is false is held to
|
||||
* live pages in SQL, and one for whom it is true has every row checked with `maySeeUnpublished`.
|
||||
*/
|
||||
export function maySeeUnpublishedAnywhere(actor: AccessActor): boolean {
|
||||
return UNPUBLISHED_PERMISSIONS.some((permission) =>
|
||||
WIKI.models.groups.grantsAnywhere(actor, permission)
|
||||
)
|
||||
}
|
||||
@@ -140,6 +140,7 @@
|
||||
"admin.audit.actions.createAuthStrategy": "Added an authentication strategy",
|
||||
"admin.audit.actions.createComment": "Posted a comment",
|
||||
"admin.audit.actions.createFolder": "Created a folder",
|
||||
"admin.audit.actions.createGlossaryTerm": "Created a glossary term",
|
||||
"admin.audit.actions.createGroup": "Created a group",
|
||||
"admin.audit.actions.createHook": "Created a webhook",
|
||||
"admin.audit.actions.createPage": "Created a page",
|
||||
@@ -153,6 +154,7 @@
|
||||
"admin.audit.actions.deleteBlock": "Deleted a custom block",
|
||||
"admin.audit.actions.deleteComment": "Deleted a comment",
|
||||
"admin.audit.actions.deleteFolder": "Deleted a folder",
|
||||
"admin.audit.actions.deleteGlossaryTerm": "Deleted a glossary term",
|
||||
"admin.audit.actions.deleteGroup": "Deleted a group",
|
||||
"admin.audit.actions.deleteHook": "Deleted a webhook",
|
||||
"admin.audit.actions.deleteIconSet": "Deleted an icon set",
|
||||
@@ -233,6 +235,7 @@
|
||||
"admin.audit.actions.updateEditorSettings": "Changed their editor settings",
|
||||
"admin.audit.actions.updateFlags": "Changed the system flags",
|
||||
"admin.audit.actions.updateFolder": "Renamed a folder",
|
||||
"admin.audit.actions.updateGlossaryTerm": "Edited a glossary term",
|
||||
"admin.audit.actions.updateGroup": "Updated a group",
|
||||
"admin.audit.actions.updateHook": "Updated a webhook",
|
||||
"admin.audit.actions.updateIconSet": "Enabled or disabled an icon set",
|
||||
@@ -276,6 +279,7 @@
|
||||
"admin.audit.kinds.asset": "File",
|
||||
"admin.audit.kinds.auth": "Sign-in",
|
||||
"admin.audit.kinds.comment": "Comment",
|
||||
"admin.audit.kinds.glossary": "Glossary",
|
||||
"admin.audit.kinds.page": "Page",
|
||||
"admin.audit.kinds.profile": "Profile",
|
||||
"admin.audit.loadActionsFailed": "Could not load the list of actions.",
|
||||
@@ -501,6 +505,8 @@
|
||||
"admin.general.allowCollaborativeEditingHint": "Can several people edit the same page at the same time, seeing each other's cursors and changes live? Changes are still only stored when someone saves the page.",
|
||||
"admin.general.allowComments": "Allow Comments",
|
||||
"admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.",
|
||||
"admin.general.allowGlossary": "Glossary",
|
||||
"admin.general.allowGlossaryHint": "Offer a glossary of terms in the Library menu. Who may read it is set by the Read Glossary page rule, and who may edit it by the Manage Glossary permission.",
|
||||
"admin.general.allowLastEditedBy": "Allow Last Edited By",
|
||||
"admin.general.allowLastEditedByHint": "Can the \"Last Edited By\" info be displayed on pages?",
|
||||
"admin.general.allowListFolderChildren": "List Children on Folder Pages",
|
||||
@@ -649,8 +655,12 @@
|
||||
"admin.groups.redirectOnLogoutHint": "The path / URL where the user will be redirected upon logout. Leave empty to use the site-defined value.",
|
||||
"admin.groups.refreshSuccess": "Groups refreshed successfully.",
|
||||
"admin.groups.ruleAllow": "Allow",
|
||||
"admin.groups.ruleAllowHint": "Grants the selected permissions.",
|
||||
"admin.groups.ruleDelete": "Delete Rule",
|
||||
"admin.groups.ruleDeny": "Deny",
|
||||
"admin.groups.ruleDenyHint": "Refuses them, over an equally specific Allow.",
|
||||
"admin.groups.ruleForceAllow": "Force Allow",
|
||||
"admin.groups.ruleForceAllowHint": "Grants them, over an equally specific Deny.",
|
||||
"admin.groups.ruleLocales": "Locale(s)",
|
||||
"admin.groups.ruleMatch": "Match",
|
||||
"admin.groups.ruleMatchEnd": "Path Ends With...",
|
||||
@@ -661,6 +671,14 @@
|
||||
"admin.groups.ruleMatchTag": "Has Any Tag...",
|
||||
"admin.groups.ruleMatchTagAll": "Has All Tags...",
|
||||
"admin.groups.rulePath": "Path",
|
||||
"admin.groups.rulePermissionsCount": "No permissions selected | 1 permission selected | {count} permissions selected",
|
||||
"admin.groups.rulePermissionsLocale": "Site + Locale Only",
|
||||
"admin.groups.rulePermissionsLocaleHint": "The rule's pattern is ignored: the rule applies to its sites and locales as a whole, and a Deny anywhere outranks an Allow anywhere.",
|
||||
"admin.groups.rulePermissionsNone": "No permissions selected.",
|
||||
"admin.groups.rulePermissionsPage": "Site + Locale + Pattern",
|
||||
"admin.groups.rulePermissionsPageHint": "Applies to the pages the rule's pattern matches, on its sites and in its locales.",
|
||||
"admin.groups.rulePermissionsSelect": "Select Permissions...",
|
||||
"admin.groups.rulePermissionsTitle": "Rule Permissions",
|
||||
"admin.groups.ruleSites": "Site(s)",
|
||||
"admin.groups.ruleTags": "Tags",
|
||||
"admin.groups.ruleTagsFailed": "Failed to load the list of existing tags.",
|
||||
@@ -2083,10 +2101,12 @@
|
||||
"common.header.delete": "Delete",
|
||||
"common.header.duplicate": "Duplicate",
|
||||
"common.header.edit": "Edit",
|
||||
"common.header.glossary": "Glossary",
|
||||
"common.header.history": "History",
|
||||
"common.header.home": "Home",
|
||||
"common.header.imagesFiles": "Images & Files",
|
||||
"common.header.language": "Language",
|
||||
"common.header.library": "Library",
|
||||
"common.header.login": "Login",
|
||||
"common.header.logout": "Logout",
|
||||
"common.header.move": "Move / Rename",
|
||||
@@ -2919,6 +2939,75 @@
|
||||
"folderDeleteDialog.deleteSuccess": "Folder has been deleted successfully.",
|
||||
"folderDeleteDialog.folderId": "Folder ID {id}",
|
||||
"folderDeleteDialog.title": "Confirm Delete Folder",
|
||||
"glossary.alsoKnownAs": "Also known as",
|
||||
"glossary.backToList": "Back to the list",
|
||||
"glossary.category": "Category",
|
||||
"glossary.createSuccess": "Term created.",
|
||||
"glossary.deleteConfirm": "Delete the term \"{term}\"? It is also removed from the related terms of every other term.",
|
||||
"glossary.deleteConfirmTitle": "Delete Term",
|
||||
"glossary.deleteSuccess": "Term deleted.",
|
||||
"glossary.documentation": "Documentation",
|
||||
"glossary.documentationMissing": "The page /{path} was not found.",
|
||||
"glossary.editTermTitle": "Edit Term",
|
||||
"glossary.empty": "This glossary has no terms yet.",
|
||||
"glossary.filter": "Filter terms...",
|
||||
"glossary.forbidden": "You may not read the glossary in this locale.",
|
||||
"glossary.form.addReference": "Add Reference",
|
||||
"glossary.form.aliases": "Aliases",
|
||||
"glossary.form.aliasesHint": "Alternate forms, spellings and inflections. Press Enter to add each one.",
|
||||
"glossary.form.autoLink": "Auto-Link",
|
||||
"glossary.form.autoLinkHint": "Link the term automatically where it appears in pages. Coming soon.",
|
||||
"glossary.form.caseSensitive": "Case Sensitive",
|
||||
"glossary.form.caseSensitiveHint": "Only match text written with the exact same case, e.g. \"REST\" but not \"rest\".",
|
||||
"glossary.form.category": "Category",
|
||||
"glossary.form.categoryHint": "Groups terms together in the list. Press Enter to use a new one.",
|
||||
"glossary.form.definition": "Definition",
|
||||
"glossary.form.definitionHint": "Basic markdown: emphasis, lists, links, quotes and code.",
|
||||
"glossary.form.documentation": "Documentation Page",
|
||||
"glossary.form.documentationHint": "The page on this wiki that documents the term, in the same locale.",
|
||||
"glossary.form.documentationLabel": "Link Label",
|
||||
"glossary.form.documentationNone": "No page selected",
|
||||
"glossary.form.expansion": "Expansion",
|
||||
"glossary.form.expansionHint": "The full form, if the term is an abbreviation.",
|
||||
"glossary.form.invalidRelated": "A related term no longer exists. The list has been reloaded.",
|
||||
"glossary.form.keepOldAsAlias": "Keep \"{name}\" as an alias",
|
||||
"glossary.form.nameTaken": "\"{name}\" is already used by the term \"{holder}\".",
|
||||
"glossary.form.notFound": "This term no longer exists.",
|
||||
"glossary.form.preview": "Preview",
|
||||
"glossary.form.referenceLabel": "Label (optional)",
|
||||
"glossary.form.referenceUrl": "URL",
|
||||
"glossary.form.referenceUrlInvalid": "Must be an http:// or https:// address.",
|
||||
"glossary.form.references": "External References",
|
||||
"glossary.form.relatedTerms": "Related Terms",
|
||||
"glossary.form.relatedTermsHint": "Related terms are listed on both terms.",
|
||||
"glossary.form.selectPage": "Select Page",
|
||||
"glossary.form.staleMessage": "{name} saved it on {date}, after you started editing.",
|
||||
"glossary.form.staleMessageNoAuthor": "It was saved on {date}, after you started editing.",
|
||||
"glossary.form.staleOverwrite": "Save Over It",
|
||||
"glossary.form.staleReload": "Discard My Changes",
|
||||
"glossary.form.staleTitle": "This term was changed by somebody else",
|
||||
"glossary.form.term": "Term",
|
||||
"glossary.form.write": "Write",
|
||||
"glossary.groupAz": "A–Z",
|
||||
"glossary.groupCategory": "Category",
|
||||
"glossary.grouping": "Group terms by",
|
||||
"glossary.lastEdited": "Last edited by {name} on {date}",
|
||||
"glossary.lastEditedNoAuthor": "Last edited on {date}",
|
||||
"glossary.loadFailed": "Failed to load the glossary.",
|
||||
"glossary.newTerm": "New Term",
|
||||
"glossary.newTermTitle": "New Term",
|
||||
"glossary.noDefinition": "No definition yet.",
|
||||
"glossary.noMatch": "No term matches the filter.",
|
||||
"glossary.pickTerm": "Select a term to read its definition.",
|
||||
"glossary.pickTermManage": "Select a term to read its definition, or create a new one.",
|
||||
"glossary.readMore": "Read more",
|
||||
"glossary.references": "References",
|
||||
"glossary.related": "Related",
|
||||
"glossary.saveSuccess": "Term saved.",
|
||||
"glossary.title": "Glossary",
|
||||
"glossary.uncategorized": "Uncategorized",
|
||||
"glossary.unsavedMessage": "This term has unsaved changes. Discard them?",
|
||||
"glossary.unsavedTitle": "Discard Changes?",
|
||||
"history.action.created": "Created",
|
||||
"history.action.deleted": "Deleted",
|
||||
"history.action.moved": "Moved",
|
||||
|
||||
@@ -9,7 +9,15 @@ import { sanitizeMeta } from '../helpers/audit.ts'
|
||||
* migration. The admin area's filter is built from this list, and `admin.audit.kinds.<kind>` is the
|
||||
* translation of each.
|
||||
*/
|
||||
export const AUDIT_KINDS = ['page', 'asset', 'comment', 'auth', 'profile', 'admin'] as const
|
||||
export const AUDIT_KINDS = [
|
||||
'page',
|
||||
'asset',
|
||||
'comment',
|
||||
'glossary',
|
||||
'auth',
|
||||
'profile',
|
||||
'admin'
|
||||
] as const
|
||||
export type AuditKind = (typeof AUDIT_KINDS)[number]
|
||||
|
||||
/**
|
||||
@@ -58,6 +66,7 @@ export const AUDIT_ACTIONS = {
|
||||
'reopenAnnotation',
|
||||
'deleteAnnotation'
|
||||
],
|
||||
glossary: ['createGlossaryTerm', 'updateGlossaryTerm', 'deleteGlossaryTerm'],
|
||||
auth: [
|
||||
'login',
|
||||
'logout',
|
||||
|
||||
+25
-16
@@ -3,6 +3,8 @@ import { pages as pagesTable, tree as treeTable, users as usersTable } from '../
|
||||
import { decodeTreePath, encodeTreePath } from '../helpers/common.ts'
|
||||
import { isBodylessEditor, parseBlogContent, type BlogContent } from './pages.ts'
|
||||
import type { AccessActor } from './groups.ts'
|
||||
import type { RulePageRef } from '../helpers/pageRules.ts'
|
||||
import { liveCondition } from '../helpers/publishing.ts'
|
||||
|
||||
/**
|
||||
* How many of a blog's posts are read out of the database for one request.
|
||||
@@ -99,6 +101,7 @@ export interface BlogFilter {
|
||||
/** One candidate row, before the page rules and the nesting rule have had their say. */
|
||||
interface Candidate extends BlogPost {
|
||||
editor: string
|
||||
isLive: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,18 +291,22 @@ class Blogs {
|
||||
* - **A page the reader may not open**, by the same rule the page view applies. Filtered here rather
|
||||
* than in SQL because a page rule is not a `WHERE` clause.
|
||||
*
|
||||
* @param publicOnly Restrict to what a reader with no session may see, i.e. published posts only.
|
||||
* - **A post that is not live**, unless `unpublished` says this reader may see it — the page view's
|
||||
* own rule (`helpers/publishing.ts`). Live posts only by default.
|
||||
*
|
||||
* @param unpublished As for `pages.getPage`: false holds the listing to live posts in SQL, and a
|
||||
* predicate is asked of each post that is not live.
|
||||
*/
|
||||
async postsFor({
|
||||
siteId,
|
||||
blog,
|
||||
actor,
|
||||
publicOnly = true
|
||||
unpublished = false
|
||||
}: {
|
||||
siteId: string
|
||||
blog: BlogRef
|
||||
actor?: AccessActor
|
||||
publicOnly?: boolean
|
||||
unpublished?: false | ((page: RulePageRef) => boolean)
|
||||
}): Promise<{ posts: BlogPost[]; truncated: boolean }> {
|
||||
const encodedPath = encodeTreePath(blog.path)
|
||||
const depth = blog.settings.depth
|
||||
@@ -326,8 +333,8 @@ class Blogs {
|
||||
eq(treeTable.type, 'page'),
|
||||
sql`${treeTable.folderPath} ~ ${pathQuery}::lquery`
|
||||
]
|
||||
if (publicOnly) {
|
||||
conditions.push(eq(pagesTable.publishState, 'published'))
|
||||
if (!unpublished) {
|
||||
conditions.push(liveCondition(pagesTable))
|
||||
}
|
||||
|
||||
const rows = await WIKI.db
|
||||
@@ -343,6 +350,7 @@ class Blogs {
|
||||
updatedAt: pagesTable.updatedAt,
|
||||
authorId: pagesTable.authorId,
|
||||
authorName: usersTable.name,
|
||||
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean),
|
||||
publishedAt
|
||||
})
|
||||
.from(treeTable)
|
||||
@@ -366,7 +374,8 @@ class Blogs {
|
||||
publishedAt: row.publishedAt,
|
||||
updatedAt: row.updatedAt,
|
||||
authorId: row.authorId,
|
||||
authorName: row.authorName ?? ''
|
||||
authorName: row.authorName ?? '',
|
||||
isLive: row.isLive
|
||||
}
|
||||
})
|
||||
|
||||
@@ -386,18 +395,18 @@ class Blogs {
|
||||
if (nestedRoots.some((root) => row.path.startsWith(root))) {
|
||||
return false
|
||||
}
|
||||
const ref = { siteId, path: row.path, locale: blog.locale, tags: row.tags }
|
||||
if (!row.isLive && !(unpublished && unpublished(ref))) {
|
||||
return false
|
||||
}
|
||||
if (!actor) {
|
||||
return true
|
||||
}
|
||||
return WIKI.models.groups.checkAccess(actor, 'read:pages', {
|
||||
siteId,
|
||||
path: row.path,
|
||||
locale: blog.locale,
|
||||
tags: row.tags
|
||||
})
|
||||
return WIKI.models.groups.checkAccess(actor, 'read:pages', ref)
|
||||
})
|
||||
|
||||
// -> Which editor wrote a post is how it was filtered, not something a listing shows
|
||||
// -> Which editor wrote a post, and whether it is live, are how it was filtered, not something a
|
||||
// listing shows
|
||||
return {
|
||||
posts: posts.map((post) => ({
|
||||
id: post.id,
|
||||
@@ -459,16 +468,16 @@ class Blogs {
|
||||
siteId,
|
||||
blog,
|
||||
actor,
|
||||
publicOnly = true,
|
||||
unpublished = false,
|
||||
filter = {}
|
||||
}: {
|
||||
siteId: string
|
||||
blog: BlogRef
|
||||
actor?: AccessActor
|
||||
publicOnly?: boolean
|
||||
unpublished?: false | ((page: RulePageRef) => boolean)
|
||||
filter?: BlogFilter
|
||||
}): Promise<BlogListing> {
|
||||
const { posts: readable, truncated } = await this.postsFor({ siteId, blog, actor, publicOnly })
|
||||
const { posts: readable, truncated } = await this.postsFor({ siteId, blog, actor, unpublished })
|
||||
const facets = this.facetsFor(readable)
|
||||
|
||||
const matching = readable.filter((post) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
parseModuleProps
|
||||
} from '../helpers/common.ts'
|
||||
import type { ModuleProp } from '../helpers/common.ts'
|
||||
import { liveCondition } from '../helpers/publishing.ts'
|
||||
|
||||
/**
|
||||
* The key of the provider that IS this wiki, as opposed to the ones that are somebody else's service.
|
||||
@@ -759,7 +760,7 @@ class Comments {
|
||||
* Its path, locale and tags because that is what a page rule is matched against, and
|
||||
* `allowComments` because a page can be closed to discussion from its own properties dialog
|
||||
* whatever the site has configured. Deliberately not `pages.getPage` — that assembles a page for
|
||||
* reading, and this is four columns and a scoping check.
|
||||
* reading, and this is a few columns and a scoping check. `isLive` is the caller's to act on.
|
||||
*
|
||||
* @returns The reference, or null when no such page exists on this site
|
||||
*/
|
||||
@@ -771,7 +772,10 @@ class Comments {
|
||||
locale: pagesTable.locale,
|
||||
title: pagesTable.title,
|
||||
tags: pagesTable.tags,
|
||||
allowComments: sql<boolean>`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)`
|
||||
allowComments: sql<boolean>`coalesce((${pagesTable.config} ->> 'allowComments')::boolean, true)`,
|
||||
// -> A discussion quotes its page, annotations word for word, so it is only there while the
|
||||
// page is — see `helpers/publishing.ts`
|
||||
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean)
|
||||
})
|
||||
.from(pagesTable)
|
||||
.where(and(eq(pagesTable.id, pageId), eq(pagesTable.siteId, siteId)))
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
import { and, asc, eq, inArray, isNotNull, ne, or, sql } from 'drizzle-orm'
|
||||
import {
|
||||
glossaryTermRelations as relationsTable,
|
||||
glossaryTerms as termsTable,
|
||||
pages as pagesTable,
|
||||
users as usersTable
|
||||
} from '../db/schema.ts'
|
||||
|
||||
/**
|
||||
* How much a term may carry. Shared with the route schemas, which are what actually enforce it.
|
||||
*
|
||||
* Per `dev/specs/glossary.md` §3.4. A name — the term, an alias, the expansion, the category, the
|
||||
* documentation label — is a varchar(255) column or an entry like one.
|
||||
*/
|
||||
export const GLOSSARY_NAME_MAX_LENGTH = 255
|
||||
export const GLOSSARY_ALIASES_MAX = 50
|
||||
export const GLOSSARY_DEFINITION_MAX_LENGTH = 10000
|
||||
export const GLOSSARY_REFERENCES_MAX = 20
|
||||
export const GLOSSARY_RELATED_MAX = 50
|
||||
|
||||
/** An external reference: a link out, with an optional label the URL stands in for. */
|
||||
export interface GlossaryReference {
|
||||
url: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** What a create or a save carries. `locale` is fixed at creation, so a save never names one. */
|
||||
export interface GlossaryTermInput {
|
||||
term: string
|
||||
expansion?: string | null
|
||||
definition?: string
|
||||
aliases?: string[]
|
||||
relatedTerms?: string[]
|
||||
documentationPath?: string | null
|
||||
documentationLabel?: string | null
|
||||
references?: GlossaryReference[]
|
||||
caseSensitive?: boolean
|
||||
autoLink?: boolean
|
||||
category?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A term as the sidebar lists it.
|
||||
*
|
||||
* Also exactly what auto-linking will need to build its matcher (spec §9), which is why the two case
|
||||
* and linking flags are here although the sidebar draws neither.
|
||||
*/
|
||||
export interface GlossaryTermSummary {
|
||||
id: string
|
||||
term: string
|
||||
expansion: string | null
|
||||
aliases: string[]
|
||||
category: string | null
|
||||
caseSensitive: boolean
|
||||
autoLink: boolean
|
||||
}
|
||||
|
||||
/** A term in full, as the main panel shows it and the form edits it. */
|
||||
export interface GlossaryTerm extends GlossaryTermSummary {
|
||||
siteId: string
|
||||
locale: string
|
||||
definition: string
|
||||
references: GlossaryReference[]
|
||||
relatedTerms: Array<{ id: string; term: string }>
|
||||
/**
|
||||
* The documentation page, resolved. Null when the term names none; `exists: false` when it names a
|
||||
* path with no page, which the view says rather than offering a dead link.
|
||||
*/
|
||||
documentation: {
|
||||
path: string
|
||||
label: string | null
|
||||
title: string | null
|
||||
exists: boolean
|
||||
} | null
|
||||
author: { id: string; name: string } | null
|
||||
createdAt: string
|
||||
/** At millisecond precision — what a save hands back as `expectedUpdatedAt`. */
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a write was refused, for the route to turn into a status code.
|
||||
*
|
||||
* Results rather than thrown errors, because two of them carry something the client needs beyond a
|
||||
* message: who already holds a name, and the term as somebody else just saved it.
|
||||
*/
|
||||
export type GlossaryWriteResult =
|
||||
| { ok: true; term: GlossaryTerm; changedFields: string[] }
|
||||
| { ok: false; reason: 'notFound' }
|
||||
| { ok: false; reason: 'invalidRelated' }
|
||||
| {
|
||||
ok: false
|
||||
reason: 'nameTaken'
|
||||
/** Which of the caller's fields holds the clashing name. */
|
||||
field: 'term' | 'aliases'
|
||||
name: string
|
||||
holder: { id: string; term: string }
|
||||
}
|
||||
| { ok: false; reason: 'stale'; current: GlossaryTerm }
|
||||
|
||||
/** The fields compared to report what a save changed, for the audit log. */
|
||||
const COMPARED_FIELDS = [
|
||||
'term',
|
||||
'expansion',
|
||||
'definition',
|
||||
'aliases',
|
||||
'relatedTerms',
|
||||
'documentationPath',
|
||||
'documentationLabel',
|
||||
'references',
|
||||
'caseSensitive',
|
||||
'autoLink',
|
||||
'category'
|
||||
] as const
|
||||
|
||||
/**
|
||||
* A name as it is stored and compared: NFC, trimmed, inner whitespace collapsed to one space.
|
||||
*
|
||||
* So that `REST API`, `REST API` and a decomposed accent are each one name rather than three that
|
||||
* look identical in the sidebar and collide nowhere.
|
||||
*/
|
||||
export function normalizeName(value: string): string {
|
||||
return value.normalize('NFC').trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
/** An optional text field: normalized like a name, with empty meaning absent. */
|
||||
function optionalName(value: string | null | undefined): string | null {
|
||||
const normalized = normalizeName(value ?? '')
|
||||
return normalized.length > 0 ? normalized : null
|
||||
}
|
||||
|
||||
/** Compared without leading or trailing slashes, as every page path is stored. */
|
||||
function normalizePath(value: string | null | undefined): string | null {
|
||||
const path = (value ?? '').trim().replace(/^\/+|\/+$/g, '')
|
||||
return path.length > 0 ? path : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The aliases as they will be stored.
|
||||
*
|
||||
* Normalized, then with every repeat and every copy of the term's own name dropped silently rather
|
||||
* than refused (spec §3.3): saying a name twice is not a mistake worth stopping a save for.
|
||||
*/
|
||||
function cleanAliases(aliases: string[] | undefined, term: string): string[] {
|
||||
const seen = new Set([term.toLowerCase()])
|
||||
const out: string[] = []
|
||||
for (const alias of aliases ?? []) {
|
||||
const name = normalizeName(alias)
|
||||
const key = name.toLowerCase()
|
||||
if (name.length < 1 || seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
out.push(name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** References with both halves trimmed, and any without a URL dropped. */
|
||||
function cleanReferences(references: GlossaryReference[] | undefined): GlossaryReference[] {
|
||||
return (references ?? [])
|
||||
.map((ref) => ({ url: (ref.url ?? '').trim(), label: normalizeName(ref.label ?? '') }))
|
||||
.filter((ref) => ref.url.length > 0)
|
||||
}
|
||||
|
||||
/** A transaction, or the database outside one. Every read here works inside a write's transaction. */
|
||||
type Executor = typeof WIKI.db | Parameters<Parameters<typeof WIKI.db.transaction>[0]>[0]
|
||||
|
||||
/** `updatedAt` as the API carries it. JS dates are milliseconds, which is the precision promised. */
|
||||
function iso(value: Date): string {
|
||||
return value.toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Glossary model. See `dev/specs/glossary.md`.
|
||||
*/
|
||||
class Glossary {
|
||||
/**
|
||||
* Whether the site has a glossary at all — `features.glossary`, General → Features.
|
||||
*
|
||||
* On unless switched off, the way `backlinks` and `comments` read: the switch exists to turn it off.
|
||||
*/
|
||||
isEnabled(siteId: string): boolean {
|
||||
return WIKI.sites[siteId]?.config?.features?.glossary !== false
|
||||
}
|
||||
|
||||
/** Whether a locale is one this site offers, which is the only kind a term may be written in. */
|
||||
isActiveLocale(siteId: string, locale: string): boolean {
|
||||
const active: string[] = WIKI.sites[siteId]?.config?.locales?.active ?? []
|
||||
return active.includes(locale)
|
||||
}
|
||||
|
||||
/** Every term of a locale, A–Z, as the sidebar lists them. */
|
||||
async list(siteId: string, locale: string): Promise<GlossaryTermSummary[]> {
|
||||
return WIKI.db
|
||||
.select({
|
||||
id: termsTable.id,
|
||||
term: termsTable.term,
|
||||
expansion: termsTable.expansion,
|
||||
aliases: termsTable.aliases,
|
||||
category: termsTable.category,
|
||||
caseSensitive: termsTable.caseSensitive,
|
||||
autoLink: termsTable.autoLink
|
||||
})
|
||||
.from(termsTable)
|
||||
.where(and(eq(termsTable.siteId, siteId), eq(termsTable.locale, locale)))
|
||||
.orderBy(sql`lower(${termsTable.term})`, asc(termsTable.term))
|
||||
}
|
||||
|
||||
/** The categories in use in a locale, for the form's suggestions. */
|
||||
async categories(siteId: string, locale: string): Promise<string[]> {
|
||||
const rows = await WIKI.db
|
||||
.selectDistinct({ category: termsTable.category })
|
||||
.from(termsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(termsTable.siteId, siteId),
|
||||
eq(termsTable.locale, locale),
|
||||
isNotNull(termsTable.category)
|
||||
)
|
||||
)
|
||||
return rows
|
||||
.map((row) => row.category as string)
|
||||
.sort((a, b) => a.localeCompare(b, locale, { sensitivity: 'base' }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The term a name or an alias belongs to, for a `?glossary=` link (spec §7).
|
||||
*
|
||||
* Aliases count, so that a term renamed with its old name kept as an alias keeps its old links.
|
||||
*/
|
||||
async lookup(siteId: string, locale: string, name: string): Promise<string | null> {
|
||||
const wanted = normalizeName(name)
|
||||
if (wanted.length < 1) {
|
||||
return null
|
||||
}
|
||||
const rows = await WIKI.db
|
||||
.select({ id: termsTable.id })
|
||||
.from(termsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(termsTable.siteId, siteId),
|
||||
eq(termsTable.locale, locale),
|
||||
or(
|
||||
sql`lower(${termsTable.term}) = lower(${wanted})`,
|
||||
sql`EXISTS (SELECT 1 FROM unnest(${termsTable.aliases}) AS a WHERE lower(a) = lower(${wanted}))`
|
||||
)
|
||||
)
|
||||
)
|
||||
// -> The term's own name before an alias, should a race ever have let both exist
|
||||
.orderBy(sql`lower(${termsTable.term}) = lower(${wanted}) DESC`)
|
||||
.limit(1)
|
||||
return rows[0]?.id ?? null
|
||||
}
|
||||
|
||||
/** The locale a term is in, which is what every permission over it is asked about. */
|
||||
async localeOf(siteId: string, id: string): Promise<string | null> {
|
||||
const rows = await WIKI.db
|
||||
.select({ locale: termsTable.locale })
|
||||
.from(termsTable)
|
||||
.where(and(eq(termsTable.id, id), eq(termsTable.siteId, siteId)))
|
||||
.limit(1)
|
||||
return rows[0]?.locale ?? null
|
||||
}
|
||||
|
||||
/** A term in full, or null when there is none with this id on this site. */
|
||||
async get(siteId: string, id: string, tx: Executor = WIKI.db): Promise<GlossaryTerm | null> {
|
||||
const rows = await tx
|
||||
.select({
|
||||
row: termsTable,
|
||||
authorName: usersTable.name
|
||||
})
|
||||
.from(termsTable)
|
||||
.leftJoin(usersTable, eq(usersTable.id, termsTable.authorId))
|
||||
.where(and(eq(termsTable.id, id), eq(termsTable.siteId, siteId)))
|
||||
.limit(1)
|
||||
if (rows.length < 1) {
|
||||
return null
|
||||
}
|
||||
const { row, authorName } = rows[0]
|
||||
|
||||
// -> Both sides of the pair table: a relation is one row whichever term it was saved from
|
||||
const related = await tx
|
||||
.select({ id: termsTable.id, term: termsTable.term })
|
||||
.from(relationsTable)
|
||||
.innerJoin(
|
||||
termsTable,
|
||||
sql`${termsTable.id} = CASE WHEN ${relationsTable.termId} = ${id} THEN ${relationsTable.relatedId} ELSE ${relationsTable.termId} END`
|
||||
)
|
||||
.where(or(eq(relationsTable.termId, id), eq(relationsTable.relatedId, id)))
|
||||
.orderBy(sql`lower(${termsTable.term})`)
|
||||
|
||||
let documentation: GlossaryTerm['documentation'] = null
|
||||
if (row.documentationPath) {
|
||||
const page = await tx
|
||||
.select({ title: pagesTable.title })
|
||||
.from(pagesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(pagesTable.siteId, siteId),
|
||||
eq(pagesTable.locale, row.locale),
|
||||
eq(pagesTable.path, row.documentationPath)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
documentation = {
|
||||
path: row.documentationPath,
|
||||
label: row.documentationLabel,
|
||||
title: page[0]?.title ?? null,
|
||||
exists: page.length > 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
siteId: row.siteId,
|
||||
locale: row.locale,
|
||||
term: row.term,
|
||||
expansion: row.expansion,
|
||||
definition: row.definition,
|
||||
aliases: row.aliases,
|
||||
category: row.category,
|
||||
caseSensitive: row.caseSensitive,
|
||||
autoLink: row.autoLink,
|
||||
references: (row.references ?? []) as GlossaryReference[],
|
||||
relatedTerms: related,
|
||||
documentation,
|
||||
author: row.authorId && authorName ? { id: row.authorId, name: authorName } : null,
|
||||
createdAt: iso(row.createdAt),
|
||||
updatedAt: iso(row.updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a term.
|
||||
*
|
||||
* @param actorId The account creating it, or null for an API key
|
||||
*/
|
||||
async create(
|
||||
siteId: string,
|
||||
locale: string,
|
||||
input: GlossaryTermInput,
|
||||
actorId: string | null
|
||||
): Promise<GlossaryWriteResult> {
|
||||
return WIKI.db.transaction(async (tx) => {
|
||||
const values = this.#valuesFrom(input)
|
||||
const refused = await this.#checkWrite(tx, siteId, locale, null, values, input)
|
||||
if (refused) {
|
||||
return refused
|
||||
}
|
||||
// -> Both dates from JS rather than `now()`, so that what is stored is the millisecond value a
|
||||
// save will name as `expectedUpdatedAt` — postgres would keep microseconds
|
||||
const now = new Date()
|
||||
const [created] = await tx
|
||||
.insert(termsTable)
|
||||
.values({
|
||||
...values,
|
||||
siteId,
|
||||
locale,
|
||||
creatorId: actorId,
|
||||
authorId: actorId,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning({ id: termsTable.id })
|
||||
await this.#replaceRelations(tx, created.id, input.relatedTerms ?? [])
|
||||
const term = (await this.get(siteId, created.id, tx)) as GlossaryTerm
|
||||
return { ok: true, term, changedFields: [] }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a term, provided nobody else has saved it since `expectedUpdatedAt` (spec §5.2).
|
||||
*
|
||||
* The compare is made by the UPDATE itself, so there is no window between checking and writing.
|
||||
*/
|
||||
async update(
|
||||
siteId: string,
|
||||
id: string,
|
||||
input: GlossaryTermInput,
|
||||
expectedUpdatedAt: string,
|
||||
actorId: string | null
|
||||
): Promise<GlossaryWriteResult> {
|
||||
return WIKI.db.transaction(async (tx) => {
|
||||
const before = await this.get(siteId, id, tx)
|
||||
if (!before) {
|
||||
return { ok: false, reason: 'notFound' }
|
||||
}
|
||||
const values = this.#valuesFrom(input)
|
||||
const refused = await this.#checkWrite(tx, siteId, before.locale, id, values, input)
|
||||
if (refused) {
|
||||
return refused
|
||||
}
|
||||
const expected = new Date(expectedUpdatedAt)
|
||||
const updated = await tx
|
||||
.update(termsTable)
|
||||
.set({ ...values, authorId: actorId, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(termsTable.id, id),
|
||||
eq(termsTable.siteId, siteId),
|
||||
sql`date_trunc('milliseconds', ${termsTable.updatedAt}) = ${expected.toISOString()}::timestamp`
|
||||
)
|
||||
)
|
||||
.returning({ id: termsTable.id })
|
||||
if (updated.length < 1) {
|
||||
return { ok: false, reason: 'stale', current: before }
|
||||
}
|
||||
await this.#replaceRelations(tx, id, input.relatedTerms ?? [])
|
||||
const term = (await this.get(siteId, id, tx)) as GlossaryTerm
|
||||
return { ok: true, term, changedFields: this.#changedFields(before, term) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Delete a term. Its relations go with it, by cascade. Returns what was deleted, for the log. */
|
||||
async remove(siteId: string, id: string): Promise<{ term: string; locale: string } | null> {
|
||||
const deleted = await WIKI.db
|
||||
.delete(termsTable)
|
||||
.where(and(eq(termsTable.id, id), eq(termsTable.siteId, siteId)))
|
||||
.returning({ term: termsTable.term, locale: termsTable.locale })
|
||||
return deleted[0] ?? null
|
||||
}
|
||||
|
||||
/** The columns a create or a save writes, normalized. */
|
||||
#valuesFrom(input: GlossaryTermInput) {
|
||||
const term = normalizeName(input.term)
|
||||
const documentationPath = normalizePath(input.documentationPath)
|
||||
return {
|
||||
term,
|
||||
expansion: optionalName(input.expansion),
|
||||
definition: (input.definition ?? '').trim(),
|
||||
aliases: cleanAliases(input.aliases, term),
|
||||
documentationPath,
|
||||
// -> A label for no link would be stored and never shown, then come back on the next link set
|
||||
documentationLabel: documentationPath ? optionalName(input.documentationLabel) : null,
|
||||
references: cleanReferences(input.references),
|
||||
caseSensitive: input.caseSensitive ?? false,
|
||||
autoLink: input.autoLink ?? true,
|
||||
category: optionalName(input.category)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that can refuse a write before it is made: the names, then the related terms.
|
||||
*
|
||||
* Inside the write's transaction. Two saves racing to claim the same ALIAS can still both pass —
|
||||
* no index reaches into the arrays — and that is accepted (spec §3.3): the next edit of either term
|
||||
* is refused, which surfaces it.
|
||||
*/
|
||||
async #checkWrite(
|
||||
tx: Executor,
|
||||
siteId: string,
|
||||
locale: string,
|
||||
selfId: string | null,
|
||||
values: { term: string; aliases: string[] },
|
||||
input: GlossaryTermInput
|
||||
): Promise<GlossaryWriteResult | null> {
|
||||
const names = [values.term, ...values.aliases]
|
||||
const clashes = await tx
|
||||
.select({ id: termsTable.id, term: termsTable.term, aliases: termsTable.aliases })
|
||||
.from(termsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(termsTable.siteId, siteId),
|
||||
eq(termsTable.locale, locale),
|
||||
selfId ? ne(termsTable.id, selfId) : undefined,
|
||||
or(
|
||||
sql`lower(${termsTable.term}) IN (SELECT lower(n) FROM unnest(${sql.param(names)}::text[]) AS n)`,
|
||||
sql`EXISTS (SELECT 1 FROM unnest(${termsTable.aliases}) AS a WHERE lower(a) IN (SELECT lower(n) FROM unnest(${sql.param(names)}::text[]) AS n))`
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (clashes.length > 0) {
|
||||
const holder = clashes[0]
|
||||
const taken = new Set([holder.term, ...holder.aliases].map((n) => n.toLowerCase()))
|
||||
// -> Which of the caller's names it was, so the form can mark the right field. The term first:
|
||||
// a clash on the name itself is the one to fix before any alias
|
||||
const name = names.find((n) => taken.has(n.toLowerCase())) ?? values.term
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'nameTaken',
|
||||
field: name === values.term ? 'term' : 'aliases',
|
||||
name,
|
||||
holder: { id: holder.id, term: holder.term }
|
||||
}
|
||||
}
|
||||
|
||||
const related = [...new Set(input.relatedTerms ?? [])]
|
||||
if (related.length > 0) {
|
||||
if (selfId && related.includes(selfId)) {
|
||||
return { ok: false, reason: 'invalidRelated' }
|
||||
}
|
||||
const found = await tx
|
||||
.select({ id: termsTable.id })
|
||||
.from(termsTable)
|
||||
.where(
|
||||
and(
|
||||
inArray(termsTable.id, related),
|
||||
eq(termsTable.siteId, siteId),
|
||||
eq(termsTable.locale, locale)
|
||||
)
|
||||
)
|
||||
if (found.length !== related.length) {
|
||||
return { ok: false, reason: 'invalidRelated' }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a term's related terms exactly these, from both sides.
|
||||
*
|
||||
* Every pair touching the term is replaced, so taking B out of A's list takes A out of B's. Each pair
|
||||
* is written smaller id first, which is what the table's check requires and what makes it one row.
|
||||
*/
|
||||
async #replaceRelations(tx: Executor, id: string, relatedIds: string[]): Promise<void> {
|
||||
await tx
|
||||
.delete(relationsTable)
|
||||
.where(or(eq(relationsTable.termId, id), eq(relationsTable.relatedId, id)))
|
||||
const pairs = [...new Set(relatedIds)]
|
||||
.filter((other) => other !== id)
|
||||
.map((other) =>
|
||||
id < other ? { termId: id, relatedId: other } : { termId: other, relatedId: id }
|
||||
)
|
||||
if (pairs.length > 0) {
|
||||
await tx.insert(relationsTable).values(pairs).onConflictDoNothing()
|
||||
}
|
||||
}
|
||||
|
||||
/** Which fields a save changed — names only, never values (spec §8). */
|
||||
#changedFields(before: GlossaryTerm, after: GlossaryTerm): string[] {
|
||||
const shape = (term: GlossaryTerm) => ({
|
||||
term: term.term,
|
||||
expansion: term.expansion,
|
||||
definition: term.definition,
|
||||
aliases: term.aliases,
|
||||
relatedTerms: term.relatedTerms.map((r) => r.id).sort(),
|
||||
documentationPath: term.documentation?.path ?? null,
|
||||
documentationLabel: term.documentation?.label ?? null,
|
||||
references: term.references,
|
||||
caseSensitive: term.caseSensitive,
|
||||
autoLink: term.autoLink,
|
||||
category: term.category
|
||||
})
|
||||
const a = shape(before)
|
||||
const b = shape(after)
|
||||
return COMPARED_FIELDS.filter((field) => JSON.stringify(a[field]) !== JSON.stringify(b[field]))
|
||||
}
|
||||
}
|
||||
|
||||
export const glossary = new Glossary()
|
||||
@@ -3,7 +3,7 @@ import { and, count, eq, ilike, ne, or, sql } from 'drizzle-orm'
|
||||
import { groups as groupsTable, userGroups, users as usersTable } from '../db/schema.ts'
|
||||
import { invalidateAppShellCache } from '../helpers/appShell.ts'
|
||||
import { CustomError } from '../helpers/common.ts'
|
||||
import { resolvePageRule, type RulePageRef } from '../helpers/pageRules.ts'
|
||||
import { resolveLocaleRule, resolvePageRule, type RulePageRef } from '../helpers/pageRules.ts'
|
||||
import type { SystemIds } from './types.ts'
|
||||
import type { FastifyRequest } from 'fastify'
|
||||
|
||||
@@ -91,9 +91,22 @@ export const PAGE_PERMISSIONS = [
|
||||
'read:comments',
|
||||
'write:comments',
|
||||
'manage:comments',
|
||||
'manage:navigation'
|
||||
'manage:navigation',
|
||||
'read:glossary',
|
||||
'manage:glossary'
|
||||
]
|
||||
|
||||
/**
|
||||
* The page permissions that are about a site and a locale rather than a page.
|
||||
*
|
||||
* Granted through rules like every other page permission, but a rule's `match`, `path` and `tags` say
|
||||
* nothing about them: a glossary has no path. Every rule naming one that is scoped to the site and the
|
||||
* locale applies, and the modes rank as they always do — see `resolveLocaleRule` in
|
||||
* `helpers/pageRules.ts`. `checkAccess` routes them there, so a caller asking about one at a page gets
|
||||
* the locale's answer rather than one that changes from page to page.
|
||||
*/
|
||||
export const LOCALE_PERMISSIONS = ['read:glossary', 'manage:glossary']
|
||||
|
||||
/** Whether a permission list carries any of `ELEVATED_PERMISSIONS`. */
|
||||
export function isElevated(permissions: readonly string[]): boolean {
|
||||
return permissions.some((permission) =>
|
||||
@@ -231,7 +244,8 @@ export const GUEST_ROLES = [
|
||||
'read:history',
|
||||
'read:assets',
|
||||
'read:comments',
|
||||
'write:comments'
|
||||
'write:comments',
|
||||
'read:glossary'
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -333,10 +347,44 @@ class Groups {
|
||||
if (actor.permissions.includes('manage:system')) {
|
||||
return true
|
||||
}
|
||||
if (LOCALE_PERMISSIONS.includes(permission)) {
|
||||
return this.checkLocaleAccess(actor, permission, page.siteId, page.locale)
|
||||
}
|
||||
const rule = resolvePageRule(this.rulesForGroups(actor.groupIds), permission, page)
|
||||
return rule ? rule.mode !== 'DENY' : false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this caller holds a locale permission (`LOCALE_PERMISSIONS`) on a site, in a locale.
|
||||
*
|
||||
* The question `checkAccess` cannot ask without a page. Every rule naming the permission that is
|
||||
* scoped to the site and the locale counts, whatever it addresses, and mode alone decides between
|
||||
* them: a DENY anywhere closes the locale, a FORCE ALLOW anywhere reopens it.
|
||||
*
|
||||
* `manage:glossary` implies `read:glossary` in the same locale: editing a glossary one may not read is
|
||||
* not a state worth supporting, and the editor needs the list to pick related terms from. Each is
|
||||
* decided on its own rules first, so a DENY on reading does not take managing with it.
|
||||
*
|
||||
* @param locale Absent, rules limited to particular locales are not excluded — as for a page
|
||||
* reference that leaves the locale out
|
||||
*/
|
||||
checkLocaleAccess(
|
||||
actor: AccessActor,
|
||||
permission: string,
|
||||
siteId: string,
|
||||
locale?: string
|
||||
): boolean {
|
||||
if (actor.permissions.includes('manage:system')) {
|
||||
return true
|
||||
}
|
||||
const rules = this.rulesForGroups(actor.groupIds)
|
||||
const grants = (name: string) => {
|
||||
const rule = resolveLocaleRule(rules, name, { siteId, locale })
|
||||
return rule ? rule.mode !== 'DENY' : false
|
||||
}
|
||||
return grants(permission) || (permission === 'read:glossary' && grants('manage:glossary'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any rule this actor holds grants a page permission ANYWHERE.
|
||||
*
|
||||
@@ -381,7 +429,13 @@ class Groups {
|
||||
*/
|
||||
id: uuid(),
|
||||
name: 'Default Rule',
|
||||
roles: ['read:pages', 'read:assets', 'read:comments', 'write:comments'],
|
||||
roles: [
|
||||
'read:pages',
|
||||
'read:assets',
|
||||
'read:comments',
|
||||
'write:comments',
|
||||
'read:glossary'
|
||||
],
|
||||
match: 'START',
|
||||
mode: 'ALLOW',
|
||||
path: '',
|
||||
@@ -405,6 +459,10 @@ class Groups {
|
||||
statement about comments: an operator opening the wiki up flips this one rule to ALLOW,
|
||||
and what they get is the set the guests group is allowed to hold (`GUEST_ROLES`) rather
|
||||
than a public wiki whose readers still cannot say anything.
|
||||
|
||||
`read:glossary` is deliberately NOT named here. It ignores the path, so a DENY for it
|
||||
anywhere outranks every ALLOW for it everywhere: named in this rule, an operator's later
|
||||
ALLOW for guests would do nothing. Guests are denied it anyway with no rule at all.
|
||||
*/
|
||||
roles: ['read:pages', 'read:assets', 'read:comments', 'write:comments'],
|
||||
match: 'START',
|
||||
@@ -462,9 +520,9 @@ class Groups {
|
||||
|
||||
async createGroup(name: string): Promise<string> {
|
||||
const startingPermissions = ['read:pages', 'read:assets', 'read:comments']
|
||||
// -> The rule grants one more than the group-wide list does: see the note on the Users group in
|
||||
// -> The rule grants more than the group-wide list does: see the note on the Users group in
|
||||
// `init()` for why the two differ
|
||||
const startingRoles = [...startingPermissions, 'write:comments']
|
||||
const startingRoles = [...startingPermissions, 'write:comments', 'read:glossary']
|
||||
const result = await WIKI.db
|
||||
.insert(groupsTable)
|
||||
.values({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { blogs } from './blogs.ts'
|
||||
import { comments } from './comments.ts'
|
||||
import { extensions } from './extensions.ts'
|
||||
import { flags } from './flags.ts'
|
||||
import { glossary } from './glossary.ts'
|
||||
import { groups } from './groups.ts'
|
||||
import { hooks } from './hooks.ts'
|
||||
import { icons } from './icons.ts'
|
||||
@@ -52,6 +53,7 @@ export default {
|
||||
comments,
|
||||
extensions,
|
||||
flags,
|
||||
glossary,
|
||||
groups,
|
||||
hooks,
|
||||
icons,
|
||||
|
||||
+19
-14
@@ -5,6 +5,7 @@ import {
|
||||
tree as treeTable
|
||||
} from '../db/schema.ts'
|
||||
import { decodeTreePath } from '../helpers/common.ts'
|
||||
import { liveCondition } from '../helpers/publishing.ts'
|
||||
|
||||
/**
|
||||
* Page graph model
|
||||
@@ -48,7 +49,8 @@ export interface GraphPageRef {
|
||||
locale: string
|
||||
path: string
|
||||
tags: string[]
|
||||
publishState: string
|
||||
/** Whether the page is open to its readers now — see `helpers/publishing.ts`. */
|
||||
isLive: boolean
|
||||
}
|
||||
|
||||
export interface GraphPage {
|
||||
@@ -90,21 +92,24 @@ interface PageRow {
|
||||
path: string
|
||||
title: string
|
||||
tags: string[] | null
|
||||
publishState: string
|
||||
isLive: boolean
|
||||
editor: string
|
||||
alias: string | null
|
||||
relations: unknown
|
||||
}
|
||||
|
||||
const pageColumns = {
|
||||
id: pagesTable.id,
|
||||
locale: pagesTable.locale,
|
||||
path: pagesTable.path,
|
||||
title: pagesTable.title,
|
||||
tags: pagesTable.tags,
|
||||
publishState: pagesTable.publishState,
|
||||
editor: pagesTable.editor,
|
||||
alias: pagesTable.alias
|
||||
/** A function, not a constant: whether a page is live is a question about the time of the query. */
|
||||
function pageColumns() {
|
||||
return {
|
||||
id: pagesTable.id,
|
||||
locale: pagesTable.locale,
|
||||
path: pagesTable.path,
|
||||
title: pagesTable.title,
|
||||
tags: pagesTable.tags,
|
||||
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean),
|
||||
editor: pagesTable.editor,
|
||||
alias: pagesTable.alias
|
||||
}
|
||||
}
|
||||
|
||||
/** Precedence when one page reaches another in more than one way: the strongest link is kept. */
|
||||
@@ -133,7 +138,7 @@ class PageGraphModel {
|
||||
// -> The locale's own pages. Relations are read here and nowhere else: they are what tells a
|
||||
// relation apart from a link in the rows below, and only a source page's matter
|
||||
const pageRows = (await WIKI.db
|
||||
.select({ ...pageColumns, relations: pagesTable.relations })
|
||||
.select({ ...pageColumns(), relations: pagesTable.relations })
|
||||
.from(pagesTable)
|
||||
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.locale, locale)))
|
||||
.orderBy(pagesTable.path)
|
||||
@@ -163,7 +168,7 @@ class PageGraphModel {
|
||||
locale: row.locale,
|
||||
path: row.path,
|
||||
tags: row.tags ?? [],
|
||||
publishState: row.publishState
|
||||
isLive: row.isLive
|
||||
})
|
||||
if (!seen) {
|
||||
refused.add(row.id)
|
||||
@@ -284,7 +289,7 @@ class PageGraphModel {
|
||||
lookups.push(sql`${pagesTable.alias} = ANY(${sql.param(refs)}::text[])`)
|
||||
}
|
||||
const others = (await WIKI.db
|
||||
.select(pageColumns)
|
||||
.select(pageColumns())
|
||||
.from(pagesTable)
|
||||
.where(
|
||||
and(eq(pagesTable.siteId, siteId), sql`(${sql.join(lookups, sql` OR `)})`)
|
||||
|
||||
+80
-46
@@ -12,6 +12,7 @@ import {
|
||||
timingSafeCompare
|
||||
} from '../helpers/common.ts'
|
||||
import { invalidateAppShellCache } from '../helpers/appShell.ts'
|
||||
import { liveCondition, maySeeUnpublished } from '../helpers/publishing.ts'
|
||||
import { rewriteSourceLinks } from '../helpers/linkRewrite.ts'
|
||||
import type { SourceSyntax } from '../helpers/linkRewrite.ts'
|
||||
import { relinkHref, rewriteRenderLinks } from '../helpers/pageLinks.ts'
|
||||
@@ -899,7 +900,7 @@ class Pages {
|
||||
* Answered from the group id the page already carries, so a page that is not part of one costs no
|
||||
* query at all — which is nearly every page on nearly every site.
|
||||
*
|
||||
* `publicOnly` narrows it the same way it narrows the page itself: a draft translation is not
|
||||
* `unpublished` narrows it the same way it narrows the page itself: a draft translation is not
|
||||
* something to offer a reader who could not open it. It is what makes the locale selector's list of
|
||||
* languages the list of languages this reader can actually reach.
|
||||
*/
|
||||
@@ -908,8 +909,13 @@ class Pages {
|
||||
{
|
||||
localeGroupId,
|
||||
id,
|
||||
publicOnly = false
|
||||
}: { localeGroupId: string | null; id: string; publicOnly?: boolean }
|
||||
unpublished = true
|
||||
}: {
|
||||
localeGroupId: string | null
|
||||
id: string
|
||||
/** As for `getPage`. */
|
||||
unpublished?: boolean | ((page: RulePageRef) => boolean)
|
||||
}
|
||||
): Promise<PageLocaleRelation[]> {
|
||||
if (!localeGroupId) {
|
||||
return []
|
||||
@@ -919,19 +925,28 @@ class Pages {
|
||||
eq(pagesTable.localeGroupId, localeGroupId),
|
||||
ne(pagesTable.id, id)
|
||||
]
|
||||
if (publicOnly) {
|
||||
conditions.push(eq(pagesTable.publishState, 'published'))
|
||||
if (unpublished === false) {
|
||||
conditions.push(liveCondition(pagesTable))
|
||||
}
|
||||
return await WIKI.db
|
||||
const rows = await WIKI.db
|
||||
.select({
|
||||
locale: pagesTable.locale,
|
||||
path: pagesTable.path,
|
||||
title: pagesTable.title,
|
||||
tags: pagesTable.tags
|
||||
tags: pagesTable.tags,
|
||||
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean)
|
||||
})
|
||||
.from(pagesTable)
|
||||
.where(and(...conditions))
|
||||
.orderBy(pagesTable.locale)
|
||||
return rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.isLive ||
|
||||
typeof unpublished !== 'function' ||
|
||||
unpublished({ siteId, locale: row.locale, path: row.path, tags: row.tags ?? [] })
|
||||
)
|
||||
.map(({ locale, path, title, tags }) => ({ locale, path, title, tags }))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1255,7 +1270,7 @@ class Pages {
|
||||
* through the same check, because the enforcement is this method and not the client.
|
||||
*
|
||||
* **The defaults hand over the whole page**, `unlocked` and `withPassword` included, the way they do
|
||||
* for `publicOnly` beside them: most callers here are a save, a move, a delete or a re-render, and
|
||||
* for `unpublished` beside them: most callers here are a save, a move, a delete or a re-render, and
|
||||
* none of those is a reader — a save that got a withheld body back would answer its author with an
|
||||
* empty page, and a re-render would store one. A path that serves a reader has to say so, and there
|
||||
* are exactly two: the `GET` route, and `unlockPage` below.
|
||||
@@ -1377,8 +1392,9 @@ class Pages {
|
||||
* A sitemap is a list handed to search engines, so the question is not what exists but what a
|
||||
* crawler may both reach and index. Four things decide it, three of them in SQL:
|
||||
*
|
||||
* - **Published**, on the same reading as everywhere else — a `scheduled` page is not published yet
|
||||
* whatever its dates say, and a draft never was.
|
||||
* - **Live**, on the same reading as everywhere else (`helpers/publishing.ts`) — a `scheduled` page
|
||||
* inside its window is listed, one outside it is not, and a draft never is. The list is cached, so a
|
||||
* window opening or closing reaches it within `SITEMAP_TTL`.
|
||||
* - **Not a redirection**, which has no body to index and sends its reader elsewhere anyway.
|
||||
* - **Not password protected**, since what a crawler would reach there is the lock screen.
|
||||
* - **`isSearchable`**, the page property whose whole purpose is keeping a page out of search
|
||||
@@ -1403,7 +1419,7 @@ class Pages {
|
||||
.where(
|
||||
and(
|
||||
eq(pagesTable.siteId, siteId),
|
||||
eq(pagesTable.publishState, 'published'),
|
||||
liveCondition(pagesTable),
|
||||
eq(pagesTable.isSearchable, true),
|
||||
ne(pagesTable.editor, REDIRECT_EDITOR),
|
||||
isNull(pagesTable.password)
|
||||
@@ -1435,14 +1451,14 @@ class Pages {
|
||||
* decide and nothing about the requester does, which is what makes the answer the same for whoever
|
||||
* fetched it and therefore safe to cache and hand on.
|
||||
*
|
||||
* Published only, as it is everywhere the public is being answered — which is also what lets the app
|
||||
* Live pages only, as everywhere the public is being answered — which is also what lets the app
|
||||
* shell say 404 rather than 200 at a path with nothing published at it.
|
||||
*/
|
||||
async describePageForPublic(
|
||||
siteId: string,
|
||||
ref: { locale: string; path: string }
|
||||
): Promise<PageDescription | null> {
|
||||
return this.describePage(siteId, ref, WIKI.models.groups.actorForPublic(), true)
|
||||
return this.describePage(siteId, ref, WIKI.models.groups.actorForPublic(), false)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1450,9 +1466,9 @@ class Pages {
|
||||
*
|
||||
* Deliberately the same cut the page route itself makes (`GET /sites/:siteId/pages/:pageIdOrHash`),
|
||||
* so that the title in the document and the page the app then draws can never be about different
|
||||
* things: its `read:pages` check per path, and its `publicOnly: !actorFrom(req)` — which is to say
|
||||
* any signed-in session sees an unpublished page, and an API key is answered as the public is, since
|
||||
* a draft belongs to the people working on it rather than to whatever holds a token.
|
||||
* things: its `read:pages` check per path, and its `unpublished` — a page that is not live is
|
||||
* described only to a signed-in session holding `write:pages` or `manage:pages` on it, and an API
|
||||
* key is answered as the public is (`helpers/publishing.ts`).
|
||||
*
|
||||
* Not for caching across requesters: the answer is one reader's.
|
||||
*/
|
||||
@@ -1462,13 +1478,19 @@ class Pages {
|
||||
req: FastifyRequest
|
||||
): Promise<PageDescription | null> {
|
||||
const isSession = Boolean(req.session?.authenticated && req.session.user?.id)
|
||||
return this.describePage(siteId, ref, WIKI.models.groups.actorForRequest(req), !isSession)
|
||||
const actor = WIKI.models.groups.actorForRequest(req)
|
||||
return this.describePage(
|
||||
siteId,
|
||||
ref,
|
||||
actor,
|
||||
isSession ? (page: RulePageRef) => maySeeUnpublished(actor, page) : false
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The read behind both, which is the only thing that should call it.
|
||||
*
|
||||
* `actor` and `publicOnly` are paired by the two methods above rather than left to a caller, because
|
||||
* `actor` and `unpublished` are paired by the two methods above rather than left to a caller, because
|
||||
* the wrong pairing — the public actor with unpublished pages included — would describe a draft to
|
||||
* whoever asked.
|
||||
*/
|
||||
@@ -1476,7 +1498,7 @@ class Pages {
|
||||
siteId: string,
|
||||
{ locale, path }: { locale: string; path: string },
|
||||
actor: AccessActor,
|
||||
publicOnly: boolean
|
||||
unpublished: false | ((page: RulePageRef) => boolean)
|
||||
): Promise<PageDescription | null> {
|
||||
const conditions = [
|
||||
eq(pagesTable.siteId, siteId),
|
||||
@@ -1486,12 +1508,13 @@ class Pages {
|
||||
// whoever asked simply need not have — nothing here was handed a hash to look up
|
||||
eq(pagesTable.path, path)
|
||||
]
|
||||
if (publicOnly) {
|
||||
conditions.push(eq(pagesTable.publishState, 'published'))
|
||||
if (unpublished === false) {
|
||||
conditions.push(liveCondition(pagesTable))
|
||||
}
|
||||
|
||||
const rows = await WIKI.db
|
||||
.select({
|
||||
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean),
|
||||
locale: pagesTable.locale,
|
||||
path: pagesTable.path,
|
||||
title: pagesTable.title,
|
||||
@@ -1515,6 +1538,9 @@ class Pages {
|
||||
if (!WIKI.models.groups.checkAccess(actor, 'read:pages', { ...row, siteId })) {
|
||||
return null
|
||||
}
|
||||
if (!row.isLive && unpublished && !unpublished({ ...row, siteId })) {
|
||||
return null
|
||||
}
|
||||
|
||||
// -> A page and its lock are two things: the title and the description are not what a password
|
||||
// covers (the search index treats them the same way), so they travel and the body does not.
|
||||
@@ -1538,11 +1564,11 @@ class Pages {
|
||||
/**
|
||||
* The locales a page can be read in, for the alternates a document names.
|
||||
*
|
||||
* Published and readable by this actor, and including the page itself, which is what an `hreflang`
|
||||
* Live and readable by this actor, and including the page itself, which is what an `hreflang`
|
||||
* set calls for. A translation the asker may not read is not a URL to point them at — for the public
|
||||
* that is the same cut the sitemap makes, which is why the two say the same thing about a page.
|
||||
*
|
||||
* Published regardless of who is asking, unlike the page itself: a draft translation is not an
|
||||
* Live regardless of who is asking, unlike the page itself: a draft translation is not an
|
||||
* alternate version of a document, it is one that does not exist yet.
|
||||
*/
|
||||
private async readableAlternates(
|
||||
@@ -1560,7 +1586,7 @@ class Pages {
|
||||
and(
|
||||
eq(pagesTable.siteId, siteId),
|
||||
eq(pagesTable.localeGroupId, localeGroupId),
|
||||
eq(pagesTable.publishState, 'published')
|
||||
liveCondition(pagesTable)
|
||||
)
|
||||
)
|
||||
.orderBy(pagesTable.locale)
|
||||
@@ -1595,7 +1621,7 @@ class Pages {
|
||||
hash,
|
||||
locale,
|
||||
withContent = false,
|
||||
publicOnly = false,
|
||||
unpublished = true,
|
||||
unlocked = true,
|
||||
withPassword = true
|
||||
}: {
|
||||
@@ -1609,17 +1635,21 @@ class Pages {
|
||||
* request addressing a page by hash has in hand before the row is read.
|
||||
*/
|
||||
withContent?: boolean | ((page: RulePageRef) => boolean)
|
||||
/** Restrict to what a reader with no session may see: published pages. */
|
||||
publicOnly?: boolean
|
||||
/**
|
||||
* Whether a page that is not live (`helpers/publishing.ts`) may be returned: `false` holds the
|
||||
* read to live pages, and a predicate is asked of a page that is not live once its row is read.
|
||||
* Open by default, since most callers are the wiki acting on its own pages; anything answering a
|
||||
* reader passes one.
|
||||
*/
|
||||
unpublished?: boolean | ((page: RulePageRef) => boolean)
|
||||
unlocked?: boolean | ((pageId: string) => boolean)
|
||||
withPassword?: boolean
|
||||
}): Promise<Page | null> {
|
||||
const conditions = [eq(pagesTable.siteId, siteId)]
|
||||
if (publicOnly) {
|
||||
// -> Page-level access rules are not implemented, so this is the whole of it: an anonymous
|
||||
// reader sees published pages, and nothing else. A password does not hide a page from them —
|
||||
// it withholds the body until they enter it, which is what `locked` below does.
|
||||
conditions.push(eq(pagesTable.publishState, 'published'))
|
||||
if (unpublished === false) {
|
||||
// -> A password does not hide a page from a reader — it withholds the body until they enter
|
||||
// it, which is what `locked` below does. Being unpublished does.
|
||||
conditions.push(liveCondition(pagesTable))
|
||||
}
|
||||
if (id) {
|
||||
conditions.push(eq(pagesTable.id, id))
|
||||
@@ -1634,6 +1664,7 @@ class Pages {
|
||||
const results = await WIKI.db
|
||||
.select({
|
||||
page: pagesTable,
|
||||
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean),
|
||||
authorName: usersTable.name,
|
||||
authorHasAvatar: usersTable.hasAvatar,
|
||||
navigationId: treeTable.navigationId,
|
||||
@@ -1649,16 +1680,18 @@ class Pages {
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
const ref: RulePageRef = {
|
||||
path: row.page.path,
|
||||
locale: row.page.locale,
|
||||
siteId,
|
||||
tags: row.page.tags ?? []
|
||||
}
|
||||
// -> Not there, rather than refused: whether a draft exists at a path is the draft's business
|
||||
if (!row.isLive && typeof unpublished === 'function' && !unpublished(ref)) {
|
||||
return null
|
||||
}
|
||||
const isUnlocked = typeof unlocked === 'function' ? unlocked(row.page.id) : unlocked
|
||||
const includeContent =
|
||||
typeof withContent === 'function'
|
||||
? withContent({
|
||||
path: row.page.path,
|
||||
locale: row.page.locale,
|
||||
siteId,
|
||||
tags: row.page.tags ?? []
|
||||
})
|
||||
: withContent
|
||||
const includeContent = typeof withContent === 'function' ? withContent(ref) : withContent
|
||||
return this.toPage(
|
||||
{
|
||||
...row.page,
|
||||
@@ -1670,7 +1703,7 @@ class Pages {
|
||||
localeRelations: await this.localeRelationsFor(siteId, {
|
||||
localeGroupId: row.page.localeGroupId,
|
||||
id: row.page.id,
|
||||
publicOnly
|
||||
unpublished
|
||||
})
|
||||
},
|
||||
{
|
||||
@@ -1696,14 +1729,15 @@ class Pages {
|
||||
hash,
|
||||
locale,
|
||||
password,
|
||||
publicOnly = false
|
||||
unpublished = true
|
||||
}: {
|
||||
siteId: string
|
||||
id?: string
|
||||
hash?: string
|
||||
locale?: string
|
||||
password: string
|
||||
publicOnly?: boolean
|
||||
/** As for `getPage`. */
|
||||
unpublished?: boolean | ((page: RulePageRef) => boolean)
|
||||
}): Promise<Page | null> {
|
||||
/*
|
||||
Asked for as a reader would see it, for two reasons: a wrong guess must not assemble the body in
|
||||
@@ -1714,7 +1748,7 @@ class Pages {
|
||||
id,
|
||||
hash,
|
||||
locale,
|
||||
publicOnly,
|
||||
unpublished,
|
||||
unlocked: false,
|
||||
withPassword: false
|
||||
})
|
||||
@@ -1735,7 +1769,7 @@ class Pages {
|
||||
return this.getPage({
|
||||
siteId,
|
||||
id: page.id,
|
||||
publicOnly,
|
||||
unpublished,
|
||||
unlocked: true,
|
||||
withPassword: false
|
||||
})
|
||||
|
||||
+34
-17
@@ -1,4 +1,9 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import {
|
||||
liveCondition,
|
||||
maySeeUnpublished,
|
||||
maySeeUnpublishedAnywhere
|
||||
} from '../helpers/publishing.ts'
|
||||
import type { AccessActor } from './groups.ts'
|
||||
|
||||
/**
|
||||
@@ -117,10 +122,12 @@ export interface SearchPagesParams {
|
||||
orderByDirection?: 'asc' | 'desc'
|
||||
offset?: number
|
||||
limit?: number
|
||||
/** Restrict to what a reader with no session may see: published pages. */
|
||||
publicOnly?: boolean
|
||||
/** Whether unpublished pages belong in the results, which is an editor's view of the wiki. */
|
||||
includeDrafts?: boolean
|
||||
/**
|
||||
* Whether a page that is not live (`helpers/publishing.ts`) may be listed, for an `actor` holding
|
||||
* `write:pages` or `manage:pages` on it. False — a request without a session — holds every result
|
||||
* to live pages.
|
||||
*/
|
||||
unpublished?: boolean
|
||||
/**
|
||||
* Who is searching, so that a result they could not open never reaches them.
|
||||
*
|
||||
@@ -258,8 +265,7 @@ class Search {
|
||||
orderByDirection = 'desc',
|
||||
offset = 0,
|
||||
limit = 25,
|
||||
publicOnly = false,
|
||||
includeDrafts = false,
|
||||
unpublished = false,
|
||||
hideProtectedContent = true,
|
||||
actor
|
||||
}: SearchPagesParams): Promise<SearchPagesResult> {
|
||||
@@ -286,12 +292,19 @@ class Search {
|
||||
if (hasQuery) {
|
||||
conditions.push(sql`p.ts @@ ${tsQuery}`)
|
||||
}
|
||||
if (publicOnly) {
|
||||
// -> Matches what a page view shows an anonymous reader, so that search cannot surface a page
|
||||
// that could not then be opened
|
||||
conditions.push(sql`p."publishState" = 'published'`)
|
||||
} else if (!includeDrafts) {
|
||||
conditions.push(sql`p."publishState" <> 'draft'`)
|
||||
/*
|
||||
Matches what the page view shows, so that search cannot surface a page that could not then be
|
||||
opened. Who may see a page that is not live is a page rule's answer, so it is settled per row
|
||||
below; a searcher who holds neither permission anywhere is held to live pages here instead.
|
||||
*/
|
||||
const liveNow = liveCondition({
|
||||
publishState: sql`p."publishState"`,
|
||||
publishStartDate: sql`p."publishStartDate"`,
|
||||
publishEndDate: sql`p."publishEndDate"`
|
||||
})
|
||||
const checkUnpublished = unpublished && !!actor && maySeeUnpublishedAnywhere(actor)
|
||||
if (!checkUnpublished) {
|
||||
conditions.push(liveNow)
|
||||
}
|
||||
if (hideProtectedContent && hasQuery) {
|
||||
/*
|
||||
@@ -392,19 +405,23 @@ class Search {
|
||||
let totalHits: number
|
||||
if (checkRules) {
|
||||
const candidates = await WIKI.db.execute(sql`
|
||||
SELECT p.id, p.path, p.locale, p.tags, ${relevancy} AS relevancy
|
||||
SELECT p.id, p.path, p.locale, p.tags, ${liveNow} AS "isLive", ${relevancy} AS relevancy
|
||||
FROM pages p
|
||||
WHERE ${where}
|
||||
ORDER BY ${ordering}
|
||||
`)
|
||||
const readable = ((candidates.rows ?? candidates) as any[]).filter((row) =>
|
||||
WIKI.models.groups.checkAccess(actor, 'read:pages', {
|
||||
const readable = ((candidates.rows ?? candidates) as any[]).filter((row) => {
|
||||
const ref = {
|
||||
siteId,
|
||||
path: row.path as string,
|
||||
locale: row.locale as string,
|
||||
tags: (row.tags ?? []) as string[]
|
||||
})
|
||||
)
|
||||
}
|
||||
return (
|
||||
WIKI.models.groups.checkAccess(actor, 'read:pages', ref) &&
|
||||
(row.isLive === true || maySeeUnpublished(actor, ref))
|
||||
)
|
||||
})
|
||||
pageIds = readable.slice(offset, offset + limit).map((row) => row.id as string)
|
||||
totalHits = readable.length
|
||||
} else {
|
||||
|
||||
@@ -139,6 +139,9 @@ class Sites {
|
||||
// been picked. This is the switch that turns them all off without losing that
|
||||
// choice, which is only useful to somebody who has already made it.
|
||||
comments: true,
|
||||
// -> On: a glossary with no terms costs a reader nothing, and the menu row that opens it is
|
||||
// also gated on `read:glossary`, which nobody but a fresh install's users holds
|
||||
glossary: true,
|
||||
lastEditedBy: true,
|
||||
// -> On: a reader who follows a link to a folder rather than a page is better served by
|
||||
// what is in it than by a notice that there is nothing there
|
||||
@@ -468,6 +471,7 @@ class Sites {
|
||||
collaborativeEditing: true,
|
||||
ratingsMode: 'off',
|
||||
comments: true,
|
||||
glossary: true,
|
||||
lastEditedBy: true,
|
||||
listFolderChildren: true,
|
||||
notifications: true,
|
||||
|
||||
+64
-24
@@ -1,5 +1,7 @@
|
||||
import { and, asc, desc, eq, exists, inArray, ne, not, or, sql, type SQL } from 'drizzle-orm'
|
||||
import { alias, type PgColumn } from 'drizzle-orm/pg-core'
|
||||
import { liveCondition } from '../helpers/publishing.ts'
|
||||
import type { RulePageRef } from '../helpers/pageRules.ts'
|
||||
import { pages as pagesTable, tree as treeTable } from '../db/schema.ts'
|
||||
import {
|
||||
CustomError,
|
||||
@@ -217,10 +219,12 @@ function toTreeItem(row: TreeRow, depth: number, parentPath: string): TreeItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a page has to be for a reader to be shown that it exists.
|
||||
* What a page has to be for a reader to be shown that it exists, as far as SQL can say.
|
||||
*
|
||||
* Deliberately the same rule the page view itself applies (see `pages.getPage`'s `publicOnly`), so
|
||||
* that a menu never offers a page that would answer 404 — nor hides one that would open.
|
||||
* Deliberately the same rule the page view itself applies (see `pages.getPage`'s `unpublished`), so
|
||||
* that a menu never offers a page that would answer 404 — nor hides one that would open. With
|
||||
* `unpublished` false a page has to be live; with a predicate every state comes back and the rows are
|
||||
* held to it by `isListable`, since who may see a page that is not live is a page rule's answer.
|
||||
*
|
||||
* A password-protected page is listed. It is not hidden but locked: opening it puts the reader in
|
||||
* front of the unlock prompt, which is exactly where someone who has the password wants to end up,
|
||||
@@ -229,17 +233,27 @@ function toTreeItem(row: TreeRow, depth: number, parentPath: string): TreeItem {
|
||||
* The columns come in one by one rather than as a table, because this is applied both to `pages` and
|
||||
* to an alias of it, and an alias is a different type.
|
||||
*
|
||||
* @param publicOnly Restrict to what a reader with no session may see. `isBrowsable` applies either
|
||||
* way: it is the author saying "not in the tree", not an access rule.
|
||||
* @param unpublished As for `pages.getPage`. `isBrowsable` applies either way: it is the author saying
|
||||
* "not in the tree", not an access rule.
|
||||
*/
|
||||
function pageIsVisible(
|
||||
columns: { isBrowsable: PgColumn; publishState: PgColumn },
|
||||
publicOnly: boolean
|
||||
columns: {
|
||||
isBrowsable: PgColumn
|
||||
publishState: PgColumn
|
||||
publishStartDate: PgColumn
|
||||
publishEndDate: PgColumn
|
||||
},
|
||||
unpublished: Unpublished
|
||||
): (SQL | undefined)[] {
|
||||
return [
|
||||
eq(columns.isBrowsable, true),
|
||||
...(publicOnly ? [eq(columns.publishState, 'published')] : [])
|
||||
]
|
||||
return [eq(columns.isBrowsable, true), ...(unpublished ? [] : [liveCondition(columns)])]
|
||||
}
|
||||
|
||||
/** Whether a page that is not live may be listed: `getPage`'s `unpublished`, minus the `true`. */
|
||||
type Unpublished = false | ((page: RulePageRef) => boolean)
|
||||
|
||||
/** The half of `pageIsVisible` that has to be asked of each row once it is read. */
|
||||
function isListable(unpublished: Unpublished, isLive: boolean, page: RulePageRef): boolean {
|
||||
return isLive || (unpublished !== false && unpublished(page))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -373,7 +387,7 @@ class Tree {
|
||||
* @param path Slash-separated path to list. The site root when empty.
|
||||
* @param depth How many folders below the path to include. 0, the default, is the path itself.
|
||||
* @param tags Only pages carrying every one of these tags.
|
||||
* @param publicOnly Restrict to what a reader with no session may see. See `pageIsVisible`.
|
||||
* @param unpublished Whether a page that is not live may be listed. See `pageIsVisible`.
|
||||
*/
|
||||
async listPages({
|
||||
siteId,
|
||||
@@ -384,7 +398,7 @@ class Tree {
|
||||
orderBy = 'title',
|
||||
orderByDirection = 'asc',
|
||||
depth = 0,
|
||||
publicOnly = true
|
||||
unpublished = false
|
||||
}: {
|
||||
siteId: string
|
||||
path?: string | null
|
||||
@@ -394,7 +408,7 @@ class Tree {
|
||||
orderBy?: TreeOrderBy
|
||||
orderByDirection?: 'asc' | 'desc'
|
||||
depth?: number
|
||||
publicOnly?: boolean
|
||||
unpublished?: Unpublished
|
||||
}): Promise<ListedPage[]> {
|
||||
if (limit < 1 || limit > MAX_LIMIT) {
|
||||
throw new CustomError('treeInvalidLimit', `The limit must be between 1 and ${MAX_LIMIT}.`)
|
||||
@@ -416,7 +430,8 @@ class Tree {
|
||||
title: treeTable.title,
|
||||
tags: treeTable.tags,
|
||||
description: pagesTable.description,
|
||||
icon: pagesTable.icon
|
||||
icon: pagesTable.icon,
|
||||
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean)
|
||||
})
|
||||
.from(treeTable)
|
||||
.innerJoin(pagesTable, eq(pagesTable.id, treeTable.id))
|
||||
@@ -427,15 +442,15 @@ class Tree {
|
||||
eq(treeTable.type, 'page'),
|
||||
sql`${treeTable.folderPath} ~ ${pathQuery}::lquery`,
|
||||
...(tags && tags.length > 0 ? [sql`${treeTable.tags} @> ${sql.param(tags)}`] : []),
|
||||
...pageIsVisible(pagesTable, publicOnly)
|
||||
...pageIsVisible(pagesTable, unpublished)
|
||||
)
|
||||
)
|
||||
.orderBy(direction(treeTable[orderBy]))
|
||||
.limit(limit)
|
||||
|
||||
return rows.map((row) => {
|
||||
return rows.flatMap((row) => {
|
||||
const folderPath = decodeTreePath(row.folderPath ?? '') ?? ''
|
||||
return {
|
||||
const page = {
|
||||
id: row.id,
|
||||
path: folderPath ? `${folderPath}/${row.fileName}` : row.fileName,
|
||||
title: row.title,
|
||||
@@ -443,6 +458,14 @@ class Tree {
|
||||
icon: row.icon ?? '',
|
||||
tags: row.tags ?? []
|
||||
}
|
||||
return isListable(unpublished, row.isLive, {
|
||||
siteId,
|
||||
locale,
|
||||
path: page.path,
|
||||
tags: page.tags
|
||||
})
|
||||
? [page]
|
||||
: []
|
||||
})
|
||||
}
|
||||
|
||||
@@ -456,19 +479,19 @@ class Tree {
|
||||
* invisible is a dead end rather than something to offer.
|
||||
*
|
||||
* @param path Slash-separated path of the folder to list. The site root when empty.
|
||||
* @param publicOnly Restrict pages to what a reader with no session may see. See `pageIsVisible`.
|
||||
* @param unpublished Whether a page that is not live may be listed. See `pageIsVisible`.
|
||||
* @returns The level, or null when there is no such folder
|
||||
*/
|
||||
async browse({
|
||||
siteId,
|
||||
path,
|
||||
locale,
|
||||
publicOnly = true
|
||||
unpublished = false
|
||||
}: {
|
||||
siteId: string
|
||||
path?: string | null
|
||||
locale: string
|
||||
publicOnly?: boolean
|
||||
unpublished?: Unpublished
|
||||
}): Promise<BrowseLevel | null> {
|
||||
const encodedPath = encodeTreePath(path)
|
||||
const basePath = decodeTreePath(encodedPath) ?? ''
|
||||
@@ -509,6 +532,10 @@ class Tree {
|
||||
A folder is created for whatever is put in it, so it can end up holding only assets, only
|
||||
drafts, or nothing at all — descending into any of those lands on an empty menu. `EXISTS` stops
|
||||
at the first hit, so this costs an index lookup per folder in the level rather than a count.
|
||||
|
||||
Approximate for a reader who may see SOME unpublished pages: whether they may see a particular
|
||||
one below is a page rule's answer, which SQL cannot ask, so for them any state counts here. The
|
||||
same is already true of `read:pages`, which is not asked of a folder's contents either.
|
||||
*/
|
||||
const holdsVisiblePages = exists(
|
||||
WIKI.db
|
||||
@@ -521,7 +548,7 @@ class Tree {
|
||||
eq(descendant.locale, treeTable.locale),
|
||||
eq(descendant.type, 'page'),
|
||||
sql`${descendant.folderPath} <@ (${childPathPrefix}::text || ${treeTable.fileName})::ltree`,
|
||||
...pageIsVisible(descendantPage, publicOnly)
|
||||
...pageIsVisible(descendantPage, unpublished)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -538,6 +565,7 @@ class Tree {
|
||||
title: treeTable.title,
|
||||
tags: treeTable.tags,
|
||||
icon: pagesTable.icon,
|
||||
isLive: sql<boolean>`${liveCondition(pagesTable)}`.mapWith(Boolean),
|
||||
holdsVisiblePages: sql<boolean>`${holdsVisiblePages}`.mapWith(Boolean)
|
||||
})
|
||||
.from(treeTable)
|
||||
@@ -549,7 +577,7 @@ class Tree {
|
||||
eq(treeTable.folderPath, encodedPath),
|
||||
or(
|
||||
eq(treeTable.type, 'folder'),
|
||||
and(eq(treeTable.type, 'page'), ...pageIsVisible(pagesTable, publicOnly))
|
||||
and(eq(treeTable.type, 'page'), ...pageIsVisible(pagesTable, unpublished))
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -561,8 +589,20 @@ class Tree {
|
||||
if (row.type === 'folder' && !row.holdsVisiblePages) {
|
||||
continue
|
||||
}
|
||||
const rowPath = basePath ? `${basePath}/${row.fileName}` : row.fileName
|
||||
if (
|
||||
row.type === 'page' &&
|
||||
!isListable(unpublished, row.isLive, {
|
||||
siteId,
|
||||
locale,
|
||||
path: rowPath,
|
||||
tags: row.tags ?? []
|
||||
})
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const entry = merged.get(row.fileName) ?? {
|
||||
path: basePath ? `${basePath}/${row.fileName}` : row.fileName,
|
||||
path: rowPath,
|
||||
fileName: row.fileName,
|
||||
title: row.title,
|
||||
icon: null,
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
# Glossary
|
||||
|
||||
**Status:** phases 1 to 3 of [§11](#11-suggested-order) implemented — storage, permissions, the API
|
||||
and the overlay. URLs (§7) and the page-move rewrite (§5.3) are still to come. Where building it
|
||||
changed the design, this document was changed with it.
|
||||
**Covers:** what a glossary term is, how it is stored, who may read and edit it, the overlay it is read
|
||||
and edited in, its URL, and what the first version does to leave room for linking terms automatically
|
||||
in page text later.
|
||||
|
||||
A wiki accumulates vocabulary: product names, acronyms, words that mean something narrower here than
|
||||
they do anywhere else. A glossary gives each of them one place to be defined, one page that documents
|
||||
it properly, and a way of saying which terms belong together.
|
||||
|
||||
The constraints this is written against:
|
||||
|
||||
- **One site, one locale, one glossary.** A term belongs to a site and to a locale, the same way a page
|
||||
does. Nothing is shared between sites, and a term is not translated by being edited in another
|
||||
locale.
|
||||
- **A name means one term.** A term's name and every one of its aliases are unique within its site and
|
||||
locale, compared case-insensitively. That is what lets a URL, a related-terms list and, later, a
|
||||
word in a paragraph each resolve to exactly one entry.
|
||||
- **The glossary is read over the page, not instead of it.** It is an overlay, fetched the first time
|
||||
somebody opens it, so a reader who never does pays nothing for it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals and non-goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- A **Glossary overlay**, opened from the header's **Library** menu: terms listed in a sidebar, the
|
||||
selected term in the main panel, and a **New Term** button at the top of the sidebar that opens the
|
||||
form in the main panel.
|
||||
- The fields in [§3.1](#31-glossaryterms): term, expansion, definition, aliases, related terms,
|
||||
documentation page and its label, external references, case sensitivity, category, and auto-link.
|
||||
- **Two-way related terms.** Relating A to B relates B to A.
|
||||
- **A URL per term**, so a term can be linked to from a chat message or another page
|
||||
([§7](#7-urls)).
|
||||
- **Two new permissions** ([§4](#4-permissions)): `read:glossary` for reading a site's glossary in a
|
||||
locale, and `manage:glossary` for creating, editing and deleting its terms. Both are page-rule
|
||||
permissions whose rule's path is ignored.
|
||||
- **Moving a page with `updateLinks` updates every term documented by it** ([§5.3](#53-moving-a-page)).
|
||||
- **Lost updates are refused, not silent**: a save against a term somebody else changed in the meantime
|
||||
answers 409 ([§5.2](#52-concurrent-edits)).
|
||||
- **A per-site switch**, `features.glossary`.
|
||||
|
||||
**Non-goals for the first version**
|
||||
|
||||
- **Linking terms in page text automatically.** Planned; the `autoLink` column ships now and its
|
||||
toggle is shown disabled. [§9](#9-auto-linking-later) is the design the first version is built to
|
||||
accommodate.
|
||||
- **A `block-glossary` content block**, listing terms (or one category's) inside a page, and an inline
|
||||
block for marking a term explicitly.
|
||||
- **Glossary terms in site search results.**
|
||||
- **Linking equivalent terms across locales**, the way pages are grouped into locale groups.
|
||||
- **Import and export** (CSV, JSON).
|
||||
- **Revision history of a term.** The audit log records who changed what and which fields; it does not
|
||||
keep the previous text ([§8](#8-audit-log)).
|
||||
- **Real-time collaborative editing.** [§5.2](#52-concurrent-edits) is the whole of the concurrency
|
||||
story.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
| Piece | Where |
|
||||
| --- | --- |
|
||||
| Tables | `glossaryTerms`, `glossaryTermRelations` in `backend/db/schema.ts`, one generated migration (`--name=glossary`) |
|
||||
| Model | `backend/models/glossary.ts`, exposed as `WIKI.models.glossary` |
|
||||
| API | `backend/api/glossary.ts`, schemas in `backend/api/schemas/` |
|
||||
| Permissions | `read:glossary` and `manage:glossary` in `PAGE_PERMISSIONS` and `LOCALE_PERMISSIONS` (`models/groups.ts`); the group editor's rule list (`GroupEditOverlay.vue`); the lists in CLAUDE.md |
|
||||
| Overlay | `frontend/src/components/GlossaryOverlay.vue`, registered in `MainOverlayDialog.vue`, opened by `siteStore.openGlossary()` |
|
||||
| Definition renderer | `frontend/src/renderers/glossary.js`, built on `createProseMarkdown` from `renderers/comment.js` ([§6.4](#64-the-definition)) |
|
||||
| Access | `frontend/src/stores/glossary.js`, from `GET /glossary/access` ([§4.5](#45-on-the-frontend)) |
|
||||
| Entry point | The Glossary row of `HeaderLibraryMenu.vue` |
|
||||
| Strings | `glossary.*` in `backend/locales/en.json` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Storage
|
||||
|
||||
### 3.1 `glossaryTerms`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | uuid pk | |
|
||||
| `siteId` | uuid, → `sites`, on delete cascade | |
|
||||
| `locale` | varchar(255), not null | Same shape as `pages.locale` |
|
||||
| `term` | varchar(255), not null | Trimmed, inner whitespace collapsed, NFC |
|
||||
| `expansion` | varchar(255), nullable | The full form, when the term is an abbreviation |
|
||||
| `definition` | text, not null, default `''` | Basic markdown source, never HTML ([§6.4](#64-the-definition)) |
|
||||
| `aliases` | text[], not null, default `{}` | Alternate forms and inflections; normalized like `term` |
|
||||
| `documentationPath` | varchar(255), nullable | A page path in the term's own locale, no locale prefix |
|
||||
| `documentationLabel` | varchar(255), nullable | Null means the default, "Read more", which comes from the locale strings and is not stored |
|
||||
| `references` | jsonb, not null, default `[]` | `[{ url, label }]`, in the order given |
|
||||
| `caseSensitive` | boolean, not null, default false | How the term is *matched in text*; never how it is compared for uniqueness ([§3.3](#33-uniqueness)) |
|
||||
| `autoLink` | boolean, not null, default true | Whether the term is linked automatically in page text; no effect until [§9](#9-auto-linking-later) ships |
|
||||
| `category` | varchar(255), nullable | One per term, free text, used to group terms in the sidebar |
|
||||
| `creatorId` | uuid, → `users`, on delete set null | |
|
||||
| `authorId` | uuid, → `users`, on delete set null | Who saved it last |
|
||||
| `createdAt` | timestamp, default now | |
|
||||
| `updatedAt` | timestamp, default now | Also the concurrency token ([§5.2](#52-concurrent-edits)) |
|
||||
|
||||
Indexes:
|
||||
|
||||
- `(siteId, locale)` for the list.
|
||||
- **Unique `(siteId, locale, lower(term))`**: the database's half of [§3.3](#33-uniqueness).
|
||||
- `(siteId, locale, documentationPath)` where `documentationPath` is not null, for
|
||||
[§5.3](#53-moving-a-page).
|
||||
|
||||
**Category is a column, not the site `tags` table.** That table counts how many pages use each tag and
|
||||
is what `/_tags` is drawn from; glossary categories in it would appear on the Tags page as page tags
|
||||
that no page carries. The category suggestions in the form come from
|
||||
`SELECT DISTINCT category` over the locale's terms.
|
||||
|
||||
### 3.2 `glossaryTermRelations`
|
||||
|
||||
| Column | Type |
|
||||
| --- | --- |
|
||||
| `termId` | uuid, → `glossaryTerms`, on delete cascade |
|
||||
| `relatedId` | uuid, → `glossaryTerms`, on delete cascade |
|
||||
|
||||
Primary key `(termId, relatedId)`, a `CHECK (termId < relatedId)`, and an index on `relatedId`.
|
||||
|
||||
**One row per pair, read from both sides.** The check stores each relation exactly once with the
|
||||
smaller id first, so "B is related to A" and "A is related to B" are the same row and cannot drift
|
||||
apart. A term's related terms are the union of `relatedId where termId = $1` and
|
||||
`termId where relatedId = $1`. Saving a term's `relatedTerms` replaces the rows touching that term, in
|
||||
the same transaction as the term itself, so removing B from A's list removes A from B's.
|
||||
|
||||
A join table rather than a uuid array on the term, so that deleting a term takes it out of every other
|
||||
term's list through the cascade alone, with no cleanup code to forget.
|
||||
|
||||
Related terms must be in the same site and locale; the model refuses any other id with 400.
|
||||
|
||||
### 3.3 Uniqueness
|
||||
|
||||
**A term's name and its aliases share one namespace with every other term's name and aliases** in the
|
||||
same site and locale, compared with `lower()` after normalization. So:
|
||||
|
||||
- `REST` and `rest` cannot be two terms. `caseSensitive` on `REST` says that the word `rest` in a
|
||||
paragraph is not a mention of it; it does not make room for a second entry spelled `rest`.
|
||||
- An alias may not equal another term's name, nor one of its aliases.
|
||||
- An alias equal to the term's own name, or repeated, is dropped silently on save rather than refused.
|
||||
|
||||
The unique index covers term against term. Everything involving an alias is checked in the model,
|
||||
inside the save's transaction, and refused with **409** naming the term that already holds the name, so
|
||||
the form can say which one. Two saves racing to claim the same alias are not caught by the index; that
|
||||
is accepted, since the outcome is two terms sharing an alias, which a later edit of either one refuses
|
||||
and so surfaces.
|
||||
|
||||
Comparison uses Postgres `lower()` on the server and `toLocaleLowerCase(locale)` in the browser. The two
|
||||
agree for every case a glossary is likely to meet; the server is the one that decides.
|
||||
|
||||
### 3.4 Limits
|
||||
|
||||
| Field | Limit |
|
||||
| --- | --- |
|
||||
| `term`, `expansion`, `category`, `documentationLabel`, each alias | 255 characters |
|
||||
| `aliases` | 50 |
|
||||
| `definition` | 10,000 characters |
|
||||
| `references` | 20; `url` is `http:` or `https:` only, `label` up to 255 characters and optional (the URL stands in) |
|
||||
| `relatedTerms` | 50 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Permissions
|
||||
|
||||
Two permissions of the same kind: page-rule permissions whose rule's path is ignored, so that both are
|
||||
granted per site and per locale. A site may open its glossary to the readers of one locale and not
|
||||
another, and hand the maintenance of each locale's glossary to the people who write in it.
|
||||
|
||||
### 4.1 The rule
|
||||
|
||||
Both are **page-rule permissions**, added to `PAGE_PERMISSIONS` and granted through a group's rules like
|
||||
`read:pages`. They are the page permissions whose rule's **path is ignored** (`LOCALE_PERMISSIONS`): the
|
||||
question they answer is about a site and a locale, and a glossary has no path.
|
||||
|
||||
**Every rule naming the permission that is scoped to the site and the locale applies, whatever its
|
||||
`match`, `path` or `tags`, and the modes rank as they do for any other rule:**
|
||||
|
||||
ALLOW < DENY < FORCE ALLOW
|
||||
|
||||
So: no such rule, denied; only ALLOWs, granted; any DENY overrides every ALLOW; any FORCE ALLOW
|
||||
overrides every DENY. This is step 4 of the ordering in
|
||||
[`helpers/pageRules.ts`](../../backend/helpers/pageRules.ts) on its own (`resolveLocaleRule`). Steps 1
|
||||
to 3, which rank rules by how precisely they address a page, have nothing to rank when no page is being
|
||||
asked about, so every rule counts as equally specific and mode alone decides.
|
||||
|
||||
What follows from ignoring the path, and what the permissions' hints in the group editor have to say:
|
||||
|
||||
- **A rule anywhere speaks for the whole locale.** An ALLOW written for `/docs` opens the entire
|
||||
glossary, the same as one for the site root.
|
||||
- **A DENY anywhere closes the whole locale**, and a more specific ALLOW does not reopen it the way it
|
||||
would for pages. Only a FORCE ALLOW does. A DENY rule for `/confidential` that also names
|
||||
`read:glossary` therefore hides the glossary from that group entirely.
|
||||
|
||||
Both are asked by `groups.checkLocaleAccess(actor, permission, siteId, locale)`, and `checkAccess` routes
|
||||
them there, so asking about one at a page gets the locale's answer. The worker-side `rulesAllow` is not
|
||||
involved: nothing in a worker reads the glossary. `manage:system` implies both, as it implies
|
||||
everything. No route declares either in `config.permissions` (the hook reads the global list only);
|
||||
every glossary route checks in the handler, with a `No route-level permissions:` comment as
|
||||
`api/pages.ts` does.
|
||||
|
||||
### 4.2 `read:glossary`
|
||||
|
||||
- **`manage:glossary` in a locale implies it there.** Editing a glossary one may not read is not a state
|
||||
worth supporting, and the editor has to see the list to pick related terms. Each is decided on its own
|
||||
rules first, so a DENY on reading does not take managing with it.
|
||||
- **Guests may hold it**: it joins `GUEST_ROLES` in `models/groups.ts` (and the copy in
|
||||
`GroupEditOverlay.vue`), which is what lets a public wiki open its glossary to readers without an
|
||||
account.
|
||||
- **Seeding**: `read:glossary` is added beside `read:pages` in the Users group's seeded ALLOW rule
|
||||
(`groups.init()`) and in the starting rule of a newly created group (`createGroup`). It is
|
||||
deliberately **not** added to the Guests group's seeded DENY rule. Guests are denied anyway with no
|
||||
rule at all, and a DENY there would mean an administrator's later ALLOW for guests did nothing (the
|
||||
DENY outranks it, path or no path), leaving FORCE ALLOW or editing the default rule as the only ways
|
||||
in. Without it, a fresh install's guests cannot read the glossary until an administrator writes an
|
||||
ALLOW rule for it, as with pages.
|
||||
|
||||
### 4.3 `manage:glossary`
|
||||
|
||||
Create, edit and delete terms in the rule's locales. A write is asked about in the locale of the term it
|
||||
touches: the body's `locale` on a create, the stored term's on a save or a delete. A term the caller may
|
||||
not even read answers 404 there, as it does on the read route; one they may read but not manage, 403.
|
||||
|
||||
- **Not seeded anywhere** and not in `GUEST_ROLES`: maintaining the glossary is something an
|
||||
administrator hands out, and a guest has no session to attribute an edit to.
|
||||
|
||||
### 4.4 What a reader sees
|
||||
|
||||
**The documentation link is shown to every reader of the term**, whether or not they may read the page
|
||||
it points at; following it answers as the page route always does. Hiding it would need a page-rule check
|
||||
per term in the list, for no information the reader does not already have from the term itself.
|
||||
|
||||
### 4.5 On the frontend
|
||||
|
||||
`userStore.pagePermissions` cannot answer `read:glossary`: it is what the session holds at the current
|
||||
PATH, it now arrives with the page payload rather than from `pages/userPermissions`, and it is empty on
|
||||
every route that is not a page — `/_tags` included, where the Library menu is still drawn. So the
|
||||
answer has an endpoint of its own, `GET /sites/:siteId/glossary/access`, which reports the site switch,
|
||||
the active locales the session may read the glossary in, and the ones it may edit it in.
|
||||
`stores/glossary.js` asks it once per site and session (again after a login or a logout), since none
|
||||
of it changes from page to page.
|
||||
|
||||
- The Library menu's Glossary row shows when `features.glossary` is on and at least one locale is
|
||||
readable. The overlay opens in the interface's locale when that one is readable, and in the first
|
||||
readable one otherwise.
|
||||
- New Term, Edit and Delete show where the locale on screen is one of the editable ones.
|
||||
- The overlay's locale picker offers every active locale. One the session may not read the glossary in
|
||||
answers the list with 403, and the sidebar says so in place of the list.
|
||||
|
||||
---
|
||||
|
||||
## 5. Backend
|
||||
|
||||
### 5.1 API
|
||||
|
||||
All under `/_api/sites/:siteId/glossary`, tagged `Glossary`.
|
||||
|
||||
| Method and path | Permission | What it does |
|
||||
| --- | --- | --- |
|
||||
| `GET /access` | none (it reports the permissions) | `{ enabled, readableLocales, manageableLocales }` for the caller ([§4.5](#45-on-the-frontend)) |
|
||||
| `GET /?locale=` | `read:glossary` in the locale | The list: `id`, `term`, `expansion`, `aliases`, `category`, `caseSensitive`, `autoLink`. Everything the sidebar draws and everything [§9](#9-auto-linking-later) will need, and nothing else |
|
||||
| `GET /categories?locale=` | `read:glossary` in the locale | Distinct categories, for the form's suggestions |
|
||||
| `GET /:termId` | `read:glossary` in the term's locale | The whole term, with `relatedTerms` as `[{ id, term }]` and `documentation` as `{ path, label, title, exists }` |
|
||||
| `GET /lookup?locale=&name=` | `read:glossary` in the locale | Resolves a name or alias to a term id, for [§7](#7-urls); 404 when nothing matches |
|
||||
| `POST /` | `manage:glossary` in the body's locale | Creates a term; answers it in full |
|
||||
| `PUT /:termId` | `manage:glossary` in the term's locale | Replaces a term; carries `expectedUpdatedAt`; answers it in full |
|
||||
| `DELETE /:termId` | `manage:glossary` in the term's locale | Deletes a term and, by cascade, its relations |
|
||||
|
||||
`relatedTerms` is written as an array of ids. `locale` is set on create and cannot be changed by `PUT`:
|
||||
moving a term to another locale would orphan its relations and its documentation path at once, and a
|
||||
term in another locale is a different term.
|
||||
|
||||
Every write fails with 404 when `features.glossary` is off for the site, the two lists answer empty, and
|
||||
a single term and a lookup answer 404, so that turning the switch off hides the glossary without
|
||||
deleting it.
|
||||
|
||||
### 5.2 Concurrent edits
|
||||
|
||||
`PUT` carries `expectedUpdatedAt`, the `updatedAt` the form was opened with. The model compares it inside
|
||||
the save's transaction (`UPDATE … WHERE id = $1 AND "updatedAt" = $2`), and when no row matches because
|
||||
the term has changed since, answers **409** with the term as it now stands. The form then says that
|
||||
somebody else changed it, shows who and when, and offers to reload it (losing the draft) or to keep the
|
||||
draft and save over the newer version, which re-sends with the new `updatedAt`.
|
||||
|
||||
A term deleted while it was open answers 404, and the form says so.
|
||||
|
||||
`updatedAt` is compared at millisecond precision, as written by the rest of the codebase.
|
||||
|
||||
### 5.3 Moving a page
|
||||
|
||||
`pages.relinkMovedPage` is what `PUT /pages/:id/move` calls when `updateLinks` is set. It also rewrites
|
||||
`documentationPath` on every term in the page's site and old locale that points at the old path, in the
|
||||
same call:
|
||||
|
||||
- **Same locale**: the path is replaced.
|
||||
- **Another locale**: the term's path is cleared, since a documentation page must be in the term's own
|
||||
locale. The move's result reports the terms affected, so the dialog can say so.
|
||||
|
||||
These are not edits to somebody else's work in the way relinking a page is, so they are not asked about
|
||||
per term: the person moving the page with `updateLinks` has asked for what pointed at it to follow.
|
||||
`updatedAt` is bumped on each term touched, so a form open on one of them gets the 409 above.
|
||||
|
||||
**Not covered**: a move without `updateLinks`, a deletion, and a **folder rename**, which moves the pages
|
||||
beneath it without relinking anything today. Folder renames are deliberately out of scope here; they
|
||||
will be handled, for links and documentation paths together, as a separate improvement. A term then
|
||||
points at a path with no page, and the term view says the page was not found rather than offering a
|
||||
dead link.
|
||||
|
||||
### 5.4 Site switch
|
||||
|
||||
`features.glossary`, on by default, in the site defaults in `models/sites.ts` and under **General →
|
||||
Features**. Off, the Library menu has no Glossary row and the API behaves as in
|
||||
[§5.1](#51-api). Nothing is deleted.
|
||||
|
||||
---
|
||||
|
||||
## 6. The overlay
|
||||
|
||||
### 6.1 Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Glossary [ en ▾ ] [×] │
|
||||
├──────────────────────┬───────────────────────────────────────────────┤
|
||||
│ [ + New Term ] │ │
|
||||
│ [ Filter… ] │ TERM │
|
||||
│ [Category ▾] │ Expansion │
|
||||
│ │ │
|
||||
│ A │ Definition… │
|
||||
│ API │ │
|
||||
│ Application Prog… │ Also: aliases │
|
||||
│ ▸ ACL │ Related: [chip] [chip] │
|
||||
│ B │ Read more → /docs/api │
|
||||
│ … │ References: … │
|
||||
│ │ Category: Networking │
|
||||
│ │ [Edit] [Delete] │
|
||||
└──────────────────────┴───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Header**: title, the locale picker (only where the site has more than one active locale), close.
|
||||
- **Sidebar**: New Term (for `manage:glossary`), a filter field matching names and aliases, an
|
||||
**A–Z / Category** switch, then the terms, each with its expansion as a second line.
|
||||
- **A–Z**, the default: under letter headings. Letters are the term's first character, upper-cased
|
||||
in the locale; everything that is not a letter goes under `#`.
|
||||
- **Category**: under one heading per category, sorted by name, A–Z within each, with the terms that
|
||||
have none last under "Uncategorized".
|
||||
|
||||
The switch is remembered per browser (`localStorage`, as a convenience that may come back empty). The
|
||||
whole list for the locale is fetched once when the overlay opens, which is comfortable to a few
|
||||
thousand terms; beyond that the filter would move to the server.
|
||||
- **Main panel**: one of three states, below.
|
||||
|
||||
Below 900px the two columns become a list → detail flow: the list fills the overlay, a term replaces it,
|
||||
and a back button returns. The same breakpoint the header's action buttons collapse at.
|
||||
|
||||
### 6.2 Viewing a term
|
||||
|
||||
Name, expansion, the rendered definition, aliases, related terms as chips (clicking one moves the panel to
|
||||
it and updates the URL), the documentation link (its label, or "Read more"; "page not found" where the
|
||||
path no longer resolves), references as a list of external links, and the category (clicking it switches the
|
||||
sidebar to Category and scrolls to that heading). Edit and Delete for `manage:glossary`; Delete confirms first.
|
||||
|
||||
### 6.3 The form
|
||||
|
||||
Shown by New Term and by Edit, in the main panel.
|
||||
|
||||
| Field | Control |
|
||||
| --- | --- |
|
||||
| Term | Text input, required |
|
||||
| Expansion | Text input |
|
||||
| Definition | Textarea with a Write / Preview toggle |
|
||||
| Aliases | Chip input |
|
||||
| Related terms | Autocomplete over the locale's terms, excluding this one |
|
||||
| Documentation page | `LinkPickerDialog` with `pagesOnly` (a new prop hiding the URL tab), locked to the term's locale; a clear button |
|
||||
| Documentation label | Text input, placeholder "Read more" |
|
||||
| References | Repeatable rows of URL + label, with add and remove |
|
||||
| Case sensitive | Toggle |
|
||||
| Auto-link | Toggle, shown on and **disabled**, with a "Coming soon" hint ([§9](#9-auto-linking-later)) |
|
||||
| Category | Select with suggestions from `GET /categories`; Enter on a new name uses it; shown as a removable chip |
|
||||
|
||||
**Renaming.** When the Term field of an existing term is changed, a checkbox appears under it: *Keep
|
||||
"‹old name›" as an alias*, **unchecked by default**. Checked, the form adds the old name to `aliases`
|
||||
before saving, so that `?glossary=` links to the old name keep resolving ([§7](#7-urls)). It is the
|
||||
form's doing rather than the API's: the server sees an ordinary save, and the uniqueness check runs
|
||||
against the term's new name and aliases as usual, which the old name, now freed by the rename, passes.
|
||||
|
||||
A name collision answers 409 from the server and is shown against the field that caused it, naming the
|
||||
term that holds the name. The sidebar's list is also checked as the user types, which catches most
|
||||
collisions before a save; the server's answer is the one that counts.
|
||||
|
||||
**Unsaved changes**: choosing another term, New Term, switching locale or closing the overlay with a
|
||||
changed form asks before discarding it.
|
||||
|
||||
### 6.4 The definition
|
||||
|
||||
**Basic markdown, stored as source and rendered in the browser at display time**, which is the bargain
|
||||
the built-in comments already make. `html: false` is the security boundary: nothing stored is ever HTML,
|
||||
so there is nothing to sanitize and nothing sanitized by older rules to serve back.
|
||||
|
||||
The comment renderer (`renderers/comment.js`) is the right feature set (paragraphs, emphasis, inline
|
||||
code, code blocks, lists, links; no headings, images or tables) with two things the glossary does not
|
||||
want: `@handle` mentions, and `rel="nofollow ugc"` on links that a holder of `manage:glossary`, not an
|
||||
anonymous commenter, wrote. So `comment.js` exports `createProseMarkdown({ rel })`, the configuration
|
||||
both use, and `renderers/glossary.js` builds its own instance from it with `rel="noopener"` and no
|
||||
mention pass.
|
||||
|
||||
---
|
||||
|
||||
## 7. URLs
|
||||
|
||||
**`?glossary=<name>` on any path the main layout draws** opens the overlay on that term, in the locale of
|
||||
the path. `<name>` is the term's name, URL-encoded, resolved with `GET /lookup`, which matches aliases
|
||||
too, so a term renamed with its old name kept as an alias keeps its old links working.
|
||||
|
||||
- Opening the overlay from the menu adds `?glossary` with no value (the list, nothing selected).
|
||||
- Selecting a term replaces the value; closing the overlay removes the parameter. Both use
|
||||
`router.replace`, so browsing the glossary does not fill the history with one entry per term.
|
||||
- A name that resolves to nothing opens the list with a notice, rather than an error page.
|
||||
- Switching locale in the overlay adds `&glossaryLocale=<code>` when it differs from the path's locale,
|
||||
and removes it when it does not.
|
||||
|
||||
A query parameter rather than a route of its own, so a shared link opens the term **over the page it was
|
||||
shared from**, which is usually the context it was being discussed in. The overlay is only mounted by
|
||||
`MainLayout`, so the admin area and the profile pages ignore the parameter.
|
||||
|
||||
---
|
||||
|
||||
## 8. Audit log
|
||||
|
||||
A new kind, `glossary`, with three actions: `createGlossaryTerm`, `updateGlossaryTerm`,
|
||||
`deleteGlossaryTerm`. Each records `siteId`, `locale`, `termId` and `term`; an update also records which
|
||||
fields changed, not their values, as configuration routes do. The terms rewritten by a page move are
|
||||
recorded in that move's own entry (`meta.glossaryRelinked: [termId…]`) rather than as entries of their
|
||||
own.
|
||||
|
||||
Strings: `admin.audit.kinds.glossary` and `admin.audit.actions.<action>`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Auto-linking (later)
|
||||
|
||||
Recognising terms in page text and linking them to their definitions. Not built in the first version,
|
||||
but the first version is shaped for it: the `autoLink` and `caseSensitive` columns, aliases, the list
|
||||
endpoint carrying exactly what a matcher needs, and the uniqueness rule that makes every match resolve to
|
||||
a single term.
|
||||
|
||||
**It happens in the reader's browser, when the page is drawn.** A page's render is produced once at save
|
||||
time; linking terms into it then would mean re-rendering every page whenever any term changes, and would
|
||||
freeze a link to whatever a word meant on the day the page was saved. Drawn at display time, a new term
|
||||
appears in every page at once and a deleted one disappears.
|
||||
|
||||
Sketch, to be settled when it is built:
|
||||
|
||||
- **The list**, `GET /?locale=` filtered to `autoLink`, fetched once per locale per session and
|
||||
revalidated with an ETag derived from the locale's newest `updatedAt` and term count.
|
||||
- **The matcher** is compiled from every name and alias, longest first, so `REST API` wins over `REST`.
|
||||
Case-insensitive terms compare with `toLocaleLowerCase(locale)`. Word boundaries come from
|
||||
`Intl.Segmenter(locale, { granularity: 'word' })`, which is what makes it work in languages written
|
||||
without spaces.
|
||||
- **Where it looks**: text in the article body only. Not in headings, links, `code`, `pre`, `kbd`, blocks,
|
||||
or the table of contents.
|
||||
- **How often**: the first occurrence of each term per page, so a paragraph about REST is not a wall of
|
||||
underlines.
|
||||
- **What a match looks like**: an underline, a hover card (expansion and the first lines of the
|
||||
definition), and a click that opens the overlay on the term. It has to be focusable and reachable by
|
||||
keyboard.
|
||||
- **Switches**: the term's `autoLink`, a site setting, and a page property to opt a page out.
|
||||
- **Crawlers** never see it. The app shell's prerendered body is the stored render, unchanged.
|
||||
|
||||
**Open design point: spans or highlights.** Wrapping matches in elements is the simplest way to get
|
||||
hover, focus and keyboard access, but changes the DOM of the article. Annotations deliberately insert
|
||||
nothing and use the CSS Custom Highlight API with click hit-testing (`PageAnnotationsLayer.vue`); the two
|
||||
features have to coexist on one article, and annotations find their passages again by text, which
|
||||
wrapped matches must not disturb.
|
||||
|
||||
---
|
||||
|
||||
## 10. Behaviour worth pinning down
|
||||
|
||||
- **Deleting a term** removes it from every related list (cascade) and breaks any `?glossary=` link to it,
|
||||
which then opens the list with a notice.
|
||||
- **Renaming a term** breaks `?glossary=` links to the old name, unless the editor ticks the option to
|
||||
keep it as an alias ([§6.3](#63-the-form)).
|
||||
- **A related term in the list but deleted before the save** is refused with 400, and the form reloads the
|
||||
list.
|
||||
- **A term's documentation page deleted** leaves the path in place, shown as "page not found", so that
|
||||
restoring the page restores the link.
|
||||
- **Turning `features.glossary` off** hides the glossary everywhere and keeps every term.
|
||||
- **Deleting a site** deletes its terms (cascade on `siteId`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Suggested order
|
||||
|
||||
1. Schema and migration; `read:glossary` and `manage:glossary` (model lists, `checkLocaleAccess`, the
|
||||
seeded default rule, the group editor, CLAUDE.md); the site switch.
|
||||
2. Model and API, including uniqueness, two-way relations, the `updatedAt` check and the audit entries.
|
||||
3. The overlay: list, view, form, and the Library menu row.
|
||||
4. URLs.
|
||||
5. `relinkMovedPage` rewriting documentation paths.
|
||||
6. Later: auto-linking ([§9](#9-auto-linking-later)), then the other non-goals as they are wanted.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open questions
|
||||
|
||||
None at the moment.
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M1.5 35.5L1.5 4.5 11.793 4.5 14.793 7.5 35.5 7.5 35.5 35.5z"/><path fill="#4788c7" d="M11.586,5l2.707,2.707L14.586,8H15h20v27H2V5H11.586 M12,4H1v32h35V7H15L12,4L12,4z"/><path fill="#fff" d="M4.5 7.5H35.5V25.5H4.5z"/><path fill="#4788c7" d="M35,8v17H5V8H35 M36,7H4v19h32V7L36,7z"/><g><path fill="#dff0fe" d="M1.599 35.5L5.417 14.5 16.151 14.5 19.151 12.5 39.41 12.5 35.577 35.5z"/><path fill="#4788c7" d="M38.82,13l-3.667,22H2.198l3.636-20H16h0.303l0.252-0.168L19.303,13H38.82 M40,12H19l-3,2H5L1,36 h35L40,12L40,12z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 639 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M26.5,8.5V6c0-0.857,1.35-3.5,3-3.5H35c1.93,0,3.5,1.57,3.5,3.5v2.5H26.5z"/><path fill="#4788c7" d="M35,3c1.654,0,3,1.346,3,3v2H27V6c0-0.672,1.211-3,2.5-3H35 M35,2h-5.5C27.465,2,26,4.96,26,6v3h13 V6C39,3.8,37.2,2,35,2L35,2z"/><path fill="#dff0fe" d="M18.792,37.5l-7.643-5H6.5v-7.246L8.225,24L6.5,22.746V7c0-2.481,2.019-4.5,4.5-4.5h23.5v0.036 c-1.694,0.243-3,1.704-3,3.464v5.717L28.528,13.5l2.972,1.783v3.408L29.882,19.5l1.618,0.809v6.446L29.892,28l1.608,1.245V34 c0,1.93-1.57,3.5-3.5,3.5H18.792z"/><path fill="#4788c7" d="M32.357,3C31.525,3.734,31,4.807,31,6v3v2.434l-2.014,1.209L27.556,13.5l1.429,0.857L31,15.566 v2.816l-0.447,0.224L28.764,19.5l1.789,0.894L31,20.618v5.891l-0.904,0.7L29.075,28l1.021,0.791L31,29.49V34c0,1.654-1.346,3-3,3 h-9.059l-7.393-4.837L11.298,32H11H7v-6.491l0.963-0.7L9.075,24l-1.112-0.809L7,22.491V9V7c0-2.206,1.794-4,4-4H32.357 M35,2H11 C8.25,2,6,4.25,6,7v2v14l1.375,1L6,25v8h5l7.643,5H28c2.2,0,4-1.8,4-4v-5l-1.292-1L32,27v-7l-1-0.5l1-0.5v-4l-2.5-1.5L32,12V9V6 c0-1.657,1.343-3,3-3C35,3,35,2.622,35,2L35,2z"/><path fill="#4788c7" d="M24.5 10h-12C12.224 10 12 9.776 12 9.5v0C12 9.224 12.224 9 12.5 9h12C24.776 9 25 9.224 25 9.5v0C25 9.776 24.776 10 24.5 10zM22.5 16h-10c-.276 0-.5-.224-.5-.5v0c0-.276.224-.5.5-.5h10c.276 0 .5.224.5.5v0C23 15.776 22.776 16 22.5 16zM24.5 22h-12c-.276 0-.5-.224-.5-.5l0 0c0-.276.224-.5.5-.5h12c.276 0 .5.224.5.5l0 0C25 21.776 24.776 22 24.5 22zM22.5 28h-10c-.276 0-.5-.224-.5-.5l0 0c0-.276.224-.5.5-.5h10c.276 0 .5.224.5.5l0 0C23 27.776 22.776 28 22.5 28z"/><g><path fill="#b6dcfe" d="M5,37.5c-1.93,0-3.5-1.57-3.5-3.5v-2.5h23V34c0,1.76,1.306,3.221,3,3.464V37.5H5z"/><path fill="#4788c7" d="M24,32v2c0,1.193,0.525,2.266,1.357,3H5c-1.654,0-3-1.346-3-3v-2H24 M25,31H1v3c0,2.2,1.8,4,4,4h23 v-1c-1.657,0-3-1.343-3-3V31L25,31z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M14.93,32.5c-0.478,0-0.927-0.186-1.265-0.524L2.024,20.336c-0.697-0.698-0.697-1.833,0-2.53L17.739,2.133c0.408-0.408,0.952-0.633,1.53-0.633H30c1.379,0,2.5,1.122,2.5,2.5v10.77c0,0.599-0.243,1.184-0.667,1.607L16.194,31.976C15.856,32.314,15.407,32.5,14.93,32.5z M28,4.5c-0.827,0-1.5,0.673-1.5,1.5s0.673,1.5,1.5,1.5s1.5-0.673,1.5-1.5S28.827,4.5,28,4.5z"/><path fill="#4788c7" d="M30,2c1.103,0,2,0.897,2,2v10.77c0,0.474-0.185,0.918-0.52,1.253l-15.639,15.6C15.597,31.866,15.274,32,14.929,32c-0.344,0-0.668-0.134-0.911-0.377l-11.64-11.64c-0.502-0.502-0.502-1.32-0.001-1.822L18.092,2.487C18.406,2.173,18.824,2,19.269,2H30 M28,8c1.103,0,2-0.897,2-2s-0.897-2-2-2s-2,0.897-2,2S26.897,8,28,8 M30,1H19.269c-0.706,0-1.383,0.28-1.883,0.779L1.671,17.453c-0.894,0.894-0.894,2.343,0,3.237l11.64,11.64c0.447,0.447,1.033,0.67,1.618,0.67c0.586,0,1.172-0.223,1.618-0.67l15.639-15.599C32.707,16.211,33,15.506,33,14.77V4C33,2.343,31.657,1,30,1L30,1z M28,7c-0.552,0-1-0.448-1-1s0.448-1,1-1s1,0.448,1,1S28.552,7,28,7L28,7z"/><path fill="#dff0fe" d="M20.93,38.5c-0.478,0-0.927-0.186-1.265-0.524L8.024,26.336c-0.697-0.698-0.697-1.833,0-2.53L23.739,8.133c0.408-0.408,0.952-0.633,1.53-0.633H36c1.379,0,2.5,1.122,2.5,2.5v10.77c0,0.599-0.243,1.184-0.667,1.607L22.194,37.976C21.856,38.314,21.407,38.5,20.93,38.5z M34,10.5c-0.827,0-1.5,0.673-1.5,1.5s0.673,1.5,1.5,1.5s1.5-0.673,1.5-1.5S34.827,10.5,34,10.5z"/><path fill="#4788c7" d="M36,8c1.103,0,2,0.897,2,2v10.77c0,0.474-0.185,0.918-0.52,1.253l-15.639,15.6C21.597,37.866,21.274,38,20.929,38c-0.344,0-0.668-0.134-0.911-0.377l-11.64-11.64c-0.502-0.502-0.502-1.32-0.001-1.822L24.092,8.487C24.406,8.173,24.824,8,25.269,8H36 M34,14c1.103,0,2-0.897,2-2s-0.897-2-2-2s-2,0.897-2,2S32.897,14,34,14 M36,7H25.269c-0.706,0-1.383,0.28-1.883,0.779L7.671,23.453c-0.894,0.894-0.894,2.343,0,3.237l11.64,11.64c0.447,0.447,1.033,0.67,1.618,0.67c0.586,0,1.172-0.223,1.618-0.67l15.639-15.599C38.707,22.211,39,21.506,39,20.77V10C39,8.343,37.657,7,36,7L36,7z M34,13c-0.552,0-1-0.448-1-1s0.448-1,1-1s1,0.448,1,1S34.552,13,34,13L34,13z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -5,7 +5,7 @@
|
||||
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
|
||||
removing an icon; `check-icons.mjs` fails the build if this drifts.
|
||||
|
||||
308 icons.
|
||||
311 icons.
|
||||
*/
|
||||
export const BUNDLED_ICONS = {
|
||||
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
|
||||
@@ -23,6 +23,7 @@ export const BUNDLED_ICONS = {
|
||||
"la:bell": {"body":"<path fill=\"currentColor\" d=\"M16 3a2 2 0 0 0-2 2c0 .086.02.168.031.25C10.574 6.133 8 9.273 8 13v9c0 .566-.434 1-1 1H6v2h7.188A3 3 0 0 0 13 26c0 1.645 1.355 3 3 3s3-1.355 3-3a3 3 0 0 0-.188-1H26v-2h-1c-.566 0-1-.434-1-1v-8.719c0-3.758-2.512-7.11-6.031-8.031c.011-.082.031-.164.031-.25a2 2 0 0 0-2-2m-.438 4c.145-.012.29 0 .438 0h.188C19.453 7.098 22 9.96 22 13.281V22c0 .352.074.684.188 1H9.813A3 3 0 0 0 10 22v-9a6.005 6.005 0 0 1 5.563-6zM16 25c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1\"/>","width":32,"height":32},
|
||||
"la:bolt": {"body":"<path fill=\"currentColor\" d=\"M16 6v9l4 1l-3.898 10H16v-8.031l-4-1L15.898 6zm2-2h-3.512l-.472 1.328l-3.903 10.973l-.734 2.074l2.137.535l2.484.621V28h3.469l.496-1.273l3.898-10l.825-2.118L18 13.438z\"/>","width":32,"height":32},
|
||||
"la:book": {"body":"<path fill=\"currentColor\" d=\"M9 4C7.355 4 6 5.355 6 7v18c0 1.645 1.355 3 3 3h17V4zm0 2h15v16H9a3 3 0 0 0-1 .188V7c0-.566.434-1 1-1m2 3v2h11V9zM9 24h15v2H9c-.566 0-1-.434-1-1s.434-1 1-1\"/>","width":32,"height":32},
|
||||
"la:book-open": {"body":"<path fill=\"currentColor\" d=\"M3 6v19h10c1.102 0 2 .898 2 2h2c0-1.102.898-2 2-2h10V6H19a4 4 0 0 0-3 1.36A4 4 0 0 0 13 6zm2 2h8c1.102 0 2 .898 2 2h2c0-1.102.898-2 2-2h8v15h-8a4 4 0 0 0-3 1.36A4 4 0 0 0 13 23H5zm10 4v2h2v-2zm0 4v2h2v-2zm0 4v2h2v-2z\"/>","width":32,"height":32},
|
||||
"la:broadcast-tower": {"body":"<path fill=\"currentColor\" d=\"M7.188 4.188c-4.297 4.183-4.282 11.125 0 15.406l1.406-1.407c-3.52-3.519-3.504-9.148 0-12.562zm17.625.093L23.405 5.72c3.524 3.523 3.524 9.039 0 12.562l1.407 1.438a10.897 10.897 0 0 0 0-15.438zM9.905 7.188c-2.586 2.585-2.586 6.82 0 9.406l1.406-1.407a4.68 4.68 0 0 1 0-6.593zm12.188.093L20.687 8.72a4.64 4.64 0 0 1 0 6.562l1.407 1.438c2.586-2.586 2.586-6.852 0-9.438zM16 10a2 2 0 0 0-2 2c0 .625.3 1.164.75 1.531L10.312 26H9v2h4v-2h-.594L16 15.969L19.594 26H19v2h4v-2h-1.313L17.25 13.531c.45-.367.75-.906.75-1.531a2 2 0 0 0-2-2\"/>","width":32,"height":32},
|
||||
"la:broom": {"body":"<path fill=\"currentColor\" d=\"m28.281 2.281l-10 10L17 11v-.031l-.031-.031c-.64-.57-1.477-.844-2.282-.844s-1.582.3-2.187.906l-.156.125l-.5.5l-.344.281L2.375 19l-.875.719L12.281 30.5l.719-.875l7.063-9.063l.03.032l1-1h.032l.031-.032c1.14-1.285 1.149-3.257-.062-4.468l-1.375-1.375l10-10zm-13.593 9.813a1.4 1.4 0 0 1 .906.312c.011.008.02.024.031.031l4.063 4.063c.375.375.41 1.172 0 1.688c-.016.019-.016.042-.032.062l-.312.281l-5.782-5.781l.344-.344c.192-.191.473-.304.781-.312zM12.03 14.03l5.938 5.938l-5.875 7.5l-1.438-1.438l2.156-2.25l-1.437-1.375l-2.125 2.219l-1.313-1.313l3.875-3.906L10.406 18L6.5 21.875l-1.969-1.969z\"/>","width":32,"height":32},
|
||||
"la:calendar": {"body":"<path fill=\"currentColor\" d=\"M9 4v1H5v22h22V5h-4V4h-2v1H11V4zM7 7h2v1h2V7h10v1h2V7h2v2H7zm0 4h18v14H7zm6 2v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zM9 17v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zM9 21v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2z\"/>","width":32,"height":32},
|
||||
@@ -155,6 +156,7 @@ export const BUNDLED_ICONS = {
|
||||
"la:stream": {"body":"<path fill=\"currentColor\" d=\"M5 5v6h19V5zm2 2h15v2H7zm2 6v6h19v-6zm2 2h15v2H11zm-6 6v6h19v-6zm2 2h15v2H7z\"/>","width":32,"height":32},
|
||||
"la:sun": {"body":"<path fill=\"currentColor\" d=\"M15 3v5h2V3zM7.5 6.094L6.094 7.5l3.531 3.563l1.438-1.438zm17 0l-3.563 3.531l1.438 1.438L25.906 7.5zM16 9c-3.855 0-7 3.145-7 7s3.145 7 7 7s7-3.145 7-7s-3.145-7-7-7m0 2c2.773 0 5 2.227 5 5s-2.227 5-5 5s-5-2.227-5-5s2.227-5 5-5M3 15v2h5v-2zm21 0v2h5v-2zM9.625 20.938L6.094 24.5L7.5 25.906l3.563-3.531zm12.75 0l-1.438 1.437l3.563 3.531l1.406-1.406zM15 24v5h2v-5z\"/>","width":32,"height":32},
|
||||
"la:sync-alt": {"body":"<path fill=\"currentColor\" d=\"M16 4c-5.113 0-9.383 3.16-11.125 7.625l1.844.75C8.176 8.641 11.71 6 16 6c3.242 0 6.133 1.59 7.938 4H20v2h7V5h-2v3.094A11.94 11.94 0 0 0 16 4m9.281 15.625C23.824 23.359 20.29 26 16 26c-3.277 0-6.156-1.613-7.969-4H12v-2H5v7h2v-3.094C9.188 26.386 12.395 28 16 28c5.113 0 9.383-3.16 11.125-7.625z\"/>","width":32,"height":32},
|
||||
"la:tag": {"body":"<path fill=\"currentColor\" d=\"m16 5l-.313.281L4.281 16.813l-.687.687l.687.719l9.5 9.5l.719.687l.688-.687l11.53-11.407L27 16V5zm.844 2H25v8.156L14.5 25.594L6.406 17.5zM22 9c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32},
|
||||
"la:tags": {"body":"<path fill=\"currentColor\" d=\"m14.594 4l-.313.281l-11 11l-.687.719l.687.719l9 9l.719.687l.719-.687l11-11l.281-.313V4zm.844 2H23v7.563l-10 10L5.437 16zM26 7v2h1v8.156l-9.5 9.438l-1.25-1.25l-1.406 1.406l1.937 1.969l.719.687l.688-.687l10.53-10.407L29 18V7zm-6 1c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32},
|
||||
"la:tasks": {"body":"<path fill=\"currentColor\" d=\"M10.293 5.293L7 8.586L5.707 7.293L4.293 8.707L7 11.414l4.707-4.707zM14 7v2h14V7zm0 8v2h14v-2zm0 8v2h14v-2z\"/>","width":32,"height":32},
|
||||
"la:th-list": {"body":"<path fill=\"currentColor\" d=\"M4 6v20h24V6zm2 2h5v4H6zm7 0h13v4H13zm-7 6h5v4H6zm7 0h13v4H13zm-7 6h5v4H6zm7 0h13v4H13z\"/>","width":32,"height":32},
|
||||
@@ -282,6 +284,7 @@ export const BUNDLED_ICONS = {
|
||||
"mdi:logout": {"body":"<path fill=\"currentColor\" d=\"m17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5M4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4z\"/>","width":24,"height":24},
|
||||
"mdi:menu-down": {"body":"<path fill=\"currentColor\" d=\"m7 10l5 5l5-5z\"/>","width":24,"height":24},
|
||||
"mdi:message-alert": {"body":"<path fill=\"currentColor\" d=\"M13 11h-2V5h2m0 10h-2v-2h2m7-11H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2\"/>","width":24,"height":24},
|
||||
"mdi:museum-outline": {"body":"<path fill=\"currentColor\" d=\"M6.5 10h-2v7h2zm6 0h-2v7h2zm8.5 9H2v2h19zm-2.5-9h-2v7h2zm-7-6.74L16.71 6H6.29zm0-2.26L2 6v2h19V6z\"/>","width":24,"height":24},
|
||||
"mdi:palette": {"body":"<path fill=\"currentColor\" d=\"M17.5 12a1.5 1.5 0 0 1-1.5-1.5A1.5 1.5 0 0 1 17.5 9a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-3-4A1.5 1.5 0 0 1 13 6.5A1.5 1.5 0 0 1 14.5 5A1.5 1.5 0 0 1 16 6.5A1.5 1.5 0 0 1 14.5 8m-5 0A1.5 1.5 0 0 1 8 6.5A1.5 1.5 0 0 1 9.5 5A1.5 1.5 0 0 1 11 6.5A1.5 1.5 0 0 1 9.5 8m-3 4A1.5 1.5 0 0 1 5 10.5A1.5 1.5 0 0 1 6.5 9A1.5 1.5 0 0 1 8 10.5A1.5 1.5 0 0 1 6.5 12M12 3a9 9 0 0 0-9 9a9 9 0 0 0 9 9a1.5 1.5 0 0 0 1.5-1.5c0-.39-.15-.74-.39-1c-.23-.27-.38-.62-.38-1a1.5 1.5 0 0 1 1.5-1.5H16a5 5 0 0 0 5-5c0-4.42-4.03-8-9-8\"/>","width":24,"height":24},
|
||||
"mdi:percent-outline": {"body":"<path fill=\"currentColor\" d=\"m18.5 3.5l2 2l-15 15l-2-2zM7 4c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3m10 10c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3M7 6c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m10 10c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":24,"height":24},
|
||||
"mdi:play": {"body":"<path fill=\"currentColor\" d=\"M8 5.14v14l11-7z\"/>","width":24,"height":24},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -235,90 +235,81 @@
|
||||
<w-card class="shadow-1 pb-2" v-else>
|
||||
<w-card-section>
|
||||
<div class="admin-groups-rule" v-for="rule of state.group.rules" :key="rule.id">
|
||||
<div class="admin-groups-rule-icon" :class="getRuleModeColor(rule.mode)">
|
||||
<w-icon
|
||||
:name="getRuleModeIcon(rule.mode)"
|
||||
color="white"
|
||||
@click="cycleRuleMode(rule)" />
|
||||
</div>
|
||||
<!-- -> The timeline down the left, which the mode button's disc sits at the top of -->
|
||||
<div class="admin-groups-rule-icon" :class="getRuleModeColor(rule.mode)" />
|
||||
<div class="admin-groups-rule-name">
|
||||
<div class="admin-groups-rule-name-text">
|
||||
<strong :class="getRuleModeColor(rule.mode)">{{
|
||||
getRuleModeName(rule.mode)
|
||||
}}</strong>
|
||||
</div>
|
||||
<!--
|
||||
The disc and the mode name are one button opening the choice of mode
|
||||
(`RuleModeMenu`), so the whole of it is one target with one hover. It reaches back
|
||||
over the timeline's column to put the disc where it has always been. Disabled for a
|
||||
reader who may look at a rule but not change it.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
class="admin-groups-rule-mode w-unstyled"
|
||||
:class="getRuleModeColor(rule.mode)"
|
||||
:disabled="!canManage">
|
||||
<span class="admin-groups-rule-mode-disc">
|
||||
<w-icon :name="getRuleModeIcon(rule.mode)" />
|
||||
</span>
|
||||
<strong>{{ getRuleModeName(rule.mode) }}</strong>
|
||||
<w-icon v-if="canManage" name="la:angle-down" size="10px" />
|
||||
<rule-mode-menu :mode="rule.mode" @select="(mode) => setRuleMode(rule, mode)" />
|
||||
</button>
|
||||
<w-separator class="ml-2 mr-1" vertical />
|
||||
<input
|
||||
type="text"
|
||||
v-model="rule.name"
|
||||
placeholder="Rule Name"
|
||||
:disabled="!canManage" />
|
||||
<w-btn
|
||||
v-if="canManage"
|
||||
class="admin-groups-rule-delete"
|
||||
flat
|
||||
round
|
||||
dense
|
||||
size="sm"
|
||||
icon="la:trash"
|
||||
color="negative"
|
||||
:aria-label="t(`admin.groups.ruleDelete`)"
|
||||
@click="deleteRule(rule.id)">
|
||||
<w-tooltip>{{ t('admin.groups.ruleDelete') }}</w-tooltip>
|
||||
</w-btn>
|
||||
</div>
|
||||
<w-card class="admin-groups-rule-card mt-4" flat>
|
||||
<w-card-section
|
||||
class="admin-groups-rule-card-permissions"
|
||||
:class="getRuleModeClass(rule.mode)">
|
||||
<w-select
|
||||
class="mt-1"
|
||||
standout
|
||||
v-model="rule.roles"
|
||||
emit-value
|
||||
map-options
|
||||
dense
|
||||
:aria-label="t(`admin.groups.ruleSites`)"
|
||||
:options="ruleOptions"
|
||||
:disable="!canManage"
|
||||
placeholder="Select permissions..."
|
||||
option-value="permission"
|
||||
option-label="title"
|
||||
options-dense
|
||||
multiple
|
||||
use-chips
|
||||
stack-label>
|
||||
<template #selected-item="scope">
|
||||
<w-chip
|
||||
square
|
||||
dense
|
||||
:tabindex="scope.tabindex"
|
||||
:color="getRuleModeBgColor(rule.mode)"
|
||||
text-color="white">
|
||||
<span class="text-caption">{{ scope.opt.title }}</span>
|
||||
</w-chip>
|
||||
</template>
|
||||
<template #option="{ itemProps, itemEvents, opt, selected, toggleOption }">
|
||||
<w-item v-bind="itemProps" v-on="itemEvents">
|
||||
<w-item-section side>
|
||||
<w-toggle
|
||||
:model-value="selected"
|
||||
@update:model-value="toggleOption(opt)"
|
||||
color="primary"
|
||||
checked-icon="la:check"
|
||||
unchecked-icon="la:times"
|
||||
:aria-label="opt.label" />
|
||||
</w-item-section>
|
||||
<!-- q-item-section(side, style='flex-basis: 150px;') -->
|
||||
<!-- q-chip.text-caption( -->
|
||||
<!-- square -->
|
||||
<!-- color='teal' -->
|
||||
<!-- text-color='white' -->
|
||||
<!-- dense -->
|
||||
<!-- ) {{opt.permission}} -->
|
||||
<w-item-section>
|
||||
<w-item-label>{{ opt.title }}</w-item-label>
|
||||
<w-item-label caption>{{ opt.hint }}</w-item-label>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</template>
|
||||
</w-select>
|
||||
<!--
|
||||
The permissions themselves, in the bar rather than behind a dropdown: a rule is
|
||||
read far more often than it is changed, and what it grants is the first thing a
|
||||
reader wants. Picking them is a dialog of its own (`RulePermissionsDialog`), which
|
||||
has room to lay them out by what the rule's pattern means to each.
|
||||
-->
|
||||
<div class="admin-groups-rule-roles">
|
||||
<w-chip
|
||||
v-for="perm of ruleRoleChips(rule)"
|
||||
:key="perm.permission"
|
||||
square
|
||||
dense
|
||||
:color="getRuleModeBgColor(rule.mode)"
|
||||
text-color="white">
|
||||
<span class="text-caption">{{ perm.title }}</span>
|
||||
</w-chip>
|
||||
<span v-if="rule.roles.length < 1" class="text-caption text-grey-6">
|
||||
{{ t('admin.groups.rulePermissionsNone') }}
|
||||
</span>
|
||||
</div>
|
||||
<w-btn
|
||||
class="acrylic-btn ml-4"
|
||||
flat
|
||||
icon="la:trash"
|
||||
color="negative"
|
||||
padding="sm sm"
|
||||
size="md"
|
||||
v-if="canManage"
|
||||
@click="deleteRule(rule.id)" />
|
||||
class="admin-groups-rule-select acrylic-btn ml-4 shrink-0"
|
||||
flat
|
||||
no-caps
|
||||
:color="getRuleModeBgColor(rule.mode)"
|
||||
icon="la:list-alt"
|
||||
padding="xs md"
|
||||
:label="t(`admin.groups.rulePermissionsSelect`)"
|
||||
@click="selectRulePermissions(rule)" />
|
||||
</w-card-section>
|
||||
<w-card-section horizontal>
|
||||
<w-card-section class="admin-groups-rule-card-filters">
|
||||
@@ -336,28 +327,11 @@
|
||||
option-value="id"
|
||||
option-label="title"
|
||||
multiple
|
||||
behavior="dialog"
|
||||
:display-value="
|
||||
t(`admin.groups.selectedSites`, rule.sites.length, {
|
||||
count: rule.sites.length
|
||||
})
|
||||
">
|
||||
<template #option="{ itemProps, itemEvents, opt, selected, toggleOption }">
|
||||
<w-item v-bind="itemProps" v-on="itemEvents">
|
||||
<w-item-section>
|
||||
<w-item-label>{{ opt.title }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-toggle
|
||||
:model-value="selected"
|
||||
@update:model-value="toggleOption(opt)"
|
||||
color="primary"
|
||||
checked-icon="la:check"
|
||||
unchecked-icon="la:times"
|
||||
:aria-label="opt.label" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</template>
|
||||
</w-select>
|
||||
<w-select
|
||||
class="mt-2"
|
||||
@@ -372,7 +346,6 @@
|
||||
option-value="code"
|
||||
option-label="name"
|
||||
multiple
|
||||
behavior="dialog"
|
||||
:display-value="
|
||||
t(
|
||||
`admin.groups.selectedLocales`,
|
||||
@@ -385,22 +358,6 @@
|
||||
rule.locales.length
|
||||
)
|
||||
">
|
||||
<template #option="{ itemProps, opt, selected, toggleOption }">
|
||||
<w-item v-bind="itemProps">
|
||||
<w-item-section>
|
||||
<w-item-label>{{ opt.name }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-toggle
|
||||
:model-value="selected"
|
||||
@update:model-value="toggleOption(opt)"
|
||||
color="primary"
|
||||
checked-icon="la:check"
|
||||
unchecked-icon="la:times"
|
||||
:aria-label="opt.name" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</template>
|
||||
</w-select>
|
||||
</w-card-section>
|
||||
<w-card-section class="admin-groups-rule-card-pattern">
|
||||
@@ -794,6 +751,8 @@ import { useUserStore } from '@/stores/user'
|
||||
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { fileOpen, fileSave } from 'browser-fs-access'
|
||||
import RuleModeMenu from '@/components/RuleModeMenu.vue'
|
||||
import RulePermissionsDialog from '@/components/RulePermissionsDialog.vue'
|
||||
import UserSearchDialog from '@/components/UserSearchDialog.vue'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
|
||||
@@ -1028,7 +987,8 @@ const GUEST_ROLES = [
|
||||
'read:history',
|
||||
'read:assets',
|
||||
'read:comments',
|
||||
'write:comments'
|
||||
'write:comments',
|
||||
'read:glossary'
|
||||
]
|
||||
|
||||
const rules = [
|
||||
@@ -1167,6 +1127,32 @@ const rules = [
|
||||
warning: false,
|
||||
restrictedForSystem: true,
|
||||
disabled: false
|
||||
},
|
||||
/*
|
||||
The two rule permissions that ignore the path: a glossary belongs to a site and a locale, so every
|
||||
rule naming one that is scoped to them applies, and mode alone decides -- see `resolveLocaleRule`
|
||||
in `helpers/pageRules.ts`. The hints say so, because a DENY written for one folder closing the
|
||||
whole glossary is not what anybody would guess. `scope: 'locale'` is what puts them in the
|
||||
picker's second column (`RulePermissionsDialog`), whose heading carries that warning for both,
|
||||
and has to match `LOCALE_PERMISSIONS` in `models/groups.ts`.
|
||||
*/
|
||||
{
|
||||
permission: 'read:glossary',
|
||||
scope: 'locale',
|
||||
title: 'Read Glossary',
|
||||
hint: "Can view the site's glossary.",
|
||||
warning: false,
|
||||
restrictedForSystem: false,
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
permission: 'manage:glossary',
|
||||
scope: 'locale',
|
||||
title: 'Manage Glossary',
|
||||
hint: 'Can create, edit and delete glossary terms, and view them.',
|
||||
warning: false,
|
||||
restrictedForSystem: true,
|
||||
disabled: false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1299,16 +1285,6 @@ function getRuleModeIcon(mode) {
|
||||
)
|
||||
}
|
||||
|
||||
function getNextRuleMode(mode) {
|
||||
return (
|
||||
{
|
||||
DENY: 'FORCEALLOW',
|
||||
ALLOW: 'DENY',
|
||||
FORCEALLOW: 'ALLOW'
|
||||
}[mode] || 'ALLOW'
|
||||
)
|
||||
}
|
||||
|
||||
function getRuleModeName(mode) {
|
||||
switch (mode) {
|
||||
case 'ALLOW':
|
||||
@@ -1435,15 +1411,39 @@ function newRule() {
|
||||
}
|
||||
|
||||
/*
|
||||
Cycle a rule between ALLOW, DENY and FORCEALLOW. Guarded here rather than in the template, because
|
||||
the control is an icon with a click handler rather than a form control there is a `disable` to set
|
||||
-- a reader holding `read:groups` may look at a rule, not re-point it.
|
||||
Set what a rule does. Guarded here as well as by the disabled triggers, since the menu is the only
|
||||
thing between a reader holding `read:groups` and re-pointing a rule.
|
||||
*/
|
||||
function cycleRuleMode(rule) {
|
||||
function setRuleMode(rule, mode) {
|
||||
if (!canManage.value) {
|
||||
return
|
||||
}
|
||||
rule.mode = getNextRuleMode(rule.mode)
|
||||
rule.mode = mode
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule's permissions as the bar draws them: in the order the picker offers them, with anything the
|
||||
* rule names that is not on offer (an imported rule may) shown under its raw name at the end.
|
||||
*/
|
||||
function ruleRoleChips(rule) {
|
||||
const roles = new Set(rule.roles)
|
||||
const known = rules.filter((opt) => roles.has(opt.permission))
|
||||
const unknown = rule.roles
|
||||
.filter((role) => !rules.some((opt) => opt.permission === role))
|
||||
.map((role) => ({ permission: role, title: role }))
|
||||
return [...known, ...unknown]
|
||||
}
|
||||
|
||||
function selectRulePermissions(rule) {
|
||||
dialog({
|
||||
component: RulePermissionsDialog,
|
||||
componentProps: {
|
||||
options: ruleOptions.value,
|
||||
selected: rule.roles
|
||||
}
|
||||
}).onOk((roles) => {
|
||||
rule.roles = roles
|
||||
})
|
||||
}
|
||||
|
||||
function deleteRule(id) {
|
||||
@@ -1628,22 +1628,14 @@ onMounted(() => {
|
||||
position: relative;
|
||||
padding: 10px 0 24px 40px;
|
||||
|
||||
/* -> Drawing only, under the mode button's disc: it must never take the click meant for it */
|
||||
&-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 31px;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
border-radius: 100%;
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
background-color: currentColor;
|
||||
top: 4px;
|
||||
}
|
||||
pointer-events: none;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
@@ -1656,25 +1648,65 @@ onMounted(() => {
|
||||
background-color: currentColor;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Sized and placed to the disc `::before` draws, with the glyph inset by the padding: an inline
|
||||
<svg> scales its viewBox to whatever box it is given, so the old `width: 100%; height: 38px`
|
||||
-- metrics for the icon FONT this replaced, where `font-size` did the sizing -- stretched the
|
||||
mark across the whole circle.
|
||||
/*
|
||||
The mode: disc, name and caret as one button, in the rule's colour (`currentColor` throughout, from
|
||||
the `text-*` class the template sets).
|
||||
|
||||
The box stays the full 31px even though the glyph is 15px, so the click target is the disc a
|
||||
reader is aiming at rather than the mark inside it.
|
||||
*/
|
||||
.w-icon {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
It sits in the title row but has to put its disc where the timeline starts, at the rule's left
|
||||
edge and 4px down: hence the negative left margin over the row's 40px of padding, and the negative
|
||||
vertical margins that let a 31px disc ride a 12px row without making it taller. The 9px gap is what
|
||||
leaves the name where it always was, 40px in.
|
||||
*/
|
||||
&-mode {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
height: 31px;
|
||||
margin: -9.5px 0 -9.5px -40px;
|
||||
padding-right: 10px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible {
|
||||
background-color: color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&-disc {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 100%;
|
||||
background-color: currentColor;
|
||||
|
||||
.w-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
> .w-icon {
|
||||
margin-left: -5px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1691,11 +1723,6 @@ onMounted(() => {
|
||||
align-items: baseline;
|
||||
padding-top: 4px;
|
||||
|
||||
&-text {
|
||||
flex: 0 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
input {
|
||||
font-weight: 700;
|
||||
color: $grey-6;
|
||||
@@ -1722,6 +1749,23 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* -> At the end of the title row, level with the mode name and the rule's title */
|
||||
&-delete {
|
||||
align-self: center;
|
||||
margin: -6px 0 -6px 0.5rem;
|
||||
}
|
||||
|
||||
/* -> The chips wrap onto as many lines as the rule needs; the picker button keeps its place */
|
||||
&-roles {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
&-card {
|
||||
background-color: $grey-2 !important;
|
||||
|
||||
@@ -1735,10 +1779,6 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.w-select {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
&.is-allow {
|
||||
background-color: rgba($positive, 0.1);
|
||||
border-bottom: 1px solid rgba($positive, 0.3);
|
||||
@@ -1751,6 +1791,25 @@ onMounted(() => {
|
||||
background-color: rgba($blue, 0.1);
|
||||
border-bottom: 1px solid rgba($blue, 0.3);
|
||||
}
|
||||
|
||||
/*
|
||||
-> The mode's colour on its own pale bar is too light to read as text, in light mode: each one
|
||||
darkened by the same amount rather than swapped for a deeper Material shade, which would be
|
||||
another hue. `!important` because `WBtn` writes its colour as an inline style, which
|
||||
nothing else outranks.
|
||||
*/
|
||||
@at-root .body--light & .admin-groups-rule-select {
|
||||
color: color-mix(in srgb, var(--rule-select-color) 65%, black) !important;
|
||||
}
|
||||
@at-root .body--light &.is-allow {
|
||||
--rule-select-color: var(--color-positive);
|
||||
}
|
||||
@at-root .body--light &.is-deny {
|
||||
--rule-select-color: var(--color-negative);
|
||||
}
|
||||
@at-root .body--light &.is-forceallow {
|
||||
--rule-select-color: var(--color-blue);
|
||||
}
|
||||
}
|
||||
|
||||
&-filters {
|
||||
|
||||
@@ -42,9 +42,9 @@
|
||||
choice, from the same component, as the header button on a wide screen. It anchors to this row
|
||||
because `WMenu` takes the nearest `.w-item` as its trigger.
|
||||
|
||||
`hide-asset-btn` because this menu has a File Manager row of its own directly below, and
|
||||
`@new-page` closes this menu once the submenu has acted: the editor opens behind it otherwise,
|
||||
with the menu still floating over it.
|
||||
`hide-asset-btn` because the Library submenu directly below has a File Manager row of its own,
|
||||
and `@new-page` closes this menu once the submenu has acted: the editor opens behind it
|
||||
otherwise, with the menu still floating over it.
|
||||
-->
|
||||
<w-item v-if="userStore.can(`write:pages`)" clickable>
|
||||
<w-item-section avatar>
|
||||
@@ -56,17 +56,17 @@
|
||||
</w-item-section>
|
||||
<page-new-menu hide-asset-btn @new-page="close" />
|
||||
</w-item>
|
||||
<!--
|
||||
-> Whoever may put a file somewhere: `write:assets` outright, or `write:pages` for an author
|
||||
whose rules cover the pages but not the assets beside them, since the editor sends them
|
||||
here to insert an image. Every folder and every file is checked again by the endpoints
|
||||
behind the manager, which answer per path, so this decides only whether the door is shown.
|
||||
-->
|
||||
<w-item v-if="canUseFileManager" clickable @click="openFileManager">
|
||||
<!-- -> A submenu for the same reason as New Page above: the wide header's folder button is a menu
|
||||
too, and this opens the same one. Everybody's, since only its File Manager row is gated -->
|
||||
<w-item clickable>
|
||||
<w-item-section avatar>
|
||||
<w-icon name="la:folder-open" class="text-positive" />
|
||||
<w-icon name="mdi:museum-outline" class="text-positive" />
|
||||
</w-item-section>
|
||||
<w-item-section>File Manager</w-item-section>
|
||||
<w-item-section>{{ t('common.header.library') }}</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-icon name="la:angle-right" />
|
||||
</w-item-section>
|
||||
<header-library-menu @navigate="close" />
|
||||
</w-item>
|
||||
<w-item v-if="userStore.authenticated" clickable to="/_inbox" @click="close">
|
||||
<w-item-section avatar>
|
||||
@@ -83,10 +83,7 @@
|
||||
</w-item-section>
|
||||
<w-item-section>{{ t('common.header.admin') }}</w-item-section>
|
||||
</w-item>
|
||||
<!-- -> Only once there is something above it to divide from the account rows below: a guest whose
|
||||
rules grant nothing but reading has none of the four, and the menu opened on a rule with
|
||||
blank space over it and Login alone underneath -->
|
||||
<w-separator v-if="hasActionRows" class="my-2" />
|
||||
<w-separator class="my-2" />
|
||||
<!--
|
||||
The account rows, flattened into this list rather than opened as a second submenu: they are two
|
||||
plain actions, and the panel they live in on a wide screen is 300px of card -- wider than this
|
||||
@@ -120,18 +117,18 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
import HeaderLibraryMenu from '@/components/HeaderLibraryMenu.vue'
|
||||
import PageNewMenu from '@/components/PageNewMenu.vue'
|
||||
|
||||
/**
|
||||
* The phone header's overflow menu: one button standing in for every action the bar shows as its own
|
||||
* icon on a wide screen -- New Page, File Manager, Inbox, Administration, and the account.
|
||||
* icon on a wide screen -- New Page, Library, Inbox, Administration, and the account.
|
||||
*
|
||||
* Rendered only below 900px; see `HeaderNav`. The rows repeat the permission tests the buttons they
|
||||
* replace make, rather than being handed a list, so the two cannot drift apart.
|
||||
@@ -139,7 +136,6 @@ import PageNewMenu from '@/components/PageNewMenu.vue'
|
||||
|
||||
// STORES
|
||||
|
||||
const siteStore = useSiteStore()
|
||||
const userStore = useUserStore()
|
||||
const notificationsStore = useNotificationsStore()
|
||||
|
||||
@@ -151,27 +147,6 @@ const { t } = useI18n()
|
||||
|
||||
const menu = ref(null)
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const canUseFileManager = computed(
|
||||
() => userStore.can('write:assets') || userStore.can('write:pages')
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether any row is shown above the account group, which is what decides the rule between the two.
|
||||
*
|
||||
* Restates the test each of those four rows makes rather than a shorter equivalent — `canUseFileManager`
|
||||
* already implies the New Page row's permission today, but a row added or a test changed up there would
|
||||
* otherwise leave this behind, and the failure is silent.
|
||||
*/
|
||||
const hasActionRows = computed(
|
||||
() =>
|
||||
userStore.can('write:pages') ||
|
||||
canUseFileManager.value ||
|
||||
userStore.authenticated ||
|
||||
userStore.can('access:admin')
|
||||
)
|
||||
|
||||
// METHODS
|
||||
|
||||
/**
|
||||
@@ -185,15 +160,6 @@ function close() {
|
||||
menu.value?.hide()
|
||||
}
|
||||
|
||||
/*
|
||||
Closed before the manager opens: it is a full-screen overlay, and a menu teleported to the body
|
||||
outranks it -- so the menu would be left floating over the panel it had just opened.
|
||||
*/
|
||||
function openFileManager() {
|
||||
close()
|
||||
siteStore.openFileManager()
|
||||
}
|
||||
|
||||
function logout() {
|
||||
close()
|
||||
userStore.logout()
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<w-menu class="translucent-menu" auto-close anchor="bottom right" self="top right">
|
||||
<w-list padding>
|
||||
<!--
|
||||
-> Whoever may put a file somewhere: `write:assets` outright, or `write:pages` for an author
|
||||
whose rules cover the pages but not the assets beside them, since the editor sends them here
|
||||
to insert an image. Every folder and every file is checked again by the endpoints behind
|
||||
the manager, which answer per path, so this decides only whether the door is shown.
|
||||
-->
|
||||
<w-item v-if="canUseFileManager" clickable @click="openFileManager">
|
||||
<blueprint-icon icon="live-folder" />
|
||||
<w-item-section class="pr-2">{{ t('fileman.title') }}</w-item-section>
|
||||
</w-item>
|
||||
<!--
|
||||
-> Where the site has a glossary and this session may read it in at least one locale --
|
||||
`read:glossary` ignores the path, so the answer does not change from page to page and is
|
||||
asked once (`stores/glossary.js`). Experimental for now: this row is the glossary's only
|
||||
way in, so the flag hides the feature as a whole.
|
||||
-->
|
||||
<w-item
|
||||
v-if="flagsStore.experimental && glossaryStore.isAvailable"
|
||||
clickable
|
||||
@click="openGlossary">
|
||||
<blueprint-icon icon="parchment" />
|
||||
<w-item-section class="pr-2">{{ t('common.header.glossary') }}</w-item-section>
|
||||
</w-item>
|
||||
<w-item clickable to="/_tags" @click="emit('navigate')">
|
||||
<blueprint-icon icon="tags" />
|
||||
<w-item-section class="pr-2">{{ t('tags.title') }}</w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
</w-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFlagsStore } from '@/stores/flags'
|
||||
import { useGlossaryStore } from '@/stores/glossary'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
/**
|
||||
* The header's Library menu: the File Manager, the Glossary and the Tags page.
|
||||
*
|
||||
* Its own component, like `PageNewMenu`, because it is opened from two places -- the folder button on
|
||||
* a wide header, and a submenu of the overflow menu below 900px. `navigate` is emitted as a row acts,
|
||||
* so that a menu holding this one as a submenu can close itself too.
|
||||
*/
|
||||
|
||||
// EMITS
|
||||
|
||||
const emit = defineEmits(['navigate'])
|
||||
|
||||
// STORES
|
||||
|
||||
const flagsStore = useFlagsStore()
|
||||
const glossaryStore = useGlossaryStore()
|
||||
const siteStore = useSiteStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const canUseFileManager = computed(
|
||||
() => userStore.can('write:assets') || userStore.can('write:pages')
|
||||
)
|
||||
|
||||
// WATCHERS
|
||||
|
||||
// -> Asked again whenever the answer could have changed: a login, a logout, another site -- and not
|
||||
// at all while the glossary is behind the experimental flag and nothing would show it
|
||||
watch(
|
||||
() => [flagsStore.experimental, siteStore.id, userStore.authenticated, userStore.id],
|
||||
() => {
|
||||
if (flagsStore.experimental) {
|
||||
glossaryStore.ensureAccess()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// METHODS
|
||||
|
||||
/*
|
||||
`navigate` first: the manager is a full-screen overlay, and a parent menu teleported to the body
|
||||
outranks it -- so the parent has to be gone before the panel opens.
|
||||
*/
|
||||
function openFileManager() {
|
||||
emit('navigate')
|
||||
siteStore.openFileManager()
|
||||
}
|
||||
|
||||
function openGlossary() {
|
||||
emit('navigate')
|
||||
siteStore.openGlossary()
|
||||
}
|
||||
</script>
|
||||
@@ -57,24 +57,18 @@
|
||||
<w-tooltip>Create New Page</w-tooltip>
|
||||
<new-menu />
|
||||
</w-btn>
|
||||
<!--
|
||||
-> Whoever may put a file somewhere: `write:assets` outright, or `write:pages` for an
|
||||
author whose rules cover the pages but not the assets beside them, since the editor
|
||||
sends them here to insert an image. Every folder and every file is checked again by the
|
||||
endpoints behind the manager, which answer per path, so this decides only whether the
|
||||
door is shown.
|
||||
-->
|
||||
<!-- -> Shown to everyone: Tags and the Glossary are for readers, and the menu holds back its
|
||||
File Manager row by itself; see `HeaderLibraryMenu` -->
|
||||
<w-btn
|
||||
v-if="userStore.can(`write:assets`) || userStore.can(`write:pages`)"
|
||||
class="ml-4"
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="la:folder-open"
|
||||
icon="mdi:museum-outline"
|
||||
color="positive"
|
||||
aria-label="File Manager"
|
||||
@click="openFileManager">
|
||||
<w-tooltip>File Manager</w-tooltip>
|
||||
:aria-label="t(`common.header.library`)">
|
||||
<w-tooltip>{{ t('common.header.library') }}</w-tooltip>
|
||||
<header-library-menu />
|
||||
</w-btn>
|
||||
<w-btn
|
||||
v-if="userStore.authenticated"
|
||||
@@ -159,6 +153,7 @@ import { useUserStore } from '@/stores/user'
|
||||
import AccountMenu from '@/components/AccountMenu.vue'
|
||||
import NewMenu from '@/components/PageNewMenu.vue'
|
||||
import HeaderActionsMenu from '@/components/HeaderActionsMenu.vue'
|
||||
import HeaderLibraryMenu from '@/components/HeaderLibraryMenu.vue'
|
||||
import HeaderSearch from '@/components/HeaderSearch.vue'
|
||||
|
||||
/**
|
||||
@@ -308,10 +303,6 @@ function onKeydown(ev) {
|
||||
function toggleSearchRow() {
|
||||
searchRowIsOpen.value = !searchRowIsOpen.value
|
||||
}
|
||||
|
||||
function openFileManager() {
|
||||
siteStore.openFileManager()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
a track of its own, so it sits ON the card rather than spanning it edge to edge -->
|
||||
<w-tabs class="m-2" v-model="state.currentTab" no-caps inline-label>
|
||||
<w-tab name="page" icon="la:file-alt" :label="t(`linkPicker.page`)" />
|
||||
<w-tab name="url" icon="la:globe" :label="t(`linkPicker.url`)" />
|
||||
<w-tab v-if="!props.pagesOnly" name="url" icon="la:globe" :label="t(`linkPicker.url`)" />
|
||||
</w-tabs>
|
||||
<w-separator />
|
||||
<w-tab-panels v-model="state.currentTab">
|
||||
@@ -212,6 +212,15 @@ const props = defineProps({
|
||||
lockLocale: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* Offer the Page tab alone, for a caller whose slot holds a page of this wiki and nothing else -- a
|
||||
* glossary term's documentation page. An `initialHref` that is a URL then opens on the page tree
|
||||
* rather than on a tab that is not there.
|
||||
*/
|
||||
pagesOnly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -445,10 +454,12 @@ onMounted(async () => {
|
||||
highlighted row once its folder is listed, and in the footer either way.
|
||||
*/
|
||||
if (props.initialHref) {
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(props.initialHref) || props.initialHref.startsWith('//')) {
|
||||
const isUrl =
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(props.initialHref) || props.initialHref.startsWith('//')
|
||||
if (isUrl && !props.pagesOnly) {
|
||||
state.currentTab = 'url'
|
||||
state.url = props.initialHref
|
||||
} else {
|
||||
} else if (!isUrl) {
|
||||
/*
|
||||
Re-opening on a link that already carries a prefix: the locale comes off it, so the picker
|
||||
starts in the tree the link points into rather than in the page's own.
|
||||
|
||||
@@ -33,6 +33,10 @@ const overlays = {
|
||||
loader: () => import('./FileManager.vue'),
|
||||
loadingComponent: LoadingGeneric
|
||||
}),
|
||||
Glossary: defineAsyncComponent({
|
||||
loader: () => import('./GlossaryOverlay.vue'),
|
||||
loadingComponent: LoadingGeneric
|
||||
}),
|
||||
NavEdit: defineAsyncComponent({
|
||||
loader: () => import('./NavEditOverlay.vue'),
|
||||
loadingComponent: LoadingGeneric
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<w-menu class="translucent-menu" auto-close anchor="bottom left" self="top left">
|
||||
<w-list padding style="min-width: 280px">
|
||||
<w-item
|
||||
v-for="mode of modes"
|
||||
:key="mode.value"
|
||||
clickable
|
||||
:active="mode.value === props.mode"
|
||||
@click="emit('select', mode.value)">
|
||||
<w-item-section avatar>
|
||||
<!--
|
||||
The disc the rule itself is drawn with, so a choice here looks like what it turns the rule
|
||||
into. Its colour through the CSS variable rather than a `bg-*` class built at runtime,
|
||||
which Tailwind would never emit.
|
||||
-->
|
||||
<span class="rule-mode-disc" :style="{ backgroundColor: `var(--color-${mode.color})` }">
|
||||
<w-icon :name="mode.icon" size="14px" />
|
||||
</span>
|
||||
</w-item-section>
|
||||
<w-item-section class="pr-2">
|
||||
<w-item-label>{{ mode.label }}</w-item-label>
|
||||
<w-item-label caption>{{ mode.hint }}</w-item-label>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
</w-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
/**
|
||||
* The choice of what a page rule does: Allow, Deny or Force Allow.
|
||||
*
|
||||
* Written as the last child of its trigger, like any `WMenu` -- the group editor opens it from both the
|
||||
* rule's disc and the mode name beside it. It only reports the choice (`select`); the rule is the
|
||||
* caller's to change.
|
||||
*/
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
/** The mode the rule has now, which the menu marks. */
|
||||
mode: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/**
|
||||
* Weakest first, which is also the order they outrank each other in when two rules are equally
|
||||
* specific -- see `helpers/pageRules.ts`. Icon names are literals for the icon bundle's sake.
|
||||
*/
|
||||
const modes = computed(() => [
|
||||
{
|
||||
value: 'ALLOW',
|
||||
label: t('admin.groups.ruleAllow'),
|
||||
hint: t('admin.groups.ruleAllowHint'),
|
||||
icon: 'la:check',
|
||||
color: 'positive'
|
||||
},
|
||||
{
|
||||
value: 'DENY',
|
||||
label: t('admin.groups.ruleDeny'),
|
||||
hint: t('admin.groups.ruleDenyHint'),
|
||||
icon: 'la:ban',
|
||||
color: 'negative'
|
||||
},
|
||||
{
|
||||
value: 'FORCEALLOW',
|
||||
label: t('admin.groups.ruleForceAllow'),
|
||||
hint: t('admin.groups.ruleForceAllowHint'),
|
||||
icon: 'la:check-double',
|
||||
color: 'blue'
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.rule-mode-disc {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 100%;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
|
||||
<w-card class="rule-perms" style="width: 1280px; max-width: 95vw">
|
||||
<w-card-section class="card-header">
|
||||
<w-icon name="img:/_assets/icons/fluent-security-lock.svg" size="sm" class="mr-2" />
|
||||
<span>{{ t('admin.groups.rulePermissionsTitle') }}</span>
|
||||
<w-space />
|
||||
<!-- -> The help button the overlays carry in their headers -- see `FileManager` -->
|
||||
<w-btn
|
||||
class="-my-2"
|
||||
flat
|
||||
round
|
||||
dense
|
||||
color="white"
|
||||
icon="la:question-circle"
|
||||
:aria-label="t(`common.actions.viewDocs`)"
|
||||
:href="siteStore.docsBase + `/admin/permissions#permissions`"
|
||||
target="_blank">
|
||||
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
|
||||
</w-btn>
|
||||
</w-card-section>
|
||||
<!--
|
||||
Grouped by what a rule's pattern means to the permission: most are decided per page, and the
|
||||
pattern says which pages; a few are about a site and a locale, and the pattern says nothing to
|
||||
them (`LOCALE_PERMISSIONS` in `backend/models/groups.ts`). Putting them side by side is what
|
||||
tells an administrator that a rule for `/docs` granting one of the second kind grants it
|
||||
everywhere.
|
||||
|
||||
The first group is long enough to take two lists of its own where the dialog is wide, so the
|
||||
layout is three columns, two, or one -- see the container queries below.
|
||||
-->
|
||||
<div class="rule-perms-body">
|
||||
<section
|
||||
v-for="group of groups"
|
||||
:key="group.key"
|
||||
class="rule-perms-group"
|
||||
:class="`is-${group.key}`">
|
||||
<div class="rule-perms-group-header">
|
||||
<div class="text-subtitle2">{{ group.title }}</div>
|
||||
<div class="text-caption text-grey-7">{{ group.hint }}</div>
|
||||
</div>
|
||||
<div class="rule-perms-lists">
|
||||
<!--
|
||||
-> Not `dense`, on the list or the rows: `WList` compresses a dense list's rows with a
|
||||
scoped rule that outranks `.rule-perms-row`, which is what sets their spacing here.
|
||||
-->
|
||||
<w-list v-for="(list, idx) of group.lists" :key="idx">
|
||||
<w-item
|
||||
v-for="opt of list"
|
||||
:key="opt.permission"
|
||||
class="rule-perms-row"
|
||||
:class="{ 'is-section-end': SECTION_ENDS.includes(opt.permission) }"
|
||||
tag="label">
|
||||
<w-item-section avatar class="!min-w-0 !pr-3">
|
||||
<w-toggle
|
||||
v-model="state.selected"
|
||||
:val="opt.permission"
|
||||
dense
|
||||
:aria-label="opt.title" />
|
||||
</w-item-section>
|
||||
<w-item-section>
|
||||
<w-item-label>{{ opt.title }}</w-item-label>
|
||||
<w-item-label caption>{{ opt.hint }}</w-item-label>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<w-card-actions class="card-actions">
|
||||
<span class="text-caption text-grey-7 pl-2">
|
||||
{{
|
||||
t(
|
||||
'admin.groups.rulePermissionsCount',
|
||||
{ count: state.selected.length },
|
||||
state.selected.length
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<w-space />
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
:label="t(`common.actions.cancel`)"
|
||||
color="grey"
|
||||
padding="xs md"
|
||||
@click="onDialogCancel" />
|
||||
<w-btn
|
||||
unelevated
|
||||
:label="t(`common.actions.apply`)"
|
||||
color="primary"
|
||||
padding="xs md"
|
||||
@click="apply" />
|
||||
</w-card-actions>
|
||||
</w-card>
|
||||
</w-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
/**
|
||||
* Pick the permissions one page rule grants or denies.
|
||||
*
|
||||
* Opened from a rule in the group editor (`GroupEditOverlay`), which owns the list of what may be
|
||||
* offered -- the guests group is offered fewer -- and gets back the chosen permissions, in the order
|
||||
* they are offered here, through `onOk`.
|
||||
*/
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* What may be picked: `{ permission, title, hint, scope }`, where `scope` is `locale` for the
|
||||
* permissions a rule's pattern does not apply to.
|
||||
*/
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
/** The permissions the rule holds now. */
|
||||
selected: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
defineEmits([...dialogComponentEmits])
|
||||
|
||||
// DIALOG
|
||||
|
||||
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
|
||||
|
||||
// STORES
|
||||
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
selected: [...props.selected]
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/**
|
||||
* What the second list of the per-page group holds: the permissions over the things that hang off a
|
||||
* page rather than the page itself -- its assets, its comments and its navigation menu. Everything
|
||||
* else about pages stays in the first. Tested against the option, not the position, so the order of
|
||||
* the list each comes from is kept.
|
||||
*/
|
||||
/**
|
||||
* The last permission of each run of related ones -- the page lifecycle, what may go into a page,
|
||||
* assets, comments -- which a dotted rule closes off from the next run. Only ever drawn where the
|
||||
* permission is not the last of its list, so a column never ends on a rule.
|
||||
*/
|
||||
const SECTION_ENDS = ['delete:pages', 'write:scripts', 'manage:assets', 'manage:comments']
|
||||
|
||||
function isAttachedToPage(opt) {
|
||||
return /:(assets|comments)$/.test(opt.permission) || opt.permission === 'manage:navigation'
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
const perPage = props.options.filter((opt) => opt.scope !== 'locale')
|
||||
return [
|
||||
{
|
||||
key: 'page',
|
||||
title: t('admin.groups.rulePermissionsPage'),
|
||||
hint: t('admin.groups.rulePermissionsPageHint'),
|
||||
lists: [
|
||||
perPage.filter((opt) => !isAttachedToPage(opt)),
|
||||
// -> Navigation last, after the assets and comments, wherever the source list has it
|
||||
[
|
||||
...perPage.filter(
|
||||
(opt) => isAttachedToPage(opt) && opt.permission !== 'manage:navigation'
|
||||
),
|
||||
...perPage.filter((opt) => opt.permission === 'manage:navigation')
|
||||
]
|
||||
].filter((list) => list.length > 0)
|
||||
},
|
||||
{
|
||||
key: 'locale',
|
||||
title: t('admin.groups.rulePermissionsLocale'),
|
||||
hint: t('admin.groups.rulePermissionsLocaleHint'),
|
||||
lists: [props.options.filter((opt) => opt.scope === 'locale')]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
/**
|
||||
* Hand back what is ticked, in the order offered, followed by anything the rule held that this list
|
||||
* does not offer -- a rule imported from elsewhere may name one, and picking from a narrower list is
|
||||
* not a reason to drop it silently.
|
||||
*/
|
||||
function apply() {
|
||||
const offered = props.options.map((opt) => opt.permission)
|
||||
const ticked = new Set(state.selected)
|
||||
onDialogOK([
|
||||
...offered.filter((permission) => ticked.has(permission)),
|
||||
...props.selected.filter((permission) => !offered.includes(permission))
|
||||
])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/*
|
||||
Three columns, two or one, decided by the card's own width rather than the window's (the dialog is
|
||||
capped at 95vw, and a container query cannot be fooled by that):
|
||||
|
||||
- 1100px and up: the per-page group takes two thirds, as two lists side by side; the locale group
|
||||
takes the last third.
|
||||
- 700px and up: two equal columns, the per-page group's lists stacked in the first.
|
||||
- Below that: everything in one column.
|
||||
*/
|
||||
.rule-perms {
|
||||
container-type: inline-size;
|
||||
|
||||
&-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0 1rem;
|
||||
/* -> The window less the header, the actions and the dialog's own margins */
|
||||
max-height: calc(100vh - 180px);
|
||||
overflow: auto;
|
||||
padding: 0.5rem 0.5rem 0.75rem;
|
||||
|
||||
@container (min-width: 700px) {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@container (min-width: 1100px) {
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
&-lists {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@container (min-width: 1100px) {
|
||||
.is-page &-lists {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* -> Room under the hint, so it reads as the group's caption rather than the first row's */
|
||||
&-group-header {
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
/*
|
||||
-> A rule between the two groups wherever they sit side by side, since that is the line the dialog
|
||||
exists to draw: what a rule's pattern applies to, and what it does not.
|
||||
*/
|
||||
@container (min-width: 700px) {
|
||||
.is-locale {
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.12);
|
||||
padding-left: 0.5rem;
|
||||
|
||||
@at-root .body--dark & {
|
||||
border-left-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
-> Each permission its own row, with room between them: a title and its hint read as a pair, and
|
||||
the next pair starts clearly below. The toggle is centred on the two together.
|
||||
*/
|
||||
&-row {
|
||||
min-height: 0;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
|
||||
.w-item-label--caption {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&.is-section-end:not(:last-child) {
|
||||
border-bottom: 2px dotted rgba(0, 0, 0, 0.16);
|
||||
margin-bottom: 5px;
|
||||
padding-bottom: 10px;
|
||||
|
||||
@at-root .body--dark & {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -279,6 +279,19 @@
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item tag="label">
|
||||
<blueprint-icon icon="parchment" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.general.allowGlossary`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.general.allowGlossaryHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section avatar>
|
||||
<w-toggle
|
||||
v-model="state.config.features.glossary"
|
||||
:aria-label="t(`admin.general.allowGlossary`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<!--
|
||||
What the missing-page screen shows at a path with pages under it. Off, that screen is
|
||||
only the notice and the Create button.
|
||||
@@ -732,6 +745,7 @@ function defaultConfig() {
|
||||
collaborativeEditing: false,
|
||||
ratingsMode: 'off',
|
||||
comments: true,
|
||||
glossary: true,
|
||||
lastEditedBy: true,
|
||||
listFolderChildren: false,
|
||||
notifications: true,
|
||||
@@ -875,6 +889,7 @@ async function save() {
|
||||
// silent: the toggle moved, the save succeeded, and the next load put it back.
|
||||
collaborativeEditing: state.config.features?.collaborativeEditing ?? false,
|
||||
comments: state.config.features?.comments ?? true,
|
||||
glossary: state.config.features?.glossary ?? true,
|
||||
lastEditedBy: state.config.features?.lastEditedBy ?? true,
|
||||
listFolderChildren: state.config.features?.listFolderChildren ?? false,
|
||||
notifications: state.config.features?.notifications ?? true,
|
||||
|
||||
@@ -20,38 +20,62 @@ import MarkdownIt from 'markdown-it'
|
||||
* tables, no footnotes, no HTML. Links are rendered but every one of them leaves with
|
||||
* `rel="nofollow ugc noopener"` and opens in a new tab.
|
||||
*/
|
||||
const md = new MarkdownIt('zero', {
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
typographer: false
|
||||
})
|
||||
.enable([
|
||||
'blockquote',
|
||||
'code',
|
||||
'emphasis',
|
||||
'entity',
|
||||
'escape',
|
||||
'fence',
|
||||
'linkify',
|
||||
'list',
|
||||
'newline',
|
||||
'backticks',
|
||||
'link',
|
||||
'strikethrough'
|
||||
])
|
||||
// -> A comment is prose, and a rule across it is furniture; a heading in one would outrank the
|
||||
// page's own headings in the outline of the view it sits in
|
||||
.disable([
|
||||
'heading',
|
||||
'lheading',
|
||||
'hr',
|
||||
'image',
|
||||
'table',
|
||||
'reference',
|
||||
'html_block',
|
||||
'html_inline'
|
||||
])
|
||||
|
||||
/**
|
||||
* The prose markdown this file renders, as a fresh instance whose links carry `rel`.
|
||||
*
|
||||
* Exported for the glossary's definitions (`renderers/glossary.js`), which want exactly this feature
|
||||
* set and differ only in who wrote the links -- see there. Everything said above about `html: false`
|
||||
* holds for both.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.rel What every link leaves with
|
||||
*/
|
||||
export function createProseMarkdown({ rel }) {
|
||||
const instance = new MarkdownIt('zero', {
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
typographer: false
|
||||
})
|
||||
.enable([
|
||||
'blockquote',
|
||||
'code',
|
||||
'emphasis',
|
||||
'entity',
|
||||
'escape',
|
||||
'fence',
|
||||
'linkify',
|
||||
'list',
|
||||
'newline',
|
||||
'backticks',
|
||||
'link',
|
||||
'strikethrough'
|
||||
])
|
||||
// -> Prose, and a rule across it is furniture; a heading in one would outrank the page's own
|
||||
// headings in the outline of the view it sits in
|
||||
.disable([
|
||||
'heading',
|
||||
'lheading',
|
||||
'hr',
|
||||
'image',
|
||||
'table',
|
||||
'reference',
|
||||
'html_block',
|
||||
'html_inline'
|
||||
])
|
||||
|
||||
// -> Every link opens in a new tab: the view it is drawn in is something the reader is in the middle
|
||||
// of, and `noopener` (which every `rel` given here carries) keeps the tab from handing back a
|
||||
// handle to it
|
||||
instance.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx]
|
||||
token.attrSet('rel', rel)
|
||||
token.attrSet('target', '_blank')
|
||||
return self.renderToken(tokens, idx, options)
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Every link a comment carries, whoever wrote it.
|
||||
@@ -60,12 +84,7 @@ const md = new MarkdownIt('zero', {
|
||||
* two attributes exist to say -- and `noopener` because the tab is opened by the wiki and must not
|
||||
* hand the opened page a handle back to it.
|
||||
*/
|
||||
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx]
|
||||
token.attrSet('rel', 'nofollow ugc noopener')
|
||||
token.attrSet('target', '_blank')
|
||||
return self.renderToken(tokens, idx, options)
|
||||
}
|
||||
const md = createProseMarkdown({ rel: 'nofollow ugc noopener' })
|
||||
|
||||
/**
|
||||
* A mention as it is written in a comment: `@handle`.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createProseMarkdown } from './comment.js'
|
||||
|
||||
/**
|
||||
* The markdown a glossary definition may be written in: a comment's, link for link.
|
||||
*
|
||||
* The same feature set and the same security boundary (`html: false` -- see `renderers/comment.js`), so
|
||||
* nothing stored is ever HTML. Two differences, both about who wrote it: links lose `nofollow ugc`,
|
||||
* since a definition is written by somebody holding `manage:glossary` rather than by whoever may
|
||||
* comment, and `@handle` is not a mention, since nobody is being addressed.
|
||||
*/
|
||||
const md = createProseMarkdown({ rel: 'noopener' })
|
||||
|
||||
/**
|
||||
* Render one definition.
|
||||
*
|
||||
* @param {string} source Markdown as it was typed
|
||||
* @returns {string} HTML, safe to hand to `v-html`: nothing in it came from the source unescaped
|
||||
*/
|
||||
export function renderDefinition(source) {
|
||||
return md.render(source ?? '')
|
||||
}
|
||||
|
||||
export default renderDefinition
|
||||
@@ -0,0 +1,74 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useSiteStore } from './site'
|
||||
import { useUserStore } from './user'
|
||||
|
||||
/**
|
||||
* What the current session may do with this site's glossary.
|
||||
*
|
||||
* Asked once per site and session (`glossary/access`) rather than per route, because `read:glossary`
|
||||
* does not depend on the page: it is a page permission whose rule's path is ignored, answered per
|
||||
* locale. That is also why it is not part of `userStore.pagePermissions`, which is fetched with a page
|
||||
* and is empty on every route that is not one -- the Library menu has to know on `/_tags` too.
|
||||
*
|
||||
* The terms themselves are not held here: the overlay is the only thing that reads them, and it
|
||||
* fetches them when it opens.
|
||||
*/
|
||||
export const useGlossaryStore = defineStore('glossary', {
|
||||
state: () => ({
|
||||
/** Whose answer this is -- `siteId:userId` -- so a login, a logout or another site asks again. */
|
||||
loadedFor: null,
|
||||
enabled: false,
|
||||
readableLocales: [],
|
||||
/** The locales this session may edit the glossary in -- `manage:glossary`, asked per locale. */
|
||||
manageableLocales: []
|
||||
}),
|
||||
getters: {
|
||||
/** Whether there is a glossary this session may open, in any locale. */
|
||||
isAvailable: (state) => state.enabled && state.readableLocales.length > 0,
|
||||
/** Whether this session may create, edit and delete terms in a locale. */
|
||||
canManageIn: (state) => (locale) => state.manageableLocales.includes(locale)
|
||||
},
|
||||
actions: {
|
||||
/**
|
||||
* Fetch the access answer, unless it is already the one for this site and this session.
|
||||
*
|
||||
* A failure leaves the glossary unavailable rather than throwing: the menu row is the only thing
|
||||
* waiting on it, and a missing row is the right answer to not knowing.
|
||||
*/
|
||||
async ensureAccess() {
|
||||
const siteStore = useSiteStore()
|
||||
const userStore = useUserStore()
|
||||
if (!siteStore.id) {
|
||||
return
|
||||
}
|
||||
const key = `${siteStore.id}:${userStore.authenticated ? userStore.id : 'guest'}`
|
||||
if (this.loadedFor === key) {
|
||||
return
|
||||
}
|
||||
this.loadedFor = key
|
||||
try {
|
||||
const access = await API_CLIENT.get(`sites/${siteStore.id}/glossary/access`).json()
|
||||
// -> Asked again for somebody else while this was in flight: that answer is the one to keep
|
||||
if (this.loadedFor !== key) {
|
||||
return
|
||||
}
|
||||
this.$patch({
|
||||
enabled: access.enabled,
|
||||
readableLocales: access.readableLocales ?? [],
|
||||
manageableLocales: access.manageableLocales ?? []
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn(`Failed to fetch glossary access: ${err.message}`)
|
||||
if (this.loadedFor === key) {
|
||||
this.$patch({
|
||||
loadedFor: null,
|
||||
enabled: false,
|
||||
readableLocales: [],
|
||||
manageableLocales: []
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -119,6 +119,8 @@ export const useSiteStore = defineStore('site', {
|
||||
backlinks: true,
|
||||
browse: false,
|
||||
collaborativeEditing: false,
|
||||
// -> On, for the reason `backlinks` is: the server reads a missing key as on
|
||||
glossary: true,
|
||||
lastEditedBy: true,
|
||||
listFolderChildren: false,
|
||||
// -> On, for the reason `backlinks` is: the server reads a missing key as on, and the badge
|
||||
@@ -413,6 +415,20 @@ export const useSiteStore = defineStore('site', {
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* @param {object} [opts]
|
||||
* @param {?string} [opts.termId] The term to open on. Absent, the list with nothing selected.
|
||||
* @param {?string} [opts.locale] The locale to open in. Absent, the one the interface is in.
|
||||
*/
|
||||
openGlossary(opts) {
|
||||
this.$patch({
|
||||
overlay: 'Glossary',
|
||||
overlayOpts: {
|
||||
termId: opts?.termId ?? null,
|
||||
locale: opts?.locale ?? null
|
||||
}
|
||||
})
|
||||
},
|
||||
async loadSite(hostname) {
|
||||
try {
|
||||
// -> The locale descriptors come alongside rather than after, so the selector's label is
|
||||
|
||||
Reference in New Issue
Block a user