mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
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:
Generated
+234
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
extends: ['./node_modules/@hcengineering/platform-rig/profiles/model/eslint.config.json'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: './tsconfig.json'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!/lib/**
|
||||
!CHANGELOG.md
|
||||
/lib/**/__tests__/
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
|
||||
"rigPackageName": "@hcengineering/platform-rig",
|
||||
"rigProfile": "model"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
|
||||
roots: ["./src"],
|
||||
coverageReporters: ["text-summary", "html"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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, {})
|
||||
@@ -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)})`
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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 ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<{
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user