// Copyright © 2025 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 { type AccountClient, getClient as getAccountClientRaw } from '@hcengineering/account-client' import { Analytics } from '@hcengineering/analytics' import { type Card, CardEvents, cardId, type CardSpace, type MasterTag, type Tag } from '@hcengineering/card' import { chatId } from '@hcengineering/chat' import communication from '@hcengineering/communication' import { type PermissionsStore } from '@hcengineering/contact' import core, { AccountRole, type Class, type ClassPermission, type Client, type Data, type Doc, type DocumentQuery, fillDefaults, generateId, getCurrentAccount, hasAccountRole, type Hierarchy, makeCollabId, makeDocCollabId, type Markup, type MarkupBlobRef, type Mixin, type Ref, type RelatedDocument, type Space, toRank, type TxOperations, type WithLookup } from '@hcengineering/core' import login from '@hcengineering/login' import { getMetadata, translate } from '@hcengineering/platform' import presentation, { createMarkup, getClient, getMarkup, IconWithEmoji, MessageBox, type ObjectSearchResult } from '@hcengineering/presentation' import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text' import { getCurrentLocation, getCurrentResolvedLocation, getPanelURI, type IconComponent, type IconProps, type Location, navigate, type ResolvedLocation, showPopup } from '@hcengineering/ui' import view, { canCopyLink, encodeObjectURI } from '@hcengineering/view' import { accessDeniedStore } from '@hcengineering/view-resources' import workbench, { type LocationData, type Widget, type WidgetTab } from '@hcengineering/workbench' import { createWidgetTab } from '@hcengineering/workbench-resources' import attachment from '@hcengineering/attachment' import { makeRank } from '@hcengineering/rank' import { writable } from 'svelte/store' import CardSearchItem from './components/CardSearchItem.svelte' import CreateSpace from './components/navigator/CreateSpace.svelte' import card from './plugin' import { type NavigatorConfig } from './types' export async function deleteMasterTag (tag: MasterTag | undefined, onDelete?: () => void): Promise { if (tag !== undefined) { const client = getClient() if (tag._class === card.class.MasterTag) { showPopup(MessageBox, { label: card.string.DeleteMasterTag, message: card.string.DeleteMasterTagConfirm, action: async () => { onDelete?.() await client.update(tag, { removed: true }) } }) } else { showPopup(MessageBox, { label: card.string.DeleteTag, message: card.string.DeleteTagConfirm, action: async () => { onDelete?.() await client.remove(tag) } }) } } } export async function createTypePermissions (masterTag: MasterTag | Tag): Promise { const client = getClient() const hierarchy = client.getHierarchy() const isMixin = hierarchy.isMixin(masterTag._id) const objectClass = hierarchy.getBaseClass(masterTag._id) const txClass = isMixin ? core.class.TxMixin : core.class.TxUpdateDoc await client.createDoc( core.class.ClassPermission, core.space.Model, { objectClass, txClass, txMatch: { [isMixin ? 'mixin' : 'objectClass']: masterTag._id }, scope: 'space', forbid: false, label: view.string.AllowAttributeChanges, description: masterTag.label, targetClass: masterTag._id }, `${masterTag._id}_allowed` as Ref ) await client.createDoc( core.class.ClassPermission, core.space.Model, { objectClass, txClass, txMatch: { [isMixin ? 'mixin' : 'objectClass']: masterTag._id }, scope: 'space', forbid: true, label: view.string.ForbidAttributeChanges, description: masterTag.label, targetClass: masterTag._id }, `${masterTag._id}_forbidden` as Ref ) if (isMixin) { await client.createDoc( core.class.ClassPermission, core.space.Model, { objectClass, txClass: core.class.TxMixin, txMatch: { mixin: masterTag._id }, scope: 'space', forbid: false, label: card.string.AddTagPermission, description: masterTag.label, targetClass: masterTag._id }, `${masterTag._id}_create_allowed` as Ref ) await client.createDoc( core.class.ClassPermission, core.space.Model, { objectClass, txClass: core.class.TxMixin, txMatch: { mixin: masterTag._id }, scope: 'space', forbid: true, label: card.string.ForbidAddTagPermission, description: masterTag.label, targetClass: masterTag._id }, `${masterTag._id}_create_forbidden` as Ref ) const key = `operations.$unset.${masterTag._id}` await client.createDoc( core.class.ClassPermission, core.space.Model, { objectClass, txClass: core.class.TxUpdateDoc, txMatch: { [key]: { $exists: true } }, scope: 'space', forbid: false, label: card.string.RemoveTag, description: masterTag.label, targetClass: masterTag._id }, `${masterTag._id}_remove_allowed` as Ref ) await client.createDoc( core.class.ClassPermission, core.space.Model, { objectClass, txClass: core.class.TxUpdateDoc, txMatch: { [key]: { $exists: true } }, scope: 'space', forbid: true, label: card.string.ForbidRemoveTag, description: masterTag.label, targetClass: masterTag._id }, `${masterTag._id}_remove_forbidden` as Ref ) } else { await client.createDoc( core.class.ClassPermission, core.space.Model, { objectClass, txClass: core.class.TxCreateDoc, scope: 'space', forbid: false, label: card.string.CreateCardPermission, description: masterTag.label, targetClass: masterTag._id }, `${masterTag._id}_create_allowed` as Ref ) await client.createDoc( core.class.ClassPermission, core.space.Model, { objectClass, txClass: core.class.TxCreateDoc, scope: 'space', forbid: true, label: card.string.ForbidCreateCardPermission, description: masterTag.label, targetClass: masterTag._id }, `${masterTag._id}_create_forbidden` as Ref ) await client.createDoc( core.class.ClassPermission, core.space.Model, { objectClass, txClass: core.class.TxRemoveDoc, scope: 'space', forbid: false, label: card.string.RemoveCard, description: masterTag.label, targetClass: masterTag._id }, `${masterTag._id}_remove_allowed` as Ref ) await client.createDoc( core.class.ClassPermission, core.space.Model, { objectClass, txClass: core.class.TxRemoveDoc, txMatch: { objectClass: masterTag._id }, scope: 'space', forbid: true, label: card.string.ForbidRemoveCard, description: masterTag.label, targetClass: masterTag._id }, `${masterTag._id}_remove_forbidden` as Ref ) } } interface CopySettings { excludedProperties?: string[] excludedRelations?: string[] // ${associationId}_${a|b} excludeMixins?: Array>> } async function cloneCard ( origin: Card, overrideProps: Record, config?: CopySettings, copyIds: boolean = false ): Promise> { const client = getClient() const h = client.getHierarchy() const props: Partial> = {} const base = h.getBaseClass(origin._class) const mixins = h.findAllMixins(origin) const attrs = h.getAllAttributes(base, core.class.Doc) const skipClasses = copyIds ? [core.class.TypeCollaborativeDoc] : [core.class.TypeCollaborativeDoc, core.class.TypeIdentifier] const systemFields = ['_class', 'id', 'createdOn', 'modifiedOn', 'modifiedBy', 'createdBy', 'createdOn', 'rank'] for (const [key, attr] of attrs) { if (config?.excludedProperties?.includes(key) === true || systemFields.includes(key)) { continue } if (attr.type._class === core.class.Collection) { ;(props as any)[key] = 0 } else if (!skipClasses.includes(attr.type._class)) { ;(props as any)[key] = (origin as any)[key] } } for (const [k, v] of Object.entries(overrideProps)) { ;(props as any)[k] = v } props.rank = makeRank(origin.rank, undefined) const targetId = generateId() const relationsA = await client.findAll(core.class.Relation, { docA: origin._id }) const relationsB = await client.findAll(core.class.Relation, { docB: origin._id }) if (config?.excludedProperties?.includes('content') !== true) { const markup = await getMarkup(makeDocCollabId(origin, 'content'), origin.content) if (!isEmptyMarkup(markup)) { const collabId = makeCollabId(base, targetId, 'content') props.content = await createMarkup(collabId, markup) } } const ops = client.apply(`Duplicate_card_${origin._id}`) await ops.createDoc(base, origin.space, props, targetId) for (const mixin of mixins) { if (config?.excludeMixins?.includes(mixin) === true) { continue } const mixinAttrs = h.getOwnAttributes(mixin) const as = h.as(origin, mixin) const attributes: Partial> = {} for (const [key] of mixinAttrs) { ;(attributes as any)[key] = (as as any)[key] } await ops.createMixin(targetId, base, origin.space, mixin, attributes) } for (const rel of relationsA) { if (config?.excludedRelations?.includes(`${rel.association}_b`) !== true) { await ops.createDoc(core.class.Relation, core.space.Workspace, { docA: targetId, docB: rel.docB, association: rel.association }) } } for (const rel of relationsB) { if (config?.excludedRelations?.includes(`${rel.association}_a`) !== true) { await ops.createDoc(core.class.Relation, core.space.Workspace, { docA: rel.docA, docB: targetId, association: rel.association }) } } await ops.commit() if (config?.excludedProperties?.includes('attachments') !== true) { const attachments = await client.findAll(attachment.class.Attachment, { attachedTo: origin._id }) const attachmentOps = client.apply(`Duplicate_attachments_${origin._id}`) for (const att of attachments) { const { _id, modifiedBy, modifiedOn, attachedTo, attachedToClass, collection, space, ...props } = att await attachmentOps.addCollection(attachment.class.Attachment, origin.space, targetId, base, 'attachments', props) } await attachmentOps.commit() } return targetId } export async function duplicateCard (origin: Card, config?: CopySettings): Promise { const targetId = await cloneCard( origin, { title: `${origin.title} (Copy)` }, config ) const loc = getCurrentLocation() loc.path[2] = cardId loc.path[3] = targetId loc.path.length = 4 navigate(loc) } export async function resolveLocation (loc: Location): Promise { if (loc.path[2] !== cardId) { return undefined } const id = loc.path[3] const specialItems = ['browser', 'type', 'all'] if (loc.path[4] === undefined && id !== undefined && !specialItems.includes(id)) { return await generateLocation(loc, id) } } export async function editSpace (value: CardSpace | undefined): Promise { if (value !== undefined) { showPopup(CreateSpace, { space: value }) } } async function generateLocation (loc: Location, id: string): Promise { const client = getClient() const doc = await client.findOne(card.class.Card, { _id: id as Ref }) if (doc === undefined) { accessDeniedStore.set(true) return undefined } const appComponent = loc.path[0] ?? '' const workspace = loc.path[1] ?? '' const special = doc._class const objectPanel = client.getHierarchy().classHierarchyMixin(doc._class as Ref>, view.mixin.ObjectPanel) const component = objectPanel?.component ?? view.component.EditDoc return { loc: { path: [appComponent, workspace], fragment: getPanelURI(component, doc._id, doc._class, 'content') }, defaultLocation: { path: [appComponent, workspace, cardId, doc.space, special], fragment: getPanelURI(component, doc._id, doc._class, 'content') } } } export async function resolveLocationData (loc: Location): Promise { const special = loc.path[3] const base = { nameIntl: card.string.Cards } if (special == null) { return base } if (special === 'cards') { return base } const client = getClient() const object = await client.findOne(card.class.Card, { _id: special as Ref }) if (object === undefined) { return base } return { name: object.title } } export async function getCardTitle (client: TxOperations, ref: Ref, doc?: Card): Promise { const object = doc ?? (await client.findOne(card.class.Card, { _id: ref })) if (object === undefined) throw new Error(`Card not found, _id: ${ref}`) const h = client.getHierarchy() const attrs = [...h.getAllAttributes(object._class, core.class.Doc).values()].sort((a, b) => { const rankA = a.rank ?? toRank(a._id) ?? '' const rankB = b.rank ?? toRank(b._id) ?? '' return rankA.localeCompare(rankB) }) const res: string[] = [] for (const attr of attrs) { const val = (object as any)[attr.name] if (attr.showInPresenter === true && val !== undefined) { if (typeof val === 'string' || typeof val === 'number') { res.push(val.toString()) } else if (typeof val === 'boolean') { res.push(val ? '✅' : '❌️') } } } const ids = res.join(' ') let version = '' if (h.classHierarchyMixin(object._class, core.mixin.VersionableClass)?.enabled === true) { version = `v${object.version ?? 1}` } return ids + ' ' + object.title + ' ' + version } export async function cardReferenceObjectProvider ( client: Client, ref: Ref, doc?: T ): Promise { const object = (doc as unknown as Card | undefined) ?? (await client.findOne(card.class.Card, { _id: ref as any as Ref })) if (object === undefined) return const versioningEnabled = client .getHierarchy() .classHierarchyMixin(object._class, core.mixin.VersionableClass)?.enabled if (versioningEnabled !== true) return object const baseId = object.baseId ?? object._id if (object.isLatest === true) return object return (await client.findOne(object._class, { baseId, isLatest: true } as any)) ?? object } export async function getCardLink (doc: Card): Promise { const loc = getCurrentResolvedLocation() loc.path.length = 2 loc.fragment = undefined loc.query = undefined loc.path[2] = cardId loc.path[3] = doc._id return loc } export async function queryCard ( client: Client, search: string, filter?: { in?: RelatedDocument[], nin?: RelatedDocument[] } ): Promise { const q: DocumentQuery = { title: { $like: `%${search}%` } } if (filter?.in !== undefined || filter?.nin !== undefined) { q._id = {} if (filter.in !== undefined) { q._id.$in = filter.in?.map((it) => it._id as Ref) } if (filter.nin !== undefined) { q._id.$nin = filter.nin?.map((it) => it._id as Ref) } } return (await client.findAll(card.class.Card, q, { limit: 200 })).map(toCardObjectSearchResult) } const toCardObjectSearchResult = (e: WithLookup): ObjectSearchResult => ({ doc: e, title: e.title, icon: card.icon.Card, component: CardSearchItem }) export async function cardFactory (props: Record = {}): Promise | undefined> { const _class = props._class as Ref | undefined const space = props.space as Ref | undefined if (_class === undefined || space === undefined) { return undefined } if (isBaseTypeWithSubtypes(getClient().getHierarchy(), _class)) { return undefined } return await createCard(_class, space, props.data, props.content) } export async function createNewVersion (card: Card): Promise> { const client = getClient() const mixin = client.getHierarchy().classHierarchyMixin(card._class, core.mixin.VersionableClass) return await cloneCard( card, { baseId: card.baseId, docCreatedBy: card.docCreatedBy ?? card.createdBy ?? card.modifiedBy }, mixin, true ) } export async function createCard ( type: Ref, space: Ref, data: Partial> = {}, contentMarkup: Markup = EmptyMarkup, id?: Ref ): Promise> { const client = getClient() const hierarchy = client.getHierarchy() if (isBaseTypeWithSubtypes(hierarchy, type)) { throw new Error(`Cannot create card with base type ${type}`) } const title = data.title ?? (await translate(card.string.Card, {})) const _id = id ?? generateId() const content = isEmptyMarkup(contentMarkup) ? ('' as MarkupBlobRef) : await createMarkup(makeCollabId(type, _id, 'content'), contentMarkup) const _data: Data = { parentInfo: [], blobs: {}, ...data, title, rank: '', content } const filledData = fillDefaults(hierarchy, _data, type) await client.createDoc(type, space, filledData, _id) Analytics.handleEvent(CardEvents.CardCreated) return _id } export function isBaseTypeWithSubtypes (hierarchy: Hierarchy, type: Ref): boolean { const clazz = hierarchy.getClass(type) as MasterTag | undefined if (clazz?.baseType !== true) return false return hierarchy.getDescendants(type).some((descendant) => { if (descendant === type || hierarchy.isMixin(descendant)) return false const descendantClass = hierarchy.getClass(descendant) as MasterTag | undefined return descendantClass?._class === card.class.MasterTag && descendantClass.removed !== true }) } export function getFirstCreatableSubtype (hierarchy: Hierarchy, type: Ref): Ref | undefined { return hierarchy.getDescendants(type).find((descendant) => { if (descendant === type || hierarchy.isMixin(descendant)) return false const descendantClass = hierarchy.getClass(descendant) as MasterTag | undefined return ( descendantClass?._class === card.class.MasterTag && descendantClass.removed !== true && !isBaseTypeWithSubtypes(hierarchy, descendant as Ref) ) }) as Ref | undefined } export async function createChildCard (object: Card): Promise { const client = getClient() const hierarchy = client.getHierarchy() const title = await translate(card.string.Card, {}) const data: Data = { parent: object._id, title, rank: '', content: '' as MarkupBlobRef, blobs: {}, parentInfo: [ ...(object.parentInfo ?? []), { _id: object._id, _class: object._class, title: object.title } ] } const filledData = fillDefaults(hierarchy, data, object._class) const _id = await client.createDoc(object._class, object.space, filledData) Analytics.handleEvent(CardEvents.CardCreated) const loc = getCurrentLocation() if (loc.path[2] === chatId) { loc.path[3] = encodeObjectURI(_id, card.class.Card) } else { loc.path[2] = cardId loc.path[3] = _id } loc.path.length = 4 navigate(loc) } export async function createChildAction (doc: Card | Card[]): Promise { if (doc !== undefined && !Array.isArray(doc)) { await createChildCard(doc) } } export function getRootType (hierarchy: Hierarchy, type: Ref): Ref { const ancestors = hierarchy.getAncestors(type) const idx = ancestors.indexOf(card.class.Card) return idx > 0 ? ancestors[idx - 1] : type } export function sortNavigatorTypes (types: MasterTag[], config: NavigatorConfig): MasterTag[] { return types.sort((a, b) => { const aOrder = config.preorder?.find((it) => it.type === a._id)?.order ?? Infinity const bOrder = config.preorder?.find((it) => it.type === b._id)?.order ?? Infinity if (aOrder !== bOrder) { return aOrder - bOrder } return a.label.localeCompare(b.label) }) } export async function openCardInSidebar (cardId: Ref, doc?: Card): Promise { const client = getClient() const widget = client.getModel().findAllSync(workbench.class.Widget, { _id: card.ids.CardWidget as Ref })[0] if (widget === undefined) return const object = doc ?? (await client.findOne(card.class.Card, { _id: cardId })) if (object === undefined) return const tab: WidgetTab = { id: cardId, name: object.title } createWidgetTab(widget, tab, false) } export function cardCustomLinkMatch (doc: Card): boolean { const loc = getCurrentResolvedLocation() const client = getClient() const alias = loc.path[2] const app = client.getModel().findAllSync(workbench.class.Application, { alias })[0] return app.type === 'cards' } export function cardCustomLinkEncode (doc: Card): Location { const loc = getCurrentResolvedLocation() loc.path[3] = encodeObjectURI(doc._id, card.class.Card) return loc } export async function checkOldMessagesSectionVisibility (doc: Card): Promise { if (!hasAccountRole(getCurrentAccount(), AccountRole.User)) { return false } return getMetadata(communication.metadata.Enabled) !== true } export async function checkCommunicationMessagesSectionVisibility (doc: Card): Promise { if (!hasAccountRole(getCurrentAccount(), AccountRole.User)) { return false } return getMetadata(communication.metadata.Enabled) === true } export async function checkChildrenSectionVisibility (doc: Card): Promise { return (doc.children ?? 0) > 0 } export async function checkRelationsSectionVisibility (doc: Card): Promise { const client = getClient() const h = client.getHierarchy() const parents = h.getAncestors(doc._class) const mixins = h.findAllMixins(doc) const associationsB = client .getModel() .findAllSync(core.class.Association, { classA: { $in: [...parents, ...mixins] } }) .filter((a) => a.nameB.trim().length > 0) if (associationsB.length > 0) { return true } return ( client .getModel() .findAllSync(core.class.Association, { classB: { $in: [...parents, ...mixins] } }) .filter((a) => a.nameA.trim().length > 0).length > 0 ) } export function getCardIconInfo (doc?: Card): { icon: IconComponent, props: IconProps } { if (doc === undefined) return { icon: card.icon.Card, props: {} } if (doc.icon === view.ids.IconWithEmoji) { return { icon: IconWithEmoji, props: { icon: doc.color } } } if (doc.icon !== undefined) { return { icon: doc.icon, props: {} } } const client = getClient() const hierarchy = client.getHierarchy() const clazz = hierarchy.getClass(doc._class) as MasterTag if (clazz?.icon === view.ids.IconWithEmoji) { return { icon: IconWithEmoji, props: { icon: clazz.color } } } return { icon: clazz?.icon ?? card.icon.MasterTag, props: {} } } export function getAccountClient (): AccountClient { const accountsUrl = getMetadata(login.metadata.AccountsUrl) const token = getMetadata(presentation.metadata.Token) return getAccountClientRaw(accountsUrl, token) } export async function getSpaceAccessPublicLink (doc?: Doc | Doc[]): Promise { doc = Array.isArray(doc) ? doc[0] : doc if (doc === undefined) { return '' } const accountClient = getAccountClient() const navigateUrl = getCurrentLocation() navigateUrl.path[2] = cardId navigateUrl.path.length = 3 const accessLink = await accountClient.createAccessLink(AccountRole.Guest, { spaces: [doc._id], navigateUrl: JSON.stringify(navigateUrl) }) return accessLink } export async function canGetSpaceAccessPublicLink (doc?: Doc | Doc[]): Promise { if (!hasAccountRole(getCurrentAccount(), AccountRole.User)) { return false } return await canCopyLink(doc) } export function canLockSection (space: Ref, store: PermissionsStore): boolean { if (getMetadata(core.metadata.DisablePermissions) === true) return true if (store.whitelist.has(space)) return true const allowed = store.ps[space]?.has(card.permission.LockSection) if (allowed) return true return !store.restrictedSpaces.has(space) } export function canUnlockSection (space: Ref, store: PermissionsStore): boolean { if (getMetadata(core.metadata.DisablePermissions) === true) return true if (store.whitelist.has(space)) return true const allowed = store.ps[space]?.has(card.permission.UnlockSection) if (allowed) return true return !store.restrictedSpaces.has(space) } export function showAllVersions (value: any, query: DocumentQuery): DocumentQuery { if (value === true) { return { ...query, isLatest: { $in: [true, false] } } } return query } export const viewStore = writable, string>>( JSON.parse(localStorage.getItem('card.layout') ?? '{}') ) export function setViewMode (type: Ref, mode: string): void { viewStore.update((views) => { views[type] = mode localStorage.setItem('card.layout', JSON.stringify(views)) return views }) }