UBER-911: Mentions without second input and tabs (#3798)

Signed-off-by: Maxim Karmatskikh <mkarmatskih@gmail.com>
This commit is contained in:
Maksim Karmatskikh 2023-11-17 08:35:09 +01:00 committed by GitHub
parent f0ba202f28
commit af5c79c928
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 1577 additions and 121 deletions

View File

@ -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

View File

@ -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<SearchResult> {
return { docs: [] }
}
async loadModel (lastModelTx: Timestamp): Promise<Tx[]> {
return await this.storage.findAll(this.measureCtx, core.class.Tx, {
space: core.space.Model,

View File

@ -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
)

View File

@ -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<ObjectSearchFactory>,

View File

@ -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<ObjectSearchFactory>
classToSearch!: Ref<Class<Doc>>
}
@Model(presentation.class.PresentationMiddlewareFactory, core.class.Doc, DOMAIN_MODEL)

View File

@ -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
)

View File

@ -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: {

View File

@ -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<SearchPresenterFunc>
getSearchTitle!: Resource<SearchPresenterFunc>
}
export function createModel (builder: Builder): void {
builder.createModel(TTrigger, TObjectDDParticipant, TSearchPresenter)
}

View File

@ -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",

View File

@ -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,

View File

@ -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
})

View File

@ -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
)

View File

@ -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<Application>

View File

