UBER-942: Rework skill optimization (#3941)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-11-07 19:22:45 +07:00 committed by GitHub
parent 734e200d16
commit 3b135f749a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1063 additions and 247 deletions

View File

@ -101,6 +101,9 @@ export class TTxApplyIf extends TTx implements TxApplyIf {
// All matches should be false for all documents.
notMatch!: DocumentClassQuery<Doc>[]
txes!: TxCUD<Doc>[]
notify!: boolean
extraNotify!: Ref<Class<Doc>>[]
}
@Model(core.class.TxWorkspaceEvent, core.class.Doc)

View File

@ -55,6 +55,28 @@ export const recruitOperation: MigrateOperation = {
}
})
}
},
{
state: 'wrong-categories',
func: async (client): Promise<void> => {
const ops = new TxOperations(client, core.account.System)
while (true) {
const docs = await ops.findAll(
tags.class.TagElement,
{
targetClass: recruit.mixin.Candidate,
category: { $in: [tracker.category.Other, 'document:category:Other' as Ref<TagCategory>] }
},
{ limit: 1000 }
)
for (const d of docs) {
await ops.update(d, { category: recruit.category.Other })
}
if (docs.length === 0) {
break
}
}
}
}
])
}

View File

@ -43,8 +43,8 @@ export function createModel (builder: Builder): void {
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverTracker.trigger.OnComponentRemove,
txMatch: {
_class: core.class.TxCollectionCUD,
'tx._class': core.class.TxCreateDoc
_class: core.class.TxRemoveDoc,
objectClass: tracker.class.Component
}
})

View File

