feat: tags page + various fixes

This commit is contained in:
NGPixel committed 2026-09-12 02:03:11 -04:00
1 parent 48835a2701
commit b6bcee8a3d
15 files changed
+1924 -75

No files matched your search

+17 -2
View File
@@ -1,7 +1,12 @@
import { validate as uuidValidate } from 'uuid'
import type { FastifyInstance, FastifyRequest } from 'fastify'
import type { PageActor, PageInput } from '../models/pages.ts'
import { SEARCH_ORDER_BY, type SearchOrderBy } from '../models/search.ts'
import {
SEARCH_ORDER_BY,
SEARCH_TAGS_MATCH,
type SearchOrderBy,
type SearchTagsMatch
} from '../models/search.ts'
import { audit } from '../helpers/audit.ts'
import { generatePathHash, normalizePagePath } from '../helpers/common.ts'
import { limitAuthAttempts, limitRenders } from '../helpers/rateLimit.ts'
@@ -292,6 +297,7 @@ async function routes(app: FastifyInstance) {
path?: string
locales?: string
tags?: string
tagsMatch?: SearchTagsMatch
editor?: string
publishState?: string
creatorId?: string
@@ -331,7 +337,15 @@ async function routes(app: FastifyInstance) {
tags: {
type: 'string',
maxLength: 2048,
description: 'Comma-separated tags a page must carry all of.'
description:
"Comma-separated tags to match against a page's own, as `tagsMatch` says."
},
tagsMatch: {
type: 'string',
enum: SEARCH_TAGS_MATCH,
default: 'all',
description:
'Whether a page must carry `all` of the tags given (the default) or `any` one of them.'
},
editor: {
type: 'string',
@@ -419,6 +433,7 @@ async function routes(app: FastifyInstance) {
path: req.query.path,
locales: splitList(req.query.locales),
tags: splitList(req.query.tags),
tagsMatch: req.query.tagsMatch,
editor: req.query.editor,
publishState: req.query.publishState,
creatorId: req.query.creatorId,
+17
View File
@@ -2587,23 +2587,40 @@
"search.sortByRelevance": "Relevance",
"search.sortByTitle": "Title",
"search.totalResults": "No result | {0} result | {0} results",
"tags.allLocales": "All locales",
"tags.allTags": "All Tags",
"tags.clearSelection": "Clear Selection",
"tags.currentSelection": "Current Selection",
"tags.loadFailed": "Failed to load the list of tags.",
"tags.loadMore": "Load More",
"tags.locale": "Locale",
"tags.localeAny": "Any",
"tags.matchAll": "AND",
"tags.matchAllHint": "Pages carrying every selected tag",
"tags.matchAny": "OR",
"tags.matchAnyHint": "Pages carrying any selected tag",
"tags.matchMode": "Match tags",
"tags.noResults": "Couldn't find any page with the selected tags.",
"tags.noResultsWithFilter": "Couldn't find any page matching the current filtering options.",
"tags.noTags": "No tags have been used on this site yet.",
"tags.orderBy": "Order By",
"tags.orderByField.ID": "ID",
"tags.orderByField.creationDate": "Creation Date",
"tags.orderByField.lastModified": "Last Modified",
"tags.orderByField.path": "Path",
"tags.orderByField.title": "Title",
"tags.orderDirectionAscending": "Ascending",
"tags.orderDirectionDescending": "Descending",
"tags.pageLastUpdated": "Last Updated {date}",
"tags.retrievingResultsLoading": "Retrieving page results...",
"tags.searchFailed": "Failed to load the pages for the selected tags.",
"tags.searchWithinResultsPlaceholder": "Search within results...",
"tags.selectOneMoreTags": "Select one or more tags",
"tags.selectOneMoreTagsHint": "Select one or more tags on the left.",
"tags.sortAlphabetical": "List tags alphabetically",
"tags.sortPopularity": "List tags by popularity",
"tags.sortTags": "Sort tags",
"tags.title": "Tags",
"userProfile.lastLogin": "Last Login",
"userProfile.loadMore": "Load More",
"userProfile.loadingFailed": "Failed to load user profile.",
+35 -2
View File
@@ -54,7 +54,25 @@ export interface RebuildResult {
locales: { locale: string; dictionary: string; pages: number }[]
}
export const SEARCH_ORDER_BY = ['relevancy', 'title', 'createdAt', 'updatedAt'] as const
/**
* How a list of tags is matched against a page's own.
*
* `all` is the narrowing sense a filter usually has — each tag added takes pages away — and is what
* a tag filter alongside a text query means. `any` is the widening one, which is what browsing by
* tag wants: two tags picked off a list are two things the reader is interested in, not a demand
* that one page be both.
*/
export const SEARCH_TAGS_MATCH = ['all', 'any'] as const
export type SearchTagsMatch = (typeof SEARCH_TAGS_MATCH)[number]
export const SEARCH_ORDER_BY = [
'relevancy',
'id',
'path',
'title',
'createdAt',
'updatedAt'
] as const
export type SearchOrderBy = (typeof SEARCH_ORDER_BY)[number]
export interface SearchResult {
@@ -82,6 +100,8 @@ export interface SearchPagesParams {
path?: string
locales?: string[]
tags?: string[]
/** Whether a page must carry every tag in `tags` (the default) or merely one of them. */
tagsMatch?: SearchTagsMatch
editor?: string
publishState?: string
/**
@@ -229,6 +249,7 @@ class Search {
path = '',
locales = [],
tags = [],
tagsMatch = 'all',
editor = '',
publishState = '',
creatorId = '',
@@ -300,7 +321,12 @@ class Search {
conditions.push(sql`p.locale = ANY(${sql.param(locales)}::text[])`)
}
if (tags.length > 0) {
conditions.push(sql`p.tags @> ${sql.param(tags)}::text[]`)
// -> `@>` is contains-all, `&&` is overlaps; both are indexable the same way
conditions.push(
tagsMatch === 'any'
? sql`p.tags && ${sql.param(tags)}::text[]`
: sql`p.tags @> ${sql.param(tags)}::text[]`
)
}
if (editor) {
conditions.push(sql`p.editor = ${editor}`)
@@ -317,6 +343,13 @@ class Search {
const effectiveOrderBy = orderBy === 'relevancy' && !hasQuery ? 'updatedAt' : orderBy
const ordering = {
relevancy: sql`relevancy ${direction}, p."updatedAt" DESC`,
id: sql`p.id ${direction}`,
/*
A path is only unique within a locale, so two translations of the same page would otherwise
come back in whatever order the planner chose -- and swap places between two requests for the
same list. The locale settles it.
*/
path: sql`p.path ${direction}, p.locale ASC`,
title: sql`p.title ${direction}`,
createdAt: sql`p."createdAt" ${direction}`,
updatedAt: sql`p."updatedAt" ${direction}`