mirror of https://github.com/requarks/wiki
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.
508 lines
19 KiB
508 lines
19 KiB
import { and, eq, inArray, sql, type SQL } from 'drizzle-orm'
|
|
import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts'
|
|
import { decodeTreePath, encodeTreePath } from '../helpers/common.ts'
|
|
import { isBodylessEditor, parseBlogContent, type BlogContent } from './pages.ts'
|
|
import type { AccessActor } from './groups.ts'
|
|
|
|
/**
|
|
* How many of a blog's posts are read out of the database for one request.
|
|
*
|
|
* Every post is read, not just the page of them being shown, because the ordering that matters is
|
|
* the one that survives the page rules: which posts a reader may open cannot be expressed in SQL —
|
|
* a rule matches on path, locale and tags and is resolved a page at a time — so a `LIMIT 10` here
|
|
* would be ten CANDIDATES, of which this reader might see six, and page 2 would then start in the
|
|
* wrong place. Filtering first and slicing afterwards is what makes "posts 11-20 of 47" true.
|
|
*
|
|
* The facets are the same read. A tag cloud and an archive counted in SQL would count posts this
|
|
* reader may not open, which is a listing of pages they were told nothing about — the same reasoning
|
|
* `tags.getTags` applies site-wide.
|
|
*
|
|
* The ceiling is what keeps that honest rather than unbounded. A blog past it is not a blog any more
|
|
* and wants a search page; the listing says so rather than quietly serving a prefix.
|
|
*/
|
|
const MAX_POSTS = 2000
|
|
|
|
/** A blog's front page: where it is, and how its author set it up. */
|
|
export interface BlogRef {
|
|
id: string
|
|
/** Slash-separated path of the front page, i.e. the blog's own URL within the site. */
|
|
path: string
|
|
title: string
|
|
locale: string
|
|
settings: BlogContent
|
|
}
|
|
|
|
/** One post, as a listing draws it. */
|
|
export interface BlogPost {
|
|
id: string
|
|
/** Slash-separated path of the post, i.e. its URL within the site. */
|
|
path: string
|
|
title: string
|
|
description: string
|
|
/** The post's icon, as an Iconify reference. Empty when it has none. */
|
|
icon: string
|
|
tags: string[]
|
|
/**
|
|
* When the post counts as having been published: its `publishStartDate`, or when it was created.
|
|
*
|
|
* One field rather than two, because a blog is ordered by one thing and a reader is shown one
|
|
* date, and a post written today about last week belongs where its author dated it.
|
|
*
|
|
* It is not what decides whether the post is LISTED. That is `publishState`, here as everywhere
|
|
* else in the wiki — so a post left `published` with a start date next Tuesday is in the blog
|
|
* today, dated next Tuesday. `scheduled` is what holds one back.
|
|
*/
|
|
publishedAt: Date
|
|
updatedAt: Date
|
|
authorId: string
|
|
/** Who wrote the version that stands. Empty once that account is deleted. */
|
|
authorName: string
|
|
}
|
|
|
|
/** What the column beside a listing offers to narrow it by. */
|
|
export interface BlogFacets {
|
|
/** Every tag carried by a readable post, most used first. */
|
|
tags: { tag: string; count: number }[]
|
|
/** One entry per month that has posts, newest first. */
|
|
archive: { year: number; month: number; count: number }[]
|
|
}
|
|
|
|
/** A listing, as one request answers it. */
|
|
export interface BlogListing {
|
|
posts: BlogPost[]
|
|
/** How many posts match the filters, ignoring which page of them was asked for. */
|
|
total: number
|
|
/** Which page of the listing this is, counted from 1. */
|
|
page: number
|
|
pageCount: number
|
|
/**
|
|
* The facets over every readable post of the blog, NOT over the filtered set — a tag cloud that
|
|
* emptied itself as soon as a tag was picked would leave a reader with no way back out of the
|
|
* filter they just applied.
|
|
*/
|
|
facets: BlogFacets
|
|
/** Whether the blog holds more posts than one request will read. See `MAX_POSTS`. */
|
|
truncated: boolean
|
|
}
|
|
|
|
/** What a listing may be narrowed by, as a reader picks it out of the sidebar. */
|
|
export interface BlogFilter {
|
|
/** Only posts carrying this tag. */
|
|
tag?: string | null
|
|
/** Only posts published in this year, and in this month of it when one is given. */
|
|
year?: number | null
|
|
month?: number | null
|
|
/** Which page of the listing to answer with, counted from 1. */
|
|
page?: number
|
|
}
|
|
|
|
/** One candidate row, before the page rules and the nesting rule have had their say. */
|
|
interface Candidate extends BlogPost {
|
|
editor: string
|
|
}
|
|
|
|
/**
|
|
* Blogs
|
|
*
|
|
* A blog is a page written with the `blog` editor plus the pages underneath it. That is the whole of
|
|
* the data model: the front page's content column holds how the blog should look (`BlogContent`), and
|
|
* WHICH pages are its posts is worked out here, at read time, from where they sit.
|
|
*
|
|
* Nothing is written on a post to say it is one. That is deliberate and it is what makes the feature
|
|
* cost nothing to the rest of the wiki: a post is an ordinary page with ordinary history, ordinary
|
|
* permissions and an ordinary place in the tree, and moving it out of the blog's path is how it stops
|
|
* being a post. The tree already supports a page and a folder sharing a name — `/my-blog` is both the
|
|
* front page and the way into `/my-blog/…`, the same way `/guide` is — so nothing had to change there
|
|
* either.
|
|
*
|
|
* The cost of that choice is that the blog's membership is a fact about PATHS, and paths move. Moving
|
|
* the front page away from its posts, or renaming the folder out from under it, leaves a blog with
|
|
* nothing in it and no row anywhere recording that anything is wrong. `listing` reports the empty
|
|
* case as its own answer rather than as a listing that happens to be empty, so the failure is at
|
|
* least legible to whoever caused it.
|
|
*/
|
|
class Blogs {
|
|
/**
|
|
* The blog whose front page IS this path, or null where that page is not a blog.
|
|
*
|
|
* One lookup on `(siteId, locale, path)`, which is a unique index.
|
|
*/
|
|
async blogAt(siteId: string, locale: string, path: string): Promise<BlogRef | null> {
|
|
const rows = await WIKI.db
|
|
.select({
|
|
id: pagesTable.id,
|
|
path: pagesTable.path,
|
|
title: pagesTable.title,
|
|
locale: pagesTable.locale,
|
|
content: pagesTable.content,
|
|
editor: pagesTable.editor
|
|
})
|
|
.from(pagesTable)
|
|
.where(
|
|
and(
|
|
eq(pagesTable.siteId, siteId),
|
|
eq(pagesTable.locale, locale),
|
|
eq(pagesTable.path, path),
|
|
eq(pagesTable.editor, 'blog')
|
|
)
|
|
)
|
|
.limit(1)
|
|
const row = rows[0]
|
|
return row ? { ...row, settings: parseBlogContent(row.content) } : null
|
|
}
|
|
|
|
/**
|
|
* The blog a page belongs to, or null for a page that is not in one.
|
|
*
|
|
* Asked of every page view, so it is one indexed read and not a scan: the candidates are the page's
|
|
* own ancestors, which is a handful of paths a path already names, so the lookup is an `IN` over the
|
|
* same unique index `blogAt` uses. A page at the site root has no ancestors and costs no query at
|
|
* all.
|
|
*
|
|
* The NEAREST ancestor wins. A blog inside a blog is its own blog — its posts belong to it and not
|
|
* to the one above, which is also what `postsFor` implements coming the other way.
|
|
*
|
|
* The page itself is never the answer: a blog's front page is not one of its own posts.
|
|
*/
|
|
async blogFor(siteId: string, locale: string, path: string): Promise<BlogRef | null> {
|
|
const parts = path.split('/').filter((part) => part.length > 0)
|
|
if (parts.length < 2) {
|
|
return null
|
|
}
|
|
const ancestors: string[] = []
|
|
for (let i = 1; i < parts.length; i++) {
|
|
ancestors.push(parts.slice(0, i).join('/'))
|
|
}
|
|
const rows = await WIKI.db
|
|
.select({
|
|
id: pagesTable.id,
|
|
path: pagesTable.path,
|
|
title: pagesTable.title,
|
|
locale: pagesTable.locale,
|
|
content: pagesTable.content
|
|
})
|
|
.from(pagesTable)
|
|
.where(
|
|
and(
|
|
eq(pagesTable.siteId, siteId),
|
|
eq(pagesTable.locale, locale),
|
|
eq(pagesTable.editor, 'blog'),
|
|
inArray(pagesTable.path, ancestors)
|
|
)
|
|
)
|
|
if (rows.length < 1) {
|
|
return null
|
|
}
|
|
// -> The nearest one, which is the longest path: every row here is an ancestor of the same page
|
|
const nearest = rows.reduce((best, row) => (row.path.length > best.path.length ? row : best))
|
|
return { ...nearest, settings: parseBlogContent(nearest.content) }
|
|
}
|
|
|
|
/**
|
|
* Make sure the folder a blog's posts will go in exists.
|
|
*
|
|
* A blog's front page is a page at `my-blog`, and its posts are pages at `my-blog/…` — which means
|
|
* the folder `my-blog` is where an author is about to be working, and until something is saved
|
|
* under it the folder is not there at all. The tree creates folders from the middle out as pages
|
|
* arrive, so nothing is BROKEN without this: the folder appears the moment the first post is saved.
|
|
* What is missing is somewhere to save that first post FROM — the file manager and the tree browser
|
|
* have no folder to open, so the blog looks like a dead end until somebody types the path by hand.
|
|
*
|
|
* A page and a folder of the same name sit side by side quite happily — that is how `/guide` gets
|
|
* to be both a page and the way into `/guide/…` — so this adds a folder rather than changing
|
|
* anything about the page.
|
|
*
|
|
* **It never fails the caller.** The folder is a convenience and the blog is complete without it,
|
|
* so the two ways this can legitimately not work are logged and stepped over rather than raised:
|
|
* a folder name is held to `[a-z0-9-]` while a page path may also carry underscores (a blog at
|
|
* `my_blog` has no legal folder name), and an ASSET already sitting at that name blocks a folder
|
|
* where it would not have blocked the page.
|
|
*
|
|
* @returns Whether a folder was created. False when one was already there, and when one could not be.
|
|
*/
|
|
async ensureFolder({
|
|
siteId,
|
|
locale,
|
|
path,
|
|
title
|
|
}: {
|
|
siteId: string
|
|
locale: string
|
|
path: string
|
|
title: string
|
|
}): Promise<boolean> {
|
|
const encoded = encodeTreePath(path)
|
|
const parts = encoded.split('.')
|
|
const fileName = parts.at(-1)!
|
|
const folderPath = parts.slice(0, -1).join('.')
|
|
|
|
/*
|
|
Asked directly rather than through `tree.getFolder({ createIfMissing: true })`, which would find
|
|
or create in one call: that one titles what it creates after the path segment, and this folder
|
|
IS the blog — `Engineering Notes` is what somebody making it by hand would have called it, and
|
|
the title is what the file manager and the browse menu show.
|
|
*/
|
|
const existing = await WIKI.db
|
|
.select({ id: treeTable.id })
|
|
.from(treeTable)
|
|
.where(
|
|
and(
|
|
eq(treeTable.siteId, siteId),
|
|
eq(treeTable.locale, locale),
|
|
eq(treeTable.folderPath, folderPath),
|
|
eq(treeTable.fileName, fileName),
|
|
eq(treeTable.type, 'folder')
|
|
)
|
|
)
|
|
.limit(1)
|
|
if (existing.length > 0) {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
await WIKI.models.tree.createFolder({
|
|
parentPath: decodeTreePath(folderPath) ?? '',
|
|
pathName: fileName,
|
|
title,
|
|
locale,
|
|
siteId
|
|
})
|
|
return true
|
|
} catch (err: any) {
|
|
WIKI.logger.warn(
|
|
`Could not create the folder for the blog at /${path}: ${err.message}. Posts saved under it will create it.`
|
|
)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Every post of a blog this reader may open, newest or oldest first as the blog is set up.
|
|
*
|
|
* Three things are dropped on the way, and each for its own reason:
|
|
*
|
|
* - **A page with no body of its own** — a redirection, or the front page of a blog nested inside
|
|
* this one. A doorway is not a post, and neither is another blog.
|
|
* - **A page under a nested blog.** Its own blog lists it; this one stops where that one starts, so
|
|
* a post never appears in two listings under two different bylines.
|
|
* - **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.
|
|
*/
|
|
async postsFor({
|
|
siteId,
|
|
blog,
|
|
actor,
|
|
publicOnly = true
|
|
}: {
|
|
siteId: string
|
|
blog: BlogRef
|
|
actor?: AccessActor
|
|
publicOnly?: boolean
|
|
}): Promise<{ posts: BlogPost[]; truncated: boolean }> {
|
|
const encodedPath = encodeTreePath(blog.path)
|
|
const depth = blog.settings.depth
|
|
const levels = depth > 0 ? `*{,${depth}}` : '*{0}'
|
|
const pathQuery = encodedPath ? `${encodedPath}.${levels}` : levels
|
|
|
|
/*
|
|
`publishStartDate` when the author gave one and the creation date otherwise, which is the one
|
|
date a blog orders by and shows. Computed in SQL so the order it produces is the order the rows
|
|
arrive in, and `MAX_POSTS` therefore cuts the far end of the blog rather than an arbitrary set.
|
|
|
|
`mapWith` and not a bare `sql<Date>`: that generic is an assertion and nothing more, so without
|
|
a mapper the driver hands back postgres's own `2026-08-22 09:00:00` and every caller downstream
|
|
gets a string where the column beside it is a `Date`. Mapped through `updatedAt`, which is the
|
|
same `timestamp` type, so both dates on a post are the same kind of value.
|
|
*/
|
|
const publishedAt =
|
|
sql<Date>`coalesce(${pagesTable.publishStartDate}, ${pagesTable.createdAt})`.mapWith(
|
|
pagesTable.updatedAt
|
|
)
|
|
const conditions: (SQL | undefined)[] = [
|
|
eq(treeTable.siteId, siteId),
|
|
eq(treeTable.locale, blog.locale),
|
|
eq(treeTable.type, 'page'),
|
|
sql`${treeTable.folderPath} ~ ${pathQuery}::lquery`
|
|
]
|
|
if (publicOnly) {
|
|
conditions.push(eq(pagesTable.publishState, 'published'))
|
|
}
|
|
|
|
const rows = await WIKI.db
|
|
.select({
|
|
id: treeTable.id,
|
|
folderPath: treeTable.folderPath,
|
|
fileName: treeTable.fileName,
|
|
title: treeTable.title,
|
|
tags: treeTable.tags,
|
|
description: pagesTable.description,
|
|
icon: pagesTable.icon,
|
|
editor: pagesTable.editor,
|
|
updatedAt: pagesTable.updatedAt,
|
|
authorId: pagesTable.authorId,
|
|
authorName: usersTable.name,
|
|
publishedAt
|
|
})
|
|
.from(treeTable)
|
|
.innerJoin(pagesTable, eq(pagesTable.id, treeTable.id))
|
|
.leftJoin(usersTable, eq(usersTable.id, pagesTable.authorId))
|
|
.where(and(...conditions))
|
|
.orderBy(blog.settings.sort === 'oldest' ? sql`${publishedAt} asc` : sql`${publishedAt} desc`)
|
|
.limit(MAX_POSTS + 1)
|
|
|
|
const truncated = rows.length > MAX_POSTS
|
|
const candidates: Candidate[] = rows.slice(0, MAX_POSTS).map((row) => {
|
|
const folderPath = decodeTreePath(row.folderPath ?? '') ?? ''
|
|
return {
|
|
id: row.id,
|
|
path: folderPath ? `${folderPath}/${row.fileName}` : row.fileName,
|
|
title: row.title,
|
|
description: row.description ?? '',
|
|
icon: row.icon ?? '',
|
|
tags: row.tags ?? [],
|
|
editor: row.editor,
|
|
publishedAt: row.publishedAt,
|
|
updatedAt: row.updatedAt,
|
|
authorId: row.authorId,
|
|
authorName: row.authorName ?? ''
|
|
}
|
|
})
|
|
|
|
/*
|
|
Where this blog stops. A blog nested inside this one owns everything below it, so the outer
|
|
listing ends at its front page — otherwise the same post appears in two blogs, dated and
|
|
attributed by two sets of settings that need not agree.
|
|
*/
|
|
const nestedRoots = candidates
|
|
.filter((row) => row.editor === 'blog')
|
|
.map((row) => `${row.path}/`)
|
|
|
|
const posts = candidates.filter((row) => {
|
|
if (isBodylessEditor(row.editor)) {
|
|
return false
|
|
}
|
|
if (nestedRoots.some((root) => row.path.startsWith(root))) {
|
|
return false
|
|
}
|
|
if (!actor) {
|
|
return true
|
|
}
|
|
return WIKI.models.groups.checkAccess(actor, 'read:pages', {
|
|
siteId,
|
|
path: row.path,
|
|
locale: blog.locale,
|
|
tags: row.tags
|
|
})
|
|
})
|
|
|
|
// -> Which editor wrote a post is how it was filtered, not something a listing shows
|
|
return {
|
|
posts: posts.map((post) => ({
|
|
id: post.id,
|
|
path: post.path,
|
|
title: post.title,
|
|
description: post.description,
|
|
icon: post.icon,
|
|
tags: post.tags,
|
|
publishedAt: post.publishedAt,
|
|
updatedAt: post.updatedAt,
|
|
authorId: post.authorId,
|
|
authorName: post.authorName
|
|
})),
|
|
truncated
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Count what the sidebar offers to narrow a listing by.
|
|
*
|
|
* Over the posts as they were handed in, which is to say over the posts this reader may open: a
|
|
* count is a statement about how many pages exist, and one that included pages they were never
|
|
* shown would be answering a question they did not ask about content they cannot reach.
|
|
*/
|
|
facetsFor(posts: BlogPost[]): BlogFacets {
|
|
const tagCounts = new Map<string, number>()
|
|
const monthCounts = new Map<string, number>()
|
|
for (const post of posts) {
|
|
for (const tag of post.tags) {
|
|
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1)
|
|
}
|
|
const at = new Date(post.publishedAt)
|
|
// -> UTC, because the stored instant is: a blog's archive must not put a post in a different
|
|
// month for a reader in a different place, and the server's own zone is nobody's
|
|
const key = `${at.getUTCFullYear()}-${at.getUTCMonth() + 1}`
|
|
monthCounts.set(key, (monthCounts.get(key) ?? 0) + 1)
|
|
}
|
|
return {
|
|
tags: [...tagCounts.entries()]
|
|
.map(([tag, count]) => ({ tag, count }))
|
|
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)),
|
|
archive: [...monthCounts.entries()]
|
|
.map(([key, count]) => {
|
|
const [year, month] = key.split('-')
|
|
return { year: Number(year), month: Number(month), count }
|
|
})
|
|
.sort((a, b) => b.year - a.year || b.month - a.month)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One page of a blog's listing, with the facets beside it.
|
|
*
|
|
* The whole answer to one request: the posts to draw, where the reader is in the blog, and what
|
|
* the sidebar should offer. Read once and narrowed in memory — see `MAX_POSTS` for why the filters
|
|
* are not a `WHERE` clause.
|
|
*/
|
|
async listing({
|
|
siteId,
|
|
blog,
|
|
actor,
|
|
publicOnly = true,
|
|
filter = {}
|
|
}: {
|
|
siteId: string
|
|
blog: BlogRef
|
|
actor?: AccessActor
|
|
publicOnly?: boolean
|
|
filter?: BlogFilter
|
|
}): Promise<BlogListing> {
|
|
const { posts: readable, truncated } = await this.postsFor({ siteId, blog, actor, publicOnly })
|
|
const facets = this.facetsFor(readable)
|
|
|
|
const matching = readable.filter((post) => {
|
|
if (filter.tag && !post.tags.includes(filter.tag)) {
|
|
return false
|
|
}
|
|
if (filter.year) {
|
|
const at = new Date(post.publishedAt)
|
|
if (at.getUTCFullYear() !== filter.year) {
|
|
return false
|
|
}
|
|
if (filter.month && at.getUTCMonth() + 1 !== filter.month) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
|
|
const perPage = blog.settings.perPage
|
|
const pageCount = Math.max(1, Math.ceil(matching.length / perPage))
|
|
// -> Clamped rather than answered empty: a `?p=99` typed into the bar, or left over from a filter
|
|
// that used to have more pages, should land on the last page of the blog and not on nothing
|
|
const page = Math.min(Math.max(1, Math.trunc(filter.page ?? 1)), pageCount)
|
|
|
|
return {
|
|
posts: matching.slice((page - 1) * perPage, page * perPage),
|
|
total: matching.length,
|
|
page,
|
|
pageCount,
|
|
facets,
|
|
truncated
|
|
}
|
|
}
|
|
}
|
|
|
|
export const blogs = new Blogs()
|