@ -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<SearchResult> => {
return { docs: [] }
},
tx: async (tx: Tx): Promise<TxResult> => {
if (tx.objectSpace === core.space.Model) {
hierarchy.tx(tx)

View File

@ -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<ClientConnect
return {
findAll,
searchFulltext: async (query: SearchQuery, options: SearchOptions): Promise<SearchResult> => {
return { docs: [] }
},
tx: async (tx: Tx): Promise<TxResult> => {
if (tx.objectSpace === core.space.Model) {
hierarchy.tx(tx)

View File

@ -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<SearchResult> {
return { docs: [] }
}
async close (): Promise<void> {}
}

View File

@ -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<void>
onConnect?: (event: ClientConnectEvent) => Promise<void>
@ -127,6 +127,10 @@ class ClientImpl implements AccountClient, BackupClient {
return toFindResult(result, data.total)
}
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
return await this.conn.searchFulltext(query, options)
}
async findOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,

View File

@ -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<Client, 'notify'> {
return this.client.findOne(_class, query, options)
}
searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
return this.client.searchFulltext(query, options)
}
tx (tx: Tx): Promise<TxResult> {
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<TxResult> => {
if (ops.getHierarchy().isDerived(tx._class, core.class.TxCUD)) {
this.txes.push(tx as TxCUD<Doc>)
@ -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<TxResult> => {
if (this.hierarchy.isDerived(tx._class, core.class.TxCUD)) {
this.txes.push(tx as TxCUD<Doc>)

View File

@ -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<T>,
options?: FindOptions<T>
) => Promise<FindResult<T>>
searchFulltext: (ctx: MeasureContext, query: SearchQuery, options: SearchOptions) => Promise<SearchResult>
tx: (ctx: MeasureContext, tx: Tx) => Promise<[TxResult, Tx[]]>
apply: (ctx: MeasureContext, tx: Tx[], broadcast: boolean) => Promise<Tx[]>
close: () => Promise<void>

View File

@ -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<T extends Doc> = WithLookup<T>[] & {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface TxResult {}
/**
* @public
*/
export interface SearchQuery {
query: string
classes?: Ref<Class<Doc>>[]
spaces?: Ref<Space>[]
}
/**
* @public
*/
export interface SearchOptions {
limit?: number
}
/**
* @public
*/
export interface SearchResultDoc {
id: Ref<Doc>
icon?: Asset
iconComponent?: string
iconProps?: { [key: string]: string }
shortTitle?: string
title?: string
doc: Pick<Doc, '_id' | '_class'>
}
/**
* @public
*/
export interface SearchResult {
docs: SearchResultDoc[]
total?: number
}
/**
* @public
*/
@ -217,5 +256,13 @@ export interface Storage {
query: DocumentQuery<T>,
options?: FindOptions<T>
) => Promise<FindResult<T>>
tx: (tx: Tx) => Promise<TxResult>
}
/**
* @public
*/
export interface FulltextStorage {
searchFulltext: (query: SearchQuery, options: SearchOptions) => Promise<SearchResult>
}

View File

@ -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<Class<Obj>>
docId?: Ref<DocIndexState>
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
}
/**

View File

@ -27,6 +27,7 @@
"DocumentPreview": "Preview",
"MakePrivate": "Make private",
"MakePrivateDescription": "Only members can see it",
"Created": "Created"
"Created": "Created",
"NoResults": "No results to show"
}
}

View File

@ -27,6 +27,7 @@
"DocumentPreview": "Предпросмотр",
"MakePrivate": "Сделать личным",
"MakePrivateDescription": "Только пользователи могут видеть это",
"Created": "Созданные"
"Created": "Созданные",
"NoResults": "Нет результатов"
}
}

View File

@ -14,7 +14,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Ref, RelatedDocument } from '@hcengineering/core'
import { Ref, RelatedDocument, DocumentQuery } from '@hcengineering/core'
import { getResource, IntlString } from '@hcengineering/platform'
import ui, {
@ -49,15 +49,17 @@
const client = getClient()
let category: ObjectSearchCategory | undefined
client
.findAll(
presentation.class.ObjectSearchCategory,
allowCategory !== undefined ? { _id: { $in: allowCategory } } : {}
)
.then((r) => {
categories = r.filter((it) => hasResource(it.query))
category = categories[0]
})
const categoryQuery: DocumentQuery<ObjectSearchCategory> = {
context: 'search'
}
if (allowCategory !== undefined) {
categoryQuery._id = { $in: allowCategory }
}
client.findAll(presentation.class.ObjectSearchCategory, categoryQuery).then((r) => {
categories = r.filter((it) => hasResource(it.query))
category = categories[0]
})
const dispatch = createEventDispatcher()

View File

@ -11,7 +11,10 @@ import {
Tx,
TxResult,
WithLookup,
toFindResult
toFindResult,
SearchQuery,
SearchOptions,
SearchResult
} from '@hcengineering/core'
import { Resource } from '@hcengineering/platform'
@ -108,6 +111,10 @@ export class PresentationPipelineImpl implements PresentationPipeline {
: await this.client.findAll(_class, query, options)
}
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
return await this.client.searchFulltext(query, options)
}
async findOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,

View File

@ -63,7 +63,8 @@ export default plugin(presentationId, {
MakePrivate: '' as IntlString,
MakePrivateDescription: '' as IntlString,
OpenInANewTab: '' as IntlString,
Created: '' as IntlString
Created: '' as IntlString,
NoResults: '' as IntlString
},
metadata: {
RequiredVersion: '' as Metadata<string>,

View File

@ -45,18 +45,31 @@ export interface ObjectCreate {
export type ObjectSearchFactory = (
client: Client,
query: string,
filter?: { in?: RelatedDocument[], nin?: RelatedDocument[] }
options?: {
in?: RelatedDocument[]
nin?: RelatedDocument[]
}
) => Promise<ObjectSearchResult[]>
/**
* @public
* search - show in search popup
* mention - show in mentions
*/
export type ObjectSearchContext = 'search' | 'mention'
/**
* @public
*/
export interface ObjectSearchCategory extends Doc {
label: IntlString
icon: Asset
title: IntlString
context: ObjectSearchContext[]
// Query for documents with pattern
query: Resource<ObjectSearchFactory>
classToSearch?: Ref<Class<Doc>>
}
export interface ComponentExt {

View File

@ -34,7 +34,10 @@ import core, {
Tx,
TxOperations,
TxResult,
WithLookup
WithLookup,
SearchQuery,
SearchOptions,
SearchResult
} from '@hcengineering/core'
import { getMetadata, getResource } from '@hcengineering/platform'
import { LiveQuery as LQ } from '@hcengineering/query'
@ -93,6 +96,10 @@ class UIClient extends TxOperations implements Client {
override async tx (tx: Tx): Promise<TxResult> {
return await this.client.tx(tx)
}
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
return await this.client.searchFulltext(query, options)
}
}
/**

View File

@ -26,14 +26,19 @@ import type {
Ref,
Timestamp,
Tx,
TxResult
TxResult,
FulltextStorage,
SearchQuery,
SearchOptions,
SearchResult
} from '@hcengineering/core'
import core, { DOMAIN_TX, Hierarchy, ModelDb, TxDb } from '@hcengineering/core'
import { genMinModel } from './minmodel'
export async function connect (handler: (tx: Tx) => void): Promise<
AccountClient &
BackupClient & {
BackupClient &
FulltextStorage & {
loadModel: (last: Timestamp, hash?: string) => Promise<Tx[] | LoadModelResponse>
}
> {
@ -86,6 +91,10 @@ BackupClient & {
closeChunk: async (idx: number) => {},
loadDocs: async (domain: Domain, docs: Ref<Doc>[]) => [],
upload: async (domain: Domain, docs: Doc[]) => {},
clean: async (domain: Domain, docs: Ref<Doc>[]) => {}
clean: async (domain: Domain, docs: Ref<Doc>[]) => {},
searchFulltext: async (query: SearchQuery, options: SearchOptions): Promise<SearchResult> => {
return { docs: [] }
}
}
}

View File

@ -48,7 +48,10 @@ import core, {
TxUpdateDoc,
TxWorkspaceEvent,
WithLookup,
WorkspaceEvent
WorkspaceEvent,
SearchQuery,
SearchOptions,
SearchResult
} from '@hcengineering/core'
import { deepEqual } from 'fast-equals'
@ -178,6 +181,10 @@ export class LiveQuery extends TxProcessor implements Client {
return toFindResult(this.clone(q.result), q.total) as FindResult<T>
}
searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
return this.client.searchFulltext(query, options)
}
async findOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,

View File

@ -1,9 +1,9 @@
import { Node, mergeAttributes } from '@tiptap/core'
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { getDataAttribute } from './utils'
import Suggestion, { SuggestionOptions } from './components/extension/suggestion'
export interface CompletionOptions {
HTMLAttributes: Record<string, any>
renderLabel: (props: { options: CompletionOptions, node: any }) => string
@ -47,6 +47,7 @@ export const Completion = Node.create<CompletionOptions>({
},
suggestion: {
char: '@',
allowSpaces: true,
// pluginKey: CompletionPluginKey,
command: ({ editor, range, props }) => {
// increase range.to by one when the next node is of type "text"
@ -59,20 +60,22 @@ export const Completion = Node.create<CompletionOptions>({
range.to += 1
}
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
attrs: props
},
{
type: 'text',
text: ' '
}
])
.run()
if (props !== null) {
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
attrs: props
},
{
type: 'text',
text: ' '
}
])
.run()
}
},
allow: ({ editor, range }) => {
if (range.from > editor.state.doc.content.size) return false

View File

@ -14,9 +14,9 @@
// limitations under the License.
-->
<script lang="ts">
import { ObjectSearchPopup, ObjectSearchResult } from '@hcengineering/presentation'
import { showPopup, resizeObserver, deviceOptionsStore as deviceInfo, PopupResult } from '@hcengineering/ui'
import { onDestroy, onMount } from 'svelte'
import MentionPopup from './MentionPopup.svelte'
import DummyPopup from './DummyPopup.svelte'
export let query: string = ''
@ -32,7 +32,10 @@
DummyPopup,
{},
undefined,
() => close(),
() => {
close()
command(null)
},
() => {},
{ overlay: false, category: '' }
)
@ -42,15 +45,15 @@
dummyPopup.close()
})
function dispatchItem (item: ObjectSearchResult): void {
function dispatchItem (item: { id: string; label: string; objectclass: string }): void {
if (item == null) {
close()
} else {
command({ id: item.doc._id, label: item.title, objectclass: item.doc._class })
command(item)
}
}
let searchPopup: ObjectSearchPopup
let searchPopup: MentionPopup
export function onKeyDown (ev: KeyboardEvent): boolean {
return searchPopup?.onKeyDown(ev)
@ -97,7 +100,7 @@
updateStyle()
}}
>
<ObjectSearchPopup bind:this={searchPopup} {query} on:close={(evt) => dispatchItem(evt.detail)} />
<MentionPopup bind:this={searchPopup} {query} on:close={(evt) => dispatchItem(evt.detail)} />
</div>
<style lang="scss">

View File

@ -0,0 +1,217 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { createFocusManager, FocusHandler, Label, ListView, resizeObserver } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import presentation, { getClient, ObjectSearchCategory } from '@hcengineering/presentation'
import { Class, Ref, Doc, SearchResultDoc } from '@hcengineering/core'
import MentionResult from './MentionResult.svelte'
export let query: string = ''
type SearchSection = { category: ObjectSearchCategory; items: SearchResultDoc[] }
type SearchItem = {
num: number
item: SearchResultDoc
category: ObjectSearchCategory
}
let items: SearchItem[] = []
let categories: ObjectSearchCategory[] = []
const client = getClient()
client.findAll(presentation.class.ObjectSearchCategory, { context: 'mention' }).then(async (results) => {
categories = results
updateItems(query)
})
const dispatch = createEventDispatcher()
let list: ListView
let scrollContainer: HTMLElement
let selection = 0
function dispatchItem (item: SearchResultDoc): void {
dispatch('close', {
id: item.id,
label: item.shortTitle ?? item.title,
objectclass: item.doc._class
})
}
export function onKeyDown (key: KeyboardEvent): boolean {
if (key.key === 'ArrowDown') {
key.stopPropagation()
key.preventDefault()
list?.select(selection + 1)
return true
}
if (key.key === 'ArrowUp') {
key.stopPropagation()
key.preventDefault()
if (selection === 0 && scrollContainer !== undefined) {
scrollContainer.scrollTop = 0
}
list?.select(selection - 1)
return true
}
if (key.key === 'Enter') {
key.preventDefault()
key.stopPropagation()
const searchItem = items[selection]
if (searchItem) {
dispatchItem(searchItem.item)
return true
} else {
return false
}
}
return false
}
export function done () {}
function packSearchResultsForListView (sections: SearchSection[]): SearchItem[] {
let results: SearchItem[] = []
for (const section of sections) {
const category = section.category
const items = section.items
if (category.classToSearch !== undefined) {
results = results.concat(
items.map((item, num) => {
return { num, category, item }
})
)
}
}
return results
}
function findCategoryByClass (
categories: ObjectSearchCategory[],
_class: Ref<Class<Doc>>
): ObjectSearchCategory | undefined {
for (const category of categories) {
if (category.classToSearch === _class) {
return category
}
}
return undefined
}
async function doFulltextSearch (classes: Ref<Class<Doc>>[], query: string): Promise<SearchSection[]> {
const result = await client.searchFulltext(
{
query: `${query}*`,
classes
},
{
limit: 10
}
)
const itemsByClass = new Map<Ref<Class<Doc>>, SearchResultDoc[]>()
for (const item of result.docs) {
const list = itemsByClass.get(item.doc._class)
if (list === undefined) {
itemsByClass.set(item.doc._class, [item])
} else {
list.push(item)
}
}
const sections: SearchSection[] = []
for (const [_class, items] of itemsByClass.entries()) {
const category = findCategoryByClass(categories, _class)
if (category !== undefined) {
sections.push({ category, items })
}
}
return sections
}
async function updateItems (query: string): Promise<void> {
const classesToSearch: Ref<Class<Doc>>[] = []
for (const cat of categories) {
if (cat.classToSearch !== undefined) {
classesToSearch.push(cat.classToSearch)
}
}
const sections = await doFulltextSearch(classesToSearch, query)
items = packSearchResultsForListView(sections)
}
$: updateItems(query)
const manager = createFocusManager()
</script>
<FocusHandler {manager} />
<form class="antiPopup mentionPoup" on:keydown={onKeyDown} use:resizeObserver={() => dispatch('changeSize')}>
<div class="ap-scroll" bind:this={scrollContainer}>
<div class="ap-box">
{#if items.length === 0 && query !== ''}
<div class="noResults"><Label label={presentation.string.NoResults} /></div>
{/if}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<ListView bind:this={list} bind:selection count={items.length}>
<svelte:fragment slot="category" let:item={num}>
{@const item = items[num]}
{#if item.num === 0}
<div class="mentonCategory">
<Label label={item.category.title} />
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="item" let:item={num}>
{@const item = items[num]}
{@const doc = item.item}
<div class="ap-menuItem withComp" on:click={() => dispatchItem(doc)}>
<MentionResult value={doc} />
</div>
</svelte:fragment>
</ListView>
</div>
</div>
<div class="ap-space x2" />
</form>
<style lang="scss">
.noResults {
display: flex;
padding: 0.25rem 1rem;
align-items: center;
align-self: stretch;
}
.mentionPoup {
padding-top: 0.5rem;
}
.mentonCategory {
padding: 0.5rem 1rem;
font-size: 0.625rem;
letter-spacing: 0.0625rem;
color: var(--theme-dark-color);
text-transform: uppercase;
line-height: 1rem;
}
</style>

View File

@ -0,0 +1,61 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import type { SearchResultDoc } from '@hcengineering/core'
import { Asset, getResource } from '@hcengineering/platform'
import { AnyComponent, Icon } from '@hcengineering/ui'
export let value: SearchResultDoc
$: iconComponent = value.iconComponent ? (value.iconComponent as AnyComponent) : undefined
$: icon = value.icon !== undefined ? (value.icon as Asset) : undefined
</script>
<div class="flex-row-center h-8">
<div class="flex-center p-1 content-dark-color flex-no-shrink">
{#if icon !== undefined}
<Icon {icon} size={'medium'} />
{/if}
{#if iconComponent}
{#await getResource(iconComponent) then component}
<svelte:component this={component} size={'smaller'} {...value.iconProps} />
{/await}
{/if}
</div>
<span class="ml-2 max-w-120 overflow-label searchResult">
{#if value.shortTitle !== undefined}
<span class="shortTitle">{value.shortTitle}</span>
{/if}
<span class="name">{value.title}</span>
</span>
</div>
<style lang="scss">
.searchResult {
display: flex;
flex-direction: row;
.shortTitle {
display: flex;
padding-right: 0.5rem;
color: var(--theme-darker-color);
}
.name {
display: flex;
flex: 1;
}
}
</style>

View File

@ -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<I = any> {
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<I[]>
render?: () => {
onBeforeStart?: (props: SuggestionProps<I>) => void
onStart?: (props: SuggestionProps<I>) => void
onBeforeUpdate?: (props: SuggestionProps<I>) => void
onUpdate?: (props: SuggestionProps<I>) => void
onExit?: (props: SuggestionProps<I>) => void
onKeyDown?: (props: SuggestionKeyDownProps) => boolean
}
allow?: (props: { editor: Editor, state: EditorState, range: Range }) => boolean
}
export interface SuggestionProps<I = any> {
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<I = any> ({
pluginKey = SuggestionPluginKey,
editor,
char = '@',
allowSpaces = false,
allowedPrefixes = [' '],
startOfLine = false,
decorationTag = 'span',
decorationClass = 'suggestion',
command = () => null,
items = () => [],
render = () => ({}),
allow = () => true
}: SuggestionOptions<I>): Plugin<any> {
let props: SuggestionProps<I> | undefined
const renderer = render?.()
const plugin: Plugin<any> = 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 well 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
}

View File

@ -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,

View File

@ -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<Doc>[]): Promise<void> {
return this.sendRequest({ method: 'clean', params: [domain, docs] })
}
searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
return this.sendRequest({ method: 'searchFulltext', params: [query, options] })
}
}
/**

View File

@ -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"
}
}

View File

@ -85,6 +85,7 @@
"MergePersonsFrom": "Исходный контакт",
"MergePersonsTo": "Финальный контакт",
"SelectAvatar": "Выбрать аватар",
"Avatar": "Аватар",
"GravatarsManaged": "Граватары управляются",
"Through": "через",
"AddMembersHeader": "Добавить пользователей в {value}:",
@ -97,6 +98,8 @@
"ConfigLabel": "Контакты",
"ConfigDescription": "Расширение по работе с сотрудниками и другими контактами.",
"HasMessagesIn": "имеет сообщения в",
"HasNewMessagesIn": "имеет новые сообщения в"
"HasNewMessagesIn": "имеет новые сообщения в",
"Employees": "Сотрудники",
"People": "Люди"
}
}

View File

@ -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,

View File

@ -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<Class<Doc>>): boolean {
return hierarchy.isDerived(_class, contactPlugin.class.Person)
}
/**
* @public
*/
export function formatContactName (hierarchy: Hierarchy, _class: Ref<Class<Doc>>, name: string): string {
if (isPersonClass(hierarchy, _class)) {
return formatName(name)
}
return name
}

View File

@ -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<SearchResult> {
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<TxResult> {
const result = await this.client.tx(tx)
if (this.notifyEnabled) {

View File

@ -0,0 +1,32 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Ref, Space, Status } from '@hcengineering/core'
import { statusStore } from '@hcengineering/view-resources'
import { Project } from '@hcengineering/tracker'
import IssueStatusIcon from './IssueStatusIcon.svelte'
export let status: string
export let space: Ref<Space>
$: st = $statusStore.byId.get(status as Ref<Status>)
$: spaceProject = space as Ref<Project>
</script>
{#if st}
<IssueStatusIcon value={st} size={'small'} space={spaceProject} />
{/if}

View File

@ -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<Resources> => ({
EditRelatedTargets,
EditRelatedTargetsPopup,
TimePresenter,
EstimationValueEditor
EstimationValueEditor,
IssueSearchIcon
},
completion: {
IssueQuery: async (client: Client, query: string, filter?: { in?: RelatedDocument[], nin?: RelatedDocument[] }) =>

View File

@ -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<Class<Doc>>) : 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
}
})

View File

@ -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<Presenter>,
PersonTextPresenter: '' as Resource<Presenter>,
OrganizationHTMLPresenter: '' as Resource<Presenter>,
OrganizationTextPresenter: '' as Resource<Presenter>
OrganizationTextPresenter: '' as Resource<Presenter>,
ContactNameProvider: '' as Resource<SearchPresenterFunc>
}
})

View File

@ -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<SearchResult> {
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<void> | undefined
timeout: any

View File

@ -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<string, any>, doc: IndexedDoc, docIdOverride?: Ref<DocIndexState>): void {
function updateDoc2Elastic (
attributes: Record<string, any>,
doc: IndexedDoc,
docIdOverride?: Ref<DocIndexState>,
refAttribute?: string
): void {
for (const [k, v] of Object.entries(attributes)) {
if (v == null) {
continue
@ -280,7 +288,11 @@ function updateDoc2Elastic (attributes: Record<string, any>, 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] ?? [])]

View File

@ -102,7 +102,7 @@ export const contentStageId = 'cnt-v2b'
/**
* @public
*/
export const fieldStateId = 'fld-v6'
export const fieldStateId = 'fld-v7'
/**
* @public

140
server/core/src/mapper.ts Normal file
View File

@ -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<Class<Doc>>,
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<Doc>
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<Class<Doc>>): 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<void> {
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])
}

View File

@ -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<SearchResult> {
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)

View File

@ -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<Class<Trigger>>
},
mixin: {
ObjectDDParticipant: '' as Ref<ObjectDDParticipant>
ObjectDDParticipant: '' as Ref<ObjectDDParticipant>,
SearchPresenter: '' as Ref<Mixin<SearchPresenter>>
},
space: {
DocIndexState: '' as Ref<Space>,

View File

@ -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<SearchResult> {
return await ctx.with('full-text-search', {}, (ctx) => {
return this.fulltext.searchFulltext(ctx, query, options)
})
}
private getParentClass (_class: Ref<Class<Doc>>): Ref<Class<Doc>> {
const baseDomain = this.hierarchy.getDomain(_class)
const ancestors = this.hierarchy.getAncestors(_class)

View File

@ -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<T>,
options?: FindOptions<T>
) => Promise<FindResult<T>>
searchFulltext: (ctx: SessionContext, query: SearchQuery, options: SearchOptions) => Promise<SearchResult>
}
/**
@ -93,6 +97,7 @@ export interface Pipeline extends LowLevelStorage {
query: DocumentQuery<T>,
options?: FindOptions<T>
) => Promise<FindResult<T>>
searchFulltext: (ctx: SessionContext, query: SearchQuery, options: SearchOptions) => Promise<SearchResult>
tx: (ctx: SessionContext, tx: Tx) => Promise<[TxResult, Tx[], string[] | undefined]>
close: () => Promise<void>
}
@ -133,20 +138,6 @@ export interface Trigger extends Doc {
txMatch?: DocumentQuery<Tx>
}
/**
* @public
*/
export interface IndexedDoc {
id: Ref<Doc>
_class: Ref<Class<Doc>>
space: Ref<Space>
modifiedOn: Timestamp
modifiedBy: Ref<Account>
attachedTo?: Ref<Doc>
attachedToClass?: Ref<Class<Doc>>
[key: string]: any
}
/**
* @public
*/
@ -160,6 +151,30 @@ export interface EmbeddingSearchOption {
minScore?: number // 75 for example.
}
/**
* @public
*/
export interface IndexedDoc {
id: Ref<Doc>
_class: Ref<Class<Doc>>
space: Ref<Space>
modifiedOn: Timestamp
modifiedBy: Ref<Account>
attachedTo?: Ref<Doc>
attachedToClass?: Ref<Class<Doc>>
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<Doc>, update: Record<string, any>) => Promise<TxResult>
remove: (id: Ref<Doc>[]) => Promise<void>
updateMany: (docs: IndexedDoc[]) => Promise<TxResult[]>
searchString: (query: SearchQuery, options: SearchOptions) => Promise<SearchStringResult>
search: (
_classes: Ref<Class<Doc>>[],
search: DocumentQuery<Doc>,
@ -220,6 +238,10 @@ export class DummyFullTextAdapter implements FullTextAdapter {
return []
}
async searchString (query: SearchQuery, options: SearchOptions): Promise<SearchStringResult> {
return { docs: [] }
}
async search (query: any): Promise<IndexedDoc[]> {
return []
}
@ -307,3 +329,44 @@ export interface ObjectDDParticipant extends Class<Obj> {
) => Promise<Doc[]>
>
}
/**
* @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<Doc> {
searchConfig: ClassSearchConfig
getSearchObjectId?: Resource<SearchPresenterFunc>
getSearchTitle?: Resource<SearchPresenterFunc>
}

View File

@ -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)
})
})

View File

@ -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<SearchStringResult> {
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<Class<Doc>>[],
query: DocumentQuery<Doc>,

View File

@ -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<SearchResult> {
return await this.provideSearchFulltext(ctx, query, options)
}
protected async provideTx (ctx: SessionContext, tx: Tx): Promise<TxMiddlewareResult> {
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<SearchResult> {
if (this.next !== undefined) {
return await this.next.searchFulltext(ctx, query, options)
}
return await this.storage.searchFulltext(ctx, query, options)
}
}

View File

@ -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<SearchResult> {
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<Space>): Promise<boolean> {
if (this.privateSpaces[space] === undefined) return false
const account = await getUser(this.storage, ctx)

View File

@ -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<DocChunk> => await Promise.reject(new Error('unsupported')),
closeChunk: async (idx) => {},

View File

@ -80,7 +80,10 @@ describe('server', () => {
}),
load: async (domain: Domain, docs: Ref<Doc>[]) => [],
upload: async (domain: Domain, docs: Doc[]) => {},
clean: async (domain: Domain, docs: Ref<Doc>[]) => {}
clean: async (domain: Domain, docs: Ref<Doc>[]) => {},
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<Doc>[]) => [],
upload: async (domain: Domain, docs: Doc[]) => {},
clean: async (domain: Domain, docs: Ref<Doc>[]) => {}
clean: async (domain: Domain, docs: Ref<Doc>[]) => {},
searchFulltext: async (ctx, query, options) => {
return { docs: [] }
}
}),
sessionFactory: (token, pipeline, broadcast) => new ClientSession(broadcast, token, pipeline),
port: 3336,

View File

@ -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<SearchResult> {
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<TxResult> {
this.total.tx++
this.current.tx++