From 111f9e14d79dd33453225a5d26eeca43e63ee695 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Thu, 29 Jan 2026 14:44:13 +0700 Subject: [PATCH] Add data converter plugin (#10460) * Add data converter plugin Signed-off-by: Artem Savchenko * Clean up Signed-off-by: Artem Savchenko * Apply templates Signed-off-by: Artem Savchenko * Fix test Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko --- common/config/rush/pnpm-lock.yaml | 234 ++++ desktop/package.json | 2 + desktop/src/ui/platform.ts | 2 + dev/prod/package.json | 2 + dev/prod/src/platform.ts | 2 + models/all/package.json | 1 + models/all/src/index.ts | 2 + models/card/package.json | 1 + models/card/src/index.ts | 5 + models/controlled-documents/package.json | 1 + models/controlled-documents/src/index.ts | 5 + models/converter/.eslintrc.js | 7 + models/converter/.npmignore | 4 + models/converter/config/rig.json | 5 + models/converter/jest.config.js | 7 + models/converter/package.json | 43 + models/converter/src/index.ts | 30 + models/converter/tsconfig.json | 12 + models/tracker/package.json | 1 + models/tracker/src/index.ts | 5 + plugins/card-resources/package.json | 2 + .../card-resources/src/cardTableFormatter.ts | 15 +- plugins/card-resources/src/index.ts | 6 +- plugins/card-resources/src/plugin.ts | 4 +- .../package.json | 2 + .../src/docTableFormatter.ts | 7 +- .../src/index.ts | 3 +- plugins/controlled-documents/package.json | 1 + plugins/controlled-documents/src/plugin.ts | 3 +- plugins/converter-resources/.eslintrc.js | 7 + plugins/converter-resources/config/rig.json | 4 + plugins/converter-resources/jest.config.js | 7 + plugins/converter-resources/package.json | 50 + .../__tests__/MarkdownTableConverter.test.ts | 67 ++ .../src/__tests__/copyAsMarkdownTable.test.ts | 220 ++++ .../src/__tests__/formatter.utils.test.ts | 78 ++ .../src/__tests__/markdown.escape.test.ts | 55 + .../src/__tests__/model.tableModel.test.ts | 96 ++ plugins/converter-resources/src/actionImpl.ts | 26 + plugins/converter-resources/src/data/index.ts | 18 + .../src/data/metadataBuilder.ts | 99 ++ .../src/data/personLoader.ts | 50 + .../src/data/relationshipBuilder.ts | 121 ++ .../src/formatter/index.ts | 24 + .../src/formatter/registry.ts | 71 ++ .../src/formatter/utils.ts | 123 ++ .../src/formatter/valueFormatter.ts | 258 ++++ plugins/converter-resources/src/index.ts | 54 + .../src/markdown/copyActions.ts | 128 ++ .../src/markdown/escape.ts | 33 + .../converter-resources/src/markdown/index.ts | 23 + .../converter-resources/src/markdown/link.ts | 42 + .../src/markdown/tableBuilder.ts | 311 +++++ .../src/model/headerGenerator.ts | 74 ++ .../converter-resources/src/model/index.ts | 18 + .../src/model/tableModel.ts | 112 ++ .../src/model/viewletLoader.ts | 62 + plugins/converter-resources/src/plugin.ts | 19 + plugins/converter-resources/src/types.ts | 138 +++ plugins/converter-resources/tsconfig.json | 12 + plugins/converter/.eslintrc.js | 7 + plugins/converter/config/rig.json | 4 + plugins/converter/jest.config.js | 7 + plugins/converter/package.json | 44 + plugins/converter/src/index.ts | 22 + plugins/converter/src/plugin.ts | 50 + plugins/converter/src/types.ts | 95 ++ plugins/converter/tsconfig.json | 12 + plugins/text-editor-resources/package.json | 1 + .../extension/table/refreshTable.ts | 11 +- plugins/tracker-resources/package.json | 2 + plugins/tracker-resources/src/index.ts | 5 +- .../src/issueTableFormatter.ts | 6 +- plugins/tracker-resources/src/plugin.ts | 4 +- plugins/view-resources/package.json | 1 + .../src/__tests__/copyAsMarkdownTable.test.ts | 446 ------- plugins/view-resources/src/actionImpl.ts | 5 +- .../src/components/RelationshipTable.svelte | 5 +- .../view-resources/src/copyAsMarkdownTable.ts | 1072 ----------------- plugins/view-resources/src/index.ts | 20 +- .../view-resources/src/markdownTableUtils.ts | 343 ------ plugins/view/src/index.ts | 8 +- rush.json | 15 + 83 files changed, 3078 insertions(+), 1921 deletions(-) create mode 100644 models/converter/.eslintrc.js create mode 100644 models/converter/.npmignore create mode 100644 models/converter/config/rig.json create mode 100644 models/converter/jest.config.js create mode 100644 models/converter/package.json create mode 100644 models/converter/src/index.ts create mode 100644 models/converter/tsconfig.json create mode 100644 plugins/converter-resources/.eslintrc.js create mode 100644 plugins/converter-resources/config/rig.json create mode 100644 plugins/converter-resources/jest.config.js create mode 100644 plugins/converter-resources/package.json create mode 100644 plugins/converter-resources/src/__tests__/MarkdownTableConverter.test.ts create mode 100644 plugins/converter-resources/src/__tests__/copyAsMarkdownTable.test.ts create mode 100644 plugins/converter-resources/src/__tests__/formatter.utils.test.ts create mode 100644 plugins/converter-resources/src/__tests__/markdown.escape.test.ts create mode 100644 plugins/converter-resources/src/__tests__/model.tableModel.test.ts create mode 100644 plugins/converter-resources/src/actionImpl.ts create mode 100644 plugins/converter-resources/src/data/index.ts create mode 100644 plugins/converter-resources/src/data/metadataBuilder.ts create mode 100644 plugins/converter-resources/src/data/personLoader.ts create mode 100644 plugins/converter-resources/src/data/relationshipBuilder.ts create mode 100644 plugins/converter-resources/src/formatter/index.ts create mode 100644 plugins/converter-resources/src/formatter/registry.ts create mode 100644 plugins/converter-resources/src/formatter/utils.ts create mode 100644 plugins/converter-resources/src/formatter/valueFormatter.ts create mode 100644 plugins/converter-resources/src/index.ts create mode 100644 plugins/converter-resources/src/markdown/copyActions.ts create mode 100644 plugins/converter-resources/src/markdown/escape.ts create mode 100644 plugins/converter-resources/src/markdown/index.ts create mode 100644 plugins/converter-resources/src/markdown/link.ts create mode 100644 plugins/converter-resources/src/markdown/tableBuilder.ts create mode 100644 plugins/converter-resources/src/model/headerGenerator.ts create mode 100644 plugins/converter-resources/src/model/index.ts create mode 100644 plugins/converter-resources/src/model/tableModel.ts create mode 100644 plugins/converter-resources/src/model/viewletLoader.ts create mode 100644 plugins/converter-resources/src/plugin.ts create mode 100644 plugins/converter-resources/src/types.ts create mode 100644 plugins/converter-resources/tsconfig.json create mode 100644 plugins/converter/.eslintrc.js create mode 100644 plugins/converter/config/rig.json create mode 100644 plugins/converter/jest.config.js create mode 100644 plugins/converter/package.json create mode 100644 plugins/converter/src/index.ts create mode 100644 plugins/converter/src/plugin.ts create mode 100644 plugins/converter/src/types.ts create mode 100644 plugins/converter/tsconfig.json delete mode 100644 plugins/view-resources/src/__tests__/copyAsMarkdownTable.test.ts delete mode 100644 plugins/view-resources/src/copyAsMarkdownTable.ts delete mode 100644 plugins/view-resources/src/markdownTableUtils.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 52193c56b7..bfa42de239 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -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 diff --git a/desktop/package.json b/desktop/package.json index 2a89a1e6ea..7fe030affb 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/src/ui/platform.ts b/desktop/src/ui/platform.ts index 62c74178cb..d46c8f5b12 100644 --- a/desktop/src/ui/platform.ts +++ b/desktop/src/ui/platform.ts @@ -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 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')) diff --git a/dev/prod/package.json b/dev/prod/package.json index 5e83bdde1b..be3d41825c 100644 --- a/dev/prod/package.json +++ b/dev/prod/package.json @@ -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", diff --git a/dev/prod/src/platform.ts b/dev/prod/src/platform.ts index a1c6f7f470..7446fc8c3b 100644 --- a/dev/prod/src/platform.ts +++ b/dev/prod/src/platform.ts @@ -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')) diff --git a/models/all/package.json b/models/all/package.json index 5fd9d1b953..e5c92c789a 100644 --- a/models/all/package.json +++ b/models/all/package.json @@ -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", diff --git a/models/all/src/index.ts b/models/all/src/index.ts index 7b8a005635..47cb2ed9fa 100644 --- a/models/all/src/index.ts +++ b/models/all/src/index.ts @@ -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], diff --git a/models/card/package.json b/models/card/package.json index e706197bd4..833ff66d01 100644 --- a/models/card/package.json +++ b/models/card/package.json @@ -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", diff --git a/models/card/src/index.ts b/models/card/src/index.ts index 42241d89f6..e156dbeb84 100644 --- a/models/card/src/index.ts +++ b/models/card/src/index.ts @@ -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, diff --git a/models/controlled-documents/package.json b/models/controlled-documents/package.json index 2c4e04f9f9..58dfbfa824 100644 --- a/models/controlled-documents/package.json +++ b/models/controlled-documents/package.json @@ -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", diff --git a/models/controlled-documents/src/index.ts b/models/controlled-documents/src/index.ts index 685064f13c..d57b95d36a 100644 --- a/models/controlled-documents/src/index.ts +++ b/models/controlled-documents/src/index.ts @@ -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 }) diff --git a/models/converter/.eslintrc.js b/models/converter/.eslintrc.js new file mode 100644 index 0000000000..c1cf82cba0 --- /dev/null +++ b/models/converter/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/model/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/models/converter/.npmignore b/models/converter/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/models/converter/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/models/converter/config/rig.json b/models/converter/config/rig.json new file mode 100644 index 0000000000..2f6be36605 --- /dev/null +++ b/models/converter/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "model" +} diff --git a/models/converter/jest.config.js b/models/converter/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/models/converter/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/models/converter/package.json b/models/converter/package.json new file mode 100644 index 0000000000..9824ea82e7 --- /dev/null +++ b/models/converter/package.json @@ -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" + } +} diff --git a/models/converter/src/index.ts b/models/converter/src/index.ts new file mode 100644 index 0000000000..59eaa1ad4b --- /dev/null +++ b/models/converter/src/index.ts @@ -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 +} + +export function createModel (builder: Builder): void { + builder.createModel(TMarkdownValueFormatter) +} diff --git a/models/converter/tsconfig.json b/models/converter/tsconfig.json new file mode 100644 index 0000000000..367a8578c9 --- /dev/null +++ b/models/converter/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/models/tracker/package.json b/models/tracker/package.json index a7cf3083ac..0bdad7a260 100644 --- a/models/tracker/package.json +++ b/models/tracker/package.json @@ -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", diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index 04395afc0e..26558b5c57 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -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 }) diff --git a/plugins/card-resources/package.json b/plugins/card-resources/package.json index 5076796d7b..1edaa94b19 100644 --- a/plugins/card-resources/package.json +++ b/plugins/card-resources/package.json @@ -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", diff --git a/plugins/card-resources/src/cardTableFormatter.ts b/plugins/card-resources/src/cardTableFormatter.ts index 7145afad46..ac999dcdbc 100644 --- a/plugins/card-resources/src/cardTableFormatter.ts +++ b/plugins/card-resources/src/cardTableFormatter.ts @@ -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): Promise { * 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 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) diff --git a/plugins/card-resources/src/index.ts b/plugins/card-resources/src/index.ts index 30e30f9163..d2c77a033f 100644 --- a/plugins/card-resources/src/index.ts +++ b/plugins/card-resources/src/index.ts @@ -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 => ({ CheckCommunicationMessagesSectionVisibility: checkCommunicationMessagesSectionVisibility, GetSpaceAccessPublicLink: getSpaceAccessPublicLink, CanGetSpaceAccessPublicLink: canGetSpaceAccessPublicLink, - CardFactory: cardFactory + CardFactory: cardFactory, + FormatCardMarkdownValue: formatCardValue } }) diff --git a/plugins/card-resources/src/plugin.ts b/plugins/card-resources/src/plugin.ts index 39e2ebb2e5..bcc7d4c606 100644 --- a/plugins/card-resources/src/plugin.ts +++ b/plugins/card-resources/src/plugin.ts @@ -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) => Promise | undefined>> + CardFactory: '' as Resource<(props?: Record) => Promise | undefined>>, + FormatCardMarkdownValue: '' as Resource }, permission: { CreateCard: '' as Ref, diff --git a/plugins/controlled-documents-resources/package.json b/plugins/controlled-documents-resources/package.json index 69f5795c5d..9f1a5ce4e9 100644 --- a/plugins/controlled-documents-resources/package.json +++ b/plugins/controlled-documents-resources/package.json @@ -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", diff --git a/plugins/controlled-documents-resources/src/docTableFormatter.ts b/plugins/controlled-documents-resources/src/docTableFormatter.ts index b66660af82..19bc1feefe 100644 --- a/plugins/controlled-documents-resources/src/docTableFormatter.ts +++ b/plugins/controlled-documents-resources/src/docTableFormatter.ts @@ -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): Promise { * 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) diff --git a/plugins/controlled-documents-resources/src/index.ts b/plugins/controlled-documents-resources/src/index.ts index 97a456c5a9..b1953e3ac5 100644 --- a/plugins/controlled-documents-resources/src/index.ts +++ b/plugins/controlled-documents-resources/src/index.ts @@ -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 => ({ ProjectDocumentReferenceObjectProvider: projectDocumentReferenceObjectProvider, ControlledDocumentTitleProvider: getControlledDocumentTitle, DocumentMetaTitleProvider: getDocumentMetaTitle, + FormatDocumentMarkdownValue: formatControlledDocumentValue, Comment: comment, IsCommentVisible: isCommentVisible }, diff --git a/plugins/controlled-documents/package.json b/plugins/controlled-documents/package.json index 7a93b379d4..274361f301 100644 --- a/plugins/controlled-documents/package.json +++ b/plugins/controlled-documents/package.json @@ -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", diff --git a/plugins/controlled-documents/src/plugin.ts b/plugins/controlled-documents/src/plugin.ts index a542f656ce..1ecf43d074 100644 --- a/plugins/controlled-documents/src/plugin.ts +++ b/plugins/controlled-documents/src/plugin.ts @@ -134,7 +134,8 @@ export const documentsPlugin = plugin(documentsId, { }, function: { CanChangeDocumentOwner: '' as Resource<(doc?: Doc | Doc[]) => Promise>, - CanDeleteDocumentCategory: '' as Resource<(doc?: Doc | Doc[]) => Promise> + CanDeleteDocumentCategory: '' as Resource<(doc?: Doc | Doc[]) => Promise>, + FormatDocumentMarkdownValue: '' as Resource }, icon: { Approvals: '' as Asset, diff --git a/plugins/converter-resources/.eslintrc.js b/plugins/converter-resources/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/plugins/converter-resources/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/plugins/converter-resources/config/rig.json b/plugins/converter-resources/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/plugins/converter-resources/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/plugins/converter-resources/jest.config.js b/plugins/converter-resources/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/plugins/converter-resources/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/plugins/converter-resources/package.json b/plugins/converter-resources/package.json new file mode 100644 index 0000000000..73b221692f --- /dev/null +++ b/plugins/converter-resources/package.json @@ -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" + } +} diff --git a/plugins/converter-resources/src/__tests__/MarkdownTableConverter.test.ts b/plugins/converter-resources/src/__tests__/MarkdownTableConverter.test.ts new file mode 100644 index 0000000000..dffc6c2a6c --- /dev/null +++ b/plugins/converter-resources/src/__tests__/MarkdownTableConverter.test.ts @@ -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('\\)') + }) + }) +}) diff --git a/plugins/converter-resources/src/__tests__/copyAsMarkdownTable.test.ts b/plugins/converter-resources/src/__tests__/copyAsMarkdownTable.test.ts new file mode 100644 index 0000000000..d962978a5d --- /dev/null +++ b/plugins/converter-resources/src/__tests__/copyAsMarkdownTable.test.ts @@ -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>) => (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> }, + 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> }, + 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> }, + 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> } + 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> } + await copyAsMarkdownTable([mockDoc], evt, props) + expect(copyMarkdown).toHaveBeenCalled() + const { addNotification } = await import('@hcengineering/ui') + expect(addNotification).toHaveBeenCalled() + }) + }) +}) diff --git a/plugins/converter-resources/src/__tests__/formatter.utils.test.ts b/plugins/converter-resources/src/__tests__/formatter.utils.test.ts new file mode 100644 index 0000000000..a8729ef016 --- /dev/null +++ b/plugins/converter-resources/src/__tests__/formatter.utils.test.ts @@ -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('') + }) + }) +}) diff --git a/plugins/converter-resources/src/__tests__/markdown.escape.test.ts b/plugins/converter-resources/src/__tests__/markdown.escape.test.ts new file mode 100644 index 0000000000..ee83407268 --- /dev/null +++ b/plugins/converter-resources/src/__tests__/markdown.escape.test.ts @@ -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') + }) + }) +}) diff --git a/plugins/converter-resources/src/__tests__/model.tableModel.test.ts b/plugins/converter-resources/src/__tests__/model.tableModel.test.ts new file mode 100644 index 0000000000..531cb4e970 --- /dev/null +++ b/plugins/converter-resources/src/__tests__/model.tableModel.test.ts @@ -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']) + }) + }) +}) diff --git a/plugins/converter-resources/src/actionImpl.ts b/plugins/converter-resources/src/actionImpl.ts new file mode 100644 index 0000000000..df8706e8e6 --- /dev/null +++ b/plugins/converter-resources/src/actionImpl.ts @@ -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 { + await copyAsMarkdownTable(doc, evt, props) +} diff --git a/plugins/converter-resources/src/data/index.ts b/plugins/converter-resources/src/data/index.ts new file mode 100644 index 0000000000..970e1aa51a --- /dev/null +++ b/plugins/converter-resources/src/data/index.ts @@ -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' diff --git a/plugins/converter-resources/src/data/metadataBuilder.ts b/plugins/converter-resources/src/data/metadataBuilder.ts new file mode 100644 index 0000000000..c48b62266a --- /dev/null +++ b/plugins/converter-resources/src/data/metadataBuilder.ts @@ -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 { + let viewletId: Ref | 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 + } +} diff --git a/plugins/converter-resources/src/data/personLoader.ts b/plugins/converter-resources/src/data/personLoader.ts new file mode 100644 index 0000000000..c225f610c8 --- /dev/null +++ b/plugins/converter-resources/src/data/personLoader.ts @@ -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 +): Promise { + 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 +} diff --git a/plugins/converter-resources/src/data/relationshipBuilder.ts b/plugins/converter-resources/src/data/relationshipBuilder.ts new file mode 100644 index 0000000000..54d4d55d9a --- /dev/null +++ b/plugins/converter-resources/src/data/relationshipBuilder.ts @@ -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>, + hierarchy: Hierarchy, + client: Client +): Promise { + 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 +} diff --git a/plugins/converter-resources/src/formatter/index.ts b/plugins/converter-resources/src/formatter/index.ts new file mode 100644 index 0000000000..c632f2236a --- /dev/null +++ b/plugins/converter-resources/src/formatter/index.ts @@ -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' diff --git a/plugins/converter-resources/src/formatter/registry.ts b/plugins/converter-resources/src/formatter/registry.ts new file mode 100644 index 0000000000..2ccadaa17f --- /dev/null +++ b/plugins/converter-resources/src/formatter/registry.ts @@ -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>, 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>, 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>): ValueFormatter[] { + const formatters: ValueFormatter[] = [] + + // Get formatters for this class and all parent classes + let currentClass: Ref> | undefined = _class + while (currentClass !== undefined) { + const classFormatters = valueFormattersByClass.get(currentClass) + if (classFormatters !== undefined) { + formatters.push(...classFormatters) + } + const classDef: Class | undefined = hierarchy.getClass(currentClass) + currentClass = classDef?.extends + } + + // Add global formatters + formatters.push(...globalValueFormatters) + + return formatters +} diff --git a/plugins/converter-resources/src/formatter/utils.ts b/plugins/converter-resources/src/formatter/utils.ts new file mode 100644 index 0000000000..b0499f6fee --- /dev/null +++ b/plugins/converter-resources/src/formatter/utils.ts @@ -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 { + const isRefArray = + attrType?._class === core.class.ArrOf && + (attrType as { of?: { _class?: Ref> } })?.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, + language: string | undefined +): Promise { + 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 '' +} diff --git a/plugins/converter-resources/src/formatter/valueFormatter.ts b/plugins/converter-resources/src/formatter/valueFormatter.ts new file mode 100644 index 0000000000..ff5ee7d1d3 --- /dev/null +++ b/plugins/converter-resources/src/formatter/valueFormatter.ts @@ -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 { + 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 + 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>, + language: string | undefined, + isFirstColumn: boolean = false, + userCache?: Map, + customFormatter?: ValueFormatter +): Promise { + // 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 + const titleOrName = await extractObjectTitleOrName(obj, language) + return titleOrName !== '' ? titleOrName : String(value) + } + + return String(value) +} diff --git a/plugins/converter-resources/src/index.ts b/plugins/converter-resources/src/index.ts new file mode 100644 index 0000000000..3e79425673 --- /dev/null +++ b/plugins/converter-resources/src/index.ts @@ -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 => ({ + function: { + CopyAsMarkdownTable: copyAsMarkdownTableFromResource, + CopyRelationshipAsMarkdown: copyRelationshipTableAsMarkdown, + BuildMarkdownTableFromMetadata: buildMarkdownTableFromMetadataImpl, + BuildMarkdownTableFromDocs: buildMarkdownTableFromDocsImpl + }, + actionImpl: { + CopyAsMarkdownTable: copyAsMarkdownTableAction + } +}) diff --git a/plugins/converter-resources/src/markdown/copyActions.ts b/plugins/converter-resources/src/markdown/copyActions.ts new file mode 100644 index 0000000000..98d87f6543 --- /dev/null +++ b/plugins/converter-resources/src/markdown/copyActions.ts @@ -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 { + 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 { + const docs = props.docs ?? [] + await copyAsMarkdownTable(docs, evt, props) +} + +/** + * Copy RelationshipTable as markdown table + */ +export async function copyRelationshipTableAsMarkdown ( + evt: Event, + props: CopyRelationshipTableAsMarkdownProps +): Promise { + 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 + ) + } +} diff --git a/plugins/converter-resources/src/markdown/escape.ts b/plugins/converter-resources/src/markdown/escape.ts new file mode 100644 index 0000000000..00ea4f81d8 --- /dev/null +++ b/plugins/converter-resources/src/markdown/escape.ts @@ -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, '\\)') +} diff --git a/plugins/converter-resources/src/markdown/index.ts b/plugins/converter-resources/src/markdown/index.ts new file mode 100644 index 0000000000..84726d0770 --- /dev/null +++ b/plugins/converter-resources/src/markdown/index.ts @@ -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' diff --git a/plugins/converter-resources/src/markdown/link.ts b/plugins/converter-resources/src/markdown/link.ts new file mode 100644 index 0000000000..695f18c55d --- /dev/null +++ b/plugins/converter-resources/src/markdown/link.ts @@ -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 { + 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) + } +} diff --git a/plugins/converter-resources/src/markdown/tableBuilder.ts b/plugins/converter-resources/src/markdown/tableBuilder.ts new file mode 100644 index 0000000000..7a74730cca --- /dev/null +++ b/plugins/converter-resources/src/markdown/tableBuilder.ts @@ -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 { + const hierarchy = client.getHierarchy() + const cardClass = metadata.cardClass as Ref> + + 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 { + 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 }) + } + + const props: CopyAsMarkdownTableProps = { + cardClass: metadata.cardClass as Ref>, + 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 { + 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() + 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 { + if (props.viewModel.length === 0 || props.model.length === 0) { + return '' + } + + const userCache = new Map() + 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() + props.model.forEach((attr, index) => { + attributeKeyToIndex.set(attr.key, index) + }) + + const activeRowSpans = new Map() + 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 +} diff --git a/plugins/converter-resources/src/model/headerGenerator.ts b/plugins/converter-resources/src/model/headerGenerator.ts new file mode 100644 index 0000000000..78eb02c614 --- /dev/null +++ b/plugins/converter-resources/src/model/headerGenerator.ts @@ -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>, + hierarchy: Hierarchy, + language: string | undefined +): Promise { + 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>, + hierarchy: Hierarchy, + language: string | undefined +): Promise { + 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 +} diff --git a/plugins/converter-resources/src/model/index.ts b/plugins/converter-resources/src/model/index.ts new file mode 100644 index 0000000000..ce8db051ef --- /dev/null +++ b/plugins/converter-resources/src/model/index.ts @@ -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' diff --git a/plugins/converter-resources/src/model/tableModel.ts b/plugins/converter-resources/src/model/tableModel.ts new file mode 100644 index 0000000000..a9a63c2c13 --- /dev/null +++ b/plugins/converter-resources/src/model/tableModel.ts @@ -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) + * Preserves custom attributes by using label as key when key is empty + */ +export function modelToConfig (model: AttributeModel[]): Array { + 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>, + viewlet: Viewlet | undefined +): Promise { + 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 = [ + '', // 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 + }) +} diff --git a/plugins/converter-resources/src/model/viewletLoader.ts b/plugins/converter-resources/src/model/viewletLoader.ts new file mode 100644 index 0000000000..f0fd7e8822 --- /dev/null +++ b/plugins/converter-resources/src/model/viewletLoader.ts @@ -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>, + propsViewlet?: Viewlet, + propsConfig?: Array +): Promise<{ viewlet: Viewlet | undefined, config: Array | 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 | 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 } +} diff --git a/plugins/converter-resources/src/plugin.ts b/plugins/converter-resources/src/plugin.ts new file mode 100644 index 0000000000..5992d78958 --- /dev/null +++ b/plugins/converter-resources/src/plugin.ts @@ -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, {}) diff --git a/plugins/converter-resources/src/types.ts b/plugins/converter-resources/src/types.ts new file mode 100644 index 0000000000..bee6456b54 --- /dev/null +++ b/plugins/converter-resources/src/types.ts @@ -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>, + language: string | undefined +) => Promise + +/** + * Mixin for classes that provide a custom value formatter for markdown/export + */ +export interface MarkdownValueFormatter extends Class { + formatter: Resource +} + +/** + * Props for CopyAsMarkdownTable function + */ +export interface CopyAsMarkdownTableProps { + cardClass: Ref> + viewlet?: Viewlet + config?: Array + valueFormatter?: ValueFormatter + query?: DocumentQuery +} + +/** + * 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> + valueFormatter?: ValueFormatter + query?: DocumentQuery +} + +/** + * Function type for CopyAsMarkdownTable (used in action context) + */ +export type CopyAsMarkdownTableFunction = (evt: Event, props: CopyAsMarkdownTableProps) => Promise + +/** + * Function type for CopyRelationshipTableAsMarkdown + */ +export type CopyRelationshipTableAsMarkdownFunction = ( + evt: Event, + props: CopyRelationshipTableAsMarkdownProps +) => Promise + +/** + * 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)})` + } +} diff --git a/plugins/converter-resources/tsconfig.json b/plugins/converter-resources/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/plugins/converter-resources/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/plugins/converter/.eslintrc.js b/plugins/converter/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/plugins/converter/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/plugins/converter/config/rig.json b/plugins/converter/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/plugins/converter/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/plugins/converter/jest.config.js b/plugins/converter/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/plugins/converter/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/plugins/converter/package.json b/plugins/converter/package.json new file mode 100644 index 0000000000..9c4c5c9517 --- /dev/null +++ b/plugins/converter/package.json @@ -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" + } +} diff --git a/plugins/converter/src/index.ts b/plugins/converter/src/index.ts new file mode 100644 index 0000000000..2e477cc74b --- /dev/null +++ b/plugins/converter/src/index.ts @@ -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 diff --git a/plugins/converter/src/plugin.ts b/plugins/converter/src/plugin.ts new file mode 100644 index 0000000000..dbcf9b3f16 --- /dev/null +++ b/plugins/converter/src/plugin.ts @@ -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> + }, + function: { + CopyAsMarkdownTable: '' as Resource, + CopyRelationshipAsMarkdown: '' as Resource, + BuildMarkdownTableFromMetadata: '' as Resource< + (docs: Doc[], metadata: BuildMarkdownTableMetadata, client: Client) => Promise + >, + BuildMarkdownTableFromDocs: '' as Resource< + (docs: Doc[], props: CopyAsMarkdownTableProps, client: Client) => Promise + > + }, + action: { + CopyAsMarkdownTable: '' as Ref>> + }, + actionImpl: { + CopyAsMarkdownTable: '' as ViewAction + } +}) + +export default converterPlugin diff --git a/plugins/converter/src/types.ts b/plugins/converter/src/types.ts new file mode 100644 index 0000000000..50a3f1d742 --- /dev/null +++ b/plugins/converter/src/types.ts @@ -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>, + language: string | undefined +) => Promise + +/** + * Mixin for classes that provide a custom value formatter for markdown/export + */ +export interface MarkdownValueFormatter extends Class { + formatter: Resource +} + +/** + * Props for CopyAsMarkdownTable function + */ +export interface CopyAsMarkdownTableProps { + cardClass: Ref> + viewlet?: Viewlet + config?: Array + valueFormatter?: ValueFormatter + query?: DocumentQuery +} + +/** + * 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> + valueFormatter?: ValueFormatter + query?: DocumentQuery +} + +/** + * Function type for CopyAsMarkdownTable (used in action context) + */ +export type CopyAsMarkdownTableFunction = (evt: Event, props: CopyAsMarkdownTableProps) => Promise + +/** + * Function type for CopyRelationshipTableAsMarkdown + */ +export type CopyRelationshipTableAsMarkdownFunction = ( + evt: Event, + props: CopyRelationshipTableAsMarkdownProps +) => Promise diff --git a/plugins/converter/tsconfig.json b/plugins/converter/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/plugins/converter/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/plugins/text-editor-resources/package.json b/plugins/text-editor-resources/package.json index d2b072b0bf..aa438e696a 100644 --- a/plugins/text-editor-resources/package.json +++ b/plugins/text-editor-resources/package.json @@ -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", diff --git a/plugins/text-editor-resources/src/components/extension/table/refreshTable.ts b/plugins/text-editor-resources/src/components/extension/table/refreshTable.ts index 06567f0718..43ddd8ee6d 100644 --- a/plugins/text-editor-resources/src/components/extension/table/refreshTable.ts +++ b/plugins/text-editor-resources/src/components/extension/table/refreshTable.ts @@ -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 { 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 '' } } diff --git a/plugins/tracker-resources/package.json b/plugins/tracker-resources/package.json index 5340d5757c..2f023dfb15 100644 --- a/plugins/tracker-resources/package.json +++ b/plugins/tracker-resources/package.json @@ -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", diff --git a/plugins/tracker-resources/src/index.ts b/plugins/tracker-resources/src/index.ts index 3ae81ee064..071a13ac73 100644 --- a/plugins/tracker-resources/src/index.ts +++ b/plugins/tracker-resources/src/index.ts @@ -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 => ({ GetIssueStatusCategories: getIssueStatusCategories, SetComponentStore: setStore, ComponentFilterFunction: filterComponents, - OpenIssuesOfTaskType: openIssuesOfTaskType + OpenIssuesOfTaskType: openIssuesOfTaskType, + FormatIssueMarkdownValue: formatIssueValue }, actionImpl: { Move: move, diff --git a/plugins/tracker-resources/src/issueTableFormatter.ts b/plugins/tracker-resources/src/issueTableFormatter.ts index 61470086ec..ec74c7de6f 100644 --- a/plugins/tracker-resources/src/issueTableFormatter.ts +++ b/plugins/tracker-resources/src/issueTableFormatter.ts @@ -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 { * 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 diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index a91d079f93..4d767bad2e 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -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>>, GetIssueIdByIdentifier: '' as Resource<(id: string) => Promise | undefined>>, - OpenIssuesOfTaskType: '' as Resource<(taskType: TaskType) => Promise> + OpenIssuesOfTaskType: '' as Resource<(taskType: TaskType) => Promise>, + FormatIssueMarkdownValue: '' as Resource }, aggregation: { CreateComponentAggregationManager: '' as CreateAggregationManagerFunc, diff --git a/plugins/view-resources/package.json b/plugins/view-resources/package.json index 8a87a91c29..5bef5c43a1 100644 --- a/plugins/view-resources/package.json +++ b/plugins/view-resources/package.json @@ -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", diff --git a/plugins/view-resources/src/__tests__/copyAsMarkdownTable.test.ts b/plugins/view-resources/src/__tests__/copyAsMarkdownTable.test.ts deleted file mode 100644 index 639beef7b0..0000000000 --- a/plugins/view-resources/src/__tests__/copyAsMarkdownTable.test.ts +++ /dev/null @@ -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>, - label: 'card:string:Card' as IntlString - } - - mockHierarchy = { - getClass: jest.fn((ref: Ref>): 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> = 'card:class:Card' as Ref> - 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> = 'card:class:Card' as Ref> - 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> = 'card:class:Card' as Ref> - 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> = 'card:class:Card' as Ref> - 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> = 'card:class:Card' as Ref> - 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> = 'card:class:Card' as Ref> - 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> = 'card:class:Card' as Ref> - 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> = 'card:class:Card' as Ref> - 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> = 'card:class:Card' as Ref> - 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> = 'card:class:Card' as Ref> - 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> = 'card:class:Card' as Ref> - 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) - }) - }) -}) diff --git a/plugins/view-resources/src/actionImpl.ts b/plugins/view-resources/src/actionImpl.ts index 65b5199fc7..1dc3f363ca 100644 --- a/plugins/view-resources/src/actionImpl.ts +++ b/plugins/view-resources/src/actionImpl.ts @@ -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) } /** diff --git a/plugins/view-resources/src/components/RelationshipTable.svelte b/plugins/view-resources/src/components/RelationshipTable.svelte index 398877a95f..1876368e6e 100644 --- a/plugins/view-resources/src/components/RelationshipTable.svelte +++ b/plugins/view-resources/src/components/RelationshipTable.svelte @@ -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 { if (model === undefined || viewModel.length === 0) return - await CopyRelationshipTableAsMarkdown(e, { + const copyFn = await getResource(converter.function.CopyRelationshipAsMarkdown) + await copyFn(e, { viewModel, model, objects, diff --git a/plugins/view-resources/src/copyAsMarkdownTable.ts b/plugins/view-resources/src/copyAsMarkdownTable.ts deleted file mode 100644 index 1671259374..0000000000 --- a/plugins/view-resources/src/copyAsMarkdownTable.ts +++ /dev/null @@ -1,1072 +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 Class, - type Client, - type Doc, - type DocumentQuery, - type Hierarchy, - type Ref, - type PersonId, - concatLink, - getDisplayTime, - getObjectValue -} from '@hcengineering/core' -import { translate, type IntlString, getMetadata } from '@hcengineering/platform' -import { addNotification, NotificationSeverity, locationToUrl, getCurrentResolvedLocation } from '@hcengineering/ui' -import { getCurrentLanguage } from '@hcengineering/theme' -import viewPlugin, { - type Viewlet, - type AttributeModel, - type BuildModelKey, - type BuildMarkdownTableMetadata, - type TableMetadata -} from '@hcengineering/view' -import presentation, { getClient } from '@hcengineering/presentation' -import { getName, getPersonByPersonId } from '@hcengineering/contact' -import { buildModel, buildConfigLookup, buildConfigAssociation, getAttributeValue } from './utils' -import { - generateHeaders, - modelToConfig, - formatArrayValue, - extractObjectTitleOrName, - formatCustomAttributeValue, - escapeMarkdownLinkText, - createMarkdownLink, - isIntlString -} from './markdownTableUtils' -import view from './plugin' -import SimpleNotification from './components/SimpleNotification.svelte' -import { copyMarkdown } from './actionImpl' - -/** - * Value formatter function for custom field extraction - * @param attr - The attribute model - * @param card - 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, - card: Doc, - hierarchy: Hierarchy, - _class: Ref>, - language: string | undefined -) => Promise - -/** - * Registry for value formatters by document class - * Plugins can register custom formatters for specific document classes - */ -const valueFormattersByClass = new Map>, 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>, 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) - */ -function getFormattersForClass (hierarchy: Hierarchy, _class: Ref>): ValueFormatter[] { - const formatters: ValueFormatter[] = [] - - // Get formatters for this class and all parent classes - let currentClass: Ref> | undefined = _class - while (currentClass !== undefined) { - const classFormatters = valueFormattersByClass.get(currentClass) - if (classFormatters !== undefined) { - formatters.push(...classFormatters) - } - const classDef: Class | undefined = hierarchy.getClass(currentClass) - currentClass = classDef?.extends - } - - // Add global formatters - formatters.push(...globalValueFormatters) - - return formatters -} - -enum DocumentAttributeKey { - CreatedBy = 'createdBy', - CreatedOn = 'createdOn', - ModifiedBy = 'modifiedBy', - ModifiedOn = 'modifiedOn', - Title = 'title', - Name = 'name' -} - -enum DateFormatOption { - Numeric = 'numeric', - Short = 'short' -} - -async function buildTableModel ( - client: Client, - hierarchy: Hierarchy, - _class: Ref>, - viewlet: Viewlet | undefined -): Promise { - 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 = [ - '', // 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 - }) -} - -/** - * Check if a string looks like an IntlString (format: plugin:kind:key) - * Examples: card:string:Card, contact:class:UserProfile, card:types:Document - * @public - */ - -async function loadPersonName ( - personId: PersonId, - hierarchy: Hierarchy, - userCache?: Map -): Promise { - 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 -} - -/** - * Loads the actual viewlet configuration, including user preferences - * @param client - The client instance - * @param hierarchy - The hierarchy instance - * @param cardClass - The class to find viewlet for - * @param propsViewlet - Optional viewlet from props - * @param propsConfig - Optional config from props - * @returns The actual config to use, or undefined if no viewlet/config found - */ -async function loadViewletConfig ( - client: Client, - hierarchy: Hierarchy, - cardClass: Ref>, - propsViewlet?: Viewlet, - propsConfig?: Array -): Promise<{ viewlet: Viewlet | undefined, config: Array | undefined }> { - // If config is provided directly, use it - if (propsConfig !== undefined && propsConfig.length > 0) { - return { viewlet: propsViewlet, config: propsConfig } - } - - // Find viewlet if not provided - let viewlet: Viewlet | undefined = propsViewlet - if (viewlet === undefined) { - // Search for viewlets attached to this class or any of its ancestor classes - // Viewlets attached to a parent class apply to child classes - 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 - }) - // Prefer viewlet attached directly to the class, then parent classes - viewlet = - viewlets.find((v) => v.attachTo === cardClass) ?? - viewlets.find((v) => allClasses.includes(v.attachTo)) ?? - viewlets[0] - } - - // Get user's viewlet preference to use the actual displayed config - let actualConfig: Array | undefined - if (viewlet !== undefined) { - const preferences = await client.findAll(viewPlugin.class.ViewletPreference, { - space: core.space.Workspace, - attachedTo: viewlet._id - }) - // Use preference config if available, otherwise fall back to viewlet config - actualConfig = preferences.length > 0 && preferences[0].config.length > 0 ? preferences[0].config : viewlet.config - } - - return { viewlet, config: actualConfig } -} - -async function formatValue ( - attr: AttributeModel, - card: Doc, - hierarchy: Hierarchy, - _class: Ref>, - language: string | undefined, - isFirstColumn: boolean = false, - userCache?: Map, - customFormatter?: ValueFormatter -): Promise { - // Try custom formatter first (from actionProps) - if (customFormatter !== undefined) { - const formattedValue = await customFormatter(attr, card, hierarchy, _class, language) - if (formattedValue !== undefined) { - return formattedValue - } - } - - // Try registered value formatters for this class - 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 { - // Handle lookup keys properly - 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) - } - } - - // Handle custom attributes that failed to build properly in the model - // These have key: '' but the actual attribute name is in the label - 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 '' - } - - // Use attribute from model if available, otherwise try to find it - 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 - const titleOrName = await extractObjectTitleOrName(obj, language) - return titleOrName !== '' ? titleOrName : String(value) - } - - return String(value) -} - -export interface CopyAsMarkdownTableProps { - cardClass: Ref> - viewlet?: Viewlet - config?: Array - valueFormatter?: ValueFormatter - query?: DocumentQuery // Original query used to fetch documents -} - -/** - * Interface for RelationshipTable's row and cell models - */ -export interface RelationshipCellModel { - attribute: AttributeModel - rowSpan: number - object: Doc | undefined - parentObject: Doc | undefined -} - -export interface RelationshipRowModel { - cells: RelationshipCellModel[] -} - -export interface CopyRelationshipTableAsMarkdownProps { - viewModel: RelationshipRowModel[] - model: AttributeModel[] - objects: Doc[] - cardClass: Ref> - valueFormatter?: ValueFormatter - query?: DocumentQuery // Original query used to fetch documents -} - -/** - * Build metadata object from props and documents - * If viewlet is not provided, tries to find a default viewlet for the class - */ -async function buildTableMetadata ( - props: CopyAsMarkdownTableProps, - docs: Doc[], - client?: Client -): Promise { - // If viewlet is not provided, try to find a default viewlet for the class - let viewletId: Ref | undefined = props.viewlet?._id - if (viewletId === undefined && client !== undefined) { - const { viewlet } = await loadViewletConfig(client, client.getHierarchy(), props.cardClass, undefined, props.config) - viewletId = viewlet?._id - } - - // Capture the original URL of the current page/view - 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) { - // If URL capture fails, continue without it - 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, // Relationship tables don't use viewlets - config: modelToConfig(props.model), // Preserve custom attributes by converting model to config - query: props.query, - documentIds: docs.map((d) => d._id), - timestamp: Date.now(), - originalUrl - } -} - -/** - * Rebuild relationship table viewModel from documents and metadata - * Recreates the hierarchical structure with row spans and separate rows for each associated child - */ -async function rebuildRelationshipTableViewModel ( - docs: Doc[], - model: AttributeModel[], - cardClass: Ref>, - hierarchy: Hierarchy, - client: Client -): Promise { - const viewModel: RelationshipRowModel[] = [] - - // Build association queries from config to fetch associations - 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')) - - // Fetch documents with associations if needed - let docsWithAssociations: Doc[] = docs - if (associations !== undefined && associations.length > 0) { - // Re-fetch documents with associations - const docIds = docs.map((d) => d._id) - const query = { _id: { $in: docIds } } - docsWithAssociations = await client.findAll(cardClass, query, { lookup, associations }) - } - - // Process each parent document and create rows with proper hierarchy - for (const parentDoc of docsWithAssociations) { - const docWithAssoc = parentDoc as any - const parentAssociations = docWithAssoc.$associations ?? {} - - // Find the maximum number of children across all association columns - 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 no children, create a single row for the parent - 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 - } - - // Create rows: first row has parent with rowSpan, then one row per child - for (let childIndex = 0; childIndex < maxChildren; childIndex++) { - const cells: RelationshipCellModel[] = [] - - for (const attr of model) { - const isAssociationKey = attr.key.startsWith('$associations') - - if (attr.key === '') { - // First column: parent document with row span - cells.push({ - attribute: attr, - rowSpan: maxChildren, // Span across all child rows - object: parentDoc, - parentObject: undefined - }) - } else if (isAssociationKey) { - // Association column: show child at current index - 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 { - // Regular attribute: show parent value only in first row - // In subsequent rows, this will be empty (handled by row span logic in markdown generation) - cells.push({ - attribute: attr, - rowSpan: 1, - object: childIndex === 0 ? parentDoc : undefined, - parentObject: undefined - }) - } - } - - viewModel.push({ cells }) - } - } - - return viewModel -} - -/** - * Build relationship table markdown from metadata - * Rebuilds the viewModel and model, then uses the common markdown generation logic - */ -async function buildRelationshipTableFromMetadata ( - docs: Doc[], - metadata: BuildMarkdownTableMetadata, - client: Client -): Promise { - const hierarchy = client.getHierarchy() - const cardClass = metadata.cardClass as Ref> - - // Rebuild model from config - const config = metadata.config ?? [] - const lookup = buildConfigLookup(hierarchy, cardClass, config) - const model = await buildModel({ - client, - _class: cardClass, - keys: config, - lookup - }) - - // Rebuild viewModel from documents - const viewModel = await rebuildRelationshipTableViewModel(docs, model, cardClass, hierarchy, client) - - // Build props and use common markdown generation - 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 - * Converts BuildMarkdownTableMetadata format to CopyAsMarkdownTableProps format - */ -export async function buildMarkdownTableFromMetadata ( - docs: Doc[], - metadata: BuildMarkdownTableMetadata, - client: Client -): Promise { - // Check if this is a relationship table (viewletId is undefined) - const tableMetadata = metadata as TableMetadata - if (isRelationshipTable(tableMetadata)) { - return await buildRelationshipTableFromMetadata(docs, metadata, client) - } - - // Regular table: Load viewlet if viewletId is provided - let viewlet: Viewlet | undefined - if (metadata.viewletId !== undefined) { - viewlet = await client.findOne(viewPlugin.class.Viewlet, { _id: metadata.viewletId as Ref }) - } - - // Convert metadata to CopyAsMarkdownTableProps - const props: CopyAsMarkdownTableProps = { - cardClass: metadata.cardClass as Ref>, - viewlet, - config: metadata.config, - query: metadata.query - } - - // Use the reusable function - return await buildMarkdownTableFromDocs(docs, props, client) -} - -/** - * Build markdown table string from documents and props - * This is the core logic for building markdown tables, extracted for reuse - * @param docs - Documents to include in the table - * @param props - Table configuration props - * @param client - Client instance - * @returns Markdown table string - */ -export async function buildMarkdownTableFromDocs ( - docs: Doc[], - props: CopyAsMarkdownTableProps, - client: Client -): Promise { - if (docs.length === 0) { - return '' - } - - const hierarchy = client.getHierarchy() - const cardClass = hierarchy.getClass(props.cardClass) - if (cardClass == null) { - return '' - } - - // Load viewlet and config (including user preferences) - const { viewlet, config: actualConfig } = await loadViewletConfig( - client, - hierarchy, - props.cardClass, - props.viewlet, - props.config - ) - - // Build displayable model from 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 | 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() - - 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 this is the first column with empty key (title attribute), create a markdown link - if (isFirstColumn && attr.key === '') { - const linkValue = await createMarkdownLink(hierarchy, card, value) - row.push(linkValue) - } else { - const escapedValue = escapeMarkdownLinkText(value) - row.push(escapedValue) - } - } - rows.push(row) - } - - let markdown = '| ' + headers.join(' | ') + ' |\n' - markdown += '| ' + headers.map(() => '---').join(' | ') + ' |\n' - for (const row of rows) { - markdown += '| ' + row.join(' | ') + ' |\n' - } - - return markdown -} - -export async function CopyAsMarkdownTable ( - doc: Doc | Doc[], - evt: Event, - props: CopyAsMarkdownTableProps -): Promise { - try { - const docs = Array.isArray(doc) ? doc : doc !== undefined ? [doc] : [] - if (docs.length === 0) { - return - } - const client = getClient() - - // Build markdown table using the extracted function - const markdown = await buildMarkdownTableFromDocs(docs, props, client) - - if (markdown.length === 0) { - return - } - - // Build metadata for table refresh/diff functionality - 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) { - const language = getCurrentLanguage() - addNotification( - await translate(view.string.Copied, {}, language), - await translate(view.string.TableCopyFailed, {}, language), - SimpleNotification, - undefined, - NotificationSeverity.Error - ) - } -} - -/** - * Build markdown table from relationship table props (viewModel, model, objects) - * This is the core logic extracted for reuse in both copy and refresh operations - */ -async function buildRelationshipTableMarkdown ( - props: CopyRelationshipTableAsMarkdownProps, - hierarchy: Hierarchy, - language: string | undefined -): Promise { - if (props.viewModel.length === 0 || props.model.length === 0) { - return '' - } - - // Cache for user ID (PersonId) -> name mappings to reduce database calls - const userCache = new Map() - - // Get the first document's class for custom attribute lookup - const firstDocClass = props.objects.length > 0 ? props.objects[0]._class : props.cardClass - - // Generate headers using common function - const headers = await generateHeaders(props.model, firstDocClass, hierarchy, language) - - // Build a map of attribute keys to their index in the model for quick lookup - const attributeKeyToIndex = new Map() - props.model.forEach((attr, index) => { - attributeKeyToIndex.set(attr.key, index) - }) - - // Track active row spans - maps attribute key to remaining span count - const activeRowSpans = new Map() - - // Process rows from viewModel - 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('') - - // First, handle cells that are continuing from previous rows (row spans) - 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) - } - } - } - } - - // Then, process cells in the current row - for (const cell of rowModel.cells) { - const attrIndex = attributeKeyToIndex.get(cell.attribute.key) - if (attrIndex === undefined) continue - - // Determine if this is an association column - 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) { - // Empty cell - row[attrIndex] = '' - continue - } - - // Use the same getValue logic as RelationshipTable - // For association keys, this returns the child document object itself - const rawValue = getAttributeValue(cell.attribute, doc, hierarchy) - - // Determine which document and class to use for formatting - let docToUse = doc - let docClass = props.cardClass - let attributeToUse = cell.attribute - - if (isAssociationKey) { - // For association keys, the value IS the child document object - 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) // Remove leading dot - const dotIndex = afterAssoc.indexOf('.') - if (dotIndex > 0) { - const attributeName = afterAssoc.substring(dotIndex + 1) - attributeToUse = { - ...cell.attribute, - key: attributeName - } - } else { - attributeToUse = { - ...cell.attribute, - key: '' - } - } - } - } - } - - // Format the value using the same logic as regular tables - const isFirstColumn = attrIndex === 0 - const allowEmptyKey = isFirstColumn || isAssociationKey - let value = await formatValue( - attributeToUse, - docToUse, - hierarchy, - docClass, - language, - allowEmptyKey, // Pass true for association keys so empty key works - 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 this cell has a row span > 1, track it for subsequent rows - if (cell.rowSpan > 1) { - activeRowSpans.set(cell.attribute.key, { - value, - remaining: cell.rowSpan - 1 - }) - } - } - - rows.push(row) - } - - // Build markdown table - let markdown = '| ' + headers.join(' | ') + ' |\n' - markdown += '| ' + headers.map(() => '---').join(' | ') + ' |\n' - for (const row of rows) { - markdown += '| ' + row.join(' | ') + ' |\n' - } - - return markdown -} - -/** - * Copy RelationshipTable as markdown table - * Handles hierarchical data with row spans by duplicating cell values across spanned rows - */ -export async function CopyRelationshipTableAsMarkdown ( - evt: Event, - props: CopyRelationshipTableAsMarkdownProps -): Promise { - try { - const client = getClient() - const hierarchy = client.getHierarchy() - const cardClass = hierarchy.getClass(props.cardClass) - if (cardClass == null) { - return - } - - const language = getCurrentLanguage() - - // Build markdown using extracted function - const markdown = await buildRelationshipTableMarkdown(props, hierarchy, language) - - if (markdown.length === 0) { - return - } - - // Build metadata for relationship table refresh/diff functionality - 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) { - const language = getCurrentLanguage() - addNotification( - await translate(view.string.Copied, {}, language), - await translate(view.string.TableCopyFailed, {}, language), - SimpleNotification, - undefined, - NotificationSeverity.Error - ) - } -} diff --git a/plugins/view-resources/src/index.ts b/plugins/view-resources/src/index.ts index eb64971b61..ccfadf3e02 100644 --- a/plugins/view-resources/src/index.ts +++ b/plugins/view-resources/src/index.ts @@ -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 => ({ BlobVideoMetadata: blobVideoMetadata, OpenDocument: openDocFromRef, CanCopyLink: canCopyLink, - GetLink: getLink, - BuildMarkdownTableFromDocs: buildMarkdownTableFromMetadata + GetLink: getLink } }) diff --git a/plugins/view-resources/src/markdownTableUtils.ts b/plugins/view-resources/src/markdownTableUtils.ts deleted file mode 100644 index b4252bae7c..0000000000 --- a/plugins/view-resources/src/markdownTableUtils.ts +++ /dev/null @@ -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>, - hierarchy: Hierarchy, - language: string | undefined -): Promise { - 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>, - hierarchy: Hierarchy, - language: string | undefined -): Promise { - 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) - * 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 { - 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 { - const isRefArray = - attrType?._class === core.class.ArrOf && - (attrType as { of?: { _class?: Ref> } })?.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, - language: string | undefined -): Promise { - 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 { - 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 - 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 { - 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) - } -} diff --git a/plugins/view/src/index.ts b/plugins/view/src/index.ts index ffe92162cf..7d29c0ab42 100644 --- a/plugins/view/src/index.ts +++ b/plugins/view/src/index.ts @@ -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, - BuildMarkdownTableFromDocs: '' as Resource< - (docs: Doc[], metadata: BuildMarkdownTableMetadata, client: Client) => Promise - > + OpenDocument: '' as Resource }, actionImpl: { CopyTextToClipboard: '' as ViewAction<{ diff --git a/rush.json b/rush.json index aeca18c9d4..11ae72e6b2 100644 --- a/rush.json +++ b/rush.json @@ -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",