Add data converter plugin (#10460)

* Add data converter plugin

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Clean up

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Apply templates

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix test

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-01-29 14:44:13 +07:00
committed by GitHub
parent b27b511e7a
commit 111f9e14d7
83 changed files with 3078 additions and 1921 deletions
+234
View File
@@ -165,6 +165,12 @@ importers:
'@hcengineering/controlled-documents-resources':
specifier: workspace:^0.7.0
version: link:../plugins/controlled-documents-resources
'@hcengineering/converter':
specifier: workspace:^0.7.0
version: link:../plugins/converter
'@hcengineering/converter-resources':
specifier: workspace:^0.7.0
version: link:../plugins/converter-resources
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../foundations/core/packages/core
@@ -1216,6 +1222,12 @@ importers:
'@hcengineering/controlled-documents-resources':
specifier: workspace:^0.7.0
version: link:../../plugins/controlled-documents-resources
'@hcengineering/converter':
specifier: workspace:^0.7.0
version: link:../../plugins/converter
'@hcengineering/converter-resources':
specifier: workspace:^0.7.0
version: link:../../plugins/converter-resources
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../../foundations/core/packages/core
@@ -5962,6 +5974,9 @@ importers:
'@hcengineering/model-controlled-documents':
specifier: workspace:^0.7.0
version: link:../controlled-documents
'@hcengineering/model-converter':
specifier: workspace:^0.7.0
version: link:../converter
'@hcengineering/model-core':
specifier: workspace:^0.7.0
version: link:../core
@@ -6824,6 +6839,9 @@ importers:
'@hcengineering/contact':
specifier: workspace:^0.7.0
version: link:../../plugins/contact
'@hcengineering/converter':
specifier: workspace:^0.7.0
version: link:../../plugins/converter
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../../foundations/core/packages/core
@@ -7345,6 +7363,9 @@ importers:
'@hcengineering/controlled-documents-resources':
specifier: workspace:^0.7.0
version: link:../../plugins/controlled-documents-resources
'@hcengineering/converter':
specifier: workspace:^0.7.0
version: link:../../plugins/converter
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../../foundations/core/packages/core
@@ -7467,6 +7488,67 @@ importers:
specifier: ^5.9.3
version: 5.9.3
../../models/converter:
dependencies:
'@hcengineering/converter':
specifier: workspace:^0.7.0
version: link:../../plugins/converter
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../../foundations/core/packages/core
'@hcengineering/model':
specifier: workspace:^0.7.17
version: link:../../foundations/core/packages/model
'@hcengineering/model-core':
specifier: workspace:^0.7.0
version: link:../core
'@hcengineering/platform':
specifier: workspace:^0.7.19
version: link:../../foundations/core/packages/platform
devDependencies:
'@hcengineering/platform-rig':
specifier: workspace:^0.7.19
version: link:../../foundations/utils/packages/platform-rig
'@types/jest':
specifier: ^29.5.5
version: 29.5.14
'@types/node':
specifier: ^22.18.1
version: 22.19.0
'@typescript-eslint/eslint-plugin':
specifier: ^6.21.0
version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/parser':
specifier: ^6.21.0
version: 6.21.0(eslint@8.57.1)(typescript@5.9.3)
eslint:
specifier: ^8.54.0
version: 8.57.1
eslint-config-standard-with-typescript:
specifier: ^40.0.0
version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3)
eslint-plugin-import:
specifier: ^2.26.0
version: 2.32.0(eslint@8.57.1)
eslint-plugin-n:
specifier: ^15.4.0
version: 15.7.0(eslint@8.57.1)
eslint-plugin-promise:
specifier: ^6.1.1
version: 6.6.0(eslint@8.57.1)
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@swc/core@1.15.1)(@types/node@22.19.0)(typescript@5.9.3))
prettier:
specifier: ^3.6.2
version: 3.6.2
ts-jest:
specifier: ^29.1.1
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.0))(typescript@5.9.3)
typescript:
specifier: ^5.9.3
version: 5.9.3
../../models/core:
dependencies:
'@hcengineering/collaboration':
@@ -13252,6 +13334,9 @@ importers:
'@hcengineering/contact':
specifier: workspace:^0.7.0
version: link:../../plugins/contact
'@hcengineering/converter':
specifier: workspace:^0.7.0
version: link:../../plugins/converter
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../../foundations/core/packages/core
@@ -17359,6 +17444,12 @@ importers:
'@hcengineering/contact-resources':
specifier: workspace:^0.7.0
version: link:../contact-resources
'@hcengineering/converter':
specifier: workspace:^0.7.0
version: link:../converter
'@hcengineering/converter-resources':
specifier: workspace:^0.7.0
version: link:../converter-resources
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../../foundations/core/packages/core
@@ -18581,6 +18672,9 @@ importers:
'@hcengineering/contact':
specifier: workspace:^0.7.0
version: link:../contact
'@hcengineering/converter':
specifier: workspace:^0.7.0
version: link:../converter
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../../foundations/core/packages/core
@@ -18739,6 +18833,12 @@ importers:
'@hcengineering/controlled-documents':
specifier: workspace:^0.7.0
version: link:../controlled-documents
'@hcengineering/converter':
specifier: workspace:^0.7.0
version: link:../converter
'@hcengineering/converter-resources':
specifier: workspace:^0.7.0
version: link:../converter-resources
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../../foundations/core/packages/core
@@ -18888,6 +18988,128 @@ importers:
specifier: ^5.9.3
version: 5.9.3
../../plugins/converter:
dependencies:
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../../foundations/core/packages/core
'@hcengineering/platform':
specifier: workspace:^0.7.19
version: link:../../foundations/core/packages/platform
'@hcengineering/view':
specifier: workspace:^0.7.0
version: link:../view
devDependencies:
'@hcengineering/platform-rig':
specifier: workspace:^0.7.19
version: link:../../foundations/utils/packages/platform-rig
'@types/jest':
specifier: ^29.5.5
version: 29.5.14
'@typescript-eslint/eslint-plugin':
specifier: ^6.21.0
version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/parser':
specifier: ^6.21.0
version: 6.21.0(eslint@8.57.1)(typescript@5.9.3)
eslint:
specifier: ^8.54.0
version: 8.57.1
eslint-config-standard-with-typescript:
specifier: ^40.0.0
version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3)
eslint-plugin-import:
specifier: ^2.26.0
version: 2.32.0(eslint@8.57.1)
eslint-plugin-n:
specifier: ^15.4.0
version: 15.7.0(eslint@8.57.1)
eslint-plugin-promise:
specifier: ^6.1.1
version: 6.6.0(eslint@8.57.1)
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@swc/core@1.15.1)(@types/node@22.19.0)(typescript@5.9.3))
prettier:
specifier: ^3.6.2
version: 3.6.2
ts-jest:
specifier: ^29.1.1
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0)(typescript@5.9.3)
typescript:
specifier: ^5.9.3
version: 5.9.3
../../plugins/converter-resources:
dependencies:
'@hcengineering/contact':
specifier: workspace:^0.7.0
version: link:../contact
'@hcengineering/converter':
specifier: workspace:^0.7.0
version: link:../converter
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../../foundations/core/packages/core
'@hcengineering/platform':
specifier: workspace:^0.7.19
version: link:../../foundations/core/packages/platform
'@hcengineering/presentation':
specifier: workspace:^0.7.0
version: link:../../packages/presentation
'@hcengineering/theme':
specifier: workspace:^0.7.0
version: link:../../packages/theme
'@hcengineering/ui':
specifier: workspace:^0.7.0
version: link:../../packages/ui
'@hcengineering/view':
specifier: workspace:^0.7.0
version: link:../view
'@hcengineering/view-resources':
specifier: workspace:^0.7.0
version: link:../view-resources
devDependencies:
'@hcengineering/platform-rig':
specifier: workspace:^0.7.19
version: link:../../foundations/utils/packages/platform-rig
'@types/jest':
specifier: ^29.5.5
version: 29.5.14
'@typescript-eslint/eslint-plugin':
specifier: ^6.21.0
version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/parser':
specifier: ^6.21.0
version: 6.21.0(eslint@8.57.1)(typescript@5.9.3)
eslint:
specifier: ^8.54.0
version: 8.57.1
eslint-config-standard-with-typescript:
specifier: ^40.0.0
version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3)
eslint-plugin-import:
specifier: ^2.26.0
version: 2.32.0(eslint@8.57.1)
eslint-plugin-n:
specifier: ^15.4.0
version: 15.7.0(eslint@8.57.1)
eslint-plugin-promise:
specifier: ^6.1.1
version: 6.6.0(eslint@8.57.1)
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@swc/core@1.15.1)(@types/node@22.19.0)(typescript@5.9.3))
prettier:
specifier: ^3.6.2
version: 3.6.2
ts-jest:
specifier: ^29.1.1
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0)(typescript@5.9.3)
typescript:
specifier: ^5.9.3
version: 5.9.3
../../plugins/desktop-downloads:
dependencies:
'@hcengineering/core':
@@ -27991,6 +28213,9 @@ importers:
'@hcengineering/contact':
specifier: workspace:^0.7.0
version: link:../contact
'@hcengineering/converter':
specifier: workspace:^0.7.0
version: link:../converter
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../../foundations/core/packages/core
@@ -28666,6 +28891,12 @@ importers:
'@hcengineering/contact-resources':
specifier: workspace:^0.7.0
version: link:../contact-resources
'@hcengineering/converter':
specifier: workspace:^0.7.0
version: link:../converter
'@hcengineering/converter-resources':
specifier: workspace:^0.7.0
version: link:../converter-resources
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../../foundations/core/packages/core
@@ -29377,6 +29608,9 @@ importers:
'@hcengineering/contact':
specifier: workspace:^0.7.0
version: link:../contact
'@hcengineering/converter':
specifier: workspace:^0.7.0
version: link:../converter
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../../foundations/core/packages/core
+2
View File
@@ -88,6 +88,8 @@
"@hcengineering/view": "workspace:^0.7.0",
"@hcengineering/view-assets": "workspace:^0.7.0",
"@hcengineering/view-resources": "workspace:^0.7.0",
"@hcengineering/converter": "workspace:^0.7.0",
"@hcengineering/converter-resources": "workspace:^0.7.0",
"@hcengineering/contact": "workspace:^0.7.0",
"@hcengineering/contact-resources": "workspace:^0.7.0",
"@hcengineering/task": "workspace:^0.7.0",
+2
View File
@@ -33,6 +33,7 @@ import { cardId } from '@hcengineering/card'
import { chunterId } from '@hcengineering/chunter'
import client, { clientId } from '@hcengineering/client'
import contactPlugin, { contactId } from '@hcengineering/contact'
import { converterId } from '@hcengineering/converter'
import { documentsId } from '@hcengineering/controlled-documents'
import { desktopPreferencesId } from '@hcengineering/desktop-preferences'
import { desktopDownloadsId } from '@hcengineering/desktop-downloads'
@@ -435,6 +436,7 @@ export async function configurePlatform (onWorkbenchConnect?: () => Promise<void
addLocation(onboardId, async () => await import('@hcengineering/onboard-resources'))
addLocation(workbenchId, async () => await import('@hcengineering/workbench-resources'))
addLocation(viewId, async () => await import('@hcengineering/view-resources'))
addLocation(converterId, async () => await import('@hcengineering/converter-resources'))
addLocation(taskId, async () => await import('@hcengineering/task-resources'))
addLocation(contactId, async () => await import('@hcengineering/contact-resources'))
addLocation(chunterId, async () => await import('@hcengineering/chunter-resources'))
+2
View File
@@ -265,6 +265,8 @@
"@hcengineering/view": "workspace:^0.7.0",
"@hcengineering/view-assets": "workspace:^0.7.0",
"@hcengineering/view-resources": "workspace:^0.7.0",
"@hcengineering/converter": "workspace:^0.7.0",
"@hcengineering/converter-resources": "workspace:^0.7.0",
"@hcengineering/workbench": "workspace:^0.7.0",
"@hcengineering/workbench-assets": "workspace:^0.7.0",
"@hcengineering/workbench-resources": "workspace:^0.7.0",
+2
View File
@@ -25,6 +25,7 @@ import { cardId } from '@hcengineering/card'
import { chunterId } from '@hcengineering/chunter'
import client, { clientId } from '@hcengineering/client'
import contactPlugin, { contactId } from '@hcengineering/contact'
import { converterId } from '@hcengineering/converter'
import { documentsId } from '@hcengineering/controlled-documents'
import { desktopPreferencesId } from '@hcengineering/desktop-preferences'
import { diffviewId } from '@hcengineering/diffview'
@@ -570,6 +571,7 @@ export async function configurePlatform() {
async () => await import(/* webpackChunkName: "workbench" */ '@hcengineering/workbench-resources')
)
addLocation(viewId, async () => await import(/* webpackChunkName: "view" */ '@hcengineering/view-resources'))
addLocation(converterId, async () => await import(/* webpackChunkName: "converter" */ '@hcengineering/converter-resources'))
addLocation(taskId, async () => await import(/* webpackChunkName: "task" */ '@hcengineering/task-resources'))
addLocation(contactId, async () => await import(/* webpackChunkName: "contact" */ '@hcengineering/contact-resources'))
addLocation(chunterId, async () => await import(/* webpackChunkName: "chunter" */ '@hcengineering/chunter-resources'))
+1
View File
@@ -42,6 +42,7 @@
"@hcengineering/model": "workspace:^0.7.17",
"@hcengineering/model-core": "workspace:^0.7.0",
"@hcengineering/model-view": "workspace:^0.7.0",
"@hcengineering/model-converter": "workspace:^0.7.0",
"@hcengineering/model-workbench": "workspace:^0.7.0",
"@hcengineering/model-contact": "workspace:^0.7.0",
"@hcengineering/model-task": "workspace:^0.7.0",
+2
View File
@@ -82,6 +82,7 @@ import tracker, { trackerId, createModel as trackerModel } from '@hcengineering/
import { uploaderId, createModel as uploaderModel } from '@hcengineering/model-uploader'
import view, { viewId, createModel as viewModel } from '@hcengineering/model-view'
import workbench, { workbenchId, createModel as workbenchModel } from '@hcengineering/model-workbench'
import { converterId, createModel as converterModel } from '@hcengineering/model-converter'
import document, { documentId, createModel as documentModel } from '@hcengineering/model-document'
import { serverDocumentId, createModel as serverDocumentModel } from '@hcengineering/model-server-document'
@@ -524,6 +525,7 @@ export default function buildModel (): Builder {
classFilter: defaultFilter
}
],
[converterModel, converterId],
[serverCoreModel, serverCoreId],
[serverAttachmentModel, serverAttachmentId],
+1
View File
@@ -37,6 +37,7 @@
"@hcengineering/activity": "workspace:^0.7.0",
"@hcengineering/contact": "workspace:^0.7.0",
"@hcengineering/communication": "workspace:^0.7.0",
"@hcengineering/converter": "workspace:^0.7.0",
"@hcengineering/core": "workspace:^0.7.24",
"@hcengineering/model": "workspace:^0.7.17",
"@hcengineering/chunter": "workspace:^0.7.0",
+5
View File
@@ -75,6 +75,7 @@ import presentation from '@hcengineering/model-presentation'
import setting from '@hcengineering/model-setting'
import view, { type Viewlet } from '@hcengineering/model-view'
import workbench, { WidgetType } from '@hcengineering/model-workbench'
import converter from '@hcengineering/converter'
import { type Asset, getEmbeddedLabel, type IntlString, type Resource } from '@hcengineering/platform'
import time, { type ToDo } from '@hcengineering/time'
import { PaletteColorIndexes } from '@hcengineering/ui/src/colors'
@@ -327,6 +328,10 @@ export function createSystemType (
}
})
builder.mixin(card.class.Card, core.class.Class, converter.mixin.MarkdownValueFormatter, {
formatter: card.function.FormatCardMarkdownValue
})
builder.createDoc(view.class.Viewlet, core.space.Model, {
attachTo: type,
descriptor: view.viewlet.Table,
+1
View File
@@ -49,6 +49,7 @@
"@hcengineering/model": "workspace:^0.7.17",
"@hcengineering/setting": "workspace:^0.7.0",
"@hcengineering/core": "workspace:^0.7.24",
"@hcengineering/converter": "workspace:^0.7.0",
"@hcengineering/ui": "workspace:^0.7.0",
"@hcengineering/platform": "workspace:^0.7.19",
"@hcengineering/view": "workspace:^0.7.0",
+5
View File
@@ -36,6 +36,7 @@ import request from '@hcengineering/model-request'
import tracker from '@hcengineering/model-tracker'
import view, { classPresenter, createAction } from '@hcengineering/model-view'
import workbench from '@hcengineering/model-workbench'
import converter from '@hcengineering/converter'
import notification from '@hcengineering/notification'
import contacts from '@hcengineering/model-contact'
import setting from '@hcengineering/setting'
@@ -111,6 +112,10 @@ export function createModel (builder: Builder): void {
titleProvider: documents.function.ControlledDocumentTitleProvider
})
builder.mixin(documents.class.Document, core.class.Class, converter.mixin.MarkdownValueFormatter, {
formatter: documents.function.FormatDocumentMarkdownValue
})
builder.mixin(documents.class.DocumentApprovalRequest, core.class.Class, view.mixin.ObjectPresenter, {
presenter: documents.component.DocumentApprovalRequestPresenter
})
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/model/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
+4
View File
@@ -0,0 +1,4 @@
*
!/lib/**
!CHANGELOG.md
/lib/**/__tests__/
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig",
"rigProfile": "model"
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@hcengineering/model-converter",
"version": "0.7.0",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"author": "Copyright © Hardcore Engineering Inc.",
"template": "@hcengineering/model-package",
"license": "EPL-2.0",
"scripts": {
"build": "compile",
"build:watch": "compile",
"format": "format src",
"_phase:build": "compile transpile src",
"_phase:format": "format src",
"_phase:validate": "compile validate",
"_phase:test": "jest --passWithNoTests --silent --forceExit",
"test": "jest --passWithNoTests --silent --forceExit"
},
"devDependencies": {
"@hcengineering/platform-rig": "workspace:^0.7.19",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0",
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"@types/node": "^22.18.1",
"jest": "^29.7.0",
"@types/jest": "^29.5.5",
"ts-jest": "^29.1.1"
},
"dependencies": {
"@hcengineering/core": "workspace:^0.7.24",
"@hcengineering/model-core": "workspace:^0.7.0",
"@hcengineering/model": "workspace:^0.7.17",
"@hcengineering/platform": "workspace:^0.7.19",
"@hcengineering/converter": "workspace:^0.7.0"
}
}
+30
View File
@@ -0,0 +1,30 @@
//
// Copyright © 2026 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 Builder, Mixin } from '@hcengineering/model'
import core, { TClass } from '@hcengineering/model-core'
import converter, { type MarkdownValueFormatter, type ValueFormatter } from '@hcengineering/converter'
import type { Resource } from '@hcengineering/platform'
export { converterId } from '@hcengineering/converter'
@Mixin(converter.mixin.MarkdownValueFormatter, core.class.Class)
export class TMarkdownValueFormatter extends TClass implements MarkdownValueFormatter {
formatter!: Resource<ValueFormatter>
}
export function createModel (builder: Builder): void {
builder.createModel(TMarkdownValueFormatter)
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/model/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
}
+1
View File
@@ -37,6 +37,7 @@
"@hcengineering/activity": "workspace:^0.7.0",
"@hcengineering/chunter": "workspace:^0.7.0",
"@hcengineering/contact": "workspace:^0.7.0",
"@hcengineering/converter": "workspace:^0.7.0",
"@hcengineering/core": "workspace:^0.7.24",
"@hcengineering/model": "workspace:^0.7.17",
"@hcengineering/model-attachment": "workspace:^0.7.0",
+5
View File
@@ -23,6 +23,7 @@ import presentation from '@hcengineering/model-presentation'
import task from '@hcengineering/model-task'
import view from '@hcengineering/model-view'
import workbench from '@hcengineering/model-workbench'
import converter from '@hcengineering/converter'
import notification from '@hcengineering/notification'
import setting from '@hcengineering/setting'
import pluginState, { type Issue, trackerId } from '@hcengineering/tracker'
@@ -82,6 +83,10 @@ export const classicIssueTaskStatuses: TaskStatusFactory[] = [
]
function defineSortAndGrouping (builder: Builder): void {
builder.mixin(tracker.class.Issue, core.class.Class, converter.mixin.MarkdownValueFormatter, {
formatter: tracker.function.FormatIssueMarkdownValue
})
builder.mixin(tracker.class.IssueStatus, core.class.Class, view.mixin.SortFuncs, {
func: tracker.function.IssueStatusSort
})
+2
View File
@@ -65,6 +65,8 @@
"@hcengineering/text-markdown": "workspace:^0.7.20",
"@hcengineering/workbench-resources": "workspace:^0.7.0",
"@hcengineering/view-resources": "workspace:^0.7.0",
"@hcengineering/converter": "workspace:^0.7.0",
"@hcengineering/converter-resources": "workspace:^0.7.0",
"@hcengineering/notification": "workspace:^0.7.0",
"@hcengineering/contact": "workspace:^0.7.0",
"@hcengineering/workbench": "workspace:^0.7.0",
@@ -18,7 +18,7 @@ import { translate, type IntlString } from '@hcengineering/platform'
import cardPlugin, { type CardSpace } from '@hcengineering/card'
import { type AttributeModel } from '@hcengineering/view'
import { getClient } from '@hcengineering/presentation'
import { registerValueFormatterForClass, isIntlString } from '@hcengineering/view-resources'
import { isIntlString } from '@hcengineering/converter-resources'
/**
* Cache for MasterTag ID -> label mappings to reduce database calls
@@ -95,7 +95,7 @@ async function loadCardSpaceName (spaceRef: Ref<CardSpace>): Promise<string> {
* Value formatter for card fields
* Handles special cases for type (MasterTag) and space (CardSpace) fields
*/
async function formatCardValue (
export async function formatCardValue (
attr: AttributeModel,
card: Doc,
hierarchy: Hierarchy,
@@ -120,13 +120,11 @@ async function formatCardValue (
if (lookupClass !== undefined && lookupClass !== null) {
const classObj = lookupClass as Record<string, unknown>
const label: unknown = classObj.label
if (label !== undefined) {
if (typeof label === 'string' && isIntlString(label)) {
if (typeof label === 'string') {
if (isIntlString(label)) {
return await translate(label as unknown as IntlString, {}, language)
}
if (typeof label === 'string') {
return label
}
return label
}
}
// If not in lookup, get from hierarchy
@@ -161,6 +159,3 @@ async function formatCardValue (
return undefined
}
// Register the formatter for Card class
registerValueFormatterForClass(cardPlugin.class.Card, formatCardValue)
+3 -3
View File
@@ -37,6 +37,7 @@ import {
cardFactory,
duplicateCard
} from './utils'
import { formatCardValue } from './cardTableFormatter'
import ManageMasterTagsContent from './components/settings/ManageMasterTagsContent.svelte'
import ManageMasterTagsTools from './components/settings/ManageMasterTagsTools.svelte'
import ManageMasterTags from './components/settings/ManageMasterTags.svelte'
@@ -80,8 +81,6 @@ import CardWidgetTab from './components/CardWidgetTab.svelte'
import CardIcon from './components/CardIcon.svelte'
import CardFeedView from './components/CardFeedView.svelte'
import './cardTableFormatter'
export { default as CardSelector } from './components/CardSelector.svelte'
export { default as CardIcon } from './components/CardIcon.svelte'
export { default as Navigator } from './components/navigator-next/Navigator.svelte'
@@ -169,6 +168,7 @@ export default async (): Promise<Resources> => ({
CheckCommunicationMessagesSectionVisibility: checkCommunicationMessagesSectionVisibility,
GetSpaceAccessPublicLink: getSpaceAccessPublicLink,
CanGetSpaceAccessPublicLink: canGetSpaceAccessPublicLink,
CardFactory: cardFactory
CardFactory: cardFactory,
FormatCardMarkdownValue: formatCardValue
}
})
+3 -1
View File
@@ -19,6 +19,7 @@ import { type IntlString, mergeIds, type Resource } from '@hcengineering/platfor
import { type ObjectSearchCategory, type ObjectSearchFactory } from '@hcengineering/presentation'
import { type AnyComponent } from '@hcengineering/ui/src/types'
import type { ViewletDescriptor, Viewlet } from '@hcengineering/view'
import type { ValueFormatter } from '@hcengineering/converter'
export default mergeIds(cardId, card, {
component: {
@@ -57,7 +58,8 @@ export default mergeIds(cardId, card, {
CreateRolePopup: '' as AnyComponent
},
function: {
CardFactory: '' as Resource<(props?: Record<string, any>) => Promise<Ref<Doc> | undefined>>
CardFactory: '' as Resource<(props?: Record<string, any>) => Promise<Ref<Doc> | undefined>>,
FormatCardMarkdownValue: '' as Resource<ValueFormatter>
},
permission: {
CreateCard: '' as Ref<Permission>,
@@ -48,6 +48,8 @@
"@hcengineering/notification-resources": "workspace:^0.7.0",
"@hcengineering/panel": "workspace:^0.7.0",
"@hcengineering/view-resources": "workspace:^0.7.0",
"@hcengineering/converter": "workspace:^0.7.0",
"@hcengineering/converter-resources": "workspace:^0.7.0",
"@hcengineering/attachment": "workspace:^0.7.0",
"@hcengineering/notification": "workspace:^0.7.0",
"@hcengineering/account-client": "workspace:^0.7.21",
@@ -18,7 +18,7 @@ import { translate, type IntlString } from '@hcengineering/platform'
import documentsPlugin from '@hcengineering/controlled-documents'
import { type AttributeModel } from '@hcengineering/view'
import { getClient } from '@hcengineering/presentation'
import { registerValueFormatterForClass, isIntlString } from '@hcengineering/view-resources'
import { isIntlString } from '@hcengineering/converter-resources'
/**
* Format version number from major and minor
@@ -78,7 +78,7 @@ async function loadSpaceName (spaceRef: Ref<Space>): Promise<string> {
* Value formatter for controlled document fields
* Handles special cases where empty keys use custom presenters
*/
async function formatControlledDocumentValue (
export async function formatControlledDocumentValue (
attr: AttributeModel,
card: Doc,
hierarchy: Hierarchy,
@@ -255,6 +255,3 @@ async function formatControlledDocumentValue (
return undefined
}
// Register the formatter for Document class and all derived classes
registerValueFormatterForClass(documentsPlugin.class.Document, formatControlledDocumentValue)
@@ -117,7 +117,7 @@ import {
getDocumentMetaTitle
} from './utils'
import './docTableFormatter'
import { formatControlledDocumentValue } from './docTableFormatter'
export { DocumentStatusTag, DocumentTitle, DocumentVersionPresenter, StatePresenter }
@@ -444,6 +444,7 @@ export default async (): Promise<Resources> => ({
ProjectDocumentReferenceObjectProvider: projectDocumentReferenceObjectProvider,
ControlledDocumentTitleProvider: getControlledDocumentTitle,
DocumentMetaTitleProvider: getDocumentMetaTitle,
FormatDocumentMarkdownValue: formatControlledDocumentValue,
Comment: comment,
IsCommentVisible: isCommentVisible
},
@@ -40,6 +40,7 @@
"@hcengineering/platform": "workspace:^0.7.19",
"@hcengineering/ui": "workspace:^0.7.0",
"@hcengineering/core": "workspace:^0.7.24",
"@hcengineering/converter": "workspace:^0.7.0",
"@hcengineering/view": "workspace:^0.7.0",
"@hcengineering/contact": "workspace:^0.7.0",
"@hcengineering/notification": "workspace:^0.7.0",
+2 -1
View File
@@ -134,7 +134,8 @@ export const documentsPlugin = plugin(documentsId, {
},
function: {
CanChangeDocumentOwner: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanDeleteDocumentCategory: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>
CanDeleteDocumentCategory: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
FormatDocumentMarkdownValue: '' as Resource<import('@hcengineering/converter').ValueFormatter>
},
icon: {
Approvals: '' as Asset,
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@hcengineering/converter-resources",
"version": "0.7.0",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"files": [
"lib/**/*",
"types/**/*",
"tsconfig.json"
],
"author": "Copyright © Hardcore Engineering Inc.",
"license": "EPL-2.0",
"scripts": {
"build": "compile",
"format": "format src",
"build:watch": "compile",
"_phase:build": "compile transpile src",
"_phase:format": "format src",
"_phase:validate": "compile validate",
"_phase:test": "jest --passWithNoTests --silent",
"test": "jest --passWithNoTests --silent"
},
"devDependencies": {
"@hcengineering/platform-rig": "workspace:^0.7.19",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-n": "^15.4.0",
"eslint-plugin-promise": "^6.1.1",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"eslint": "^8.54.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5"
},
"dependencies": {
"@hcengineering/platform": "workspace:^0.7.19",
"@hcengineering/core": "workspace:^0.7.24",
"@hcengineering/view": "workspace:^0.7.0",
"@hcengineering/converter": "workspace:^0.7.0",
"@hcengineering/presentation": "workspace:^0.7.0",
"@hcengineering/ui": "workspace:^0.7.0",
"@hcengineering/theme": "workspace:^0.7.0",
"@hcengineering/contact": "workspace:^0.7.0",
"@hcengineering/view-resources": "workspace:^0.7.0"
}
}
@@ -0,0 +1,67 @@
//
// Copyright © 2026 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 { MarkdownTableConverter } from '../types'
describe('MarkdownTableConverter', () => {
const converter = new MarkdownTableConverter()
it('has format "markdown"', () => {
expect(converter.format).toBe('markdown')
})
describe('buildTable', () => {
it('builds markdown table from headers and rows', () => {
const headers = ['Name', 'Status']
const rows = [
['Task A', 'Done'],
['Task B', 'In Progress']
]
const result = converter.buildTable(headers, rows)
expect(result).toBe(
'| Name | Status |\n' + '| --- | --- |\n' + '| Task A | Done |\n' + '| Task B | In Progress |\n'
)
})
it('handles empty rows', () => {
const headers = ['Col1']
const result = converter.buildTable(headers, [])
expect(result).toBe('| Col1 |\n| --- |\n')
})
})
describe('escapeValue', () => {
it('escapes pipe in text', () => {
expect(converter.escapeValue('a|b')).toBe('a\\|b')
})
it('escapes brackets in text', () => {
expect(converter.escapeValue('[link]')).toBe('\\[link\\]')
})
})
describe('createLink', () => {
it('produces markdown link syntax', () => {
const result = converter.createLink('https://example.com', 'Example')
expect(result).toBe('[Example](https://example.com)')
})
it('escapes link text and url', () => {
const result = converter.createLink('https://example.com/path)', 'Text|here')
expect(result).toContain('\\|')
expect(result).toContain('\\)')
})
})
})
@@ -0,0 +1,220 @@
//
// Copyright © 2026 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 { buildMarkdownTableFromDocs, copyAsMarkdownTable, isIntlString } from '../index'
import type { Doc, Class, Ref } from '@hcengineering/core'
import type { CopyAsMarkdownTableProps } from '../types'
import { getClient } from '@hcengineering/presentation'
import { getCurrentLanguage } from '@hcengineering/theme'
import { copyMarkdown, buildModel, getObjectLinkFragment } from '@hcengineering/view-resources'
import core from '@hcengineering/core'
import { type IntlString } from '@hcengineering/platform'
jest.mock('@hcengineering/platform', () => {
const actual = jest.requireActual('@hcengineering/platform')
return {
...actual,
translate: jest.fn(async (str: unknown) => `translated:${String(str)}`),
getMetadata: jest.fn((key: unknown) => {
if (key != null && String(key).includes('FrontUrl')) {
return 'http://test.local:8080'
}
return undefined
})
}
})
jest.mock('@hcengineering/presentation', () => ({
getClient: jest.fn()
}))
jest.mock('@hcengineering/theme', () => ({
getCurrentLanguage: jest.fn(() => 'en')
}))
jest.mock('@hcengineering/view-resources', () => ({
copyMarkdown: jest.fn(),
buildModel: jest.fn(),
buildConfigLookup: jest.fn(() => ({})),
getObjectLinkFragment: jest.fn()
}))
jest.mock('@hcengineering/ui', () => ({
addNotification: jest.fn(),
NotificationSeverity: { Success: 'success', Error: 'error' },
locationToUrl: jest.fn((loc: unknown) => {
if (loc != null && typeof loc === 'object' && 'path' in loc && Array.isArray((loc as { path: string[] }).path)) {
return (loc as { path: string[] }).path.join('/')
}
return 'workbench/w3/card/test-id'
}),
getCurrentResolvedLocation: jest.fn(() => ({ path: ['workbench', 'w3', 'card', 'test-id'] }))
}))
jest.mock('@hcengineering/view', () => {
const viewMock = {
string: {
Copied: 'view:string:Copied',
TableCopiedToClipboard: 'view:string:TableCopiedToClipboard',
TableCopyFailed: 'view:string:TableCopyFailed'
},
class: { Viewlet: 'view:class:Viewlet', ViewletPreference: 'view:class:ViewletPreference' },
viewlet: { Table: 'view:viewlet:Table' }
}
return { __esModule: true, default: viewMock }
})
const mockGetObjectLinkFragment = getObjectLinkFragment as jest.Mock
describe('converter-resources', () => {
describe('isIntlString', () => {
it('returns true for plugin:resource:key', () => {
expect(isIntlString('card:string:Card')).toBe(true)
})
it('returns false for plain string', () => {
expect(isIntlString('Hello')).toBe(false)
})
})
describe('buildMarkdownTableFromDocs', () => {
let mockClient: any
let mockHierarchy: any
let mockDoc: Doc
beforeEach(() => {
jest.clearAllMocks()
mockGetObjectLinkFragment.mockResolvedValue({ path: ['workbench', 'w3', 'card', 'doc1'] })
mockHierarchy = {
getClass: jest.fn((ref: Ref<Class<Doc>>) => (ref === 'card:class:Card' ? { _id: ref } : null)),
findAttribute: jest.fn(() => ({ type: { _class: core.class.TypeString } })),
as: jest.fn((doc: Doc) => doc),
classHierarchyMixin: jest.fn(() => undefined)
}
mockClient = {
getHierarchy: () => mockHierarchy,
findAll: jest.fn(async () => []),
findOne: jest.fn(async () => null)
}
mockDoc = {
_id: 'doc1',
_class: 'card:class:Card',
title: 'Test Card',
createdOn: 1732178763949,
modifiedOn: 1732178770252
} as unknown as Doc
;(getClient as jest.Mock).mockReturnValue(mockClient)
;(getCurrentLanguage as jest.Mock).mockReturnValue('en')
;(buildModel as jest.Mock).mockResolvedValue([
{ key: '', label: 'card:string:Card' as IntlString, displayProps: {}, attribute: undefined },
{ key: 'createdOn', label: 'Created', displayProps: {}, attribute: undefined }
])
})
it('returns empty string for empty docs', async () => {
const result = await buildMarkdownTableFromDocs(
[],
{ cardClass: 'card:class:Card' as Ref<Class<Doc>> },
mockClient
)
expect(result).toBe('')
})
it('builds markdown table from docs with viewlet config', async () => {
mockClient.findAll.mockResolvedValueOnce([]).mockResolvedValueOnce([{ config: ['', 'createdOn'] }])
const result = await buildMarkdownTableFromDocs(
[mockDoc],
{ cardClass: 'card:class:Card' as Ref<Class<Doc>> },
mockClient
)
expect(result).toContain('| ')
expect(result).toContain(' |\n')
expect(result).toContain('---')
expect(result).toContain('Test Card')
})
it('uses escaped text for non-title columns', async () => {
mockClient.findAll.mockResolvedValueOnce([]).mockResolvedValueOnce([{ config: ['', 'createdOn'] }])
const result = await buildMarkdownTableFromDocs(
[mockDoc],
{ cardClass: 'card:class:Card' as Ref<Class<Doc>> },
mockClient
)
expect(result.split('\n').length).toBeGreaterThanOrEqual(2)
expect(result).toMatch(/\|.*\|/)
})
})
describe('copyAsMarkdownTable', () => {
let mockClient: any
let mockHierarchy: any
let mockDoc: Doc
beforeEach(() => {
jest.clearAllMocks()
mockGetObjectLinkFragment.mockResolvedValue({ path: ['workbench', 'w3', 'card', 'doc1'] })
mockHierarchy = {
getClass: jest.fn(() => ({ _id: 'card:class:Card' })),
findAttribute: jest.fn(() => ({ type: { _class: core.class.TypeString } })),
as: jest.fn((doc: Doc) => doc),
classHierarchyMixin: jest.fn(() => undefined)
}
mockClient = {
getHierarchy: () => mockHierarchy,
findAll: jest.fn(async () => []),
findOne: jest.fn(async () => null)
}
mockDoc = {
_id: 'doc1',
_class: 'card:class:Card',
title: 'Test Card',
createdOn: 1732178763949,
modifiedOn: 1732178770252
} as unknown as Doc
;(getClient as jest.Mock).mockReturnValue(mockClient)
;(getCurrentLanguage as jest.Mock).mockReturnValue('en')
;(buildModel as jest.Mock).mockResolvedValue([
{ key: '', label: 'card:string:Card', displayProps: {}, attribute: undefined },
{ key: 'createdOn', label: 'Created', displayProps: {}, attribute: undefined }
])
})
it('does nothing when docs is empty', async () => {
const evt: Event = {} as any
const props: CopyAsMarkdownTableProps = { cardClass: 'card:class:Card' as Ref<Class<Doc>> }
await copyAsMarkdownTable([], evt, props)
expect(copyMarkdown).not.toHaveBeenCalled()
})
it('calls copyMarkdown and addNotification on success', async () => {
const evt: Event = {} as any
const props: CopyAsMarkdownTableProps = { cardClass: 'card:class:Card' as Ref<Class<Doc>> }
await copyAsMarkdownTable([mockDoc], evt, props)
expect(copyMarkdown).toHaveBeenCalled()
const { addNotification } = await import('@hcengineering/ui')
expect(addNotification).toHaveBeenCalled()
})
})
})
@@ -0,0 +1,78 @@
//
// Copyright © 2026 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 { isIntlString, extractObjectTitleOrName } from '../formatter/utils'
jest.mock('@hcengineering/platform', () => {
const actual = jest.requireActual('@hcengineering/platform')
return {
...actual,
plugin: jest.fn((_id: string, def: unknown) => def),
translate: jest.fn(async (str: unknown) => `translated:${String(str)}`)
}
})
describe('formatter/utils', () => {
describe('isIntlString', () => {
it('returns true for plugin:resource:key format', () => {
expect(isIntlString('card:string:Card')).toBe(true)
expect(isIntlString('contact:class:UserProfile')).toBe(true)
expect(isIntlString('a:b:c')).toBe(true)
})
it('returns false for empty string', () => {
expect(isIntlString('')).toBe(false)
})
it('returns false for string with fewer than 3 parts', () => {
expect(isIntlString('plugin:key')).toBe(false)
expect(isIntlString('single')).toBe(false)
})
it('returns false for string with empty segment', () => {
expect(isIntlString('plugin::key')).toBe(false)
expect(isIntlString(':resource:key')).toBe(false)
expect(isIntlString('plugin:resource:')).toBe(false)
})
it('returns false for non-string', () => {
expect(isIntlString(null as any)).toBe(false)
expect(isIntlString(undefined as any)).toBe(false)
})
})
describe('extractObjectTitleOrName', () => {
it('returns title when present and not IntlString', async () => {
expect(await extractObjectTitleOrName({ title: 'My Title' }, undefined)).toBe('My Title')
})
it('returns name when present and title absent', async () => {
expect(await extractObjectTitleOrName({ name: 'My Name' }, undefined)).toBe('My Name')
})
it('prefers title over name', async () => {
expect(await extractObjectTitleOrName({ title: 'Title', name: 'Name' }, undefined)).toBe('Title')
})
it('returns translated value for IntlString title', async () => {
expect(await extractObjectTitleOrName({ title: 'card:string:Card' }, 'en')).toBe('translated:card:string:Card')
})
it('returns empty string when neither title nor name', async () => {
expect(await extractObjectTitleOrName({}, undefined)).toBe('')
expect(await extractObjectTitleOrName({ other: 'x' }, undefined)).toBe('')
})
})
})
@@ -0,0 +1,55 @@
//
// Copyright © 2026 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 { escapeMarkdownLinkText, escapeMarkdownLinkUrl } from '../markdown/escape'
describe('markdown/escape', () => {
describe('escapeMarkdownLinkText', () => {
it('escapes backslashes', () => {
expect(escapeMarkdownLinkText('a\\b')).toBe('a\\\\b')
})
it('escapes square brackets', () => {
expect(escapeMarkdownLinkText('[text]')).toBe('\\[text\\]')
})
it('escapes pipe', () => {
expect(escapeMarkdownLinkText('a|b')).toBe('a\\|b')
})
it('replaces newlines with space', () => {
expect(escapeMarkdownLinkText('a\nb')).toBe('a b')
expect(escapeMarkdownLinkText('a\r\nb')).toBe('a b')
})
it('returns plain text unchanged when no special chars', () => {
expect(escapeMarkdownLinkText('Hello World')).toBe('Hello World')
})
})
describe('escapeMarkdownLinkUrl', () => {
it('escapes backslashes', () => {
expect(escapeMarkdownLinkUrl('path\\to')).toBe('path\\\\to')
})
it('escapes closing parenthesis', () => {
expect(escapeMarkdownLinkUrl('https://example.com/path)')).toBe('https://example.com/path\\)')
})
it('returns plain URL unchanged when no special chars', () => {
expect(escapeMarkdownLinkUrl('https://example.com')).toBe('https://example.com')
})
})
})
@@ -0,0 +1,96 @@
//
// Copyright © 2026 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 { AttributeModel } from '@hcengineering/view'
import { modelToConfig } from '../model/tableModel'
jest.mock('@hcengineering/view-resources', () => ({
buildModel: jest.fn(),
buildConfigLookup: jest.fn()
}))
jest.mock('@hcengineering/view', () => ({
default: {
class: { Viewlet: 'view:class:Viewlet', ViewletPreference: 'view:class:ViewletPreference' },
viewlet: { Table: 'view:viewlet:Table' }
}
}))
describe('model/tableModel', () => {
describe('modelToConfig', () => {
it('returns key for model with non-empty key', () => {
const model: AttributeModel[] = [{ key: 'title', label: 'Title', displayProps: {}, attribute: undefined } as any]
expect(modelToConfig(model)).toEqual(['title'])
})
it('preserves custom attribute as object when key is empty and label starts with custom', () => {
const model: AttributeModel[] = [
{
key: '',
label: 'customAttr1',
displayProps: {},
props: {},
sortingKey: undefined,
attribute: undefined
} as any
]
expect(modelToConfig(model)).toEqual([
{
key: 'customAttr1',
label: 'customAttr1',
displayProps: {},
props: {},
sortingKey: undefined
}
])
})
it('returns key string for model with castRequest when key is empty', () => {
const model: AttributeModel[] = [
{
key: '',
label: 'Label',
displayProps: {},
props: {},
sortingKey: undefined,
castRequest: 'ref',
attribute: undefined
} as any
]
expect(modelToConfig(model)).toEqual([
{
key: '',
label: 'Label',
displayProps: {},
props: {},
sortingKey: undefined
}
])
})
it('returns empty string for simple empty key without custom/castRequest', () => {
const model: AttributeModel[] = [{ key: '', label: 'Title', displayProps: {}, attribute: undefined } as any]
expect(modelToConfig(model)).toEqual([''])
})
it('handles mixed model', () => {
const model: AttributeModel[] = [
{ key: '', label: 'card:string:Card', displayProps: {}, attribute: undefined } as any,
{ key: 'createdOn', label: 'Created', displayProps: {}, attribute: undefined } as any
]
expect(modelToConfig(model)).toEqual(['', 'createdOn'])
})
})
})
@@ -0,0 +1,26 @@
//
// Copyright © 2026 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 { Doc } from '@hcengineering/core'
import type { CopyAsMarkdownTableProps } from '@hcengineering/converter'
import { copyAsMarkdownTable } from './markdown'
export async function copyAsMarkdownTableAction (
doc: Doc | Doc[],
evt: Event,
props: CopyAsMarkdownTableProps
): Promise<void> {
await copyAsMarkdownTable(doc, evt, props)
}
@@ -0,0 +1,18 @@
//
// Copyright © 2026 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.
//
export { loadPersonName } from './personLoader'
export { buildTableMetadata, buildRelationshipTableMetadata, isRelationshipTable } from './metadataBuilder'
export { rebuildRelationshipTableViewModel } from './relationshipBuilder'
@@ -0,0 +1,99 @@
//
// Copyright © 2026 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 { concatLink, type Client, type Doc, type Ref } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import { getCurrentResolvedLocation, locationToUrl } from '@hcengineering/ui'
import presentation from '@hcengineering/presentation'
import type { TableMetadata, Viewlet } from '@hcengineering/view'
import type { CopyAsMarkdownTableProps, CopyRelationshipTableAsMarkdownProps } from '../types'
import { loadViewletConfig } from '../model/viewletLoader'
import { modelToConfig } from '../model/tableModel'
/**
* Build metadata object from props and documents
* If viewlet is not provided, tries to find a default viewlet for the class
*/
export async function buildTableMetadata (
props: CopyAsMarkdownTableProps,
docs: Doc[],
client?: Client
): Promise<TableMetadata> {
let viewletId: Ref<Viewlet> | undefined = props.viewlet?._id
if (viewletId === undefined && client !== undefined) {
const { viewlet } = await loadViewletConfig(client, client.getHierarchy(), props.cardClass, undefined, props.config)
viewletId = viewlet?._id
}
let originalUrl: string | undefined
try {
const currentLocation = getCurrentResolvedLocation()
const relativeUrl = locationToUrl(currentLocation)
const frontUrl =
getMetadata(presentation.metadata.FrontUrl) ?? (typeof window !== 'undefined' ? window.location.origin : '')
originalUrl = concatLink(frontUrl, relativeUrl)
} catch (error) {
console.warn('Failed to capture original URL for table metadata:', error)
}
return {
version: '1.0',
cardClass: props.cardClass,
viewletId,
config: props.config,
query: props.query,
documentIds: docs.map((d) => d._id),
timestamp: Date.now(),
originalUrl
}
}
/**
* Check if a table metadata represents a relationship table
* Relationship tables have viewletId: undefined
*/
export function isRelationshipTable (metadata: TableMetadata): boolean {
return metadata.viewletId === undefined
}
/**
* Build metadata object for relationship tables
*/
export function buildRelationshipTableMetadata (
props: CopyRelationshipTableAsMarkdownProps,
docs: Doc[]
): TableMetadata {
let originalUrl: string | undefined
try {
const currentLocation = getCurrentResolvedLocation()
const relativeUrl = locationToUrl(currentLocation)
const frontUrl =
getMetadata(presentation.metadata.FrontUrl) ?? (typeof window !== 'undefined' ? window.location.origin : '')
originalUrl = concatLink(frontUrl, relativeUrl)
} catch (error) {
console.warn('Failed to capture original URL for relationship table metadata:', error)
}
return {
version: '1.0',
cardClass: props.cardClass,
viewletId: undefined,
config: modelToConfig(props.model),
query: props.query,
documentIds: docs.map((d) => d._id),
timestamp: Date.now(),
originalUrl
}
}
@@ -0,0 +1,50 @@
//
// Copyright © 2026 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 { Hierarchy, PersonId } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { getName, getPersonByPersonId } from '@hcengineering/contact'
/**
* Load person display name by PersonId with optional caching
*/
export async function loadPersonName (
personId: PersonId,
hierarchy: Hierarchy,
userCache?: Map<PersonId, string>
): Promise<string> {
if (userCache !== undefined) {
const cachedName = userCache.get(personId)
if (cachedName !== undefined) {
return cachedName
}
}
try {
const client = getClient()
const person = await getPersonByPersonId(client, personId)
if (person !== null) {
const name = getName(hierarchy, person)
if (userCache !== undefined) {
userCache.set(personId, name)
}
return name
}
} catch (error) {
console.warn('Failed to lookup user name for PersonId:', personId, error)
}
return personId
}
@@ -0,0 +1,121 @@
//
// Copyright © 2026 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 { Class, Client, Doc, Hierarchy, Ref } from '@hcengineering/core'
import type { AttributeModel } from '@hcengineering/view'
import { buildConfigAssociation, buildConfigLookup } from '@hcengineering/view-resources'
import type { RelationshipCellModel, RelationshipRowModel } from '../types'
/**
* Rebuild relationship table viewModel from documents and metadata
* Recreates the hierarchical structure with row spans and separate rows for each associated child
*/
export async function rebuildRelationshipTableViewModel (
docs: Doc[],
model: AttributeModel[],
cardClass: Ref<Class<Doc>>,
hierarchy: Hierarchy,
client: Client
): Promise<RelationshipRowModel[]> {
const viewModel: RelationshipRowModel[] = []
const config = model.map((m) => m.key)
const associations = buildConfigAssociation(config)
const lookup = buildConfigLookup(hierarchy, cardClass, config)
const associationAttrs = model.filter((attr) => attr.key.startsWith('$associations'))
let docsWithAssociations: Doc[] = docs
if (associations !== undefined && associations.length > 0) {
const docIds = docs.map((d) => d._id)
const query = { _id: { $in: docIds } }
docsWithAssociations = await client.findAll(cardClass, query, { lookup, associations })
}
for (const parentDoc of docsWithAssociations) {
const docWithAssoc = parentDoc as any
const parentAssociations = docWithAssoc.$associations ?? {}
let maxChildren = 0
for (const assocAttr of associationAttrs) {
const assocKey = assocAttr.key.replace('$associations.', '')
const children = parentAssociations[assocKey]
if (Array.isArray(children)) {
maxChildren = Math.max(maxChildren, children.length)
} else if (children !== undefined && children !== null) {
maxChildren = Math.max(maxChildren, 1)
}
}
if (maxChildren === 0) {
const cells: RelationshipCellModel[] = []
for (const attr of model) {
const isAssociationKey = attr.key.startsWith('$associations')
cells.push({
attribute: attr,
rowSpan: 1,
object: isAssociationKey ? undefined : parentDoc,
parentObject: isAssociationKey ? parentDoc : undefined
})
}
viewModel.push({ cells })
continue
}
for (let childIndex = 0; childIndex < maxChildren; childIndex++) {
const cells: RelationshipCellModel[] = []
for (const attr of model) {
const isAssociationKey = attr.key.startsWith('$associations')
if (attr.key === '') {
cells.push({
attribute: attr,
rowSpan: maxChildren,
object: parentDoc,
parentObject: undefined
})
} else if (isAssociationKey) {
const assocKey = attr.key.replace('$associations.', '')
const children = parentAssociations[assocKey]
let childDoc: Doc | undefined
if (Array.isArray(children) && children.length > childIndex) {
childDoc = children[childIndex] as Doc
} else if (!Array.isArray(children) && children !== undefined && children !== null && childIndex === 0) {
childDoc = children as Doc
}
cells.push({
attribute: attr,
rowSpan: 1,
object: childDoc,
parentObject: parentDoc
})
} else {
cells.push({
attribute: attr,
rowSpan: 1,
object: childIndex === 0 ? parentDoc : undefined,
parentObject: undefined
})
}
}
viewModel.push({ cells })
}
}
return viewModel
}
@@ -0,0 +1,24 @@
//
// Copyright © 2026 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.
//
export { registerValueFormatterForClass, registerValueFormatter, getFormattersForClass } from './registry'
export { formatValue, formatCustomAttributeValue } from './valueFormatter'
export {
isIntlString,
formatArrayValue,
extractObjectTitleOrName,
DocumentAttributeKey,
DateFormatOption
} from './utils'
@@ -0,0 +1,71 @@
//
// Copyright © 2026 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 { Doc, Class, Ref, Hierarchy } from '@hcengineering/core'
import type { ValueFormatter } from '../types'
/**
* Registry for value formatters by document class
* Plugins can register custom formatters for specific document classes
*/
const valueFormattersByClass = new Map<Ref<Class<Doc>>, ValueFormatter[]>()
/**
* Global formatters (checked for all classes)
*/
const globalValueFormatters: ValueFormatter[] = []
/**
* Register a value formatter for a specific document class
* @param _class - The document class this formatter applies to
* @param formatter - The formatter function to register
*/
export function registerValueFormatterForClass (_class: Ref<Class<Doc>>, formatter: ValueFormatter): void {
const formatters = valueFormattersByClass.get(_class) ?? []
formatters.push(formatter)
valueFormattersByClass.set(_class, formatters)
}
/**
* Register a global value formatter (applies to all classes)
* @param formatter - The formatter function to register
* @deprecated Use registerValueFormatterForClass for better performance and explicit class association
*/
export function registerValueFormatter (formatter: ValueFormatter): void {
globalValueFormatters.push(formatter)
}
/**
* Get formatters for a specific class (including parent classes)
*/
export function getFormattersForClass (hierarchy: Hierarchy, _class: Ref<Class<Doc>>): ValueFormatter[] {
const formatters: ValueFormatter[] = []
// Get formatters for this class and all parent classes
let currentClass: Ref<Class<Doc>> | undefined = _class
while (currentClass !== undefined) {
const classFormatters = valueFormattersByClass.get(currentClass)
if (classFormatters !== undefined) {
formatters.push(...classFormatters)
}
const classDef: Class<Doc> | undefined = hierarchy.getClass(currentClass)
currentClass = classDef?.extends
}
// Add global formatters
formatters.push(...globalValueFormatters)
return formatters
}
@@ -0,0 +1,123 @@
//
// Copyright © 2026 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, { type AnyAttribute, type Class, type Doc, type Ref } from '@hcengineering/core'
import { translate, type IntlString } from '@hcengineering/platform'
export enum DocumentAttributeKey {
CreatedBy = 'createdBy',
CreatedOn = 'createdOn',
ModifiedBy = 'modifiedBy',
ModifiedOn = 'modifiedOn',
Title = 'title',
Name = 'name'
}
export enum DateFormatOption {
Numeric = 'numeric',
Short = 'short'
}
/**
* Check if a value is an IntlString (format: "plugin:resource:key").
* Type guard: narrows unknown to string when true.
*/
export function isIntlString (value: unknown): value is string {
if (typeof value !== 'string' || value.length === 0) {
return false
}
const parts = value.split(':')
return parts.length >= 3 && parts.every((part) => part.length > 0)
}
/**
* Format an array of values, handling reference lookups if needed
*/
export async function formatArrayValue (
value: any[],
attrType: any,
attribute: AnyAttribute | undefined,
attrKey: string,
card: Doc,
language: string | undefined
): Promise<string> {
const isRefArray =
attrType?._class === core.class.ArrOf &&
(attrType as { of?: { _class?: Ref<Class<Doc>> } })?.of?._class === core.class.RefTo
if (isRefArray && (attribute !== undefined || attrKey !== '')) {
const cardWithLookup = card as any
const lookupKey = attribute?.name ?? attrKey
const lookupData = cardWithLookup.$lookup?.[lookupKey]
if (lookupData !== undefined && lookupData !== null) {
const resolvedArray = Array.isArray(lookupData) ? lookupData : [lookupData]
const translatedValues = await Promise.all(
resolvedArray.map(async (v) => {
if (typeof v === 'object' && v !== null && 'title' in v) {
const title = v.title ?? ''
if (typeof title === 'string' && isIntlString(title)) {
return await translate(title as unknown as IntlString, {}, language)
}
return String(title)
}
return typeof v === 'string' ? v : String(v)
})
)
return translatedValues.join(', ')
}
}
const translatedValues = await Promise.all(
value.map(async (v) => {
if (typeof v === 'object' && v !== null && 'title' in v) {
const title = v.title ?? ''
if (typeof title === 'string' && isIntlString(title)) {
return await translate(title as unknown as IntlString, {}, language)
}
return String(title)
}
if (typeof v === 'string' && isIntlString(v)) {
return await translate(v as unknown as IntlString, {}, language)
}
return typeof v === 'string' ? v : String(v)
})
)
return translatedValues.join(', ')
}
/**
* Extract title or name from an object, translating if needed
*/
export async function extractObjectTitleOrName (
obj: Record<string, any>,
language: string | undefined
): Promise<string> {
if ('title' in obj) {
const title = String(obj.title ?? '')
if (isIntlString(title)) {
return await translate(title as unknown as IntlString, {}, language)
}
return title
}
if ('name' in obj) {
const name = String(obj.name ?? '')
if (isIntlString(name)) {
return await translate(name as unknown as IntlString, {}, language)
}
return name
}
return ''
}
@@ -0,0 +1,258 @@
//
// Copyright © 2026 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, {
type AnyAttribute,
type Class,
type Doc,
type Hierarchy,
type Ref,
type PersonId,
getDisplayTime,
getObjectValue
} from '@hcengineering/core'
import { translate, type IntlString, getResource } from '@hcengineering/platform'
import type { AttributeModel } from '@hcengineering/view'
import converter from '@hcengineering/converter'
import { getFormattersForClass } from './registry'
import {
formatArrayValue,
extractObjectTitleOrName,
isIntlString,
DocumentAttributeKey,
DateFormatOption
} from './utils'
import { loadPersonName } from '../data/personLoader'
import type { ValueFormatter } from '../types'
/**
* Format a custom attribute value for markdown display
* Handles various types: string, number, boolean, arrays, references
*/
export async function formatCustomAttributeValue (
value: any,
attribute: AnyAttribute | undefined,
card: Doc,
hierarchy: Hierarchy,
language: string | undefined
): Promise<string> {
if (value === null || value === undefined) {
return ''
}
const attrType = attribute?.type
if (typeof value === 'number' && attrType?._class === core.class.TypeTimestamp) {
return getDisplayTime(value)
}
if (value instanceof Date) {
const options: Intl.DateTimeFormatOptions = {
year: DateFormatOption.Numeric,
month: DateFormatOption.Short,
day: DateFormatOption.Numeric
}
return value.toLocaleDateString(language ?? 'default', options)
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
if (typeof value === 'string') {
if (isIntlString(value)) {
return await translate(value as unknown as IntlString, {}, language)
}
const isRef = attrType?._class === core.class.RefTo
if (isRef && attribute !== undefined) {
const cardWithLookup = card as any
const lookupData = cardWithLookup.$lookup?.[attribute.name]
if (lookupData !== undefined && lookupData !== null) {
if (typeof lookupData === 'object' && 'title' in lookupData) {
const title = lookupData.title ?? ''
if (typeof title === 'string' && isIntlString(title)) {
return await translate(title as unknown as IntlString, {}, language)
}
return String(title)
}
}
}
return value
}
if (Array.isArray(value)) {
return await formatArrayValue(value, attrType, attribute, attribute?.name ?? '', card, language)
}
if (typeof value === 'object' && value !== null) {
const obj = value as Record<string, any>
const titleOrName = await extractObjectTitleOrName(obj, language)
return titleOrName !== '' ? titleOrName : String(value)
}
return String(value)
}
/**
* Format a single attribute value for display (used by table builders)
*/
export async function formatValue (
attr: AttributeModel,
card: Doc,
hierarchy: Hierarchy,
_class: Ref<Class<Doc>>,
language: string | undefined,
isFirstColumn: boolean = false,
userCache?: Map<PersonId, string>,
customFormatter?: ValueFormatter
): Promise<string> {
// Try custom formatter first (from actionProps)
if (customFormatter !== undefined) {
const formattedValue = await customFormatter(attr, card, hierarchy, _class, language)
if (formattedValue !== undefined) {
return formattedValue
}
}
// Try mixin-based formatter (MarkdownValueFormatter on the class)
const formatterMixin = hierarchy.classHierarchyMixin(_class, converter.mixin.MarkdownValueFormatter)
if (formatterMixin?.formatter !== undefined) {
const formatter = await getResource(formatterMixin.formatter)
const result = await formatter(attr, card, hierarchy, _class, language)
if (result !== undefined) {
return result
}
}
// Fall back to registered value formatters
const formatters = getFormattersForClass(hierarchy, _class)
for (const formatter of formatters) {
const formattedValue = await formatter(attr, card, hierarchy, _class, language)
if (formattedValue !== undefined) {
return formattedValue
}
}
let value: any
if (attr.castRequest != null) {
value = getObjectValue(attr.key.substring(attr.castRequest.length + 1), hierarchy.as(card, attr.castRequest))
} else {
if (attr.key.startsWith('$lookup.')) {
const lookupKey = attr.key.replace('$lookup.', '')
const lookupParts = lookupKey.split('.')
const cardWithLookup = card as any
const lookupObj = cardWithLookup.$lookup?.[lookupParts[0]]
if (lookupObj !== undefined && lookupObj !== null) {
if (lookupParts.length > 1) {
value = getObjectValue(lookupParts.slice(1).join('.'), lookupObj)
} else {
value = lookupObj
}
} else {
value = undefined
}
} else {
value = getObjectValue(attr.key, card)
}
}
if (attr.key === '' && !isFirstColumn) {
const labelStr = typeof attr.label === 'string' ? attr.label : ''
const isCustomAttribute = labelStr.startsWith('custom')
if (isCustomAttribute) {
const customValue = (card as any)[labelStr]
if (customValue === null || customValue === undefined) {
return ''
}
const docClass = card._class
let customAttr = hierarchy.findAttribute(docClass, labelStr)
if (customAttr === undefined) {
const allAttrs = hierarchy.getAllAttributes(docClass)
customAttr = allAttrs.get(labelStr)
}
return await formatCustomAttributeValue(customValue, customAttr, card, hierarchy, language)
}
return ''
}
if (value === null || value === undefined) {
return ''
}
const attribute = attr.attribute ?? hierarchy.findAttribute(_class, attr.key)
const attrType = attribute?.type
if (typeof value === 'number' && attrType?._class === core.class.TypeTimestamp) {
return getDisplayTime(value)
}
if (value instanceof Date) {
const options: Intl.DateTimeFormatOptions = {
year: DateFormatOption.Numeric,
month: DateFormatOption.Short,
day: DateFormatOption.Numeric
}
return value.toLocaleDateString(language ?? 'default', options)
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
if (typeof value === 'string') {
const isRef = attrType?._class === core.class.RefTo
if (isRef) {
const cardWithLookup = card as any
const lookupData = cardWithLookup.$lookup?.[attr.key]
if (lookupData !== undefined && lookupData !== null) {
const resolvedObj = lookupData
if (typeof resolvedObj === 'object' && resolvedObj !== null && 'title' in resolvedObj) {
const title = resolvedObj[DocumentAttributeKey.Title] ?? ''
if (typeof title === 'string' && isIntlString(title)) {
return await translate(title as unknown as IntlString, {}, language)
}
return String(title)
}
}
}
if (isIntlString(value)) {
return await translate(value as unknown as IntlString, {}, language)
}
if (attr.key === DocumentAttributeKey.CreatedBy || attr.key === DocumentAttributeKey.ModifiedBy) {
return await loadPersonName(value as PersonId, hierarchy, userCache)
}
return value
}
if (Array.isArray(value)) {
return await formatArrayValue(value, attrType, attribute, attr.key, card, language)
}
if (typeof value === 'object' && value !== null) {
const obj = value as Record<string, any>
const titleOrName = await extractObjectTitleOrName(obj, language)
return titleOrName !== '' ? titleOrName : String(value)
}
return String(value)
}
+54
View File
@@ -0,0 +1,54 @@
//
// Copyright © 2026 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 Resources } from '@hcengineering/platform'
import {
copyAsMarkdownTableFromResource,
copyRelationshipTableAsMarkdown,
buildMarkdownTableFromMetadata as buildMarkdownTableFromMetadataImpl,
buildMarkdownTableFromDocs as buildMarkdownTableFromDocsImpl
} from './markdown'
import { copyAsMarkdownTableAction } from './actionImpl'
export * from './formatter'
export * from './model'
export * from './data'
export {
copyAsMarkdownTable,
copyAsMarkdownTableFromResource,
copyRelationshipTableAsMarkdown,
buildMarkdownTableFromDocs,
buildMarkdownTableFromMetadata
} from './markdown'
export { isRelationshipTable, buildRelationshipTableMetadata } from './data'
export { isIntlString } from './formatter'
export { registerValueFormatterForClass, registerValueFormatter } from './formatter'
export { type TableConverter, MarkdownTableConverter } from './types'
export default async (): Promise<Resources> => ({
function: {
CopyAsMarkdownTable: copyAsMarkdownTableFromResource,
CopyRelationshipAsMarkdown: copyRelationshipTableAsMarkdown,
BuildMarkdownTableFromMetadata: buildMarkdownTableFromMetadataImpl,
BuildMarkdownTableFromDocs: buildMarkdownTableFromDocsImpl
},
actionImpl: {
CopyAsMarkdownTable: copyAsMarkdownTableAction
}
})
@@ -0,0 +1,128 @@
//
// Copyright © 2026 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 { Doc } from '@hcengineering/core'
import { translate } from '@hcengineering/platform'
import { addNotification, NotificationSeverity } from '@hcengineering/ui'
import { getCurrentLanguage } from '@hcengineering/theme'
import view from '@hcengineering/view'
import { getClient } from '@hcengineering/presentation'
import { copyMarkdown, SimpleNotification } from '@hcengineering/view-resources'
import type { CopyAsMarkdownTableProps, CopyRelationshipTableAsMarkdownProps } from '../types'
import { buildTableMetadata, buildRelationshipTableMetadata } from '../data'
import { buildMarkdownTableFromDocs, buildRelationshipTableMarkdown } from './tableBuilder'
/**
* Copy documents as markdown table to clipboard
*/
export async function copyAsMarkdownTable (
doc: Doc | Doc[],
evt: Event,
props: CopyAsMarkdownTableProps
): Promise<void> {
try {
const docs = Array.isArray(doc) ? doc : doc !== undefined ? [doc] : []
if (docs.length === 0) {
return
}
const client = getClient()
const markdown = await buildMarkdownTableFromDocs(docs, props, client)
if (markdown.length === 0) {
return
}
const metadata = await buildTableMetadata(props, docs, client)
await copyMarkdown(markdown, metadata)
const language = getCurrentLanguage()
addNotification(
await translate(view.string.Copied, {}, language),
await translate(view.string.TableCopiedToClipboard, {}, language),
SimpleNotification,
undefined,
NotificationSeverity.Success
)
} catch (error) {
console.error('Error copying markdown table', error)
const language = getCurrentLanguage()
addNotification(
await translate(view.string.Copied, {}, language),
await translate(view.string.TableCopyFailed, {}, language),
SimpleNotification,
undefined,
NotificationSeverity.Error
)
}
}
/**
* Wrapper for the function resource (evt, props).
* Callers must pass docs in props when using getResource(converter.function.CopyAsMarkdownTable).
*/
export async function copyAsMarkdownTableFromResource (
evt: Event,
props: CopyAsMarkdownTableProps & { docs?: Doc[] }
): Promise<void> {
const docs = props.docs ?? []
await copyAsMarkdownTable(docs, evt, props)
}
/**
* Copy RelationshipTable as markdown table
*/
export async function copyRelationshipTableAsMarkdown (
evt: Event,
props: CopyRelationshipTableAsMarkdownProps
): Promise<void> {
try {
const client = getClient()
const hierarchy = client.getHierarchy()
const cardClass = hierarchy.getClass(props.cardClass)
if (cardClass == null) {
return
}
const language = getCurrentLanguage()
const markdown = await buildRelationshipTableMarkdown(props, hierarchy, language)
if (markdown.length === 0) {
return
}
const metadata = buildRelationshipTableMetadata(props, props.objects)
await copyMarkdown(markdown, metadata)
addNotification(
await translate(view.string.Copied, {}, language),
await translate(view.string.TableCopiedToClipboard, {}, language),
SimpleNotification,
undefined,
NotificationSeverity.Success
)
} catch (error) {
console.error('Error copying relationship table', error)
const language = getCurrentLanguage()
addNotification(
await translate(view.string.Copied, {}, language),
await translate(view.string.TableCopyFailed, {}, language),
SimpleNotification,
undefined,
NotificationSeverity.Error
)
}
}
@@ -0,0 +1,33 @@
//
// Copyright © 2026 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.
//
/**
* Escape markdown link text (brackets, pipes, backslashes, newlines)
*/
export function escapeMarkdownLinkText (text: string): string {
return text
.replace(/\\/g, '\\\\')
.replace(/\[/g, '\\[')
.replace(/\]/g, '\\]')
.replace(/\|/g, '\\|')
.replace(/\r?\n/g, ' ')
}
/**
* Escape markdown link URL (backslashes and closing parentheses)
*/
export function escapeMarkdownLinkUrl (url: string): string {
return url.replace(/\\/g, '\\\\').replace(/\)/g, '\\)')
}
@@ -0,0 +1,23 @@
//
// Copyright © 2026 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.
//
export { escapeMarkdownLinkText, escapeMarkdownLinkUrl } from './escape'
export { createMarkdownLink } from './link'
export {
buildMarkdownTableFromDocs,
buildMarkdownTableFromMetadata,
buildRelationshipTableMarkdown
} from './tableBuilder'
export { copyAsMarkdownTable, copyAsMarkdownTableFromResource, copyRelationshipTableAsMarkdown } from './copyActions'
@@ -0,0 +1,42 @@
//
// Copyright © 2026 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 { Doc, Hierarchy } from '@hcengineering/core'
import { concatLink } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { getObjectLinkFragment } from '@hcengineering/view-resources'
import { locationToUrl } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { escapeMarkdownLinkText, escapeMarkdownLinkUrl } from './escape'
/**
* Create a markdown link for a document
*/
export async function createMarkdownLink (hierarchy: Hierarchy, card: Doc, value: string): Promise<string> {
try {
const loc = await getObjectLinkFragment(hierarchy, card, {}, view.component.EditDoc)
const relativeUrl = locationToUrl(loc)
const frontUrl =
getMetadata(presentation.metadata.FrontUrl) ?? (typeof window !== 'undefined' ? window.location.origin : '')
const fullUrl = concatLink(frontUrl, relativeUrl)
const escapedText = escapeMarkdownLinkText(value)
const escapedUrl = escapeMarkdownLinkUrl(fullUrl)
return `[${escapedText}](${escapedUrl})`
} catch (error) {
console.warn('Error creating markdown link', error)
return escapeMarkdownLinkText(value)
}
}
@@ -0,0 +1,311 @@
//
// Copyright © 2026 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 { Class, Client, Doc, Hierarchy, Ref, PersonId } from '@hcengineering/core'
import { getCurrentLanguage } from '@hcengineering/theme'
import type { AttributeModel, BuildMarkdownTableMetadata, TableMetadata, Viewlet } from '@hcengineering/view'
import viewPlugin from '@hcengineering/view'
import { buildConfigLookup, buildModel, getAttributeValue } from '@hcengineering/view-resources'
import type { CopyAsMarkdownTableProps, CopyRelationshipTableAsMarkdownProps } from '../types'
import { formatValue } from '../formatter'
import { generateHeaders, loadViewletConfig, buildTableModel } from '../model'
import { rebuildRelationshipTableViewModel, isRelationshipTable } from '../data'
import { escapeMarkdownLinkText } from './escape'
import { createMarkdownLink } from './link'
async function buildRelationshipTableFromMetadata (
docs: Doc[],
metadata: BuildMarkdownTableMetadata,
client: Client
): Promise<string> {
const hierarchy = client.getHierarchy()
const cardClass = metadata.cardClass as Ref<Class<Doc>>
const config = metadata.config ?? []
const lookup = buildConfigLookup(hierarchy, cardClass, config)
const model = await buildModel({
client,
_class: cardClass,
keys: config,
lookup
})
const viewModel = await rebuildRelationshipTableViewModel(docs, model, cardClass, hierarchy, client)
const props: CopyRelationshipTableAsMarkdownProps = {
viewModel,
model,
objects: docs,
cardClass,
query: metadata.query
}
const language = getCurrentLanguage()
return await buildRelationshipTableMarkdown(props, hierarchy, language)
}
/**
* Wrapper function for building markdown table from BuildMarkdownTableMetadata
* This is used by text-editor-resources to refresh tables
*/
export async function buildMarkdownTableFromMetadata (
docs: Doc[],
metadata: BuildMarkdownTableMetadata,
client: Client
): Promise<string> {
const tableMetadata = metadata as TableMetadata
if (isRelationshipTable(tableMetadata)) {
return await buildRelationshipTableFromMetadata(docs, metadata, client)
}
let viewlet: Viewlet | undefined
if (metadata.viewletId !== undefined) {
viewlet = await client.findOne(viewPlugin.class.Viewlet, { _id: metadata.viewletId as Ref<Viewlet> })
}
const props: CopyAsMarkdownTableProps = {
cardClass: metadata.cardClass as Ref<Class<Doc>>,
viewlet,
config: metadata.config,
query: metadata.query
}
return await buildMarkdownTableFromDocs(docs, props, client)
}
/**
* Build markdown table string from documents and props
*/
export async function buildMarkdownTableFromDocs (
docs: Doc[],
props: CopyAsMarkdownTableProps,
client: Client
): Promise<string> {
if (docs.length === 0) {
return ''
}
const hierarchy = client.getHierarchy()
const cardClass = hierarchy.getClass(props.cardClass)
if (cardClass == null) {
return ''
}
const { viewlet, config: actualConfig } = await loadViewletConfig(
client,
hierarchy,
props.cardClass,
props.viewlet,
props.config
)
let displayableModel: AttributeModel[]
if (actualConfig !== undefined && actualConfig.length > 0) {
const lookup =
viewlet !== undefined
? buildConfigLookup(hierarchy, props.cardClass, actualConfig, viewlet.options?.lookup)
: undefined
const hiddenKeys = viewlet?.configOptions?.hiddenKeys ?? []
const model = await buildModel({
client,
_class: props.cardClass,
keys: actualConfig.filter((key: string | import('@hcengineering/view').BuildModelKey) => {
if (typeof key === 'string') {
return !hiddenKeys.includes(key)
}
return !hiddenKeys.includes(key.key) && key.displayProps?.grow !== true
}),
lookup
})
displayableModel = model.filter((attr) => attr.displayProps?.grow !== true)
} else {
displayableModel = await buildTableModel(client, hierarchy, props.cardClass, viewlet)
}
if (displayableModel.length === 0) {
return ''
}
const language = getCurrentLanguage()
const userCache = new Map<PersonId, string>()
const firstDocClass = docs.length > 0 ? docs[0]._class : props.cardClass
const headers = await generateHeaders(displayableModel, firstDocClass, hierarchy, language)
const rows: string[][] = []
for (const card of docs) {
const row: string[] = []
for (let i = 0; i < displayableModel.length; i++) {
const attr = displayableModel[i]
const isFirstColumn = i === 0
const value = await formatValue(
attr,
card,
hierarchy,
props.cardClass,
language,
isFirstColumn,
userCache,
props.valueFormatter
)
if (isFirstColumn && attr.key === '') {
const linkValue = await createMarkdownLink(hierarchy, card, value)
row.push(linkValue)
} else {
row.push(escapeMarkdownLinkText(value))
}
}
rows.push(row)
}
let markdown = '| ' + headers.join(' | ') + ' |\n'
markdown += '| ' + headers.map(() => '---').join(' | ') + ' |\n'
for (const row of rows) {
markdown += '| ' + row.join(' | ') + ' |\n'
}
return markdown
}
/**
* Build markdown table from relationship table props (viewModel, model, objects)
*/
export async function buildRelationshipTableMarkdown (
props: CopyRelationshipTableAsMarkdownProps,
hierarchy: Hierarchy,
language: string | undefined
): Promise<string> {
if (props.viewModel.length === 0 || props.model.length === 0) {
return ''
}
const userCache = new Map<PersonId, string>()
const firstDocClass = props.objects.length > 0 ? props.objects[0]._class : props.cardClass
const headers = await generateHeaders(props.model, firstDocClass, hierarchy, language)
const attributeKeyToIndex = new Map<string, number>()
props.model.forEach((attr, index) => {
attributeKeyToIndex.set(attr.key, index)
})
const activeRowSpans = new Map<string, { value: string, remaining: number }>()
const rows: string[][] = []
for (let rowIdx = 0; rowIdx < props.viewModel.length; rowIdx++) {
const rowModel = props.viewModel[rowIdx]
const row: string[] = new Array(headers.length).fill('')
for (const [attrKey, spanInfo] of activeRowSpans.entries()) {
if (spanInfo.remaining > 0) {
const attrIndex = attributeKeyToIndex.get(attrKey)
if (attrIndex !== undefined) {
row[attrIndex] = spanInfo.value
spanInfo.remaining--
if (spanInfo.remaining === 0) {
activeRowSpans.delete(attrKey)
}
}
}
}
for (const cell of rowModel.cells) {
const attrIndex = attributeKeyToIndex.get(cell.attribute.key)
if (attrIndex === undefined) continue
const isAssociationKey = cell.attribute.key.startsWith('$associations')
let doc: Doc | undefined
if (isAssociationKey) {
doc = cell.object
} else {
doc = cell.object ?? cell.parentObject
}
if (doc === undefined) {
row[attrIndex] = ''
continue
}
const rawValue = getAttributeValue(cell.attribute, doc, hierarchy)
let docToUse = doc
let docClass = props.cardClass
let attributeToUse = cell.attribute
if (isAssociationKey) {
if (rawValue !== undefined && rawValue !== null && typeof rawValue === 'object' && '_class' in rawValue) {
docToUse = rawValue as Doc
docClass = docToUse._class
const parts = cell.attribute.key.split('$associations.')
if (parts.length > 1) {
const afterAssoc = parts[1].substring(1)
const dotIndex = afterAssoc.indexOf('.')
if (dotIndex > 0) {
const attributeName = afterAssoc.substring(dotIndex + 1)
attributeToUse = {
...cell.attribute,
key: attributeName
}
} else {
attributeToUse = {
...cell.attribute,
key: ''
}
}
}
}
}
const isFirstColumn = attrIndex === 0
const allowEmptyKey = isFirstColumn || isAssociationKey
let value = await formatValue(
attributeToUse,
docToUse,
hierarchy,
docClass,
language,
allowEmptyKey,
userCache,
props.valueFormatter
)
const isDocumentTitle = attributeToUse.key === '' && docToUse !== undefined
if (isDocumentTitle) {
value = await createMarkdownLink(hierarchy, docToUse, value)
} else {
value = escapeMarkdownLinkText(value)
}
row[attrIndex] = value
if (cell.rowSpan > 1) {
activeRowSpans.set(cell.attribute.key, {
value,
remaining: cell.rowSpan - 1
})
}
}
rows.push(row)
}
let markdown = '| ' + headers.join(' | ') + ' |\n'
markdown += '| ' + headers.map(() => '---').join(' | ') + ' |\n'
for (const row of rows) {
markdown += '| ' + row.join(' | ') + ' |\n'
}
return markdown
}
@@ -0,0 +1,74 @@
//
// Copyright © 2026 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 { Class, Doc, Hierarchy, Ref } from '@hcengineering/core'
import { translate, type IntlString } from '@hcengineering/platform'
import type { AttributeModel } from '@hcengineering/view'
import { isIntlString } from '../formatter/utils'
/**
* Resolve the human-readable label for a custom attribute
*/
export async function resolveCustomAttributeLabel (
attrLabel: string,
docClass: Ref<Class<Doc>>,
hierarchy: Hierarchy,
language: string | undefined
): Promise<string> {
if (!attrLabel.startsWith('custom')) {
return attrLabel
}
let customAttr = hierarchy.findAttribute(docClass, attrLabel)
if (customAttr === undefined) {
const allAttrs = hierarchy.getAllAttributes(docClass)
customAttr = allAttrs.get(attrLabel)
}
if (customAttr?.label !== undefined) {
return await translate(customAttr.label, {}, language)
}
return attrLabel
}
/**
* Generate table headers from AttributeModel array
* Handles custom attributes, IntlStrings, and regular labels
*/
export async function generateHeaders (
model: AttributeModel[],
firstDocClass: Ref<Class<Doc>>,
hierarchy: Hierarchy,
language: string | undefined
): Promise<string[]> {
const headers: string[] = []
for (const attr of model) {
let label: string
if (typeof attr.label === 'string') {
if (attr.label.startsWith('custom')) {
label = await resolveCustomAttributeLabel(attr.label, firstDocClass, hierarchy, language)
} else if (isIntlString(attr.label)) {
label = await translate(attr.label as unknown as IntlString, {}, language)
} else {
label = attr.label
}
} else {
label = await translate(attr.label, {}, language)
}
headers.push(label)
}
return headers
}
@@ -0,0 +1,18 @@
//
// Copyright © 2026 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.
//
export { buildTableModel, modelToConfig } from './tableModel'
export { loadViewletConfig } from './viewletLoader'
export { generateHeaders, resolveCustomAttributeLabel } from './headerGenerator'
@@ -0,0 +1,112 @@
//
// Copyright © 2026 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, { type Class, type Client, type Doc, type Hierarchy, type Ref } from '@hcengineering/core'
import type { AttributeModel, BuildModelKey, Viewlet } from '@hcengineering/view'
import viewPlugin from '@hcengineering/view'
import { buildModel, buildConfigLookup } from '@hcengineering/view-resources'
import { DocumentAttributeKey } from '../formatter/utils'
/**
* Convert AttributeModel array back to config format (Array<string | BuildModelKey>)
* Preserves custom attributes by using label as key when key is empty
*/
export function modelToConfig (model: AttributeModel[]): Array<string | BuildModelKey> {
return model.map((m) => {
if (m.key === '' && typeof m.label === 'string' && m.label.startsWith('custom')) {
return {
key: m.label,
label: m.label,
displayProps: m.displayProps,
props: m.props,
sortingKey: m.sortingKey
}
}
if (m.key !== '') {
return m.key
}
if (m.castRequest !== undefined) {
return {
key: m.key,
label: m.label,
displayProps: m.displayProps,
props: m.props,
sortingKey: m.sortingKey
}
}
return m.key
})
}
/**
* Build AttributeModel from viewlet config (or default config)
*/
export async function buildTableModel (
client: Client,
hierarchy: Hierarchy,
_class: Ref<Class<Doc>>,
viewlet: Viewlet | undefined
): Promise<AttributeModel[]> {
if (viewlet !== undefined) {
const preferences = await client.findAll(viewPlugin.class.ViewletPreference, {
space: core.space.Workspace,
attachedTo: viewlet._id
})
const config = preferences.length > 0 && preferences[0].config.length > 0 ? preferences[0].config : viewlet.config
const lookup = buildConfigLookup(hierarchy, _class, config, viewlet.options?.lookup)
const hiddenKeys = viewlet.configOptions?.hiddenKeys ?? []
const model = await buildModel({
client,
_class,
keys: config.filter((key: string | BuildModelKey) => {
if (typeof key === 'string') {
return !hiddenKeys.includes(key)
}
return !hiddenKeys.includes(key.key) && key.displayProps?.grow !== true
}),
lookup
})
return model.filter((attr) => attr.displayProps?.grow !== true)
}
const defaultConfig: Array<string | BuildModelKey> = [
'', // Object presenter (title)
DocumentAttributeKey.CreatedBy,
DocumentAttributeKey.CreatedOn,
DocumentAttributeKey.ModifiedBy,
DocumentAttributeKey.ModifiedOn
]
const model = await buildModel({
client,
_class,
keys: defaultConfig,
lookup: undefined
})
return model.filter((attr) => {
if (
attr.key === DocumentAttributeKey.CreatedBy ||
attr.key === DocumentAttributeKey.CreatedOn ||
attr.key === DocumentAttributeKey.ModifiedBy ||
attr.key === DocumentAttributeKey.ModifiedOn
) {
return hierarchy.findAttribute(_class, attr.key) !== undefined
}
return true
})
}
@@ -0,0 +1,62 @@
//
// Copyright © 2026 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, { type Class, type Client, type Doc, type Hierarchy, type Ref } from '@hcengineering/core'
import type { BuildModelKey, Viewlet } from '@hcengineering/view'
import viewPlugin from '@hcengineering/view'
/**
* Loads the actual viewlet configuration, including user preferences
*/
export async function loadViewletConfig (
client: Client,
hierarchy: Hierarchy,
cardClass: Ref<Class<Doc>>,
propsViewlet?: Viewlet,
propsConfig?: Array<string | BuildModelKey>
): Promise<{ viewlet: Viewlet | undefined, config: Array<string | BuildModelKey> | undefined }> {
if (propsConfig !== undefined && propsConfig.length > 0) {
return { viewlet: propsViewlet, config: propsConfig }
}
let viewlet: Viewlet | undefined = propsViewlet
if (viewlet === undefined) {
const allClasses = [cardClass]
let currentClass = hierarchy.getClass(cardClass)
while (currentClass?.extends !== undefined) {
allClasses.push(currentClass.extends)
currentClass = hierarchy.getClass(currentClass.extends)
}
const viewlets = await client.findAll(viewPlugin.class.Viewlet, {
attachTo: { $in: allClasses },
descriptor: viewPlugin.viewlet.Table
})
viewlet =
viewlets.find((v) => v.attachTo === cardClass) ??
viewlets.find((v) => allClasses.includes(v.attachTo)) ??
viewlets[0]
}
let actualConfig: Array<string | BuildModelKey> | undefined
if (viewlet !== undefined) {
const preferences = await client.findAll(viewPlugin.class.ViewletPreference, {
space: core.space.Workspace,
attachedTo: viewlet._id
})
actualConfig = preferences.length > 0 && preferences[0].config.length > 0 ? preferences[0].config : viewlet.config
}
return { viewlet, config: actualConfig }
}
+19
View File
@@ -0,0 +1,19 @@
//
// Copyright © 2026 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 { mergeIds } from '@hcengineering/platform'
import converter, { converterId } from '@hcengineering/converter'
export default mergeIds(converterId, converter, {})
+138
View File
@@ -0,0 +1,138 @@
//
// Copyright © 2026 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 { Doc, Class, Ref, Hierarchy, DocumentQuery } from '@hcengineering/core'
import type { AttributeModel, BuildModelKey, Viewlet } from '@hcengineering/view'
import type { Resource } from '@hcengineering/platform'
import { escapeMarkdownLinkText, escapeMarkdownLinkUrl } from './markdown/escape'
/**
* Value formatter function for custom field extraction during markdown/export
* @param attr - The attribute model
* @param doc - The document object
* @param hierarchy - The hierarchy instance
* @param _class - The document class
* @param language - Current language
* @returns The formatted value, or undefined if this formatter doesn't apply
*/
export type ValueFormatter = (
attr: AttributeModel,
doc: Doc,
hierarchy: Hierarchy,
_class: Ref<Class<Doc>>,
language: string | undefined
) => Promise<string | undefined>
/**
* Mixin for classes that provide a custom value formatter for markdown/export
*/
export interface MarkdownValueFormatter extends Class<Doc> {
formatter: Resource<ValueFormatter>
}
/**
* Props for CopyAsMarkdownTable function
*/
export interface CopyAsMarkdownTableProps {
cardClass: Ref<Class<Doc>>
viewlet?: Viewlet
config?: Array<string | BuildModelKey>
valueFormatter?: ValueFormatter
query?: DocumentQuery<Doc>
}
/**
* Interface for RelationshipTable's cell model
*/
export interface RelationshipCellModel {
attribute: AttributeModel
rowSpan: number
object: Doc | undefined
parentObject: Doc | undefined
}
/**
* Interface for RelationshipTable's row model
*/
export interface RelationshipRowModel {
cells: RelationshipCellModel[]
}
/**
* Props for CopyRelationshipTableAsMarkdown function
*/
export interface CopyRelationshipTableAsMarkdownProps {
viewModel: RelationshipRowModel[]
model: AttributeModel[]
objects: Doc[]
cardClass: Ref<Class<Doc>>
valueFormatter?: ValueFormatter
query?: DocumentQuery<Doc>
}
/**
* Function type for CopyAsMarkdownTable (used in action context)
*/
export type CopyAsMarkdownTableFunction = (evt: Event, props: CopyAsMarkdownTableProps) => Promise<void>
/**
* Function type for CopyRelationshipTableAsMarkdown
*/
export type CopyRelationshipTableAsMarkdownFunction = (
evt: Event,
props: CopyRelationshipTableAsMarkdownProps
) => Promise<void>
/**
* Interface for a generic table converter to different formats.
*/
export interface TableConverter {
// Build table output from formatted rows
buildTable: (headers: string[], rows: string[][]) => string
// Escape a cell value for this format
escapeValue: (value: string) => string
// Create a link in this format
createLink: (url: string, text: string) => string
// Format identifier (e.g., 'markdown', 'csv', 'html')
readonly format: string
}
/**
* Markdown implementation of TableConverter.
*/
export class MarkdownTableConverter implements TableConverter {
readonly format = 'markdown'
buildTable (headers: string[], rows: string[][]): string {
let markdown = '| ' + headers.join(' | ') + ' |\n'
markdown += '| ' + headers.map(() => '---').join(' | ') + ' |\n'
for (const row of rows) {
markdown += '| ' + row.join(' | ') + ' |\n'
}
return markdown
}
escapeValue (value: string): string {
return escapeMarkdownLinkText(value)
}
createLink (url: string, text: string): string {
return `[${escapeMarkdownLinkText(text)}](${escapeMarkdownLinkUrl(url)})`
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@hcengineering/converter",
"version": "0.7.0",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"files": [
"lib/**/*",
"types/**/*",
"tsconfig.json"
],
"author": "Copyright © Hardcore Engineering Inc.",
"license": "EPL-2.0",
"scripts": {
"build": "compile",
"build:watch": "compile",
"test": "jest --passWithNoTests --silent",
"format": "format src",
"_phase:build": "compile transpile src",
"_phase:test": "jest --passWithNoTests --silent",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"@hcengineering/platform-rig": "workspace:^0.7.19",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0",
"eslint": "^8.54.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5"
},
"dependencies": {
"@hcengineering/platform": "workspace:^0.7.19",
"@hcengineering/core": "workspace:^0.7.24",
"@hcengineering/view": "workspace:^0.7.0"
}
}
+22
View File
@@ -0,0 +1,22 @@
//
// 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 { converterPlugin, converterId } from './plugin'
export * from './types'
export { converterId }
export default converterPlugin
+50
View File
@@ -0,0 +1,50 @@
//
// 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 { Client, Doc, Mixin, Ref } from '@hcengineering/core'
import { type Plugin, type Resource, plugin } from '@hcengineering/platform'
import type { Action, BuildMarkdownTableMetadata, ViewAction } from '@hcengineering/view'
import type {
CopyAsMarkdownTableFunction,
CopyRelationshipTableAsMarkdownFunction,
MarkdownValueFormatter,
CopyAsMarkdownTableProps
} from './types'
export const converterId = 'converter' as Plugin
export const converterPlugin = plugin(converterId, {
mixin: {
MarkdownValueFormatter: '' as Ref<Mixin<MarkdownValueFormatter>>
},
function: {
CopyAsMarkdownTable: '' as Resource<CopyAsMarkdownTableFunction>,
CopyRelationshipAsMarkdown: '' as Resource<CopyRelationshipTableAsMarkdownFunction>,
BuildMarkdownTableFromMetadata: '' as Resource<
(docs: Doc[], metadata: BuildMarkdownTableMetadata, client: Client) => Promise<string>
>,
BuildMarkdownTableFromDocs: '' as Resource<
(docs: Doc[], props: CopyAsMarkdownTableProps, client: Client) => Promise<string>
>
},
action: {
CopyAsMarkdownTable: '' as Ref<Action<Doc, Record<string, any>>>
},
actionImpl: {
CopyAsMarkdownTable: '' as ViewAction<CopyAsMarkdownTableProps>
}
})
export default converterPlugin
+95
View File
@@ -0,0 +1,95 @@
//
// Copyright © 2026 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 { Class, Doc, DocumentQuery, Hierarchy, Ref } from '@hcengineering/core'
import type { Resource } from '@hcengineering/platform'
import type { AttributeModel, BuildModelKey, Viewlet } from '@hcengineering/view'
/**
* Value formatter function for custom field extraction during markdown/export
* @param attr - The attribute model
* @param doc - The document object
* @param hierarchy - The hierarchy instance
* @param _class - The document class
* @param language - Current language
* @returns The formatted value, or undefined if this formatter doesn't apply
*/
export type ValueFormatter = (
attr: AttributeModel,
doc: Doc,
hierarchy: Hierarchy,
_class: Ref<Class<Doc>>,
language: string | undefined
) => Promise<string | undefined>
/**
* Mixin for classes that provide a custom value formatter for markdown/export
*/
export interface MarkdownValueFormatter extends Class<Doc> {
formatter: Resource<ValueFormatter>
}
/**
* Props for CopyAsMarkdownTable function
*/
export interface CopyAsMarkdownTableProps {
cardClass: Ref<Class<Doc>>
viewlet?: Viewlet
config?: Array<string | BuildModelKey>
valueFormatter?: ValueFormatter
query?: DocumentQuery<Doc>
}
/**
* Interface for RelationshipTable's cell model
*/
export interface RelationshipCellModel {
attribute: AttributeModel
rowSpan: number
object: Doc | undefined
parentObject: Doc | undefined
}
/**
* Interface for RelationshipTable's row model
*/
export interface RelationshipRowModel {
cells: RelationshipCellModel[]
}
/**
* Props for CopyRelationshipTableAsMarkdown function
*/
export interface CopyRelationshipTableAsMarkdownProps {
viewModel: RelationshipRowModel[]
model: AttributeModel[]
objects: Doc[]
cardClass: Ref<Class<Doc>>
valueFormatter?: ValueFormatter
query?: DocumentQuery<Doc>
}
/**
* Function type for CopyAsMarkdownTable (used in action context)
*/
export type CopyAsMarkdownTableFunction = (evt: Event, props: CopyAsMarkdownTableProps) => Promise<void>
/**
* Function type for CopyRelationshipTableAsMarkdown
*/
export type CopyRelationshipTableAsMarkdownFunction = (
evt: Event,
props: CopyRelationshipTableAsMarkdownProps
) => Promise<void>
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
}
@@ -45,6 +45,7 @@
"@hcengineering/analytics": "workspace:^0.7.17",
"@hcengineering/presentation": "workspace:^0.7.0",
"@hcengineering/core": "workspace:^0.7.24",
"@hcengineering/converter": "workspace:^0.7.0",
"@hcengineering/highlight": "workspace:^0.7.0",
"@hcengineering/view": "workspace:^0.7.0",
"@hcengineering/text": "workspace:^0.7.18",
@@ -14,11 +14,12 @@
import type { Client, Doc } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import view, { type BuildMarkdownTableMetadata, type TableMetadata } from '@hcengineering/view'
import converter from '@hcengineering/converter'
import { type BuildMarkdownTableMetadata, type TableMetadata } from '@hcengineering/view'
/**
* Build markdown table string from documents and metadata
* Uses the extension point function from view-resources via view plugin
* Uses the extension point function from converter-resources via converter plugin
*/
export async function buildMarkdownTableFromDocs (
docs: Doc[],
@@ -26,7 +27,7 @@ export async function buildMarkdownTableFromDocs (
client: Client
): Promise<string> {
try {
const buildFunction = await getResource(view.function.BuildMarkdownTableFromDocs)
const buildFunction = await getResource(converter.function.BuildMarkdownTableFromMetadata)
// Extract only the BuildMarkdownTableMetadata fields from TableMetadata
const buildMetadata: BuildMarkdownTableMetadata = {
cardClass: metadata.cardClass,
@@ -37,8 +38,8 @@ export async function buildMarkdownTableFromDocs (
}
return await buildFunction(docs, buildMetadata, client)
} catch (error) {
// Function not available (view-resources not loaded)
console.warn('BuildMarkdownTableFromDocs function not available:', error)
// Function not available (converter-resources not loaded)
console.warn('BuildMarkdownTableFromMetadata function not available:', error)
return ''
}
}
+2
View File
@@ -70,6 +70,8 @@
"@hcengineering/ui": "workspace:^0.7.0",
"@hcengineering/view": "workspace:^0.7.0",
"@hcengineering/view-resources": "workspace:^0.7.0",
"@hcengineering/converter": "workspace:^0.7.0",
"@hcengineering/converter-resources": "workspace:^0.7.0",
"@hcengineering/workbench": "workspace:^0.7.0",
"@hcengineering/workbench-resources": "workspace:^0.7.0",
"fast-equals": "^5.2.2",
+3 -2
View File
@@ -101,7 +101,7 @@ import {
resolveLocation
} from './issues'
import tracker from './plugin'
import './issueTableFormatter'
import { formatIssueValue } from './issueTableFormatter'
import MilestoneEditor from './components/milestones/MilestoneEditor.svelte'
import MilestonePresenter from './components/milestones/MilestonePresenter.svelte'
@@ -521,7 +521,8 @@ export default async (): Promise<Resources> => ({
GetIssueStatusCategories: getIssueStatusCategories,
SetComponentStore: setStore,
ComponentFilterFunction: filterComponents,
OpenIssuesOfTaskType: openIssuesOfTaskType
OpenIssuesOfTaskType: openIssuesOfTaskType,
FormatIssueMarkdownValue: formatIssueValue
},
actionImpl: {
Move: move,
@@ -17,7 +17,6 @@ import { type Class, type Doc, type Hierarchy, type Ref, type PersonId } from '@
import trackerPlugin, { type Component, type IssueStatus, type Milestone, type Project } from '@hcengineering/tracker'
import { type AttributeModel } from '@hcengineering/view'
import { getClient } from '@hcengineering/presentation'
import { registerValueFormatterForClass } from '@hcengineering/view-resources'
import { getName, getPersonByPersonId } from '@hcengineering/contact'
/**
@@ -184,7 +183,7 @@ async function loadPersonName (personId: PersonId): Promise<string> {
* Value formatter for issue fields
* Handles special cases for status, assignee, component, space (project), and milestone fields
*/
async function formatIssueValue (
export async function formatIssueValue (
attr: AttributeModel,
card: Doc,
hierarchy: Hierarchy,
@@ -312,5 +311,4 @@ async function formatIssueValue (
return undefined
}
// Register the formatter for Issue class
registerValueFormatterForClass(trackerPlugin.class.Issue, formatIssueValue)
// Formatter is registered via MarkdownValueFormatter mixin in models/tracker
+3 -1
View File
@@ -29,6 +29,7 @@ import {
type Viewlet,
type ViewletDescriptor
} from '@hcengineering/view'
import type { ValueFormatter } from '@hcengineering/converter'
export default mergeIds(trackerId, tracker, {
viewlet: {
@@ -398,7 +399,8 @@ export default mergeIds(trackerId, tracker, {
IssueChatTitleProvider: '' as Resource<(object: Doc) => string>,
GetIssueStatusCategories: '' as Resource<(project: ProjectType) => Array<Ref<StatusCategory>>>,
GetIssueIdByIdentifier: '' as Resource<(id: string) => Promise<Ref<Issue> | undefined>>,
OpenIssuesOfTaskType: '' as Resource<(taskType: TaskType) => Promise<void>>
OpenIssuesOfTaskType: '' as Resource<(taskType: TaskType) => Promise<void>>,
FormatIssueMarkdownValue: '' as Resource<ValueFormatter>
},
aggregation: {
CreateComponentAggregationManager: '' as CreateAggregationManagerFunc,
+1
View File
@@ -47,6 +47,7 @@
"@hcengineering/panel": "workspace:^0.7.0",
"@hcengineering/guest": "workspace:^0.7.0",
"@hcengineering/core": "workspace:^0.7.24",
"@hcengineering/converter": "workspace:^0.7.0",
"@hcengineering/view": "workspace:^0.7.0",
"@hcengineering/ui": "workspace:^0.7.0",
"@hcengineering/task": "workspace:^0.7.0",
@@ -1,446 +0,0 @@
//
// 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.
//
// Mock platform plugin function first (before any imports)
// Import after mocks are set up
import { CopyAsMarkdownTable } from '../copyAsMarkdownTable'
import { isIntlString } from '../markdownTableUtils'
import core, { type Class, type Doc, type Ref } from '@hcengineering/core'
import { type IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { getCurrentLanguage } from '@hcengineering/theme'
import { copyMarkdown } from '../actionImpl'
import { addNotification } from '@hcengineering/ui'
import { buildModel } from '../utils'
jest.mock('@hcengineering/platform', () => {
const actual = jest.requireActual('@hcengineering/platform')
return {
...actual,
plugin: jest.fn((id: string, def: any) => def),
translate: jest.fn(async (str: any) => await Promise.resolve(`translated:${String(str)}`)),
getMetadata: jest.fn((key: unknown) => {
if (key != null && String(key).includes('FrontUrl')) {
return 'http://huly.local:8080'
}
return undefined
})
}
})
jest.mock('@hcengineering/presentation', () => ({
getClient: jest.fn()
}))
jest.mock('@hcengineering/theme', () => ({
getCurrentLanguage: jest.fn(() => 'en')
}))
jest.mock('../actionImpl', () => ({
copyText: jest.fn(),
copyMarkdown: jest.fn()
}))
jest.mock('@hcengineering/ui', () => ({
addNotification: jest.fn(),
NotificationSeverity: {
Success: 'success'
},
locationToUrl: jest.fn((loc: unknown) => {
if (loc != null && typeof loc === 'object' && 'path' in loc && Array.isArray((loc as { path: string[] }).path)) {
return (loc as { path: string[] }).path.join('/')
}
return 'workbench/w3/card/test-id'
})
}))
const mockGetObjectLinkFragment = jest.fn()
jest.mock('../utils', () => ({
buildModel: jest.fn(),
buildConfigLookup: jest.fn(() => ({})),
getObjectLinkFragment: (...args: any[]) => mockGetObjectLinkFragment(...args)
}))
jest.mock('../plugin', () => ({
default: {
component: {
EditDoc: 'view:component:EditDoc'
}
},
string: {
Copied: 'view:string:Copied' as any,
TableCopiedToClipboard: 'view:string:TableCopiedToClipboard' as any,
TableCopyFailed: 'view:string:TableCopyFailed' as any
}
}))
jest.mock('../components/SimpleNotification.svelte', () => ({
default: jest.fn()
}))
// We'll use jest.spyOn in individual tests to mock these functions
describe('copyAsMarkdownTable', () => {
let mockClient: any
let mockHierarchy: any
let mockCardClass: any
let mockDoc: Doc
beforeEach(() => {
jest.clearAllMocks()
mockGetObjectLinkFragment.mockReset()
mockCardClass = {
_id: 'card:class:Card' as Ref<Class<Doc>>,
label: 'card:string:Card' as IntlString
}
mockHierarchy = {
getClass: jest.fn((ref: Ref<Class<Doc>>): any => {
if (ref === 'card:class:Card') {
return mockCardClass
}
return null
}),
findAttribute: jest.fn(() => ({
type: {
_class: core.class.TypeString
}
})),
as: jest.fn((doc: Doc): Doc => doc)
}
mockClient = {
getHierarchy: jest.fn(() => mockHierarchy),
getModel: jest.fn(() => ({
findAllSync: jest.fn(() => [])
})),
findAll: jest.fn(async () => [])
}
const mockDocValue = {
_id: 'doc1',
_class: 'card:class:Card',
title: 'Test Card',
masterTag: 'card:types:Document',
tags: ['tag1', 'tag2'],
createdOn: 1732178763949,
modifiedOn: 1732178770252
} as unknown as Doc
mockDoc = mockDocValue
;(getClient as jest.Mock).mockReturnValue(mockClient)
;(getCurrentLanguage as jest.Mock).mockReturnValue('en')
void (buildModel as jest.Mock).mockResolvedValue([
{
key: '',
label: 'card:string:Card' as IntlString,
displayProps: {}
},
{
key: 'masterTag',
label: 'card:string:MasterTag' as IntlString,
displayProps: {}
},
{
key: 'tags',
label: 'card:string:Tags' as IntlString,
displayProps: {}
},
{
key: 'modifiedOn',
label: 'core:string:ModifiedDate' as IntlString,
displayProps: {}
}
])
})
describe('CopyAsMarkdownTable', () => {
it('should return early if no docs provided', async () => {
const undefinedDoc: Doc = undefined as unknown as Doc
const mockEvent: Event = new Event('test')
const cardClass: Ref<Class<Doc>> = 'card:class:Card' as Ref<Class<Doc>>
await CopyAsMarkdownTable(undefinedDoc, mockEvent, {
cardClass
})
expect(getClient).not.toHaveBeenCalled()
})
it('should return early if card class not found', async () => {
mockHierarchy.getClass.mockReturnValue(null)
const mockEvent: Event = new Event('test')
const cardClass: Ref<Class<Doc>> = 'card:class:Card' as Ref<Class<Doc>>
await CopyAsMarkdownTable(mockDoc, mockEvent, {
cardClass
})
expect(copyMarkdown).not.toHaveBeenCalled()
})
it('should return early if displayableModel is empty', async () => {
void (buildModel as jest.Mock).mockResolvedValue([])
const mockEvent: Event = new Event('test')
const cardClass: Ref<Class<Doc>> = 'card:class:Card' as Ref<Class<Doc>>
await CopyAsMarkdownTable(mockDoc, mockEvent, {
cardClass
})
expect(copyMarkdown).not.toHaveBeenCalled()
})
it('should copy markdown table and show notification', async () => {
// getObjectValue and getDisplayTime will use their actual implementations
mockHierarchy.findAttribute.mockReturnValue({
type: {
_class: core.class.TypeTimestamp
}
})
const mockEvent: Event = new Event('test')
const cardClass: Ref<Class<Doc>> = 'card:class:Card' as Ref<Class<Doc>>
await CopyAsMarkdownTable(mockDoc, mockEvent, {
cardClass
})
expect(copyMarkdown).toHaveBeenCalled()
expect(addNotification).toHaveBeenCalledWith(
'translated:view:string:Copied',
'translated:view:string:TableCopiedToClipboard',
expect.objectContaining({ default: expect.any(Function) }),
undefined,
'success'
)
const markdownCall = (copyMarkdown as jest.Mock).mock.calls[0][0]
// copyMarkdown receives markdown as first argument, metadata as second
// The markdown may have metadata comment prepended, so check for table content
const markdownContent = typeof markdownCall === 'string' ? markdownCall : ''
expect(markdownContent).toContain('|')
expect(markdownContent).toContain('---')
expect(markdownContent).toContain('translated:card:string:Card')
expect(markdownContent).toContain('translated:card:string:MasterTag')
})
it('should translate IntlString values in table', async () => {
const docWithIntlString = {
...mockDoc,
masterTag: 'card:types:Document'
} as unknown as Doc
// getObjectValue will use its actual implementation
mockHierarchy.findAttribute.mockReturnValue({
type: {
_class: core.class.TypeString
}
})
const mockEvent: Event = new Event('test')
const cardClass: Ref<Class<Doc>> = 'card:class:Card' as Ref<Class<Doc>>
await CopyAsMarkdownTable(docWithIntlString, mockEvent, {
cardClass
})
expect(copyMarkdown).toHaveBeenCalled()
const markdownCall = (copyMarkdown as jest.Mock).mock.calls[0][0]
const markdownContent = typeof markdownCall === 'string' ? markdownCall : ''
expect(markdownContent).toContain('translated:card:types:Document')
})
it('should handle multiple docs', async () => {
const mockDoc2 = {
...mockDoc,
_id: 'doc2',
title: 'Test Card 2'
} as unknown as Doc
// getObjectValue will use its actual implementation
mockHierarchy.findAttribute.mockReturnValue({
type: {
_class: core.class.TypeString
}
})
const mockEvent: Event = new Event('test')
const cardClass: Ref<Class<Doc>> = 'card:class:Card' as Ref<Class<Doc>>
await CopyAsMarkdownTable([mockDoc, mockDoc2], mockEvent, {
cardClass
})
expect(copyMarkdown).toHaveBeenCalled()
const markdownCall = (copyMarkdown as jest.Mock).mock.calls[0][0]
const markdownContent = typeof markdownCall === 'string' ? markdownCall : ''
const lines = markdownContent.split('\n').filter((line: string) => line.trim().length > 0)
expect(lines.length > 3).toBe(true)
})
it('should escape pipe characters and newlines in values', async () => {
const docWithSpecialChars = {
...mockDoc,
title: 'Test | Card\nWith Newline'
} as unknown as Doc
// getObjectValue will use its actual implementation
mockHierarchy.findAttribute.mockReturnValue({
type: {
_class: core.class.TypeString
}
})
const mockEvent: Event = new Event('test')
const cardClass: Ref<Class<Doc>> = 'card:class:Card' as Ref<Class<Doc>>
await CopyAsMarkdownTable(docWithSpecialChars, mockEvent, {
cardClass
})
expect(copyMarkdown).toHaveBeenCalled()
const markdownCall = (copyMarkdown as jest.Mock).mock.calls[0][0]
const markdownContent = typeof markdownCall === 'string' ? markdownCall : ''
// Check that pipe is escaped
expect(markdownContent).toContain('\\|')
// Check that newlines in data are replaced with spaces
// The markdown table itself has newlines between rows, so we check data rows specifically
const dataRows = markdownContent.split('\n').filter((line: string) => line.includes('|') && !line.includes('---'))
dataRows.forEach((row: string) => {
// Each cell should not contain literal newline characters
const cells = row.split('|').map((cell: string) => cell.trim())
cells.forEach((cell: string) => {
expect(cell).not.toContain('\n')
})
})
})
it('should handle empty array of docs', async () => {
const mockEvent: Event = new Event('test')
const cardClass: Ref<Class<Doc>> = 'card:class:Card' as Ref<Class<Doc>>
await CopyAsMarkdownTable([], mockEvent, {
cardClass
})
expect(getClient).not.toHaveBeenCalled()
})
it('should create markdown link for first column with empty key', async () => {
const mockLocation = {
path: ['workbench', 'w3', 'card', 'test-doc-id'],
fragment: undefined,
query: undefined
}
mockGetObjectLinkFragment.mockResolvedValue(mockLocation)
const uiModule = await import('@hcengineering/ui')
const mockLocationToUrl = uiModule.locationToUrl as jest.Mock
mockLocationToUrl.mockReturnValue('workbench/w3/card/test-doc-id')
const mockEvent: Event = new Event('test')
const cardClass: Ref<Class<Doc>> = 'card:class:Card' as Ref<Class<Doc>>
await CopyAsMarkdownTable(mockDoc, mockEvent, {
cardClass
})
expect(copyMarkdown).toHaveBeenCalled()
const markdownCall = (copyMarkdown as jest.Mock).mock.calls[0][0]
const markdownContent = typeof markdownCall === 'string' ? markdownCall : ''
// Check that the first column contains a markdown link with full URL
// Note: getObjectLinkFragment may not be called if the condition isn't met,
// but we should still check the markdown output
if (mockGetObjectLinkFragment.mock.calls.length > 0) {
expect(mockGetObjectLinkFragment).toHaveBeenCalled()
expect(markdownContent).toMatch(/\[.*\]\(http:\/\/huly\.local:8080\/.*\)/)
expect(markdownContent).toContain('http://huly.local:8080')
} else {
// If link wasn't created, verify the markdown still contains the title
expect(markdownContent).toContain('Test Card')
}
})
it('should not create link for non-first column even with empty key', async () => {
// Create a model where the second column has an empty key (shouldn't happen in practice, but test the logic)
;(buildModel as jest.Mock).mockResolvedValue([
{
key: 'someKey',
label: 'card:string:SomeKey' as IntlString,
displayProps: {}
},
{
key: '',
label: 'card:string:Title' as IntlString,
displayProps: {}
}
])
const mockEvent: Event = new Event('test')
const cardClass: Ref<Class<Doc>> = 'card:class:Card' as Ref<Class<Doc>>
await CopyAsMarkdownTable(mockDoc, mockEvent, {
cardClass
})
expect(copyMarkdown).toHaveBeenCalled()
const markdownCall = (copyMarkdown as jest.Mock).mock.calls[0][0]
const markdownContent = typeof markdownCall === 'string' ? markdownCall : ''
// Should not contain markdown links (no empty key in first column)
expect(markdownContent).not.toMatch(/\[.*\]\(http:\/\/.*\)/)
})
it('should fall back to plain text if link generation fails', async () => {
mockGetObjectLinkFragment.mockRejectedValue(new Error('Link generation failed'))
const mockEvent: Event = new Event('test')
const cardClass: Ref<Class<Doc>> = 'card:class:Card' as Ref<Class<Doc>>
await CopyAsMarkdownTable(mockDoc, mockEvent, {
cardClass
})
expect(copyMarkdown).toHaveBeenCalled()
const markdownCall = (copyMarkdown as jest.Mock).mock.calls[0][0]
const markdownContent = typeof markdownCall === 'string' ? markdownCall : ''
// Should not contain markdown links (fallback to plain text)
expect(markdownContent).not.toMatch(/\[.*\]\(http:\/\/.*\)/)
// Should contain the title as plain text
expect(markdownContent).toContain('Test Card')
})
})
describe('isIntlString', () => {
it('should detect valid IntlString format strings', () => {
const testCases = [
{ input: 'card:string:Card', expected: true },
{ input: 'contact:class:UserProfile', expected: true },
{ input: 'card:types:Document', expected: true },
{ input: 'plugin:kind:key:subkey', expected: true },
{ input: 'simple string', expected: false },
{ input: 'plugin:key', expected: false },
{ input: 'plugin:', expected: false },
{ input: ':kind:key', expected: false },
{ input: 'plugin:kind:', expected: false },
{ input: '', expected: false },
{ input: 'plugin::key', expected: false }
]
testCases.forEach(({ input, expected }) => {
expect(isIntlString(input)).toBe(expected)
})
})
it('should return false for non-string values', () => {
expect(isIntlString(null as unknown as string)).toBe(false)
expect(isIntlString(undefined as unknown as string)).toBe(false)
expect(isIntlString(123 as unknown as string)).toBe(false)
expect(isIntlString({} as unknown as string)).toBe(false)
})
})
})
+3 -2
View File
@@ -50,7 +50,7 @@ import {
} from './selection'
import { deleteObjects, getObjectId, getObjectLinkFragment, restrictionStore } from './utils'
import workbenchPlugin from '@hcengineering/workbench'
import { CopyAsMarkdownTable } from './copyAsMarkdownTable'
import converter from '@hcengineering/converter'
import { viewletContextStore } from './viewletContextStore'
/**
@@ -744,7 +744,8 @@ async function CopyAsMarkdownTableAction (
viewOptions: props.viewOptions ?? viewletContext?.viewOptions
}
await CopyAsMarkdownTable(doc, evt, mergedProps)
const copyFn = await getResource(converter.actionImpl.CopyAsMarkdownTable)
await copyFn(doc, evt, mergedProps)
}
/**
@@ -51,7 +51,7 @@
import view from '../plugin'
import { buildConfigAssociation, buildConfigLookup, buildModel, getAttributeValue, restrictionStore } from '../utils'
import { getResultOptions, getResultQuery } from '../viewOptions'
import { CopyRelationshipTableAsMarkdown } from '../copyAsMarkdownTable'
import converter from '@hcengineering/converter'
import IconUpDown from './icons/UpDown.svelte'
import RelationsSelectorPopup from './RelationsSelectorPopup.svelte'
@@ -541,7 +541,8 @@
async function handleCopyAsMarkdown (e: MouseEvent): Promise<void> {
if (model === undefined || viewModel.length === 0) return
await CopyRelationshipTableAsMarkdown(e, {
const copyFn = await getResource(converter.function.CopyRelationshipAsMarkdown)
await copyFn(e, {
viewModel,
model,
objects,
File diff suppressed because it is too large Load Diff
+3 -17
View File
@@ -145,7 +145,6 @@ import {
import ForbiddenNotification from './components/ForbiddenNotification.svelte'
import { AggregationMiddleware, AnalyticsMiddleware, ReadOnlyAccessMiddleware } from './middleware'
import { buildMarkdownTableFromMetadata } from './copyAsMarkdownTable'
import { getLink, openDocFromRef } from './utils'
import { hideArchived, showEmptyGroups } from './viewOptions'
import {
@@ -218,20 +217,8 @@ export {
} from './utils'
export * from './viewOptions'
export * from './viewletContextStore'
export {
CopyAsMarkdownTable,
type CopyAsMarkdownTableProps,
CopyRelationshipTableAsMarkdown,
type CopyRelationshipTableAsMarkdownProps,
type RelationshipCellModel,
type RelationshipRowModel,
type ValueFormatter,
registerValueFormatterForClass,
registerValueFormatter,
buildMarkdownTableFromDocs,
buildMarkdownTableFromMetadata
} from './copyAsMarkdownTable'
export { isIntlString } from './markdownTableUtils'
export { copyMarkdown } from './actionImpl'
export { default as SimpleNotification } from './components/SimpleNotification.svelte'
export type { BuildMarkdownTableMetadata } from '@hcengineering/view'
export {
ArrayEditor,
@@ -405,7 +392,6 @@ export default async (): Promise<Resources> => ({
BlobVideoMetadata: blobVideoMetadata,
OpenDocument: openDocFromRef,
CanCopyLink: canCopyLink,
GetLink: getLink,
BuildMarkdownTableFromDocs: buildMarkdownTableFromMetadata
GetLink: getLink
}
})
@@ -1,343 +0,0 @@
//
// 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 core, {
type AnyAttribute,
type Class,
type Doc,
type Hierarchy,
type Ref,
concatLink,
getDisplayTime
} from '@hcengineering/core'
import { translate, type IntlString, getMetadata } from '@hcengineering/platform'
import { locationToUrl } from '@hcengineering/ui'
import presentation from '@hcengineering/presentation'
import { type AttributeModel, type BuildModelKey } from '@hcengineering/view'
import { getObjectLinkFragment } from './utils'
import view from './plugin'
enum DateFormatOption {
Numeric = 'numeric',
Short = 'short'
}
/**
* Check if a string is an IntlString (format: "plugin:resource:key")
*/
export function isIntlString (value: string): boolean {
if (typeof value !== 'string' || value.length === 0) {
return false
}
const parts = value.split(':')
return parts.length >= 3 && parts.every((part) => part.length > 0)
}
/**
* Resolve the human-readable label for a custom attribute
* @param attrLabel - The attribute label (may be an ID like "custom...")
* @param docClass - The document's class (MasterTag) where the attribute is defined
* @param hierarchy - The hierarchy instance
* @param language - Current language
* @returns The translated human-readable label, or the original label if not found
*/
export async function resolveCustomAttributeLabel (
attrLabel: string,
docClass: Ref<Class<Doc>>,
hierarchy: Hierarchy,
language: string | undefined
): Promise<string> {
if (!attrLabel.startsWith('custom')) {
return attrLabel
}
let customAttr = hierarchy.findAttribute(docClass, attrLabel)
if (customAttr === undefined) {
const allAttrs = hierarchy.getAllAttributes(docClass)
customAttr = allAttrs.get(attrLabel)
}
if (customAttr?.label !== undefined) {
return await translate(customAttr.label, {}, language)
}
return attrLabel
}
/**
* Generate table headers from AttributeModel array
* Handles custom attributes, IntlStrings, and regular labels
* @param model - Array of AttributeModel to generate headers from
* @param firstDocClass - The first document's class (for custom attribute lookup)
* @param hierarchy - The hierarchy instance
* @param language - Current language
* @returns Array of header strings
*/
export async function generateHeaders (
model: AttributeModel[],
firstDocClass: Ref<Class<Doc>>,
hierarchy: Hierarchy,
language: string | undefined
): Promise<string[]> {
const headers: string[] = []
for (const attr of model) {
let label: string
if (typeof attr.label === 'string') {
if (attr.label.startsWith('custom')) {
label = await resolveCustomAttributeLabel(attr.label, firstDocClass, hierarchy, language)
} else if (isIntlString(attr.label)) {
label = await translate(attr.label as unknown as IntlString, {}, language)
} else {
label = attr.label
}
} else {
label = await translate(attr.label, {}, language)
}
headers.push(label)
}
return headers
}
/**
* Convert AttributeModel array back to config format (Array<string | BuildModelKey>)
* Preserves custom attributes by using label as key when key is empty
* @param model - Array of AttributeModel to convert
* @returns Config array that can be used to rebuild the table
*/
export function modelToConfig (model: AttributeModel[]): Array<string | BuildModelKey> {
return model.map((m) => {
if (m.key === '' && typeof m.label === 'string' && m.label.startsWith('custom')) {
return {
key: m.label, // Use label (custom attribute name) as key
label: m.label,
displayProps: m.displayProps,
props: m.props,
sortingKey: m.sortingKey
}
}
if (m.key !== '') {
return m.key
}
if (m.castRequest !== undefined) {
return {
key: m.key,
label: m.label,
displayProps: m.displayProps,
props: m.props,
sortingKey: m.sortingKey
}
}
return m.key
})
}
/**
* Format an array of values, handling reference lookups if needed
* @param value - The array value
* @param attrType - The attribute type
* @param attribute - The attribute definition (for getting the name)
* @param attrKey - The attribute key (fallback if attribute.name is not available)
* @param card - The document
* @param language - Current language
* @returns Formatted string with comma-separated values
*/
export async function formatArrayValue (
value: any[],
attrType: any,
attribute: AnyAttribute | undefined,
attrKey: string,
card: Doc,
language: string | undefined
): Promise<string> {
const isRefArray =
attrType?._class === core.class.ArrOf &&
(attrType as { of?: { _class?: Ref<Class<Doc>> } })?.of?._class === core.class.RefTo
if (isRefArray && (attribute !== undefined || attrKey !== '')) {
const cardWithLookup = card as any
const lookupKey = attribute?.name ?? attrKey
const lookupData = cardWithLookup.$lookup?.[lookupKey]
if (lookupData !== undefined && lookupData !== null) {
const resolvedArray = Array.isArray(lookupData) ? lookupData : [lookupData]
const translatedValues = await Promise.all(
resolvedArray.map(async (v) => {
if (typeof v === 'object' && v !== null && 'title' in v) {
const title = v.title ?? ''
if (typeof title === 'string' && isIntlString(title)) {
return await translate(title as unknown as IntlString, {}, language)
}
return String(title)
}
return typeof v === 'string' ? v : String(v)
})
)
return translatedValues.join(', ')
}
}
const translatedValues = await Promise.all(
value.map(async (v) => {
if (typeof v === 'object' && v !== null && 'title' in v) {
const title = v.title ?? ''
if (typeof title === 'string' && isIntlString(title)) {
return await translate(title as unknown as IntlString, {}, language)
}
return String(title)
}
if (typeof v === 'string' && isIntlString(v)) {
return await translate(v as unknown as IntlString, {}, language)
}
return typeof v === 'string' ? v : String(v)
})
)
return translatedValues.join(', ')
}
/**
* Extract title or name from an object, translating if needed
* @param obj - The object to extract from
* @param language - Current language
* @returns The title/name string, or empty string if not found
*/
export async function extractObjectTitleOrName (
obj: Record<string, any>,
language: string | undefined
): Promise<string> {
if ('title' in obj) {
const title = String(obj.title ?? '')
if (isIntlString(title)) {
return await translate(title as unknown as IntlString, {}, language)
}
return title
}
if ('name' in obj) {
const name = String(obj.name ?? '')
if (isIntlString(name)) {
return await translate(name as unknown as IntlString, {}, language)
}
return name
}
return ''
}
/**
* Format a custom attribute value for markdown display
* Handles various types: string, number, boolean, arrays, references
*/
export async function formatCustomAttributeValue (
value: any,
attribute: AnyAttribute | undefined,
card: Doc,
hierarchy: Hierarchy,
language: string | undefined
): Promise<string> {
if (value === null || value === undefined) {
return ''
}
const attrType = attribute?.type
if (typeof value === 'number' && attrType?._class === core.class.TypeTimestamp) {
return getDisplayTime(value)
}
if (value instanceof Date) {
const options: Intl.DateTimeFormatOptions = {
year: DateFormatOption.Numeric,
month: DateFormatOption.Short,
day: DateFormatOption.Numeric
}
return value.toLocaleDateString(language ?? 'default', options)
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
if (typeof value === 'string') {
if (isIntlString(value)) {
return await translate(value as unknown as IntlString, {}, language)
}
const isRef = attrType?._class === core.class.RefTo
if (isRef && attribute !== undefined) {
const cardWithLookup = card as any
const lookupData = cardWithLookup.$lookup?.[attribute.name]
if (lookupData !== undefined && lookupData !== null) {
if (typeof lookupData === 'object' && 'title' in lookupData) {
const title = lookupData.title ?? ''
if (typeof title === 'string' && isIntlString(title)) {
return await translate(title as unknown as IntlString, {}, language)
}
return String(title)
}
}
}
return value
}
if (Array.isArray(value)) {
return await formatArrayValue(value, attrType, attribute, attribute?.name ?? '', card, language)
}
if (typeof value === 'object' && value !== null) {
const obj = value as Record<string, any>
const titleOrName = await extractObjectTitleOrName(obj, language)
return titleOrName !== '' ? titleOrName : String(value)
}
return String(value)
}
/**
* Escape markdown link text (brackets, pipes, backslashes, newlines)
*/
export function escapeMarkdownLinkText (text: string): string {
// Escape backslashes first, then brackets and pipes, and normalize newlines to spaces
return text
.replace(/\\/g, '\\\\')
.replace(/\[/g, '\\[')
.replace(/\]/g, '\\]')
.replace(/\|/g, '\\|')
.replace(/\r?\n/g, ' ')
}
/**
* Escape markdown link URL (backslashes and closing parentheses)
*/
export function escapeMarkdownLinkUrl (url: string): string {
// Escape backslashes and closing parentheses used to terminate the URL
return url.replace(/\\/g, '\\\\').replace(/\)/g, '\\)')
}
/**
* Create a markdown link for a document
*/
export async function createMarkdownLink (hierarchy: Hierarchy, card: Doc, value: string): Promise<string> {
try {
const loc = await getObjectLinkFragment(hierarchy, card, {}, view.component.EditDoc)
const relativeUrl = locationToUrl(loc)
const frontUrl =
getMetadata(presentation.metadata.FrontUrl) ?? (typeof window !== 'undefined' ? window.location.origin : '')
const fullUrl = concatLink(frontUrl, relativeUrl)
const escapedText = escapeMarkdownLinkText(value)
const escapedUrl = escapeMarkdownLinkUrl(fullUrl)
return `[${escapedText}](${escapedUrl})`
} catch {
// If link generation fails, fall back to plain text
return escapeMarkdownLinkText(value)
}
}
+2 -6
View File
@@ -14,7 +14,7 @@
// limitations under the License.
//
import { Class, Client, Doc, DocumentQuery, FindOptions, Mixin, Ref } from '@hcengineering/core'
import { Class, Doc, DocumentQuery, FindOptions, Mixin, Ref } from '@hcengineering/core'
import { Asset, IntlString, Plugin, Resource, plugin } from '@hcengineering/platform'
import { AnyComponent, PopupAlignment, PopupPosAlignment, type ComponentExtensionId } from '@hcengineering/ui/src/types'
import {
@@ -30,7 +30,6 @@ import {
AttributeFilterPresenter,
AttributePresenter,
BaseQuery,
BuildMarkdownTableMetadata,
ClassFilters,
ClassSortFuncs,
CollectionEditor,
@@ -357,10 +356,7 @@ const view = plugin(viewId, {
PositionElementAlignment: '' as Resource<(e?: Event) => PopupAlignment | undefined>
},
function: {
OpenDocument: '' as Resource<OpenDocumentFunction>,
BuildMarkdownTableFromDocs: '' as Resource<
(docs: Doc[], metadata: BuildMarkdownTableMetadata, client: Client) => Promise<string>
>
OpenDocument: '' as Resource<OpenDocumentFunction>
},
actionImpl: {
CopyTextToClipboard: '' as ViewAction<{
+15
View File
@@ -1946,6 +1946,21 @@
"projectFolder": "plugins/controlled-documents-resources",
"shouldPublish": false
},
{
"packageName": "@hcengineering/converter",
"projectFolder": "plugins/converter",
"shouldPublish": false
},
{
"packageName": "@hcengineering/converter-resources",
"projectFolder": "plugins/converter-resources",
"shouldPublish": false
},
{
"packageName": "@hcengineering/model-converter",
"projectFolder": "models/converter",
"shouldPublish": false
},
{
"packageName": "@hcengineering/model-controlled-documents",
"projectFolder": "models/controlled-documents",