platform/plugins/view-resources/src/utils.ts

669 lines
21 KiB
TypeScript
Raw Normal View History

//
// 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.
//
import core, {
AttachedDoc,
Class,
Client,
Collection,
Doc,
DocumentUpdate,
Hierarchy,
Lookup,
Obj,
Ref,
RefTo,
ReverseLookup,
ReverseLookups,
Space,
TxOperations
} from '@hcengineering/core'
import type { IntlString } from '@hcengineering/platform'
import { getResource } from '@hcengineering/platform'
import { AttributeCategory, getAttributePresenterClass, KeyedAttribute } from '@hcengineering/presentation'
import {
AnyComponent,
ErrorPresenter,
getCurrentLocation,
getPanelURI,
getPlatformColorForText,
Location,
locationToUrl
} from '@hcengineering/ui'
import type { BuildModelOptions, Viewlet, ViewletDescriptor } from '@hcengineering/view'
import view, { AttributeModel, BuildModelKey } from '@hcengineering/view'
import { writable } from 'svelte/store'
import plugin from './plugin'
2023-01-14 10:54:54 +00:00
import { noCategory } from './viewOptions'
export { getFiltredKeys, isCollectionAttr } from '@hcengineering/presentation'
/**
* Define some properties to be used to show component until data is properly loaded.
*/
export interface LoadingProps {
length: number
}
/**
* @public
*/
export async function getObjectPresenter (
client: Client,
_class: Ref<Class<Obj>>,
preserveKey: BuildModelKey,
isCollectionAttr: boolean = false
): Promise<AttributeModel> {
const hierarchy = client.getHierarchy()
2023-01-14 10:54:54 +00:00
const mixin = isCollectionAttr ? view.mixin.CollectionPresenter : view.mixin.ObjectPresenter
const clazz = hierarchy.getClass(_class)
const presenterMixin = hierarchy.classHierarchyMixin(_class, mixin)
if (presenterMixin?.presenter === undefined) {
throw new Error(
`object presenter not found for class=${_class}, mixin=${mixin}, preserve key ${JSON.stringify(preserveKey)}`
)
}
const presenter = await getResource(presenterMixin.presenter)
const key = preserveKey.sortingKey ?? preserveKey.key
2022-06-29 11:46:22 +00:00
const sortingKey = Array.isArray(key)
? key
: clazz.sortingKey !== undefined
? key.length > 0
? key + '.' + clazz.sortingKey
: clazz.sortingKey
: key
return {
key: preserveKey.key,
_class,
label: preserveKey.label ?? clazz.label,
presenter,
props: preserveKey.props,
sortingKey,
collectionAttr: isCollectionAttr,
isLookup: false
}
}
/**
* @public
*/
export async function getListItemPresenter (client: Client, _class: Ref<Class<Obj>>): Promise<AnyComponent | undefined> {
const clazz = client.getHierarchy().getClass(_class)
const presenterMixin = client.getHierarchy().as(clazz, view.mixin.ListItemPresenter)
if (presenterMixin.presenter === undefined) {
if (clazz.extends !== undefined) {
return await getListItemPresenter(client, clazz.extends)
}
}
return presenterMixin?.presenter
}
/**
* @public
*/
2022-04-29 05:27:17 +00:00
export async function getObjectPreview (client: Client, _class: Ref<Class<Obj>>): Promise<AnyComponent | undefined> {
const clazz = client.getHierarchy().getClass(_class)
const presenterMixin = client.getHierarchy().as(clazz, view.mixin.PreviewPresenter)
if (presenterMixin.presenter === undefined) {
if (clazz.extends !== undefined) {
return await getObjectPreview(client, clazz.extends)
}
}
return presenterMixin?.presenter
}
async function getAttributePresenter (
client: Client,
_class: Ref<Class<Obj>>,
key: string,
preserveKey: BuildModelKey
): Promise<AttributeModel> {
const hierarchy = client.getHierarchy()
const attribute = hierarchy.getAttribute(_class, key)
const presenterClass = getAttributePresenterClass(hierarchy, attribute)
const isCollectionAttr = presenterClass.category === 'collection'
const mixin = isCollectionAttr ? view.mixin.CollectionPresenter : view.mixin.AttributePresenter
const presenterMixin = hierarchy.classHierarchyMixin(presenterClass.attrClass, mixin)
if (presenterMixin?.presenter === undefined) {
throw new Error('attribute presenter not found for ' + JSON.stringify(preserveKey))
}
const resultKey = preserveKey.sortingKey ?? preserveKey.key
2022-06-29 11:46:22 +00:00
const sortingKey = Array.isArray(resultKey)
? resultKey
: attribute.type._class === core.class.ArrOf
? resultKey + '.length'
: resultKey
const presenter = await getResource(presenterMixin.presenter)
return {
key: preserveKey.key,
sortingKey,
_class: presenterClass.attrClass,
label: preserveKey.label ?? attribute.shortLabel ?? attribute.label,
presenter,
2023-01-14 10:54:54 +00:00
props: preserveKey.props,
icon: presenterMixin.icon,
attribute,
collectionAttr: isCollectionAttr,
isLookup: false
}
}
export async function getPresenter<T extends Doc> (
client: Client,
_class: Ref<Class<T>>,
key: BuildModelKey,
preserveKey: BuildModelKey,
lookup?: Lookup<T>,
isCollectionAttr: boolean = false
): Promise<AttributeModel> {
if (key.presenter !== undefined) {
const { presenter, label, sortingKey } = key
return {
key: key.key ?? '',
sortingKey: sortingKey ?? '',
_class,
label: label as IntlString,
presenter: typeof presenter === 'string' ? await getResource(presenter) : presenter,
2023-01-14 10:54:54 +00:00
props: preserveKey.props,
collectionAttr: isCollectionAttr,
isLookup: false
}
}
if (key.key.length === 0) {
return await getObjectPresenter(client, _class, preserveKey, isCollectionAttr)
} else {
if (key.key.startsWith('$lookup')) {
if (lookup === undefined) {
throw new Error(`lookup class does not provided for ${key.key}`)
}
return await getLookupPresenter(client, _class, key, preserveKey, lookup)
}
return await getAttributePresenter(client, _class, key.key, preserveKey)
}
}
function getKeyLookup<T extends Doc> (
hierarchy: Hierarchy,
_class: Ref<Class<T>>,
key: string,
lookup: Lookup<T>,
lastIndex: number = 1
): Lookup<T> {
if (!key.startsWith('$lookup')) return lookup
const parts = key.split('.')
const attrib = parts[1]
const attribute = hierarchy.getAttribute(_class, attrib)
if (hierarchy.isDerived(attribute.type._class, core.class.RefTo)) {
const lookupClass = (attribute.type as RefTo<Doc>).to
const index = key.indexOf('$lookup', lastIndex)
if (index === -1) {
if ((lookup as any)[attrib] === undefined) {
;(lookup as any)[attrib] = lookupClass
}
} else {
let nested = Array.isArray((lookup as any)[attrib]) ? (lookup as any)[attrib][1] : {}
nested = getKeyLookup(hierarchy, lookupClass, key.slice(index), nested)
;(lookup as any)[attrib] = [lookupClass, nested]
}
} else if (hierarchy.isDerived(attribute.type._class, core.class.Collection)) {
if ((lookup as any)._id === undefined) {
;(lookup as any)._id = {}
}
;(lookup as any)._id[attrib] = (attribute.type as Collection<AttachedDoc>).of
}
return lookup
}
export function buildConfigLookup<T extends Doc> (
hierarchy: Hierarchy,
_class: Ref<Class<T>>,
config: Array<BuildModelKey | string>,
existingLookup?: Lookup<T>
): Lookup<T> {
let res: Lookup<T> = {}
for (const key of config) {
if (typeof key === 'string') {
res = getKeyLookup(hierarchy, _class, key, res)
} else {
res = getKeyLookup(hierarchy, _class, key.key, res)
}
}
if (existingLookup !== undefined) {
// Let's merg
const _id: ReverseLookup = {
...((existingLookup as ReverseLookups)._id ?? {}),
...((res as ReverseLookups)._id ?? {})
}
res = { ...existingLookup, ...res, _id }
}
return res
}
export async function buildModel (options: BuildModelOptions): Promise<AttributeModel[]> {
// eslint-disable-next-line array-callback-return
const model = options.keys
.map((key) => (typeof key === 'string' ? { key } : key))
.map(async (key) => {
try {
// Check if it is a mixin attribute configuration
const pos = key.key.lastIndexOf('.')
if (pos !== -1) {
const mixinName = key.key.substring(0, pos) as Ref<Class<Doc>>
if (!mixinName.includes('$lookup')) {
const realKey = key.key.substring(pos + 1)
const rkey = { ...key, key: realKey }
return {
...(await getPresenter(options.client, mixinName, rkey, rkey, options.lookup)),
castRequest: mixinName,
key: key.key,
sortingKey: key.key
}
}
}
return await getPresenter(options.client, options._class, key, key, options.lookup)
} catch (err: any) {
if (options.ignoreMissing ?? false) {
return undefined
}
const stringKey = key.label ?? key.key
console.error('Failed to find presenter for', key, err)
const errorPresenter: AttributeModel = {
key: '',
sortingKey: '',
presenter: ErrorPresenter,
label: stringKey as IntlString,
_class: core.class.TypeString,
props: { error: err },
collectionAttr: false,
isLookup: false
}
return errorPresenter
}
})
2022-06-29 11:46:22 +00:00
return (await Promise.all(model)).filter((a) => a !== undefined) as AttributeModel[]
}
export async function deleteObject (client: TxOperations, object: Doc): Promise<void> {
if (client.getHierarchy().isDerived(object._class, core.class.AttachedDoc)) {
const adoc = object as AttachedDoc
await client
.removeCollection(object._class, object.space, adoc._id, adoc.attachedTo, adoc.attachedToClass, adoc.collection)
.catch((err) => console.error(err))
} else {
await client.removeDoc(object._class, object.space, object._id).catch((err) => console.error(err))
}
}
export async function deleteObjects (client: TxOperations, objects: Doc[]): Promise<void> {
const ops = client.apply('delete')
for (const object of objects) {
if (client.getHierarchy().isDerived(object._class, core.class.AttachedDoc)) {
const adoc = object as AttachedDoc
await ops
.removeCollection(object._class, object.space, adoc._id, adoc.attachedTo, adoc.attachedToClass, adoc.collection)
.catch((err) => console.error(err))
} else {
await ops.removeDoc(object._class, object.space, object._id).catch((err) => console.error(err))
}
}
await ops.commit()
}
export function getMixinStyle (id: Ref<Class<Doc>>, selected: boolean): string {
const color = getPlatformColorForText(id as string)
return `
2023-03-10 00:08:04 +00:00
color: ${selected ? '#fff' : 'var(--caption-color)'};
background: ${color + (selected ? 'ff' : '33')};
border: 1px solid ${color + (selected ? '0f' : '66')};
`
}
async function getLookupPresenter<T extends Doc> (
client: Client,
_class: Ref<Class<T>>,
key: BuildModelKey,
preserveKey: BuildModelKey,
lookup: Lookup<T>
): Promise<AttributeModel> {
const lookupClass = getLookupClass(key.key, lookup, _class)
const lookupProperty = getLookupProperty(key.key)
const lookupKey = { ...key, key: lookupProperty[0] }
const model = await getPresenter(client, lookupClass[0], lookupKey, preserveKey, undefined, lookupClass[2])
model.label = getLookupLabel(client, lookupClass[1], lookupClass[0], lookupKey, lookupProperty[1])
model.isLookup = true
return model
}
export function getLookupLabel<T extends Doc> (
client: Client,
_class: Ref<Class<T>>,
lookupClass: Ref<Class<Doc>>,
key: BuildModelKey,
attrib: string
): IntlString {
if (key.label !== undefined) return key.label
if (key.key === '') {
try {
const attribute = client.getHierarchy().getAttribute(_class, attrib)
return attribute.label
} catch {}
const clazz = client.getHierarchy().getClass(lookupClass)
return clazz.label
} else {
const attribute = client.getHierarchy().getAttribute(lookupClass, key.key)
return attribute.label
}
}
export function getLookupClass<T extends Doc> (
key: string,
lookup: Lookup<T>,
parent: Ref<Class<T>>
): [Ref<Class<Doc>>, Ref<Class<Doc>>, boolean] {
const _class = getLookup(key, lookup, parent)
if (_class === undefined) {
throw new Error('lookup class does not provided for ' + key)
}
return _class
}
export function getLookupProperty (key: string): [string, string] {
const parts = key.split('$lookup')
const lastPart = parts[parts.length - 1]
const split = lastPart.split('.').filter((p) => p.length > 0)
const prev = split.shift() ?? ''
const result = split.join('.')
return [result, prev]
}
function getLookup (
key: string,
lookup: Lookup<any>,
parent: Ref<Class<Doc>>
): [Ref<Class<Doc>>, Ref<Class<Doc>>, boolean] | undefined {
const parts = key.split('$lookup.').filter((p) => p.length > 0)
const currentKey = parts[0].split('.').filter((p) => p.length > 0)[0]
const current = (lookup as any)[currentKey]
const nestedKey = parts.slice(1).join('$lookup.')
if (nestedKey.length > 0) {
if (!Array.isArray(current)) {
return
}
return getLookup(nestedKey, current[1], current[0])
}
if (Array.isArray(current)) {
return [current[0], parent, false]
}
if (current === undefined && lookup._id !== undefined) {
const reverse = (lookup._id as any)[currentKey]
return reverse !== undefined
? Array.isArray(reverse)
? [reverse[0], parent, true]
: [reverse, parent, true]
: undefined
}
return current !== undefined ? [current, parent, false] : undefined
}
export function getBooleanLabel (value: boolean | undefined): IntlString {
if (value === true) return plugin.string.LabelYes
if (value === false) return plugin.string.LabelNo
return plugin.string.LabelNA
}
export function getCollectionCounter (hierarchy: Hierarchy, object: Doc, key: KeyedAttribute): number {
if (hierarchy.isMixin(key.attr.attributeOf)) {
return (hierarchy.as(object, key.attr.attributeOf) as any)[key.key]
}
return (object as any)[key.key] ?? 0
}
export interface CategoryKey {
key: KeyedAttribute
category: AttributeCategory
}
export function categorizeFields (
hierarchy: Hierarchy,
keys: KeyedAttribute[],
useAsCollection: string[],
useAsAttribute: string[]
): {
attributes: CategoryKey[]
collections: CategoryKey[]
} {
const result = {
attributes: [] as CategoryKey[],
collections: [] as CategoryKey[]
}
for (const key of keys) {
const cl = getAttributePresenterClass(hierarchy, key.attr)
if (useAsCollection.includes(key.key)) {
result.collections.push({ key, category: cl.category })
} else if (useAsAttribute.includes(key.key)) {
result.attributes.push({ key, category: cl.category })
} else if (cl.category === 'collection' || cl.category === 'inplace') {
result.collections.push({ key, category: cl.category })
} else if (cl.category === 'array') {
const attrClass = getAttributePresenterClass(hierarchy, key.attr)
const clazz = hierarchy.getClass(attrClass.attrClass)
const mix = hierarchy.as(clazz, view.mixin.ArrayEditor)
if (mix.editor !== undefined && mix.inlineEditor === undefined) {
result.collections.push({ key, category: cl.category })
} else {
result.attributes.push({ key, category: cl.category })
}
} else {
result.attributes.push({ key, category: cl.category })
}
}
return result
}
function makeViewletKey (loc?: Location): string {
loc = loc ?? getCurrentLocation()
loc.fragment = undefined
loc.query = undefined
return 'viewlet' + locationToUrl(loc)
}
export function setActiveViewletId (viewletId: Ref<Viewlet> | null, loc?: Location): void {
const key = makeViewletKey(loc)
if (viewletId !== null && viewletId !== undefined) {
localStorage.setItem(key, viewletId)
} else {
localStorage.removeItem(key)
}
}
export function getActiveViewletId (): Ref<Viewlet> | null {
const key = makeViewletKey()
return localStorage.getItem(key) as Ref<Viewlet> | null
}
export type FixedWidthStore = Record<string, number>
export const fixedWidthStore = writable<FixedWidthStore>({})
2023-01-14 10:54:54 +00:00
export function groupBy<T extends Doc> (docs: T[], key: string): { [key: string]: T[] } {
return docs.reduce((storage: { [key: string]: T[] }, item: T) => {
2023-01-14 10:54:54 +00:00
const group = (item as any)[key] ?? undefined
storage[group] = storage[group] ?? []
storage[group].push(item)
return storage
}, {})
}
export async function getCategories (
client: TxOperations,
_class: Ref<Class<Doc>>,
docs: Doc[],
key: string,
viewletDescriptorId?: Ref<ViewletDescriptor>
2023-01-14 10:54:54 +00:00
): Promise<any[]> {
if (key === noCategory) return [undefined]
const existingCategories = Array.from(new Set(docs.map((x: any) => x[key] ?? undefined)))
return await sortCategories(client, _class, existingCategories, key, viewletDescriptorId)
}
export async function sortCategories (
client: TxOperations,
_class: Ref<Class<Doc>>,
existingCategories: any[],
key: string,
viewletDescriptorId?: Ref<ViewletDescriptor>
): Promise<any[]> {
if (key === noCategory) return [undefined]
const hierarchy = client.getHierarchy()
2023-01-14 10:54:54 +00:00
const attr = hierarchy.getAttribute(_class, key)
if (attr === undefined) return existingCategories
const attrClass = getAttributePresenterClass(hierarchy, attr).attrClass
const clazz = hierarchy.getClass(attrClass)
const sortFunc = hierarchy.as(clazz, view.mixin.SortFuncs)
if (sortFunc?.func === undefined) return existingCategories
const f = await getResource(sortFunc.func)
return await f(existingCategories, viewletDescriptorId)
2023-01-14 10:54:54 +00:00
}
export function getKeyLabel<T extends Doc> (
client: TxOperations,
_class: Ref<Class<T>>,
key: string,
lookup: Lookup<T> | undefined
): IntlString {
if (key.startsWith('$lookup') && lookup !== undefined) {
const lookupClass = getLookupClass(key, lookup, _class)
const lookupProperty = getLookupProperty(key)
const lookupKey = { key: lookupProperty[0] }
return getLookupLabel(client, lookupClass[1], lookupClass[0], lookupKey, lookupProperty[1])
} else {
const attribute = client.getHierarchy().getAttribute(_class, key)
return attribute.label
}
}
/**
* @public
* Implemenation of cosice similarity
*/
export function cosinesim (A: number[], B: number[]): number {
let dotproduct = 0
let mA = 0
let mB = 0
for (let i = 0; i < A.length; i++) {
dotproduct += A[i] * B[i]
mA += A[i] * A[i]
mB += B[i] * B[i]
}
mA = Math.sqrt(mA)
mB = Math.sqrt(mB)
const similarity = dotproduct / (mA * mB) // here you needed extra brackets
return similarity
}
/**
* Calculate SørensenDice coefficient
*/
export function calcSørensenDiceCoefficient (a: string, b: string): number {
const first = a.replace(/\s+/g, '')
const second = b.replace(/\s+/g, '')
if (first === second) return 1 // identical or empty
if (first.length < 2 || second.length < 2) return 0 // if either is a 0-letter or 1-letter string
const firstBigrams = new Map<string, number>()
for (let i = 0; i < first.length - 1; i++) {
const bigram = first.substring(i, i + 2)
const count = (firstBigrams.get(bigram) ?? 0) + 1
firstBigrams.set(bigram, count)
}
let intersectionSize = 0
for (let i = 0; i < second.length - 1; i++) {
const bigram = second.substring(i, i + 2)
const count = firstBigrams.get(bigram) ?? 0
if (count > 0) {
firstBigrams.set(bigram, count - 1)
intersectionSize++
}
}
return (2.0 * intersectionSize) / (first.length + second.length - 2)
}
/**
* @public
*/
export async function moveToSpace (
client: TxOperations,
doc: Doc,
space: Ref<Space>,
extra?: DocumentUpdate<any>
): Promise<void> {
const hierarchy = client.getHierarchy()
const attributes = hierarchy.getAllAttributes(doc._class)
for (const [name, attribute] of attributes) {
if (hierarchy.isDerived(attribute.type._class, core.class.Collection)) {
const collection = attribute.type as Collection<AttachedDoc>
const allAttached = await client.findAll(collection.of, { attachedTo: doc._id })
for (const attached of allAttached) {
// Do not use extra for childs.
await moveToSpace(client, attached, space).catch((err) => console.log('failed to move', name, err))
}
}
}
await client.update(doc, {
space,
...extra
})
}
/**
* @public
*/
export function getAdditionalHeader (client: TxOperations, _class: Ref<Class<Doc>>): AnyComponent[] | undefined {
const hierarchy = client.getHierarchy()
const presenterMixin = hierarchy.classHierarchyMixin(_class, view.mixin.ListHeaderExtra)
return presenterMixin?.presenters
}
export async function getObjectLinkFragment (
hierarchy: Hierarchy,
object: Doc,
props: Record<string, any> = {},
component: AnyComponent = view.component.EditDoc
): Promise<Location> {
const provider = hierarchy.classHierarchyMixin(object._class, view.mixin.LinkProvider)
if (provider?.encode !== undefined) {
const f = await getResource(provider.encode)
const res = await f(object, props)
if (res !== undefined) {
return res
}
}
const loc = getCurrentLocation()
loc.fragment = getPanelURI(component, object._id, Hierarchy.mixinOrClass(object), 'content')
return loc
}