From 5c6965b544aab266b2c6acebe8650a709c2a2b42 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Wed, 12 Jul 2023 23:50:50 +0000 Subject: [PATCH] feat: tags search + search improvements --- server/db/migrations/3.0.0.mjs | 5 +- server/graph/resolvers/page.mjs | 85 +++++------ server/graph/schemas/page.graphql | 21 ++- server/locales/en.json | 1 + server/models/pages.mjs | 28 +--- server/models/tags.mjs | 74 +++------- ux/src/components/HeaderNav.vue | 100 +------------ ux/src/components/HeaderSearch.vue | 224 +++++++++++++++++++++++++++++ ux/src/components/PageHeader.vue | 35 ++--- ux/src/components/PageTags.vue | 96 ++++++++++--- ux/src/pages/Search.vue | 115 +++++++++++++-- ux/src/stores/page.js | 7 +- ux/src/stores/site.js | 31 ++++ 13 files changed, 519 insertions(+), 303 deletions(-) create mode 100644 ux/src/components/HeaderSearch.vue diff --git a/server/db/migrations/3.0.0.mjs b/server/db/migrations/3.0.0.mjs index 9a1d43cd..32fb4efc 100644 --- a/server/db/migrations/3.0.0.mjs +++ b/server/db/migrations/3.0.0.mjs @@ -231,7 +231,7 @@ export async function up (knex) { table.text('render') table.text('searchContent') table.specificType('ts', 'tsvector').index('ts_idx', { indexType: 'GIN' }) - table.specificType('tags', 'int[]').index('tags_idx', { indexType: 'GIN' }) + table.specificType('tags', 'text[]').index('tags_idx', { indexType: 'GIN' }) table.jsonb('toc') table.string('editor').notNullable() table.string('contentType').notNullable() @@ -282,6 +282,7 @@ export async function up (knex) { .createTable('tags', table => { table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()')) table.string('tag').notNullable() + table.integer('usageCount').notNullable().defaultTo(0) table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now()) table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now()) }) @@ -783,7 +784,7 @@ export async function up (knex) { }, { task: 'refreshAutocomplete', - cron: '0 */3 * * *', + cron: '0 */6 * * *', type: 'system' }, { diff --git a/server/graph/resolvers/page.mjs b/server/graph/resolvers/page.mjs index 174c1758..8ab68e23 100644 --- a/server/graph/resolvers/page.mjs +++ b/server/graph/resolvers/page.mjs @@ -1,6 +1,10 @@ import _ from 'lodash-es' import { generateError, generateSuccess } from '../../helpers/graph.mjs' import { parsePath }from '../../helpers/page.mjs' +import tsquery from 'pg-tsquery' + +const tsq = tsquery() +const tagsInQueryRgx = /#[a-z0-9-\u3400-\u4DBF\u4E00-\u9FFF]+(?=(?:[^"]*(?:")[^"]*(?:"))*[^"]*$)/g export default { Query: { @@ -43,20 +47,24 @@ export default { * SEARCH PAGES */ async searchPages (obj, args, context) { + const q = args.query.trim() + const hasQuery = q.length > 0 + + // -> Validate parameters if (!args.siteId) { throw new Error('Missing Site ID') } - if (!args.query?.trim()) { - throw new Error('Missing Query') - } if (args.offset && args.offset < 0) { throw new Error('Invalid offset value.') } if (args.limit && (args.limit < 1 || args.limit > 100)) { throw new Error('Limit must be between 1 and 100.') } + try { const dictName = 'english' // TODO: Use provided locale or fallback on site locale + + // -> Select Columns const searchCols = [ 'id', 'path', @@ -64,18 +72,26 @@ export default { 'title', 'description', 'icon', + 'tags', 'updatedAt', - WIKI.db.knex.raw('ts_rank_cd(ts, query) AS relevancy'), WIKI.db.knex.raw('count(*) OVER() AS total') ] - if (WIKI.config.search.termHighlighting) { + // -> Set relevancy + if (hasQuery) { + searchCols.push(WIKI.db.knex.raw('ts_rank_cd(ts, query) AS relevancy')) + } else { + args.orderBy = args.orderBy === 'relevancy' ? 'title' : args.orderBy + } + + // -> Add Highlighting if enabled + if (WIKI.config.search.termHighlighting && hasQuery) { searchCols.push(WIKI.db.knex.raw(`ts_headline(?, "searchContent", query, 'MaxWords=5, MinWords=3, MaxFragments=5') AS highlight`, [dictName])) } const results = await WIKI.db.knex .select(searchCols) - .fromRaw('pages, websearch_to_tsquery(?, ?) query', [dictName, args.query]) + .fromRaw(hasQuery ? 'pages, to_tsquery(?, ?) query' : 'pages', hasQuery ? [dictName, tsq(q)] : []) .where('siteId', args.siteId) .where('isSearchableComputed', true) .where(builder => { @@ -91,14 +107,19 @@ export default { if (args.publishState) { builder.where('publishState', args.publishState) } + if (args.tags) { + builder.where('tags', '@>', args.tags) + } + if (hasQuery) { + builder.whereRaw('query @@ ts') + } }) - .whereRaw('query @@ ts') .orderBy(args.orderBy || 'relevancy', args.orderByDirection || 'desc') .offset(args.offset || 0) .limit(args.limit || 25) // -> Remove highlights without matches - if (WIKI.config.search.termHighlighting) { + if (WIKI.config.search.termHighlighting && hasQuery) { for (const r of results) { if (r.highlight?.indexOf('') < 0) { r.highlight = null @@ -268,50 +289,10 @@ export default { * FETCH TAGS */ async tags (obj, args, context, info) { - const pages = await WIKI.db.pages.query() - .column([ - 'path', - { locale: 'localeCode' } - ]) - .withGraphJoined('tags') - const allTags = _.filter(pages, r => { - return WIKI.auth.checkAccess(context.req.user, ['read:pages'], { - path: r.path, - locale: r.locale - }) - }).flatMap(r => r.tags) - return _.orderBy(_.uniqBy(allTags, 'id'), ['tag'], ['asc']) - }, - /** - * SEARCH TAGS - */ - async searchTags (obj, args, context, info) { - const query = _.trim(args.query) - const pages = await WIKI.db.pages.query() - .column([ - 'path', - { locale: 'localeCode' } - ]) - .withGraphJoined('tags') - .modifyGraph('tags', builder => { - builder.select('tag') - }) - .modify(queryBuilder => { - queryBuilder.andWhere(builderSub => { - if (WIKI.config.db.type === 'postgres') { - builderSub.where('tags.tag', 'ILIKE', `%${query}%`) - } else { - builderSub.where('tags.tag', 'LIKE', `%${query}%`) - } - }) - }) - const allTags = _.filter(pages, r => { - return WIKI.auth.checkAccess(context.req.user, ['read:pages'], { - path: r.path, - locale: r.locale - }) - }).flatMap(r => r.tags).map(t => t.tag) - return _.uniq(allTags).slice(0, 5) + if (!args.siteId) { throw new Error('Missing Site ID')} + const tags = await WIKI.db.knex('tags').where('siteId', args.siteId).orderBy('tag') + // TODO: check permissions + return tags }, /** * FETCH PAGE TREE diff --git a/server/graph/schemas/page.graphql b/server/graph/schemas/page.graphql index 1befeda7..42c486e2 100644 --- a/server/graph/schemas/page.graphql +++ b/server/graph/schemas/page.graphql @@ -108,20 +108,18 @@ extend type Query { alias: String! ): PageAliasPath - searchTags( - query: String! - ): [String]! - - tags: [PageTag]! + tags( + siteId: UUID! + ): [PageTag] checkConflicts( id: Int! checkoutDate: Date! - ): Boolean! + ): Boolean checkConflictsLatest( id: Int! - ): PageConflictLatest! + ): PageConflictLatest } extend type Mutation { @@ -253,7 +251,7 @@ type Page { showTags: Boolean showToc: Boolean siteId: UUID - tags: [PageTag] + tags: [String] title: String toc: [JSON] tocDepth: PageTocDepth @@ -261,9 +259,10 @@ type Page { } type PageTag { - id: Int + id: UUID tag: String - title: String + usageCount: Int + siteId: UUID createdAt: Date updatedAt: Date } @@ -401,9 +400,7 @@ input PageUpdateInput { icon: String isBrowsable: Boolean isSearchable: Boolean - locale: String password: String - path: String publishEndDate: Date publishStartDate: Date publishState: PagePublishState diff --git a/server/locales/en.json b/server/locales/en.json index ace207c5..1ae4e08f 100644 --- a/server/locales/en.json +++ b/server/locales/en.json @@ -1204,6 +1204,7 @@ "common.actions.fetch": "Fetch", "common.actions.filter": "Filter", "common.actions.generate": "Generate", + "common.actions.goback": "Go Back", "common.actions.howItWorks": "How it works", "common.actions.insert": "Insert", "common.actions.login": "Login", diff --git a/server/models/pages.mjs b/server/models/pages.mjs index d298ebe2..44ba2daf 100644 --- a/server/models/pages.mjs +++ b/server/models/pages.mjs @@ -320,7 +320,7 @@ export class Page extends Model { // -> Get Tags let tags = [] if (opts.tags && opts.tags.length > 0) { - tags = await WIKI.db.tags.fetchIds({ tags: opts.tags, siteId: opts.siteId }) + tags = await WIKI.db.tags.processNewTags(opts.tags, opts.siteId) } // -> Create page @@ -635,6 +635,7 @@ export class Page extends Model { // -> Tags if ('tags' in opts.patch) { + patch.tags = await WIKI.db.tags.processNewTags(opts.patch.tags, ogPage.siteId) historyData.affectedFields.push('tags') } @@ -646,11 +647,6 @@ export class Page extends Model { }).where('id', ogPage.id) let page = await WIKI.db.pages.getPageFromDb(ogPage.id) - // -> Save Tags - if (opts.patch.tags) { - // await WIKI.db.tags.associateTags({ tags: opts.patch.tags, page }) - } - // -> Render page to HTML if (opts.patch.content) { await WIKI.db.pages.renderPage(page) @@ -687,24 +683,6 @@ export class Page extends Model { // }) // } - // -> Perform move? - if ((opts.locale && opts.locale !== page.localeCode) || (opts.path && opts.path !== page.path)) { - // -> Check target path access - if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], { - locale: opts.locale, - path: opts.path - })) { - throw new WIKI.Error.PageMoveForbidden() - } - - await WIKI.db.pages.movePage({ - id: page.id, - destinationLocale: opts.locale, - destinationPath: opts.path, - user: opts.user - }) - } - // -> Get latest updatedAt page.updatedAt = await WIKI.db.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt) @@ -745,7 +723,7 @@ export class Page extends Model { await WIKI.db.knex.raw(` INSERT INTO "autocomplete" (word) SELECT word FROM ts_stat( - 'SELECT to_tsvector(''simple'', "title") || to_tsvector(''simple'', "description") || to_tsvector(''simple'', "searchContent") FROM "pages" WHERE isSearchableComputed IS TRUE' + 'SELECT to_tsvector(''simple'', "title") || to_tsvector(''simple'', "description") || to_tsvector(''simple'', "searchContent") FROM "pages" WHERE "isSearchableComputed" IS TRUE' ) `) } diff --git a/server/models/tags.mjs b/server/models/tags.mjs index a92c570e..089adfac 100644 --- a/server/models/tags.mjs +++ b/server/models/tags.mjs @@ -1,7 +1,7 @@ import { Model } from 'objection' -import { concat, differenceBy, some, uniq } from 'lodash-es' +import { difference, some, uniq } from 'lodash-es' -import { Page } from './pages.mjs' +const allowedCharsRgx = /^[a-z0-9-\u3400-\u4DBF\u4E00-\u9FFF]+$/ /** * Tags model @@ -15,7 +15,7 @@ export class Tag extends Model { required: ['tag'], properties: { - id: {type: 'integer'}, + id: {type: 'string'}, tag: {type: 'string'}, createdAt: {type: 'string'}, @@ -24,23 +24,6 @@ export class Tag extends Model { } } - static get relationMappings() { - return { - pages: { - relation: Model.ManyToManyRelation, - modelClass: Page, - join: { - from: 'tags.id', - through: { - from: 'pageTags.tagId', - to: 'pageTags.pageId' - }, - to: 'pages.id' - } - } - } - } - $beforeUpdate() { this.updatedAt = new Date().toISOString() } @@ -49,53 +32,28 @@ export class Tag extends Model { this.updatedAt = new Date().toISOString() } - static async associateTags ({ tags, page }) { - let existingTags = await WIKI.db.tags.query().column('id', 'tag') - - // Format tags + static async processNewTags (tags, siteId) { + // Validate tags - tags = uniq(tags.map(t => t.trim().toLowerCase())) - - // Create missing tags + const normalizedTags = uniq(tags.map(t => t.trim().toLowerCase().replaceAll('#', '')).filter(t => t)) - const newTags = tags.filter(t => !some(existingTags, ['tag', t])).map(t => ({ tag: t })) - if (newTags.length > 0) { - if (WIKI.config.db.type === 'postgres') { - const createdTags = await WIKI.db.tags.query().insert(newTags) - existingTags = concat(existingTags, createdTags) - } else { - for (const newTag of newTags) { - const createdTag = await WIKI.db.tags.query().insert(newTag) - existingTags.push(createdTag) - } + for (const tag of normalizedTags) { + if (!allowedCharsRgx.test(tag)) { + throw new Error(`Tag #${tag} has invalid characters. Must consists of letters (no diacritics), numbers, CJK logograms and dashes only.`) } } - // Fetch current page tags - - const targetTags = existingTags.filter(t => tags.includes(t.tag)) - const currentTags = await page.$relatedQuery('tags') + // Fetch existing tags - // Tags to relate + const existingTags = await WIKI.db.knex('tags').column('tag').where('siteId', siteId).pluck('tag') - const tagsToRelate = differenceBy(targetTags, currentTags, 'id') - if (tagsToRelate.length > 0) { - if (WIKI.config.db.type === 'postgres') { - await page.$relatedQuery('tags').relate(tagsToRelate) - } else { - for (const tag of tagsToRelate) { - await page.$relatedQuery('tags').relate(tag) - } - } - } - - // Tags to unrelate + // Create missing tags - const tagsToUnrelate = differenceBy(currentTags, targetTags, 'id') - if (tagsToUnrelate.length > 0) { - await page.$relatedQuery('tags').unrelate().whereIn('tags.id', tagsToUnrelate.map(t => t.id)) + const newTags = difference(normalizedTags, existingTags).map(t => ({ tag: t, usageCount: 1, siteId })) + if (newTags.length > 0) { + await WIKI.db.tags.query().insert(newTags) } - page.tags = targetTags + return normalizedTags } } diff --git a/ux/src/components/HeaderNav.vue b/ux/src/components/HeaderNav.vue index a1837872..024697c7 100644 --- a/ux/src/components/HeaderNav.vue +++ b/ux/src/components/HeaderNav.vue @@ -24,56 +24,7 @@ q-header.bg-header.text-white.site-header( style='height: 34px' ) q-toolbar-title.text-h6(v-if='siteStore.logoText') {{siteStore.title}} - q-toolbar.gt-sm( - style='height: 64px;' - dark - v-if='siteStore.features.search' - ) - q-input( - dark - v-model='siteStore.search' - standout='bg-white text-dark' - dense - rounded - ref='searchField' - style='width: 100%;' - label='Search...' - @keyup.enter='onSearchEnter' - @focus='state.searchKbdShortcutShown = false' - @blur='state.searchKbdShortcutShown = true' - ) - template(v-slot:prepend) - q-circular-progress.q-mr-xs( - v-if='siteStore.searchIsLoading && route.path !== `/_search`' - instant-feedback - indeterminate - rounded - color='primary' - size='20px' - ) - q-icon(v-else, name='las la-search') - template(v-slot:append) - q-badge.q-mr-sm( - v-if='state.searchKbdShortcutShown' - label='Ctrl+K' - color='grey-7' - outline - @click='searchField.focus()' - ) - q-badge.q-mr-sm( - v-else-if='siteStore.search && siteStore.search !== siteStore.searchLastQuery' - label='Press Enter' - color='grey-7' - outline - @click='searchField.focus()' - ) - q-icon.cursor-pointer( - name='las la-times' - size='20px' - @click='siteStore.search=``' - v-if='siteStore.search.length > 0' - color='grey-6' - ) + header-search q-toolbar( style='height: 64px;' dark @@ -138,7 +89,7 @@ q-header.bg-header.text-white.site-header( - - diff --git a/ux/src/components/HeaderSearch.vue b/ux/src/components/HeaderSearch.vue new file mode 100644 index 00000000..01fd7d1b --- /dev/null +++ b/ux/src/components/HeaderSearch.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/ux/src/components/PageHeader.vue b/ux/src/components/PageHeader.vue index 586b2264..760d96ff 100644 --- a/ux/src/components/PageHeader.vue +++ b/ux/src/components/PageHeader.vue @@ -164,16 +164,17 @@ @click.exact='saveChanges(false)' @click.ctrl.exact='saveChanges(true)' ) - q-separator(vertical, dark) - q-btn.acrylic-btn( - flat - icon='las la-check-double' - color='positive' - :aria-label='t(`common.actions.saveAndClose`)' - :disabled='!editorStore.hasPendingChanges' - @click='saveChanges(true)' - ) - q-tooltip {{ t(`common.actions.saveAndClose`) }} + template(v-if='editorStore.isActive') + q-separator(vertical, dark) + q-btn.acrylic-btn( + flat + icon='las la-check-double' + color='positive' + :aria-label='t(`common.actions.saveAndClose`)' + :disabled='!editorStore.hasPendingChanges' + @click='saveChanges(true)' + ) + q-tooltip {{ t(`common.actions.saveAndClose`) }} template(v-else-if='userStore.can(`edit:pages`)') q-btn.acrylic-btn.q-ml-md( flat @@ -222,20 +223,6 @@ const route = useRoute() const { t } = useI18n() -// COMPUTED - -const editMode = computed(() => { - return pageStore.mode === 'edit' -}) -const editCreateMode = computed(() => { - return pageStore.mode === 'edit' && pageStore.mode === 'create' -}) -const editUrl = computed(() => { - let pagePath = siteStore.useLocales ? `${pageStore.locale}/` : '' - pagePath += !pageStore.path ? 'home' : pageStore.path - return `/_edit/${pagePath}` -}) - // METHODS function openEditorSettings () { diff --git a/ux/src/components/PageTags.vue b/ux/src/components/PageTags.vue index 733d1adf..88838d8a 100644 --- a/ux/src/components/PageTags.vue +++ b/ux/src/components/PageTags.vue @@ -12,32 +12,44 @@ v-for='tag of pageStore.tags' :key='`tag-` + tag' ) - q-icon.q-mr-xs(name='las la-tag', size='14px') + q-icon.q-mr-xs(name='las la-hashtag', size='14px') span.text-caption {{tag}} - q-chip( - v-if='!props.edit && pageStore.tags.length > 1' - square - color='secondary' - text-color='white' - dense - clickable - ) - q-icon(name='las la-tags', size='14px') - q-input.q-mt-md( + q-select.q-mt-md( v-if='props.edit' outlined - v-model='state.newTag' + v-model='pageStore.tags' + :options='state.filteredTags' dense - placeholder='Add new tag...' - ) + options-dense + use-input + use-chips + multiple + hide-selected + hide-dropdown-icon + :input-debounce='0' + new-value-mode='add-unique' + @new-value='createTag' + @filter='filterTags' + placeholder='Select or create tags...' + :loading='state.loading' + ) + template(v-slot:option='scope') + q-item(v-bind='scope.itemProps') + q-item-section(side) + q-checkbox(:model-value='scope.selected', @update:model-value='scope.toggleOption(scope.opt)', size='sm') + q-item-section + q-item-label(v-html='scope.opt')