You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/backend/models/navigation.ts

292 lines
10 KiB

import { and, eq, inArray, sql } from 'drizzle-orm'
import { navigation as navigationTable, tree as treeTable } from '../db/schema.ts'
import { CustomError } from '../helpers/common.ts'
export const NAVIGATION_MODES = [
'inherit',
'override',
'overrideExact',
'hide',
'hideExact'
] as const
export type NavigationMode = (typeof NAVIGATION_MODES)[number]
export interface NavigationItem {
id: string
type: 'link' | 'header' | 'separator'
label?: string
icon?: string
target?: string
openInNewWindow?: boolean
/** A link with children only: whether the sidebar shows its submenu already open. */
expandByDefault?: boolean
visibilityGroups?: string[]
children?: NavigationItem[]
}
export interface UpdateNavigationResult {
navigationMode: NavigationMode
navigationId: string | null
}
/** An item is visible when it names no group, or names one the viewer belongs to. */
function isVisibleTo(item: NavigationItem, userGroups: string[]): boolean {
const groups = item.visibilityGroups ?? []
return groups.length < 1 || groups.some((g) => userGroups.includes(g))
}
/**
* Navigation model
*
* A navigation menu is a row of `items` keyed by the id of whatever it belongs to: a tree entry that
* overrides the menu below it, or — for the site-wide menu every page falls back to — the site's own
* id. That double use of the key is why the id alone is enough to fetch a menu, and why the home page
* edits the site menu rather than one of its own.
*
* Which menu a page gets is decided when the mode is saved rather than when the page is rendered:
* every tree entry carries the resolved `navigationId`, so drawing a sidebar is one lookup.
*/
class Navigation {
/**
* The items of one menu.
*
* @param id Menu id — a tree entry id, or a site id for the site-wide menu
* @param userGroups Groups the viewer belongs to. Items limited to other groups are dropped, at both
* levels, unless `unfiltered` is set.
* @param unfiltered Return every item regardless of visibility, which is what editing one needs —
* an editor that could not see an item would drop it on the next save.
*/
async getNav(
id: string,
{ userGroups = [], unfiltered = false }: { userGroups?: string[]; unfiltered?: boolean } = {}
): Promise<NavigationItem[]> {
const rows = await WIKI.db
.select({ items: navigationTable.items })
.from(navigationTable)
.where(eq(navigationTable.id, id))
.limit(1)
const items = (rows[0]?.items ?? []) as NavigationItem[]
if (unfiltered) {
return items
}
return items
.filter((item) => isVisibleTo(item, userGroups))
.map((item) =>
item.children?.length
? { ...item, children: item.children.filter((c) => isVisibleTo(c, userGroups)) }
: item
)
}
/**
* The menu the site as a whole uses, which is the one every page inherits by default.
*
* Created empty on demand: a site made before this row existed, or one whose menu was never edited,
* has nothing stored, and an absent menu is an empty one rather than an error.
*/
async ensureSiteNav(siteId: string): Promise<void> {
await WIKI.db
.insert(navigationTable)
.values({ id: siteId, siteId, items: [] })
.onConflictDoNothing()
}
/**
* Drop the menus belonging to tree entries that no longer exist.
*
* A menu is keyed by the id of the entry that owns it, so deleting a page or a folder would
* otherwise leave its menu behind with nothing able to reach it. The site's own menu is keyed by the
* site id and is never a tree entry, so it is not at risk here.
*
* @param ids Tree entry ids being removed
*/
async deleteNavForEntries(ids: string[]): Promise<void> {
if (ids.length < 1) {
return
}
await WIKI.db.delete(navigationTable).where(inArray(navigationTable.id, ids))
}
/** The tree entry a navigation change is addressed to. */
private async getEntry(siteId: string, pageId: string) {
const entries = await WIKI.db
.select()
.from(treeTable)
.where(and(eq(treeTable.id, pageId), eq(treeTable.siteId, siteId)))
.limit(1)
const entry = entries[0]
if (!entry) {
throw new CustomError('navInvalidPage', 'This page does not exist.', 404)
}
return entry
}
/**
* The menu a tree entry falls back to: the nearest ancestor that overrides or hides, or the
* site-wide menu when nothing above it does either.
*
* @param siteId Site the entry belongs to, since paths are only unique within one
* @param folderPath Encoded ltree path of the folder holding the entry, empty at the site root
*/
private async ancestorNavId(siteId: string, folderPath: string): Promise<string | null> {
if (!folderPath) {
return siteId
}
const result = await WIKI.db.execute(sql`
SELECT "navigationId"
FROM tree
WHERE "siteId" = ${siteId}
AND ("folderPath" || "fileName") @> ${folderPath}::ltree
AND "navigationMode" IN ('override', 'hide')
ORDER BY nlevel("folderPath" || "fileName") DESC
LIMIT 1
`)
const rows = (result.rows ?? result) as any[]
return rows.length > 0 ? (rows[0].navigationId ?? null) : siteId
}
/**
* The menu a page inherits — the one its sidebar shows while its own mode is `inherit`.
*
* `navigationId` on the entry already answers this for a page that IS inheriting, but only for one:
* the navigation editor asks before anything is saved, so that a page can edit the menu it shows
* without being opened on the ancestor that owns it, and so that it can tell there is one to edit.
*
* Null when the nearest ancestor hides the sidebar, which leaves nothing to inherit.
*/
async inheritedNavId(siteId: string, pageId: string): Promise<string | null> {
const entry = await this.getEntry(siteId, pageId)
return this.ancestorNavId(siteId, entry.folderPath ?? '')
}
/**
* Set how a page decides its sidebar, and optionally the menu itself.
*
* Two things move here. The entry records its own mode and the menu it resolves to, and — when the
* change alters what descendants inherit — every entry below it that is still on `inherit` is
* repointed, stopping at any that overrides or hides in between.
*
* @param items When given, the menu the mode resolves to, replacing whatever was there — this
* entry's own, or the one it inherits when the mode is `inherit`
*/
async updateNavigation({
siteId,
pageId,
mode,
items
}: {
siteId: string
pageId: string
mode: NavigationMode
items?: NavigationItem[]
}): Promise<UpdateNavigationResult> {
const entry = await this.getEntry(siteId, pageId)
// -> Whatever this change resolves to, `inherit` ultimately falls back to the site menu, and a
// site created before that row existed does not have one yet
await this.ensureSiteNav(siteId)
const folderPath = entry.folderPath ?? ''
// -> The home page at the root edits the site-wide menu rather than one of its own, which is what
// makes it the menu every other page inherits
const isSiteRoot = folderPath === '' && entry.fileName === 'home'
const ownNavId = isSiteRoot ? siteId : entry.id
const fullPath = folderPath ? `${folderPath}.${entry.fileName}` : entry.fileName
const ancestorId = await this.ancestorNavId(siteId, folderPath)
if (items) {
/*
Which menu the items belong to is the mode's answer, not the entry's: a page that inherits
shows a menu belonging to an ancestor, so editing the sidebar from that page edits THAT menu
rather than starting one of its own that nothing would point at. For the root home page the two
are the same id — the site-wide menu is what it inherits and what it owns.
*/
const targetNavId = mode === 'inherit' ? ancestorId : ownNavId
if (!targetNavId) {
throw new CustomError(
'navNoInheritedMenu',
'This page inherits a hidden sidebar, so there is no menu to save items to.',
400
)
}
await WIKI.db
.insert(navigationTable)
.values({ id: targetNavId, siteId, items })
.onConflictDoUpdate({ target: navigationTable.id, set: { items } })
}
// -> A mode that stops applying below this entry hands its descendants back to the ancestor
const wasCascading = ['override', 'hide'].includes(entry.navigationMode)
let navId: string | null = null
let cascadeTo: string | null | undefined
switch (mode) {
case 'inherit': {
navId = ancestorId
if (wasCascading) {
cascadeTo = ancestorId
}
break
}
case 'override': {
navId = ownNavId
cascadeTo = ownNavId
break
}
case 'overrideExact': {
navId = ownNavId
if (wasCascading) {
cascadeTo = ancestorId
}
break
}
case 'hide': {
navId = null
cascadeTo = null
break
}
case 'hideExact': {
navId = null
if (wasCascading) {
cascadeTo = ancestorId
}
break
}
}
await WIKI.db
.update(treeTable)
.set({ navigationMode: mode, navigationId: navId })
.where(eq(treeTable.id, entry.id))
if (cascadeTo !== undefined) {
// -> Everything below that still inherits, except what sits under a nearer override or hide,
// which owns its own subtree
await WIKI.db.execute(sql`
UPDATE tree tt
SET "navigationId" = ${cascadeTo}
WHERE tt."siteId" = ${siteId}
AND tt.tree IN ('page', 'folder')
AND tt."folderPath" <@ ${fullPath}::ltree
AND tt."navigationMode" = 'inherit'
AND NOT EXISTS (
SELECT 1
FROM tree tc
WHERE tc."siteId" = ${siteId}
AND tc.tree IN ('page', 'folder')
AND tc."folderPath" <@ ${fullPath}::ltree
AND (tc."folderPath" || tc."fileName") @> tt."folderPath"
AND tc."navigationMode" IN ('override', 'hide')
)
`)
}
return { navigationMode: mode, navigationId: navId }
}
}
export const navigation = new Navigation()