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 6f64933b6a5..2b6d71fbb5a 100644 --- a/plugins/view-resources/src/copyAsMarkdownTable.ts +++ b/plugins/view-resources/src/copyAsMarkdownTable.ts @@ -37,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' @@ -190,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, @@ -335,9 +338,29 @@ 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) { + const customValue = (card as any)[labelStr] + if (customValue === null || customValue === undefined) { + return '' + } + + const docClass = card._class + let customAttr = hierarchy.findAttribute(docClass, labelStr) + + if (customAttr === undefined) { + const allAttrs = hierarchy.getAllAttributes(docClass) + customAttr = allAttrs.get(labelStr) + } + + return await formatCustomAttributeValue(customValue, customAttr, card, hierarchy, language) + } + return '' } @@ -345,7 +368,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 +390,22 @@ async function formatValue ( } if (typeof value === 'string') { + const isRef = attrType?._class === core.class.RefTo + if (isRef) { + const cardWithLookup = card as any + const lookupData = cardWithLookup.$lookup?.[attr.key] + if (lookupData !== undefined && lookupData !== null) { + const resolvedObj = lookupData + if (typeof resolvedObj === 'object' && resolvedObj !== null && 'title' in resolvedObj) { + const title = resolvedObj[DocumentAttributeKey.Title] ?? '' + if (typeof title === 'string' && isIntlString(title)) { + return await translate(title as unknown as IntlString, {}, language) + } + return String(title) + } + } + } + if (isIntlString(value)) { return await translate(value as unknown as IntlString, {}, language) } @@ -376,79 +416,18 @@ 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) } -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 @@ -543,7 +522,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(), @@ -641,19 +620,11 @@ export async function buildMarkdownTableFromDocs ( 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 firstDocClass = docs.length > 0 ? docs[0]._class : props.cardClass + + const headers = await generateHeaders(displayableModel, firstDocClass, hierarchy, language) const rows: string[][] = [] for (const card of docs) { @@ -761,19 +732,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() 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) + } +}