diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index e041a8dce9..61a1809a1c 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -18881,7 +18881,7 @@ packages: dev: false file:projects/middleware.tgz(@types/node@16.11.68)(esbuild@0.16.17)(ts-node@10.9.1): - resolution: {integrity: sha512-TT4uLU1UvoXXmVb3OHUhCemx34agLPzeNhwbHmAyPXREt7phuCSwYy2TkRJl/9F0QryvgBR+C2n99jqSqSAaXg==, tarball: file:projects/middleware.tgz} + resolution: {integrity: sha512-gSuvnAq/78PHhS/K116OxI+McFyaXSly7OUaNxgGbYIXtwPJlNHLPqFXxGjATRzBr4M3dplOeEs+4odQGn8AhQ==, tarball: file:projects/middleware.tgz} id: file:projects/middleware.tgz name: '@rush-temp/middleware' version: 0.0.0 @@ -18896,6 +18896,7 @@ packages: eslint-plugin-promise: 6.1.1(eslint@8.51.0) fast-equals: 2.0.4 jest: 29.7.0(@types/node@16.11.68)(ts-node@10.9.1) + just-clone: 6.2.0 prettier: 2.8.8 ts-jest: 29.1.1(esbuild@0.16.17)(jest@29.7.0)(typescript@5.2.2) typescript: 5.2.2 diff --git a/dev/client-resources/src/connection.ts b/dev/client-resources/src/connection.ts index de550f7a30..a5e33c04e9 100644 --- a/dev/client-resources/src/connection.ts +++ b/dev/client-resources/src/connection.ts @@ -31,7 +31,10 @@ import core, { Timestamp, Tx, TxHandler, - TxResult + TxResult, + SearchQuery, + SearchOptions, + SearchResult } from '@hcengineering/core' import { createInMemoryTxAdapter } from '@hcengineering/dev-storage' import devmodel from '@hcengineering/devmodel' @@ -59,6 +62,10 @@ class ServerStorageWrapper implements ClientConnection { return this.storage.findAll(this.measureCtx, c, q, o) } + async searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return { docs: [] } + } + async loadModel (lastModelTx: Timestamp): Promise { return await this.storage.findAll(this.measureCtx, core.class.Tx, { space: core.space.Model, diff --git a/models/contact/src/index.ts b/models/contact/src/index.ts index fc94f2ed7e..34d35b4191 100644 --- a/models/contact/src/index.ts +++ b/models/contact/src/index.ts @@ -48,6 +48,7 @@ import { TypeRef, TypeString, TypeTimestamp, + TypeAttachment, UX } from '@hcengineering/model' import attachment from '@hcengineering/model-attachment' @@ -92,7 +93,10 @@ export class TContact extends TDoc implements Contact { @Index(IndexKind.FullText) name!: string - avatar?: string | null + @Prop(TypeAttachment(), contact.string.Avatar) + @Index(IndexKind.FullText) + @Hidden() + avatar?: string | null @Prop(Collection(contact.class.Channel), contact.string.ContactInfo) channels?: number @@ -678,7 +682,8 @@ export function createModel (builder: Builder): void { }) builder.mixin(contact.class.Contact, core.class.Class, view.mixin.ClassFilters, { - filters: [] + filters: [], + ignoreKeys: ['avatar'] }) builder.mixin(contact.class.Person, core.class.Class, view.mixin.ClassFilters, { @@ -713,7 +718,9 @@ export function createModel (builder: Builder): void { { icon: contact.icon.Person, label: contact.string.SearchEmployee, - query: contact.completion.EmployeeQuery + title: contact.string.Employees, + query: contact.completion.EmployeeQuery, + context: ['search'] }, contact.completion.EmployeeCategory ) @@ -724,7 +731,10 @@ export function createModel (builder: Builder): void { { icon: contact.icon.Persona, label: contact.string.SearchPerson, - query: contact.completion.PersonQuery + title: contact.string.People, + query: contact.completion.PersonQuery, + context: ['search', 'mention'], + classToSearch: contact.class.Person }, contact.completion.PersonCategory ) @@ -735,7 +745,10 @@ export function createModel (builder: Builder): void { { icon: contact.icon.Company, label: contact.string.SearchOrganization, - query: contact.completion.OrganizationQuery + title: contact.string.Organizations, + query: contact.completion.OrganizationQuery, + context: ['search', 'mention'], + classToSearch: contact.class.Organization }, contact.completion.OrganizationCategory ) diff --git a/models/contact/src/plugin.ts b/models/contact/src/plugin.ts index d6347ee47b..93706cb293 100644 --- a/models/contact/src/plugin.ts +++ b/models/contact/src/plugin.ts @@ -98,7 +98,9 @@ export default mergeIds(contactId, contact, { CurrentEmployee: '' as IntlString, ConfigLabel: '' as IntlString, - ConfigDescription: '' as IntlString + ConfigDescription: '' as IntlString, + Employees: '' as IntlString, + People: '' as IntlString }, completion: { PersonQuery: '' as Resource, diff --git a/models/presentation/src/index.ts b/models/presentation/src/index.ts index 31bc88b9fd..139b78efef 100644 --- a/models/presentation/src/index.ts +++ b/models/presentation/src/index.ts @@ -26,6 +26,7 @@ import { DocRules, DocCreateExtension, DocCreateFunction, + ObjectSearchContext, ObjectSearchCategory, ObjectSearchFactory } from '@hcengineering/presentation/src/types' @@ -40,9 +41,12 @@ export { CreateExtensionKind, DocCreateExtension, DocCreateFunction, ObjectSearc export class TObjectSearchCategory extends TDoc implements ObjectSearchCategory { label!: IntlString icon!: Asset + title!: IntlString + context!: ObjectSearchContext[] // Query for documents with pattern query!: Resource + classToSearch!: Ref> } @Model(presentation.class.PresentationMiddlewareFactory, core.class.Doc, DOMAIN_MODEL) diff --git a/models/recruit/src/index.ts b/models/recruit/src/index.ts index 4a4fa156a7..a051fca2a9 100644 --- a/models/recruit/src/index.ts +++ b/models/recruit/src/index.ts @@ -1198,7 +1198,10 @@ export function createModel (builder: Builder): void { { icon: recruit.icon.Application, label: recruit.string.SearchApplication, - query: recruit.completion.ApplicationQuery + title: recruit.string.Applications, + query: recruit.completion.ApplicationQuery, + context: ['search', 'mention'], + classToSearch: recruit.class.Applicant }, recruit.completion.ApplicationCategory ) @@ -1209,7 +1212,10 @@ export function createModel (builder: Builder): void { { icon: recruit.icon.Vacancy, label: recruit.string.SearchVacancy, - query: recruit.completion.VacancyQuery + title: recruit.string.Vacancies, + query: recruit.completion.VacancyQuery, + context: ['search', 'mention'], + classToSearch: recruit.class.Vacancy }, recruit.completion.VacancyCategory ) diff --git a/models/server-contact/src/index.ts b/models/server-contact/src/index.ts index 43bd02ea71..b952e83ed2 100644 --- a/models/server-contact/src/index.ts +++ b/models/server-contact/src/index.ts @@ -40,6 +40,17 @@ export function createModel (builder: Builder): void { presenter: serverContact.function.OrganizationTextPresenter }) + builder.mixin(contact.class.Contact, core.class.Class, serverCore.mixin.SearchPresenter, { + searchConfig: { + iconConfig: { + component: contact.component.Avatar, + props: ['avatar', 'name'] + }, + title: { props: ['name'] } + }, + getSearchTitle: serverContact.function.ContactNameProvider + }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverContact.trigger.OnContactDelete, txMatch: { diff --git a/models/server-core/src/index.ts b/models/server-core/src/index.ts index 4b460f2bf7..462725e1bc 100644 --- a/models/server-core/src/index.ts +++ b/models/server-core/src/index.ts @@ -14,7 +14,7 @@ // limitations under the License. // -import { Builder, Model } from '@hcengineering/model' +import { Builder, Model, Mixin } from '@hcengineering/model' import { TClass, TDoc } from '@hcengineering/model-core' import type { Resource } from '@hcengineering/platform' @@ -28,7 +28,14 @@ import core, { Hierarchy, Ref } from '@hcengineering/core' -import type { ObjectDDParticipant, Trigger, TriggerFunc } from '@hcengineering/server-core' +import type { + ObjectDDParticipant, + Trigger, + TriggerFunc, + SearchPresenter, + SearchPresenterFunc, + ClassSearchConfig +} from '@hcengineering/server-core' import serverCore from '@hcengineering/server-core' export { serverCoreId } from '@hcengineering/server-core' @@ -53,6 +60,13 @@ export class TObjectDDParticipant extends TClass implements ObjectDDParticipant > } -export function createModel (builder: Builder): void { - builder.createModel(TTrigger, TObjectDDParticipant) +@Mixin(serverCore.mixin.SearchPresenter, core.class.Class) +export class TSearchPresenter extends TClass implements SearchPresenter { + searchConfig!: ClassSearchConfig + getSearchObjectId!: Resource + getSearchTitle!: Resource +} + +export function createModel (builder: Builder): void { + builder.createModel(TTrigger, TObjectDDParticipant, TSearchPresenter) } diff --git a/models/server-recruit/package.json b/models/server-recruit/package.json index 07f95483db..1ad8f84fef 100644 --- a/models/server-recruit/package.json +++ b/models/server-recruit/package.json @@ -28,6 +28,8 @@ "@hcengineering/model": "^0.6.6", "@hcengineering/platform": "^0.6.9", "@hcengineering/server-recruit": "^0.6.0", + "@hcengineering/server-contact": "^0.6.1", + "@hcengineering/contact": "^0.6.19", "@hcengineering/server-core": "^0.6.1", "@hcengineering/model-recruit": "^0.6.0", "@hcengineering/notification": "^0.6.15", diff --git a/models/server-recruit/src/index.ts b/models/server-recruit/src/index.ts index f4a64b1f3e..5b62cc9e93 100644 --- a/models/server-recruit/src/index.ts +++ b/models/server-recruit/src/index.ts @@ -21,6 +21,8 @@ import notification from '@hcengineering/notification' import serverCore from '@hcengineering/server-core' import serverNotification from '@hcengineering/server-notification' import serverRecruit from '@hcengineering/server-recruit' +import serverContact from '@hcengineering/server-contact' +import contact from '@hcengineering/contact' export { serverRecruitId } from '@hcengineering/server-recruit' @@ -45,6 +47,30 @@ export function createModel (builder: Builder): void { trigger: serverRecruit.trigger.OnRecruitUpdate }) + builder.mixin(recruit.class.Vacancy, core.class.Class, serverCore.mixin.SearchPresenter, { + searchConfig: { + icon: recruit.icon.Vacancy, + title: 'name' + } + }) + + builder.mixin(recruit.class.Applicant, core.class.Class, serverCore.mixin.SearchPresenter, { + searchConfig: { + iconConfig: { + component: contact.component.Avatar, + props: [{ avatar: ['attachedTo', 'avatar'] }, { name: ['attachedTo', 'name'] }] + }, + shortTitle: { + tmpl: 'APP-{number}', + props: ['number'] + }, + title: { + props: [{ _class: ['attachedTo', '_class'] }, { name: ['attachedTo', 'name'] }] + } + }, + getSearchTitle: serverContact.function.ContactNameProvider + }) + builder.mixin( recruit.ids.AssigneeNotification, notification.class.NotificationType, diff --git a/models/server-tracker/src/index.ts b/models/server-tracker/src/index.ts index ce7464fa2c..5965b5cb3b 100644 --- a/models/server-tracker/src/index.ts +++ b/models/server-tracker/src/index.ts @@ -36,6 +36,20 @@ export function createModel (builder: Builder): void { presenter: serverTracker.function.IssueNotificationContentProvider }) + builder.mixin(tracker.class.Issue, core.class.Class, serverCore.mixin.SearchPresenter, { + searchConfig: { + iconConfig: { + component: tracker.component.IssueSearchIcon, + props: ['status', 'space'] + }, + shortTitle: { + tmpl: '{identifier}-{number}', + props: [{ identifier: ['space', 'identifier'] }, 'number'] + }, + title: 'title' + } + }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverTracker.trigger.OnIssueUpdate }) diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index 916d63fe21..737d6207b5 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -505,7 +505,10 @@ export function createModel (builder: Builder): void { { icon: tracker.icon.TrackerApplication, label: tracker.string.SearchIssue, - query: tracker.completion.IssueQuery + title: tracker.string.Issues, + query: tracker.completion.IssueQuery, + context: ['search', 'mention'], + classToSearch: tracker.class.Issue }, tracker.completion.IssueCategory ) diff --git a/models/tracker/src/plugin.ts b/models/tracker/src/plugin.ts index 5a0514327e..de9b4f4bcc 100644 --- a/models/tracker/src/plugin.ts +++ b/models/tracker/src/plugin.ts @@ -57,7 +57,8 @@ export default mergeIds(trackerId, tracker, { NotificationIssuePresenter: '' as AnyComponent, MilestoneFilter: '' as AnyComponent, EditRelatedTargets: '' as AnyComponent, - EditRelatedTargetsPopup: '' as AnyComponent + EditRelatedTargetsPopup: '' as AnyComponent, + IssueSearchIcon: '' as AnyComponent }, app: { Tracker: '' as Ref diff --git a/packages/core/src/__tests__/client.test.ts b/packages/core/src/__tests__/client.test.ts index 80cbf41626..aa8b363c88 100644 --- a/packages/core/src/__tests__/client.test.ts +++ b/packages/core/src/__tests__/client.test.ts @@ -21,7 +21,7 @@ import core from '../component' import { Hierarchy } from '../hierarchy' import { ModelDb, TxDb } from '../memdb' import { TxOperations } from '../operations' -import type { DocumentQuery, FindResult, TxResult } from '../storage' +import type { DocumentQuery, FindResult, TxResult, SearchQuery, SearchOptions, SearchResult } from '../storage' import { Tx, TxFactory, TxProcessor } from '../tx' import { connect } from './connection' import { genMinModel } from './minmodel' @@ -93,6 +93,11 @@ describe('client', () => { return { findAll, + + searchFulltext: async (query: SearchQuery, options: SearchOptions): Promise => { + return { docs: [] } + }, + tx: async (tx: Tx): Promise => { if (tx.objectSpace === core.space.Model) { hierarchy.tx(tx) diff --git a/packages/core/src/__tests__/connection.ts b/packages/core/src/__tests__/connection.ts index cd74509974..64d6f41c6a 100644 --- a/packages/core/src/__tests__/connection.ts +++ b/packages/core/src/__tests__/connection.ts @@ -18,7 +18,7 @@ import { ClientConnection } from '../client' import core from '../component' import { Hierarchy } from '../hierarchy' import { ModelDb, TxDb } from '../memdb' -import type { DocumentQuery, FindResult, TxResult } from '../storage' +import type { DocumentQuery, FindResult, TxResult, SearchQuery, SearchOptions, SearchResult } from '../storage' import type { Tx } from '../tx' import { DOMAIN_TX } from '../tx' import { genMinModel } from './minmodel' @@ -44,6 +44,11 @@ export async function connect (handler: (tx: Tx) => void): Promise => { + return { docs: [] } + }, + tx: async (tx: Tx): Promise => { if (tx.objectSpace === core.space.Model) { hierarchy.tx(tx) diff --git a/packages/core/src/__tests__/memdb.test.ts b/packages/core/src/__tests__/memdb.test.ts index f518944c6e..0bdf71cff0 100644 --- a/packages/core/src/__tests__/memdb.test.ts +++ b/packages/core/src/__tests__/memdb.test.ts @@ -19,7 +19,15 @@ import core from '../component' import { Hierarchy } from '../hierarchy' import { ModelDb, TxDb } from '../memdb' import { TxOperations } from '../operations' -import { DocumentQuery, FindOptions, SortingOrder, WithLookup } from '../storage' +import { + DocumentQuery, + FindOptions, + SortingOrder, + WithLookup, + SearchQuery, + SearchOptions, + SearchResult +} from '../storage' import { Tx } from '../tx' import { genMinModel, test, TestMixin } from './minmodel' @@ -44,6 +52,10 @@ class ClientModel extends ModelDb implements Client { return (await this.findAll(_class, query, options)).shift() } + async searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return { docs: [] } + } + async close (): Promise {} } diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index d042881ca2..78d4c4a30e 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -19,8 +19,8 @@ import { Account, AttachedDoc, Class, DOMAIN_MODEL, Doc, Domain, PluginConfigura import core from './component' import { Hierarchy } from './hierarchy' import { ModelDb } from './memdb' -import type { DocumentQuery, FindOptions, FindResult, Storage, TxResult, WithLookup } from './storage' -import { SortingOrder } from './storage' +import type { DocumentQuery, FindOptions, FindResult, Storage, FulltextStorage, TxResult, WithLookup } from './storage' +import { SortingOrder, SearchQuery, SearchOptions, SearchResult } from './storage' import { Tx, TxCUD, TxCollectionCUD, TxCreateDoc, TxProcessor, TxUpdateDoc } from './tx' import { toFindResult } from './utils' @@ -34,7 +34,7 @@ export type TxHandler = (tx: Tx) => void /** * @public */ -export interface Client extends Storage { +export interface Client extends Storage, FulltextStorage { notify?: (tx: Tx) => void getHierarchy: () => Hierarchy getModel: () => ModelDb @@ -79,7 +79,7 @@ export enum ClientConnectEvent { /** * @public */ -export interface ClientConnection extends Storage, BackupClient { +export interface ClientConnection extends Storage, FulltextStorage, BackupClient { close: () => Promise onConnect?: (event: ClientConnectEvent) => Promise @@ -127,6 +127,10 @@ class ClientImpl implements AccountClient, BackupClient { return toFindResult(result, data.total) } + async searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return await this.conn.searchFulltext(query, options) + } + async findOne( _class: Ref>, query: DocumentQuery, diff --git a/packages/core/src/operations.ts b/packages/core/src/operations.ts index 1f4a33ca66..544b3e0aa1 100644 --- a/packages/core/src/operations.ts +++ b/packages/core/src/operations.ts @@ -15,7 +15,16 @@ import type { } from './classes' import { Client } from './client' import core from './component' -import type { DocumentQuery, FindOptions, FindResult, TxResult, WithLookup } from './storage' +import type { + DocumentQuery, + FindOptions, + FindResult, + SearchQuery, + SearchOptions, + SearchResult, + TxResult, + WithLookup +} from './storage' import { DocumentClassQuery, Tx, TxCUD, TxFactory, TxProcessor } from './tx' /** @@ -60,6 +69,10 @@ export class TxOperations implements Omit { return this.client.findOne(_class, query, options) } + searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return this.client.searchFulltext(query, options) + } + tx (tx: Tx): Promise { return this.client.tx(tx) } @@ -421,6 +434,7 @@ export class ApplyOperations extends TxOperations { close: () => ops.client.close(), findOne: (_class, query, options?) => ops.client.findOne(_class, query, options), findAll: (_class, query, options?) => ops.client.findAll(_class, query, options), + searchFulltext: (query, options) => ops.client.searchFulltext(query, options), tx: async (tx): Promise => { if (ops.getHierarchy().isDerived(tx._class, core.class.TxCUD)) { this.txes.push(tx as TxCUD) @@ -474,6 +488,7 @@ export class TxBuilder extends TxOperations { close: async () => {}, findOne: async (_class, query, options?) => undefined, findAll: async (_class, query, options?) => toFindResult([]), + searchFulltext: async (query, options) => ({ docs: [] }), tx: async (tx): Promise => { if (this.hierarchy.isDerived(tx._class, core.class.TxCUD)) { this.txes.push(tx as TxCUD) diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 31d4a35cf2..1e04f31abd 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -17,7 +17,15 @@ import { MeasureContext } from './measurements' import type { Doc, Class, Ref, Domain, Timestamp } from './classes' import { Hierarchy } from './hierarchy' import { ModelDb } from './memdb' -import type { DocumentQuery, FindOptions, FindResult, TxResult } from './storage' +import type { + DocumentQuery, + FindOptions, + FindResult, + TxResult, + SearchQuery, + SearchOptions, + SearchResult +} from './storage' import type { Tx } from './tx' import { LoadModelResponse } from '.' @@ -66,6 +74,7 @@ export interface ServerStorage extends LowLevelStorage { query: DocumentQuery, options?: FindOptions ) => Promise> + searchFulltext: (ctx: MeasureContext, query: SearchQuery, options: SearchOptions) => Promise tx: (ctx: MeasureContext, tx: Tx) => Promise<[TxResult, Tx[]]> apply: (ctx: MeasureContext, tx: Tx[], broadcast: boolean) => Promise close: () => Promise diff --git a/packages/core/src/storage.ts b/packages/core/src/storage.ts index 6d82366976..b6eacc37a4 100644 --- a/packages/core/src/storage.ts +++ b/packages/core/src/storage.ts @@ -13,8 +13,10 @@ // limitations under the License. // +import type { Asset } from '@hcengineering/platform' + import type { KeysByType } from 'simplytyped' -import type { AttachedDoc, Class, Doc, Ref } from './classes' +import type { AttachedDoc, Class, Doc, Ref, Space } from './classes' import type { Tx } from './tx' /** @@ -208,6 +210,43 @@ export type FindResult = WithLookup[] & { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface TxResult {} +/** + * @public + */ +export interface SearchQuery { + query: string + classes?: Ref>[] + spaces?: Ref[] +} + +/** + * @public + */ +export interface SearchOptions { + limit?: number +} + +/** + * @public + */ +export interface SearchResultDoc { + id: Ref + icon?: Asset + iconComponent?: string + iconProps?: { [key: string]: string } + shortTitle?: string + title?: string + doc: Pick +} + +/** + * @public + */ +export interface SearchResult { + docs: SearchResultDoc[] + total?: number +} + /** * @public */ @@ -217,5 +256,13 @@ export interface Storage { query: DocumentQuery, options?: FindOptions ) => Promise> + tx: (tx: Tx) => Promise } + +/** + * @public + */ +export interface FulltextStorage { + searchFulltext: (query: SearchQuery, options: SearchOptions) => Promise +} diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 56fe59acc4..617ac33a77 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -16,8 +16,8 @@ import { Account, AnyAttribute, Class, Doc, DocData, DocIndexState, IndexKind, Obj, Ref, Space } from './classes' import core from './component' import { Hierarchy } from './hierarchy' -import { DocumentQuery, FindResult } from './storage' import { isPredicate } from './predicate' +import { FindResult, DocumentQuery } from './storage' function toHex (value: number, chars: number): string { const result = value.toString(16) @@ -116,6 +116,8 @@ export interface IndexKeyOptions { _class?: Ref> docId?: Ref extra?: string[] + relative?: boolean + refAttribute?: string } /** * @public @@ -129,10 +131,16 @@ export function docUpdKey (name: string, opt?: IndexKeyOptions): string { */ export function docKey (name: string, opt?: IndexKeyOptions): string { const extra = opt?.extra !== undefined && opt?.extra?.length > 0 ? `#${opt.extra?.join('#') ?? ''}` : '' - return ( + let key = (opt?.docId !== undefined ? opt.docId.split('.').join('_') + '|' : '') + (opt?._class === undefined ? name : `${opt?._class}%${name}${extra}`) - ) + if (opt?.refAttribute !== undefined) { + key = `${opt?.refAttribute}->${key}` + } + if (opt?.refAttribute !== undefined || (opt?.relative !== undefined && opt?.relative)) { + key = '|' + key + } + return key } /** diff --git a/packages/presentation/lang/en.json b/packages/presentation/lang/en.json index f29c5ba87e..550a0d93b3 100644 --- a/packages/presentation/lang/en.json +++ b/packages/presentation/lang/en.json @@ -27,6 +27,7 @@ "DocumentPreview": "Preview", "MakePrivate": "Make private", "MakePrivateDescription": "Only members can see it", - "Created": "Created" + "Created": "Created", + "NoResults": "No results to show" } } diff --git a/packages/presentation/lang/ru.json b/packages/presentation/lang/ru.json index b320e739d6..ab991496ce 100644 --- a/packages/presentation/lang/ru.json +++ b/packages/presentation/lang/ru.json @@ -27,6 +27,7 @@ "DocumentPreview": "Предпросмотр", "MakePrivate": "Сделать личным", "MakePrivateDescription": "Только пользователи могут видеть это", - "Created": "Созданные" + "Created": "Созданные", + "NoResults": "Нет результатов" } } diff --git a/packages/presentation/src/components/ObjectSearchPopup.svelte b/packages/presentation/src/components/ObjectSearchPopup.svelte index 9e39a726f7..014dc58bc5 100644 --- a/packages/presentation/src/components/ObjectSearchPopup.svelte +++ b/packages/presentation/src/components/ObjectSearchPopup.svelte @@ -14,7 +14,7 @@ // limitations under the License. --> + + + +
dispatch('changeSize')}> +
+
+ {#if items.length === 0 && query !== ''} +
+ {/if} + + + + {@const item = items[num]} + {#if item.num === 0} +
+
+ {/if} +
+ + {@const item = items[num]} + {@const doc = item.item} +
dispatchItem(doc)}> + +
+
+
+
+
+
+ + + diff --git a/packages/text-editor/src/components/MentionResult.svelte b/packages/text-editor/src/components/MentionResult.svelte new file mode 100644 index 0000000000..7c9f12c336 --- /dev/null +++ b/packages/text-editor/src/components/MentionResult.svelte @@ -0,0 +1,61 @@ + + + +
+
+ {#if icon !== undefined} + + {/if} + {#if iconComponent} + {#await getResource(iconComponent) then component} + + {/await} + {/if} +
+ + {#if value.shortTitle !== undefined} + {value.shortTitle} + {/if} + {value.title} + +
+ + diff --git a/packages/text-editor/src/components/extension/suggestion.ts b/packages/text-editor/src/components/extension/suggestion.ts new file mode 100644 index 0000000000..dd7d78c5b0 --- /dev/null +++ b/packages/text-editor/src/components/extension/suggestion.ts @@ -0,0 +1,430 @@ +import { Editor, Range, escapeForRegEx } from '@tiptap/core' +import { EditorState, Plugin, PluginKey, Transaction } from '@tiptap/pm/state' +import { ReplaceStep } from '@tiptap/pm/transform' +import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view' + +import { ResolvedPos } from '@tiptap/pm/model' + +export interface Trigger { + char: string + allowSpaces: boolean + allowedPrefixes: string[] | null + startOfLine: boolean + $position: ResolvedPos +} + +export type SuggestionMatch = { + range: Range + query: string + text: string +} | null + +function hasChar (tr: Transaction, char = ''): boolean { + let isHardStop = false + let isChar = false + for (const step of tr.steps) { + if (step instanceof ReplaceStep) { + const slice = step.slice + + slice.content.descendants((node, _pos): boolean => { + if (isHardStop) { + return false + } + + if (node.type.isText && node.text !== undefined && node.text !== '') { + if (char !== '') { + if (node.text?.includes(char)) { + isChar = true + isHardStop = true + return false + } + } else { + isChar = true + isHardStop = true + return false + } + } + return true + }) + } + } + + return isChar +} + +export function findSuggestionMatch (config: Trigger): SuggestionMatch { + const { char, allowSpaces, allowedPrefixes, startOfLine, $position } = config + + const escapedChar = escapeForRegEx(char) + + const suffix = new RegExp(`\\s${escapedChar}$`) + const prefix = startOfLine ? '^' : '' + + // If allowSpaces: true terminates on at least 2 whitespaces + const regexp = allowSpaces + ? new RegExp(`${prefix}${escapedChar}.*?(?=\\s{2}|$)`, 'gm') + : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\s${escapedChar}]*`, 'gm') + + let text + if ($position.nodeBefore?.isText !== undefined && $position.nodeBefore?.isText) { + text = $position.nodeBefore.text + } + + if (text === undefined || text === '') { + return null + } + + const textFrom = $position.pos - text.length + + const match: any = Array.from(text.matchAll(regexp)).pop() + + if (match === undefined || match === null || match.input === undefined || match.index === undefined) { + return null + } + + // JavaScript doesn't have lookbehinds. This hacks a check that first character + // is a space or the start of the line + const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index) + + if (allowedPrefixes !== null) { + const matchPrefixIsAllowed = new RegExp(`^[${allowedPrefixes.join('')}\0]?$`).test(matchPrefix) + if (!matchPrefixIsAllowed) { + return null + } + } + + /* eslint-disable @typescript-eslint/restrict-plus-operands */ + // The absolute position of the match in the document + const from: number = textFrom + match.index + let to: number = from + match[0].length + + // Edge case handling; if spaces are allowed and we're directly in between + // two triggers + if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) { + match[0] += ' ' + to += 1 + } + + // If the $position is located within the matched substring, return that range + if (from < $position.pos && to >= $position.pos) { + return { + range: { + from, + to + }, + query: match[0].slice(char.length), + text: match[0] + } + } + + return null +} + +export interface SuggestionOptions { + pluginKey?: PluginKey + editor: Editor + char?: string + allowSpaces?: boolean + allowedPrefixes?: string[] | null + startOfLine?: boolean + decorationTag?: string + decorationClass?: string + command?: (props: { editor: Editor, range: Range, props: I }) => void + items?: (props: { query: string, editor: Editor }) => I[] | Promise + render?: () => { + onBeforeStart?: (props: SuggestionProps) => void + onStart?: (props: SuggestionProps) => void + onBeforeUpdate?: (props: SuggestionProps) => void + onUpdate?: (props: SuggestionProps) => void + onExit?: (props: SuggestionProps) => void + onKeyDown?: (props: SuggestionKeyDownProps) => boolean + } + allow?: (props: { editor: Editor, state: EditorState, range: Range }) => boolean +} + +export interface SuggestionProps { + editor: Editor + range: Range + query: string + text: string + items: I[] + command: (props: I) => void + decorationNode: Element | null + clientRect?: (() => DOMRect | null) | null +} + +export interface SuggestionKeyDownProps { + view: EditorView + event: KeyboardEvent + range: Range +} + +export const SuggestionPluginKey = new PluginKey('suggestion') + +export default function Suggestion ({ + pluginKey = SuggestionPluginKey, + editor, + char = '@', + allowSpaces = false, + allowedPrefixes = [' '], + startOfLine = false, + decorationTag = 'span', + decorationClass = 'suggestion', + command = () => null, + items = () => [], + render = () => ({}), + allow = () => true +}: SuggestionOptions): Plugin { + let props: SuggestionProps | undefined + const renderer = render?.() + + const plugin: Plugin = new Plugin({ + key: pluginKey, + + view () { + return { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + update: async (view, prevState) => { + const prev = this.key?.getState(prevState) + const next = this.key?.getState(view.state) + + // See how the state changed + /* eslint-disable @typescript-eslint/strict-boolean-expressions */ + const moved = prev.active && next.active && prev.range.from !== next.range.from + const started = !prev.active && next.active + const stopped = prev.active && !next.active + const changed = !started && !stopped && prev.query !== next.query + const handleStart = started || moved + const handleChange = changed && !moved + const handleExit = stopped || moved + + // Cancel when suggestion isn't active + if (!handleStart && !handleChange && !handleExit) { + return + } + + const state = handleExit && !handleStart ? prev : next + /* eslint-disable @typescript-eslint/restrict-template-expressions */ + const decorationNode = view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`) + let clientRect + if (decorationNode !== null) { + clientRect = () => { + // because of `items` can be asynchrounous we’ll search for the current decoration node + const { decorationId } = this.key?.getState(editor.state) // eslint-disable-line + const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`) + if (currentDecorationNode !== null) { + return currentDecorationNode?.getBoundingClientRect() + } + return null + } + } + + props = { + editor, + range: state.range, + query: state.query, + text: state.text, + items: [], + command: (commandProps) => { + command({ + editor, + range: state.range, + props: commandProps + }) + }, + decorationNode, + // virtual node for popper.js or tippy.js + // this can be used for building popups without a DOM node + clientRect + } + + if (handleStart) { + renderer?.onBeforeStart?.(props) + } + + if (handleChange) { + renderer?.onBeforeUpdate?.(props) + } + + if (handleChange || handleStart) { + props.items = await items({ + editor, + query: state.query + }) + } + + if (handleExit) { + renderer?.onExit?.(props) + } + + if (handleChange) { + renderer?.onUpdate?.(props) + } + + if (handleStart) { + renderer?.onStart?.(props) + } + }, + + destroy: () => { + if (props == null) { + return + } + + renderer?.onExit?.(props) + } + } + }, + + state: { + // Initialize the plugin's internal state. + init () { + const state: { + active: boolean + range: Range + query: null | string + text: null | string + composing: boolean + decorationId?: string | null + specialCharInserted: boolean + maxRangeTo: number + } = { + active: false, + range: { + from: 0, + to: 0 + }, + query: null, + text: null, + composing: false, + specialCharInserted: false, + maxRangeTo: 0 + } + + return state + }, + + // Apply changes to the plugin state from a view transaction. + apply (transaction, prev, oldState, state) { + const { isEditable } = editor + const { composing } = editor.view + const { selection } = transaction + const { empty, from } = selection + const next = { ...prev } + const trPluginState = transaction.getMeta(pluginKey) + + if (trPluginState?.forceCancelSuggestion) { + next.specialCharInserted = false + } + + next.composing = composing + + // We can only be suggesting if the view is editable, and: + // * there is no selection, or + // * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449) + if (isEditable && (empty || editor.view.composing)) { + // Reset active state if we just left the previous suggestion range + if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) { + next.active = false + } + + if (!prev.specialCharInserted) { + next.specialCharInserted = hasChar(transaction, char) + } + + if (selection.$from.pos > next.maxRangeTo && transaction.steps.length === 0) { + next.specialCharInserted = false + } + + // Make sure special char was inserted by user + // Before try to make any match + if (prev.specialCharInserted || next.specialCharInserted) { + // Try to match against where our cursor currently is + const match = findSuggestionMatch({ + char, + allowSpaces, + allowedPrefixes, + startOfLine, + $position: selection.$from + }) + const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}` + + // If we found a match, update the current state to show it + if (match != null && allow({ editor, state, range: match.range })) { + next.active = true + next.decorationId = prev.decorationId ? prev.decorationId : decorationId + next.range = match.range + + if (next.range.to > next.maxRangeTo || transaction.steps.length !== 0) { + next.maxRangeTo = next.range.to + } + + next.query = match.query + next.text = match.text + } else { + next.active = false + } + } else { + next.active = false + } + } else { + next.active = false + } + + // Make sure to empty the range if suggestion is inactive + if (!next.active) { + next.decorationId = null + next.range = { from: 0, to: 0 } + next.maxRangeTo = 0 + next.query = null + next.text = null + next.specialCharInserted = false + } + + return next + } + }, + + props: { + // Call the keydown hook if suggestion is active. + handleKeyDown (view, event) { + const { active, range } = plugin.getState(view.state) + + if (!active) { + return false + } + + if (event.key === 'Escape') { + const flag = { forceCancelSuggestion: true } + + // It's important to dispatch this state twice + // Just one state change is not enough to handle all + // decorators + view.dispatch(view.state.tr.setMeta(pluginKey, flag)) + view.dispatch(view.state.tr.setMeta(pluginKey, flag)) + } + + return renderer?.onKeyDown?.({ view, event, range }) ?? false + }, + + // Setup decorator on the currently active suggestion. + decorations (state) { + const { active, range, decorationId } = plugin.getState(state) + + if (!active) { + return null + } + + return DecorationSet.create(state.doc, [ + Decoration.inline(range.from, range.to, { + nodeName: decorationTag, + class: decorationClass, + 'data-decoration-id': decorationId + }) + ]) + } + } + }) + + return plugin +} diff --git a/packages/theme/styles/_text-editor.scss b/packages/theme/styles/_text-editor.scss index b0146f1fae..d414572a97 100644 --- a/packages/theme/styles/_text-editor.scss +++ b/packages/theme/styles/_text-editor.scss @@ -11,6 +11,14 @@ // overflow-y: auto; color: var(--theme-text-primary-color); + .suggestion { + display: inline-flex; + padding: 0 .25rem; + color: var(--theme-link-color); + background-color: var(--theme-mention-bg-color); + border-radius: .25rem; + } + .title, h1, h2, diff --git a/plugins/client-resources/src/connection.ts b/plugins/client-resources/src/connection.ts index c8cff4034a..b44a010d09 100644 --- a/plugins/client-resources/src/connection.ts +++ b/plugins/client-resources/src/connection.ts @@ -35,7 +35,10 @@ import core, { TxResult, TxWorkspaceEvent, WorkspaceEvent, - generateId + generateId, + SearchQuery, + SearchOptions, + SearchResult } from '@hcengineering/core' import { PlatformError, UNAUTHORIZED, broadcastEvent, getMetadata, unknownError } from '@hcengineering/platform' @@ -407,6 +410,10 @@ class Connection implements ClientConnection { clean (domain: Domain, docs: Ref[]): Promise { return this.sendRequest({ method: 'clean', params: [domain, docs] }) } + + searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + return this.sendRequest({ method: 'searchFulltext', params: [query, options] }) + } } /** diff --git a/plugins/contact-assets/lang/en.json b/plugins/contact-assets/lang/en.json index 1eb262a5f6..b6aaa74f2f 100644 --- a/plugins/contact-assets/lang/en.json +++ b/plugins/contact-assets/lang/en.json @@ -84,6 +84,7 @@ "MergePersonsFrom": "Source contact", "MergePersonsTo": "Final contact", "SelectAvatar": "Select avatar", + "Avatar": "Avatar", "AvatarProvider": "Avatar provider", "GravatarsManaged": "Gravatars are managed", "Through": "through", @@ -97,6 +98,8 @@ "ConfigLabel": "Contacts", "ConfigDescription": "Extension to hold information about all Employees and other Person/Organization contacts.", "HasMessagesIn": "has messages in", - "HasNewMessagesIn": "has new messages in" + "HasNewMessagesIn": "has new messages in", + "Employees": "Employees", + "People": "People" } } diff --git a/plugins/contact-assets/lang/ru.json b/plugins/contact-assets/lang/ru.json index 630ac97851..50c13ba9e2 100644 --- a/plugins/contact-assets/lang/ru.json +++ b/plugins/contact-assets/lang/ru.json @@ -85,6 +85,7 @@ "MergePersonsFrom": "Исходный контакт", "MergePersonsTo": "Финальный контакт", "SelectAvatar": "Выбрать аватар", + "Avatar": "Аватар", "GravatarsManaged": "Граватары управляются", "Through": "через", "AddMembersHeader": "Добавить пользователей в {value}:", @@ -97,6 +98,8 @@ "ConfigLabel": "Контакты", "ConfigDescription": "Расширение по работе с сотрудниками и другими контактами.", "HasMessagesIn": "имеет сообщения в", - "HasNewMessagesIn": "имеет новые сообщения в" + "HasNewMessagesIn": "имеет новые сообщения в", + "Employees": "Сотрудники", + "People": "Люди" } } diff --git a/plugins/contact-resources/src/plugin.ts b/plugins/contact-resources/src/plugin.ts index 95ad496eaf..083abdc8f1 100644 --- a/plugins/contact-resources/src/plugin.ts +++ b/plugins/contact-resources/src/plugin.ts @@ -66,6 +66,7 @@ export default mergeIds(contactId, contact, { MergePersonsFrom: '' as IntlString, MergePersonsTo: '' as IntlString, SelectAvatar: '' as IntlString, + Avatar: '' as IntlString, GravatarsManaged: '' as IntlString, Through: '' as IntlString, AvatarProvider: '' as IntlString, diff --git a/plugins/contact/src/utils.ts b/plugins/contact/src/utils.ts index b49b83b1f5..788a48c55c 100644 --- a/plugins/contact/src/utils.ts +++ b/plugins/contact/src/utils.ts @@ -234,5 +234,19 @@ export function getName (hierarchy: Hierarchy, value: Contact): string { } function isPerson (hierarchy: Hierarchy, value: Contact): value is Person { - return hierarchy.isDerived(value._class, contactPlugin.class.Person) + return isPersonClass(hierarchy, value._class) +} + +function isPersonClass (hierarchy: Hierarchy, _class: Ref>): boolean { + return hierarchy.isDerived(_class, contactPlugin.class.Person) +} + +/** + * @public + */ +export function formatContactName (hierarchy: Hierarchy, _class: Ref>, name: string): string { + if (isPersonClass(hierarchy, _class)) { + return formatName(name) + } + return name } diff --git a/plugins/devmodel-resources/src/index.ts b/plugins/devmodel-resources/src/index.ts index a3fbbccb61..41f2d15949 100644 --- a/plugins/devmodel-resources/src/index.ts +++ b/plugins/devmodel-resources/src/index.ts @@ -27,7 +27,10 @@ import core, { Ref, Tx, TxResult, - WithLookup + WithLookup, + SearchQuery, + SearchOptions, + SearchResult } from '@hcengineering/core' import { devModelId } from '@hcengineering/devmodel' import { Builder } from '@hcengineering/model' @@ -123,6 +126,14 @@ class ModelClient implements AccountClient { return result } + async searchFulltext (query: SearchQuery, options: SearchOptions): Promise { + const result = await this.client.searchFulltext(query, options) + if (this.notifyEnabled) { + console.debug('devmodel# searchFulltext=>', query, options, 'result => ', result) + } + return result + } + async tx (tx: Tx): Promise { const result = await this.client.tx(tx) if (this.notifyEnabled) { diff --git a/plugins/tracker-resources/src/components/issues/IssueSearchIcon.svelte b/plugins/tracker-resources/src/components/issues/IssueSearchIcon.svelte new file mode 100644 index 0000000000..34b0d0b0a4 --- /dev/null +++ b/plugins/tracker-resources/src/components/issues/IssueSearchIcon.svelte @@ -0,0 +1,32 @@ + + + +{#if st} + +{/if} diff --git a/plugins/tracker-resources/src/index.ts b/plugins/tracker-resources/src/index.ts index 9c505c8f69..b9dd2043ae 100644 --- a/plugins/tracker-resources/src/index.ts +++ b/plugins/tracker-resources/src/index.ts @@ -49,6 +49,7 @@ import AssigneeEditor from './components/issues/AssigneeEditor.svelte' import DueDatePresenter from './components/issues/DueDatePresenter.svelte' import EditIssue from './components/issues/edit/EditIssue.svelte' import IssueItem from './components/issues/IssueItem.svelte' +import IssueSearchIcon from './components/issues/IssueSearchIcon.svelte' import IssuePresenter from './components/issues/IssuePresenter.svelte' import IssuePreview from './components/issues/IssuePreview.svelte' import Issues from './components/issues/Issues.svelte' @@ -490,7 +491,8 @@ export default async (): Promise => ({ EditRelatedTargets, EditRelatedTargetsPopup, TimePresenter, - EstimationValueEditor + EstimationValueEditor, + IssueSearchIcon }, completion: { IssueQuery: async (client: Client, query: string, filter?: { in?: RelatedDocument[], nin?: RelatedDocument[] }) => diff --git a/server-plugins/contact-resources/src/index.ts b/server-plugins/contact-resources/src/index.ts index d3a93ea106..003ae8f23d 100644 --- a/server-plugins/contact-resources/src/index.ts +++ b/server-plugins/contact-resources/src/index.ts @@ -14,8 +14,16 @@ // limitations under the License. // -import contact, { Channel, Contact, Organization, Person, contactId, getName } from '@hcengineering/contact' -import { Doc, Tx, TxRemoveDoc, TxUpdateDoc, concatLink } from '@hcengineering/core' +import contact, { + Channel, + Contact, + Organization, + Person, + contactId, + getName, + formatContactName +} from '@hcengineering/contact' +import { Ref, Class, Doc, Tx, TxRemoveDoc, TxUpdateDoc, concatLink, Hierarchy } from '@hcengineering/core' import notification, { Collaborators } from '@hcengineering/notification' import { getMetadata } from '@hcengineering/platform' import serverCore, { TriggerControl } from '@hcengineering/server-core' @@ -158,6 +166,14 @@ export function organizationTextPresenter (doc: Doc): string { return `${organization.name}` } +/** + * @public + */ +export function contactNameProvider (hierarchy: Hierarchy, props: { [key: string]: string }): string { + const _class = props._class !== undefined ? (props._class as Ref>) : contact.class.Contact + return formatContactName(hierarchy, _class, props.name ?? '') +} + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export default async () => ({ trigger: { @@ -168,6 +184,7 @@ export default async () => ({ PersonHTMLPresenter: personHTMLPresenter, PersonTextPresenter: personTextPresenter, OrganizationHTMLPresenter: organizationHTMLPresenter, - OrganizationTextPresenter: organizationTextPresenter + OrganizationTextPresenter: organizationTextPresenter, + ContactNameProvider: contactNameProvider } }) diff --git a/server-plugins/contact/src/index.ts b/server-plugins/contact/src/index.ts index f21ea8a543..7554ea4858 100644 --- a/server-plugins/contact/src/index.ts +++ b/server-plugins/contact/src/index.ts @@ -16,7 +16,7 @@ import type { Plugin, Resource } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' -import type { TriggerFunc } from '@hcengineering/server-core' +import type { TriggerFunc, SearchPresenterFunc } from '@hcengineering/server-core' import { Presenter } from '@hcengineering/server-notification' /** @@ -36,6 +36,8 @@ export default plugin(serverContactId, { PersonHTMLPresenter: '' as Resource, PersonTextPresenter: '' as Resource, OrganizationHTMLPresenter: '' as Resource, - OrganizationTextPresenter: '' as Resource + OrganizationTextPresenter: '' as Resource, + + ContactNameProvider: '' as Resource } }) diff --git a/server/core/src/fulltext.ts b/server/core/src/fulltext.ts index 04ce743907..ecfb93df2d 100644 --- a/server/core/src/fulltext.ts +++ b/server/core/src/fulltext.ts @@ -36,12 +36,16 @@ import core, { TxCUD, TxFactory, TxResult, - WorkspaceId + WorkspaceId, + SearchQuery, + SearchOptions, + SearchResult } from '@hcengineering/core' import { MinioService } from '@hcengineering/minio' import { FullTextIndexPipeline } from './indexer' import { createStateDoc, isClassIndexable } from './indexer/utils' -import type { FullTextAdapter, IndexedDoc, WithFind } from './types' +import { mapSearchResultDoc } from './mapper' +import type { FullTextAdapter, WithFind, IndexedDoc } from './types' /** * @public @@ -241,6 +245,18 @@ export class FullTextIndex implements WithFind { return result } + async searchFulltext (ctx: MeasureContext, query: SearchQuery, options: SearchOptions): Promise { + const resultRaw = await this.adapter.searchString(query, options) + + const result: SearchResult = { + ...resultRaw, + docs: resultRaw.docs.map((raw) => { + return mapSearchResultDoc(this.hierarchy, raw) + }) + } + return result + } + submitting: Promise | undefined timeout: any diff --git a/server/core/src/indexer/fulltextPush.ts b/server/core/src/indexer/fulltextPush.ts index 1cef194ead..f5ffc3d5dd 100644 --- a/server/core/src/indexer/fulltextPush.ts +++ b/server/core/src/indexer/fulltextPush.ts @@ -40,7 +40,8 @@ import { FullTextPipelineStage, fullTextPushStageId } from './types' -import { collectPropagate, collectPropagateClasses, docKey, getFullTextContext } from './utils' +import { collectPropagate, collectPropagateClasses, docKey, getFullTextContext, IndexKeyOptions } from './utils' +import { updateDocWithPresenter } from '../mapper' /** * @public @@ -128,7 +129,7 @@ export class FullTextPushStage implements FullTextPipelineStage { ) if (refDocs.length > 0) { refDocs.forEach((c) => { - updateDoc2Elastic(c.attributes, elasticDoc, c._id) + updateDoc2Elastic(c.attributes, elasticDoc, c._id, attribute) }) } } @@ -208,6 +209,8 @@ export class FullTextPushStage implements FullTextPipelineStage { // Include child ref attributes await this.indexRefAttributes(allAttributes, doc, elasticDoc, metrics) + await updateDocWithPresenter(pipeline.hierarchy, elasticDoc) + this.checkIntegrity(elasticDoc) bulk.push(elasticDoc) } catch (err: any) { @@ -258,7 +261,12 @@ export function createElasticDoc (upd: DocIndexState): IndexedDoc { } return doc } -function updateDoc2Elastic (attributes: Record, doc: IndexedDoc, docIdOverride?: Ref): void { +function updateDoc2Elastic ( + attributes: Record, + doc: IndexedDoc, + docIdOverride?: Ref, + refAttribute?: string +): void { for (const [k, v] of Object.entries(attributes)) { if (v == null) { continue @@ -280,7 +288,11 @@ function updateDoc2Elastic (attributes: Record, doc: IndexedDoc, do } continue } - const docIdAttr = '|' + docKey(attr, { _class, extra: extra.filter((it) => it !== 'base64') }) + const docKeyOpts: IndexKeyOptions = { _class, relative: true, extra: extra.filter((it) => it !== 'base64') } + if (refAttribute !== undefined) { + docKeyOpts.refAttribute = refAttribute + } + const docIdAttr = docKey(attr, docKeyOpts) if (vv !== null) { // Since we replace array of values, we could ignore null doc[docIdAttr] = [...(doc[docIdAttr] ?? [])] diff --git a/server/core/src/indexer/types.ts b/server/core/src/indexer/types.ts index 4b3c113070..b5fb35d4b9 100644 --- a/server/core/src/indexer/types.ts +++ b/server/core/src/indexer/types.ts @@ -102,7 +102,7 @@ export const contentStageId = 'cnt-v2b' /** * @public */ -export const fieldStateId = 'fld-v6' +export const fieldStateId = 'fld-v7' /** * @public diff --git a/server/core/src/mapper.ts b/server/core/src/mapper.ts new file mode 100644 index 0000000000..60f74d4b4c --- /dev/null +++ b/server/core/src/mapper.ts @@ -0,0 +1,140 @@ +import { Hierarchy, Ref, RefTo, Class, Doc, SearchResultDoc, docKey } from '@hcengineering/core' +import { getResource } from '@hcengineering/platform' + +import plugin from './plugin' +import { IndexedDoc, SearchPresenter, ClassSearchConfigProps } from './types' + +interface IndexedReader { + get: (attribute: string) => any + getDoc: (attribute: string) => IndexedReader | undefined +} + +function createIndexedReader ( + _class: Ref>, + hierarchy: Hierarchy, + doc: IndexedDoc, + refAttribute?: string +): IndexedReader { + return { + get: (attr: string) => { + const realAttr = hierarchy.findAttribute(_class, attr) + if (realAttr !== undefined) { + return doc[docKey(attr, { refAttribute, _class: realAttr.attributeOf })] + } + return undefined + }, + getDoc: (attr: string) => { + const realAttr = hierarchy.findAttribute(_class, attr) + if (realAttr !== undefined) { + const refAtrr = realAttr.type as RefTo + return createIndexedReader(refAtrr.to, hierarchy, doc, docKey(attr, { _class })) + } + return undefined + } + } +} + +function readAndMapProps (reader: IndexedReader, props: ClassSearchConfigProps[]): { [key: string]: string } { + const res: { [key: string]: string } = {} + for (const prop of props) { + if (typeof prop === 'string') { + res[prop] = reader.get(prop) + } else { + for (const [propName, rest] of Object.entries(prop)) { + if (rest.length > 1) { + const val = reader.getDoc(rest[0])?.get(rest[1]) ?? '' + res[propName] = Array.isArray(val) ? val[0] : val + } + } + } + } + return res +} + +function findSearchPresenter (hierarchy: Hierarchy, _class: Ref>): SearchPresenter | undefined { + const ancestors = hierarchy.getAncestors(_class).reverse() + for (const _class of ancestors) { + const searchMixin = hierarchy.classHierarchyMixin(_class, plugin.mixin.SearchPresenter) + if (searchMixin !== undefined) { + return searchMixin + } + } + return undefined +} + +/** + * @public + */ +export async function updateDocWithPresenter (hierarchy: Hierarchy, doc: IndexedDoc): Promise { + const searchPresenter = findSearchPresenter(hierarchy, doc._class) + if (searchPresenter === undefined) { + return + } + + const reader = createIndexedReader(doc._class, hierarchy, doc) + + const props = [ + { + name: 'searchTitle', + config: searchPresenter.searchConfig.title, + provider: searchPresenter.getSearchTitle + } + ] + + if (searchPresenter.searchConfig.shortTitle !== undefined) { + props.push({ + name: 'searchShortTitle', + config: searchPresenter.searchConfig.shortTitle, + provider: searchPresenter.getSearchObjectId + }) + } + + for (const prop of props) { + let value + if (typeof prop.config === 'string') { + value = reader.get(prop.config) + } else if (prop.config.tmpl !== undefined) { + const tmpl = prop.config.tmpl + const renderProps = readAndMapProps(reader, prop.config.props) + value = fillTemplate(tmpl, renderProps) + } else if (prop.provider !== undefined) { + const func = await getResource(prop.provider) + const renderProps = readAndMapProps(reader, prop.config.props) + value = func(hierarchy, { _class: doc._class, ...renderProps }) + } + doc[prop.name] = value + } +} + +/** + * @public + */ +export function mapSearchResultDoc (hierarchy: Hierarchy, raw: IndexedDoc): SearchResultDoc { + const doc: SearchResultDoc = { + id: raw.id, + title: raw.searchTitle, + shortTitle: raw.searchShortTitle, + doc: { + _id: raw.id, + _class: raw._class + } + } + + const searchPresenter = findSearchPresenter(hierarchy, doc.doc._class) + if (searchPresenter?.searchConfig.icon !== undefined) { + doc.icon = searchPresenter.searchConfig.icon + } + if (searchPresenter?.searchConfig.iconConfig !== undefined) { + doc.iconComponent = searchPresenter.searchConfig.iconConfig.component + doc.iconProps = readAndMapProps( + createIndexedReader(raw._class, hierarchy, raw), + searchPresenter.searchConfig.iconConfig.props + ) + } + + return doc +} + +function fillTemplate (tmpl: string, props: { [key: string]: string }): string { + return tmpl.replace(/{(.*?)}/g, (_, key: string) => props[key]) +} diff --git a/server/core/src/pipeline.ts b/server/core/src/pipeline.ts index 5790765b3d..5284ff127f 100644 --- a/server/core/src/pipeline.ts +++ b/server/core/src/pipeline.ts @@ -26,7 +26,10 @@ import { ServerStorage, StorageIterator, Tx, - TxResult + TxResult, + SearchQuery, + SearchOptions, + SearchResult } from '@hcengineering/core' import { DbConfiguration, createServerStorage } from './storage' import { BroadcastFunc, Middleware, MiddlewareCreator, Pipeline, SessionContext } from './types' @@ -91,6 +94,12 @@ class PipelineImpl implements Pipeline { : await this.storage.findAll(ctx, _class, query, options) } + async searchFulltext (ctx: SessionContext, query: SearchQuery, options: SearchOptions): Promise { + return this.head !== undefined + ? await this.head.searchFulltext(ctx, query, options) + : await this.storage.searchFulltext(ctx, query, options) + } + async tx (ctx: SessionContext, tx: Tx): Promise<[TxResult, Tx[], string[] | undefined]> { if (this.head === undefined) { const res = await this.storage.tx(ctx, tx) diff --git a/server/core/src/plugin.ts b/server/core/src/plugin.ts index 38854a3ce0..1c6c1cfba5 100644 --- a/server/core/src/plugin.ts +++ b/server/core/src/plugin.ts @@ -16,8 +16,8 @@ import { Metadata, Plugin, plugin } from '@hcengineering/platform' -import type { Class, Ref, Space } from '@hcengineering/core' -import type { ObjectDDParticipant, Trigger } from './types' +import type { Class, Ref, Space, Mixin } from '@hcengineering/core' +import type { ObjectDDParticipant, SearchPresenter, Trigger } from './types' /** * @public @@ -32,7 +32,8 @@ const serverCore = plugin(serverCoreId, { Trigger: '' as Ref> }, mixin: { - ObjectDDParticipant: '' as Ref + ObjectDDParticipant: '' as Ref, + SearchPresenter: '' as Ref> }, space: { DocIndexState: '' as Ref, diff --git a/server/core/src/storage.ts b/server/core/src/storage.ts index f8f13fe60d..c5b06389ec 100644 --- a/server/core/src/storage.ts +++ b/server/core/src/storage.ts @@ -51,7 +51,10 @@ import core, { TxUpdateDoc, TxWorkspaceEvent, WorkspaceEvent, - WorkspaceId + WorkspaceId, + SearchQuery, + SearchOptions, + SearchResult } from '@hcengineering/core' import { MinioService } from '@hcengineering/minio' import { getResource } from '@hcengineering/platform' @@ -376,6 +379,12 @@ class TServerStorage implements ServerStorage { }) } + async searchFulltext (ctx: MeasureContext, query: SearchQuery, options: SearchOptions): Promise { + return await ctx.with('full-text-search', {}, (ctx) => { + return this.fulltext.searchFulltext(ctx, query, options) + }) + } + private getParentClass (_class: Ref>): Ref> { const baseDomain = this.hierarchy.getDomain(_class) const ancestors = this.hierarchy.getAncestors(_class) diff --git a/server/core/src/types.ts b/server/core/src/types.ts index a10e64acac..05c8be6bf2 100644 --- a/server/core/src/types.ts +++ b/server/core/src/types.ts @@ -34,10 +34,13 @@ import { Tx, TxFactory, TxResult, - WorkspaceId + WorkspaceId, + SearchQuery, + SearchOptions, + SearchResult } from '@hcengineering/core' import { MinioService } from '@hcengineering/minio' -import type { Resource } from '@hcengineering/platform' +import type { Resource, Asset } from '@hcengineering/platform' import { Readable } from 'stream' /** @@ -59,6 +62,7 @@ export interface Middleware { query: DocumentQuery, options?: FindOptions ) => Promise> + searchFulltext: (ctx: SessionContext, query: SearchQuery, options: SearchOptions) => Promise } /** @@ -93,6 +97,7 @@ export interface Pipeline extends LowLevelStorage { query: DocumentQuery, options?: FindOptions ) => Promise> + searchFulltext: (ctx: SessionContext, query: SearchQuery, options: SearchOptions) => Promise tx: (ctx: SessionContext, tx: Tx) => Promise<[TxResult, Tx[], string[] | undefined]> close: () => Promise } @@ -133,20 +138,6 @@ export interface Trigger extends Doc { txMatch?: DocumentQuery } -/** - * @public - */ -export interface IndexedDoc { - id: Ref - _class: Ref> - space: Ref - modifiedOn: Timestamp - modifiedBy: Ref - attachedTo?: Ref - attachedToClass?: Ref> - [key: string]: any -} - /** * @public */ @@ -160,6 +151,30 @@ export interface EmbeddingSearchOption { minScore?: number // 75 for example. } +/** + * @public + */ +export interface IndexedDoc { + id: Ref + _class: Ref> + space: Ref + modifiedOn: Timestamp + modifiedBy: Ref + attachedTo?: Ref + attachedToClass?: Ref> + searchTitle?: string + searchShortTitle?: string + [key: string]: any +} + +/** + * @public + */ +export interface SearchStringResult { + docs: IndexedDoc[] + total?: number +} + /** * @public */ @@ -173,6 +188,9 @@ export interface FullTextAdapter { update: (id: Ref, update: Record) => Promise remove: (id: Ref[]) => Promise updateMany: (docs: IndexedDoc[]) => Promise + + searchString: (query: SearchQuery, options: SearchOptions) => Promise + search: ( _classes: Ref>[], search: DocumentQuery, @@ -220,6 +238,10 @@ export class DummyFullTextAdapter implements FullTextAdapter { return [] } + async searchString (query: SearchQuery, options: SearchOptions): Promise { + return { docs: [] } + } + async search (query: any): Promise { return [] } @@ -307,3 +329,44 @@ export interface ObjectDDParticipant extends Class { ) => Promise > } + +/** + * @public + */ +export interface SearchProps { + [key: string]: string +} + +/** + * @public + */ +export type SearchPresenterFunc = (hierarchy: Hierarchy, props: SearchProps) => string + +/** + * @public + */ +export type ClassSearchConfigProps = string | { [key: string]: string[] } + +/** + * @public + */ +export type ClassSearchConfigProperty = string | { tmpl?: string, props: ClassSearchConfigProps[] } + +/** + * @public + */ +export interface ClassSearchConfig { + icon?: Asset + iconConfig?: { component: any, props: ClassSearchConfigProps[] } + title: ClassSearchConfigProperty + shortTitle?: ClassSearchConfigProperty +} + +/** + * @public + */ +export interface SearchPresenter extends Class { + searchConfig: ClassSearchConfig + getSearchObjectId?: Resource + getSearchTitle?: Resource +} diff --git a/server/elastic/src/__tests__/adapter.test.ts b/server/elastic/src/__tests__/adapter.test.ts index 86dba04b6e..8860e09886 100644 --- a/server/elastic/src/__tests__/adapter.test.ts +++ b/server/elastic/src/__tests__/adapter.test.ts @@ -16,6 +16,7 @@ import { Account, Class, Doc, getWorkspaceId, MeasureMetricsContext, Ref, Space } from '@hcengineering/core' import type { IndexedDoc } from '@hcengineering/server-core' + import { createElasticAdapter } from '../adapter' describe('client', () => { @@ -38,7 +39,18 @@ describe('client', () => { console.log(hits) }) - // it('should find document', async () => { - // const adapter = await createElasticAdapter('http://localhost:9200/', 'ws1') - // }) + it('should find document with raw search', async () => { + const adapter = await createElasticAdapter( + 'http://localhost:9200/', + getWorkspaceId('ws1', ''), + new MeasureMetricsContext('-', {}) + ) + const result = await adapter.searchString( + { + query: 'hey' + }, + {} + ) + console.log(result) + }) }) diff --git a/server/elastic/src/adapter.ts b/server/elastic/src/adapter.ts index 6f418b3bd7..db86af80de 100644 --- a/server/elastic/src/adapter.ts +++ b/server/elastic/src/adapter.ts @@ -23,12 +23,17 @@ import { Ref, toWorkspaceString, TxResult, - WorkspaceId + WorkspaceId, + SearchQuery, + SearchOptions } from '@hcengineering/core' -import type { EmbeddingSearchOption, FullTextAdapter, IndexedDoc } from '@hcengineering/server-core' +import type { EmbeddingSearchOption, FullTextAdapter, SearchStringResult, IndexedDoc } from '@hcengineering/server-core' import { Client, errors as esErr } from '@elastic/elasticsearch' import { Domain } from 'node:domain' + +const DEFAULT_LIMIT = 200 + class ElasticAdapter implements FullTextAdapter { constructor ( private readonly client: Client, @@ -100,6 +105,65 @@ class ElasticAdapter implements FullTextAdapter { return this._metrics } + async searchString (query: SearchQuery, options: SearchOptions): Promise { + try { + const elasticQuery: any = { + query: { + bool: { + must: { + simple_query_string: { + query: query.query, + analyze_wildcard: true, + flags: 'OR|PREFIX|PHRASE|FUZZY|NOT|ESCAPE', + default_operator: 'and', + fields: [ + 'searchTitle^5', // Boost matches in searchTitle by a factor of 5 + 'searchShortTitle^5', + '*' // Search in all other fields without a boost + ] + } + } + } + }, + size: options.limit ?? DEFAULT_LIMIT + } + + const filter = [] + if (query.spaces !== undefined) { + filter.push({ + terms: { 'space.keyword': query.spaces } + }) + } + if (query.classes !== undefined) { + filter.push({ + terms: { '_class.keyword': query.classes } + }) + } + + if (filter.length > 0) { + elasticQuery.query.bool.filter = filter + } + + const result = await this.client.search({ + index: toWorkspaceString(this.workspaceId), + body: elasticQuery + }) + + const resp: SearchStringResult = { docs: [] } + if (result.body.hits !== undefined) { + if (result.body.hits.total?.value !== undefined) { + resp.total = result.body.hits.total?.value + } + resp.docs = result.body.hits.hits.map((hit: any) => ({ ...hit._source, _score: hit._score })) + } + + return resp + } catch (err) { + console.error('elastic error', JSON.stringify(err, null, 2)) + return { docs: [] } + } + } + async search ( _classes: Ref>[], query: DocumentQuery, diff --git a/server/middleware/src/base.ts b/server/middleware/src/base.ts index b5894ee08b..cd1572c656 100644 --- a/server/middleware/src/base.ts +++ b/server/middleware/src/base.ts @@ -13,7 +13,19 @@ // limitations under the License. // -import { Class, Doc, DocumentQuery, FindOptions, FindResult, Ref, ServerStorage, Tx } from '@hcengineering/core' +import { + Class, + Doc, + DocumentQuery, + FindOptions, + FindResult, + Ref, + ServerStorage, + Tx, + SearchQuery, + SearchOptions, + SearchResult +} from '@hcengineering/core' import { Middleware, SessionContext, TxMiddlewareResult } from '@hcengineering/server-core' /** @@ -31,6 +43,10 @@ export abstract class BaseMiddleware { return await this.provideFindAll(ctx, _class, query, options) } + async searchFulltext (ctx: SessionContext, query: SearchQuery, options: SearchOptions): Promise { + return await this.provideSearchFulltext(ctx, query, options) + } + protected async provideTx (ctx: SessionContext, tx: Tx): Promise { if (this.next !== undefined) { return await this.next.tx(ctx, tx) @@ -50,4 +66,15 @@ export abstract class BaseMiddleware { } return await this.storage.findAll(ctx, _class, query, options) } + + protected async provideSearchFulltext ( + ctx: SessionContext, + query: SearchQuery, + options: SearchOptions + ): Promise { + if (this.next !== undefined) { + return await this.next.searchFulltext(ctx, query, options) + } + return await this.storage.searchFulltext(ctx, query, options) + } } diff --git a/server/middleware/src/spaceSecurity.ts b/server/middleware/src/spaceSecurity.ts index 79ab16fbc5..c1dc74c783 100644 --- a/server/middleware/src/spaceSecurity.ts +++ b/server/middleware/src/spaceSecurity.ts @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. // - import core, { Account, AttachedDoc, @@ -38,7 +37,10 @@ import core, { TxRemoveDoc, TxUpdateDoc, TxWorkspaceEvent, - WorkspaceEvent + WorkspaceEvent, + SearchResult, + SearchQuery, + SearchOptions } from '@hcengineering/core' import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' import { BroadcastFunc, Middleware, SessionContext, TxMiddlewareResult } from '@hcengineering/server-core' @@ -400,6 +402,20 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar return findResult } + override async searchFulltext ( + ctx: SessionContext, + query: SearchQuery, + options: SearchOptions + ): Promise { + const newQuery = { ...query } + const account = await getUser(this.storage, ctx) + if (!isSystem(account)) { + newQuery.spaces = await this.getAllAllowedSpaces(account) + } + const result = await this.provideSearchFulltext(ctx, newQuery, options) + return result + } + async isUnavailable (ctx: SessionContext, space: Ref): Promise { if (this.privateSpaces[space] === undefined) return false const account = await getUser(this.storage, ctx) diff --git a/server/mongo/src/__tests__/storage.test.ts b/server/mongo/src/__tests__/storage.test.ts index 36f70eaafd..6efda0c188 100644 --- a/server/mongo/src/__tests__/storage.test.ts +++ b/server/mongo/src/__tests__/storage.test.ts @@ -166,6 +166,7 @@ describe('mongo operations', () => { const st: ClientConnection = { findAll: async (_class, query, options) => await serverStorage.findAll(ctx, _class, query, options), tx: async (tx) => (await serverStorage.tx(ctx, tx))[0], + searchFulltext: async () => ({ docs: [] }), close: async () => {}, loadChunk: async (domain): Promise => await Promise.reject(new Error('unsupported')), closeChunk: async (idx) => {}, diff --git a/server/ws/src/__tests__/server.test.ts b/server/ws/src/__tests__/server.test.ts index f159f98912..d71dba7f7a 100644 --- a/server/ws/src/__tests__/server.test.ts +++ b/server/ws/src/__tests__/server.test.ts @@ -80,7 +80,10 @@ describe('server', () => { }), load: async (domain: Domain, docs: Ref[]) => [], upload: async (domain: Domain, docs: Doc[]) => {}, - clean: async (domain: Domain, docs: Ref[]) => {} + clean: async (domain: Domain, docs: Ref[]) => {}, + searchFulltext: async (ctx, query, options) => { + return { docs: [] } + } }), sessionFactory: (token, pipeline, broadcast) => new ClientSession(broadcast, token, pipeline), port: 3335, @@ -175,7 +178,10 @@ describe('server', () => { }), load: async (domain: Domain, docs: Ref[]) => [], upload: async (domain: Domain, docs: Doc[]) => {}, - clean: async (domain: Domain, docs: Ref[]) => {} + clean: async (domain: Domain, docs: Ref[]) => {}, + searchFulltext: async (ctx, query, options) => { + return { docs: [] } + } }), sessionFactory: (token, pipeline, broadcast) => new ClientSession(broadcast, token, pipeline), port: 3336, diff --git a/server/ws/src/client.ts b/server/ws/src/client.ts index be4458718a..7eb53f9d00 100644 --- a/server/ws/src/client.ts +++ b/server/ws/src/client.ts @@ -33,7 +33,10 @@ import core, { TxResult, TxWorkspaceEvent, WorkspaceEvent, - generateId + generateId, + SearchQuery, + SearchOptions, + SearchResult } from '@hcengineering/core' import { Pipeline, SessionContext } from '@hcengineering/server-core' import { Token } from '@hcengineering/server-token' @@ -110,6 +113,12 @@ export class ClientSession implements Session { return await this._pipeline.findAll(context, _class, query, options) } + async searchFulltext (ctx: MeasureContext, query: SearchQuery, options: SearchOptions): Promise { + const context = ctx as SessionContext + context.userEmail = this.token.email + return await this._pipeline.searchFulltext(context, query, options) + } + async tx (ctx: MeasureContext, tx: Tx): Promise { this.total.tx++ this.current.tx++