From 858227a80f1a474eff6423720962e8f01b9a7cef Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Tue, 27 Jan 2026 13:58:42 +0700 Subject: [PATCH 1/3] Fix custom attributes in markdown table Signed-off-by: Artem Savchenko --- .../view-resources/src/copyAsMarkdownTable.ts | 395 +++++++++++++++--- 1 file changed, 335 insertions(+), 60 deletions(-) diff --git a/plugins/view-resources/src/copyAsMarkdownTable.ts b/plugins/view-resources/src/copyAsMarkdownTable.ts index 6f64933b6a5..27304e3d862 100644 --- a/plugins/view-resources/src/copyAsMarkdownTable.ts +++ b/plugins/view-resources/src/copyAsMarkdownTable.ts @@ -14,6 +14,7 @@ // import core, { + type AnyAttribute, type Class, type Client, type Doc, @@ -284,6 +285,279 @@ async function loadViewletConfig ( return { viewlet, config: actualConfig } } +/** + * 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 + */ +async function resolveCustomAttributeLabel ( + attrLabel: string, + docClass: Ref>, + hierarchy: Hierarchy, + language: string | undefined +): Promise { + if (!attrLabel.startsWith('custom')) { + return attrLabel + } + + // Try to find the attribute on the document's actual class (MasterTag) + let customAttr = hierarchy.findAttribute(docClass, attrLabel) + if (customAttr === undefined) { + const allAttrs = hierarchy.getAllAttributes(docClass) + customAttr = allAttrs.get(attrLabel) + } + + if (customAttr?.label !== undefined) { + // Use the attribute's human-readable label + return await translate(customAttr.label, {}, language) + } + + // Fallback to the attribute ID + 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 + */ +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') { + // Check if this is a custom attribute ID that needs label lookup + 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 + */ +function modelToConfig (model: AttributeModel[]): Array { + return model.map((m) => { + // For custom attributes, key is empty but label contains the attribute name + if (m.key === '' && typeof m.label === 'string' && m.label.startsWith('custom')) { + // Return as BuildModelKey to preserve the custom attribute name + return { + key: m.label, // Use label (custom attribute name) as key + label: m.label, + displayProps: m.displayProps, + props: m.props, + sortingKey: m.sortingKey + } + } + // For regular attributes, return the key + if (m.key !== '') { + return m.key + } + // For empty key attributes (like object presenter), return empty string or BuildModelKey + 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 + */ +async function formatArrayValue ( + value: any[], + attrType: any, + attribute: AnyAttribute | undefined, + attrKey: string, + card: Doc, + language: string | undefined +): Promise { + // Check if it's an array of references + 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(', ') + } + } + + // Handle regular arrays (strings, objects with title, etc.) + 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 + */ +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 + */ +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 + + // Handle timestamps + if (typeof value === 'number' && attrType?._class === core.class.TypeTimestamp) { + return getDisplayTime(value) + } + + // Handle dates + if (value instanceof Date) { + const options: Intl.DateTimeFormatOptions = { + year: DateFormatOption.Numeric, + month: DateFormatOption.Short, + day: DateFormatOption.Numeric + } + return value.toLocaleDateString(language ?? 'default', options) + } + + // Handle numbers and booleans + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + + // Handle strings + if (typeof value === 'string') { + // Check if it's an IntlString that needs translation + if (isIntlString(value)) { + return await translate(value as unknown as IntlString, {}, language) + } + + // Check if it's a reference ID - try to resolve from $lookup + 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 + } + + // Handle arrays + if (Array.isArray(value)) { + return await formatArrayValue(value, attrType, attribute, attribute?.name ?? '', card, language) + } + + // Handle objects (try to get title or name) + 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) +} + async function formatValue ( attr: AttributeModel, card: Doc, @@ -335,9 +609,33 @@ async function formatValue ( } } - // If this is an empty key but NOT the first column, return empty string - // (empty key should only be used for the object presenter in the first column) + // 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) { + // Get value directly from document using label as key + const customValue = (card as any)[labelStr] + if (customValue === null || customValue === undefined) { + return '' + } + + // Try to find the attribute definition on the document's class (MasterTag) + const docClass = card._class + let customAttr = hierarchy.findAttribute(docClass, labelStr) + + // If not found on document class, search in all attributes + if (customAttr === undefined) { + const allAttrs = hierarchy.getAllAttributes(docClass) + customAttr = allAttrs.get(labelStr) + } + + return await formatCustomAttributeValue(customValue, customAttr, card, hierarchy, language) + } + + // Not a custom attribute with empty key - skip (e.g., object presenter in non-first column) return '' } @@ -345,7 +643,8 @@ async function formatValue ( return '' } - const attribute = hierarchy.findAttribute(_class, attr.key) + // 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) { @@ -366,6 +665,25 @@ async function formatValue ( } if (typeof value === 'string') { + // Check if this is a single reference ID that needs lookup + 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) { + // Use the resolved object + 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) + } + // Fall through to display as string if no title + } + } + if (isIntlString(value)) { return await translate(value as unknown as IntlString, {}, language) } @@ -376,43 +694,13 @@ async function formatValue ( } if (Array.isArray(value)) { - const translatedValues = await Promise.all( - value.map(async (v) => { - if (typeof v === 'object' && v !== null && 'title' in v) { - const title = v[DocumentAttributeKey.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(', ') + return await formatArrayValue(value, attrType, attribute, attr.key, card, language) } if (typeof value === 'object' && value !== null) { const obj = value as Record - const title = obj[DocumentAttributeKey.Title] - if (title != null && title !== undefined) { - const titleStr = String(title) - if (isIntlString(titleStr)) { - return await translate(titleStr as unknown as IntlString, {}, language) - } - return titleStr - } - const name = obj[DocumentAttributeKey.Name] - if (name != null && name !== undefined) { - const nameStr = String(name) - if (isIntlString(nameStr)) { - return await translate(nameStr as unknown as IntlString, {}, language) - } - return nameStr - } - return String(value) + const titleOrName = await extractObjectTitleOrName(obj, language) + return titleOrName !== '' ? titleOrName : String(value) } return String(value) @@ -543,7 +831,7 @@ export function buildRelationshipTableMetadata ( version: '1.0', cardClass: props.cardClass, viewletId: undefined, // Relationship tables don't use viewlets - config: props.model.map((m) => m.key), + config: modelToConfig(props.model), // Preserve custom attributes by converting model to config query: props.query, documentIds: docs.map((d) => d._id), timestamp: Date.now(), @@ -644,16 +932,11 @@ export async function buildMarkdownTableFromDocs ( // 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) - } + // Get the first document's class for custom attribute lookup + const firstDocClass = docs.length > 0 ? docs[0]._class : props.cardClass + + // Generate headers using common function + const headers = await generateHeaders(displayableModel, firstDocClass, hierarchy, language) const rows: string[][] = [] for (const card of docs) { @@ -761,19 +1044,11 @@ export async function CopyRelationshipTableAsMarkdown ( // Cache for user ID (PersonId) -> name mappings to reduce database calls const userCache = new Map() - // Extract headers from model - const headers: string[] = [] - for (const attr of props.model) { - 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) - } + // 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() From dc1920eb1e64af0201f824d46fb5a7ac5e881ba1 Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Tue, 27 Jan 2026 14:03:28 +0700 Subject: [PATCH 2/3] Clean up Signed-off-by: Artem Savchenko --- .../view-resources/src/copyAsMarkdownTable.ts | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/plugins/view-resources/src/copyAsMarkdownTable.ts b/plugins/view-resources/src/copyAsMarkdownTable.ts index 27304e3d862..1207b08b1c8 100644 --- a/plugins/view-resources/src/copyAsMarkdownTable.ts +++ b/plugins/view-resources/src/copyAsMarkdownTable.ts @@ -303,7 +303,6 @@ async function resolveCustomAttributeLabel ( return attrLabel } - // Try to find the attribute on the document's actual class (MasterTag) let customAttr = hierarchy.findAttribute(docClass, attrLabel) if (customAttr === undefined) { const allAttrs = hierarchy.getAllAttributes(docClass) @@ -311,11 +310,9 @@ async function resolveCustomAttributeLabel ( } if (customAttr?.label !== undefined) { - // Use the attribute's human-readable label return await translate(customAttr.label, {}, language) } - // Fallback to the attribute ID return attrLabel } @@ -338,7 +335,6 @@ async function generateHeaders ( for (const attr of model) { let label: string if (typeof attr.label === 'string') { - // Check if this is a custom attribute ID that needs label lookup if (attr.label.startsWith('custom')) { label = await resolveCustomAttributeLabel(attr.label, firstDocClass, hierarchy, language) } else if (isIntlString(attr.label)) { @@ -362,9 +358,7 @@ async function generateHeaders ( */ function modelToConfig (model: AttributeModel[]): Array { return model.map((m) => { - // For custom attributes, key is empty but label contains the attribute name if (m.key === '' && typeof m.label === 'string' && m.label.startsWith('custom')) { - // Return as BuildModelKey to preserve the custom attribute name return { key: m.label, // Use label (custom attribute name) as key label: m.label, @@ -373,11 +367,9 @@ function modelToConfig (model: AttributeModel[]): Array sortingKey: m.sortingKey } } - // For regular attributes, return the key if (m.key !== '') { return m.key } - // For empty key attributes (like object presenter), return empty string or BuildModelKey if (m.castRequest !== undefined) { return { key: m.key, @@ -409,7 +401,6 @@ async function formatArrayValue ( card: Doc, language: string | undefined ): Promise { - // Check if it's an array of references const isRefArray = attrType?._class === core.class.ArrOf && (attrType as { of?: { _class?: Ref> } })?.of?._class === core.class.RefTo @@ -437,7 +428,6 @@ async function formatArrayValue ( } } - // Handle regular arrays (strings, objects with title, etc.) const translatedValues = await Promise.all( value.map(async (v) => { if (typeof v === 'object' && v !== null && 'title' in v) { @@ -497,12 +487,10 @@ async function formatCustomAttributeValue ( const attrType = attribute?.type - // Handle timestamps if (typeof value === 'number' && attrType?._class === core.class.TypeTimestamp) { return getDisplayTime(value) } - // Handle dates if (value instanceof Date) { const options: Intl.DateTimeFormatOptions = { year: DateFormatOption.Numeric, @@ -512,19 +500,15 @@ async function formatCustomAttributeValue ( return value.toLocaleDateString(language ?? 'default', options) } - // Handle numbers and booleans if (typeof value === 'number' || typeof value === 'boolean') { return String(value) } - // Handle strings if (typeof value === 'string') { - // Check if it's an IntlString that needs translation if (isIntlString(value)) { return await translate(value as unknown as IntlString, {}, language) } - // Check if it's a reference ID - try to resolve from $lookup const isRef = attrType?._class === core.class.RefTo if (isRef && attribute !== undefined) { const cardWithLookup = card as any @@ -543,12 +527,10 @@ async function formatCustomAttributeValue ( return value } - // Handle arrays if (Array.isArray(value)) { return await formatArrayValue(value, attrType, attribute, attribute?.name ?? '', card, language) } - // Handle objects (try to get title or name) if (typeof value === 'object' && value !== null) { const obj = value as Record const titleOrName = await extractObjectTitleOrName(obj, language) @@ -616,17 +598,14 @@ async function formatValue ( const isCustomAttribute = labelStr.startsWith('custom') if (isCustomAttribute) { - // Get value directly from document using label as key const customValue = (card as any)[labelStr] if (customValue === null || customValue === undefined) { return '' } - // Try to find the attribute definition on the document's class (MasterTag) const docClass = card._class let customAttr = hierarchy.findAttribute(docClass, labelStr) - // If not found on document class, search in all attributes if (customAttr === undefined) { const allAttrs = hierarchy.getAllAttributes(docClass) customAttr = allAttrs.get(labelStr) @@ -635,7 +614,6 @@ async function formatValue ( return await formatCustomAttributeValue(customValue, customAttr, card, hierarchy, language) } - // Not a custom attribute with empty key - skip (e.g., object presenter in non-first column) return '' } @@ -665,13 +643,11 @@ async function formatValue ( } if (typeof value === 'string') { - // Check if this is a single reference ID that needs lookup 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) { - // Use the resolved object const resolvedObj = lookupData if (typeof resolvedObj === 'object' && resolvedObj !== null && 'title' in resolvedObj) { const title = resolvedObj[DocumentAttributeKey.Title] ?? '' @@ -680,7 +656,6 @@ async function formatValue ( } return String(title) } - // Fall through to display as string if no title } } @@ -929,13 +904,10 @@ export async function buildMarkdownTableFromDocs ( const language = getCurrentLanguage() - // 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 = docs.length > 0 ? docs[0]._class : props.cardClass - // Generate headers using common function const headers = await generateHeaders(displayableModel, firstDocClass, hierarchy, language) const rows: string[][] = [] From e2f54e70c02721285e0b29789ddb428a345685da Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Tue, 27 Jan 2026 14:24:28 +0700 Subject: [PATCH 3/3] Refactor Signed-off-by: Artem Savchenko --- .../src/__tests__/copyAsMarkdownTable.test.ts | 3 +- .../view-resources/src/copyAsMarkdownTable.ts | 306 +--------------- plugins/view-resources/src/index.ts | 2 +- .../view-resources/src/markdownTableUtils.ts | 343 ++++++++++++++++++ 4 files changed, 357 insertions(+), 297 deletions(-) create mode 100644 plugins/view-resources/src/markdownTableUtils.ts diff --git a/plugins/view-resources/src/__tests__/copyAsMarkdownTable.test.ts b/plugins/view-resources/src/__tests__/copyAsMarkdownTable.test.ts index 41fe3512ab9..639beef7b0a 100644 --- a/plugins/view-resources/src/__tests__/copyAsMarkdownTable.test.ts +++ b/plugins/view-resources/src/__tests__/copyAsMarkdownTable.test.ts @@ -15,7 +15,8 @@ // Mock platform plugin function first (before any imports) // Import after mocks are set up -import { CopyAsMarkdownTable, isIntlString } from '../copyAsMarkdownTable' +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' diff --git a/plugins/view-resources/src/copyAsMarkdownTable.ts b/plugins/view-resources/src/copyAsMarkdownTable.ts index 1207b08b1c8..2b6d71fbb5a 100644 --- a/plugins/view-resources/src/copyAsMarkdownTable.ts +++ b/plugins/view-resources/src/copyAsMarkdownTable.ts @@ -14,7 +14,6 @@ // import core, { - type AnyAttribute, type Class, type Client, type Doc, @@ -38,7 +37,17 @@ import viewPlugin, { } from '@hcengineering/view' import presentation, { getClient } from '@hcengineering/presentation' import { getName, getPersonByPersonId } from '@hcengineering/contact' -import { buildModel, buildConfigLookup, getAttributeValue, getObjectLinkFragment } from './utils' +import { buildModel, buildConfigLookup, 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' @@ -191,13 +200,6 @@ async function buildTableModel ( * Examples: card:string:Card, contact:class:UserProfile, card:types:Document * @public */ -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) -} async function loadPersonName ( personId: PersonId, @@ -285,261 +287,6 @@ async function loadViewletConfig ( return { viewlet, config: actualConfig } } -/** - * 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 - */ -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 - */ -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 - */ -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 - */ -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 - */ -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 - */ -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) -} - async function formatValue ( attr: AttributeModel, card: Doc, @@ -681,37 +428,6 @@ async function formatValue ( return String(value) } -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, ' ') -} - -function escapeMarkdownLinkUrl (url: string): string { - // Escape backslashes and closing parentheses used to terminate the URL - return url.replace(/\\/g, '\\\\').replace(/\)/g, '\\)') -} - -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) - } -} - export interface CopyAsMarkdownTableProps { cardClass: Ref> viewlet?: Viewlet diff --git a/plugins/view-resources/src/index.ts b/plugins/view-resources/src/index.ts index 46e29c8640a..eb64971b613 100644 --- a/plugins/view-resources/src/index.ts +++ b/plugins/view-resources/src/index.ts @@ -228,10 +228,10 @@ export { type ValueFormatter, registerValueFormatterForClass, registerValueFormatter, - isIntlString, buildMarkdownTableFromDocs, buildMarkdownTableFromMetadata } from './copyAsMarkdownTable' +export { isIntlString } from './markdownTableUtils' export type { BuildMarkdownTableMetadata } from '@hcengineering/view' export { ArrayEditor, diff --git a/plugins/view-resources/src/markdownTableUtils.ts b/plugins/view-resources/src/markdownTableUtils.ts new file mode 100644 index 00000000000..b4252bae7c1 --- /dev/null +++ b/plugins/view-resources/src/markdownTableUtils.ts @@ -0,0 +1,343 @@ +// +// 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) + } +}