Pārlūkot izejas kodu

feat: admin search

NGPixel 1 gadu atpakaļ
vecāks
revīzija
cbbc10dadb

+ 2 - 2
server/core/db.mjs

@@ -116,8 +116,8 @@ export default {
     const dbVersion = semver.coerce(resVersion.rows[0].server_version, { loose: true })
     const dbVersion = semver.coerce(resVersion.rows[0].server_version, { loose: true })
     this.VERSION = dbVersion.version
     this.VERSION = dbVersion.version
     this.LEGACY = dbVersion.major < 16
     this.LEGACY = dbVersion.major < 16
-    if (dbVersion.major < 11) {
-      WIKI.logger.error('Your PostgreSQL database version is too old and unsupported by Wiki.js. Exiting...')
+    if (dbVersion.major < 12) {
+      WIKI.logger.error(`Your PostgreSQL database version (${dbVersion.major}) is too old and unsupported by Wiki.js. Requires >= 12. Exiting...`)
       process.exit(1)
       process.exit(1)
     }
     }
     WIKI.logger.info(`PostgreSQL ${dbVersion.version} [ ${this.LEGACY ? 'LEGACY MODE' : 'OK'} ]`)
     WIKI.logger.info(`PostgreSQL ${dbVersion.version} [ ${this.LEGACY ? 'LEGACY MODE' : 'OK'} ]`)

+ 8 - 0
server/db/migrations/3.0.0.mjs

@@ -228,6 +228,7 @@ export async function up (knex) {
       table.jsonb('relations').notNullable().defaultTo('[]')
       table.jsonb('relations').notNullable().defaultTo('[]')
       table.text('content')
       table.text('content')
       table.text('render')
       table.text('render')
+      table.specificType('ts', 'tsvector').index('ts_idx', { indexType: 'GIN' })
       table.jsonb('toc')
       table.jsonb('toc')
       table.string('editor').notNullable()
       table.string('editor').notNullable()
       table.string('contentType').notNullable()
       table.string('contentType').notNullable()
@@ -488,6 +489,13 @@ export async function up (knex) {
         dkimPrivateKey: ''
         dkimPrivateKey: ''
       }
       }
     },
     },
+    {
+      key: 'search',
+      value: {
+        termHighlighting: true,
+        dictOverrides: []
+      }
+    },
     {
     {
       key: 'security',
       key: 'security',
       value: {
       value: {

+ 11 - 0
server/graph/resolvers/system.mjs

@@ -76,6 +76,9 @@ export default {
         { column: 'waitUntil', order: 'asc', nulls: 'first' },
         { column: 'waitUntil', order: 'asc', nulls: 'first' },
         { column: 'createdAt', order: 'asc' }
         { column: 'createdAt', order: 'asc' }
       ])
       ])
+    },
+    systemSearch () {
+      return WIKI.config.search
     }
     }
   },
   },
   Mutation: {
   Mutation: {
@@ -179,6 +182,14 @@ export default {
         operation: generateSuccess('System Flags applied successfully')
         operation: generateSuccess('System Flags applied successfully')
       }
       }
     },
     },
+    async updateSystemSearch (obj, args, context) {
+      WIKI.config.search = _.defaultsDeep(_.omit(args, ['__typename']), WIKI.config.search)
+      // TODO: broadcast config update
+      await WIKI.configSvc.saveToDb(['search'])
+      return {
+        operation: generateSuccess('System Search configuration applied successfully')
+      }
+    },
     async updateSystemSecurity (obj, args, context) {
     async updateSystemSecurity (obj, args, context) {
       WIKI.config.security = _.defaultsDeep(_.omit(args, ['__typename']), WIKI.config.security)
       WIKI.config.security = _.defaultsDeep(_.omit(args, ['__typename']), WIKI.config.security)
       // TODO: broadcast config update
       // TODO: broadcast config update

+ 11 - 0
server/graph/schemas/system.graphql

@@ -13,6 +13,7 @@ extend type Query {
   ): [SystemJob]
   ): [SystemJob]
   systemJobsScheduled: [SystemJobScheduled]
   systemJobsScheduled: [SystemJobScheduled]
   systemJobsUpcoming: [SystemJobUpcoming]
   systemJobsUpcoming: [SystemJobUpcoming]
+  systemSearch: SystemSearch
 }
 }
 
 
 extend type Mutation {
 extend type Mutation {
@@ -32,6 +33,11 @@ extend type Mutation {
     id: UUID!
     id: UUID!
   ): DefaultResponse
   ): DefaultResponse
 
 
+  updateSystemSearch(
+    termHighlighting: Boolean
+    dictOverrides: String
+  ): DefaultResponse
+
   updateSystemFlags(
   updateSystemFlags(
     flags: JSON!
     flags: JSON!
   ): DefaultResponse
   ): DefaultResponse
@@ -213,3 +219,8 @@ type SystemCheckUpdateResponse {
   latest: String
   latest: String
   latestDate: String
   latestDate: String
 }
 }
