From 144b0acfe1ca571863708f50d45e5e407ed597fc Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Sat, 17 Jan 2026 12:17:20 +0700 Subject: [PATCH] Support documents table diff and refresh (#10411) * Add metadata for markdown tables Signed-off-by: Artem Savchenko * Support table diff and refresh Signed-off-by: Artem Savchenko * Fix warnings and icons Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko --- models/text-editor/src/plugin.ts | 4 + .../components/document/EditDocContent.svelte | 3 + plugins/text-editor-assets/assets/icons.svg | 14 + plugins/text-editor-assets/lang/cs.json | 5 + plugins/text-editor-assets/lang/de.json | 5 + plugins/text-editor-assets/lang/en.json | 5 + plugins/text-editor-assets/lang/es.json | 5 + plugins/text-editor-assets/lang/fr.json | 5 + plugins/text-editor-assets/lang/it.json | 5 + plugins/text-editor-assets/lang/ja.json | 6 +- plugins/text-editor-assets/lang/pt.json | 5 + plugins/text-editor-assets/lang/ru.json | 5 + plugins/text-editor-assets/lang/tr.json | 5 + plugins/text-editor-assets/lang/zh.json | 6 +- plugins/text-editor-assets/src/index.ts | 3 + .../src/components/CollaborationUsers.svelte | 2 +- .../src/components/MentionPopup.svelte | 4 +- .../src/components/StyledTextBox.svelte | 4 +- .../src/components/TextActionButton.svelte | 5 +- .../extension/shortcuts/tablePaste.ts | 211 ++++++++++++ .../table/actions/TableDiffViewer.svelte | 58 ++++ .../extension/table/actions/index.ts | 17 + .../extension/table/actions/refreshTable.ts | 127 ++++++++ .../table/actions/seeOriginalTableData.ts | 29 ++ .../extension/table/actions/showTableDiff.ts | 164 ++++++++++ .../extension/table/refreshTable.ts | 44 +++ .../src/components/extension/table/table.ts | 114 ++++++- .../extension/table/tableMetadata.ts | 76 +++++ .../extension/toolbar/EditorToolbar.svelte | 4 +- .../toc/TableOfContentsContent.svelte | 4 +- plugins/text-editor-resources/src/index.ts | 10 +- .../src/kits/editor-kit.ts | 2 + plugins/text-editor/src/plugin.ts | 9 + .../src/__tests__/copyAsMarkdownTable.test.ts | 74 +++-- plugins/view-resources/src/actionImpl.ts | 99 +++++- .../view-resources/src/copyAsMarkdownTable.ts | 307 ++++++++++++------ plugins/view-resources/src/index.ts | 10 +- plugins/view/src/index.ts | 8 +- plugins/view/src/types.ts | 11 + 39 files changed, 1307 insertions(+), 167 deletions(-) create mode 100644 plugins/text-editor-resources/src/components/extension/shortcuts/tablePaste.ts create mode 100644 plugins/text-editor-resources/src/components/extension/table/actions/TableDiffViewer.svelte create mode 100644 plugins/text-editor-resources/src/components/extension/table/actions/index.ts create mode 100644 plugins/text-editor-resources/src/components/extension/table/actions/refreshTable.ts create mode 100644 plugins/text-editor-resources/src/components/extension/table/actions/seeOriginalTableData.ts create mode 100644 plugins/text-editor-resources/src/components/extension/table/actions/showTableDiff.ts create mode 100644 plugins/text-editor-resources/src/components/extension/table/refreshTable.ts create mode 100644 plugins/text-editor-resources/src/components/extension/table/tableMetadata.ts diff --git a/models/text-editor/src/plugin.ts b/models/text-editor/src/plugin.ts index 6653ef41c5..e654b6dda6 100644 --- a/models/text-editor/src/plugin.ts +++ b/models/text-editor/src/plugin.ts @@ -27,6 +27,9 @@ export default mergeIds(textEditorId, textEditor, { FormatLink: '' as Resource, OpenTableOptions: '' as Resource, SelectTable: '' as Resource, + RefreshTable: '' as Resource, + ShowTableDiff: '' as Resource, + SeeOriginalTableData: '' as Resource, OpenImage: '' as Resource, ExpandImage: '' as Resource, MoreImageActions: '' as Resource, @@ -35,6 +38,7 @@ export default mergeIds(textEditorId, textEditor, { IsEditableTableActive: '' as Resource, IsTableToolbarContext: '' as Resource, + IsRefreshableTableActive: '' as Resource, IsEditableNote: '' as Resource, IsEditable: '' as Resource, IsTextStylingEnabled: '' as Resource, diff --git a/plugins/controlled-documents-resources/src/components/document/EditDocContent.svelte b/plugins/controlled-documents-resources/src/components/document/EditDocContent.svelte index f11616fca7..59731ba758 100644 --- a/plugins/controlled-documents-resources/src/components/document/EditDocContent.svelte +++ b/plugins/controlled-documents-resources/src/components/document/EditDocContent.svelte @@ -258,6 +258,9 @@ } } }, + shortcuts: { + tableMetadataPaste: true + }, toc: { onChange: (h) => { headings = h diff --git a/plugins/text-editor-assets/assets/icons.svg b/plugins/text-editor-assets/assets/icons.svg index 3a49b4ec2b..7abfc69e21 100644 --- a/plugins/text-editor-assets/assets/icons.svg +++ b/plugins/text-editor-assets/assets/icons.svg @@ -236,4 +236,18 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/cs.json b/plugins/text-editor-assets/lang/cs.json index 19d4e28d76..4f73be70ca 100644 --- a/plugins/text-editor-assets/lang/cs.json +++ b/plugins/text-editor-assets/lang/cs.json @@ -70,6 +70,11 @@ "AddCommentPlaceholder": "Přidat komentář...", "SetCellHighlightColor": "Nastavit barvu buňky", "SetTextColor": "Nastavit barvu textu", + "RefreshTable": "Obnovit tabulku", + "ShowDiff": "Zobrazit rozdíly", + "SeeOriginalData": "Zobrazit původní data", + "CategoryVersioning": "Verzování", + "TableDiffLabel": "Změny v tabulce", "ConvertToLinkPreview": "Zobrazit jako odkaz", "ConvertToEmbedPreview": "Zobrazit jako náhled obsahu", "UnableToLoadEmbeddedContent": "Náhled odkazu nelze načíst kvůli nastavení oprávnění nebo nepodporovanému obsahu", diff --git a/plugins/text-editor-assets/lang/de.json b/plugins/text-editor-assets/lang/de.json index b774ae53e5..7f6d09a85e 100644 --- a/plugins/text-editor-assets/lang/de.json +++ b/plugins/text-editor-assets/lang/de.json @@ -69,6 +69,11 @@ "AddCommentPlaceholder": "Fügen Sie einen Kommentar hinzu...", "SetCellHighlightColor": "Zellfarbe ändern", "SetTextColor": "Textfarbe ändern", + "RefreshTable": "Tabelle aktualisieren", + "ShowDiff": "Unterschiede anzeigen", + "SeeOriginalData": "Originaldaten anzeigen", + "CategoryVersioning": "Versionierung", + "TableDiffLabel": "Tabellenänderungen", "ConvertToLinkPreview": "Als Link anzeigen", "ConvertToEmbedPreview": "Als Inhaltsvorschau anzeigen", "UnableToLoadEmbeddedContent": "Die Linkvorschau konnte aufgrund von Berechtigungseinstellungen oder nicht unterstütztem Inhalt nicht geladen werden", diff --git a/plugins/text-editor-assets/lang/en.json b/plugins/text-editor-assets/lang/en.json index a39402c8db..04b94891c6 100644 --- a/plugins/text-editor-assets/lang/en.json +++ b/plugins/text-editor-assets/lang/en.json @@ -70,6 +70,11 @@ "AddCommentPlaceholder": "Add a comment...", "SetCellHighlightColor": "Set cell color", "SetTextColor": "Set text color", + "RefreshTable": "Refresh Table", + "ShowDiff": "Show Diff", + "SeeOriginalData": "See Original Data", + "CategoryVersioning": "Versioning", + "TableDiffLabel": "Table Changes", "ConvertToLinkPreview": "Show as a link", "ConvertToEmbedPreview": "Show as a content preview", diff --git a/plugins/text-editor-assets/lang/es.json b/plugins/text-editor-assets/lang/es.json index 06023b102f..6c3b570643 100644 --- a/plugins/text-editor-assets/lang/es.json +++ b/plugins/text-editor-assets/lang/es.json @@ -60,6 +60,11 @@ "TodoList": "Lista de tareas", "TodoItem": "Tarea pendiente", "DrawingBoard": "Tablero de dibujos", + "RefreshTable": "Actualizar tabla", + "ShowDiff": "Mostrar diferencias", + "SeeOriginalData": "Ver datos originales", + "CategoryVersioning": "Control de versiones", + "TableDiffLabel": "Cambios en la tabla", "ConvertToLinkPreview": "Mostrar como enlace", "ConvertToEmbedPreview": "Mostrar como vista previa de contenido", "UnableToLoadEmbeddedContent": "No se pudo cargar la vista previa del enlace debido a la configuración de permisos o contenido no compatible", diff --git a/plugins/text-editor-assets/lang/fr.json b/plugins/text-editor-assets/lang/fr.json index d13f25a7c9..140cd368c2 100644 --- a/plugins/text-editor-assets/lang/fr.json +++ b/plugins/text-editor-assets/lang/fr.json @@ -60,6 +60,11 @@ "Image": "Image", "SeparatorLine": "Ligne de séparation", "DrawingBoard": "Tableau de dessin", + "RefreshTable": "Actualiser le tableau", + "ShowDiff": "Afficher les différences", + "SeeOriginalData": "Voir les données originales", + "CategoryVersioning": "Gestion de versions", + "TableDiffLabel": "Modifications du tableau", "ConvertToLinkPreview": "Afficher comme lien", "ConvertToEmbedPreview": "Afficher comme aperçu de contenu", "UnableToLoadEmbeddedContent": "L’aperçu du lien n’a pas pu être chargé en raison des paramètres d’autorisation ou d’un contenu non pris en charge", diff --git a/plugins/text-editor-assets/lang/it.json b/plugins/text-editor-assets/lang/it.json index d52c1e1def..7eb6bf0f98 100644 --- a/plugins/text-editor-assets/lang/it.json +++ b/plugins/text-editor-assets/lang/it.json @@ -69,6 +69,11 @@ "AddCommentPlaceholder": "Aggiungi un commento...", "SetCellHighlightColor": "Cambia il colore delle celle", "SetTextColor": "Cambia il colore del testo", + "RefreshTable": "Aggiorna tabella", + "ShowDiff": "Mostra differenze", + "SeeOriginalData": "Vedi dati originali", + "CategoryVersioning": "Versionamento", + "TableDiffLabel": "Modifiche alla tabella", "ConvertToLinkPreview": "Mostra come link", "ConvertToEmbedPreview": "Mostra come anteprima del contenuto", "UnableToLoadEmbeddedContent": "Impossibile caricare l'anteprima del link a causa delle impostazioni dei permessi o di contenuto non supportato", diff --git a/plugins/text-editor-assets/lang/ja.json b/plugins/text-editor-assets/lang/ja.json index 2cded209d2..e7e368b214 100644 --- a/plugins/text-editor-assets/lang/ja.json +++ b/plugins/text-editor-assets/lang/ja.json @@ -70,7 +70,11 @@ "AddCommentPlaceholder": "コメントを追加...", "SetCellHighlightColor": "セルの色を設定", "SetTextColor": "テキストの色を設定", - + "RefreshTable": "表を更新", + "ShowDiff": "差分を表示", + "SeeOriginalData": "元のデータを表示", + "CategoryVersioning": "バージョン管理", + "TableDiffLabel": "テーブルの変更", "ConvertToLinkPreview": "リンクとして表示", "ConvertToEmbedPreview": "コンテンツプレビューとして表示", "UnableToLoadEmbeddedContent": "リンクのプレビューを読み込めません。権限設定または非対応のコンテンツが原因です", diff --git a/plugins/text-editor-assets/lang/pt.json b/plugins/text-editor-assets/lang/pt.json index 61979657bf..d3879902d7 100644 --- a/plugins/text-editor-assets/lang/pt.json +++ b/plugins/text-editor-assets/lang/pt.json @@ -60,6 +60,11 @@ "TodoItem": "Tarefa", "TodoList": "Lista de tarefas", "DrawingBoard": "Quadro de desenho", + "RefreshTable": "Atualizar tabela", + "ShowDiff": "Mostrar diferenças", + "SeeOriginalData": "Ver dados originais", + "CategoryVersioning": "Versionamento", + "TableDiffLabel": "Alterações na tabela", "ConvertToLinkPreview": "Mostrar como link", "ConvertToEmbedPreview": "Mostrar como pré-visualização de conteúdo", "UnableToLoadEmbeddedContent": "Não foi possível carregar a pré-visualização do link devido às permissões ou a conteúdo não suportado", diff --git a/plugins/text-editor-assets/lang/ru.json b/plugins/text-editor-assets/lang/ru.json index c4ccf5b755..68fc4cf7eb 100644 --- a/plugins/text-editor-assets/lang/ru.json +++ b/plugins/text-editor-assets/lang/ru.json @@ -70,6 +70,11 @@ "AddCommentPlaceholder": "Добавьте комментарий...", "SetCellHighlightColor": "Изменить цвет ячеек", "SetTextColor": "Изменить цвет текста", + "RefreshTable": "Обновить таблицу", + "ShowDiff": "Показать различия", + "SeeOriginalData": "Показать исходные данные", + "CategoryVersioning": "Версионирование", + "TableDiffLabel": "Изменения в таблице", "ConvertToLinkPreview": "Показать как ссылку", "ConvertToEmbedPreview": "Показать как превью контента", "UnableToLoadEmbeddedContent": "Не удалось загрузить превью ссылки из-за настроек доступа или неподдерживаемого содержимого", diff --git a/plugins/text-editor-assets/lang/tr.json b/plugins/text-editor-assets/lang/tr.json index 87256b6b14..7a8c66bba8 100644 --- a/plugins/text-editor-assets/lang/tr.json +++ b/plugins/text-editor-assets/lang/tr.json @@ -70,6 +70,11 @@ "AddCommentPlaceholder": "Yorum ekle...", "SetCellHighlightColor": "Hücre rengini ayarla", "SetTextColor": "Metin rengini ayarla", + "RefreshTable": "Tabloyu yenile", + "ShowDiff": "Farkları göster", + "SeeOriginalData": "Orijinal verileri görüntüle", + "CategoryVersioning": "Sürümleme", + "TableDiffLabel": "Tablo değişiklikleri", "ConvertToLinkPreview": "Bağlantı olarak göster", "ConvertToEmbedPreview": "İçerik önizlemesi olarak göster", "UnableToLoadEmbeddedContent": "Bağlantı önizlemesi izin ayarları veya desteklenmeyen içerik nedeniyle yüklenemedi", diff --git a/plugins/text-editor-assets/lang/zh.json b/plugins/text-editor-assets/lang/zh.json index 3e63c1bee5..287bf737d8 100644 --- a/plugins/text-editor-assets/lang/zh.json +++ b/plugins/text-editor-assets/lang/zh.json @@ -62,7 +62,11 @@ "TodoItem": "待办事项", "TodoList": "待办事项列表", "DrawingBoard": "画板", - + "RefreshTable": "刷新表格", + "ShowDiff": "显示差异", + "SeeOriginalData": "查看原始数据", + "CategoryVersioning": "版本控制", + "TableDiffLabel": "表格变更", "ConvertToLinkPreview": "显示为链接", "ConvertToEmbedPreview": "显示为内容预览", "UnableToLoadEmbeddedContent": "由于权限设置或不支持的内容,无法加载链接预览", diff --git a/plugins/text-editor-assets/src/index.ts b/plugins/text-editor-assets/src/index.ts index 26edb96031..0ab87536ce 100644 --- a/plugins/text-editor-assets/src/index.ts +++ b/plugins/text-editor-assets/src/index.ts @@ -48,5 +48,8 @@ loadMetadata(textEditor.icon, { Brush: `${icons}#brush`, TextStyle: `${icons}#textStyle`, LinkPreview: `${icons}#link`, + Refresh: `${icons}#refresh`, + ShowDiff: `${icons}#showDiff`, + SeeOriginalData: `${icons}#seeOriginal`, EmbedPreview: `${icons}#linkEmbed` }) diff --git a/plugins/text-editor-resources/src/components/CollaborationUsers.svelte b/plugins/text-editor-resources/src/components/CollaborationUsers.svelte index 717e38c8e3..d04f3f6c1c 100644 --- a/plugins/text-editor-resources/src/components/CollaborationUsers.svelte +++ b/plugins/text-editor-resources/src/components/CollaborationUsers.svelte @@ -37,7 +37,7 @@ const entries: Array<[number, AwarenessState]> = Array.from(map.entries()) states = entries .filter(([clientId, state]) => clientId !== provider.awareness?.clientID && state.user != null) - .map(([_, state]) => state) + .map(([, state]) => state) }) } diff --git a/plugins/text-editor-resources/src/components/MentionPopup.svelte b/plugins/text-editor-resources/src/components/MentionPopup.svelte index 7029a93f3f..af6f6666f4 100644 --- a/plugins/text-editor-resources/src/components/MentionPopup.svelte +++ b/plugins/text-editor-resources/src/components/MentionPopup.svelte @@ -50,7 +50,9 @@ if (employeeSearchCategory === undefined) return [] const clazz = - docClass && client.getHierarchy().hasClass(docClass) ? client.getHierarchy().getClass(docClass) : undefined + docClass != null && client.getHierarchy().hasClass(docClass) + ? client.getHierarchy().getClass(docClass) + : undefined const docTitle = await translate(clazz?.label ?? core.string.Object, {}) const everyoneDescription = await translate(contact.string.EveryoneDescription, { diff --git a/plugins/text-editor-resources/src/components/StyledTextBox.svelte b/plugins/text-editor-resources/src/components/StyledTextBox.svelte index 663c15815f..26c28ffd97 100644 --- a/plugins/text-editor-resources/src/components/StyledTextBox.svelte +++ b/plugins/text-editor-resources/src/components/StyledTextBox.svelte @@ -125,7 +125,7 @@ export let focusIndex = -1 const { idx, focusManager } = registerFocus(focusIndex, { focus: () => { - const editable = editor?.isEditable() ?? false + const editable: boolean = editor != null ? editor.isEditable() : false if (editable) { focused = true focus() @@ -179,7 +179,7 @@ targetItem instanceof MouseEvent ? getEventPositionElement(targetItem) : getPopupPositionElement(targetItem) } - addTableHandler(editor.editorHandler.insertTable, position) + void addTableHandler(editor.editorHandler.insertTable, position) break } case 'code-block': diff --git a/plugins/text-editor-resources/src/components/TextActionButton.svelte b/plugins/text-editor-resources/src/components/TextActionButton.svelte index 6b45bfc55c..df93e82ff5 100644 --- a/plugins/text-editor-resources/src/components/TextActionButton.svelte +++ b/plugins/text-editor-resources/src/components/TextActionButton.svelte @@ -18,7 +18,6 @@ import { type TextEditorAction, type ActionContext } from '@hcengineering/text-editor' import { getResource } from '@hcengineering/platform' import { Icon, IconSize, tooltip, type LabelAndProps } from '@hcengineering/ui' - import tr from 'date-fns/locale/tr' import { Transaction } from '@tiptap/pm/state' export let action: TextEditorAction @@ -34,7 +33,7 @@ $: void updateSelected(editor, action) if (listenCursorUpdate) { - const listener = ({ transaction }: { transaction: Transaction }) => { + const listener = ({ transaction }: { transaction: Transaction }): void => { if (transaction.getMeta('contextCursorUpdate') === true) { void updateSelected(editor, action) } @@ -76,7 +75,7 @@ const { command, params } = handler const cmd = (editor.commands as any)[command] - if (cmd) { + if (cmd !== null && cmd !== undefined) { cmd(params) } } diff --git a/plugins/text-editor-resources/src/components/extension/shortcuts/tablePaste.ts b/plugins/text-editor-resources/src/components/extension/shortcuts/tablePaste.ts new file mode 100644 index 0000000000..e824bc598f --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/shortcuts/tablePaste.ts @@ -0,0 +1,211 @@ +// +// 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 { markdownToMarkup } from '@hcengineering/text-markdown' +import { Extension } from '@tiptap/core' +import { Fragment, Node } from '@tiptap/pm/model' +import { Plugin } from '@tiptap/pm/state' + +// TableMetadata type - matches the definition in @hcengineering/view-resources +// Defined here to avoid circular dependency (view-resources depends on text-editor-resources) +interface TableMetadata { + version: string + cardClass: string + viewletId?: string + config?: Array> + query?: Record + documentIds: string[] + timestamp: number + workspace?: string +} + +export const TableMetadataPasteExtension = Extension.create({ + name: 'tableMetadataPaste', + + addProseMirrorPlugins () { + return [TableMetadataPastePlugin()] + } +}) + +/** + * Extract metadata from HTML comments in markdown or HTML text + * Looks for pattern: + * Returns both the metadata and the text with comment removed + */ +function extractMetadataFromHtmlComments (text: string): { metadata: TableMetadata | null, cleanedText: string } { + // Look for HTML comment with pattern: + const commentRegex = //s + const match = text.match(commentRegex) + if (match?.[1] !== undefined) { + try { + const metadata = JSON.parse(match[1]) as TableMetadata + // Remove the HTML comment from the text + const cleanedText = text.replace(commentRegex, '').trim() + return { metadata, cleanedText } + } catch (e) { + console.warn('Failed to parse metadata from HTML comment:', e) + } + } + return { metadata: null, cleanedText: text } +} + +function TableMetadataPastePlugin (): Plugin { + return new Plugin({ + props: { + handlePaste: (view, event, slice) => { + const clipboardData = event.clipboardData + if (clipboardData === null) return false + + // Try to get metadata from multiple sources (priority order) + let metadata: TableMetadata | null = null + + // 1. Try custom MIME type (fastest, most reliable for internal paste) + const metadataType = 'application/x-huly-table-metadata' + if (clipboardData.types.includes(metadataType)) { + try { + const metadataJsonStr = clipboardData.getData(metadataType) + metadata = JSON.parse(metadataJsonStr) as TableMetadata + } catch (e) { + console.warn('Failed to parse metadata from MIME type:', e) + } + } + + // Track cleaned markdown text (with HTML comments removed) + let cleanedMarkdown: string | null = null + + // 2. Try HTML comments in markdown (fallback, works across browsers) + if (metadata === null) { + const markdownText = clipboardData.getData('text/markdown') + if (markdownText?.length > 0) { + const result = extractMetadataFromHtmlComments(markdownText) + if (result.metadata !== null) { + metadata = result.metadata + cleanedMarkdown = result.cleanedText + } + } + } + + // 3. Try HTML comments in plain text (for old browsers that only provide text/plain) + if (metadata === null) { + const plainText = clipboardData.getData('text/plain') + if (plainText?.length > 0) { + const result = extractMetadataFromHtmlComments(plainText) + if (result.metadata !== null) { + metadata = result.metadata + cleanedMarkdown = result.cleanedText + } + } + } + + if (metadata === null) { + return false // Not our table, let other handlers process + } + + try { + // Get markdown or plain text content, using cleaned version if available + let markdown: string + if (cleanedMarkdown !== null) { + // Use cleaned markdown (HTML comment already removed) + markdown = cleanedMarkdown + } else { + // Metadata came from custom MIME type, but markdown might still have HTML comment + const markdownText = clipboardData.getData('text/markdown') + const plainText = clipboardData.getData('text/plain') + + if (markdownText !== '' && markdownText.length > 0) { + markdown = markdownText + } else if (plainText !== '' && plainText.length > 0) { + markdown = plainText + } else { + return false + } + + // Remove HTML comment if present (markdown might have it even if metadata came from MIME type) + const result = extractMetadataFromHtmlComments(markdown) + markdown = result.cleanedText + } + + if (markdown.length === 0) { + return false + } + + // Check if we're in a code block (don't process tables there) + const { $from } = view.state.selection + for (let d = $from.depth; d > 0; d--) { + const node = $from.node(d) + if (node.type.name === 'codeBlock') { + return false // Paste as plain text in code blocks + } + } + + // Parse markdown to ProseMirror nodes + const markupNode = markdownToMarkup(markdown) + const content = Node.fromJSON(view.state.schema, markupNode) + + // Check if the content contains a table + let hasTable = false + content.descendants((node) => { + if (node.type.name === 'table') { + hasTable = true + return false // Stop after first table + } + }) + + if (hasTable) { + const metadataAttr = JSON.stringify(metadata) + + // Rebuild content with metadata in table nodes + const rebuildNode = (node: Node): Node => { + // Text nodes cannot be recreated, return as-is + if (node.isText) { + return node + } + + if (node.type.name === 'table') { + const newAttrs = { ...node.attrs, tableMetadata: metadataAttr } + const newContent = rebuildFragment(node.content) + return node.type.create(newAttrs, newContent, node.marks) + } + + // For other nodes, rebuild content but keep original attributes and marks + const newContent = rebuildFragment(node.content) + return node.type.create(node.attrs, newContent, node.marks) + } + + const rebuildFragment = (fragment: Fragment): Fragment => { + if (fragment.size === 0) { + return fragment + } + const nodes: Node[] = [] + fragment.forEach((node) => { + nodes.push(rebuildNode(node)) + }) + return Fragment.fromArray(nodes) + } + + const modifiedContent = rebuildNode(content) + const transaction = view.state.tr.replaceSelectionWith(modifiedContent) + view.dispatch(transaction) + return true // Handled + } + } catch (e) { + console.warn('Failed to parse table metadata:', e) + // Fall back to normal paste + } + + return false + } + } + }) +} diff --git a/plugins/text-editor-resources/src/components/extension/table/actions/TableDiffViewer.svelte b/plugins/text-editor-resources/src/components/extension/table/actions/TableDiffViewer.svelte new file mode 100644 index 0000000000..83f53ea9ca --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/table/actions/TableDiffViewer.svelte @@ -0,0 +1,58 @@ + + + + +
+ +
+
+ + diff --git a/plugins/text-editor-resources/src/components/extension/table/actions/index.ts b/plugins/text-editor-resources/src/components/extension/table/actions/index.ts new file mode 100644 index 0000000000..37fd18a9bb --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/table/actions/index.ts @@ -0,0 +1,17 @@ +// +// 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. +// + +export { refreshTable } from './refreshTable' +export { showTableDiff } from './showTableDiff' +export { seeOriginalTableData } from './seeOriginalTableData' diff --git a/plugins/text-editor-resources/src/components/extension/table/actions/refreshTable.ts b/plugins/text-editor-resources/src/components/extension/table/actions/refreshTable.ts new file mode 100644 index 0000000000..1c2c688783 --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/table/actions/refreshTable.ts @@ -0,0 +1,127 @@ +// +// 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 Editor } from '@tiptap/core' +import { Node } from '@tiptap/pm/model' +import { markdownToMarkup } from '@hcengineering/text-markdown' +import { getClient } from '@hcengineering/presentation' +import { buildMarkdownTableFromDocs } from '../refreshTable' +import { findTable } from '../utils' +import { getTableMetadata } from '../tableMetadata' + +/** + * Refresh a table by re-executing its query and rebuilding the table content + */ +export async function refreshTable (editor: Editor): Promise { + const table = findTable(editor.state.selection) + if (table === undefined) { + console.warn('No table found to refresh') + return + } + + const metadata = getTableMetadata(table.node) + if (metadata === null || metadata === undefined) { + console.warn('Table has no metadata to refresh') + return + } + + try { + const client = getClient() + const hierarchy = client.getHierarchy() + + // Convert string cardClass to Ref> and validate + const cardClassRef = metadata.cardClass as any + const cardClass = hierarchy.getClass(cardClassRef) + if (cardClass == null) { + console.warn('Invalid cardClass in table metadata:', metadata.cardClass) + return + } + + // Build query: use existing query if available, otherwise build from documentIds + let query: any + let useDocumentIdsOrder = false + if (metadata.query !== null && metadata.query !== undefined) { + query = metadata.query + } else if (metadata.documentIds !== undefined && metadata.documentIds.length > 0) { + // Build query from document IDs + query = { _id: { $in: metadata.documentIds } } + useDocumentIdsOrder = true + } else { + console.warn('Table metadata has no query or documentIds to execute') + return + } + + // Execute query to fetch fresh documents + let docs = await client.findAll(cardClassRef, query) + + // Sort by documentIds order if query was built from documentIds + if (useDocumentIdsOrder && metadata.documentIds !== undefined) { + const idIndexMap = new Map(metadata.documentIds.map((id, index) => [id, index])) + docs = docs.sort((a, b) => { + const indexA = idIndexMap.get(a._id) ?? Infinity + const indexB = idIndexMap.get(b._id) ?? Infinity + return indexA - indexB + }) + } + + if (docs.length === 0) { + console.warn('Query returned no documents') + // Optionally show empty table or keep existing + return + } + + // Build markdown table from fresh documents + const markdown = await buildMarkdownTableFromDocs(docs, metadata, client) + + if (markdown.length === 0) { + console.warn('Failed to build markdown table') + return + } + + // Convert markdown to ProseMirror nodes + const markupNode = markdownToMarkup(markdown) + const content = Node.fromJSON(editor.state.schema, markupNode) + + // Find the table node in the parsed content + let newTableNode: Node | null = null + content.descendants((node: Node) => { + if (node.type.name === 'table' && newTableNode === null) { + newTableNode = node + return false // Stop after first table + } + }) + + if (newTableNode === null) { + console.warn('Failed to parse table from markdown') + return + } + + // Preserve metadata in the new table node + // TypeScript needs explicit type assertion here + const tableNode: Node = newTableNode + const metadataAttr = JSON.stringify(metadata) + const newAttrs = { ...tableNode.attrs, tableMetadata: metadataAttr } + const tableWithMetadata = tableNode.type.create(newAttrs, tableNode.content, tableNode.marks) + + // Replace the old table with the new one + const tr = editor.state.tr + tr.replaceWith(table.pos, table.pos + table.node.nodeSize, tableWithMetadata) + + // Dispatch the transaction + editor.view.dispatch(tr) + } catch (error) { + console.error('Failed to refresh table:', error) + // Error is logged, user can retry if needed + } +} diff --git a/plugins/text-editor-resources/src/components/extension/table/actions/seeOriginalTableData.ts b/plugins/text-editor-resources/src/components/extension/table/actions/seeOriginalTableData.ts new file mode 100644 index 0000000000..4d09bfedcc --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/table/actions/seeOriginalTableData.ts @@ -0,0 +1,29 @@ +// +// 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 Editor } from '@tiptap/core' +import { findTable } from '../utils' +import { getTableMetadata } from '../tableMetadata' + +/** + * Show original table data from when it was first created + */ +export async function seeOriginalTableData (editor: Editor): Promise { + const table = findTable(editor.state.selection) + if (table === undefined) return + const metadata = getTableMetadata(table.node) + if (metadata === null || metadata === undefined) return + // Empty handler for now + console.log('See original data:', metadata) +} diff --git a/plugins/text-editor-resources/src/components/extension/table/actions/showTableDiff.ts b/plugins/text-editor-resources/src/components/extension/table/actions/showTableDiff.ts new file mode 100644 index 0000000000..a6de74d785 --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/table/actions/showTableDiff.ts @@ -0,0 +1,164 @@ +// +// 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 Editor } from '@tiptap/core' +import { type Node } from '@tiptap/pm/model' +import { TableMap } from '@tiptap/pm/tables' +import { getClient } from '@hcengineering/presentation' +import { showPopup } from '@hcengineering/ui' +import { buildMarkdownTableFromDocs } from '../refreshTable' +import { findTable } from '../utils' +import { getTableMetadata } from '../tableMetadata' +import TableDiffViewer from './TableDiffViewer.svelte' + +/** + * Extract markdown string from a ProseMirror table node + */ +function extractTableMarkdown (tableNode: Node): string { + try { + const map = TableMap.get(tableNode) + const { width, height } = map + + // Track visited cells to handle rowspan/colspan + const visitedCells = new Set() + const rows: string[][] = [] + + // Extract all rows + for (let row = 0; row < height; row++) { + const rowCells: string[] = [] + for (let col = 0; col < width; col++) { + const pos = map.map[row * width + col] + if (!visitedCells.has(pos)) { + const cell = tableNode.nodeAt(pos) + if (cell !== null) { + rowCells.push(cell.textContent.trim() ?? '') + } else { + rowCells.push('') + } + visitedCells.add(pos) + } else { + // Cell already processed (rowspan/colspan), use empty or previous value + rowCells.push('') + } + } + rows.push(rowCells) + } + + if (rows.length === 0) { + return '' + } + + // Build markdown table + // First row is headers + const headerRow = rows[0] + let markdown = '| ' + headerRow.join(' | ') + ' |\n' + markdown += '| ' + headerRow.map(() => '---').join(' | ') + ' |\n' + + // Data rows + for (let i = 1; i < rows.length; i++) { + markdown += '| ' + rows[i].join(' | ') + ' |\n' + } + + return markdown + } catch (error) { + console.warn('Failed to extract table markdown:', error) + return '' + } +} + +/** + * Show diff between current table content and fresh data from database + */ +export async function showTableDiff (editor: Editor): Promise { + const table = findTable(editor.state.selection) + if (table === undefined) { + console.warn('No table found to show diff') + return + } + + const metadata = getTableMetadata(table.node) + if (metadata === null || metadata === undefined) { + console.warn('Table has no metadata to show diff') + return + } + + try { + // Extract current table as markdown + const currentTableMarkdown = extractTableMarkdown(table.node) + + // Generate fresh table version using the same logic as refreshTable + const client = getClient() + const hierarchy = client.getHierarchy() + + // Convert string cardClass to Ref> and validate + const cardClassRef = metadata.cardClass as any + const cardClass = hierarchy.getClass(cardClassRef) + if (cardClass == null) { + console.warn('Invalid cardClass in table metadata:', metadata.cardClass) + return + } + + // Build query: use existing query if available, otherwise build from documentIds + let query: any + let useDocumentIdsOrder = false + if (metadata.query !== null && metadata.query !== undefined) { + query = metadata.query + } else if (metadata.documentIds !== undefined && metadata.documentIds.length > 0) { + // Build query from document IDs + query = { _id: { $in: metadata.documentIds } } + useDocumentIdsOrder = true + } else { + console.warn('Table metadata has no query or documentIds to execute') + return + } + + // Execute query to fetch fresh documents + let docs = await client.findAll(cardClassRef, query) + + // Sort by documentIds order if query was built from documentIds + if (useDocumentIdsOrder && metadata.documentIds !== undefined) { + const idIndexMap = new Map(metadata.documentIds.map((id, index) => [id, index])) + docs = docs.sort((a, b) => { + const indexA = idIndexMap.get(a._id) ?? Infinity + const indexB = idIndexMap.get(b._id) ?? Infinity + return indexA - indexB + }) + } + + if (docs.length === 0) { + console.warn('Query returned no documents') + return + } + + // Build markdown table from fresh documents + const freshTableMarkdown = await buildMarkdownTableFromDocs(docs, metadata, client) + + if (freshTableMarkdown.length === 0) { + console.warn('Failed to build markdown table') + return + } + + // Show diff in popup + showPopup( + TableDiffViewer, + { + oldMarkdown: currentTableMarkdown, + newMarkdown: freshTableMarkdown + }, + 'center' + ) + } catch (error) { + console.error('Failed to show table diff:', error) + } +} diff --git a/plugins/text-editor-resources/src/components/extension/table/refreshTable.ts b/plugins/text-editor-resources/src/components/extension/table/refreshTable.ts new file mode 100644 index 0000000000..56741bcd6a --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/table/refreshTable.ts @@ -0,0 +1,44 @@ +// +// 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 } from '@hcengineering/core' +import { getResource } from '@hcengineering/platform' +import view, { type BuildMarkdownTableMetadata } from '@hcengineering/view' +import type { TableMetadata } from './tableMetadata' + +/** + * Build markdown table string from documents and metadata + * Uses the extension point function from view-resources via view plugin + */ +export async function buildMarkdownTableFromDocs ( + docs: Doc[], + metadata: TableMetadata, + client: Client +): Promise { + try { + const buildFunction = await getResource(view.function.BuildMarkdownTableFromDocs) + // Extract only the BuildMarkdownTableMetadata fields from TableMetadata + const buildMetadata: BuildMarkdownTableMetadata = { + cardClass: metadata.cardClass, + viewletId: metadata.viewletId, + config: metadata.config, + query: metadata.query + } + return await buildFunction(docs, buildMetadata, client) + } catch (error) { + // Function not available (view-resources not loaded) + console.warn('BuildMarkdownTableFromDocs function not available:', error) + return '' + } +} diff --git a/plugins/text-editor-resources/src/components/extension/table/table.ts b/plugins/text-editor-resources/src/components/extension/table/table.ts index 433c1a3486..4917dbb3a4 100644 --- a/plugins/text-editor-resources/src/components/extension/table/table.ts +++ b/plugins/text-editor-resources/src/components/extension/table/table.ts @@ -39,10 +39,31 @@ import { import TableNodeView from './TableNodeView.svelte' import { TableSelection } from './types' import { findTable, isTableSelected, selectTable as selectTableNode } from './utils' +import { getTableMetadata } from './tableMetadata' +import { refreshTable, showTableDiff, seeOriginalTableData } from './actions' export const Table = TiptapTable.extend({ draggable: true, + addAttributes () { + return { + ...this.parent?.(), + tableMetadata: { + default: null, + parseHTML: (element) => element.getAttribute('data-table-metadata'), + renderHTML: (attributes) => { + const metadata = attributes.tableMetadata + if (metadata === null || metadata === undefined || metadata === '') { + return {} + } + return { + 'data-table-metadata': metadata + } + } + } + } + }, + addKeyboardShortcuts () { return { Tab: () => { @@ -207,8 +228,66 @@ function handleModDelete (editor: Editor): boolean { return false } +interface TableAction { + id: string + icon?: any + label: any + action: () => boolean | undefined + category?: { + label: any + } +} + export async function openTableOptions (editor: Editor, event: MouseEvent): Promise { - const ops = [ + // Check if table has metadata + const table = findTable(editor.state.selection) + const metadata = table !== undefined ? getTableMetadata(table.node) : null + + const ops: TableAction[] = [] + + // Add refreshable table actions first if metadata exists + if (metadata !== null && metadata !== undefined) { + ops.push( + { + id: '#refreshTable', + icon: textEditor.icon.Refresh, + label: textEditor.string.RefreshTable, + action: () => { + refreshTable(editor).catch(() => {}) + return true + }, + category: { + label: textEditor.string.CategoryVersioning + } + }, + { + id: '#showDiff', + icon: textEditor.icon.ShowDiff, + label: textEditor.string.ShowDiff, + action: () => { + showTableDiff(editor).catch(() => {}) + return true + }, + category: { + label: textEditor.string.CategoryVersioning + } + }, + { + id: '#seeOriginalData', + icon: textEditor.icon.SeeOriginalData, + label: textEditor.string.SeeOriginalData, + action: () => { + seeOriginalTableData(editor).catch(() => {}) + return true + }, + category: { + label: textEditor.string.CategoryVersioning + } + } + ) + } + + ops.push( { id: '#addColumnBefore', icon: AddColBefore, @@ -281,17 +360,19 @@ export async function openTableOptions (editor: Editor, event: MouseEvent): Prom category: { label: textEditor.string.CategoryCell } - }, - { - id: '#deleteTable', - icon: DeleteTable, - label: textEditor.string.DeleteTable, - action: () => editor.commands.deleteTable(), - category: { - label: textEditor.string.Table - } } - ] + ) + + // Add delete table action at the end + ops.push({ + id: '#deleteTable', + icon: DeleteTable, + label: textEditor.string.DeleteTable, + action: () => editor.commands.deleteTable(), + category: { + label: textEditor.string.Table + } + }) await new Promise((resolve) => { showPopup( @@ -312,7 +393,6 @@ export async function openTableOptions (editor: Editor, event: MouseEvent): Prom ) }) } - export async function selectTable (editor: Editor, event: MouseEvent): Promise { const table = findTable(editor.state.selection) if (table === undefined) return @@ -329,3 +409,13 @@ export async function isEditableTableActive (editor: Editor): Promise { export async function isTableToolbarContext (editor: Editor, context: ActionContext): Promise { return editor.isEditable && getTableCursor(editor.state) !== null } + +export async function isRefreshableTableActive (editor: Editor, context: ActionContext): Promise { + if (!editor.isEditable) return false + const table = findTable(editor.state.selection) + if (table === undefined) return false + const metadata = getTableMetadata(table.node) + return metadata !== null && metadata !== undefined +} + +export { refreshTable, showTableDiff, seeOriginalTableData } from './actions' diff --git a/plugins/text-editor-resources/src/components/extension/table/tableMetadata.ts b/plugins/text-editor-resources/src/components/extension/table/tableMetadata.ts new file mode 100644 index 0000000000..3f01b9d7c0 --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/table/tableMetadata.ts @@ -0,0 +1,76 @@ +// +// 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 { Node } from '@tiptap/pm/model' +import type { BuildMarkdownTableMetadata } from '@hcengineering/view' + +// Extended TableMetadata for text editor storage (includes additional fields for persistence) +export interface TableMetadata extends BuildMarkdownTableMetadata { + version: string + documentIds: string[] + timestamp: number + workspace?: string +} + +/** + * Extract table metadata from a ProseMirror table node + * @param node - The table node to extract metadata from + * @returns The table metadata if present, undefined otherwise + */ +export function getTableMetadata (node: Node): TableMetadata | undefined { + if (node.type.name !== 'table') { + return undefined + } + + const metadataAttr = node.attrs?.tableMetadata + if (metadataAttr === undefined || typeof metadataAttr !== 'string') { + return undefined + } + + try { + return JSON.parse(metadataAttr) as TableMetadata + } catch (e) { + console.warn('Failed to parse table metadata:', e) + return undefined + } +} + +/** + * Check if a node has table metadata + * @param node - The node to check + * @returns True if the node is a table with metadata + */ +export function hasTableMetadata (node: Node): boolean { + return getTableMetadata(node) !== undefined +} + +/** + * Extract table metadata from a document by traversing all table nodes + * @param doc - The ProseMirror document to search + * @returns Array of tuples containing [table node, metadata] + */ +export function getAllTableMetadata (doc: Node): Array<{ node: Node, metadata: TableMetadata, pos: number }> { + const results: Array<{ node: Node, metadata: TableMetadata, pos: number }> = [] + + doc.descendants((node, pos) => { + if (node.type.name === 'table') { + const metadata = getTableMetadata(node) + if (metadata !== undefined) { + results.push({ node, metadata, pos }) + } + } + }) + + return results +} diff --git a/plugins/text-editor-resources/src/components/extension/toolbar/EditorToolbar.svelte b/plugins/text-editor-resources/src/components/extension/toolbar/EditorToolbar.svelte index ad16722316..e52bce5cee 100644 --- a/plugins/text-editor-resources/src/components/extension/toolbar/EditorToolbar.svelte +++ b/plugins/text-editor-resources/src/components/extension/toolbar/EditorToolbar.svelte @@ -78,7 +78,7 @@ $: head = cursor?.viewOptions?.head -{#if cursor && actions.length > 0} +{#if cursor != null && actions.length > 0}
{/if} - {#each category as [_, action]} + {#each category as [, action]} dispatch('close', item)} - use:tooltip={{ label: item.titleIntl ? item.titleIntl : getEmbeddedLabel(item.title ?? '') }} + use:tooltip={{ + label: item.titleIntl ?? getEmbeddedLabel(item.title ?? '') + }} >
=> ({ ConfigureNote: configureNote, IsEditableTableActive: isEditableTableActive, IsTableToolbarContext: isTableToolbarContext, + IsRefreshableTableActive: isRefreshableTableActive, + RefreshTable: refreshTable, + ShowTableDiff: showTableDiff, + SeeOriginalTableData: seeOriginalTableData, IsEditableNote: isEditableNote, IsEditable: isEditable, IsHeadingVisible: isHeadingVisible, diff --git a/plugins/text-editor-resources/src/kits/editor-kit.ts b/plugins/text-editor-resources/src/kits/editor-kit.ts index 00e4691375..db441759dc 100644 --- a/plugins/text-editor-resources/src/kits/editor-kit.ts +++ b/plugins/text-editor-resources/src/kits/editor-kit.ts @@ -58,6 +58,7 @@ import { IndentExtension, indentExtensionOptions } from '../components/extension import { LinkKeymapExtension } from '../components/extension/shortcuts/linkKeymap' import { ParagraphKeymapExtension } from '../components/extension/shortcuts/paragraphKeymap' import { SmartPasteExtension } from '../components/extension/shortcuts/smartPaste' +import { TableMetadataPasteExtension } from '../components/extension/shortcuts/tablePaste' import { HandleSubmitExtension } from '../components/extension/shortcuts/handleSubmit' import { Table, TableCell, TableRow } from '../components/extension/table' import { ToCExtension } from '../components/extension/toc' @@ -184,6 +185,7 @@ const subKits = { imageUpload: e(ImageUploadExtension, false), indent: e(IndentExtension, indentExtensionOptions), smartPaste: e(SmartPasteExtension), + tableMetadataPaste: e(TableMetadataPasteExtension), paragraphKeymap: e(ParagraphKeymapExtension, context.mode === 'compact'), linkKeymap: e(LinkKeymapExtension), listKeymap: e(ListKeymapExtension, { diff --git a/plugins/text-editor/src/plugin.ts b/plugins/text-editor/src/plugin.ts index 4eccb16a79..d5a152054f 100644 --- a/plugins/text-editor/src/plugin.ts +++ b/plugins/text-editor/src/plugin.ts @@ -108,6 +108,12 @@ export default plugin(textEditorId, { SetCellHighlightColor: '' as IntlString, SetTextColor: '' as IntlString, + RefreshTable: '' as IntlString, + ShowDiff: '' as IntlString, + SeeOriginalData: '' as IntlString, + CategoryVersioning: '' as IntlString, + TableDiffLabel: '' as IntlString, + ConvertToLinkPreview: '' as IntlString, ConvertToEmbedPreview: '' as IntlString, UnableToLoadEmbeddedContent: '' as IntlString @@ -143,6 +149,9 @@ export default plugin(textEditorId, { Brush: '' as Asset, TextStyle: '' as Asset, LinkPreview: '' as Asset, + Refresh: '' as Asset, + ShowDiff: '' as Asset, + SeeOriginalData: '' as Asset, EmbedPreview: '' as Asset } }) diff --git a/plugins/view-resources/src/__tests__/copyAsMarkdownTable.test.ts b/plugins/view-resources/src/__tests__/copyAsMarkdownTable.test.ts index 62610a80d4..41fe3512ab 100644 --- a/plugins/view-resources/src/__tests__/copyAsMarkdownTable.test.ts +++ b/plugins/view-resources/src/__tests__/copyAsMarkdownTable.test.ts @@ -20,7 +20,7 @@ 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 { copyText } from '../actionImpl' +import { copyMarkdown } from '../actionImpl' import { addNotification } from '@hcengineering/ui' import { buildModel } from '../utils' @@ -48,7 +48,8 @@ jest.mock('@hcengineering/theme', () => ({ })) jest.mock('../actionImpl', () => ({ - copyText: jest.fn() + copyText: jest.fn(), + copyMarkdown: jest.fn() })) jest.mock('@hcengineering/ui', () => ({ @@ -185,7 +186,7 @@ describe('copyAsMarkdownTable', () => { cardClass }) - expect(copyText).not.toHaveBeenCalled() + expect(copyMarkdown).not.toHaveBeenCalled() }) it('should return early if displayableModel is empty', async () => { @@ -197,7 +198,7 @@ describe('copyAsMarkdownTable', () => { cardClass }) - expect(copyText).not.toHaveBeenCalled() + expect(copyMarkdown).not.toHaveBeenCalled() }) it('should copy markdown table and show notification', async () => { @@ -214,7 +215,7 @@ describe('copyAsMarkdownTable', () => { cardClass }) - expect(copyText).toHaveBeenCalled() + expect(copyMarkdown).toHaveBeenCalled() expect(addNotification).toHaveBeenCalledWith( 'translated:view:string:Copied', 'translated:view:string:TableCopiedToClipboard', @@ -223,11 +224,14 @@ describe('copyAsMarkdownTable', () => { 'success' ) - const markdownCall = (copyText as jest.Mock).mock.calls[0][0] - expect(markdownCall).toContain('|') - expect(markdownCall).toContain('---') - expect(markdownCall).toContain('translated:card:string:Card') - expect(markdownCall).toContain('translated:card:string:MasterTag') + 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 () => { @@ -249,9 +253,10 @@ describe('copyAsMarkdownTable', () => { cardClass }) - expect(copyText).toHaveBeenCalled() - const markdownCall = (copyText as jest.Mock).mock.calls[0][0] - expect(markdownCall).toContain('translated:card:types:Document') + 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 () => { @@ -274,9 +279,10 @@ describe('copyAsMarkdownTable', () => { cardClass }) - expect(copyText).toHaveBeenCalled() - const markdownCall = (copyText as jest.Mock).mock.calls[0][0] - const lines = markdownCall.split('\n').filter((line: string) => line.trim().length > 0) + 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) }) @@ -299,13 +305,14 @@ describe('copyAsMarkdownTable', () => { cardClass }) - expect(copyText).toHaveBeenCalled() - const markdownCall = (copyText as jest.Mock).mock.calls[0][0] + 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(markdownCall).toContain('\\|') + 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 = markdownCall.split('\n').filter((line: string) => line.includes('|') && !line.includes('---')) + 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()) @@ -344,18 +351,19 @@ describe('copyAsMarkdownTable', () => { cardClass }) - expect(copyText).toHaveBeenCalled() - const markdownCall = (copyText as jest.Mock).mock.calls[0][0] + 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(markdownCall).toMatch(/\[.*\]\(http:\/\/huly\.local:8080\/.*\)/) - expect(markdownCall).toContain('http://huly.local:8080') + 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(markdownCall).toContain('Test Card') + expect(markdownContent).toContain('Test Card') } }) @@ -380,10 +388,11 @@ describe('copyAsMarkdownTable', () => { cardClass }) - expect(copyText).toHaveBeenCalled() - const markdownCall = (copyText as jest.Mock).mock.calls[0][0] + 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(markdownCall).not.toMatch(/\[.*\]\(http:\/\/.*\)/) + expect(markdownContent).not.toMatch(/\[.*\]\(http:\/\/.*\)/) }) it('should fall back to plain text if link generation fails', async () => { @@ -395,12 +404,13 @@ describe('copyAsMarkdownTable', () => { cardClass }) - expect(copyText).toHaveBeenCalled() - const markdownCall = (copyText as jest.Mock).mock.calls[0][0] + 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(markdownCall).not.toMatch(/\[.*\]\(http:\/\/.*\)/) + expect(markdownContent).not.toMatch(/\[.*\]\(http:\/\/.*\)/) // Should contain the title as plain text - expect(markdownCall).toContain('Test Card') + expect(markdownContent).toContain('Test Card') }) }) diff --git a/plugins/view-resources/src/actionImpl.ts b/plugins/view-resources/src/actionImpl.ts index a88e828848..5c8e6bc8c1 100644 --- a/plugins/view-resources/src/actionImpl.ts +++ b/plugins/view-resources/src/actionImpl.ts @@ -81,22 +81,101 @@ async function CopyTextToClipboard ( export async function copyText (text: any, contentType: string = 'text/plain'): Promise { try { - const clipboardItem = new ClipboardItem({ - 'text/plain': text - }) - await navigator.clipboard.write([clipboardItem]) - } catch { - // Fallback to default clipboard API implementation + // Check if ClipboardItem is available + if (typeof ClipboardItem !== 'undefined') { + const clipboardData: Record = { + [contentType]: text instanceof Promise ? text : Promise.resolve(text) + } + + const clipboardItem = new ClipboardItem(clipboardData) + await navigator.clipboard.write([clipboardItem]) + } else { + // Fallback if ClipboardItem is not available + if (navigator.clipboard != null && typeof navigator.clipboard.writeText === 'function') { + await navigator.clipboard.writeText(text instanceof Promise ? await text : text) + } else { + copyTextToClipboardOldBrowser(text instanceof Promise ? await text : text) + } + } + } catch (error) { + // Log error and fallback: only copy main content + console.error('Failed to copy to clipboard, falling back to plain text:', error) if (navigator.clipboard != null && typeof navigator.clipboard.writeText === 'function') { try { - await navigator.clipboard.writeText(text) - } catch { - copyTextToClipboardOldBrowser(text) + await navigator.clipboard.writeText(text instanceof Promise ? await text : text) + } catch (fallbackError) { + console.error('Failed to copy to clipboard with writeText, using old browser fallback:', fallbackError) + copyTextToClipboardOldBrowser(text instanceof Promise ? await text : text) } - } else copyTextToClipboardOldBrowser(text) + } else { + copyTextToClipboardOldBrowser(text instanceof Promise ? await text : text) + } } } +/** + * Copy markdown text to clipboard with optional metadata for refreshable tables. + * This function is specifically designed for copying markdown tables with metadata + * that enables refresh, diff, and "see original data" functionality in text editors. + * + * @param markdown - The markdown text to copy + * @param metadata - Optional metadata object containing table information (query, config, document IDs, etc.) + */ +export async function copyMarkdown (markdown: string, metadata?: Record): Promise { + // Step 1: Always embed metadata in markdown FIRST (if metadata exists) + let markdownToCopy = markdown + if (metadata !== undefined) { + try { + const metadataComment = `` + // Insert comment before first table (or at start if no table) + const tableIndex = markdown.indexOf('|') + markdownToCopy = + tableIndex !== -1 + ? markdown.slice(0, tableIndex) + metadataComment + '\n' + markdown.slice(tableIndex) + : metadataComment + '\n' + markdown + } catch (e) { + console.error('Failed to embed metadata in markdown:', e) + // Continue with original markdown if embedding fails + } + } + + // Step 2: Try modern ClipboardItem API (with custom MIME type for performance) + try { + if (typeof ClipboardItem !== 'undefined') { + const clipboardData: Record = { + 'text/markdown': Promise.resolve(markdownToCopy) + } + // Add custom MIME type for fast parsing in modern browsers + if (metadata !== undefined) { + try { + clipboardData['application/x-huly-table-metadata'] = Promise.resolve(JSON.stringify(metadata)) + } catch (e) { + console.error('Failed to stringify metadata for custom MIME type:', e) + } + } + + const clipboardItem = new ClipboardItem(clipboardData) + await navigator.clipboard.write([clipboardItem]) + return // Success, exit early + } + } catch (error) { + console.error('Failed to copy with ClipboardItem, falling back:', error) + } + + // Step 3: Fallback to writeText (markdownToCopy already has metadata) + try { + if (navigator.clipboard != null && typeof navigator.clipboard.writeText === 'function') { + await navigator.clipboard.writeText(markdownToCopy) + return // Success, exit early + } + } catch (fallbackError) { + console.error('Failed to copy with writeText, using old browser fallback:', fallbackError) + } + + // Step 4: Final fallback to old browser method (markdownToCopy already has metadata) + copyTextToClipboardOldBrowser(markdownToCopy) +} + function Delete ( object: Doc | Doc[], evt: Event, diff --git a/plugins/view-resources/src/copyAsMarkdownTable.ts b/plugins/view-resources/src/copyAsMarkdownTable.ts index f57eecefaa..ba3e2b09f4 100644 --- a/plugins/view-resources/src/copyAsMarkdownTable.ts +++ b/plugins/view-resources/src/copyAsMarkdownTable.ts @@ -17,6 +17,7 @@ import core, { type Class, type Client, type Doc, + type DocumentQuery, type Hierarchy, type Ref, type PersonId, @@ -27,13 +28,18 @@ import core, { import { translate, type IntlString, getMetadata } from '@hcengineering/platform' import { addNotification, NotificationSeverity, locationToUrl } from '@hcengineering/ui' import { getCurrentLanguage } from '@hcengineering/theme' -import viewPlugin, { type Viewlet, type AttributeModel, type BuildModelKey } from '@hcengineering/view' +import viewPlugin, { + type Viewlet, + type AttributeModel, + type BuildModelKey, + type BuildMarkdownTableMetadata +} from '@hcengineering/view' import presentation, { getClient } from '@hcengineering/presentation' import { getName, getPersonByPersonId } from '@hcengineering/contact' import { buildModel, buildConfigLookup, getAttributeValue, getObjectLinkFragment } from './utils' import view from './plugin' import SimpleNotification from './components/SimpleNotification.svelte' -import { copyText } from './actionImpl' +import { copyMarkdown } from './actionImpl' /** * Value formatter function for custom field extraction @@ -447,6 +453,7 @@ export interface CopyAsMarkdownTableProps { viewlet?: Viewlet config?: Array valueFormatter?: ValueFormatter + query?: DocumentQuery // Original query used to fetch documents } /** @@ -469,6 +476,197 @@ export interface CopyRelationshipTableAsMarkdownProps { objects: Doc[] cardClass: Ref> valueFormatter?: ValueFormatter + query?: DocumentQuery // Original query used to fetch documents +} + +/** + * Metadata structure for table clipboard data + * Used to preserve query and configuration for refresh/diff functionality + */ +export interface TableMetadata { + version: string // For future compatibility + cardClass: Ref> + viewletId?: Ref + config?: Array + query?: DocumentQuery + documentIds: Array> + timestamp: number + workspace?: string // Optional workspace identifier +} + +/** + * Build metadata object from props and documents + */ +function buildTableMetadata (props: CopyAsMarkdownTableProps, docs: Doc[]): TableMetadata { + return { + version: '1.0', + cardClass: props.cardClass, + viewletId: props.viewlet?._id, + config: props.config, + query: props.query, + documentIds: docs.map((d) => d._id), + timestamp: Date.now() + } +} + +/** + * Build metadata object for relationship tables + */ +export function buildRelationshipTableMetadata ( + props: CopyRelationshipTableAsMarkdownProps, + docs: Doc[] +): TableMetadata { + return { + version: '1.0', + cardClass: props.cardClass, + viewletId: undefined, // Relationship tables don't use viewlets + config: props.model.map((m) => m.key), + query: props.query, + documentIds: docs.map((d) => d._id), + timestamp: Date.now() + } +} + +/** + * 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 { + // 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() + + // Cache for user ID (PersonId) -> name mappings to reduce database calls + const userCache = new Map() + + const headers: string[] = [] + for (const attr of displayableModel) { + let label: string + if (typeof attr.label === 'string') { + label = isIntlString(attr.label) ? await translate(attr.label as unknown as IntlString, {}, language) : attr.label + } else { + label = await translate(attr.label, {}, language) + } + headers.push(label) + } + + 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 ( @@ -482,104 +680,19 @@ export async function CopyAsMarkdownTable ( return } const client = getClient() - const hierarchy = client.getHierarchy() - const cardClass = hierarchy.getClass(props.cardClass) - if (cardClass == null) { + + // Build markdown table using the extracted function + const markdown = await buildMarkdownTableFromDocs(docs, props, client) + + if (markdown.length === 0) { 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 - } + // Build metadata for table refresh/diff functionality + const metadata = buildTableMetadata(props, docs) + await copyMarkdown(markdown, metadata) const language = getCurrentLanguage() - - // Cache for user ID (PersonId) -> name mappings to reduce database calls - const userCache = new Map() - - const headers: string[] = [] - for (const attr of displayableModel) { - let label: string - if (typeof attr.label === 'string') { - label = isIntlString(attr.label) - ? await translate(attr.label as unknown as IntlString, {}, language) - : attr.label - } else { - label = await translate(attr.label, {}, language) - } - headers.push(label) - } - - 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' - } - - await copyText(markdown, 'text/markdown') - addNotification( await translate(view.string.Copied, {}, language), await translate(view.string.TableCopiedToClipboard, {}, language), @@ -764,7 +877,9 @@ export async function CopyRelationshipTableAsMarkdown ( markdown += '| ' + row.join(' | ') + ' |\n' } - await copyText(markdown, 'text/markdown') + // 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), diff --git a/plugins/view-resources/src/index.ts b/plugins/view-resources/src/index.ts index 940b271637..72a3a96502 100644 --- a/plugins/view-resources/src/index.ts +++ b/plugins/view-resources/src/index.ts @@ -145,6 +145,7 @@ 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 { @@ -223,11 +224,15 @@ export { type CopyRelationshipTableAsMarkdownProps, type RelationshipCellModel, type RelationshipRowModel, + type TableMetadata, type ValueFormatter, registerValueFormatterForClass, registerValueFormatter, - isIntlString + isIntlString, + buildMarkdownTableFromDocs, + buildMarkdownTableFromMetadata } from './copyAsMarkdownTable' +export type { BuildMarkdownTableMetadata } from '@hcengineering/view' export { ArrayEditor, BooleanEditor, @@ -400,6 +405,7 @@ export default async (): Promise => ({ BlobVideoMetadata: blobVideoMetadata, OpenDocument: openDocFromRef, CanCopyLink: canCopyLink, - GetLink: getLink + GetLink: getLink, + BuildMarkdownTableFromDocs: buildMarkdownTableFromMetadata } }) diff --git a/plugins/view/src/index.ts b/plugins/view/src/index.ts index d796eb0328..782ecaa5a8 100644 --- a/plugins/view/src/index.ts +++ b/plugins/view/src/index.ts @@ -14,7 +14,7 @@ // limitations under the License. // -import { Class, Doc, DocumentQuery, FindOptions, Mixin, Ref } from '@hcengineering/core' +import { Class, Client, 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,6 +30,7 @@ import { AttributeFilterPresenter, AttributePresenter, BaseQuery, + BuildMarkdownTableMetadata, ClassFilters, ClassSortFuncs, CollectionEditor, @@ -356,7 +357,10 @@ const view = plugin(viewId, { PositionElementAlignment: '' as Resource<(e?: Event) => PopupAlignment | undefined> }, function: { - OpenDocument: '' as Resource + OpenDocument: '' as Resource, + BuildMarkdownTableFromDocs: '' as Resource< + (docs: Doc[], metadata: BuildMarkdownTableMetadata, client: Client) => Promise + > }, actionImpl: { CopyTextToClipboard: '' as ViewAction<{ diff --git a/plugins/view/src/types.ts b/plugins/view/src/types.ts index 845bd64386..d4f33578ae 100644 --- a/plugins/view/src/types.ts +++ b/plugins/view/src/types.ts @@ -894,3 +894,14 @@ export interface AttrPresenter extends Doc { objectClass: Ref> component: AnyComponent } + +/** + * @public + * Metadata for markdown table generation and refresh + */ +export interface BuildMarkdownTableMetadata { + cardClass: string | Ref> + viewletId?: string | Ref + config?: Array + query?: Record | DocumentQuery +}