From 7b133e084d366643bc45a29d64da5deb570c29cd Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Mon, 9 Feb 2026 09:22:27 +0700 Subject: [PATCH 1/2] fix: secure blobs (#10490) Signed-off-by: Alexander Onnikov --- dev/docker-compose.yaml | 1 + .../storage-client/src/client/datalake.ts | 10 +++++ .../storage-client/src/client/front.ts | 10 +++++ .../storage-client/src/client/hulylake.ts | 10 +++++ .../core/packages/storage-client/src/types.ts | 1 + packages/presentation/src/utils.ts | 20 ++++++---- services/datalake/pod-datalake/src/config.ts | 2 + .../datalake/pod-datalake/src/middleware.ts | 8 ++++ services/datalake/pod-datalake/src/server.ts | 38 ++++++++++++++++--- 9 files changed, 86 insertions(+), 14 deletions(-) diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index ad14db6e43..057895795d 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -519,6 +519,7 @@ services: - REGION=cockroach - QUEUE_CONFIG=${QUEUE_CONFIG} - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces + - SECURE=true restart: unless-stopped hulylake: image: hardcoreeng/hulylake diff --git a/foundations/core/packages/storage-client/src/client/datalake.ts b/foundations/core/packages/storage-client/src/client/datalake.ts index 24c9476d13..ad1dc1f7aa 100644 --- a/foundations/core/packages/storage-client/src/client/datalake.ts +++ b/foundations/core/packages/storage-client/src/client/datalake.ts @@ -17,6 +17,11 @@ import { concatLink } from '@hcengineering/core' import { FileStorage, FileStorageUploadOptions } from '../types' import { uploadMultipart, uploadXhr } from '../upload' +const getPathname = (url: string): string => { + const base = window?.location?.href !== undefined ? window.location.href : 'http://localhost' + return new URL(url, base).pathname +} + /** @public */ export class DatalakeStorage implements FileStorage { constructor (private readonly baseUrl: string) {} @@ -26,6 +31,11 @@ export class DatalakeStorage implements FileStorage { return concatLink(this.baseUrl, path) } + getCookiePath (workspace: string): string { + const url = concatLink(this.baseUrl, `/blob/${workspace}`) + return getPathname(url) + } + async getFileMeta (token: string, workspace: string, file: string): Promise> { const url = concatLink(this.baseUrl, `/meta/${encodeURIComponent(workspace)}/${encodeURIComponent(file)}`) try { diff --git a/foundations/core/packages/storage-client/src/client/front.ts b/foundations/core/packages/storage-client/src/client/front.ts index 67ade0229c..54fa4cd0b9 100644 --- a/foundations/core/packages/storage-client/src/client/front.ts +++ b/foundations/core/packages/storage-client/src/client/front.ts @@ -17,6 +17,11 @@ import { concatLink } from '@hcengineering/core' import { FileStorage, FileStorageUploadOptions } from '../types' import { uploadXhr } from '../upload' +const getPathname = (url: string): string => { + const base = window?.location?.href !== undefined ? window.location.href : 'http://localhost' + return new URL(url, base).pathname +} + /** @public */ export class FrontStorage implements FileStorage { constructor (private readonly baseUrl: string) {} @@ -26,6 +31,11 @@ export class FrontStorage implements FileStorage { return concatLink(this.baseUrl, path) } + getCookiePath (workspace: string): string { + const url = concatLink(this.baseUrl, `/${workspace}`) + return getPathname(url) + } + async getFileMeta (token: string, workspace: string, file: string): Promise> { return {} } diff --git a/foundations/core/packages/storage-client/src/client/hulylake.ts b/foundations/core/packages/storage-client/src/client/hulylake.ts index 397815c015..00acdb31f6 100644 --- a/foundations/core/packages/storage-client/src/client/hulylake.ts +++ b/foundations/core/packages/storage-client/src/client/hulylake.ts @@ -17,6 +17,11 @@ import { concatLink } from '@hcengineering/core' import { FileStorage, FileStorageUploadOptions } from '../types' import { uploadXhr } from '../upload' +const getPathname = (url: string): string => { + const base = window?.location?.href !== undefined ? window.location.href : 'http://localhost' + return new URL(url, base).pathname +} + /** @public */ export class HulylakeStorage implements FileStorage { constructor (private readonly baseUrl: string) {} @@ -26,6 +31,11 @@ export class HulylakeStorage implements FileStorage { return concatLink(this.baseUrl, path) } + getCookiePath (workspace: string): string { + const url = concatLink(this.baseUrl, `/api/${workspace}`) + return getPathname(url) + } + async getFileMeta (token: string, workspace: string, file: string): Promise> { return {} } diff --git a/foundations/core/packages/storage-client/src/types.ts b/foundations/core/packages/storage-client/src/types.ts index bcbe88be8f..45924bc3dd 100644 --- a/foundations/core/packages/storage-client/src/types.ts +++ b/foundations/core/packages/storage-client/src/types.ts @@ -29,6 +29,7 @@ export interface FileStorageUploadOptions { /** @public */ export interface FileStorage { getFileUrl: (workspace: string, file: string, filename?: string) => string + getCookiePath: (workspace: string) => string getFileMeta: (token: string, workspace: string, file: string) => Promise> uploadFile: ( token: string, diff --git a/packages/presentation/src/utils.ts b/packages/presentation/src/utils.ts index 43b320c19e..8771da2ab4 100644 --- a/packages/presentation/src/utils.ts +++ b/packages/presentation/src/utils.ts @@ -891,15 +891,19 @@ export function isSpaceClass (_class: Ref>): boolean { } export function setPresentationCookie (token: string, workspaceUuid: WorkspaceUuid): void { - function setToken (path: string): void { - const res = - encodeURIComponent(plugin.metadata.Token.replaceAll(':', '-')) + - '=' + - encodeURIComponent(token) + - `; path=${path}` - document.cookie = res + const cookieName = encodeURIComponent(plugin.metadata.Token.replaceAll(':', '-')) + const cookieValue = encodeURIComponent(token) + + const storage = getMetadata(plugin.metadata.FileStorage) + if (storage !== undefined) { + let path = `/files/${workspaceUuid}` + try { + path = storage.getCookiePath(workspaceUuid) + } catch {} + + const normalized = path.startsWith('/') ? path : `/${path}` + document.cookie = `${cookieName}=${cookieValue}; path=${normalized}` } - setToken('/files/' + workspaceUuid) } export const upgradeDownloadProgress = writable(-1) diff --git a/services/datalake/pod-datalake/src/config.ts b/services/datalake/pod-datalake/src/config.ts index af85152bf0..70b16b0781 100644 --- a/services/datalake/pod-datalake/src/config.ts +++ b/services/datalake/pod-datalake/src/config.ts @@ -35,6 +35,7 @@ export interface Config { DbUrl: string Buckets: BucketConfig[] CleanupInterval: number + Secure: boolean Readonly: boolean Cache: CacheConfig } @@ -86,6 +87,7 @@ const config: Config = (() => { AccountsUrl: process.env.ACCOUNTS_URL, DbUrl: process.env.DB_URL, Buckets: parseBucketsConfig(process.env.BUCKETS), + Secure: process.env.SECURE === 'true', Readonly: process.env.READONLY === 'true', Cache: { enabled: process.env.CACHE_ENABLED !== 'false', diff --git a/services/datalake/pod-datalake/src/middleware.ts b/services/datalake/pod-datalake/src/middleware.ts index 986cdc98bb..f59a1910c5 100644 --- a/services/datalake/pod-datalake/src/middleware.ts +++ b/services/datalake/pod-datalake/src/middleware.ts @@ -38,6 +38,14 @@ export const keepAlive = (options: KeepAliveOptions): RequestHandler => { } } +export const withOptionalAuth = (secure: boolean): RequestHandler => { + return secure + ? withAuthorization + : (req: Request, res: Response, next: NextFunction) => { + next() + } +} + export const withAdminAuthorization = (req: RequestWithAuth, res: Response, next: NextFunction): void => { try { const token = extractToken(req.headers) diff --git a/services/datalake/pod-datalake/src/server.ts b/services/datalake/pod-datalake/src/server.ts index 7cac5cc190..ac5d634509 100644 --- a/services/datalake/pod-datalake/src/server.ts +++ b/services/datalake/pod-datalake/src/server.ts @@ -36,7 +36,8 @@ import { withAuthorization, withBlob, withWorkspace, - withReadonly + withReadonly, + withOptionalAuth } from './middleware' import { handleBlobDelete, @@ -176,13 +177,33 @@ export async function createServer ( app.get('/blob/:workspace', withAdminAuthorization, withWorkspace, wrapRequest(ctx, 'listBlobs', handleBlobList)) - app.head('/blob/:workspace/:name', withBlob, wrapRequest(ctx, 'headBlob', handleBlobHead)) + app.head( + '/blob/:workspace/:name', + withOptionalAuth(config.Secure), + withBlob, + wrapRequest(ctx, 'headBlob', handleBlobHead) + ) - app.head('/blob/:workspace/:name/:filename', withBlob, wrapRequest(ctx, 'headBlob', handleBlobHead)) + app.head( + '/blob/:workspace/:name/:filename', + withOptionalAuth(config.Secure), + withBlob, + wrapRequest(ctx, 'headBlob', handleBlobHead) + ) - app.get('/blob/:workspace/:name', withBlob, wrapRequest(ctx, 'getBlob', handleBlobGet)) + app.get( + '/blob/:workspace/:name', + withOptionalAuth(config.Secure), + withBlob, + wrapRequest(ctx, 'getBlob', handleBlobGet) + ) - app.get('/blob/:workspace/:name/:filename', withBlob, wrapRequest(ctx, 'getBlob', handleBlobGet)) + app.get( + '/blob/:workspace/:name/:filename', + withOptionalAuth(config.Secure), + withBlob, + wrapRequest(ctx, 'getBlob', handleBlobGet) + ) app.delete('/blob/:workspace/:name', withAuthorization, withBlob, wrapRequest(ctx, 'deleteBlob', handleBlobDelete)) @@ -206,7 +227,12 @@ export async function createServer ( // Blob meta - app.get('/meta/:workspace/:name', withBlob, wrapRequest(ctx, 'getMeta', handleMetaGet)) + app.get( + '/meta/:workspace/:name', + withOptionalAuth(config.Secure), + withBlob, + wrapRequest(ctx, 'getMeta', handleMetaGet) + ) app.put('/meta/:workspace/:name', withAuthorization, withBlob, wrapRequest(ctx, 'putMeta', handleMetaPut)) From baacc660626c41dad5e4d00db9c153ef924a304a Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Mon, 9 Feb 2026 12:01:12 +0700 Subject: [PATCH 2/2] Fix relationship table refresh (#10494) * Fix relationship table refresh Signed-off-by: Artem Savchenko * Fix warnings Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko --- .../src/__tests__/relationshipBuilder.test.ts | 180 ++++++++++++++++++ .../src/data/relationshipBuilder.ts | 165 +++++++++++----- .../src/markdown/tableBuilder.ts | 37 ++-- 3 files changed, 327 insertions(+), 55 deletions(-) create mode 100644 plugins/converter-resources/src/__tests__/relationshipBuilder.test.ts diff --git a/plugins/converter-resources/src/__tests__/relationshipBuilder.test.ts b/plugins/converter-resources/src/__tests__/relationshipBuilder.test.ts new file mode 100644 index 0000000000..731224be4e --- /dev/null +++ b/plugins/converter-resources/src/__tests__/relationshipBuilder.test.ts @@ -0,0 +1,180 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { AttributeModel } from '@hcengineering/view' +import type { IntlString } from '@hcengineering/platform' +import type { Class, Client, Doc, Hierarchy, Ref } from '@hcengineering/core' +import { rebuildRelationshipTableViewModel } from '../data/relationshipBuilder' + +jest.mock('@hcengineering/view-resources', () => ({ + buildConfigAssociation: jest.fn(() => []), + buildConfigLookup: jest.fn(() => ({})) +})) + +type DocOverrides = Partial & { + $associations?: Record + title?: string +} + +function doc (id: string, overrides: DocOverrides = {}): Doc { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test helper with spread overrides + const result = { + _id: id as Ref, + _class: 'test:class:Doc' as Ref>, + space: 'test:space' as any, + modifiedOn: 0, + modifiedBy: '' as any, + createdOn: 0, + createdBy: '' as any, + ...overrides + } as Doc + return result +} + +function attr (key: string, label: string = key): AttributeModel { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- minimal AttributeModel for tests + const result = { + key, + label: label as IntlString, + _class: '' as Ref>, + sortingKey: '', + collectionAttr: false, + isLookup: false + } as AttributeModel + return result +} + +describe('relationshipBuilder', () => { + const hierarchy: Hierarchy = {} as any + const client: Client = {} as any + const cardClass = 'test:class:Card' as Ref> + + describe('rebuildRelationshipTableViewModel', () => { + it('returns one row when doc has no associations (single expanded row with undefined children)', async () => { + const root = doc('root-1') + const model: AttributeModel[] = [ + attr(''), + attr('$associations.assoc1_b', 'Level1'), + attr('$associations.assoc1_b.$associations.assoc2_b', 'Level2') + ] + const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client) + expect(viewModel).toHaveLength(1) + expect(viewModel[0].cells).toHaveLength(3) + expect(viewModel[0].cells[0].object).toBe(root) + expect(viewModel[0].cells[0].rowSpan).toBe(1) + expect(viewModel[0].cells[1].object).toBeUndefined() + expect(viewModel[0].cells[1].parentObject).toBe(root) + expect(viewModel[0].cells[2].object).toBeUndefined() + expect(viewModel[0].cells[2].parentObject).toBeUndefined() + }) + + it('builds one row per first-level child for single-level association', async () => { + const b1 = doc('b1', { title: 'B1' }) + const b2 = doc('b2', { title: 'B2' }) + const root = doc('root-1', { + $associations: { assoc1_b: [b1, b2] } + }) + const model: AttributeModel[] = [attr(''), attr('$associations.assoc1_b', 'Level1')] + const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client) + expect(viewModel).toHaveLength(2) + expect(viewModel[0].cells[0].object).toBe(root) + expect(viewModel[0].cells[0].rowSpan).toBe(2) + expect(viewModel[0].cells[1].object).toBe(b1) + expect(viewModel[0].cells[1].parentObject).toBe(root) + expect(viewModel[0].cells[1].rowSpan).toBe(1) + expect(viewModel[1].cells[0].rowSpan).toBe(0) + expect(viewModel[1].cells[0].object).toBeUndefined() + expect(viewModel[1].cells[1].object).toBe(b2) + expect(viewModel[1].cells[1].parentObject).toBe(root) + }) + + it('builds rows with correct nested objects for two-level association (A -> B -> C)', async () => { + const c1 = doc('c1', { title: 'C1' }) + const c2 = doc('c2', { title: 'C2' }) + const c3 = doc('c3', { title: 'C3' }) + const b1 = doc('b1', { title: 'B1', $associations: { assoc2_b: [c1, c2] } }) + const b2 = doc('b2', { title: 'B2', $associations: { assoc2_b: [c3] } }) + const root = doc('root-1', { + $associations: { assoc1_b: [b1, b2] } + }) + const model: AttributeModel[] = [ + attr(''), + attr('$associations.assoc1_b', 'Level1'), + attr('$associations.assoc1_b.$associations.assoc2_b', 'Level2') + ] + const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client) + expect(viewModel).toHaveLength(3) + expect(viewModel[0].cells[0].object).toBe(root) + expect(viewModel[0].cells[0].rowSpan).toBe(3) + expect(viewModel[0].cells[1].object).toBe(b1) + expect(viewModel[0].cells[1].parentObject).toBe(root) + expect(viewModel[0].cells[1].rowSpan).toBe(2) + expect(viewModel[0].cells[2].object).toBe(c1) + expect(viewModel[0].cells[2].parentObject).toBe(b1) + expect(viewModel[0].cells[2].rowSpan).toBe(1) + expect(viewModel[1].cells[2].object).toBe(c2) + expect(viewModel[1].cells[2].parentObject).toBe(b1) + expect(viewModel[2].cells[1].object).toBe(b2) + expect(viewModel[2].cells[1].parentObject).toBe(root) + expect(viewModel[2].cells[1].rowSpan).toBe(1) + expect(viewModel[2].cells[2].object).toBe(c3) + expect(viewModel[2].cells[2].parentObject).toBe(b2) + }) + + it('emits one row when first-level child has no nested children', async () => { + const b1 = doc('b1', { title: 'B1' }) + const root = doc('root-1', { $associations: { assoc1_b: [b1] } }) + const model: AttributeModel[] = [ + attr(''), + attr('$associations.assoc1_b', 'Level1'), + attr('$associations.assoc1_b.$associations.assoc2_b', 'Level2') + ] + const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client) + expect(viewModel).toHaveLength(1) + expect(viewModel[0].cells[0].object).toBe(root) + expect(viewModel[0].cells[1].object).toBe(b1) + expect(viewModel[0].cells[1].parentObject).toBe(root) + expect(viewModel[0].cells[2].object).toBeUndefined() + expect(viewModel[0].cells[2].parentObject).toBe(b1) + }) + + it('handles multiple root docs each with their own expanded rows', async () => { + const b1 = doc('b1') + const b2 = doc('b2') + const root1 = doc('root-1', { $associations: { assoc1_b: [b1] } }) + const root2 = doc('root-2', { $associations: { assoc1_b: [b2] } }) + const model: AttributeModel[] = [attr(''), attr('$associations.assoc1_b', 'Level1')] + const viewModel = await rebuildRelationshipTableViewModel([root1, root2], model, cardClass, hierarchy, client) + expect(viewModel).toHaveLength(2) + expect(viewModel[0].cells[0].object).toBe(root1) + expect(viewModel[0].cells[0].rowSpan).toBe(1) + expect(viewModel[0].cells[1].object).toBe(b1) + expect(viewModel[1].cells[0].object).toBe(root2) + expect(viewModel[1].cells[1].object).toBe(b2) + }) + + it('preserves model order for non-association attributes', async () => { + const b1 = doc('b1') + const root = doc('root-1', { $associations: { assoc1_b: [b1] } }) + const model: AttributeModel[] = [attr(''), attr('$associations.assoc1_b', 'Assoc'), attr('title', 'Title')] + const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client) + expect(viewModel).toHaveLength(1) + expect(viewModel[0].cells).toHaveLength(3) + expect(viewModel[0].cells[2].attribute.key).toBe('title') + expect(viewModel[0].cells[2].object).toBe(root) + expect(viewModel[0].cells[2].rowSpan).toBe(1) + }) + }) +}) diff --git a/plugins/converter-resources/src/data/relationshipBuilder.ts b/plugins/converter-resources/src/data/relationshipBuilder.ts index 4b070f0e85..3f33f94ed1 100644 --- a/plugins/converter-resources/src/data/relationshipBuilder.ts +++ b/plugins/converter-resources/src/data/relationshipBuilder.ts @@ -18,9 +18,86 @@ import type { AttributeModel } from '@hcengineering/view' import { buildConfigAssociation, buildConfigLookup } from '@hcengineering/view-resources' import type { RelationshipCellModel, RelationshipRowModel } from '../types' +/** + * Parse association attribute key into path of association keys. + * e.g. "$associations.X_b" -> ["X_b"], "$associations.X_b.$associations.Y_b" -> ["X_b", "Y_b"] + */ +function parseAssociationKeyPath (key: string): string[] { + if (!key.startsWith('$associations')) return [] + const parts = key.split('$associations.').filter((p) => p.length > 0) + return parts.map((p) => (p.endsWith('.') ? p.slice(0, -1) : p).trim()).filter(Boolean) +} + +/** + * Get doc at given depth from parent's $associations using path[depth]. + * depth 0 = parent itself, depth 1 = parent.$associations[path[0]][index], etc. + */ +function getAssocChildren (parent: any, pathKey: string): Doc[] | undefined { + const arr = parent?.$associations?.[pathKey] + if (Array.isArray(arr)) return arr + if (arr !== undefined && arr !== null) return [arr as Doc] + return undefined +} + +/** + * Expand rows for one root: one row per (level0, level1, ..., levelN) with correct nesting. + * Each row is [root, child1, child2, ...] where child1 from root.$associations[path[0]], child2 from child1.$associations[path[1]], etc. + */ +function expandRowsForRoot ( + root: Doc, + associationAttrs: AttributeModel[], + pathPerAttr: string[][] +): Array<{ docsByLevel: (Doc | undefined)[] }> { + const rows: Array<{ docsByLevel: (Doc | undefined)[] }> = [] + + function expand (parent: Doc | undefined, level: number, docsSoFar: (Doc | undefined)[]): void { + if (level >= associationAttrs.length) { + rows.push({ docsByLevel: docsSoFar }) + return + } + const path = pathPerAttr[level] + const key = path[level] + const children = parent !== undefined ? getAssocChildren(parent as any, key) : undefined + const list = children !== undefined && children !== null && children.length > 0 ? children : [undefined] + for (const doc of list) { + expand(doc ?? undefined, level + 1, [...docsSoFar, doc]) + } + } + + const firstPath = pathPerAttr[0] + const firstKey = firstPath[0] + const firstChildren = getAssocChildren(root as any, firstKey) + const firstList = + firstChildren !== undefined && firstChildren !== null && firstChildren.length > 0 ? firstChildren : [undefined] + for (const doc of firstList) { + expand(doc ?? undefined, 1, [root, doc]) + } + return rows +} + +/** + * Compute rowSpan for a given level at each row index: how many consecutive rows share the same doc at this level. + */ +function computeRowSpans (rows: Array<{ docsByLevel: (Doc | undefined)[] }>, level: number): number[] { + const spans: number[] = [] + for (let i = 0; i < rows.length; i++) { + const doc = rows[i].docsByLevel[level] + const docId = doc?._id + let span = 1 + for (let j = i + 1; j < rows.length; j++) { + if (rows[j].docsByLevel[level]?._id === docId) span++ + else break + } + spans.push(span) + } + return spans +} + /** * Rebuild relationship table viewModel from documents and metadata - * Recreates the hierarchical structure with row spans and separate rows for each associated child + * Recreates the hierarchical structure with row spans and separate rows for each associated child. + * Supports multi-level associations (A -> B -> C): nested keys like $associations.X_b.$associations.Y_b + * are resolved from the correct parent doc per level. */ export async function rebuildRelationshipTableViewModel ( docs: Doc[], @@ -36,33 +113,21 @@ export async function rebuildRelationshipTableViewModel ( const lookup = buildConfigLookup(hierarchy, cardClass, config) const associationAttrs = model.filter((attr) => attr.key.startsWith('$associations')) + const pathPerAttr = associationAttrs.map((attr) => parseAssociationKeyPath(attr.key)) let docsWithAssociations: Doc[] = docs if (associations !== undefined && associations.length > 0) { const docIds = docs.map((d) => d._id) const query = { _id: { $in: docIds } } docsWithAssociations = await client.findAll(cardClass, query, { lookup, associations }) - // Preserve order of input docs (findAll does not guarantee order) const idToIndex = new Map(docs.map((d, i) => [d._id, i])) docsWithAssociations.sort((a, b) => (idToIndex.get(a._id) ?? Infinity) - (idToIndex.get(b._id) ?? Infinity)) } for (const parentDoc of docsWithAssociations) { - const docWithAssoc = parentDoc as any - const parentAssociations = docWithAssoc.$associations ?? {} + const expandedRows = expandRowsForRoot(parentDoc, associationAttrs, pathPerAttr) - let maxChildren = 0 - for (const assocAttr of associationAttrs) { - const assocKey = assocAttr.key.replace('$associations.', '') - const children = parentAssociations[assocKey] - if (Array.isArray(children)) { - maxChildren = Math.max(maxChildren, children.length) - } else if (children !== undefined && children !== null) { - maxChildren = Math.max(maxChildren, 1) - } - } - - if (maxChildren === 0) { + if (expandedRows.length === 0) { const cells: RelationshipCellModel[] = [] for (const attr of model) { const isAssociationKey = attr.key.startsWith('$associations') @@ -77,45 +142,59 @@ export async function rebuildRelationshipTableViewModel ( continue } - for (let childIndex = 0; childIndex < maxChildren; childIndex++) { + const rowSpanByLevel: number[][] = [] + for (let level = 0; level <= associationAttrs.length; level++) { + rowSpanByLevel.push(computeRowSpans(expandedRows, level)) + } + + for (let rowIdx = 0; rowIdx < expandedRows.length; rowIdx++) { + const rowData = expandedRows[rowIdx] const cells: RelationshipCellModel[] = [] for (const attr of model) { const isAssociationKey = attr.key.startsWith('$associations') if (attr.key === '') { + const span = rowSpanByLevel[0][rowIdx] + const isFirstInSpan = + rowIdx === 0 || expandedRows[rowIdx - 1].docsByLevel[0]?._id !== rowData.docsByLevel[0]?._id cells.push({ attribute: attr, - rowSpan: maxChildren, - object: parentDoc, - parentObject: undefined - }) - } else if (isAssociationKey) { - const assocKey = attr.key.replace('$associations.', '') - const children = parentAssociations[assocKey] - let childDoc: Doc | undefined - if (Array.isArray(children) && children.length > childIndex) { - childDoc = children[childIndex] as Doc - } else if (!Array.isArray(children) && children !== undefined && children !== null && childIndex === 0) { - childDoc = children as Doc - } - - cells.push({ - attribute: attr, - rowSpan: 1, - object: childDoc, - parentObject: parentDoc - }) - } else { - cells.push({ - attribute: attr, - rowSpan: 1, - object: childIndex === 0 ? parentDoc : undefined, + rowSpan: isFirstInSpan ? span : 0, + object: isFirstInSpan ? parentDoc : undefined, parentObject: undefined }) + continue } - } + if (isAssociationKey) { + const assocIdx = associationAttrs.indexOf(attr) + if (assocIdx < 0) { + cells.push({ attribute: attr, rowSpan: 1, object: undefined, parentObject: undefined }) + continue + } + const level = assocIdx + 1 + const docAtLevel = rowData.docsByLevel[level] + const parentAtLevel = rowData.docsByLevel[level - 1] + const span = rowSpanByLevel[level][rowIdx] + const isFirstInSpan = + rowIdx === 0 || expandedRows[rowIdx - 1].docsByLevel[level]?._id !== rowData.docsByLevel[level]?._id + cells.push({ + attribute: attr, + rowSpan: isFirstInSpan ? span : 0, + object: isFirstInSpan ? docAtLevel : undefined, + parentObject: isFirstInSpan ? parentAtLevel : undefined + }) + continue + } + + cells.push({ + attribute: attr, + rowSpan: 1, + object: rowIdx === 0 ? parentDoc : undefined, + parentObject: undefined + }) + } viewModel.push({ cells }) } } diff --git a/plugins/converter-resources/src/markdown/tableBuilder.ts b/plugins/converter-resources/src/markdown/tableBuilder.ts index 7a74730cca..3487f51079 100644 --- a/plugins/converter-resources/src/markdown/tableBuilder.ts +++ b/plugins/converter-resources/src/markdown/tableBuilder.ts @@ -25,33 +25,45 @@ import { rebuildRelationshipTableViewModel, isRelationshipTable } from '../data' import { escapeMarkdownLinkText } from './escape' import { createMarkdownLink } from './link' -async function buildRelationshipTableFromMetadata ( +async function buildRelationshipTablePropsFromMetadata ( docs: Doc[], metadata: BuildMarkdownTableMetadata, client: Client -): Promise { +): Promise { const hierarchy = client.getHierarchy() const cardClass = metadata.cardClass as Ref> - const config = metadata.config ?? [] - const lookup = buildConfigLookup(hierarchy, cardClass, config) - const model = await buildModel({ - client, - _class: cardClass, - keys: config, - lookup - }) + let model: AttributeModel[] + if (metadata.config !== undefined && metadata.config.length > 0) { + const config = metadata.config + const lookup = buildConfigLookup(hierarchy, cardClass, config) + model = await buildModel({ + client, + _class: cardClass, + keys: config, + lookup + }) + } else { + model = await buildTableModel(client, hierarchy, cardClass, undefined) + } const viewModel = await rebuildRelationshipTableViewModel(docs, model, cardClass, hierarchy, client) - - const props: CopyRelationshipTableAsMarkdownProps = { + return { viewModel, model, objects: docs, cardClass, query: metadata.query } +} +async function buildRelationshipTableFromMetadata ( + docs: Doc[], + metadata: BuildMarkdownTableMetadata, + client: Client +): Promise { + const hierarchy = client.getHierarchy() + const props = await buildRelationshipTablePropsFromMetadata(docs, metadata, client) const language = getCurrentLanguage() return await buildRelationshipTableMarkdown(props, hierarchy, language) } @@ -234,6 +246,7 @@ export async function buildRelationshipTableMarkdown ( } if (doc === undefined) { + if (cell.rowSpan === 0) continue row[attrIndex] = '' continue }