mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 22:01:58 -05:00
feat: tags page + various fixes
This commit is contained in:
15 files changed
+1924
-75
No files matched your search
+17
-2
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}`
|
||||
|
||||
Reference in new issue
Block a user