+
+type SystemSearch {
+  termHighlighting: Boolean
+  dictOverrides: String
+}

+ 5 - 0
server/locales/en.json

@@ -524,11 +524,16 @@
   "admin.scheduler.useWorker": "Execution Mode",
   "admin.scheduler.useWorker": "Execution Mode",
   "admin.scheduler.waitUntil": "Start",
   "admin.scheduler.waitUntil": "Start",
   "admin.search.configSaveSuccess": "Search engine configuration saved successfully.",
   "admin.search.configSaveSuccess": "Search engine configuration saved successfully.",
+  "admin.search.dictOverrides": "PostgreSQL Dictionary Mapping Overrides",
+  "admin.search.dictOverridesHint": "One override per line, in the format: en=english",
   "admin.search.engineConfig": "Engine Configuration",
   "admin.search.engineConfig": "Engine Configuration",
   "admin.search.engineNoConfig": "This engine has no configuration options you can modify.",
   "admin.search.engineNoConfig": "This engine has no configuration options you can modify.",
+  "admin.search.highlighting": "Enable Term Highlighting",
+  "admin.search.highlightingHint": "Whether to show the highlighted terms in search results. There is a slight performance impact when enabled.",
   "admin.search.indexRebuildSuccess": "Index rebuilt successfully.",
   "admin.search.indexRebuildSuccess": "Index rebuilt successfully.",
   "admin.search.listRefreshSuccess": "List of search engines has been refreshed.",
   "admin.search.listRefreshSuccess": "List of search engines has been refreshed.",
   "admin.search.rebuildIndex": "Rebuild Index",
   "admin.search.rebuildIndex": "Rebuild Index",
+  "admin.search.saveSuccess": "Search engine configuration saved successfully",
   "admin.search.searchEngine": "Search Engine",
   "admin.search.searchEngine": "Search Engine",
   "admin.search.subtitle": "Configure the search capabilities of your wiki",
   "admin.search.subtitle": "Configure the search capabilities of your wiki",
   "admin.search.title": "Search Engine",
   "admin.search.title": "Search Engine",

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
ux/public/_assets/icons/fluent-find-and-replace.svg


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
ux/public/_assets/illustrations/undraw_file_searching.svg


+ 12 - 2
ux/src/components/HeaderNav.vue

@@ -43,7 +43,14 @@ q-header.bg-header.text-white.site-header(
         @blur='state.searchKbdShortcutShown = true'
         @blur='state.searchKbdShortcutShown = true'
         )
         )
         template(v-slot:prepend)
         template(v-slot:prepend)