@ -441,10 +441,18 @@ export class ApplyOperations extends TxOperations {
return this
}
async commit (): Promise<boolean> {
async commit (notify: boolean = true, extraNotify: Ref<Class<Doc>>[] = []): Promise<boolean> {
if (this.txes.length > 0) {
return await ((await this.ops.tx(
this.ops.txFactory.createTxApplyIf(core.space.Tx, this.scope, this.matches, this.notMatches, this.txes)
this.ops.txFactory.createTxApplyIf(
core.space.Tx,
this.scope,
this.matches,
this.notMatches,
this.txes,
notify,
extraNotify
)
)) as Promise<boolean>)
}
return true

View File

@ -51,16 +51,17 @@ export enum WorkspaceEvent {
Upgrade,
IndexingUpdate,
SecurityChange,
MaintenanceNotification
MaintenanceNotification,
BulkUpdate
}
/**
* Event to be send by server during model upgrade procedure.
* @public
*/
export interface TxWorkspaceEvent extends Tx {
export interface TxWorkspaceEvent<T = any> extends Tx {
event: WorkspaceEvent
params: any
params: T
}
/**
@ -70,6 +71,13 @@ export interface IndexingUpdateEvent {
_class: Ref<Class<Doc>>[]
}
/**
* @public
*/
export interface BulkUpdateEvent {
_class: Ref<Class<Doc>>[]
}
/**
* @public
*/
@ -125,6 +133,11 @@ export interface TxApplyIf extends Tx {
// If all matched execute following transactions.
txes: TxCUD<Doc>[]
notify?: boolean // If false will not send notifications.
// If passed, will send WorkspaceEvent.BulkUpdate event with list of classes to update
extraNotify?: Ref<Class<Doc>>[]
}
/**
@ -591,6 +604,8 @@ export class TxFactory {
match: DocumentClassQuery<Doc>[],
notMatch: DocumentClassQuery<Doc>[],
txes: TxCUD<Doc>[],
notify: boolean = true,
extraNotify: Ref<Class<Doc>>[] = [],
modifiedOn?: Timestamp,
modifiedBy?: Ref<Account>
): TxApplyIf {
@ -604,7 +619,9 @@ export class TxFactory {
scope,
match,
notMatch,
txes
txes,
notify,
extraNotify
}
}
}

View File

@ -24,6 +24,7 @@
IconCheck,
IconSearch,
ListView,
Spinner,
createFocusManager,
deviceOptionsStore,
resizeObserver,
@ -59,6 +60,7 @@
export let disallowDeselect: Ref<Doc>[] | undefined = undefined
export let created: Doc[] = []
export let embedded: boolean = false
export let loading = false
let search: string = ''
@ -185,7 +187,7 @@
icon={IconAdd}
showTooltip={{ label: create.label }}
on:click={onCreate}
disabled={readonly}
disabled={readonly || loading}
/>
</div>
{/if}
@ -210,20 +212,24 @@
{@const isDeselectDisabled = selectedElements.has(obj._id) && forbiddenDeselectItemIds.has(obj._id)}
<button
class="menu-item withList w-full flex-row-center"
disabled={readonly || isDeselectDisabled}
disabled={readonly || isDeselectDisabled || loading}
on:click={() => {
handleSelection(undefined, objects, item)
}}
>
<span class="label" class:disabled={readonly || isDeselectDisabled}>
<span class="label" class:disabled={readonly || isDeselectDisabled || loading}>
<slot name="item" item={obj} />
</span>
{#if (allowDeselect && selected) || multiSelect || selected}
<div class="check" class:disabled={readonly}>
{#if obj._id === selected || selectedElements.has(obj._id)}
<div use:tooltip={{ label: titleDeselect ?? presentation.string.Deselect }}>
<Icon icon={IconCheck} size={'small'} />
</div>
{#if loading}
<Spinner size={'small'} />
{:else}
<div use:tooltip={{ label: titleDeselect ?? presentation.string.Deselect }}>
<Icon icon={IconCheck} size={'small'} />
</div>
{/if}
{/if}
</div>
{/if}

View File

@ -16,11 +16,11 @@
import type { Class, Doc, DocumentQuery, FindOptions, Ref } from '@hcengineering/core'
import type { IntlString } from '@hcengineering/platform'
import { Label } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import presentation from '..'
import { ObjectCreate } from '../types'
import { createQuery } from '../utils'
import DocPopup from './DocPopup.svelte'
import { createEventDispatcher } from 'svelte'
export let _class: Ref<Class<Doc>>
export let options: FindOptions<Doc> | undefined = undefined
@ -47,6 +47,7 @@
export let readonly = false
export let disallowDeselect: Ref<Doc>[] | undefined = undefined
export let embedded: boolean = false
export let loading = false
export let filter: (it: Doc) => boolean = () => {
return true
@ -110,6 +111,7 @@
{readonly}
{disallowDeselect}
{embedded}
{loading}
on:update
on:close
on:changeContent

View File

@ -28,6 +28,7 @@ import core, {
getObjectValue,
Hierarchy,
IndexingUpdateEvent,
BulkUpdateEvent,
Lookup,
LookupData,
matchQuery,
@ -1034,6 +1035,31 @@ export class LiveQuery extends TxProcessor implements Client {
}
}
}
if (evt.event === WorkspaceEvent.BulkUpdate) {
const params = evt.params as BulkUpdateEvent
for (const q of [...this.queue]) {
if (params._class.includes(q._class)) {
if (!this.removeFromQueue(q)) {
try {
await this.refresh(q)
} catch (err) {
console.error(err)
}
}
}
}
for (const v of this.queries.values()) {
for (const q of v) {
if (params._class.includes(q._class)) {
try {
await this.refresh(q)
} catch (err) {
console.error(err)
}
}
}
}
}
}
private async changePrivateHandler (tx: Tx): Promise<void> {

View File

@ -21,6 +21,7 @@
import Icon from './Icon.svelte'
import Button from './Button.svelte'
import IconClose from './icons/Close.svelte'
import Spinner from './Spinner.svelte'
export let icon: Asset | AnySvelteComponent | ComponentType
export let width: string | undefined = undefined
@ -29,6 +30,7 @@
export let placeholderParam: any | undefined = undefined
export let autoFocus: boolean = false
export let size: 'small' | 'medium' | 'large' = 'medium'
export let loading = false
const dispatch = createEventDispatcher()
let textHTML: HTMLInputElement
@ -52,21 +54,28 @@
<div class="mr-2 content-dark-color"><Icon {icon} size={'small'} /></div>
<input bind:this={textHTML} type="text" bind:value placeholder={phTraslate} on:change on:input on:keydown />
<slot name="extra" />
{#if value}
<div class="ml-2">
<Button
icon={IconClose}
kind={'ghost'}
size={'small'}
noFocus
on:click={() => {
value = ''
dispatch('change', '')
textHTML.focus()
}}
/>
</div>
{/if}
<div class="flex-row-center">
{#if value}
<div class="ml-2">
<Button
icon={IconClose}
kind={'ghost'}
size={'small'}
noFocus
on:click={() => {
value = ''
dispatch('change', '')
textHTML.focus()
}}
/>
</div>
{/if}
{#if loading}
<div class="ml-1 mr-2">
<Spinner size={'medium'} />
</div>
{/if}
</div>
</div>
<style lang="scss">

View File

@ -148,17 +148,17 @@
class:anim={element === 'float' || element === 'centered'}
bind:this={modalHTML}
style={`z-index: ${zIndex + 1};`}
style:top={options.props.top}
style:bottom={options.props.bottom}
style:left={options.props.left}
style:right={options.props.right}
style:width={options.props.width}
style:height={options.props.height}
style:max-width={options.props.maxWidth}
style:max-height={options.props.maxHeight}
style:min-width={options.props.minWidth}
style:min-height={options.props.minHeight}
style:transform={options.props.transform}
style:top={options?.props?.top}
style:bottom={options?.props?.bottom}
style:left={options?.props?.left}
style:right={options?.props?.right}
style:width={options?.props?.width}
style:height={options?.props?.height}
style:max-width={options?.props?.maxWidth}
style:max-height={options?.props?.maxHeight}
style:min-width={options?.props?.minWidth}
style:min-height={options?.props?.minHeight}
style:transform={options?.props?.transform}
use:resizeObserver={(element) => {
clientWidth = element.clientWidth
clientHeight = element.clientHeight
@ -187,7 +187,7 @@
{#if overlay}
<div
class="modal-overlay"
class:antiOverlay={options.showOverlay}
class:antiOverlay={options?.showOverlay}
style={`z-index: ${zIndex};`}
on:click={handleOverlayClick}
on:keydown|stopPropagation|preventDefault={() => {}}

View File

@ -23,6 +23,7 @@
import Icon from './Icon.svelte'
import Label from './Label.svelte'
import ListView from './ListView.svelte'
import Spinner from './Spinner.svelte'
import IconCheck from './icons/Check.svelte'
import IconSearch from './icons/Search.svelte'
@ -35,6 +36,7 @@
export let onSelect: ((value: SelectPopupValueType['id']) => void) | undefined = undefined
export let showShadow: boolean = true
export let embedded: boolean = false
export let loading = false
let popupElement: HTMLDivElement | undefined = undefined
let search: string = ''
@ -46,7 +48,10 @@
let selection = 0
let list: ListView
let selected: any
function sendSelect (id: SelectPopupValueType['id']): void {
selected = id
if (onSelect) {
onSelect(id)
} else {
@ -129,7 +134,7 @@
>
<svelte:fragment slot="item" let:item={itemId}>
{@const item = filteredObjects[itemId]}
<button class="menu-item withList w-full" on:click={() => sendSelect(item.id)}>
<button class="menu-item withList w-full" on:click={() => sendSelect(item.id)} disabled={loading}>
<div class="flex-row-center flex-grow pointer-events-none">
{#if item.component}
<div class="flex-grow clear-mins"><svelte:component this={item.component} {...item.props} /></div>
@ -154,6 +159,9 @@
{/if}
</div>
{/if}
{#if item.id === selected && loading}
<Spinner size={'small'} />
{/if}
</div>
</button>
</svelte:fragment>
@ -162,11 +170,6 @@
{#if obj.category && ((row === 0 && obj.category.label !== undefined) || obj.category.label !== filteredObjects[row - 1]?.category?.label)}
{#if row > 0}<div class="menu-separator" />{/if}
<div class="menu-group__header flex-row-center">
<!-- {#if obj.category.icon}
<div class="flex-no-shrink mr-2">
<Icon icon={obj.category.icon} size={'small'} />
</div>
{/if} -->
<span class="overflow-label">
<Label label={obj.category.label} />
</span>

View File

@ -14,11 +14,12 @@
-->
<script lang="ts">
import { Event } from '@hcengineering/calendar'
import { Doc } from '@hcengineering/core'
import { DateRangeMode, Doc } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { DatePresenter, DateTimeRangePresenter, Label, showPopup } from '@hcengineering/ui'
import view, { ObjectEditor } from '@hcengineering/view'
import calendar from '../plugin'
import DateRangePresenter from '@hcengineering/ui/src/components/calendar/DateRangePresenter.svelte'
export let value: Event
export let hideDetails: boolean = false
@ -50,7 +51,10 @@
{#if value.allDay}
<DatePresenter value={value.date} />
{:else}
<DateTimeRangePresenter value={value.date} />
<div class="flex-row-center">
<DateTimeRangePresenter value={value.date} /> <span class="p-1">-</span>
<DateRangePresenter value={value.dueDate} mode={DateRangeMode.TIME} editable={false} />
</div>
{/if}
{/if}
{/if}

View File

@ -55,7 +55,8 @@ export {
EventTimeExtraButton,
EventReminders,
VisibilityEditor,
CalendarSelector
CalendarSelector,
EventPresenter
}
export type {

View File

@ -245,26 +245,30 @@ class Connection implements ClientConnection {
}
void broadcastEvent(client.event.NetworkRequests, this.requests.size)
} else {
const tx = resp.result as Tx
if (
(tx?._class === core.class.TxWorkspaceEvent && (tx as TxWorkspaceEvent).event === WorkspaceEvent.Upgrade) ||
tx?._class === core.class.TxModelUpgrade
) {
console.log('Processing upgrade')
websocket.send(
serialize(
{
method: '#upgrading',
params: [],
id: -1
},
false
const txArr = Array.isArray(resp.result) ? (resp.result as Tx[]) : [resp.result as Tx]
for (const tx of txArr) {
if (
(tx?._class === core.class.TxWorkspaceEvent &&
(tx as TxWorkspaceEvent).event === WorkspaceEvent.Upgrade) ||
tx?._class === core.class.TxModelUpgrade
) {
console.log('Processing upgrade')
websocket.send(
serialize(
{
method: '#upgrading',
params: [],
id: -1
},
false
)
)
)
this.onUpgrade?.()
return
this.onUpgrade?.()
return
}
this.handler(tx)
}
this.handler(tx)
clearTimeout(this.incomingTimer)
void broadcastEvent(client.event.NetworkRequests, this.requests.size + 1)
@ -292,7 +296,8 @@ class Connection implements ClientConnection {
params: [],
id: -1,
binary: useBinary,
compression: useCompression
compression: useCompression,
broadcast: true
}
websocket.send(serialize(helloRequest, false))
}

View File

@ -26,6 +26,7 @@
IconSearch,
Label,
ListView,
Spinner,
createFocusManager,
deviceOptionsStore,
resizeObserver,
@ -49,6 +50,7 @@
export let width: 'medium' | 'large' | 'full' = 'medium'
export let searchField: string = 'name'
export let icon: Asset | AnySvelteComponent | undefined = undefined
export let loading = false
$: showCategories = categories !== undefined && categories.length > 0
@ -77,7 +79,14 @@
{ ...(options ?? {}), limit: 200, sort: { name: 1 } }
)
$: updateCategories(objects, categories)
let dataLoading = false
$: {
dataLoading = true
updateCategories(objects, categories).then(() => {
dataLoading = false
})
}
const currentUserCategory: AssigneeCategory = {
label: contact.string.CategoryCurrentUser,
@ -117,6 +126,7 @@
if (c) {
contacts.push(c)
}
contacts = contacts
})
}
@ -174,6 +184,7 @@
bind:value={search}
{placeholder}
{placeholderParam}
loading={dataLoading}
on:change
/>
</div>
@ -209,13 +220,17 @@
<UserInfo size={'smaller'} value={obj} {icon} />
</div>
{#if allowDeselect && selected}
<div class="check">
{#if obj._id === selected}
<div use:tooltip={{ label: titleDeselect ?? presentation.string.Deselect }}>
<Icon icon={IconCheck} size={'small'} />
</div>
{/if}
</div>
{#if loading && obj._id === selected}
<Spinner size={'small'} />
{:else}
<div class="check">
{#if obj._id === selected}
<div use:tooltip={{ label: titleDeselect ?? presentation.string.Deselect }}>
<Icon icon={IconCheck} size={'small'} />
</div>
{/if}
</div>
{/if}
{/if}
</button>
</svelte:fragment>

View File

@ -157,17 +157,23 @@
const elementQuery = createQuery()
let elementsPromise: Promise<void>
$: elementsPromise = new Promise((resolve) => {
elementQuery.query(tags.class.TagElement, {}, (result) => {
const ne = new Map<Ref<TagElement>, TagElement>()
const nne = new Map<string, TagElement>()
for (const t of newElements.concat(result)) {
ne.set(t._id, t)
nne.set(t.title.trim().toLowerCase(), t)
elementQuery.query(
tags.class.TagElement,
{
targetClass: recruit.mixin.Candidate
},
(result) => {
const ne = new Map<Ref<TagElement>, TagElement>()
const nne = new Map<string, TagElement>()
for (const t of newElements.concat(result)) {
ne.set(t._id, t)
nne.set(t.title.trim().toLowerCase(), t)
}
elements = ne
namedElements = nne
resolve()
}
elements = ne
namedElements = nne
resolve()
})
)
})
async function createCandidate () {

View File

@ -0,0 +1,551 @@
<!--
// Copyright © 2023 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 { Class, Doc, Ref, toIdMap } from '@hcengineering/core'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { Card, getClient } from '@hcengineering/presentation'
import tags, { TagCategory, TagElement, TagReference } from '@hcengineering/tags'
import { EditBox, ListView, Loading } from '@hcengineering/ui'
import { FILTER_DEBOUNCE_MS } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import recruit from '../plugin'
export let targetClass: Ref<Class<Doc>>
export function canClose (): boolean {
return true
}
const dispatch = createEventDispatcher()
// const query = createQuery()
// const elementsQuery = createQuery()
// const refsQuery = createQuery()
let categories: TagCategory[] = []
let elements: TagElement[] = []
// let refs: TagReference[] = []
let loading1: boolean = false
let loading2: boolean = false
$: {
loading1 = true
getClient()
.findAll(tags.class.TagCategory, { targetClass })
.then((result) => {
categories = result
loading1 = false
})
}
$: {
loading2 = true
getClient()
.findAll(
tags.class.TagElement,
{ category: { $in: Array.from(categories.map((it) => it._id)) } },
{ sort: { title: 1 } }
)
.then((res) => {
elements = res.toSorted((a, b) => prepareTitle(a.title).localeCompare(prepareTitle(b.title)))
loading2 = false
})
}
// $: refsQuery.query(tags.class.TagReference, { tag: { $in: Array.from(elements.map((it) => it._id)) } }, (res) => {
// refs = res
// })
const selection = 0
$: selEl = elements[selection]
interface TagUpdatePlan {
// Just updated or new elements
elements: {
original: TagElement
element?: TagElement
move: Ref<TagElement>[]
toDelete: boolean
total?: number
}[]
move: number
}
function prepareTitle (title: string): string {
// Replace all non letter or digit characters with spaces
let result = ''
let last = ''
for (const c of title) {
if (c === '-') {
continue
}
if (isLetterOrDigit(c) || (result.length > 0 && (c === '+' || c === '#'))) {
if (c !== '+' && last === '+') {
// Remove + in the middle
result = result.substring(0, result.length - 1)
}
if (c !== '#' && last === '#') {
// Remove # in the middle
result = result.substring(0, result.length - 1)
}
result += c
last = c
} else {
if (last !== ' ') {
result += ' '
last = ' '
}
}
}
return result.trim()
}
function isLetter (c?: string): boolean {
if (c == null) {
return false
}
return c.toLowerCase() !== c.toUpperCase()
}
function isDigit (c?: string): boolean {
return (
c === '0' ||
c === '1' ||
c === '2' ||
c === '3' ||
c === '4' ||
c === '5' ||
c === '6' ||
c === '7' ||
c === '8' ||
c === '9'
)
}
function isLetterOrDigit (c?: string): boolean {
if (c == null) {
return false
}
if (isDigit(c)) {
return true
}
return c.toLowerCase() !== c.toUpperCase()
}
function isForRemove (cc: string): boolean {
for (const c of cc) {
if (isLetter(c)) {
return false
}
}
// Only digits and non letters
return true
}
let plan: TagUpdatePlan = {
elements: [],
move: 0
}
// Will return a set of operations over tag elements
async function updateTagsList (tagElements: TagElement[]): Promise<void> {
const _plan: TagUpdatePlan = {
elements: [],
move: 0
}
const tagMap = toIdMap(tagElements)
const namedElements = new Map<string, Ref<TagElement>>()
const goodTags: TagElement[] = []
for (const tag of tagElements) {
if (tag.category.indexOf(recruit.category.Category) >= 0) {
namedElements.set(prepareTitle(tag.title.toLowerCase()), tag._id)
goodTags.push(tag)
}
}
const expertRefs = await getClient().findAll(
tags.class.TagReference,
{
tag: {
$in: Array.from(tagElements.map((it) => it._id))
},
weight: { $gt: 5 } // We need expert ones.
},
{
projection: {
tag: 1,
_id: 1,
title: 1
}
}
)
const toGoodTags = Array.from(new Set(expertRefs.map((it) => it.tag)))
.map((it) => tagMap.get(it))
.filter((it) => it) as TagElement[]
let goodTagMap = toIdMap(goodTags)
for (const tag of toGoodTags) {
const tt = prepareTitle(tag.title.toLowerCase())
if (!goodTagMap.has(tag._id) && !namedElements.has(tt)) {
namedElements.set(tt, tag._id)
goodTags.push(tag)
}
}
goodTagMap = toIdMap(goodTags)
const goodSortedTags = goodTags
.toSorted((a, b) => b.title.length - a.title.length)
.filter((t) => t.title.length > 2)
const goodSortedTagsTitles = new Map<Ref<TagElement>, string>()
processed = -1
for (const tag of tagElements.toSorted((a, b) => prepareTitle(a.title).length - prepareTitle(b.title).length)) {
processed++
if (goodTagMap.has(tag._id)) {
_plan.elements.push({
original: tag,
move: [],
toDelete: false,
total: -1
})
continue
}
let title = prepareTitle(tag.title)
if (title.length === 1) {
_plan.elements.push({
original: tag,
move: [],
toDelete: true
})
continue
}
const namedIdx = namedElements.get(prepareTitle(title.toLowerCase()))
if (namedIdx !== undefined || title.length === 0) {
_plan.elements.push({
original: tag,
move: namedIdx !== undefined ? [namedIdx] : [],
toDelete: true
})
_plan.move++
continue
}
// Search for included tags
const toReplace = goodSortedTags.filter((t) => {
const lowTitle = title.toLowerCase()
let tt = goodSortedTagsTitles.get(t._id)
if (tt === undefined) {
tt = prepareTitle(t.title.toLowerCase())
goodSortedTagsTitles.set(t._id, tt)
}
if (lowTitle.indexOf(tt) !== -1) {
// We need to be sure we have some non word character at the end of match
let spos = 0
while (true) {
const pos = title.toLowerCase().indexOf(tt, spos)
if (pos === -1) {
return false
}
if (!isLetter(title[pos - 1]) && !isLetter(title[pos + tt.length])) {
// Begin and end
return true
}
spos = pos + 1
}
}
return false
})
if (toReplace.length > 0) {
const mve: TagUpdatePlan['elements'][0] = {
original: tag,
move: [],
toDelete: false
}
for (const t of toReplace) {
let tt = goodSortedTagsTitles.get(t._id)
if (tt === undefined) {
tt = prepareTitle(t.title.toLowerCase())
goodSortedTagsTitles.set(t._id, tt)
}
let p = 0
while (true) {
const pos = title.toLowerCase().indexOf(tt.toLowerCase())
if (pos === -1) {
break
}
p++
title = prepareTitle((title.substring(0, pos) + ' ' + title.substring(pos + tt.length)).replace(' ', ' '))
}
if (p === 0) {
continue
}
mve.move.push(t._id)
}
const namedIdx = namedElements.get(prepareTitle(title).toLowerCase())
if (namedIdx !== undefined) {
title = ''
mve.move.push(namedIdx)
}
mve.element = { ...tag, title: prepareTitle(title) }
mve.toDelete = prepareTitle(title).length <= 1 || isForRemove(title)
if (isForRemove(title)) {
mve.element.title = ''
} else {
// Candidate to have in list.
const refs = await getClient().findAll(tags.class.TagReference, { tag: tag._id }, { limit: 2, total: true })
if (refs.length < 2) {
mve.toDelete = true
mve.total = refs.total
}
}
if (!mve.toDelete) {
namedElements.set(prepareTitle(mve.element.title.toLowerCase()), tag._id)
goodSortedTags.push(mve.element)
goodSortedTags.sort((a, b) => b.title.length - a.title.length).filter((t) => t.title.length > 2)
goodSortedTagsTitles.delete(mve.element._id)
}
_plan.elements.push(mve)
_plan.move++
continue
}
// Candidate to have in list.
const refs = await getClient().findAll(tags.class.TagReference, { tag: tag._id }, { limit: 2, total: true })
if (isForRemove(title) || refs.length < 2) {
_plan.elements.push({
original: tag,
move: [],
toDelete: true
})
_plan.move++
continue
}
namedElements.set(prepareTitle(title.toLowerCase()), tag._id)
const ee = {
original: tag,
element: { ...tag, title },
move: [],
toDelete: false,
total: refs.total
}
_plan.elements.push(ee)
if (ee.element?.title.length > 2) {
goodSortedTags.push(ee.element)
goodSortedTagsTitles.delete(ee.element._id)
goodSortedTags.sort((a, b) => b.title.length - a.title.length).filter((t) => t.title.length > 2)
}
goodTags.push(ee.element)
}
_plan.elements.sort((a, b) => prepareTitle(a.original.title).localeCompare(prepareTitle(b.original.title)))
plan = _plan
processed = 0
}
let doProcessing = false
$: {
doProcessing = true
updateTagsList(elements).then(() => {
doProcessing = false
})
}
let search: string = ''
let _search: string = ''
$: setTimeout(() => {
_search = search
}, FILTER_DEBOUNCE_MS)
$: searchPlanElements = plan.elements.filter(
(it) => it.original.title.toLowerCase().indexOf(_search.toLowerCase()) !== -1
)
let processed: number = 0
async function applyPlan (): Promise<void> {
processed = 0
const updateClasses = new Set<Ref<Class<Doc>>>()
const client = getClient()
for (const item of searchPlanElements) {
console.log('Apply', item.original.title)
const st = Date.now()
const ops = client.apply('optimize:' + item.original._id)
let allRefs: TagReference[] = await client.findAll(tags.class.TagReference, { tag: item.original._id })
allRefs.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
const uniqRefs = new Set()
const uniqTags = new Set()
for (const d of allRefs) {
if (!uniqTags.has(d.tag + d.attachedTo)) {
uniqTags.add(d.tag + d.attachedTo)
uniqRefs.add(d._id)
} else {
await ops.remove(d)
}
}
allRefs = allRefs.filter((it) => uniqRefs.has(it._id))
if (item.move.length > 0) {
// We need to find all objects and add new tag elements to them and preserve skill level
for (const a of allRefs) {
for (const m of item.move) {
const me = plan.elements.find((it) => it.original._id === m && it.toDelete === false)
if (me !== undefined) {
const id = await ops.addCollection(
tags.class.TagReference,
a.space,
a.attachedTo,
a.attachedToClass,
a.collection,
{
tag: m,
color: me.original.color,
title: me.element?.title ?? me.original.title,
weight: a.weight
}
)
uniqRefs.add(id)
}
}
}
}
// We could delete original and it's all refs
if (item.toDelete) {
updateClasses.add(item.original._class)
await ops.remove(item.original)
for (const a of allRefs) {
updateClasses.add(a._class)
await ops.remove(a)
}
} else {
if (item.element !== undefined) {
updateClasses.add(item.original._class)
for (const a of allRefs) {
if (prepareTitle(a.title.toLowerCase()) === prepareTitle(item.element.title.toLowerCase())) {
updateClasses.add(a._class)
await ops.diffUpdate(a, {
title: item.element.title,
color: item.element.color
})
} else {
updateClasses.add(a._class)
// Let's remove and add new tag reference
await ops.remove(a)
updateClasses.add(tags.class.TagReference)
await ops.addCollection(tags.class.TagReference, a.space, a.attachedTo, a.attachedToClass, a.collection, {
tag: item.element._id,
color: item.element.color,
title: item.element.title,
weight: a.weight
})
}
}
await ops.diffUpdate(item.original, {
...item.element,
refCount: uniqRefs.size
})
}
}
console.log('Apply:commit', item.original.title, Date.now() - st)
await ops.commit(false)
processed++
}
const ops = client.apply('optimize:done')
await ops.commit(true, Array.from(updateClasses))
console.log('Apply:done')
processed = 0
}
function toColor (el: TagUpdatePlan['elements'][0]): string | undefined {
if (el.total === -1) {
return 'blue'
}
if (el.toDelete && el.move.length === 0) {
return 'red'
}
if (el.move.length > 0) {
return 'purple'
}
return undefined
}
</script>
<Card
label={getEmbeddedLabel('Skills optimizer')}
okAction={applyPlan}
okLabel={getEmbeddedLabel('Apply')}
canSave={true}
on:close={() => {
dispatch('close')
}}
on:changeContent
>
<div class="flex-row-center">
<EditBox kind={'search-style'} bind:value={search} />
{#if processed > 0}
<div class="p-1">
<Loading />
Processing: {processed} / {searchPlanElements.length}
</div>
{/if}
{#if (loading1 || loading2) && !doProcessing}
<Loading />
{/if}
</div>
<div class="flex clear-mins" style:overflow={'auto'}>
<div class="flex-grow flex-nowrap no-word-wrap">
<div class="flex-row-center">
{elements.length} => {plan.elements.length - plan.move}
</div>
<ListView count={searchPlanElements.length}>
<svelte:fragment slot="item" let:item>
{@const el = searchPlanElements[item]}
<div class="flex-row-center" style:color={toColor(el)}>
{el.original.title}
{#if el.element}
=> {el.element?.title}
{/if}
{#each el.move as mid}
{@const orig = plan.elements.find((it) => it.original._id === mid)}
{#if orig !== undefined}
➡︎ {orig?.element?.title ?? orig?.original.title}
{:else}
{mid}
{/if}
{/each}
{#if (el.total ?? 0) > 0}
({el.total})
{/if}
</div>
</svelte:fragment>
</ListView>
</div>
</div>
</Card>
<style lang="scss">
.color {
width: 1rem;
height: 1rem;
border-radius: 0.25rem;
}
</style>

View File

@ -1,10 +1,12 @@
<script lang="ts">
import tags, { selectedTagElements, TagElement } from '@hcengineering/tags'
import { Component, getCurrentResolvedLocation, navigate } from '@hcengineering/ui'
import { Button, Component, getCurrentResolvedLocation, navigate, showPopup } from '@hcengineering/ui'
import recruit from '../plugin'
import { buildFilterKey, setFilters } from '@hcengineering/view-resources'
import { getClient } from '@hcengineering/presentation'
import { Filter } from '@hcengineering/view'
import { getEmbeddedLabel } from '@hcengineering/platform'
import OptimizeSkills from './OptimizeSkills.svelte'
function setFilterTag (tag: TagElement) {
const client = getClient()
@ -43,4 +45,12 @@
сreateItemLabel: recruit.string.SkillCreateLabel,
onTag
}}
/>
>
<Button
label={getEmbeddedLabel('Optimize')}
kind={'regular'}
on:click={() => {
showPopup(OptimizeSkills, { targetClass: recruit.mixin.Candidate })
}}
/>
</Component>

View File

@ -21,12 +21,14 @@
import tags from '../plugin'
import { getTagStyle } from '../utils'
import TagsCategoryPopup from './TagsCategoryPopup.svelte'
import { TagElementInfo } from '../utils'
export let targetClass: Ref<Class<Doc>>
export let category: Ref<TagCategory> | undefined = undefined
export let selected: Ref<TagElement>[] = []
export let gap: 'small' | 'big' = 'small'
export let mode: 'item' | 'category' = 'category'
export let tagElements: Map<Ref<TagElement>, TagElementInfo> | undefined
let categories: TagCategory[] = []
let visibleCategories: TagCategory[] = []
@ -58,32 +60,6 @@
elementsQuery.unsubscribe()
}
type TagElementInfo = { count: number; modifiedOn: number }
let tagElements: Map<Ref<TagElement>, TagElementInfo> | undefined = undefined
const refQuery = createQuery()
$: refQuery.query(
tags.class.TagReference,
{},
(res) => {
const result = new Map<Ref<TagElement>, TagElementInfo>()
for (const d of res) {
const v = result.get(d.tag) ?? { count: 0, modifiedOn: 0 }
v.count++
v.modifiedOn = Math.max(v.modifiedOn, d.modifiedOn)
result.set(d.tag, v)
}
tagElements = result
},
{
projection: {
_id: 1,
tag: 1
}
}
)
$: {
const counts = new Map<Ref<TagCategory>, TagElement[]>()
for (const e of elements) {
@ -103,7 +79,9 @@
const categoriesQuery = createQuery()
$: categoriesQuery.query(
tags.class.TagCategory,
{},
{
targetClass
},
(res) => {
categories = res
},

View File

@ -49,9 +49,13 @@
class="tag-item-inline overflow-label max-w-40"
on:click
use:tooltip={{
label: element?.description ? tags.string.TagTooltip : undefined,
props: { text: element?.description },
direction: 'right'
label: tags.string.TagTooltip,
props: {
text: `${name} ${
element?.description !== undefined && element?.description.length > 0 ? ': ' + element?.description : ''
}`
},
direction: 'top'
}}
>
{name}
@ -63,9 +67,13 @@
on:click
on:keydown
use:tooltip={{
label: element?.description ? tags.string.TagTooltip : undefined,
props: { text: element?.description },
direction: 'right'
label: tags.string.TagTooltip,
props: {
text: `${name} ${
element?.description !== undefined && element?.description.length > 0 ? ': ' + element?.description : ''
}`
},
direction: 'top'
}}
>
<span class="overflow-label max-w-40">{name}</span>

View File

@ -23,6 +23,7 @@
import CategoryBar from './CategoryBar.svelte'
import CreateTagElement from './CreateTagElement.svelte'
// import { deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
import { TagElementInfo } from '../utils'
export let title: IntlString = tags.string.Tags
export let icon: Asset | AnySvelteComponent = tags.icon.Tags
@ -56,7 +57,6 @@
}
let category: Ref<TagCategory> | undefined = undefined
type TagElementInfo = { count: number; modifiedOn: number }
let tagElements: Map<Ref<TagElement>, TagElementInfo> | undefined
const refQuery = createQuery()
$: refQuery.query(
@ -94,8 +94,9 @@
<span class="ac-header__title"><Label label={title} /></span>
</div>
<div class="clear-mins mb-1">
<div class="clear-mins mb-1 flex-row-center">
<Button icon={IconAdd} label={сreateItemLabel} kind={'primary'} on:click={showCreateDialog} />
<slot />
</div>
</div>
<div class="ac-header full divide search-start">
@ -108,6 +109,7 @@
<CategoryBar
{targetClass}
{category}
{tagElements}
on:change={(evt) => {
category = evt.detail.category ?? undefined
updateResultQuery(search, category)

View File

@ -46,3 +46,11 @@ export const tagLevel: Record<0 | 1 | 2 | 3, Asset> = {
1: tags.icon.Level1,
0: tags.icon.Tags
}
/**
* @public
*/
export interface TagElementInfo {
count: number
modifiedOn: number
}

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { Attribute, Class, IdMap, Ref, Status } from '@hcengineering/core'
import { Attribute, Class, IdMap, Ref, Status, generateId } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { DocPopup, createQuery, getClient } from '@hcengineering/presentation'
import { Project, ProjectType, Task, getStates } from '@hcengineering/task'
@ -17,19 +17,22 @@
const dispatch = createEventDispatcher()
const client = getClient()
let progress = false
const changeStatus = async (newStatus: any) => {
if (newStatus === undefined) {
dispatch('close', undefined)
return
}
progress = true
const docs = Array.isArray(value) ? value : [value]
const ops = client.apply('set-status' + generateId())
const changed = (d: Task) => d.status !== newStatus
await Promise.all(
docs.filter(changed).map((it) => {
return client.update(it, { status: newStatus })
})
)
for (const it of docs.filter(changed)) {
await ops.update(it, { status: newStatus })
}
await ops.commit()
progress = false
dispatch('close', newStatus)
}
@ -82,6 +85,7 @@
{placeholder}
{width}
{embedded}
loading={progress}
on:changeContent
>
<svelte:fragment slot="item" let:item>

View File

@ -13,28 +13,29 @@
// limitations under the License.
-->
<script lang="ts">
import { AttachedData } from '@hcengineering/core'
import { DatePopup } from '@hcengineering/ui'
import { AttachedData, generateId } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Issue, IssueDraft } from '@hcengineering/tracker'
import { DatePopup } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
export let value: Issue | AttachedData<Issue> | Issue[] | IssueDraft
export let mondayStart = true
export let withTime = false
const client = getClient()
const dispatch = createEventDispatcher()
async function onUpdate ({ detail }: CustomEvent<Date | null | undefined>) {
const newDueDate = detail && detail?.getTime()
const vv = Array.isArray(value) ? value : [value]
const ops = getClient().apply(generateId())
for (const docValue of vv) {
if ('_class' in docValue && newDueDate !== undefined && newDueDate !== docValue.dueDate) {
await client.update(docValue, { dueDate: newDueDate })
await ops.update(docValue, { dueDate: newDueDate })
}
}
await ops.commit()
dispatch('update', newDueDate)
}

View File

@ -16,7 +16,7 @@
import contact, { Employee, Person, PersonAccount } from '@hcengineering/contact'
import { AssigneeBox, AssigneePopup, personAccountByIdStore } from '@hcengineering/contact-resources'
import { AssigneeCategory } from '@hcengineering/contact-resources/src/assignee'
import { Account, Doc, DocumentQuery, Ref, Space } from '@hcengineering/core'
import { Account, Doc, DocumentQuery, Ref, Space, generateId } from '@hcengineering/core'
import { RuleApplyResult, getClient, getDocRules } from '@hcengineering/presentation'
import { Component, Issue } from '@hcengineering/tracker'
import { ButtonKind, ButtonSize, IconSize, TooltipAlignment } from '@hcengineering/ui'
@ -48,26 +48,30 @@
const client = getClient()
const dispatch = createEventDispatcher()
let progress = false
const handleAssigneeChanged = async (newAssignee: Ref<Person> | undefined | null) => {
if (newAssignee === undefined || (!Array.isArray(_object) && _object?.assignee === newAssignee)) {
return
}
progress = true
const ops = client.apply(generateId())
if (Array.isArray(_object)) {
await Promise.all(
_object.map(async (p) => {
if ('_class' in p) {
await client.update(p, { assignee: newAssignee })
}
})
)
for (const p of _object) {
if ('_class' in p) {
await ops.update(p, { assignee: newAssignee })
}
}
} else {
if ('_class' in _object) {
await client.update(_object as any, { assignee: newAssignee })
await ops.update(_object as any, { assignee: newAssignee })
}
}
await ops.commit()
progress = false
dispatch('change', newAssignee)
if (isAction) dispatch('close')
}
@ -156,6 +160,7 @@
selected={sel}
allowDeselect={true}
titleDeselect={undefined}
loading={progress}
on:close={(evt) => {
const result = evt.detail
if (result === null) {

View File

@ -530,7 +530,7 @@ export async function getPreviousAssignees (objectId: Ref<Doc> | undefined): Pro
{ sort: { modifiedOn: -1 } }
)
const set: Set<Ref<Contact>> = new Set()
const createAssignee = (createTx.tx as TxCreateDoc<Issue>).attributes.assignee
const createAssignee = (createTx?.tx as TxCreateDoc<Issue>)?.attributes?.assignee
for (const tx of updateTxes) {
const assignee = (tx.tx as TxUpdateDoc<Issue>).operations.assignee
if (assignee == null) continue

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { Class, Doc, DocumentQuery, FindOptions, Hierarchy, Mixin, Ref } from '@hcengineering/core'
import { Class, Doc, DocumentQuery, FindOptions, Hierarchy, Mixin, Ref, generateId } from '@hcengineering/core'
import { Asset, IntlString } from '@hcengineering/platform'
import { getClient, ObjectPopup, updateAttribute } from '@hcengineering/presentation'
import { ObjectPopup, getClient, updateAttribute } from '@hcengineering/presentation'
import { Label, SelectPopup, resizeObserver } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import view from '../plugin'
@ -32,6 +32,7 @@
export let size: 'small' | 'medium' | 'large' = 'small'
export let embedded: boolean = false
let progress = false
const dispatch = createEventDispatcher()
const client = getClient()
const hierarchy = client.getHierarchy()
@ -44,24 +45,24 @@
dispatch('close', undefined)
return
}
progress = true
const docs = Array.isArray(value) ? value : [value]
const changed = (d: Doc) => (d as any)[attribute] !== newStatus
await Promise.all(
docs.filter(changed).map((it) => {
// c.update(it, { [attribute]: newStatus } )
const cl = Hierarchy.mixinOrClass(it)
const attr =
castRequest !== undefined
? hierarchy.getAttribute(castRequest, attribute)
: hierarchy.getAttribute(cl, attribute)
if (attr === undefined) {
throw new Error('attribute not found')
}
return updateAttribute(client, it, cl, { key: attribute, attr }, newStatus)
})
)
const ops = client.apply('value-selector:' + generateId())
for (const it of docs.filter(changed)) {
const cl = Hierarchy.mixinOrClass(it)
const attr =
castRequest !== undefined
? hierarchy.getAttribute(castRequest, attribute)
: hierarchy.getAttribute(cl, attribute)
if (attr === undefined) {
throw new Error('attribute not found')
}
await updateAttribute(ops, it, cl, { key: attribute, attr }, newStatus)
}
await ops.commit()
progress = false
dispatch('close', newStatus)
}
@ -135,6 +136,7 @@
{width}
{size}
{embedded}
loading={progress}
on:changeContent
/>
{:else if _class !== undefined}
@ -152,6 +154,7 @@
{width}
{size}
{embedded}
loading={progress}
on:changeContent
>
<svelte:fragment slot="item" let:item>

View File

@ -101,6 +101,19 @@
$: initialLimit = !lastLevel ? undefined : singleCat ? singleCategoryLimit : defaultLimit
$: limit = initialLimit
$: selection = $focusStore.provider?.selection
let selectedMatch: Ref<Doc>[] = []
$: if (itemProj !== undefined && itemProj.length > 0 && $selection !== undefined && $selection.length > 0) {
// update limit if we have selected items.
const prj = new Set(itemProj.map((it) => it._id))
selectedMatch = $selection.filter((it) => prj.has(it._id)).map((it) => it._id)
if (selectedMatch.length > (limit ?? 0)) {
limit = (limit ?? 0) + selectedMatch.length
}
}
$: if (lastLevel) {
limiter.add(async () => {
loading = docsQuery.query(

View File

@ -551,7 +551,7 @@
res((status) => handleSupportStatusChanged(status))
)
onDestroy(async () => {
await supportClient?.then((support) => support.destroy())
await supportClient?.then((support) => support?.destroy())
})
let supportWidgetLoading = false

View File

@ -104,7 +104,8 @@ export async function connect (title: string): Promise<Client | undefined> {
if (currentVersionStr !== reconnectVersionStr) {
// It seems upgrade happened
location.reload()
// location.reload()
versionError = `${currentVersionStr} != ${reconnectVersionStr}`
}
const serverVersion: { version: string } = await (
await fetch(serverEndpoint + '/api/v1/version', {})
@ -151,9 +152,6 @@ export async function connect (title: string): Promise<Client | undefined> {
if (version === undefined || requiredVersion !== versionStr) {
versionError = `${versionStr} => ${requiredVersion}`
setTimeout(() => {
window.location.reload()
}, 5000)
return undefined
}
}
@ -168,17 +166,10 @@ export async function connect (title: string): Promise<Client | undefined> {
) {
const versionStr = version !== undefined ? versionToString(version) : 'unknown'
versionError = `${versionStr} => ${serverVersion.version}`
setTimeout(() => {
window.location.reload()
}, 5000)
return
}
} catch (err: any) {
versionError = 'server version not available'
setTimeout(() => {
window.location.reload()
}, 5000)
return
}
} catch (err: any) {

View File

@ -314,20 +314,29 @@ async function BacklinksCreate (tx: Tx, control: TriggerControl): Promise<Tx[]>
async function BacklinksUpdate (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const ctx = TxProcessor.extractTx(tx) as TxUpdateDoc<Doc>
if (ctx._class !== core.class.TxUpdateDoc) return []
if (control.hierarchy.isDerived(ctx.objectClass, chunter.class.Backlink)) return []
const rawDoc = (await control.findAll(ctx.objectClass, { _id: ctx.objectId }))[0]
let hasUpdates = false
const attributes = control.hierarchy.getAllAttributes(ctx.objectClass)
for (const attr of attributes.values()) {
if (attr.type._class === core.class.TypeMarkup && attr.name in ctx.operations) {
hasUpdates = true
break
}
}
if (rawDoc !== undefined) {
const txFactory = new TxFactory(control.txFactory.account)
if (hasUpdates) {
const rawDoc = (await control.findAll(ctx.objectClass, { _id: ctx.objectId }))[0]
const doc = TxProcessor.updateDoc2Doc(rawDoc, ctx)
const targetTx = guessBacklinkTx(control.hierarchy, tx as TxCUD<Doc>)
const txes: Tx[] = await getUpdateBacklinksTxes(control, txFactory, doc, targetTx.objectId, targetTx.objectClass)
if (rawDoc !== undefined) {
const txFactory = new TxFactory(control.txFactory.account)
if (txes.length !== 0) {
await control.apply(txes, true)
const doc = TxProcessor.updateDoc2Doc(rawDoc, ctx)
const targetTx = guessBacklinkTx(control.hierarchy, tx as TxCUD<Doc>)
const txes: Tx[] = await getUpdateBacklinksTxes(control, txFactory, doc, targetTx.objectId, targetTx.objectClass)
if (txes.length !== 0) {
await control.apply(txes, true)
}
}
}
@ -335,16 +344,24 @@ async function BacklinksUpdate (tx: Tx, control: TriggerControl): Promise<Tx[]>
}
async function BacklinksRemove (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const hierarchy = control.hierarchy
const ctx = TxProcessor.extractTx(tx) as TxRemoveDoc<Doc>
if (ctx._class !== core.class.TxRemoveDoc) return []
if (hierarchy.isDerived(ctx.objectClass, chunter.class.Backlink)) return []
const txFactory = new TxFactory(control.txFactory.account)
let hasMarkdown = false
const attributes = control.hierarchy.getAllAttributes(ctx.objectClass)
for (const attr of attributes.values()) {
if (attr.type._class === core.class.TypeMarkup) {
hasMarkdown = true
break
}
}
const txes: Tx[] = await getRemoveBacklinksTxes(control, txFactory, ctx.objectId)
if (txes.length !== 0) {
await control.apply(txes, true)
if (hasMarkdown) {
const txFactory = new TxFactory(control.txFactory.account)
const txes: Tx[] = await getRemoveBacklinksTxes(control, txFactory, ctx.objectId)
if (txes.length !== 0) {
await control.apply(txes, true)
}
}
return []
@ -416,9 +433,21 @@ export async function OnMessageSent (tx: Tx, control: TriggerControl): Promise<T
* @public
*/
export async function BacklinkTrigger (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const promises = [BacklinksCreate(tx, control), BacklinksUpdate(tx, control), BacklinksRemove(tx, control)]
const res = await Promise.all(promises)
return res.flat()
const result: Tx[] = []
const ctx = TxProcessor.extractTx(tx) as TxCreateDoc<Doc>
if (control.hierarchy.isDerived(ctx.objectClass, chunter.class.Backlink)) return []
if (ctx._class === core.class.TxCreateDoc) {
result.push(...(await BacklinksCreate(tx, control)))
}
if (ctx._class === core.class.TxUpdateDoc) {
result.push(...(await BacklinksUpdate(tx, control)))
}
if (ctx._class === core.class.TxRemoveDoc) {
result.push(...(await BacklinksRemove(tx, control)))
}
return result
}
/**

View File

@ -687,16 +687,17 @@ export async function createCollaboratorDoc (
const res: Tx[] = []
const hierarchy = control.hierarchy
const mixin = hierarchy.classHierarchyMixin(tx.objectClass, notification.mixin.ClassCollaborators)
const doc = TxProcessor.createDoc2Doc(tx)
if (mixin !== undefined) {
const doc = TxProcessor.createDoc2Doc(tx)
const collaborators = await getDocCollaborators(doc, mixin, control)
const mixinTx = getMixinTx(tx, control, collaborators)
const notificationTxes = await createCollabDocInfo(collaborators, control, tx, originTx, doc, true)
res.push(mixinTx)
res.push(...notificationTxes)
res.push(...(await getSpaceCollabTxes(control, doc, tx, originTx)))
}
res.push(...(await getSpaceCollabTxes(control, doc, tx, originTx)))
return res
}
@ -786,11 +787,14 @@ async function collectionCollabDoc (tx: TxCollectionCUD<Doc, AttachedDoc>, contr
const actualTx = TxProcessor.extractTx(tx) as TxCUD<Doc>
let res = await collaboratorDocHandler(actualTx, control, tx)
if ([core.class.TxCreateDoc, core.class.TxRemoveDoc].includes(actualTx._class)) {
const doc = (await control.findAll(tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0]
if (doc !== undefined) {
if (control.hierarchy.hasMixin(doc, notification.mixin.Collaborators)) {
const collabMixin = control.hierarchy.as(doc, notification.mixin.Collaborators)
res = res.concat(await createCollabDocInfo(collabMixin.collaborators, control, actualTx, tx, doc, false))
const mixin = control.hierarchy.classHierarchyMixin(tx.objectClass, notification.mixin.ClassCollaborators)
if (mixin !== undefined) {
const doc = (await control.findAll(tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0]
if (doc !== undefined) {
if (control.hierarchy.hasMixin(doc, notification.mixin.Collaborators)) {
const collabMixin = control.hierarchy.as(doc, notification.mixin.Collaborators)
res = res.concat(await createCollabDocInfo(collabMixin.collaborators, control, actualTx, tx, doc, false))
}
}
}
}

View File

@ -165,7 +165,7 @@ export async function getIssueNotificationContent (
* @public
*/
export async function OnComponentRemove (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const ctx = TxProcessor.extractTx(tx) as TxUpdateDoc<Component>
const ctx = TxProcessor.extractTx(tx) as TxRemoveDoc<Component>
const issues = await control.findAll(tracker.class.Issue, {
component: ctx.objectId

View File

@ -644,7 +644,6 @@ export async function restore (
let idx: number | undefined
let loaded = 0
let last = 0
let el = 0
let chunks = 0
while (true) {
@ -660,9 +659,7 @@ export async function restore (
loaded++
}
const mr = Math.round(loaded / 10000)
if (mr !== last) {
last = mr
if (el > 2500) {
console.log(' loaded from server', loaded, el, chunks)
el = 0
chunks = 0

View File

@ -564,7 +564,9 @@ export class FullTextIndexPipeline implements FullTextPipeline {
if (modelVersion !== undefined) {
const modelVersionString = versionToString(modelVersion)
if (modelVersionString !== process.env.MODEL_VERSION) {
console.error('Indexer: Model version mismatch', modelVersionString, process.env.MODEL_VERSION)
console.error(
`Indexer: Model version mismatch model: ${modelVersionString} env: ${process.env.MODEL_VERSION}`
)
return
}
}

View File

@ -569,9 +569,7 @@ class TServerStorage implements ServerStorage {
}
const triggers = await ctx.with('process-triggers', {}, async (ctx) => {
const result: Tx[] = []
for (const tx of txes) {
result.push(...(await this.triggers.apply(tx.modifiedBy, tx, triggerControl)))
}
result.push(...(await this.triggers.apply(ctx, txes, triggerControl)))
return result
})
@ -652,8 +650,7 @@ class TServerStorage implements ServerStorage {
derived = result[1]
if (broadcast) {
this.options?.broadcast?.(txes)
this.options?.broadcast?.(derived)
this.options?.broadcast?.([...txes, ...derived])
}
return [...txes, ...derived]

View File

@ -15,23 +15,23 @@
//
import core, {
Tx,
Doc,
TxCreateDoc,
Ref,
Account,
TxCollectionCUD,
AttachedDoc,
DocumentQuery,
matchQuery,
TxFactory,
Hierarchy,
Class,
Obj
Doc,
DocumentQuery,
Hierarchy,
MeasureContext,
Obj,
Ref,
Tx,
TxCollectionCUD,
TxCreateDoc,
TxFactory,
matchQuery
} from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import type { Trigger, TriggerFunc, TriggerControl } from './types'
import { Resource, getResource } from '@hcengineering/platform'
import type { Trigger, TriggerControl, TriggerFunc } from './types'
import serverCore from './plugin'
@ -39,7 +39,7 @@ import serverCore from './plugin'
* @public
*/
export class Triggers {
private readonly triggers: [DocumentQuery<Tx> | undefined, TriggerFunc][] = []
private readonly triggers: [DocumentQuery<Tx> | undefined, TriggerFunc, Resource<TriggerFunc>][] = []
constructor (protected readonly hierarchy: Hierarchy) {}
@ -53,25 +53,29 @@ export class Triggers {
const trigger = (createTx as TxCreateDoc<Trigger>).attributes.trigger
const match = (createTx as TxCreateDoc<Trigger>).attributes.txMatch
const func = await getResource(trigger)
this.triggers.push([match, func])
this.triggers.push([match, func, trigger])
}
}
}
async apply (account: Ref<Account>, tx: Tx, ctrl: Omit<TriggerControl, 'txFactory'>): Promise<Tx[]> {
const control = { ...ctrl, txFactory: new TxFactory(account, true) }
const derived = this.triggers
.filter(([query]) => {
if (query === undefined) {
return true
}
async apply (ctx: MeasureContext, tx: Tx[], ctrl: Omit<TriggerControl, 'txFactory'>): Promise<Tx[]> {
const result: Tx[] = []
for (const [query, trigger, resource] of this.triggers) {
let matches = tx
if (query !== undefined) {
this.addDerived(query, 'objectClass')
this.addDerived(query, 'tx.objectClass')
return matchQuery([tx], query, core.class.Tx, control.hierarchy).length > 0
})
.map(([, trigger]) => trigger(tx, control))
const result = await Promise.all(derived)
return result.flatMap((x) => x)
matches = matchQuery(tx, query, core.class.Tx, ctrl.hierarchy) as Tx[]
}
if (matches.length > 0) {
await ctx.with(resource, {}, async (ctx) => {
for (const tx of matches) {
result.push(...(await trigger(tx, { ...ctrl, txFactory: new TxFactory(tx.modifiedBy, true) })))
}
})
}
}
return result
}
private addDerived (q: DocumentQuery<Tx>, key: string): void {

View File

@ -38,6 +38,7 @@ export interface Request<P extends any[]> {
export interface HelloRequest extends Request<any[]> {
binary?: boolean
compression?: boolean
broadcast?: boolean
}
/**
* @public

View File

@ -16,6 +16,7 @@
import core, {
Account,
AccountRole,
BulkUpdateEvent,
Class,
Doc,
DocumentQuery,
@ -26,7 +27,13 @@ import core, {
Ref,
Timestamp,
Tx,
TxResult
TxApplyIf,
TxCUD,
TxProcessor,
TxResult,
TxWorkspaceEvent,
WorkspaceEvent,
generateId
} from '@hcengineering/core'
import { Pipeline, SessionContext } from '@hcengineering/server-core'
import { Token } from '@hcengineering/server-token'
@ -39,6 +46,7 @@ export class ClientSession implements Session {
requests: Map<string, SessionRequest> = new Map()
binaryResponseMode: boolean = false
useCompression: boolean = true
useBroadcast: boolean = false
sessionId = ''
total: StatisticsElement = { find: 0, tx: 0 }
@ -109,10 +117,66 @@ export class ClientSession implements Session {
context.userEmail = this.token.email
const [result, derived, target] = await this._pipeline.tx(context, tx)
this.broadcast(this, this.token.workspace, { result: tx }, target)
for (const dtx of derived) {
this.broadcast(null, this.token.workspace, { result: dtx }, target)
let shouldBroadcast = true
if (tx._class === core.class.TxApplyIf) {
const apply = tx as TxApplyIf
shouldBroadcast = apply.notify ?? true
}
if (tx._class !== core.class.TxApplyIf) {
this.broadcast(this, this.token.workspace, { result: tx }, target)
}
if (shouldBroadcast) {
if (this.useBroadcast) {
if (derived.length > 250) {
const classes = new Set<Ref<Class<Doc>>>()
for (const dtx of derived) {
if (this._pipeline.storage.hierarchy.isDerived(dtx._class, core.class.TxCUD)) {
classes.add((dtx as TxCUD<Doc>).objectClass)
}
const etx = TxProcessor.extractTx(dtx)
if (this._pipeline.storage.hierarchy.isDerived(etx._class, core.class.TxCUD)) {
classes.add((etx as TxCUD<Doc>).objectClass)
}
}
console.log('Broadcasting bulk', derived.length)
this.broadcast(null, this.token.workspace, { result: this.createBroadcastEvent(Array.from(classes)) }, target)
} else {
while (derived.length > 0) {
const part = derived.splice(0, 250)
console.log('Broadcasting part', part.length, derived.length)
this.broadcast(null, this.token.workspace, { result: part }, target)
}
}
} else {
for (const dtx of derived) {
this.broadcast(null, this.token.workspace, { result: dtx }, target)
}
}
}
if (tx._class === core.class.TxApplyIf) {
const apply = tx as TxApplyIf
if (apply.extraNotify !== undefined && apply.extraNotify.length > 0) {
this.broadcast(null, this.token.workspace, { result: this.createBroadcastEvent(apply.extraNotify) }, target)
}
}
return result
}
private createBroadcastEvent (classes: Ref<Class<Doc>>[]): TxWorkspaceEvent<BulkUpdateEvent> {
return {
_class: core.class.TxWorkspaceEvent,
_id: generateId(),
event: WorkspaceEvent.BulkUpdate,
params: {
_class: classes
},
modifiedBy: core.account.System,
modifiedOn: Date.now(),
objectSpace: core.space.DerivedTx,
space: core.space.DerivedTx
}
}
}

View File

@ -243,7 +243,12 @@ class TSessionManager implements SessionManager {
for (const session of sessions.splice(0, 1)) {
if (targets !== undefined && !targets.includes(session.session.getUser())) continue
for (const _tx of tx) {
void session.socket.send(ctx, { result: _tx }, session.session.binaryResponseMode, false)
void session.socket.send(
ctx,
{ result: _tx },
session.session.binaryResponseMode,
session.session.useCompression
)
}
}
if (sessions.length > 0) {
@ -461,9 +466,19 @@ class TSessionManager implements SessionManager {
for (const sessionRef of sessions.splice(0, 1)) {
if (sessionRef.session.sessionId !== from?.sessionId) {
if (target === undefined) {
void sessionRef.socket.send(ctx, resp, sessionRef.session.binaryResponseMode, false)
void sessionRef.socket.send(
ctx,
resp,
sessionRef.session.binaryResponseMode,
sessionRef.session.useCompression
)
} else if (target.includes(sessionRef.session.getUser())) {
void sessionRef.socket.send(ctx, resp, sessionRef.session.binaryResponseMode, false)
void sessionRef.socket.send(
ctx,
resp,
sessionRef.session.binaryResponseMode,
sessionRef.session.useCompression
)
}
}
}
@ -497,6 +512,7 @@ class TSessionManager implements SessionManager {
const hello = request as HelloRequest
service.binaryResponseMode = hello.binary ?? false
service.useCompression = hello.compression ?? false
service.useBroadcast = hello.broadcast ?? false
if (LOGGING_ENABLED) {
console.timeLog(

View File

@ -54,6 +54,7 @@ export interface Session {
binaryResponseMode: boolean
useCompression: boolean
useBroadcast: boolean
total: StatisticsElement
current: StatisticsElement