-          q-icon(name='las la-search')
+          q-circular-progress.q-mr-xs(
+            v-if='siteStore.searchIsLoading'
+            indeterminate
+            rounded
+            color='primary'
+            size='20px'
+            )
+          q-icon(v-else, name='las la-search')
         template(v-slot:append)
         template(v-slot:append)
           q-badge.q-mr-sm(
           q-badge.q-mr-sm(
             v-if='state.searchKbdShortcutShown'
             v-if='state.searchKbdShortcutShown'
@@ -176,7 +183,10 @@ function handleKeyPress (ev) {
 }
 }
 
 
 function onSearchEnter () {
 function onSearchEnter () {
-  if (!route.path.startsWith('/_search')) {
+  if (route.path === '/_search') {
+    router.replace({ path: '/_search', query: { q: siteStore.search } })
+  } else {
+    siteStore.searchIsLoading = true
     router.push({ path: '/_search', query: { q: siteStore.search } })
     router.push({ path: '/_search', query: { q: siteStore.search } })
   }
   }
 }
 }

+ 4 - 0
ux/src/layouts/AdminLayout.vue

@@ -187,6 +187,10 @@ q-layout.admin(view='hHh Lpr lff')
             q-item-section {{ t('admin.scheduler.title') }}
             q-item-section {{ t('admin.scheduler.title') }}
             q-item-section(side)
             q-item-section(side)
               status-light(:color='adminStore.info.isSchedulerHealthy ? `positive` : `warning`', :pulse='!adminStore.info.isSchedulerHealthy')
               status-light(:color='adminStore.info.isSchedulerHealthy ? `positive` : `warning`', :pulse='!adminStore.info.isSchedulerHealthy')
+          q-item(to='/_admin/search', v-ripple, active-class='bg-primary text-white')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-find-and-replace.svg')
+            q-item-section {{ t('admin.search.title') }}
           q-item(to='/_admin/security', v-ripple, active-class='bg-primary text-white')
           q-item(to='/_admin/security', v-ripple, active-class='bg-primary text-white')
             q-item-section(avatar)
             q-item-section(avatar)
               q-icon(name='img:/_assets/icons/fluent-protect.svg')
               q-icon(name='img:/_assets/icons/fluent-protect.svg')

+ 193 - 0
ux/src/pages/AdminSearch.vue

@@ -0,0 +1,193 @@
+<template lang='pug'>
+q-page.admin-flags
+  .row.q-pa-md.items-center
+    .col-auto
+      img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-find-and-replace.svg')
+    .col.q-pl-md
+      .text-h5.text-primary.animated.fadeInLeft {{ t('admin.search.title') }}
+      .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ t('admin.search.subtitle') }}
+    .col-auto
+      q-btn.q-mr-sm.acrylic-btn(
+        icon='las la-question-circle'
+        flat
+        color='grey'
+        :aria-label='t(`common.actions.viewDocs`)'
+        :href='siteStore.docsBase + `/system/search`'
+        target='_blank'
+        type='a'
+        )
+        q-tooltip {{ t(`common.actions.viewDocs`) }}
+      q-btn.q-mr-sm.acrylic-btn(
+        icon='las la-redo-alt'
+        flat
+        color='secondary'
+        :loading='state.loading > 0'
+        :aria-label='t(`common.actions.refresh`)'
+        @click='load'
+        )
+        q-tooltip {{ t(`common.actions.refresh`) }}
+      q-btn(
+        unelevated
+        icon='mdi-check'
+        :label='t(`common.actions.apply`)'
+        color='secondary'
+        @click='save'
+        :loading='state.loading > 0'
+      )
+  q-separator(inset)
+  .row.q-pa-md.q-col-gutter-md
+    .col-12.col-lg-7
+      q-card.q-py-sm
+        q-item(tag='label')
+          blueprint-icon(icon='search')
+          q-item-section
+            q-item-label {{t(`admin.search.highlighting`)}}
+            q-item-label(caption) {{t(`admin.search.highlightingHint`)}}
+          q-item-section(avatar)
+            q-toggle(
+              v-model='state.config.termHighlighting'
+              color='primary'
+              checked-icon='las la-check'
+              unchecked-icon='las la-times'
+              :aria-label='t(`admin.search.highlighting`)'
+              )
+        q-separator.q-my-sm(inset)
+        q-item
+          blueprint-icon.self-start(icon='search')
+          q-item-section
+            q-item-label {{t(`admin.search.dictOverrides`)}}
+            q-input.q-mt-sm(
+              type='textarea'
+              v-model='state.config.dictOverrides'
+              outlined
+              :aria-label='t(`admin.search.dictOverrides`)'
+              :hint='t(`admin.search.dictOverridesHint`)'
+              input-style='min-height: 200px;'
+              )
+
+    .col-12.col-lg-5.gt-md
+      .q-pa-md.text-center
+        img(src='/_assets/illustrations/undraw_file_searching.svg', style='width: 80%;')
+</template>
+
+<script setup>
+import gql from 'graphql-tag'
+import { onMounted, reactive, ref } from 'vue'
+import { cloneDeep, omit } from 'lodash-es'
+import { useMeta, useQuasar } from 'quasar'
+import { useI18n } from 'vue-i18n'
+
+import { useSiteStore } from 'src/stores/site'
+import { useFlagsStore } from 'src/stores/flags'
+
+// QUASAR
+
+const $q = useQuasar()
+
+// STORES
+
+const flagsStore = useFlagsStore()
+const siteStore = useSiteStore()
+
+// I18N
+
+const { t } = useI18n()
+
+// META
+
+useMeta({
+  title: t('admin.flags.title')
+})
+
+// DATA
+
+const state = reactive({
+  loading: 0,
+  config: {
+    termHighlighting: false,
+    dictOverrides: ''
+  }
+})
+
+// METHODS
+
+async function load () {
+  state.loading++
+  $q.loading.show()
+  try {
+    const resp = await APOLLO_CLIENT.query({
+      query: gql`
+        query getSearchConfig {
+          systemSearch {
+            termHighlighting
+            dictOverrides
+          }
+        }
+      `,
+      fetchPolicy: 'network-only'
+    })
+    state.config = cloneDeep(resp?.data?.systemSearch)
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: 'Failed to load search config',
+      caption: err.message
+    })
+  }
+  $q.loading.hide()
+  state.loading--
+}
+
+async function save () {
+  state.loading++
+  try {
+    const respRaw = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation saveSearchConfig (
+          $termHighlighting: Boolean
+          $dictOverrides: String
+        ) {
+          updateSystemSearch(
+            termHighlighting: $termHighlighting
+            dictOverrides: $dictOverrides
+          ) {
+            operation {
+              succeeded
+              slug
+              message
+            }
+          }
+        }
+      `,
+      variables: state.config
+    })
+    const resp = respRaw?.data?.updateSystemSearch?.operation || {}
+    if (resp.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('admin.search.saveSuccess')
+      })
+    } else {
+      throw new Error(resp.message)
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: 'Failed to save search config',
+      caption: err.message
+    })
+  }
+  state.loading--
+}
+
+// MOUNTED
+
+onMounted(async () => {
+  load()
+})
+
+</script>
+
+<style lang='scss'>
+
+</style>

+ 11 - 3
ux/src/pages/Search.vue

@@ -94,9 +94,9 @@ q-layout(view='hHh Lpr lff')
         .text-header.flex
         .text-header.flex
           span {{t('search.results')}}
           span {{t('search.results')}}
           q-space
           q-space
-          span.text-caption #[strong 12] results
+          span.text-caption #[strong {{ state.items }}] results
         q-list(separator, padding)
         q-list(separator, padding)
-          q-item(v-for='item of 12', clickable)
+          q-item(v-for='item of state.items', clickable)
             q-item-section(avatar)
             q-item-section(avatar)
               q-avatar(color='primary' text-color='white' rounded icon='las la-file-alt')
               q-avatar(color='primary' text-color='white' rounded icon='las la-file-alt')
             q-item-section
             q-item-section
@@ -169,7 +169,8 @@ const state = reactive({
   filterTags: [],
   filterTags: [],
   filterLocale: ['en'],
   filterLocale: ['en'],
   filterEditor: '',
   filterEditor: '',
-  filterPublishState: ''
+  filterPublishState: '',
+  items: 25
 })
 })
 
 
 const editors = computed(() => {
 const editors = computed(() => {
@@ -205,6 +206,13 @@ function pageStyle (offset, height) {
     'min-height': `${height - 100 - offset}px`
     'min-height': `${height - 100 - offset}px`
   }
   }
 }
 }
+
+// MOUNTED
+
+onMounted(() => {
+  siteStore.searchIsLoading = false
+})
+
 </script>
 </script>
 
 
 <style lang="scss">
 <style lang="scss">

+ 1 - 0
ux/src/router/routes.js

@@ -63,6 +63,7 @@ const routes = [
       { path: 'mail', component: () => import('pages/AdminMail.vue') },
       { path: 'mail', component: () => import('pages/AdminMail.vue') },
       { path: 'rendering', component: () => import('pages/AdminRendering.vue') },
       { path: 'rendering', component: () => import('pages/AdminRendering.vue') },
       { path: 'scheduler', component: () => import('pages/AdminScheduler.vue') },
       { path: 'scheduler', component: () => import('pages/AdminScheduler.vue') },
+      { path: 'search', component: () => import('pages/AdminSearch.vue') },
       { path: 'security', component: () => import('pages/AdminSecurity.vue') },
       { path: 'security', component: () => import('pages/AdminSecurity.vue') },
       { path: 'system', component: () => import('pages/AdminSystem.vue') },
       { path: 'system', component: () => import('pages/AdminSystem.vue') },
       { path: 'terminal', component: () => import('pages/AdminTerminal.vue') },
       { path: 'terminal', component: () => import('pages/AdminTerminal.vue') },

+ 0 - 3
ux/src/stores/site.js

@@ -16,10 +16,7 @@ export const useSiteStore = defineStore('site', {
     description: '',
     description: '',
     logoText: true,
     logoText: true,
     search: '',
     search: '',
-    searchIsFocused: false,
     searchIsLoading: false,
     searchIsLoading: false,
-    searchRestrictLocale: false,
-    searchRestrictPath: false,
     printView: false,
     printView: false,
     pageDataTemplates: [],
     pageDataTemplates: [],
     showSideNav: true,
     showSideNav: true,

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels