From 7c56a6a4761411df6768db1599b32c39f2450663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Ord=C3=B3=C3=B1ez?= Date: Tue, 12 May 2026 21:09:10 +0200 Subject: [PATCH 1/3] refactor(liferay): centralize resource shared helpers --- .../resource/resource-import-commands.ts | 20 +- .../content/liferay-content-journal-shared.ts | 6 +- .../content/liferay-content-prune-context.ts | 19 +- .../content/liferay-content-prune-execute.ts | 2 +- .../content/liferay-content-prune-folders.ts | 2 +- .../liferay/content/liferay-content-stats.ts | 16 +- .../liferay-inventory-page-fetch-fragments.ts | 3 +- .../liferay-inventory-page-fetch-journal.ts | 3 +- src/features/liferay/portal/artifact-paths.ts | 31 ++- src/features/liferay/portal/site-token.ts | 10 - .../liferay/resource/artifact-paths.ts | 1 - .../resource/liferay-resource-export-adts.ts | 4 +- .../liferay-resource-export-fragments.ts | 12 +- .../liferay-resource-export-structure.ts | 12 +- .../liferay-resource-export-structures.ts | 4 +- .../liferay-resource-export-template.ts | 12 +- .../liferay-resource-export-templates.ts | 4 +- .../resource/liferay-resource-get-adt.ts | 2 +- .../resource/liferay-resource-import-adts.ts | 87 -------- .../liferay-resource-import-shared.ts | 198 +++++++++++++++++- .../liferay-resource-import-structures.ts | 94 --------- .../liferay-resource-import-templates.ts | 84 -------- .../resource/liferay-resource-paths.ts | 58 ----- .../resource/liferay-resource-sync-adt.ts | 2 +- ...iferay-resource-sync-fragments-importer.ts | 155 -------------- .../liferay-resource-sync-fragments-local.ts | 4 +- .../liferay-resource-sync-fragments.ts | 134 +++++++++++- .../liferay-resource-sync-structure-shared.ts | 22 -- .../liferay-resource-sync-structure-utils.ts | 15 -- .../liferay-resource-sync-structure.ts | 2 +- .../liferay-resource-sync-template.ts | 2 +- .../liferay/resource/migration/init.ts | 2 +- .../liferay/resource/migration/pipeline.ts | 2 +- .../resource/migration/structure-content.ts | 54 ++--- .../sync-strategies/adt-sync-strategy.ts | 11 +- .../resource/sync-strategies/shared.ts | 16 ++ .../structure-sync-strategy.ts | 30 ++- .../sync-strategies/template-sync-strategy.ts | 31 ++- tests/unit/liferay-artifact-paths.test.ts | 2 +- tests/unit/liferay-resource-import.test.ts | 8 +- 40 files changed, 473 insertions(+), 703 deletions(-) delete mode 100644 src/features/liferay/portal/site-token.ts delete mode 100644 src/features/liferay/resource/artifact-paths.ts delete mode 100644 src/features/liferay/resource/liferay-resource-import-adts.ts delete mode 100644 src/features/liferay/resource/liferay-resource-import-structures.ts delete mode 100644 src/features/liferay/resource/liferay-resource-import-templates.ts delete mode 100644 src/features/liferay/resource/liferay-resource-paths.ts delete mode 100644 src/features/liferay/resource/liferay-resource-sync-fragments-importer.ts delete mode 100644 src/features/liferay/resource/liferay-resource-sync-structure-shared.ts delete mode 100644 src/features/liferay/resource/liferay-resource-sync-structure-utils.ts create mode 100644 src/features/liferay/resource/sync-strategies/shared.ts diff --git a/src/commands/resource/resource-import-commands.ts b/src/commands/resource/resource-import-commands.ts index e09d9676..f2d8f5a9 100644 --- a/src/commands/resource/resource-import-commands.ts +++ b/src/commands/resource/resource-import-commands.ts @@ -7,20 +7,12 @@ import { requireResourceValue, } from './resource-workflow.js'; import { - formatLiferayResourceImportAdts, - getLiferayResourceImportAdtsExitCode, + formatLiferayResourceImportResult, + getLiferayResourceImportExitCode, runLiferayResourceImportAdts, -} from '../../features/liferay/resource/liferay-resource-import-adts.js'; -import { - formatLiferayResourceImportStructures, - getLiferayResourceImportStructuresExitCode, runLiferayResourceImportStructures, -} from '../../features/liferay/resource/liferay-resource-import-structures.js'; -import { - formatLiferayResourceImportTemplates, - getLiferayResourceImportTemplatesExitCode, runLiferayResourceImportTemplates, -} from '../../features/liferay/resource/liferay-resource-import-templates.js'; +} from '../../features/liferay/resource/liferay-resource-import-shared.js'; import { formatLiferayResourceSyncAdt, runLiferayResourceSyncAdt, @@ -211,7 +203,7 @@ export function registerResourceImportCommands(resource: Command): void { allowBreakingChange: Boolean(options.allowBreakingChange), continueOnError: Boolean(options.continueOnError), }), - render: {text: formatLiferayResourceImportStructures, exitCode: getLiferayResourceImportStructuresExitCode}, + render: {text: formatLiferayResourceImportResult, exitCode: getLiferayResourceImportExitCode}, }); registerResourceWorkflow(resource, { @@ -243,7 +235,7 @@ export function registerResourceImportCommands(resource: Command): void { structureKey: options.structure as string | undefined, continueOnError: Boolean(options.continueOnError), }), - render: {text: formatLiferayResourceImportTemplates, exitCode: getLiferayResourceImportTemplatesExitCode}, + render: {text: formatLiferayResourceImportResult, exitCode: getLiferayResourceImportExitCode}, }); registerResourceWorkflow(resource, { @@ -277,6 +269,6 @@ export function registerResourceImportCommands(resource: Command): void { createMissing: Boolean(options.createMissing), continueOnError: Boolean(options.continueOnError), }), - render: {text: formatLiferayResourceImportAdts, exitCode: getLiferayResourceImportAdtsExitCode}, + render: {text: formatLiferayResourceImportResult, exitCode: getLiferayResourceImportExitCode}, }); } diff --git a/src/features/liferay/content/liferay-content-journal-shared.ts b/src/features/liferay/content/liferay-content-journal-shared.ts index 175247a8..bf111e83 100644 --- a/src/features/liferay/content/liferay-content-journal-shared.ts +++ b/src/features/liferay/content/liferay-content-journal-shared.ts @@ -197,15 +197,15 @@ function dedupeJournalRows(rows: JsonwsJournalArticleRow[]): JsonwsJournalArticl return [...unique.values()]; } -function isGatewayError(error: unknown): error is CliError { +export function isGatewayError(error: unknown): error is CliError { return error instanceof CliError && error.code === 'LIFERAY_GATEWAY_ERROR'; } -function isGatewayStatus(error: unknown, status: number): boolean { +export function isGatewayStatus(error: unknown, status: number): boolean { return isGatewayError(error) && error.message.includes(`status=${status}`); } -function getGatewayStatus(error: CliError): number | undefined { +export function getGatewayStatus(error: CliError): number | undefined { const match = /status=(\d+)/.exec(error.message); if (!match) { return undefined; diff --git a/src/features/liferay/content/liferay-content-prune-context.ts b/src/features/liferay/content/liferay-content-prune-context.ts index 971a88c4..741dc70a 100644 --- a/src/features/liferay/content/liferay-content-prune-context.ts +++ b/src/features/liferay/content/liferay-content-prune-context.ts @@ -1,5 +1,4 @@ import type {AppConfig} from '../../../core/config/load-config.js'; -import {CliError} from '../../../core/errors.js'; import {createOAuthTokenClient, type OAuthTokenClient} from '../../../core/http/auth.js'; import {createLiferayApiClient, type HttpApiClient} from '../../../core/http/client.js'; import type {Printer} from '../../../core/output/printer.js'; @@ -88,20 +87,4 @@ export function isPresentNumber(value: number | undefined): value is number { return value !== undefined && Number.isFinite(value); } -export function isGatewayError(error: unknown): error is CliError { - return error instanceof CliError && error.code === 'LIFERAY_GATEWAY_ERROR'; -} - -export function isGatewayStatus(error: unknown, status: number): boolean { - return isGatewayError(error) && error.message.includes(`status=${status}`); -} - -export function getGatewayStatus(error: CliError): number | undefined { - const match = /status=(\d+)/.exec(error.message); - if (!match) { - return undefined; - } - - const value = Number(match[1]); - return Number.isFinite(value) ? value : undefined; -} +export {isGatewayError, isGatewayStatus, getGatewayStatus} from './liferay-content-journal-shared.js'; diff --git a/src/features/liferay/content/liferay-content-prune-execute.ts b/src/features/liferay/content/liferay-content-prune-execute.ts index f2325fe0..e6e54b3c 100644 --- a/src/features/liferay/content/liferay-content-prune-execute.ts +++ b/src/features/liferay/content/liferay-content-prune-execute.ts @@ -3,7 +3,7 @@ import type {Printer} from '../../../core/output/printer.js'; import {runStep} from '../../../core/output/run-step.js'; import {LiferayErrors} from '../errors/index.js'; import type {LiferayGateway} from '../liferay-gateway.js'; -import {getGatewayStatus, isGatewayError, isGatewayStatus} from './liferay-content-prune-context.js'; +import {getGatewayStatus, isGatewayError, isGatewayStatus} from './liferay-content-journal-shared.js'; import type {PruneContext} from './liferay-content-prune-context.js'; import { computeRemovableFolderIds, diff --git a/src/features/liferay/content/liferay-content-prune-folders.ts b/src/features/liferay/content/liferay-content-prune-folders.ts index 06a1b100..98e4c02f 100644 --- a/src/features/liferay/content/liferay-content-prune-folders.ts +++ b/src/features/liferay/content/liferay-content-prune-folders.ts @@ -1,6 +1,6 @@ import type {LiferayGateway} from '../liferay-gateway.js'; import {LiferayErrors} from '../errors/index.js'; -import {getGatewayStatus, isGatewayError, isGatewayStatus} from './liferay-content-prune-context.js'; +import {getGatewayStatus, isGatewayError, isGatewayStatus} from './liferay-content-journal-shared.js'; export type HeadlessFolder = { id?: number; diff --git a/src/features/liferay/content/liferay-content-stats.ts b/src/features/liferay/content/liferay-content-stats.ts index a646d34c..384fe9f9 100644 --- a/src/features/liferay/content/liferay-content-stats.ts +++ b/src/features/liferay/content/liferay-content-stats.ts @@ -1,6 +1,6 @@ import type {AppConfig} from '../../../core/config/load-config.js'; import {createConcurrencyLimiter, mapConcurrent} from '../../../core/concurrency.js'; -import {CliError} from '../../../core/errors.js'; +import {isGatewayError, getGatewayStatus} from './liferay-content-journal-shared.js'; import {createOAuthTokenClient, type OAuthTokenClient} from '../../../core/http/auth.js'; import {createLiferayApiClient, type HttpApiClient} from '../../../core/http/client.js'; import type {Printer} from '../../../core/output/printer.js'; @@ -489,17 +489,3 @@ function compareSites(left: ContentStatsSite, right: ContentStatsSite, sortBy: ' return compareSitesByVolume(left, right); } - -function isGatewayError(error: unknown): error is CliError { - return error instanceof CliError && error.code === 'LIFERAY_GATEWAY_ERROR'; -} - -function getGatewayStatus(error: CliError): number | undefined { - const match = /status=(\d+)/.exec(error.message); - if (!match) { - return undefined; - } - - const value = Number(match[1]); - return Number.isFinite(value) ? value : undefined; -} diff --git a/src/features/liferay/inventory/liferay-inventory-page-fetch-fragments.ts b/src/features/liferay/inventory/liferay-inventory-page-fetch-fragments.ts index 3ae9f9c8..47264397 100644 --- a/src/features/liferay/inventory/liferay-inventory-page-fetch-fragments.ts +++ b/src/features/liferay/inventory/liferay-inventory-page-fetch-fragments.ts @@ -5,8 +5,7 @@ import type {HttpApiClient} from '../../../core/http/client.js'; import {type FragmentEntryLink, type PageFragmentEntry} from './liferay-inventory-page-assemble.js'; import type {LiferayGateway} from '../liferay-gateway.js'; import {buildSiteChain} from '../portal/site-resolution.js'; -import {resolveSiteToken} from '../portal/site-token.js'; -import {tryResolveFragmentsBaseDir} from '../portal/artifact-paths.js'; +import {resolveSiteToken, tryResolveFragmentsBaseDir} from '../portal/artifact-paths.js'; import {safeGatewayGet} from './liferay-inventory-page-fetch-http.js'; export async function tryFetchFragmentEntryLinks( diff --git a/src/features/liferay/inventory/liferay-inventory-page-fetch-journal.ts b/src/features/liferay/inventory/liferay-inventory-page-fetch-journal.ts index 49b9b5bc..086d6ab9 100644 --- a/src/features/liferay/inventory/liferay-inventory-page-fetch-journal.ts +++ b/src/features/liferay/inventory/liferay-inventory-page-fetch-journal.ts @@ -24,8 +24,7 @@ import type {LiferayGateway} from '../liferay-gateway.js'; import {buildSiteChain, fetchGroupInfo} from '../portal/site-resolution.js'; import {listDdmTemplates, resolveResourceSite} from '../portal/template-queries.js'; import {matchesDdmTemplate} from '../liferay-identifiers.js'; -import {resolveSiteToken} from '../portal/site-token.js'; -import {tryResolveArtifactSiteDir} from '../portal/artifact-paths.js'; +import {resolveSiteToken, tryResolveArtifactSiteDir} from '../portal/artifact-paths.js'; import {type ArticleRef, fetchContentStructureById} from './liferay-inventory-page-fetch-article.js'; import {resolveJournalArticleReference} from './liferay-inventory-journal-article-resolver.js'; import {safeGatewayGet} from './liferay-inventory-page-fetch-http.js'; diff --git a/src/features/liferay/portal/artifact-paths.ts b/src/features/liferay/portal/artifact-paths.ts index 92d938b3..2cc41841 100644 --- a/src/features/liferay/portal/artifact-paths.ts +++ b/src/features/liferay/portal/artifact-paths.ts @@ -5,9 +5,14 @@ import {CliError} from '../../../core/errors.js'; import type {AppConfig} from '../../../core/config/load-config.js'; import {LIFERAY_RESOURCE_PATH_DEFAULTS} from '../../../core/config/liferay-resource-path-defaults.js'; import {LiferayErrors} from '../errors/index.js'; -import {resolveSiteToken, siteTokenToFriendlyUrl} from './site-token.js'; +export function resolveSiteToken(siteFriendlyUrl: string): string { + const token = siteFriendlyUrl.replace(/^\//, '').trim(); + return token === '' ? 'global' : token; +} -export {resolveSiteToken, siteTokenToFriendlyUrl}; +export function siteTokenToFriendlyUrl(token: string): string { + return token === 'global' ? '/global' : `/${token}`; +} export type ArtifactType = 'template' | 'structure' | 'adt' | 'fragment'; @@ -149,6 +154,28 @@ export async function resolveArtifactFile(config: AppConfig, options: ResolveArt } } +export async function resolveStructureFile(config: AppConfig, key: string, file?: string): Promise { + return resolveArtifactFile(config, {type: 'structure', key, fileOverride: file}); +} + +export async function resolveTemplateFile( + config: AppConfig, + siteToken: string, + name: string, + file?: string, +): Promise { + return resolveArtifactFile(config, {type: 'template', key: name, siteToken, fileOverride: file}); +} + +export async function resolveAdtFile( + config: AppConfig, + name: string, + widgetType: string, + file?: string, +): Promise { + return resolveArtifactFile(config, {type: 'adt', key: name, widgetType, fileOverride: file}); +} + export function resolveArtifactSiteDir( config: AppConfig, type: ArtifactType, diff --git a/src/features/liferay/portal/site-token.ts b/src/features/liferay/portal/site-token.ts deleted file mode 100644 index 413f63ce..00000000 --- a/src/features/liferay/portal/site-token.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Converts a site friendly URL to a safe filesystem directory token. */ -export function resolveSiteToken(siteFriendlyUrl: string): string { - const token = siteFriendlyUrl.replace(/^\//, '').trim(); - return token === '' ? 'global' : token; -} - -/** Inverse of resolveSiteToken: converts a directory token back to a site friendly URL. */ -export function siteTokenToFriendlyUrl(token: string): string { - return token === 'global' ? '/global' : `/${token}`; -} diff --git a/src/features/liferay/resource/artifact-paths.ts b/src/features/liferay/resource/artifact-paths.ts deleted file mode 100644 index 4c10e602..00000000 --- a/src/features/liferay/resource/artifact-paths.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../portal/artifact-paths.js'; diff --git a/src/features/liferay/resource/liferay-resource-export-adts.ts b/src/features/liferay/resource/liferay-resource-export-adts.ts index 6d10e079..0abe47d5 100644 --- a/src/features/liferay/resource/liferay-resource-export-adts.ts +++ b/src/features/liferay/resource/liferay-resource-export-adts.ts @@ -6,8 +6,8 @@ import type {OAuthTokenClient} from '../../../core/http/auth.js'; import type {HttpApiClient} from '../../../core/http/client.js'; import {runLiferayInventorySitesIncludingGlobal} from '../inventory/liferay-inventory-sites.js'; import {runLiferayResourceListAdts} from './liferay-resource-list-adts.js'; -import {resolveSiteToken, ADT_WIDGET_DIR_BY_TYPE} from './liferay-resource-paths.js'; -import {resolveArtifactBaseDir, sanitizeArtifactToken} from './artifact-paths.js'; +import {resolveSiteToken, ADT_WIDGET_DIR_BY_TYPE} from '../portal/artifact-paths.js'; +import {resolveArtifactBaseDir, sanitizeArtifactToken} from '../portal/artifact-paths.js'; import {resolveResourceSite} from './liferay-resource-shared.js'; type ResourceDependencies = { diff --git a/src/features/liferay/resource/liferay-resource-export-fragments.ts b/src/features/liferay/resource/liferay-resource-export-fragments.ts index 5bbc5c7c..10699bec 100644 --- a/src/features/liferay/resource/liferay-resource-export-fragments.ts +++ b/src/features/liferay/resource/liferay-resource-export-fragments.ts @@ -2,18 +2,12 @@ import fs from 'fs-extra'; import path from 'node:path'; import type {AppConfig} from '../../../core/config/load-config.js'; -import type {OAuthTokenClient} from '../../../core/http/auth.js'; -import type {HttpApiClient} from '../../../core/http/client.js'; import {isRecord, parseJsonUnknown} from '../../../core/utils/json.js'; import {runLiferayInventorySitesIncludingGlobal} from '../inventory/liferay-inventory-sites.js'; import {listFragmentCollections, listFragments, resolveResourceSite} from './liferay-resource-shared.js'; -import {resolveSiteToken} from './liferay-resource-paths.js'; -import {resolveArtifactBaseDir, resolveArtifactSiteDir, sanitizeArtifactToken} from './artifact-paths.js'; - -type ResourceDependencies = { - apiClient?: HttpApiClient; - tokenClient?: OAuthTokenClient; -}; +import {resolveSiteToken} from '../portal/artifact-paths.js'; +import {resolveArtifactBaseDir, resolveArtifactSiteDir, sanitizeArtifactToken} from '../portal/artifact-paths.js'; +import type {ResourceSyncDependencies as ResourceDependencies} from './liferay-resource-sync-shared.js'; export type LiferayResourceExportFragmentsResult = { mode?: 'all-sites'; diff --git a/src/features/liferay/resource/liferay-resource-export-structure.ts b/src/features/liferay/resource/liferay-resource-export-structure.ts index 389d885b..8a57919e 100644 --- a/src/features/liferay/resource/liferay-resource-export-structure.ts +++ b/src/features/liferay/resource/liferay-resource-export-structure.ts @@ -1,17 +1,11 @@ import type {AppConfig} from '../../../core/config/load-config.js'; -import type {OAuthTokenClient} from '../../../core/http/auth.js'; -import type {HttpApiClient} from '../../../core/http/client.js'; -import {resolveSiteToken} from './liferay-resource-paths.js'; +import {resolveSiteToken} from '../portal/artifact-paths.js'; import {runLiferayResourceGetStructure} from './liferay-resource-get-structure.js'; import {writeLiferayResourceFile} from './liferay-resource-export-shared.js'; import {normalizeLiferayStructurePayload} from './liferay-resource-structure-normalize.js'; import path from 'node:path'; -import {resolveArtifactSiteDir} from './artifact-paths.js'; - -type ResourceDependencies = { - apiClient?: HttpApiClient; - tokenClient?: OAuthTokenClient; -}; +import {resolveArtifactSiteDir} from '../portal/artifact-paths.js'; +import type {ResourceSyncDependencies as ResourceDependencies} from './liferay-resource-sync-shared.js'; export async function runLiferayResourceExportStructure( config: AppConfig, diff --git a/src/features/liferay/resource/liferay-resource-export-structures.ts b/src/features/liferay/resource/liferay-resource-export-structures.ts index e4eeb1ba..d2a3124f 100644 --- a/src/features/liferay/resource/liferay-resource-export-structures.ts +++ b/src/features/liferay/resource/liferay-resource-export-structures.ts @@ -8,8 +8,8 @@ import {runLiferayInventorySitesIncludingGlobal} from '../inventory/liferay-inve import {runLiferayInventoryStructures} from '../inventory/liferay-inventory-structures.js'; import {runLiferayResourceGetStructure} from './liferay-resource-get-structure.js'; import {writeLiferayResourceFile} from './liferay-resource-export-shared.js'; -import {resolveSiteToken} from './liferay-resource-paths.js'; -import {resolveArtifactSiteDir} from './artifact-paths.js'; +import {resolveSiteToken} from '../portal/artifact-paths.js'; +import {resolveArtifactSiteDir} from '../portal/artifact-paths.js'; import {normalizeLiferayStructurePayload} from './liferay-resource-structure-normalize.js'; type ResourceDependencies = { diff --git a/src/features/liferay/resource/liferay-resource-export-template.ts b/src/features/liferay/resource/liferay-resource-export-template.ts index 294bab6e..2d582370 100644 --- a/src/features/liferay/resource/liferay-resource-export-template.ts +++ b/src/features/liferay/resource/liferay-resource-export-template.ts @@ -1,17 +1,11 @@ import type {AppConfig} from '../../../core/config/load-config.js'; -import type {OAuthTokenClient} from '../../../core/http/auth.js'; -import type {HttpApiClient} from '../../../core/http/client.js'; import path from 'node:path'; -import {resolveSiteToken} from './liferay-resource-paths.js'; +import {resolveSiteToken} from '../portal/artifact-paths.js'; import {runLiferayResourceGetTemplate} from './liferay-resource-get-template.js'; import fs from 'fs-extra'; import {normalizeLiferayTemplateScript} from './liferay-resource-template-normalize.js'; -import {resolveArtifactSiteDir} from './artifact-paths.js'; - -type ResourceDependencies = { - apiClient?: HttpApiClient; - tokenClient?: OAuthTokenClient; -}; +import {resolveArtifactSiteDir} from '../portal/artifact-paths.js'; +import type {ResourceSyncDependencies as ResourceDependencies} from './liferay-resource-sync-shared.js'; export async function runLiferayResourceExportTemplate( config: AppConfig, diff --git a/src/features/liferay/resource/liferay-resource-export-templates.ts b/src/features/liferay/resource/liferay-resource-export-templates.ts index 5e62afa8..3e1a8b2b 100644 --- a/src/features/liferay/resource/liferay-resource-export-templates.ts +++ b/src/features/liferay/resource/liferay-resource-export-templates.ts @@ -8,8 +8,8 @@ import {LiferayErrors} from '../errors/index.js'; import {runLiferayInventorySitesIncludingGlobal} from '../inventory/liferay-inventory-sites.js'; import {runLiferayInventoryTemplates, type LiferayInventoryTemplate} from '../inventory/liferay-inventory-templates.js'; import {listDdmTemplates} from './liferay-resource-shared.js'; -import {resolveSiteToken} from './liferay-resource-paths.js'; -import {resolveArtifactBaseDir, sanitizeArtifactToken} from './artifact-paths.js'; +import {resolveSiteToken} from '../portal/artifact-paths.js'; +import {resolveArtifactBaseDir, sanitizeArtifactToken} from '../portal/artifact-paths.js'; import {resolveResourceSite} from './liferay-resource-shared.js'; import {normalizeLiferayTemplateScript} from './liferay-resource-template-normalize.js'; diff --git a/src/features/liferay/resource/liferay-resource-get-adt.ts b/src/features/liferay/resource/liferay-resource-get-adt.ts index 2461dd40..dc52c6c6 100644 --- a/src/features/liferay/resource/liferay-resource-get-adt.ts +++ b/src/features/liferay/resource/liferay-resource-get-adt.ts @@ -4,7 +4,7 @@ import type {OAuthTokenClient} from '../../../core/http/auth.js'; import type {HttpApiClient} from '../../../core/http/client.js'; import {LiferayErrors} from '../errors/index.js'; import {runLiferayInventorySitesIncludingGlobal} from '../inventory/liferay-inventory-sites.js'; -import {ADT_WIDGET_DIR_BY_TYPE} from './liferay-resource-paths.js'; +import {ADT_WIDGET_DIR_BY_TYPE} from '../portal/artifact-paths.js'; import {runLiferayResourceListAdts} from './liferay-resource-list-adts.js'; import {buildSiteChain} from '../portal/site-resolution.js'; import {matchesAdtRow, normalizeAdtIdentifier} from '../liferay-identifiers.js'; diff --git a/src/features/liferay/resource/liferay-resource-import-adts.ts b/src/features/liferay/resource/liferay-resource-import-adts.ts deleted file mode 100644 index aca26c62..00000000 --- a/src/features/liferay/resource/liferay-resource-import-adts.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type {AppConfig} from '../../../core/config/load-config.js'; -import {LiferayErrors} from '../errors/index.js'; -import { - formatLiferayResourceImportResult, - getLiferayResourceImportExitCode, - normalizeImportKeys, - resolveDefaultOrSiteBaseDir, - runLiferayResourceFileImport, - unwrapLiferayResourceImportFailure, - type LiferayResourceImportResult, -} from './liferay-resource-import-shared.js'; -import type {ResourceSyncDependencies} from './liferay-resource-sync-shared.js'; -import {runLiferayResourceSyncAdt} from './liferay-resource-sync-adt.js'; - -export type LiferayResourceImportAdtsResult = LiferayResourceImportResult; - -export async function runLiferayResourceImportAdts( - config: AppConfig, - options?: { - site?: string; - dir?: string; - allSites?: boolean; - apply?: boolean; - adtKeys?: string[]; - checkOnly?: boolean; - createMissing?: boolean; - widgetType?: string; - className?: string; - continueOnError?: boolean; - }, - dependencies?: ResourceSyncDependencies, -): Promise { - const adtKeys = normalizeImportKeys(options?.adtKeys); - const hasScopedFilter = - adtKeys.length > 0 || Boolean(options?.widgetType?.trim()) || Boolean(options?.className?.trim()); - if (!options?.allSites && !options?.apply && !hasScopedFilter) { - throw LiferayErrors.resourceError( - 'resource import-adts requires --adt (repeatable), --widget-type, --class-name, --apply for the resolved site, or --all-sites to avoid accidental mass imports.', - ); - } - - try { - return await runLiferayResourceFileImport(config, { - artifactType: 'adt', - dir: options?.dir, - site: options?.site, - allSites: Boolean(options?.allSites), - continueOnError: Boolean(options?.continueOnError), - extension: '.ftl', - allowedKeys: adtKeys, - resolveSiteDirs: resolveDefaultOrSiteBaseDir, - runEntry: (site, file) => - runLiferayResourceSyncAdt( - config, - { - site, - file, - widgetType: options?.widgetType, - className: options?.className, - checkOnly: Boolean(options?.checkOnly), - createMissing: Boolean(options?.createMissing), - }, - dependencies, - ), - formatFailure: (failure) => - `Import failed for ADT '${failure.entry}' in site '${failure.site}': ${failure.message}`, - }); - } catch (error) { - const failure = unwrapLiferayResourceImportFailure(error); - if (failure) { - throw LiferayErrors.resourceError( - `Import failed for ADT '${failure.entry}' in site '${failure.site}': ${failure.message}`, - {details: failure}, - ); - } - - throw error; - } -} - -export function formatLiferayResourceImportAdts(result: LiferayResourceImportAdtsResult): string { - return formatLiferayResourceImportResult(result); -} - -export function getLiferayResourceImportAdtsExitCode(result: LiferayResourceImportAdtsResult): number { - return getLiferayResourceImportExitCode(result); -} diff --git a/src/features/liferay/resource/liferay-resource-import-shared.ts b/src/features/liferay/resource/liferay-resource-import-shared.ts index 61566a34..b215f528 100644 --- a/src/features/liferay/resource/liferay-resource-import-shared.ts +++ b/src/features/liferay/resource/liferay-resource-import-shared.ts @@ -3,7 +3,12 @@ import path from 'node:path'; import {normalizeCliError} from '../../../core/errors.js'; import type {AppConfig} from '../../../core/config/load-config.js'; -import {resolveArtifactBaseDir, resolveSiteToken, siteTokenToFriendlyUrl} from './artifact-paths.js'; +import {LiferayErrors} from '../errors/index.js'; +import {resolveArtifactBaseDir, resolveSiteToken, siteTokenToFriendlyUrl} from '../portal/artifact-paths.js'; +import type {ResourceSyncDependencies} from './liferay-resource-sync-shared.js'; +import {runLiferayResourceSyncAdt} from './liferay-resource-sync-adt.js'; +import {runLiferayResourceSyncStructure} from './liferay-resource-sync-structure.js'; +import {runLiferayResourceSyncTemplate} from './liferay-resource-sync-template.js'; export type LiferayResourceImportFailure = { site: string; @@ -200,3 +205,194 @@ function isImportFailure(value: unknown): value is LiferayResourceImportFailure 'message' in value ); } + +export async function runLiferayResourceImportStructures( + config: AppConfig, + options?: { + site?: string; + dir?: string; + allSites?: boolean; + apply?: boolean; + structureKeys?: string[]; + checkOnly?: boolean; + createMissing?: boolean; + skipUpdate?: boolean; + migrationPlan?: string; + migrationPhase?: string; + migrationDryRun?: boolean; + cleanupMigration?: boolean; + allowBreakingChange?: boolean; + continueOnError?: boolean; + }, + dependencies?: ResourceSyncDependencies, +): Promise { + const structureKeys = normalizeImportKeys(options?.structureKeys); + if (!options?.allSites && !options?.apply && structureKeys.length === 0) { + throw LiferayErrors.resourceError( + 'resource import-structures requires --structure (repeatable), --apply for the resolved site, or --all-sites to avoid accidental mass imports.', + ); + } + + try { + return await runLiferayResourceFileImport(config, { + artifactType: 'structure', + dir: options?.dir, + site: options?.site, + allSites: Boolean(options?.allSites), + continueOnError: Boolean(options?.continueOnError), + extension: '.json', + allowedKeys: structureKeys, + runEntry: (site, file) => + runLiferayResourceSyncStructure( + config, + { + site, + key: path.basename(file, '.json'), + file, + checkOnly: Boolean(options?.checkOnly), + createMissing: Boolean(options?.createMissing), + skipUpdate: Boolean(options?.skipUpdate), + migrationPlan: options?.migrationPlan, + migrationPhase: options?.migrationPhase, + migrationDryRun: Boolean(options?.migrationDryRun), + cleanupMigration: Boolean(options?.cleanupMigration), + allowBreakingChange: Boolean(options?.allowBreakingChange), + }, + dependencies, + ), + formatFailure: (failure) => + `Import failed for structure '${failure.entry}' in site '${failure.site}': ${failure.message}`, + }); + } catch (error) { + const failure = unwrapLiferayResourceImportFailure(error); + if (failure) { + throw LiferayErrors.resourceError( + `Import failed for structure '${failure.entry}' in site '${failure.site}': ${failure.message}`, + {details: failure}, + ); + } + throw error; + } +} + +export async function runLiferayResourceImportTemplates( + config: AppConfig, + options?: { + site?: string; + dir?: string; + allSites?: boolean; + apply?: boolean; + templateKeys?: string[]; + checkOnly?: boolean; + createMissing?: boolean; + structureKey?: string; + continueOnError?: boolean; + }, + dependencies?: ResourceSyncDependencies, +): Promise { + const templateKeys = normalizeImportKeys(options?.templateKeys); + if (!options?.allSites && !options?.apply && templateKeys.length === 0) { + throw LiferayErrors.resourceError( + 'resource import-templates requires --template (repeatable), --apply for the resolved site, or --all-sites to avoid accidental mass imports.', + ); + } + + try { + return await runLiferayResourceFileImport(config, { + artifactType: 'template', + dir: options?.dir, + site: options?.site, + allSites: Boolean(options?.allSites), + continueOnError: Boolean(options?.continueOnError), + extension: '.ftl', + allowedKeys: templateKeys, + runEntry: (site, file) => + runLiferayResourceSyncTemplate( + config, + { + site, + key: path.basename(file, '.ftl'), + file, + structureKey: options?.structureKey, + checkOnly: Boolean(options?.checkOnly), + createMissing: Boolean(options?.createMissing), + }, + dependencies, + ), + formatFailure: (failure) => + `Import failed for template '${failure.entry}' in site '${failure.site}': ${failure.message}`, + }); + } catch (error) { + const failure = unwrapLiferayResourceImportFailure(error); + if (failure) { + throw LiferayErrors.resourceError( + `Import failed for template '${failure.entry}' in site '${failure.site}': ${failure.message}`, + {details: failure}, + ); + } + throw error; + } +} + +export async function runLiferayResourceImportAdts( + config: AppConfig, + options?: { + site?: string; + dir?: string; + allSites?: boolean; + apply?: boolean; + adtKeys?: string[]; + checkOnly?: boolean; + createMissing?: boolean; + widgetType?: string; + className?: string; + continueOnError?: boolean; + }, + dependencies?: ResourceSyncDependencies, +): Promise { + const adtKeys = normalizeImportKeys(options?.adtKeys); + const hasScopedFilter = + adtKeys.length > 0 || Boolean(options?.widgetType?.trim()) || Boolean(options?.className?.trim()); + if (!options?.allSites && !options?.apply && !hasScopedFilter) { + throw LiferayErrors.resourceError( + 'resource import-adts requires --adt (repeatable), --widget-type, --class-name, --apply for the resolved site, or --all-sites to avoid accidental mass imports.', + ); + } + + try { + return await runLiferayResourceFileImport(config, { + artifactType: 'adt', + dir: options?.dir, + site: options?.site, + allSites: Boolean(options?.allSites), + continueOnError: Boolean(options?.continueOnError), + extension: '.ftl', + allowedKeys: adtKeys, + resolveSiteDirs: resolveDefaultOrSiteBaseDir, + runEntry: (site, file) => + runLiferayResourceSyncAdt( + config, + { + site, + file, + widgetType: options?.widgetType, + className: options?.className, + checkOnly: Boolean(options?.checkOnly), + createMissing: Boolean(options?.createMissing), + }, + dependencies, + ), + formatFailure: (failure) => + `Import failed for ADT '${failure.entry}' in site '${failure.site}': ${failure.message}`, + }); + } catch (error) { + const failure = unwrapLiferayResourceImportFailure(error); + if (failure) { + throw LiferayErrors.resourceError( + `Import failed for ADT '${failure.entry}' in site '${failure.site}': ${failure.message}`, + {details: failure}, + ); + } + throw error; + } +} diff --git a/src/features/liferay/resource/liferay-resource-import-structures.ts b/src/features/liferay/resource/liferay-resource-import-structures.ts deleted file mode 100644 index 09a057c5..00000000 --- a/src/features/liferay/resource/liferay-resource-import-structures.ts +++ /dev/null @@ -1,94 +0,0 @@ -import path from 'node:path'; - -import type {AppConfig} from '../../../core/config/load-config.js'; -import {LiferayErrors} from '../errors/index.js'; -import { - formatLiferayResourceImportResult, - getLiferayResourceImportExitCode, - normalizeImportKeys, - runLiferayResourceFileImport, - unwrapLiferayResourceImportFailure, - type LiferayResourceImportResult, -} from './liferay-resource-import-shared.js'; -import type {ResourceSyncDependencies} from './liferay-resource-sync-shared.js'; -import {runLiferayResourceSyncStructure} from './liferay-resource-sync-structure.js'; - -export type LiferayResourceImportStructuresResult = LiferayResourceImportResult; - -export async function runLiferayResourceImportStructures( - config: AppConfig, - options?: { - site?: string; - dir?: string; - allSites?: boolean; - apply?: boolean; - structureKeys?: string[]; - checkOnly?: boolean; - createMissing?: boolean; - skipUpdate?: boolean; - migrationPlan?: string; - migrationPhase?: string; - migrationDryRun?: boolean; - cleanupMigration?: boolean; - allowBreakingChange?: boolean; - continueOnError?: boolean; - }, - dependencies?: ResourceSyncDependencies, -): Promise { - const structureKeys = normalizeImportKeys(options?.structureKeys); - if (!options?.allSites && !options?.apply && structureKeys.length === 0) { - throw LiferayErrors.resourceError( - 'resource import-structures requires --structure (repeatable), --apply for the resolved site, or --all-sites to avoid accidental mass imports.', - ); - } - - try { - return await runLiferayResourceFileImport(config, { - artifactType: 'structure', - dir: options?.dir, - site: options?.site, - allSites: Boolean(options?.allSites), - continueOnError: Boolean(options?.continueOnError), - extension: '.json', - allowedKeys: structureKeys, - runEntry: (site, file) => - runLiferayResourceSyncStructure( - config, - { - site, - key: path.basename(file, '.json'), - file, - checkOnly: Boolean(options?.checkOnly), - createMissing: Boolean(options?.createMissing), - skipUpdate: Boolean(options?.skipUpdate), - migrationPlan: options?.migrationPlan, - migrationPhase: options?.migrationPhase, - migrationDryRun: Boolean(options?.migrationDryRun), - cleanupMigration: Boolean(options?.cleanupMigration), - allowBreakingChange: Boolean(options?.allowBreakingChange), - }, - dependencies, - ), - formatFailure: (failure) => - `Import failed for structure '${failure.entry}' in site '${failure.site}': ${failure.message}`, - }); - } catch (error) { - const failure = unwrapLiferayResourceImportFailure(error); - if (failure) { - throw LiferayErrors.resourceError( - `Import failed for structure '${failure.entry}' in site '${failure.site}': ${failure.message}`, - {details: failure}, - ); - } - - throw error; - } -} - -export function formatLiferayResourceImportStructures(result: LiferayResourceImportStructuresResult): string { - return formatLiferayResourceImportResult(result); -} - -export function getLiferayResourceImportStructuresExitCode(result: LiferayResourceImportStructuresResult): number { - return getLiferayResourceImportExitCode(result); -} diff --git a/src/features/liferay/resource/liferay-resource-import-templates.ts b/src/features/liferay/resource/liferay-resource-import-templates.ts deleted file mode 100644 index afc84a3a..00000000 --- a/src/features/liferay/resource/liferay-resource-import-templates.ts +++ /dev/null @@ -1,84 +0,0 @@ -import path from 'node:path'; - -import type {AppConfig} from '../../../core/config/load-config.js'; -import {LiferayErrors} from '../errors/index.js'; -import { - formatLiferayResourceImportResult, - getLiferayResourceImportExitCode, - normalizeImportKeys, - runLiferayResourceFileImport, - unwrapLiferayResourceImportFailure, - type LiferayResourceImportResult, -} from './liferay-resource-import-shared.js'; -import type {ResourceSyncDependencies} from './liferay-resource-sync-shared.js'; -import {runLiferayResourceSyncTemplate} from './liferay-resource-sync-template.js'; - -export type LiferayResourceImportTemplatesResult = LiferayResourceImportResult; - -export async function runLiferayResourceImportTemplates( - config: AppConfig, - options?: { - site?: string; - dir?: string; - allSites?: boolean; - apply?: boolean; - templateKeys?: string[]; - checkOnly?: boolean; - createMissing?: boolean; - structureKey?: string; - continueOnError?: boolean; - }, - dependencies?: ResourceSyncDependencies, -): Promise { - const templateKeys = normalizeImportKeys(options?.templateKeys); - if (!options?.allSites && !options?.apply && templateKeys.length === 0) { - throw LiferayErrors.resourceError( - 'resource import-templates requires --template (repeatable), --apply for the resolved site, or --all-sites to avoid accidental mass imports.', - ); - } - - try { - return await runLiferayResourceFileImport(config, { - artifactType: 'template', - dir: options?.dir, - site: options?.site, - allSites: Boolean(options?.allSites), - continueOnError: Boolean(options?.continueOnError), - extension: '.ftl', - allowedKeys: templateKeys, - runEntry: (site, file) => - runLiferayResourceSyncTemplate( - config, - { - site, - key: path.basename(file, '.ftl'), - file, - structureKey: options?.structureKey, - checkOnly: Boolean(options?.checkOnly), - createMissing: Boolean(options?.createMissing), - }, - dependencies, - ), - formatFailure: (failure) => - `Import failed for template '${failure.entry}' in site '${failure.site}': ${failure.message}`, - }); - } catch (error) { - const failure = unwrapLiferayResourceImportFailure(error); - if (failure) { - throw LiferayErrors.resourceError( - `Import failed for template '${failure.entry}' in site '${failure.site}': ${failure.message}`, - {details: failure}, - ); - } - - throw error; - } -} - -export function formatLiferayResourceImportTemplates(result: LiferayResourceImportTemplatesResult): string { - return formatLiferayResourceImportResult(result); -} - -export function getLiferayResourceImportTemplatesExitCode(result: LiferayResourceImportTemplatesResult): number { - return getLiferayResourceImportExitCode(result); -} diff --git a/src/features/liferay/resource/liferay-resource-paths.ts b/src/features/liferay/resource/liferay-resource-paths.ts deleted file mode 100644 index 170dd0ba..00000000 --- a/src/features/liferay/resource/liferay-resource-paths.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type {AppConfig} from '../../../core/config/load-config.js'; - -export { - ADT_WIDGET_DIR_BY_TYPE, - requireRepoRoot, - resolveAdtsBaseDir, - resolveArtifactBaseDir, - resolveArtifactSiteDir, - resolveFragmentsBaseDir, - resolveMigrationsBaseDir, - resolveRepoPath, - resolveSiteToken, - resolveStructuresBaseDir, - resolveTemplatesBaseDir, - siteTokenToFriendlyUrl, - tryResolveArtifactBaseDir, - tryResolveArtifactSiteDir, - tryResolveFragmentsBaseDir, - tryResolveRepoPath, -} from './artifact-paths.js'; - -import {resolveArtifactFile} from './artifact-paths.js'; - -export async function resolveStructureFile(config: AppConfig, key: string, file?: string): Promise { - return resolveArtifactFile(config, { - type: 'structure', - key, - fileOverride: file, - }); -} - -export async function resolveTemplateFile( - config: AppConfig, - siteToken: string, - name: string, - file?: string, -): Promise { - return resolveArtifactFile(config, { - type: 'template', - key: name, - siteToken, - fileOverride: file, - }); -} - -export async function resolveAdtFile( - config: AppConfig, - name: string, - widgetType: string, - file?: string, -): Promise { - return resolveArtifactFile(config, { - type: 'adt', - key: name, - widgetType, - fileOverride: file, - }); -} diff --git a/src/features/liferay/resource/liferay-resource-sync-adt.ts b/src/features/liferay/resource/liferay-resource-sync-adt.ts index d6b2057b..43897016 100644 --- a/src/features/liferay/resource/liferay-resource-sync-adt.ts +++ b/src/features/liferay/resource/liferay-resource-sync-adt.ts @@ -9,7 +9,7 @@ import { normalizeAdtWidgetType, runLiferayResourceListAdts, } from './liferay-resource-list-adts.js'; -import {resolveAdtFile, ADT_WIDGET_DIR_BY_TYPE} from './liferay-resource-paths.js'; +import {resolveAdtFile, ADT_WIDGET_DIR_BY_TYPE} from '../portal/artifact-paths.js'; import {syncArtifact} from './sync-engine.js'; import {adtSyncStrategy} from './sync-strategies/adt-sync-strategy.js'; import {matchesAdtRow} from '../liferay-identifiers.js'; diff --git a/src/features/liferay/resource/liferay-resource-sync-fragments-importer.ts b/src/features/liferay/resource/liferay-resource-sync-fragments-importer.ts deleted file mode 100644 index c2613634..00000000 --- a/src/features/liferay/resource/liferay-resource-sync-fragments-importer.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type {AppConfig} from '../../../core/config/load-config.js'; -import {LiferayErrors} from '../errors/index.js'; -import type {ResolvedSite} from '../portal/site-resolution.js'; -import type {ResourceSyncDependencies} from './liferay-resource-sync-shared.js'; -import {createFragmentSyncRuntimeState} from './liferay-resource-sync-fragments-api.js'; -import {toErrorMessage} from './liferay-resource-sync-fragments-local.js'; -import type { - LiferayResourceSyncFragmentItemResult, - LiferayResourceSyncFragmentsSingleResult, - LocalFragment, - LocalFragmentCollection, - LocalFragmentsProject, -} from './liferay-resource-sync-fragments-types.js'; -import {syncArtifact} from './sync-engine.js'; -import {fragmentCollectionSyncStrategy} from './sync-strategies/fragment-collection-sync-strategy.js'; -import {fragmentEntrySyncStrategy} from './sync-strategies/fragment-entry-sync-strategy.js'; - -export async function runFragmentsImport( - config: AppConfig, - groupId: number, - siteFriendlyUrl: string, - projectDir: string, - project: LocalFragmentsProject, - dependencies?: ResourceSyncDependencies, -): Promise { - const site = toResolvedSite(groupId, siteFriendlyUrl); - const runtimeState = createFragmentSyncRuntimeState(); - let imported = 0; - let errors = 0; - const fragmentResults: LiferayResourceSyncFragmentItemResult[] = []; - - for (const localCollection of project.collections) { - try { - const collectionId = await syncFragmentCollection(config, site, localCollection, runtimeState, dependencies); - if (!Number.isFinite(collectionId) || collectionId <= 0) { - throw LiferayErrors.resourceError(`fragmentCollectionId invalido para ${localCollection.slug}`); - } - - for (const localFragment of localCollection.fragments) { - try { - const syncedFragmentId = await syncFragmentEntry( - config, - site, - collectionId, - localFragment, - runtimeState, - dependencies, - ); - - fragmentResults.push({ - collection: localCollection.slug, - fragment: localFragment.slug, - status: 'imported', - fragmentEntryId: syncedFragmentId, - }); - imported += 1; - } catch (error) { - fragmentResults.push({ - collection: localCollection.slug, - fragment: localFragment.slug, - status: 'error', - error: toErrorMessage(error), - }); - errors += 1; - } - } - } catch (error) { - for (const localFragment of localCollection.fragments) { - fragmentResults.push({ - collection: localCollection.slug, - fragment: localFragment.slug, - status: 'error', - error: toErrorMessage(error), - }); - errors += 1; - } - } - } - - return { - mode: 'oauth-jsonws-import', - site: siteFriendlyUrl, - siteId: groupId, - projectDir, - summary: { - importedFragments: imported, - fragmentResults: fragmentResults.length, - pageTemplateResults: 0, - errors, - }, - fragmentResults, - pageTemplateResults: [], - }; -} - -export const runFragmentsImportLegacy = runFragmentsImport; - -async function syncFragmentCollection( - config: AppConfig, - site: ResolvedSite, - collection: LocalFragmentCollection, - runtimeState: ReturnType, - dependencies?: ResourceSyncDependencies, -): Promise { - const result = await syncArtifact( - config, - site, - fragmentCollectionSyncStrategy, - { - createMissing: true, - strategyOptions: { - collection, - runtimeState, - }, - }, - dependencies, - ); - - return Number(result.id); -} - -async function syncFragmentEntry( - config: AppConfig, - site: ResolvedSite, - collectionId: number, - fragment: LocalFragment, - runtimeState: ReturnType, - dependencies?: ResourceSyncDependencies, -): Promise { - const result = await syncArtifact( - config, - site, - fragmentEntrySyncStrategy, - { - createMissing: true, - strategyOptions: { - collectionId, - fragment, - runtimeState, - }, - }, - dependencies, - ); - - const fragmentEntryId = Number(result.id); - return Number.isFinite(fragmentEntryId) ? fragmentEntryId : -1; -} - -function toResolvedSite(groupId: number, siteFriendlyUrl: string): ResolvedSite { - return { - id: groupId, - friendlyUrlPath: siteFriendlyUrl, - name: siteFriendlyUrl, - }; -} diff --git a/src/features/liferay/resource/liferay-resource-sync-fragments-local.ts b/src/features/liferay/resource/liferay-resource-sync-fragments-local.ts index c76da735..25a629f5 100644 --- a/src/features/liferay/resource/liferay-resource-sync-fragments-local.ts +++ b/src/features/liferay/resource/liferay-resource-sync-fragments-local.ts @@ -5,7 +5,7 @@ import type {AppConfig} from '../../../core/config/load-config.js'; import {isRecord, parseJsonRecord, parseJsonUnknown, type JsonRecord} from '../../../core/utils/json.js'; import {normalizeScalarString} from '../../../core/utils/text.js'; import {LiferayErrors} from '../errors/index.js'; -import {resolveFragmentProjectDir as resolveArtifactFragmentProjectDir} from './artifact-paths.js'; +import {resolveFragmentProjectDir as resolveArtifactFragmentProjectDir} from '../portal/artifact-paths.js'; import type { LocalFragment, LocalFragmentCollection, @@ -76,7 +76,7 @@ export async function readLocalFragmentsProject( }; } -export {sanitizeArtifactToken as sanitizeFileToken} from './artifact-paths.js'; +export {sanitizeArtifactToken as sanitizeFileToken} from '../portal/artifact-paths.js'; export function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); diff --git a/src/features/liferay/resource/liferay-resource-sync-fragments.ts b/src/features/liferay/resource/liferay-resource-sync-fragments.ts index e8e66acd..638d16a7 100644 --- a/src/features/liferay/resource/liferay-resource-sync-fragments.ts +++ b/src/features/liferay/resource/liferay-resource-sync-fragments.ts @@ -2,17 +2,28 @@ import fs from 'fs-extra'; import type {AppConfig} from '../../../core/config/load-config.js'; import {LiferayErrors} from '../errors/index.js'; +import type {ResolvedSite} from '../portal/site-resolution.js'; import {runLiferayInventorySitesIncludingGlobal} from '../inventory/liferay-inventory-sites.js'; -import {resolveSiteToken} from './liferay-resource-paths.js'; +import {resolveSiteToken} from '../portal/artifact-paths.js'; import {resolveResourceSite} from './liferay-resource-shared.js'; -import {readLocalFragmentsProject, resolveFragmentsProjectDir} from './liferay-resource-sync-fragments-local.js'; -import {runFragmentsImport as runFragmentsImportWithSyncEngine} from './liferay-resource-sync-fragments-importer.js'; +import { + readLocalFragmentsProject, + resolveFragmentsProjectDir, + toErrorMessage, +} from './liferay-resource-sync-fragments-local.js'; +import {createFragmentSyncRuntimeState} from './liferay-resource-sync-fragments-api.js'; import type {ResourceSyncDependencies} from './liferay-resource-sync-shared.js'; import type { + LiferayResourceSyncFragmentItemResult, LiferayResourceSyncFragmentsAllSitesResult, LiferayResourceSyncFragmentsResult, LiferayResourceSyncFragmentsSingleResult, + LocalFragment, + LocalFragmentCollection, } from './liferay-resource-sync-fragments-types.js'; +import {syncArtifact} from './sync-engine.js'; +import {fragmentCollectionSyncStrategy} from './sync-strategies/fragment-collection-sync-strategy.js'; +import {fragmentEntrySyncStrategy} from './sync-strategies/fragment-entry-sync-strategy.js'; export type { LiferayResourceSyncFragmentItemResult, @@ -107,5 +118,120 @@ async function runFragmentsImport( dependencies?: ResourceSyncDependencies, ): Promise { const project = await readLocalFragmentsProject(projectDir, fragmentFilter); - return runFragmentsImportWithSyncEngine(config, groupId, siteFriendlyUrl, projectDir, project, dependencies); + const site = toResolvedSite(groupId, siteFriendlyUrl); + const runtimeState = createFragmentSyncRuntimeState(); + let imported = 0; + let errors = 0; + const fragmentResults: LiferayResourceSyncFragmentItemResult[] = []; + + for (const localCollection of project.collections) { + try { + const collectionId = await syncFragmentCollection(config, site, localCollection, runtimeState, dependencies); + if (!Number.isFinite(collectionId) || collectionId <= 0) { + throw LiferayErrors.resourceError(`fragmentCollectionId invalido para ${localCollection.slug}`); + } + + for (const localFragment of localCollection.fragments) { + try { + const syncedFragmentId = await syncFragmentEntry( + config, + site, + collectionId, + localFragment, + runtimeState, + dependencies, + ); + + fragmentResults.push({ + collection: localCollection.slug, + fragment: localFragment.slug, + status: 'imported', + fragmentEntryId: syncedFragmentId, + }); + imported += 1; + } catch (error) { + fragmentResults.push({ + collection: localCollection.slug, + fragment: localFragment.slug, + status: 'error', + error: toErrorMessage(error), + }); + errors += 1; + } + } + } catch (error) { + for (const localFragment of localCollection.fragments) { + fragmentResults.push({ + collection: localCollection.slug, + fragment: localFragment.slug, + status: 'error', + error: toErrorMessage(error), + }); + errors += 1; + } + } + } + + return { + mode: 'oauth-jsonws-import', + site: siteFriendlyUrl, + siteId: groupId, + projectDir, + summary: { + importedFragments: imported, + fragmentResults: fragmentResults.length, + pageTemplateResults: 0, + errors, + }, + fragmentResults, + pageTemplateResults: [], + }; +} + +async function syncFragmentCollection( + config: AppConfig, + site: ResolvedSite, + collection: LocalFragmentCollection, + runtimeState: ReturnType, + dependencies?: ResourceSyncDependencies, +): Promise { + const result = await syncArtifact( + config, + site, + fragmentCollectionSyncStrategy, + { + createMissing: true, + strategyOptions: {collection, runtimeState}, + }, + dependencies, + ); + + return Number(result.id); +} + +async function syncFragmentEntry( + config: AppConfig, + site: ResolvedSite, + collectionId: number, + fragment: LocalFragment, + runtimeState: ReturnType, + dependencies?: ResourceSyncDependencies, +): Promise { + const result = await syncArtifact( + config, + site, + fragmentEntrySyncStrategy, + { + createMissing: true, + strategyOptions: {collectionId, fragment, runtimeState}, + }, + dependencies, + ); + + const fragmentEntryId = Number(result.id); + return Number.isFinite(fragmentEntryId) ? fragmentEntryId : -1; +} + +function toResolvedSite(groupId: number, siteFriendlyUrl: string): ResolvedSite { + return {id: groupId, friendlyUrlPath: siteFriendlyUrl, name: siteFriendlyUrl}; } diff --git a/src/features/liferay/resource/liferay-resource-sync-structure-shared.ts b/src/features/liferay/resource/liferay-resource-sync-structure-shared.ts deleted file mode 100644 index 158c0dd9..00000000 --- a/src/features/liferay/resource/liferay-resource-sync-structure-shared.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type {AppConfig} from '../../../core/config/load-config.js'; -import type {OAuthTokenClient} from '../../../core/http/auth.js'; -import type {HttpApiClient} from '../../../core/http/client.js'; -import {createLiferayGateway} from '../liferay-gateway.js'; - -type ResourceDependencies = { - apiClient?: HttpApiClient; - tokenClient?: OAuthTokenClient; -}; - -export async function fetchStructureByKey( - config: AppConfig, - siteId: number, - key: string, - dependencies?: ResourceDependencies, -): Promise> { - const gateway = createLiferayGateway(config, dependencies?.apiClient, dependencies?.tokenClient); - return gateway.getJson>( - `/o/data-engine/v2.0/sites/${siteId}/data-definitions/by-content-type/journal/by-data-definition-key/${encodeURIComponent(key)}`, - 'structure-get', - ); -} diff --git a/src/features/liferay/resource/liferay-resource-sync-structure-utils.ts b/src/features/liferay/resource/liferay-resource-sync-structure-utils.ts deleted file mode 100644 index ba7f2456..00000000 --- a/src/features/liferay/resource/liferay-resource-sync-structure-utils.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {buildAuthOptions, expectJsonSuccess} from '../liferay-http-shared.js'; - -export {buildAuthOptions as authOptions, expectJsonSuccess}; - -export function normalizeMigrationPhase(phase?: string): '' | 'pre' | 'post' | 'both' { - const normalized = (phase ?? '').trim().toLowerCase(); - if (normalized === 'pre' || normalized === 'post' || normalized === 'both') { - return normalized; - } - return ''; -} - -export function shouldRunPostMigration(phase: '' | 'pre' | 'post' | 'both'): boolean { - return phase === '' || phase === 'post' || phase === 'both'; -} diff --git a/src/features/liferay/resource/liferay-resource-sync-structure.ts b/src/features/liferay/resource/liferay-resource-sync-structure.ts index b460b1ff..74843127 100644 --- a/src/features/liferay/resource/liferay-resource-sync-structure.ts +++ b/src/features/liferay/resource/liferay-resource-sync-structure.ts @@ -1,7 +1,7 @@ import type {AppConfig} from '../../../core/config/load-config.js'; import type {Printer} from '../../../core/output/printer.js'; import {resolveSite} from '../portal/site-resolution.js'; -import {resolveStructureFile} from './liferay-resource-paths.js'; +import {resolveStructureFile} from '../portal/artifact-paths.js'; import type {MigrationStats} from './migration/index.js'; import {syncArtifactDetailed} from './sync-engine.js'; import {structureSyncStrategy, type StructureResourceDependencies} from './sync-strategies/structure-sync-strategy.js'; diff --git a/src/features/liferay/resource/liferay-resource-sync-template.ts b/src/features/liferay/resource/liferay-resource-sync-template.ts index 56876812..b52ba22c 100644 --- a/src/features/liferay/resource/liferay-resource-sync-template.ts +++ b/src/features/liferay/resource/liferay-resource-sync-template.ts @@ -19,7 +19,7 @@ import { resolveTemplateFile, resolveTemplatesBaseDir, siteTokenToFriendlyUrl, -} from './liferay-resource-paths.js'; +} from '../portal/artifact-paths.js'; import {syncArtifact} from './sync-engine.js'; import {templateSyncStrategy} from './sync-strategies/template-sync-strategy.js'; diff --git a/src/features/liferay/resource/migration/init.ts b/src/features/liferay/resource/migration/init.ts index c5f9f4ba..d133ae12 100644 --- a/src/features/liferay/resource/migration/init.ts +++ b/src/features/liferay/resource/migration/init.ts @@ -8,7 +8,7 @@ import {isRecord, readJsonUnknown, type JsonRecord} from '../../../../core/utils import {normalizeScalarString} from '../../../../core/utils/text.js'; import {LiferayErrors} from '../../errors/index.js'; import {runLiferayResourceGetStructure} from '../liferay-resource-get-structure.js'; -import {resolveMigrationsBaseDir, resolveSiteToken, resolveStructureFile} from '../liferay-resource-paths.js'; +import {resolveMigrationsBaseDir, resolveSiteToken, resolveStructureFile} from '../../portal/artifact-paths.js'; import type {StructureDefinitionPayload} from '../liferay-resource-sync-structure-diff.js'; type ResourceDependencies = { diff --git a/src/features/liferay/resource/migration/pipeline.ts b/src/features/liferay/resource/migration/pipeline.ts index c57b6129..d584be1b 100644 --- a/src/features/liferay/resource/migration/pipeline.ts +++ b/src/features/liferay/resource/migration/pipeline.ts @@ -10,7 +10,7 @@ import {normalizeScalarString} from '../../../../core/utils/text.js'; import {LiferayErrors} from '../../errors/index.js'; import {runLiferayInventoryTemplates} from '../../inventory/liferay-inventory-templates.js'; import {runLiferayResourceGetStructure} from '../liferay-resource-get-structure.js'; -import {resolveStructureFile} from '../liferay-resource-paths.js'; +import {resolveStructureFile} from '../../portal/artifact-paths.js'; import {runLiferayResourceSyncStructure} from '../liferay-resource-sync-structure.js'; import {runLiferayResourceSyncTemplate} from '../liferay-resource-sync-template.js'; import type {ResourceSyncDependencies} from '../liferay-resource-sync-shared.js'; diff --git a/src/features/liferay/resource/migration/structure-content.ts b/src/features/liferay/resource/migration/structure-content.ts index d01b920c..ed385024 100644 --- a/src/features/liferay/resource/migration/structure-content.ts +++ b/src/features/liferay/resource/migration/structure-content.ts @@ -367,53 +367,57 @@ async function listStructureContentsByArticleIds( return [...deduped.values()]; } -async function listStructureContents(gateway: LiferayGateway, structureId: string): Promise { +async function fetchPagedStructuredContents( + gateway: LiferayGateway, + baseUrl: string, + label: string, +): Promise { + const fields = encodeURIComponent('id,key,contentStructureId,structuredContentFolderId,friendlyUrlPath,title'); const rows: StructuredContentRow[] = []; let page = 1; let lastPage: number; - const fields = encodeURIComponent('id,key,contentStructureId,structuredContentFolderId,friendlyUrlPath,title'); - do { const response = await gateway.getJson( - `/o/headless-delivery/v1.0/content-structures/${structureId}/structured-contents?page=${page}&pageSize=200&fields=${fields}`, - 'structure-migrate list', + `${baseUrl}?page=${page}&pageSize=200&fields=${fields}`, + label, ); rows.push(...((response as StructuredContentsPage | null)?.items ?? [])); lastPage = (response as StructuredContentsPage | null)?.lastPage ?? 1; page += 1; } while (page <= lastPage); - return rows; } +async function listStructureContents(gateway: LiferayGateway, structureId: string): Promise { + return fetchPagedStructuredContents( + gateway, + `/o/headless-delivery/v1.0/content-structures/${structureId}/structured-contents`, + 'structure-migrate list', + ); +} + async function listStructureContentsByFolders( gateway: LiferayGateway, folderIds: number[], structureId: string, ): Promise { const deduped = new Map(); - const fields = encodeURIComponent('id,key,contentStructureId,structuredContentFolderId,friendlyUrlPath,title'); for (const folderId of folderIds.sort((left, right) => left - right)) { - let page = 1; - let lastPage: number; - do { - const response = await gateway.getJson( - `/o/headless-delivery/v1.0/structured-content-folders/${folderId}/structured-contents?page=${page}&pageSize=200&fields=${fields}`, - 'structure-migrate list-by-folder', - ); - for (const item of (response as StructuredContentsPage | null)?.items ?? []) { - if (String(item.contentStructureId ?? '') !== structureId) { - continue; - } - const id = Number(item.id ?? -1); - if (id > 0 && !deduped.has(id)) { - deduped.set(id, item); - } + const items = await fetchPagedStructuredContents( + gateway, + `/o/headless-delivery/v1.0/structured-content-folders/${folderId}/structured-contents`, + 'structure-migrate list-by-folder', + ); + for (const item of items) { + if (String(item.contentStructureId ?? '') !== structureId) { + continue; } - lastPage = (response as {lastPage?: number} | null)?.lastPage ?? 1; - page += 1; - } while (page <= lastPage); + const id = Number(item.id ?? -1); + if (id > 0 && !deduped.has(id)) { + deduped.set(id, item); + } + } } return [...deduped.values()]; diff --git a/src/features/liferay/resource/sync-strategies/adt-sync-strategy.ts b/src/features/liferay/resource/sync-strategies/adt-sync-strategy.ts index d88b4958..1b5be946 100644 --- a/src/features/liferay/resource/sync-strategies/adt-sync-strategy.ts +++ b/src/features/liferay/resource/sync-strategies/adt-sync-strategy.ts @@ -11,7 +11,8 @@ import {createLiferayGateway, type LiferayGateway} from '../../liferay-gateway.j import type {ResolvedSite} from '../../portal/site-resolution.js'; import {LiferayErrors} from '../../errors/index.js'; import {runLiferayResourceListAdts} from '../liferay-resource-list-adts.js'; -import {resolveAdtFile} from '../liferay-resource-paths.js'; +import {resolveAdtFile} from '../../portal/artifact-paths.js'; +import {rethrowGatewayAsResourceError} from './shared.js'; import {fetchAdtResourceClassNameId, fetchClassNameIdForValue} from '../liferay-resource-shared.js'; import {ensureString, localizedMap, sha256, type ResourceSyncDependencies} from '../liferay-resource-sync-shared.js'; import {matchesAdtRow} from '../../liferay-identifiers.js'; @@ -236,11 +237,3 @@ async function postFormAsResource( rethrowGatewayAsResourceError(error); } } - -function rethrowGatewayAsResourceError(error: unknown): never { - if (error instanceof CliError && error.code === 'LIFERAY_GATEWAY_ERROR') { - throw LiferayErrors.resourceError(error.message); - } - - throw error; -} diff --git a/src/features/liferay/resource/sync-strategies/shared.ts b/src/features/liferay/resource/sync-strategies/shared.ts new file mode 100644 index 00000000..7a40b62f --- /dev/null +++ b/src/features/liferay/resource/sync-strategies/shared.ts @@ -0,0 +1,16 @@ +import {CliError} from '../../../../core/errors.js'; +import {LiferayErrors} from '../../errors/index.js'; + +export function isGatewayStatus(error: unknown, status: number): boolean { + return ( + error instanceof CliError && error.code === 'LIFERAY_GATEWAY_ERROR' && error.message.includes(`status=${status}`) + ); +} + +export function rethrowGatewayAsResourceError(error: unknown): never { + if (error instanceof CliError && error.code === 'LIFERAY_GATEWAY_ERROR') { + throw LiferayErrors.resourceError(error.message); + } + + throw error; +} diff --git a/src/features/liferay/resource/sync-strategies/structure-sync-strategy.ts b/src/features/liferay/resource/sync-strategies/structure-sync-strategy.ts index 92724f56..f98f8e65 100644 --- a/src/features/liferay/resource/sync-strategies/structure-sync-strategy.ts +++ b/src/features/liferay/resource/sync-strategies/structure-sync-strategy.ts @@ -13,7 +13,7 @@ import {isRecord, type JsonRecord} from '../../../../core/utils/json.js'; import {createLiferayGateway, type LiferayGateway} from '../../liferay-gateway.js'; import {LiferayErrors} from '../../errors/index.js'; import type {ResolvedSite} from '../../portal/site-resolution.js'; -import {resolveStructureFile} from '../liferay-resource-paths.js'; +import {resolveStructureFile} from '../../portal/artifact-paths.js'; import { buildTransitionPayload, collectDuplicateFieldIdentities, @@ -26,10 +26,22 @@ import { structureShapeMatches, } from '../liferay-resource-sync-structure-diff.js'; import {captureMigrationSourceSnapshots, runStructureMigration, type MigrationStats} from '../migration/index.js'; -import {normalizeMigrationPhase, shouldRunPostMigration} from '../liferay-resource-sync-structure-utils.js'; import {ensureString, type ResourceSyncDependencies} from '../liferay-resource-sync-shared.js'; +import {isGatewayStatus, rethrowGatewayAsResourceError} from './shared.js'; import type {LocalArtifact, RemoteArtifact, SyncStrategy} from '../sync-engine.js'; +function normalizeMigrationPhase(phase?: string): '' | 'pre' | 'post' | 'both' { + const normalized = (phase ?? '').trim().toLowerCase(); + if (normalized === 'pre' || normalized === 'post' || normalized === 'both') { + return normalized; + } + return ''; +} + +function shouldRunPostMigration(phase: '' | 'pre' | 'post' | 'both'): boolean { + return phase === '' || phase === 'post' || phase === 'both'; +} + type StructureLocalData = { filePath: string; }; @@ -466,20 +478,6 @@ async function pollStructureUpdateRecovery( return null; } -function isGatewayStatus(error: unknown, status: number): boolean { - return ( - error instanceof CliError && error.code === 'LIFERAY_GATEWAY_ERROR' && error.message.includes(`status=${status}`) - ); -} - -function rethrowGatewayAsResourceError(error: unknown): never { - if (error instanceof CliError && error.code === 'LIFERAY_GATEWAY_ERROR') { - throw LiferayErrors.resourceError(error.message); - } - - throw error; -} - function isRecoverableTimeoutError(error: unknown): boolean { if (!(error instanceof CliError)) { return false; diff --git a/src/features/liferay/resource/sync-strategies/template-sync-strategy.ts b/src/features/liferay/resource/sync-strategies/template-sync-strategy.ts index f553f3b5..312124e2 100644 --- a/src/features/liferay/resource/sync-strategies/template-sync-strategy.ts +++ b/src/features/liferay/resource/sync-strategies/template-sync-strategy.ts @@ -12,8 +12,8 @@ import type {ResolvedSite} from '../../portal/site-resolution.js'; import {runLiferayInventoryTemplates} from '../../inventory/liferay-inventory-templates.js'; import {LiferayErrors} from '../../errors/index.js'; import {runLiferayResourceGetTemplate} from '../liferay-resource-get-template.js'; -import {fetchStructureByKey} from '../liferay-resource-sync-structure-shared.js'; -import {resolveTemplateFile, resolveSiteToken} from '../liferay-resource-paths.js'; +import {resolveTemplateFile, resolveSiteToken} from '../../portal/artifact-paths.js'; +import {isGatewayStatus, rethrowGatewayAsResourceError} from './shared.js'; import { fetchStructureTemplateClassIds, listDdmTemplates, @@ -272,6 +272,19 @@ export const templateSyncStrategy: SyncStrategy> { + const gateway = createLiferayGateway(config, dependencies?.apiClient, dependencies?.tokenClient); + return gateway.getJson>( + `/o/data-engine/v2.0/sites/${siteId}/data-definitions/by-content-type/journal/by-data-definition-key/${encodeURIComponent(key)}`, + 'structure-get', + ); +} + /** * Helper: Try to fetch DDM template by key from server. * Returns null if not found (swallows 404-like errors). @@ -294,17 +307,3 @@ async function tryGetDdmTemplateByKey( rethrowGatewayAsResourceError(error); } } - -function isGatewayStatus(error: unknown, status: number): boolean { - return ( - error instanceof CliError && error.code === 'LIFERAY_GATEWAY_ERROR' && error.message.includes(`status=${status}`) - ); -} - -function rethrowGatewayAsResourceError(error: unknown): never { - if (error instanceof CliError && error.code === 'LIFERAY_GATEWAY_ERROR') { - throw LiferayErrors.resourceError(error.message); - } - - throw error; -} diff --git a/tests/unit/liferay-artifact-paths.test.ts b/tests/unit/liferay-artifact-paths.test.ts index f252c9af..612ded6e 100644 --- a/tests/unit/liferay-artifact-paths.test.ts +++ b/tests/unit/liferay-artifact-paths.test.ts @@ -14,7 +14,7 @@ import { tryResolveArtifactSiteDir, tryResolveFragmentsBaseDir, type ArtifactType, -} from '../../src/features/liferay/resource/artifact-paths.js'; +} from '../../src/features/liferay/portal/artifact-paths.js'; import {createTempDir} from '../../src/testing/temp-repo.js'; // --------------------------------------------------------------------------- diff --git a/tests/unit/liferay-resource-import.test.ts b/tests/unit/liferay-resource-import.test.ts index 02bf1ded..2aed74fa 100644 --- a/tests/unit/liferay-resource-import.test.ts +++ b/tests/unit/liferay-resource-import.test.ts @@ -40,12 +40,8 @@ vi.mock('../../src/features/liferay/resource/liferay-resource-sync-structure.js' runLiferayResourceSyncStructure: syncStructureMock, })); -const {runLiferayResourceImportAdts} = - await import('../../src/features/liferay/resource/liferay-resource-import-adts.js'); -const {runLiferayResourceImportTemplates} = - await import('../../src/features/liferay/resource/liferay-resource-import-templates.js'); -const {runLiferayResourceImportStructures} = - await import('../../src/features/liferay/resource/liferay-resource-import-structures.js'); +const {runLiferayResourceImportAdts, runLiferayResourceImportTemplates, runLiferayResourceImportStructures} = + await import('../../src/features/liferay/resource/liferay-resource-import-shared.js'); const CONFIG = { cwd: '/tmp/repo', From f408a68c0fb37b06dd78161ec2e7935bb65812fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Ord=C3=B3=C3=B1ez?= Date: Tue, 12 May 2026 21:09:29 +0200 Subject: [PATCH 2/3] refactor(liferay): split inventory page and where-used logic --- src/core/contracts/index.ts | 29 +- src/core/contracts/inventory.schema.ts | 185 ++++++-- .../liferay-inventory-page-fetch-article.ts | 2 +- ...liferay-inventory-page-fetch-components.ts | 25 -- .../liferay-inventory-page-fetch-fragments.ts | 82 +++- .../liferay-inventory-page-fetch-http.ts | 15 - .../liferay-inventory-page-fetch-journal.ts | 5 +- .../liferay-inventory-page-fetch-layout.ts | 185 ++++++++ .../inventory/liferay-inventory-page-fetch.ts | 349 ++------------- .../liferay-inventory-page-projection.ts | 348 +++++++++++++++ .../inventory/liferay-inventory-page.ts | 347 +-------------- .../inventory/liferay-inventory-shared.ts | 16 +- ...eray-inventory-where-used-display-pages.ts | 216 ---------- .../liferay-inventory-where-used-errors.ts | 21 - .../liferay-inventory-where-used-format.ts | 6 +- .../liferay-inventory-where-used-match.ts | 95 ----- .../liferay-inventory-where-used-normalize.ts | 32 -- ...ay-inventory-where-used-page-candidates.ts | 114 ----- .../liferay-inventory-where-used-pages.ts | 122 ------ ...ray-inventory-where-used-query-resolver.ts | 270 ------------ .../liferay-inventory-where-used-query.ts | 400 ++++++++++++++++++ .../liferay-inventory-where-used-scan.ts | Bin 0 -> 19476 bytes ...ray-inventory-where-used-site-selection.ts | 116 ----- ...liferay-inventory-where-used-validation.ts | 67 --- .../inventory/liferay-inventory-where-used.ts | 329 ++------------ tests/unit/liferay-inventory-page.test.ts | 2 +- .../unit/liferay-inventory-where-used.test.ts | 40 +- 27 files changed, 1323 insertions(+), 2095 deletions(-) delete mode 100644 src/features/liferay/inventory/liferay-inventory-page-fetch-components.ts delete mode 100644 src/features/liferay/inventory/liferay-inventory-page-fetch-http.ts create mode 100644 src/features/liferay/inventory/liferay-inventory-page-fetch-layout.ts create mode 100644 src/features/liferay/inventory/liferay-inventory-page-projection.ts delete mode 100644 src/features/liferay/inventory/liferay-inventory-where-used-display-pages.ts delete mode 100644 src/features/liferay/inventory/liferay-inventory-where-used-errors.ts delete mode 100644 src/features/liferay/inventory/liferay-inventory-where-used-match.ts delete mode 100644 src/features/liferay/inventory/liferay-inventory-where-used-normalize.ts delete mode 100644 src/features/liferay/inventory/liferay-inventory-where-used-page-candidates.ts delete mode 100644 src/features/liferay/inventory/liferay-inventory-where-used-pages.ts delete mode 100644 src/features/liferay/inventory/liferay-inventory-where-used-query-resolver.ts create mode 100644 src/features/liferay/inventory/liferay-inventory-where-used-query.ts create mode 100644 src/features/liferay/inventory/liferay-inventory-where-used-scan.ts delete mode 100644 src/features/liferay/inventory/liferay-inventory-where-used-site-selection.ts delete mode 100644 src/features/liferay/inventory/liferay-inventory-where-used-validation.ts diff --git a/src/core/contracts/index.ts b/src/core/contracts/index.ts index 775dfc8d..c2cc0256 100644 --- a/src/core/contracts/index.ts +++ b/src/core/contracts/index.ts @@ -1,8 +1,3 @@ -/** - * Central export point for all Zod schemas and inferred types. - * Organized by surface: shared, inventory, resource. - */ - // Shared schemas (used by both inventory and resource) export { resolvedSiteSchema, @@ -34,6 +29,17 @@ export { liferayInventorySitesSchema, liferayInventoryTemplatesSchema, liferayInventoryStructuresSchema, + whereUsedResourceTypes, + whereUsedMatchKinds, + pageEvidenceSourceValues, + whereUsedResourceTypeSchema, + whereUsedMatchKindSchema, + pageEvidenceSourceSchema, + whereUsedQuerySchema, + whereUsedMatchSchema, + whereUsedPageMatchSchema, + whereUsedResultSchema, + whereUsedPlanResultSchema, } from './inventory.schema.js'; export type { @@ -43,6 +49,19 @@ export type { LiferayInventorySites, LiferayInventoryTemplates, LiferayInventoryStructures, + WhereUsedResourceTypeValue, + WhereUsedMatchKindValue, + PageEvidenceSourceValue, + WhereUsedQuery, + WhereUsedResourceType, + WhereUsedMatchKind, + WhereUsedMatch, + WhereUsedPageMatch, + WhereUsedResultContract, + WhereUsedPlanResultContract, + WhereUsedResult, + WhereUsedPlanResult, + WhereUsedRunResult, } from './inventory.schema.js'; // Resource schemas diff --git a/src/core/contracts/inventory.schema.ts b/src/core/contracts/inventory.schema.ts index 73d800f2..a95eda1c 100644 --- a/src/core/contracts/inventory.schema.ts +++ b/src/core/contracts/inventory.schema.ts @@ -1,13 +1,7 @@ import {z} from 'zod'; -/** - * Schemas for inventory surfaces (sites, templates, structures). - * These define normalized output types that commands return. - */ - -/** - * LiferayInventorySite: normalized site info with group id, friendly URL, name, and pages command. - */ +// ── Site / Template / Structure inventory output ─────────────────────────────── + export const liferayInventorySiteSchema = z.object({ groupId: z.number().int().positive(), siteFriendlyUrl: z.string(), @@ -17,9 +11,6 @@ export const liferayInventorySiteSchema = z.object({ export type LiferayInventorySite = z.infer; -/** - * LiferayInventoryTemplate: normalized template info with id, name, structure ref, and optional script. - */ export const liferayInventoryTemplateSchema = z.object({ id: z.string(), name: z.string(), @@ -30,9 +21,6 @@ export const liferayInventoryTemplateSchema = z.object({ export type LiferayInventoryTemplate = z.infer; -/** - * LiferayInventoryStructure: minimal structure info with id, key, and name. - */ export const liferayInventoryStructureSchema = z.object({ id: z.number().int(), key: z.string(), @@ -41,23 +29,168 @@ export const liferayInventoryStructureSchema = z.object({ export type LiferayInventoryStructure = z.infer; -/** - * LiferayInventorySites: array of normalized sites. - */ export const liferayInventorySitesSchema = z.array(liferayInventorySiteSchema); - export type LiferayInventorySites = z.infer; -/** - * LiferayInventoryTemplates: array of normalized templates. - */ export const liferayInventoryTemplatesSchema = z.array(liferayInventoryTemplateSchema); - export type LiferayInventoryTemplates = z.infer; -/** - * LiferayInventoryStructures: array of normalized structures. - */ export const liferayInventoryStructuresSchema = z.array(liferayInventoryStructureSchema); - export type LiferayInventoryStructures = z.infer; + +// ── Where-used: enum types (shared with page evidence) ──────────────────────── + +export const whereUsedResourceTypes = ['fragment', 'widget', 'portlet', 'structure', 'template', 'adt'] as const; + +export const whereUsedMatchKinds = [ + 'fragmentEntry', + 'widgetEntry', + 'widgetAdt', + 'portlet', + 'journalArticleStructure', + 'journalArticleTemplate', + 'fragmentMappedStructure', + 'fragmentMappedTemplate', + 'contentStructure', + 'displayPageArticle', +] as const; + +export const pageEvidenceSourceValues = [ + 'fragmentEntryLink', + 'portletLayout', + 'journalArticle', + 'renderedHtmlJournalContent', + 'contentStructure', + 'displayPageArticle', +] as const; + +export const whereUsedResourceTypeSchema = z.enum(whereUsedResourceTypes); +export const whereUsedMatchKindSchema = z.enum(whereUsedMatchKinds); +export const pageEvidenceSourceSchema = z.enum(pageEvidenceSourceValues); + +export type WhereUsedResourceTypeValue = (typeof whereUsedResourceTypes)[number]; +export type WhereUsedMatchKindValue = (typeof whereUsedMatchKinds)[number]; +export type PageEvidenceSourceValue = (typeof pageEvidenceSourceValues)[number]; + +// ── Where-used: output contract ──────────────────────────────────────────────── + +const whereUsedSiteOrderSchema = z.enum(['site', 'name', 'content']); + +export const whereUsedQuerySchema = z.object({ + type: whereUsedResourceTypeSchema, + keys: z.array(z.string()), +}); + +export const whereUsedMatchSchema = z.object({ + resourceType: whereUsedResourceTypeSchema, + matchedKey: z.string(), + matchKind: whereUsedMatchKindSchema, + label: z.string(), + detail: z.string(), + source: pageEvidenceSourceSchema, +}); + +export const whereUsedPageMatchSchema = z.object({ + pageType: z.enum(['regularPage', 'displayPage']), + pageName: z.string(), + friendlyUrl: z.string(), + fullUrl: z.string(), + viewUrl: z.string().optional(), + layoutId: z.number().optional(), + plid: z.number().optional(), + privateLayout: z.boolean(), + hidden: z.boolean().optional(), + editUrl: z.string().optional(), + matches: z.array(whereUsedMatchSchema), +}); + +const whereUsedSiteResultSchema = z.object({ + siteFriendlyUrl: z.string(), + siteName: z.string(), + groupId: z.coerce.number(), + scannedPages: z.number(), + failedPages: z.number(), + matchedPages: z.array(whereUsedPageMatchSchema), + errors: z.array(z.object({fullUrl: z.string(), reason: z.string()})).optional(), +}); + +const whereUsedSkippedSiteSchema = z.object({ + siteFriendlyUrl: z.string(), + groupId: z.coerce.number(), + reason: z.string(), +}); + +export const whereUsedResultSchema = z.object({ + inventoryType: z.literal('whereUsed'), + query: z.object({ + type: whereUsedResourceTypeSchema, + keys: z.array(z.string()), + }), + scope: z.object({ + sites: z.array(z.string()), + includePrivate: z.boolean(), + concurrency: z.number(), + maxDepth: z.number(), + siteOrder: whereUsedSiteOrderSchema.default('site'), + siteLimit: z.number().optional(), + excludedSites: z.array(z.string()).default([]), + plan: z.literal(false).default(false), + }), + summary: z.object({ + totalSites: z.number(), + totalScannedPages: z.number(), + totalMatchedPages: z.number(), + totalMatches: z.number(), + totalFailedPages: z.number(), + }), + sites: z.array(whereUsedSiteResultSchema), + skippedSites: z.array(whereUsedSkippedSiteSchema).optional(), +}); + +export const whereUsedPlanResultSchema = z.object({ + inventoryType: z.literal('whereUsedPlan'), + query: z.object({ + type: whereUsedResourceTypeSchema, + keys: z.array(z.string()), + }), + scope: z.object({ + sites: z.array(z.string()), + includePrivate: z.boolean(), + concurrency: z.number(), + maxDepth: z.number(), + siteOrder: whereUsedSiteOrderSchema, + siteLimit: z.number().optional(), + excludedSites: z.array(z.string()), + plan: z.literal(true), + }), + summary: z.object({ + totalSites: z.number(), + selectedSites: z.number(), + excludedSites: z.number(), + skippedSites: z.number(), + }), + sites: z.array( + z.object({ + rank: z.number(), + siteFriendlyUrl: z.string(), + siteName: z.string(), + groupId: z.coerce.number(), + structuredContents: z.number().optional(), + selectionReason: z.enum(['explicitSite', 'siteOrder', 'contentOrder']), + }), + ), + skippedSites: z.array(whereUsedSkippedSiteSchema).optional(), +}); + +export type WhereUsedResultContract = z.infer; +export type WhereUsedPlanResultContract = z.infer; + +// ── Where-used: derived public types ──────────────────────────────────────────── +export type WhereUsedQuery = z.infer; +export type WhereUsedResourceType = WhereUsedResourceTypeValue; +export type WhereUsedMatchKind = WhereUsedMatchKindValue; +export type WhereUsedMatch = z.infer; +export type WhereUsedPageMatch = z.infer; +export type WhereUsedResult = WhereUsedResultContract; +export type WhereUsedPlanResult = WhereUsedPlanResultContract; +export type WhereUsedRunResult = WhereUsedResult | WhereUsedPlanResult; diff --git a/src/features/liferay/inventory/liferay-inventory-page-fetch-article.ts b/src/features/liferay/inventory/liferay-inventory-page-fetch-article.ts index f5e0c786..66f68004 100644 --- a/src/features/liferay/inventory/liferay-inventory-page-fetch-article.ts +++ b/src/features/liferay/inventory/liferay-inventory-page-fetch-article.ts @@ -7,7 +7,7 @@ import { type JournalArticlePayload, type StructuredContent, } from './liferay-inventory-page-assemble.js'; -import {safeGatewayGet} from './liferay-inventory-page-fetch-http.js'; +import {safeGatewayGet} from './liferay-inventory-shared.js'; export type ArticleRef = {articleId: string; groupId: number; ddmTemplateKey?: string; structuredContentId?: number}; diff --git a/src/features/liferay/inventory/liferay-inventory-page-fetch-components.ts b/src/features/liferay/inventory/liferay-inventory-page-fetch-components.ts deleted file mode 100644 index 56d08254..00000000 --- a/src/features/liferay/inventory/liferay-inventory-page-fetch-components.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type {LiferayGateway} from '../liferay-gateway.js'; -import { - fetchHeadlessSitePageElement, - fetchHeadlessSitePageMetadata, - type HeadlessPageElementPayload, - type HeadlessSitePagePayload, -} from '../page-layout/liferay-site-page-shared.js'; -import {tryFetchFragmentEntryLinks} from './liferay-inventory-page-fetch-fragments.js'; - -export async function fetchComponentPageData( - gateway: LiferayGateway, - siteId: number, - canonicalFriendlyUrl: string, - plid: number, -): Promise<{ - pageElement: HeadlessPageElementPayload | null; - pageMetadata: HeadlessSitePagePayload | null; - rawFragmentLinks: Array>; -}> { - const pageElement = await fetchHeadlessSitePageElement(gateway, siteId, canonicalFriendlyUrl); - const pageMetadata = await fetchHeadlessSitePageMetadata(gateway, siteId, canonicalFriendlyUrl); - const rawFragmentLinks = await tryFetchFragmentEntryLinks(gateway, siteId, plid); - - return {pageElement, pageMetadata, rawFragmentLinks}; -} diff --git a/src/features/liferay/inventory/liferay-inventory-page-fetch-fragments.ts b/src/features/liferay/inventory/liferay-inventory-page-fetch-fragments.ts index 47264397..b0bb8829 100644 --- a/src/features/liferay/inventory/liferay-inventory-page-fetch-fragments.ts +++ b/src/features/liferay/inventory/liferay-inventory-page-fetch-fragments.ts @@ -5,8 +5,9 @@ import type {HttpApiClient} from '../../../core/http/client.js'; import {type FragmentEntryLink, type PageFragmentEntry} from './liferay-inventory-page-assemble.js'; import type {LiferayGateway} from '../liferay-gateway.js'; import {buildSiteChain} from '../portal/site-resolution.js'; -import {resolveSiteToken, tryResolveFragmentsBaseDir} from '../portal/artifact-paths.js'; -import {safeGatewayGet} from './liferay-inventory-page-fetch-http.js'; +import {resolveSiteToken} from '../portal/artifact-paths.js'; +import {tryResolveFragmentsBaseDir} from '../portal/artifact-paths.js'; +import {safeGatewayGet} from './liferay-inventory-shared.js'; export async function tryFetchFragmentEntryLinks( gateway: LiferayGateway, @@ -131,3 +132,80 @@ async function findFragmentDir(root: string, fragmentKey: string): Promise [field.id, field.value])); + const fields = [...editableFields.entries()] + .map(([id, value]) => ({id: id.trim(), value: String(value).trim()})) + .filter((field) => field.id !== '' && field.value !== ''); + const field = (id: string): string => String(editableFields.get(id) ?? '').trim(); + const firstFieldValue = (patterns: RegExp[]): string | undefined => { + for (const pattern of patterns) { + const match = fields.find((candidate) => pattern.test(candidate.id)); + if (match) return match.value; + } + return undefined; + }; + const listFieldValues = (patterns: RegExp[]): string[] => + fields + .filter((candidate) => patterns.some((pattern) => pattern.test(candidate.id))) + .sort((left, right) => left.id.localeCompare(right.id, undefined, {numeric: true})) + .map((candidate) => candidate.value) + .filter(Boolean); + const stripHtml = (value: string): string => + value + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + const truncate = (value: string, maxLength: number): string => + value.length > maxLength ? `${value.slice(0, maxLength - 1).trimEnd()}…` : value; + + const title = firstFieldValue([/^title$/i, /(?:^|[._-])(title|heading|header|label|name)(?:[._-]|$)/i]); + + let cardCount: number | undefined; + + const heroSource = + (firstFieldValue([ + /^summary$/i, + /^description$/i, + /(?:^|[._-])(intro|intro-paragraph|paragraph|text|body|content|summary|description)(?:[._-]|$)/i, + ]) ?? + field('paragraph')) || + field('text'); + const heroText = truncate(stripHtml(heroSource), 160) || undefined; + + const navigationItems = + listFieldValues([/^(?:item|link|menu)-\d+$/i, /(?:^|[._-])(item|link|menu)-\d+(?:[._-]|$)/i]).length > 0 + ? listFieldValues([/^(?:item|link|menu)-\d+$/i, /(?:^|[._-])(item|link|menu)-\d+(?:[._-]|$)/i]) + : undefined; + + const totalLinks = Number(entry.configuration?.totalLinks ?? Number.NaN); + if (Number.isFinite(totalLinks) && totalLinks > 0) { + cardCount = totalLinks; + } else { + const cardLikeFields = fields.filter((candidate) => + /(?:^|[._-])(card|item|link)-\d+(?:[._-]|$)/i.test(candidate.id), + ); + if (cardLikeFields.length > 0) { + cardCount = cardLikeFields.length; + } + } + + const summaryParts = [title ? `title=${title}` : '', heroText ? `heroText=${heroText}` : ''] + .filter(Boolean) + .concat(navigationItems && navigationItems.length > 0 ? [`navigationItems=${navigationItems.join(' | ')}`] : []) + .concat(typeof cardCount === 'number' ? [`cardCount=${cardCount}`] : []); + + if (title) entry.title = title; + if (heroText) entry.heroText = heroText; + if (navigationItems && navigationItems.length > 0) entry.navigationItems = navigationItems; + if (typeof cardCount === 'number') entry.cardCount = cardCount; + if (summaryParts.length > 0) entry.contentSummary = summaryParts.join(' · '); + } +} diff --git a/src/features/liferay/inventory/liferay-inventory-page-fetch-http.ts b/src/features/liferay/inventory/liferay-inventory-page-fetch-http.ts deleted file mode 100644 index 2a339fb2..00000000 --- a/src/features/liferay/inventory/liferay-inventory-page-fetch-http.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type {LiferayGateway} from '../liferay-gateway.js'; - -export async function safeGatewayGet( - gateway: LiferayGateway, - requestPath: string, - label: string, - requestOptions?: {headers?: Record}, -): Promise<{ok: boolean; data: T | null}> { - try { - const data = await gateway.getJson(requestPath, label, requestOptions); - return {ok: true, data}; - } catch { - return {ok: false, data: null}; - } -} diff --git a/src/features/liferay/inventory/liferay-inventory-page-fetch-journal.ts b/src/features/liferay/inventory/liferay-inventory-page-fetch-journal.ts index 086d6ab9..bcbef090 100644 --- a/src/features/liferay/inventory/liferay-inventory-page-fetch-journal.ts +++ b/src/features/liferay/inventory/liferay-inventory-page-fetch-journal.ts @@ -24,10 +24,11 @@ import type {LiferayGateway} from '../liferay-gateway.js'; import {buildSiteChain, fetchGroupInfo} from '../portal/site-resolution.js'; import {listDdmTemplates, resolveResourceSite} from '../portal/template-queries.js'; import {matchesDdmTemplate} from '../liferay-identifiers.js'; -import {resolveSiteToken, tryResolveArtifactSiteDir} from '../portal/artifact-paths.js'; +import {resolveSiteToken} from '../portal/artifact-paths.js'; +import {tryResolveArtifactSiteDir} from '../portal/artifact-paths.js'; import {type ArticleRef, fetchContentStructureById} from './liferay-inventory-page-fetch-article.js'; import {resolveJournalArticleReference} from './liferay-inventory-journal-article-resolver.js'; -import {safeGatewayGet} from './liferay-inventory-page-fetch-http.js'; +import {safeGatewayGet} from './liferay-inventory-shared.js'; import type {HeadlessPageElementPayload} from '../page-layout/liferay-site-page-shared.js'; type TemplateInfo = { diff --git a/src/features/liferay/inventory/liferay-inventory-page-fetch-layout.ts b/src/features/liferay/inventory/liferay-inventory-page-fetch-layout.ts new file mode 100644 index 00000000..c2fdd658 --- /dev/null +++ b/src/features/liferay/inventory/liferay-inventory-page-fetch-layout.ts @@ -0,0 +1,185 @@ +import type {AppConfig} from '../../../core/config/load-config.js'; +import {LiferayErrors} from '../errors/index.js'; +import type {LiferayGateway} from '../liferay-gateway.js'; +import {fetchLayoutsByParent, type Layout} from '../page-layout/liferay-layout-shared.js'; +import {classNameIdLookupCache} from '../lookup-cache.js'; +import {safeGatewayGet} from './liferay-inventory-shared.js'; +import {KNOWN_LOCALES} from './liferay-inventory-page-url.js'; + +export type LayoutMatch = {layout: Layout; locale: string | null}; + +export async function resolveClassNameId( + config: AppConfig, + gateway: LiferayGateway, + className: string, +): Promise { + const cacheKey = `${config.liferay.url}|${className}`; + const cached = classNameIdLookupCache.get(cacheKey); + if (cached && cached > 0) { + return cached; + } + + const response = await safeGatewayGet>( + gateway, + `/api/jsonws/classname/fetch-class-name?value=${encodeURIComponent(className)}`, + 'fetch-class-name', + ); + const resolved = Number(response.data?.classNameId ?? -1); + if (!response.ok || resolved <= 0) { + throw LiferayErrors.inventoryError( + `Unable to resolve classNameId for ${className}. Verify JSONWS access to /api/jsonws/classname/fetch-class-name and portal credentials/permissions.`, + ); + } + + classNameIdLookupCache.set(cacheKey, resolved); + return resolved; +} + +export async function findLayoutByFriendlyUrl( + gateway: LiferayGateway, + groupId: number, + friendlyUrl: string, + privateLayout: boolean, + localeHint?: string, +): Promise { + // 1. Try exact match via recursive tree search (canonical URL, fast) + const canonical = await findLayoutByFriendlyUrlRecursive(gateway, groupId, friendlyUrl, privateLayout, 0); + if (canonical) { + return {layout: canonical, locale: null}; + } + + // 2. If a locale hint is available (from URL prefix like /es/web/...), use targeted JSONWS lookup + if (localeHint) { + const localeCandidates = [localeHint, ...KNOWN_LOCALES.filter((candidate) => candidate !== localeHint)]; + for (const candidateLocale of localeCandidates) { + const match = await findLayoutByLocaleFriendlyUrl(gateway, groupId, friendlyUrl, privateLayout, candidateLocale); + if (match) { + return match; + } + } + } + + // 3. Last resort for localized friendly URLs without a locale prefix. + // Try common locales and map the localized URL back to the canonical layout. + for (const candidateLocale of KNOWN_LOCALES) { + const match = await findLayoutByLocaleFriendlyUrl(gateway, groupId, friendlyUrl, privateLayout, candidateLocale); + if (match) { + return match; + } + } + + return null; +} + +async function findLayoutByLocaleFriendlyUrl( + gateway: LiferayGateway, + groupId: number, + friendlyUrl: string, + privateLayout: boolean, + languageId: string, +): Promise { + if (privateLayout) { + return null; + } + + const plid = await findLocalizedPagePlid(gateway, groupId, friendlyUrl, languageId); + if (plid <= 0) { + return null; + } + const layout = await findLayoutByPlidRecursive(gateway, groupId, privateLayout, 0, plid); + return layout ? {layout, locale: languageId} : null; +} + +async function findLocalizedPagePlid( + gateway: LiferayGateway, + groupId: number, + friendlyUrl: string, + languageId: string, +): Promise { + let page = 1; + let lastPage = 1; + const acceptLanguage = languageId.replace('_', '-'); + + while (page <= lastPage) { + const response = await safeGatewayGet<{ + items?: Array<{id?: number; friendlyUrlPath?: string}>; + lastPage?: number; + }>( + gateway, + `/o/headless-delivery/v1.0/sites/${groupId}/site-pages?page=${page}&pageSize=100`, + `list-site-pages-${page}`, + {headers: {'Accept-Language': acceptLanguage}}, + ); + + if (!response.ok || !response.data) { + return -1; + } + + const items = Array.isArray(response.data.items) ? response.data.items : []; + const match = items.find((item) => String(item.friendlyUrlPath ?? '').trim() === friendlyUrl); + if (match?.id) { + return Number(match.id); + } + + lastPage = Number(response.data.lastPage ?? 1) || 1; + page += 1; + } + + return -1; +} + +async function findLayoutByFriendlyUrlRecursive( + gateway: LiferayGateway, + groupId: number, + friendlyUrl: string, + privateLayout: boolean, + parentLayoutId: number, +): Promise { + const layouts = await fetchLayoutsByParent(gateway, groupId, privateLayout, parentLayoutId); + + for (const layout of layouts) { + if ((layout.friendlyURL ?? '') === friendlyUrl) { + return layout; + } + } + + for (const layout of layouts) { + const child = await findLayoutByFriendlyUrlRecursive( + gateway, + groupId, + friendlyUrl, + privateLayout, + layout.layoutId ?? 0, + ); + if (child) { + return child; + } + } + + return null; +} + +export async function findLayoutByPlidRecursive( + gateway: LiferayGateway, + groupId: number, + privateLayout: boolean, + parentLayoutId: number, + plid: number, +): Promise { + const layouts = await fetchLayoutsByParent(gateway, groupId, privateLayout, parentLayoutId); + + for (const layout of layouts) { + if (Number(layout.plid ?? -1) === plid) { + return layout; + } + } + + for (const layout of layouts) { + const child = await findLayoutByPlidRecursive(gateway, groupId, privateLayout, Number(layout.layoutId ?? 0), plid); + if (child) { + return child; + } + } + + return null; +} diff --git a/src/features/liferay/inventory/liferay-inventory-page-fetch.ts b/src/features/liferay/inventory/liferay-inventory-page-fetch.ts index 0679e16c..8e7fc020 100644 --- a/src/features/liferay/inventory/liferay-inventory-page-fetch.ts +++ b/src/features/liferay/inventory/liferay-inventory-page-fetch.ts @@ -3,12 +3,7 @@ import type {HttpApiClient} from '../../../core/http/client.js'; import {toBooleanOrFalse} from '../../../core/utils/coerce.js'; import {LiferayErrors} from '../errors/index.js'; import type {ResolvedSite} from '../portal/site-resolution.js'; -import { - buildLayoutDetails, - buildPageUrl, - fetchLayoutsByParent, - type Layout, -} from '../page-layout/liferay-layout-shared.js'; +import {buildLayoutDetails, buildPageUrl, fetchLayoutsByParent} from '../page-layout/liferay-layout-shared.js'; import type {LiferayGateway} from '../liferay-gateway.js'; import {buildJournalArticleAdminUrls, buildLayoutAdminUrls} from '../page-layout/liferay-page-admin-urls.js'; import { @@ -17,7 +12,6 @@ import { type JournalArticleSummary, type PageFragmentEntry, } from './liferay-inventory-page-assemble.js'; -import {KNOWN_LOCALES} from './liferay-inventory-page-url.js'; import { buildDisplayPageEvidence, buildRegularPageEvidence, @@ -28,13 +22,23 @@ import type { PagePortletSummary, ResolvedRegularLayoutPage, } from './liferay-inventory-page.js'; -import type {HeadlessSitePagePayload} from '../page-layout/liferay-site-page-shared.js'; -import {classNameIdLookupCache} from '../lookup-cache.js'; +import { + fetchHeadlessSitePageElement, + fetchHeadlessSitePageMetadata, + type HeadlessSitePagePayload, +} from '../page-layout/liferay-site-page-shared.js'; import {buildDisplayPageFriendlyUrl, buildDisplayPageUrl} from './liferay-inventory-display-page-url.js'; import {resolveDisplayPageArticle, resolveStructuredContentData} from './liferay-inventory-page-fetch-article.js'; -import {safeGatewayGet} from './liferay-inventory-page-fetch-http.js'; -import {fetchComponentPageData} from './liferay-inventory-page-fetch-components.js'; -import {enrichFragmentEntryExportPaths} from './liferay-inventory-page-fetch-fragments.js'; +import { + resolveClassNameId, + findLayoutByFriendlyUrl, + findLayoutByPlidRecursive, +} from './liferay-inventory-page-fetch-layout.js'; +import { + enrichFragmentEntryExportPaths, + enrichFragmentEntrySummaries, + tryFetchFragmentEntryLinks, +} from './liferay-inventory-page-fetch-fragments.js'; import { buildJournalArticleSummary, collectLayoutContentStructures, @@ -47,8 +51,6 @@ import { parseTypeSettingsMap, } from './liferay-inventory-page-fetch-config.js'; -type LayoutMatch = {layout: Layout; locale: string | null}; - const CLASS_NAME_LAYOUT = 'com.liferay.portal.kernel.model.Layout'; const CLASS_NAME_JOURNAL_ARTICLE = 'com.liferay.journal.model.JournalArticle'; @@ -174,15 +176,12 @@ export async function fetchRegularPageInventory( let inheritedEvidence: PageEvidence[] = []; if (componentInspectionSupported) { - const { - pageElement, - pageMetadata: fetchedMetadata, - rawFragmentLinks, - } = await fetchComponentPageData(gateway, site.id, canonicalFriendlyUrl, layout.plid ?? -1); - pageMetadata = fetchedMetadata; + const pageElement = await fetchHeadlessSitePageElement(gateway, site.id, canonicalFriendlyUrl); + pageMetadata = await fetchHeadlessSitePageMetadata(gateway, site.id, canonicalFriendlyUrl); + const rawFragmentLinks = await tryFetchFragmentEntryLinks(gateway, site.id, layout.plid ?? -1); configurationTabs = buildRegularPageConfigurationTabs(layout, layoutDetails, privateLayout, pageMetadata); fragmentEntryLinks = collectPageElements(pageElement, rawFragmentLinks, matchedLocale); - enrichRegularPageFragmentSummaries(fragmentEntryLinks); + enrichFragmentEntrySummaries(fragmentEntryLinks); await enrichFragmentEntryExportPaths(config, gateway, site.friendlyUrlPath, fragmentEntryLinks, apiClient); widgets = fragmentEntryLinks .filter((entry) => entry.type === 'widget' && entry.widgetName) @@ -227,116 +226,6 @@ export async function fetchRegularPageInventory( } } - function buildRegularPageSummary( - layoutDetails: {layoutTemplateId?: string; targetUrl?: string}, - fragmentEntryLinks?: PageFragmentEntry[], - widgets?: Array<{widgetName: string; portletId?: string; configuration?: Record}>, - portlets?: PagePortletSummary[], - ): { - layoutTemplateId?: string; - targetUrl?: string; - fragmentCount: number; - widgetCount: number; - } { - const headlessWidgetCount = widgets?.length ?? 0; - const fragmentWidgetCount = fragmentEntryLinks?.filter((entry) => entry.type === 'widget').length ?? 0; - const classicPortletCount = portlets?.length ?? 0; - - return { - ...(layoutDetails.layoutTemplateId ? {layoutTemplateId: layoutDetails.layoutTemplateId} : {}), - ...(layoutDetails.targetUrl ? {targetUrl: layoutDetails.targetUrl} : {}), - fragmentCount: fragmentEntryLinks?.filter((entry) => entry.type === 'fragment').length ?? 0, - widgetCount: Math.max(headlessWidgetCount, fragmentWidgetCount, classicPortletCount), - }; - } - - function enrichRegularPageFragmentSummaries(entries: PageFragmentEntry[]): void { - for (const entry of entries) { - if (entry.type !== 'fragment' || !entry.fragmentKey) { - continue; - } - const editableFields = new Map((entry.editableFields ?? []).map((field) => [field.id, field.value])); - const fields = [...editableFields.entries()] - .map(([id, value]) => ({id: id.trim(), value: String(value).trim()})) - .filter((field) => field.id !== '' && field.value !== ''); - const field = (id: string): string => String(editableFields.get(id) ?? '').trim(); - const firstFieldValue = (patterns: RegExp[]): string | undefined => { - for (const pattern of patterns) { - const match = fields.find((candidate) => pattern.test(candidate.id)); - if (match) { - return match.value; - } - } - return undefined; - }; - const listFieldValues = (patterns: RegExp[]): string[] => - fields - .filter((candidate) => patterns.some((pattern) => pattern.test(candidate.id))) - .sort((left, right) => left.id.localeCompare(right.id, undefined, {numeric: true})) - .map((candidate) => candidate.value) - .filter(Boolean); - const stripHtml = (value: string): string => - value - .replace(/<[^>]+>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - const truncate = (value: string, maxLength: number): string => - value.length > maxLength ? `${value.slice(0, maxLength - 1).trimEnd()}…` : value; - - const title = firstFieldValue([/^title$/i, /(?:^|[._-])(title|heading|header|label|name)(?:[._-]|$)/i]); - - let cardCount: number | undefined; - - const heroSource = - (firstFieldValue([ - /^summary$/i, - /^description$/i, - /(?:^|[._-])(intro|intro-paragraph|paragraph|text|body|content|summary|description)(?:[._-]|$)/i, - ]) ?? - field('paragraph')) || - field('text'); - const heroText = truncate(stripHtml(heroSource), 160) || undefined; - - const navigationItems = - listFieldValues([/^(?:item|link|menu)-\d+$/i, /(?:^|[._-])(item|link|menu)-\d+(?:[._-]|$)/i]).length > 0 - ? listFieldValues([/^(?:item|link|menu)-\d+$/i, /(?:^|[._-])(item|link|menu)-\d+(?:[._-]|$)/i]) - : undefined; - - const totalLinks = Number(entry.configuration?.totalLinks ?? Number.NaN); - if (Number.isFinite(totalLinks) && totalLinks > 0) { - cardCount = totalLinks; - } else { - const cardLikeFields = fields.filter((candidate) => - /(?:^|[._-])(card|item|link)-\d+(?:[._-]|$)/i.test(candidate.id), - ); - if (cardLikeFields.length > 0) { - cardCount = cardLikeFields.length; - } - } - - const summaryParts = [title ? `title=${title}` : '', heroText ? `heroText=${heroText}` : ''] - .filter(Boolean) - .concat(navigationItems && navigationItems.length > 0 ? [`navigationItems=${navigationItems.join(' | ')}`] : []) - .concat(typeof cardCount === 'number' ? [`cardCount=${cardCount}`] : []); - - if (title) { - entry.title = title; - } - if (heroText) { - entry.heroText = heroText; - } - if (navigationItems && navigationItems.length > 0) { - entry.navigationItems = navigationItems; - } - if (typeof cardCount === 'number') { - entry.cardCount = cardCount; - } - if (summaryParts.length > 0) { - entry.contentSummary = summaryParts.join(' · '); - } - } - } - return { pageType: 'regularPage', pageSubtype: layout.type ?? '', @@ -410,12 +299,8 @@ async function collectMasterLayoutEvidence( return []; } - const {pageElement, rawFragmentLinks} = await fetchComponentPageData( - gateway, - siteId, - masterFriendlyUrl, - Number(masterLayout.plid ?? -1), - ); + const pageElement = await fetchHeadlessSitePageElement(gateway, siteId, masterFriendlyUrl); + const rawFragmentLinks = await tryFetchFragmentEntryLinks(gateway, siteId, Number(masterLayout.plid ?? -1)); const masterFragmentEntryLinks = collectPageElements(pageElement, rawFragmentLinks, localeHint ?? null); const masterJournalArticles = await collectLayoutJournalArticles( gateway, @@ -555,6 +440,24 @@ function buildJournalArticleIdentity(article: JournalArticleSummary): string { return `${article.groupId ?? -1}:${article.articleId}`; } +function buildRegularPageSummary( + layoutDetails: {layoutTemplateId?: string; targetUrl?: string}, + fragmentEntryLinks?: PageFragmentEntry[], + widgets?: Array<{widgetName: string; portletId?: string; configuration?: Record}>, + portlets?: PagePortletSummary[], +): {layoutTemplateId?: string; targetUrl?: string; fragmentCount: number; widgetCount: number} { + const headlessWidgetCount = widgets?.length ?? 0; + const fragmentWidgetCount = fragmentEntryLinks?.filter((entry) => entry.type === 'widget').length ?? 0; + const classicPortletCount = portlets?.length ?? 0; + + return { + ...(layoutDetails.layoutTemplateId ? {layoutTemplateId: layoutDetails.layoutTemplateId} : {}), + ...(layoutDetails.targetUrl ? {targetUrl: layoutDetails.targetUrl} : {}), + fragmentCount: fragmentEntryLinks?.filter((entry) => entry.type === 'fragment').length ?? 0, + widgetCount: Math.max(headlessWidgetCount, fragmentWidgetCount, classicPortletCount), + }; +} + function resolveRegularPageUiType(layoutType: string | undefined): string { const normalized = String(layoutType ?? '') .trim() @@ -672,175 +575,3 @@ export async function resolveRegularLayoutPageData( ), }; } - -async function resolveClassNameId(config: AppConfig, gateway: LiferayGateway, className: string): Promise { - const cacheKey = `${config.liferay.url}|${className}`; - const cached = classNameIdLookupCache.get(cacheKey); - if (cached && cached > 0) { - return cached; - } - - const response = await safeGatewayGet>( - gateway, - `/api/jsonws/classname/fetch-class-name?value=${encodeURIComponent(className)}`, - 'fetch-class-name', - ); - const resolved = Number(response.data?.classNameId ?? -1); - if (!response.ok || resolved <= 0) { - throw LiferayErrors.inventoryError( - `Unable to resolve classNameId for ${className}. Verify JSONWS access to /api/jsonws/classname/fetch-class-name and portal credentials/permissions.`, - ); - } - - classNameIdLookupCache.set(cacheKey, resolved); - return resolved; -} - -async function findLayoutByFriendlyUrl( - gateway: LiferayGateway, - groupId: number, - friendlyUrl: string, - privateLayout: boolean, - localeHint?: string, -): Promise { - // 1. Try exact match via recursive tree search (canonical URL, fast) - const canonical = await findLayoutByFriendlyUrlRecursive(gateway, groupId, friendlyUrl, privateLayout, 0); - if (canonical) { - return {layout: canonical, locale: null}; - } - - // 2. If a locale hint is available (from URL prefix like /es/web/...), use targeted JSONWS lookup - if (localeHint) { - const localeCandidates = [localeHint, ...KNOWN_LOCALES.filter((candidate) => candidate !== localeHint)]; - for (const candidateLocale of localeCandidates) { - const match = await findLayoutByLocaleFriendlyUrl(gateway, groupId, friendlyUrl, privateLayout, candidateLocale); - if (match) { - return match; - } - } - } - - // 3. Last resort for localized friendly URLs without a locale prefix. - // Try common locales and map the localized URL back to the canonical layout. - for (const candidateLocale of KNOWN_LOCALES) { - const match = await findLayoutByLocaleFriendlyUrl(gateway, groupId, friendlyUrl, privateLayout, candidateLocale); - if (match) { - return match; - } - } - - return null; -} - -async function findLayoutByLocaleFriendlyUrl( - gateway: LiferayGateway, - groupId: number, - friendlyUrl: string, - privateLayout: boolean, - languageId: string, -): Promise { - if (privateLayout) { - return null; - } - - const plid = await findLocalizedPagePlid(gateway, groupId, friendlyUrl, languageId); - if (plid <= 0) { - return null; - } - const layout = await findLayoutByPlidRecursive(gateway, groupId, privateLayout, 0, plid); - return layout ? {layout, locale: languageId} : null; -} - -async function findLocalizedPagePlid( - gateway: LiferayGateway, - groupId: number, - friendlyUrl: string, - languageId: string, -): Promise { - let page = 1; - let lastPage = 1; - const acceptLanguage = languageId.replace('_', '-'); - - while (page <= lastPage) { - const response = await safeGatewayGet<{ - items?: Array<{id?: number; friendlyUrlPath?: string}>; - lastPage?: number; - }>( - gateway, - `/o/headless-delivery/v1.0/sites/${groupId}/site-pages?page=${page}&pageSize=100`, - `list-site-pages-${page}`, - {headers: {'Accept-Language': acceptLanguage}}, - ); - - if (!response.ok || !response.data) { - return -1; - } - - const items = Array.isArray(response.data.items) ? response.data.items : []; - const match = items.find((item) => String(item.friendlyUrlPath ?? '').trim() === friendlyUrl); - if (match?.id) { - return Number(match.id); - } - - lastPage = Number(response.data.lastPage ?? 1) || 1; - page += 1; - } - - return -1; -} - -async function findLayoutByFriendlyUrlRecursive( - gateway: LiferayGateway, - groupId: number, - friendlyUrl: string, - privateLayout: boolean, - parentLayoutId: number, -): Promise { - const layouts = await fetchLayoutsByParent(gateway, groupId, privateLayout, parentLayoutId); - - for (const layout of layouts) { - if ((layout.friendlyURL ?? '') === friendlyUrl) { - return layout; - } - } - - for (const layout of layouts) { - const child = await findLayoutByFriendlyUrlRecursive( - gateway, - groupId, - friendlyUrl, - privateLayout, - layout.layoutId ?? 0, - ); - if (child) { - return child; - } - } - - return null; -} - -async function findLayoutByPlidRecursive( - gateway: LiferayGateway, - groupId: number, - privateLayout: boolean, - parentLayoutId: number, - plid: number, -): Promise { - const layouts = await fetchLayoutsByParent(gateway, groupId, privateLayout, parentLayoutId); - - for (const layout of layouts) { - if (Number(layout.plid ?? -1) === plid) { - return layout; - } - } - - for (const layout of layouts) { - const child = await findLayoutByPlidRecursive(gateway, groupId, privateLayout, Number(layout.layoutId ?? 0), plid); - if (child) { - return child; - } - } - - return null; -} diff --git a/src/features/liferay/inventory/liferay-inventory-page-projection.ts b/src/features/liferay/inventory/liferay-inventory-page-projection.ts new file mode 100644 index 00000000..5592d9d7 --- /dev/null +++ b/src/features/liferay/inventory/liferay-inventory-page-projection.ts @@ -0,0 +1,348 @@ +import type {JournalArticleSummary} from './liferay-inventory-page-assemble.js'; +import { + validateLiferayInventoryPageJsonResult, + type LiferayInventoryPageJsonResult, +} from './liferay-inventory-page-json-schema.js'; +import type {LiferayInventoryPageResult} from './liferay-inventory-page.js'; + +export function projectLiferayInventoryPageJson( + result: LiferayInventoryPageResult, + options?: {full?: boolean}, +): LiferayInventoryPageJsonResult { + if (result.pageType === 'displayPage') { + return validateLiferayInventoryPageJsonResult(projectDisplayPageJson(result, options)); + } + + if (result.pageType === 'siteRoot') { + return validateLiferayInventoryPageJsonResult({ + page: { + type: 'siteRoot', + ...(result.siteName ? {siteName: result.siteName} : {}), + siteFriendlyUrl: result.siteFriendlyUrl, + groupId: result.groupId, + url: result.url, + }, + pages: result.pages, + }); + } + + const fragments = (result.fragmentEntryLinks ?? []) + .filter((entry) => entry.type === 'fragment' && entry.fragmentKey) + .map((entry) => ({ + fragmentKey: entry.fragmentKey!, + ...(entry.fragmentSiteFriendlyUrl ? {fragmentSiteFriendlyUrl: entry.fragmentSiteFriendlyUrl} : {}), + ...(entry.fragmentExportPath ? {fragmentExportPath: entry.fragmentExportPath} : {}), + ...(entry.configuration ? {configuration: entry.configuration} : {}), + ...(entry.contentSummary ? {contentSummary: entry.contentSummary} : {}), + ...(entry.mappedTemplateKeys && entry.mappedTemplateKeys.length > 0 + ? {mappedTemplateKeys: entry.mappedTemplateKeys} + : {}), + ...(entry.mappedStructureKeys && entry.mappedStructureKeys.length > 0 + ? {mappedStructureKeys: entry.mappedStructureKeys} + : {}), + })); + + const widgets = (result.fragmentEntryLinks ?? []) + .filter((entry) => entry.type === 'widget' && entry.widgetName) + .map((entry) => ({ + widgetName: entry.widgetName!, + ...(entry.portletId ? {portletId: entry.portletId} : {}), + ...(entry.configuration ? {configuration: entry.configuration} : {}), + })); + + const portlets = (result.portlets ?? []).map((portlet) => ({ + columnId: portlet.columnId, + position: portlet.position, + portletId: portlet.portletId, + portletName: portlet.portletName, + ...(portlet.instanceId ? {instanceId: portlet.instanceId} : {}), + ...(portlet.configuration ? {configuration: portlet.configuration} : {}), + })); + + const minimalResult = { + page: { + type: 'regularPage', + subtype: result.pageSubtype, + uiType: result.pageUiType, + siteName: result.siteName, + siteFriendlyUrl: result.siteFriendlyUrl, + groupId: result.groupId, + url: result.url, + friendlyUrl: result.friendlyUrl, + pageName: result.pageName, + privateLayout: result.privateLayout, + layoutId: result.layout.layoutId, + plid: result.layout.plid, + hidden: result.layout.hidden, + }, + ...(result.pageSummary ? {summary: result.pageSummary} : {}), + adminUrls: result.adminUrls, + ...(result.configurationTabs ? {configuration: result.configurationTabs} : {}), + ...(result.journalArticles && result.journalArticles.length > 0 + ? {contentRefs: result.journalArticles.map(projectJournalArticleRef)} + : {}), + ...(result.evidence && result.evidence.length > 0 ? {evidence: result.evidence} : {}), + ...(fragments.length > 0 || widgets.length > 0 || portlets.length > 0 + ? { + components: { + ...(fragments.length > 0 ? {fragments} : {}), + ...(widgets.length > 0 ? {widgets} : {}), + ...(portlets.length > 0 ? {portlets} : {}), + }, + } + : {}), + ...(result.componentInspectionSupported !== undefined + ? {capabilities: {componentInspectionSupported: result.componentInspectionSupported}} + : {}), + }; + + if (!options?.full) { + return validateLiferayInventoryPageJsonResult(minimalResult); + } + + const fullFragments = (result.fragmentEntryLinks ?? []).filter( + (entry) => entry.type === 'fragment' && entry.fragmentKey, + ); + const fullWidgets = (result.fragmentEntryLinks ?? []).filter((entry) => entry.type === 'widget' && entry.widgetName); + + return validateLiferayInventoryPageJsonResult({ + ...minimalResult, + full: { + ...(Object.keys(result.layoutDetails).length > 0 ? {layoutDetails: result.layoutDetails} : {}), + ...(result.configurationRaw ? {configurationRaw: result.configurationRaw} : {}), + ...(result.portlets && result.portlets.length > 0 ? {portlets: result.portlets} : {}), + ...(result.journalArticles && result.journalArticles.length > 0 ? {journalArticles: result.journalArticles} : {}), + ...(result.contentStructures && result.contentStructures.length > 0 + ? {contentStructures: result.contentStructures} + : {}), + ...(fullFragments.length > 0 || fullWidgets.length > 0 + ? { + components: { + ...(fullFragments.length > 0 ? {fragments: fullFragments} : {}), + ...(fullWidgets.length > 0 ? {widgets: fullWidgets} : {}), + }, + } + : {}), + }, + }); +} + +function projectDisplayPageJson( + result: Extract, + options?: {full?: boolean}, +): LiferayInventoryPageJsonResult { + const articleDetails = result.journalArticles?.[0]; + const contentSummary = buildDisplayContentSummary(articleDetails, result.article.title); + const rendering = buildDisplayRendering(articleDetails); + const taxonomy = + articleDetails?.taxonomyCategoryNames && articleDetails.taxonomyCategoryNames.length > 0 + ? {categories: articleDetails.taxonomyCategoryNames} + : undefined; + const lifecycle = buildDisplayLifecycle(articleDetails); + + const minimalResult = { + page: { + type: 'displayPage', + subtype: 'journalArticle', + contentItemType: 'WebContent', + siteName: result.siteName, + siteFriendlyUrl: result.siteFriendlyUrl, + groupId: result.groupId, + url: result.url, + friendlyUrl: result.friendlyUrl, + }, + article: { + id: result.article.id, + key: result.article.key, + title: result.article.title, + friendlyUrlPath: result.article.friendlyUrlPath, + contentStructureId: result.article.contentStructureId, + ...(articleDetails?.discoverySource ? {discoverySource: articleDetails.discoverySource} : {}), + ...(articleDetails?.groupId ? {groupId: articleDetails.groupId} : {}), + ...(articleDetails?.siteId ? {siteId: articleDetails.siteId} : {}), + ...(articleDetails?.siteFriendlyUrl ? {siteFriendlyUrl: articleDetails.siteFriendlyUrl} : {}), + ...(articleDetails?.siteName ? {siteName: articleDetails.siteName} : {}), + ...(articleDetails?.ddmStructureKey ? {structureKey: articleDetails.ddmStructureKey} : {}), + ...(articleDetails?.ddmStructureSiteFriendlyUrl + ? {structureSiteFriendlyUrl: articleDetails.ddmStructureSiteFriendlyUrl} + : {}), + ...(articleDetails?.structureExportPath ? {structureExportPath: articleDetails.structureExportPath} : {}), + ...(articleDetails?.ddmTemplateKey ? {templateKey: articleDetails.ddmTemplateKey} : {}), + ...(articleDetails?.ddmTemplateSiteFriendlyUrl + ? {templateSiteFriendlyUrl: articleDetails.ddmTemplateSiteFriendlyUrl} + : {}), + ...(articleDetails?.templateExportPath ? {templateExportPath: articleDetails.templateExportPath} : {}), + ...(articleDetails?.externalReferenceCode ? {externalReferenceCode: articleDetails.externalReferenceCode} : {}), + ...(articleDetails?.uuid ? {uuid: articleDetails.uuid} : {}), + }, + ...(result.adminUrls ? {adminUrls: result.adminUrls} : {}), + ...(contentSummary ? {contentSummary} : {}), + ...(rendering ? {rendering} : {}), + ...(taxonomy ? {taxonomy} : {}), + ...(lifecycle ? {lifecycle} : {}), + ...(result.evidence && result.evidence.length > 0 ? {evidence: result.evidence} : {}), + }; + + if (!options?.full) { + return minimalResult as LiferayInventoryPageJsonResult; + } + + return { + ...minimalResult, + full: { + ...(articleDetails + ? { + articleDetails: { + ...(articleDetails.contentFields && articleDetails.contentFields.length > 0 + ? {contentFields: articleDetails.contentFields} + : {}), + ...(articleDetails.widgetTemplateCandidates && articleDetails.widgetTemplateCandidates.length > 0 + ? {widgetTemplateCandidates: articleDetails.widgetTemplateCandidates} + : {}), + ...(articleDetails.displayPageTemplateCandidates && + articleDetails.displayPageTemplateCandidates.length > 0 + ? {displayPageTemplateCandidates: articleDetails.displayPageTemplateCandidates} + : {}), + ...(articleDetails.taxonomyCategoryBriefs && articleDetails.taxonomyCategoryBriefs.length > 0 + ? {taxonomyCategoryBriefs: articleDetails.taxonomyCategoryBriefs} + : {}), + ...(articleDetails.renderedContents && articleDetails.renderedContents.length > 0 + ? {renderedContents: articleDetails.renderedContents} + : {}), + }, + } + : {}), + ...(result.contentStructures && result.contentStructures.length > 0 + ? {contentStructures: result.contentStructures} + : {}), + }, + } as LiferayInventoryPageJsonResult; +} + +function buildDisplayContentSummary(articleDetails: JournalArticleSummary | undefined, fallbackTitle: string) { + const headline = articleDetails?.title ? truncateText(stripHtml(articleDetails.title), 240) : fallbackTitle; + const lead = articleDetails?.description ? truncateText(stripHtml(articleDetails.description), 240) : undefined; + + if (!headline && !lead) { + return undefined; + } + + return { + ...(headline ? {headline: decodeHtmlEntities(headline)} : {}), + ...(lead ? {lead: decodeHtmlEntities(lead)} : {}), + }; +} + +function projectJournalArticleRef(article: JournalArticleSummary) { + return { + articleId: article.articleId, + title: article.title, + ...(article.discoverySource ? {discoverySource: article.discoverySource} : {}), + ...(article.groupId ? {groupId: article.groupId} : {}), + ...(article.siteId ? {siteId: article.siteId} : {}), + ...(article.siteFriendlyUrl ? {siteFriendlyUrl: article.siteFriendlyUrl} : {}), + ...(article.siteName ? {siteName: article.siteName} : {}), + ...(article.ddmStructureKey ? {structureKey: article.ddmStructureKey} : {}), + ...(article.ddmStructureSiteFriendlyUrl ? {structureSiteFriendlyUrl: article.ddmStructureSiteFriendlyUrl} : {}), + ...(article.structureExportPath ? {structureExportPath: article.structureExportPath} : {}), + ...(article.contentStructureId ? {contentStructureId: article.contentStructureId} : {}), + ...(article.ddmTemplateKey ? {templateKey: article.ddmTemplateKey} : {}), + ...(article.ddmTemplateSiteFriendlyUrl ? {templateSiteFriendlyUrl: article.ddmTemplateSiteFriendlyUrl} : {}), + ...(article.templateExportPath ? {templateExportPath: article.templateExportPath} : {}), + ...(article.widgetDefaultTemplate ? {widgetDefaultTemplate: article.widgetDefaultTemplate} : {}), + ...(article.displayPageDefaultTemplate ? {displayPageDefaultTemplate: article.displayPageDefaultTemplate} : {}), + }; +} + +function buildDisplayRendering(articleDetails?: JournalArticleSummary) { + if (!articleDetails) { + return undefined; + } + + const derivedDisplayPageTemplate = articleDetails.renderedContents + ?.map((item) => item as Record) + .find( + (candidate) => + candidate.markedAsDefault === true && + typeof candidate.contentTemplateName === 'string' && + typeof candidate.renderedContentURL === 'string' && + candidate.renderedContentURL.includes('/rendered-content-by-display-page/'), + ); + const displayPageDefaultTemplate = + articleDetails.displayPageDefaultTemplate ?? + (derivedDisplayPageTemplate?.contentTemplateName as string | undefined); + + const hasWidgetRendering = Boolean( + articleDetails.widgetDefaultTemplate || + articleDetails.widgetHeadlessDefaultTemplate || + articleDetails.widgetTemplateCandidates?.length, + ); + const hasDisplayPageRendering = Boolean( + displayPageDefaultTemplate || articleDetails.displayPageTemplateCandidates?.length, + ); + + if (!hasWidgetRendering && !hasDisplayPageRendering) { + return undefined; + } + + return { + ...(articleDetails.widgetDefaultTemplate ? {widgetDefaultTemplate: articleDetails.widgetDefaultTemplate} : {}), + ...(displayPageDefaultTemplate ? {displayPageDefaultTemplate} : {}), + ...(articleDetails.displayPageDdmTemplates && articleDetails.displayPageDdmTemplates.length > 0 + ? {displayPageDdmTemplates: articleDetails.displayPageDdmTemplates} + : {}), + hasWidgetRendering, + hasDisplayPageRendering, + }; +} + +function buildDisplayLifecycle(articleDetails?: JournalArticleSummary) { + if (!articleDetails) { + return undefined; + } + + if ( + !articleDetails.availableLanguages?.length && + !articleDetails.dateCreated && + !articleDetails.dateModified && + !articleDetails.datePublished && + articleDetails.neverExpire === undefined + ) { + return undefined; + } + + return { + ...(articleDetails.availableLanguages?.length ? {availableLanguages: articleDetails.availableLanguages} : {}), + ...(articleDetails.dateCreated ? {dateCreated: articleDetails.dateCreated} : {}), + ...(articleDetails.dateModified ? {dateModified: articleDetails.dateModified} : {}), + ...(articleDetails.datePublished ? {datePublished: articleDetails.datePublished} : {}), + ...(articleDetails.neverExpire !== undefined ? {neverExpire: articleDetails.neverExpire} : {}), + }; +} + +function stripHtml(value: string): string { + return value + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function decodeHtmlEntities(value: string): string { + const entityMap: Record = { + ' ': ' ', + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + }; + + return value + .replace(/&(nbsp|amp|lt|gt|quot|#39);/g, (entity) => entityMap[entity] ?? entity) + .replace(/\s+/g, ' ') + .trim(); +} + +function truncateText(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1).trimEnd()}…` : value; +} diff --git a/src/features/liferay/inventory/liferay-inventory-page.ts b/src/features/liferay/inventory/liferay-inventory-page.ts index e5820529..e93a97b6 100644 --- a/src/features/liferay/inventory/liferay-inventory-page.ts +++ b/src/features/liferay/inventory/liferay-inventory-page.ts @@ -21,10 +21,6 @@ import { fetchSiteRootInventory, resolveRegularLayoutPageData, } from './liferay-inventory-page-fetch.js'; -import { - validateLiferayInventoryPageJsonResult, - type LiferayInventoryPageJsonResult, -} from './liferay-inventory-page-json-schema.js'; import {validateLiferayInventoryPageResultV2} from './liferay-inventory-page-schema.js'; import type { ContentStructureSummary, @@ -37,6 +33,7 @@ import type {HeadlessSitePagePayload} from '../page-layout/liferay-site-page-sha export {resolveInventoryPageRequest}; export {formatLiferayInventoryPage} from './liferay-inventory-page-format.js'; export type {LiferayInventoryPageJsonResult} from './liferay-inventory-page-json-schema.js'; +export {projectLiferayInventoryPageJson} from './liferay-inventory-page-projection.js'; type InventoryPageDependencies = { apiClient?: HttpApiClient; @@ -332,348 +329,6 @@ function isInventoryPageRequest(value: InventoryPageRequest | InventoryPageOptio return 'kind' in value; } -export function projectLiferayInventoryPageJson( - result: LiferayInventoryPageResult, - options?: {full?: boolean}, -): LiferayInventoryPageJsonResult { - if (result.pageType === 'displayPage') { - return validateLiferayInventoryPageJsonResult(projectDisplayPageJson(result, options)); - } - - if (result.pageType === 'siteRoot') { - return validateLiferayInventoryPageJsonResult({ - page: { - type: 'siteRoot', - ...(result.siteName ? {siteName: result.siteName} : {}), - siteFriendlyUrl: result.siteFriendlyUrl, - groupId: result.groupId, - url: result.url, - }, - pages: result.pages, - }); - } - - const fragments = (result.fragmentEntryLinks ?? []) - .filter((entry) => entry.type === 'fragment' && entry.fragmentKey) - .map((entry) => ({ - fragmentKey: entry.fragmentKey!, - ...(entry.fragmentSiteFriendlyUrl ? {fragmentSiteFriendlyUrl: entry.fragmentSiteFriendlyUrl} : {}), - ...(entry.fragmentExportPath ? {fragmentExportPath: entry.fragmentExportPath} : {}), - ...(entry.configuration ? {configuration: entry.configuration} : {}), - ...(entry.contentSummary ? {contentSummary: entry.contentSummary} : {}), - ...(entry.mappedTemplateKeys && entry.mappedTemplateKeys.length > 0 - ? {mappedTemplateKeys: entry.mappedTemplateKeys} - : {}), - ...(entry.mappedStructureKeys && entry.mappedStructureKeys.length > 0 - ? {mappedStructureKeys: entry.mappedStructureKeys} - : {}), - })); - - const widgets = (result.fragmentEntryLinks ?? []) - .filter((entry) => entry.type === 'widget' && entry.widgetName) - .map((entry) => ({ - widgetName: entry.widgetName!, - ...(entry.portletId ? {portletId: entry.portletId} : {}), - ...(entry.configuration ? {configuration: entry.configuration} : {}), - })); - - const portlets = (result.portlets ?? []).map((portlet) => ({ - columnId: portlet.columnId, - position: portlet.position, - portletId: portlet.portletId, - portletName: portlet.portletName, - ...(portlet.instanceId ? {instanceId: portlet.instanceId} : {}), - ...(portlet.configuration ? {configuration: portlet.configuration} : {}), - })); - - const minimalResult = { - page: { - type: 'regularPage', - subtype: result.pageSubtype, - uiType: result.pageUiType, - siteName: result.siteName, - siteFriendlyUrl: result.siteFriendlyUrl, - groupId: result.groupId, - url: result.url, - friendlyUrl: result.friendlyUrl, - pageName: result.pageName, - privateLayout: result.privateLayout, - layoutId: result.layout.layoutId, - plid: result.layout.plid, - hidden: result.layout.hidden, - }, - ...(result.pageSummary ? {summary: result.pageSummary} : {}), - adminUrls: result.adminUrls, - ...(result.configurationTabs ? {configuration: result.configurationTabs} : {}), - ...(result.journalArticles && result.journalArticles.length > 0 - ? {contentRefs: result.journalArticles.map(projectJournalArticleRef)} - : {}), - ...(result.evidence && result.evidence.length > 0 ? {evidence: result.evidence} : {}), - ...(fragments.length > 0 || widgets.length > 0 || portlets.length > 0 - ? { - components: { - ...(fragments.length > 0 ? {fragments} : {}), - ...(widgets.length > 0 ? {widgets} : {}), - ...(portlets.length > 0 ? {portlets} : {}), - }, - } - : {}), - ...(result.componentInspectionSupported !== undefined - ? {capabilities: {componentInspectionSupported: result.componentInspectionSupported}} - : {}), - }; - - if (!options?.full) { - return validateLiferayInventoryPageJsonResult(minimalResult); - } - - const fullFragments = (result.fragmentEntryLinks ?? []).filter( - (entry) => entry.type === 'fragment' && entry.fragmentKey, - ); - const fullWidgets = (result.fragmentEntryLinks ?? []).filter((entry) => entry.type === 'widget' && entry.widgetName); - - return validateLiferayInventoryPageJsonResult({ - ...minimalResult, - full: { - ...(Object.keys(result.layoutDetails).length > 0 ? {layoutDetails: result.layoutDetails} : {}), - ...(result.configurationRaw ? {configurationRaw: result.configurationRaw} : {}), - ...(result.portlets && result.portlets.length > 0 ? {portlets: result.portlets} : {}), - ...(result.journalArticles && result.journalArticles.length > 0 ? {journalArticles: result.journalArticles} : {}), - ...(result.contentStructures && result.contentStructures.length > 0 - ? {contentStructures: result.contentStructures} - : {}), - ...(fullFragments.length > 0 || fullWidgets.length > 0 - ? { - components: { - ...(fullFragments.length > 0 ? {fragments: fullFragments} : {}), - ...(fullWidgets.length > 0 ? {widgets: fullWidgets} : {}), - }, - } - : {}), - }, - }); -} - -function projectDisplayPageJson( - result: Extract, - options?: {full?: boolean}, -): LiferayInventoryPageJsonResult { - const articleDetails = result.journalArticles?.[0]; - const contentSummary = buildDisplayContentSummary(articleDetails, result.article.title); - const rendering = buildDisplayRendering(articleDetails); - const taxonomy = - articleDetails?.taxonomyCategoryNames && articleDetails.taxonomyCategoryNames.length > 0 - ? {categories: articleDetails.taxonomyCategoryNames} - : undefined; - const lifecycle = buildDisplayLifecycle(articleDetails); - - const minimalResult = { - page: { - type: 'displayPage', - subtype: 'journalArticle', - contentItemType: 'WebContent', - siteName: result.siteName, - siteFriendlyUrl: result.siteFriendlyUrl, - groupId: result.groupId, - url: result.url, - friendlyUrl: result.friendlyUrl, - }, - article: { - id: result.article.id, - key: result.article.key, - title: result.article.title, - friendlyUrlPath: result.article.friendlyUrlPath, - contentStructureId: result.article.contentStructureId, - ...(articleDetails?.discoverySource ? {discoverySource: articleDetails.discoverySource} : {}), - ...(articleDetails?.groupId ? {groupId: articleDetails.groupId} : {}), - ...(articleDetails?.siteId ? {siteId: articleDetails.siteId} : {}), - ...(articleDetails?.siteFriendlyUrl ? {siteFriendlyUrl: articleDetails.siteFriendlyUrl} : {}), - ...(articleDetails?.siteName ? {siteName: articleDetails.siteName} : {}), - ...(articleDetails?.ddmStructureKey ? {structureKey: articleDetails.ddmStructureKey} : {}), - ...(articleDetails?.ddmStructureSiteFriendlyUrl - ? {structureSiteFriendlyUrl: articleDetails.ddmStructureSiteFriendlyUrl} - : {}), - ...(articleDetails?.structureExportPath ? {structureExportPath: articleDetails.structureExportPath} : {}), - ...(articleDetails?.ddmTemplateKey ? {templateKey: articleDetails.ddmTemplateKey} : {}), - ...(articleDetails?.ddmTemplateSiteFriendlyUrl - ? {templateSiteFriendlyUrl: articleDetails.ddmTemplateSiteFriendlyUrl} - : {}), - ...(articleDetails?.templateExportPath ? {templateExportPath: articleDetails.templateExportPath} : {}), - ...(articleDetails?.externalReferenceCode ? {externalReferenceCode: articleDetails.externalReferenceCode} : {}), - ...(articleDetails?.uuid ? {uuid: articleDetails.uuid} : {}), - }, - ...(result.adminUrls ? {adminUrls: result.adminUrls} : {}), - ...(contentSummary ? {contentSummary} : {}), - ...(rendering ? {rendering} : {}), - ...(taxonomy ? {taxonomy} : {}), - ...(lifecycle ? {lifecycle} : {}), - ...(result.evidence && result.evidence.length > 0 ? {evidence: result.evidence} : {}), - }; - - if (!options?.full) { - return minimalResult as LiferayInventoryPageJsonResult; - } - - return { - ...minimalResult, - full: { - ...(articleDetails - ? { - articleDetails: { - ...(articleDetails.contentFields && articleDetails.contentFields.length > 0 - ? {contentFields: articleDetails.contentFields} - : {}), - ...(articleDetails.widgetTemplateCandidates && articleDetails.widgetTemplateCandidates.length > 0 - ? {widgetTemplateCandidates: articleDetails.widgetTemplateCandidates} - : {}), - ...(articleDetails.displayPageTemplateCandidates && - articleDetails.displayPageTemplateCandidates.length > 0 - ? {displayPageTemplateCandidates: articleDetails.displayPageTemplateCandidates} - : {}), - ...(articleDetails.taxonomyCategoryBriefs && articleDetails.taxonomyCategoryBriefs.length > 0 - ? {taxonomyCategoryBriefs: articleDetails.taxonomyCategoryBriefs} - : {}), - ...(articleDetails.renderedContents && articleDetails.renderedContents.length > 0 - ? {renderedContents: articleDetails.renderedContents} - : {}), - }, - } - : {}), - ...(result.contentStructures && result.contentStructures.length > 0 - ? {contentStructures: result.contentStructures} - : {}), - }, - } as LiferayInventoryPageJsonResult; -} - -function buildDisplayContentSummary(articleDetails: JournalArticleSummary | undefined, fallbackTitle: string) { - const headline = articleDetails?.title ? truncateText(stripHtml(articleDetails.title), 240) : fallbackTitle; - const lead = articleDetails?.description ? truncateText(stripHtml(articleDetails.description), 240) : undefined; - - if (!headline && !lead) { - return undefined; - } - - return { - ...(headline ? {headline: decodeHtmlEntities(headline)} : {}), - ...(lead ? {lead: decodeHtmlEntities(lead)} : {}), - }; -} - -function projectJournalArticleRef(article: JournalArticleSummary) { - return { - articleId: article.articleId, - title: article.title, - ...(article.discoverySource ? {discoverySource: article.discoverySource} : {}), - ...(article.groupId ? {groupId: article.groupId} : {}), - ...(article.siteId ? {siteId: article.siteId} : {}), - ...(article.siteFriendlyUrl ? {siteFriendlyUrl: article.siteFriendlyUrl} : {}), - ...(article.siteName ? {siteName: article.siteName} : {}), - ...(article.ddmStructureKey ? {structureKey: article.ddmStructureKey} : {}), - ...(article.ddmStructureSiteFriendlyUrl ? {structureSiteFriendlyUrl: article.ddmStructureSiteFriendlyUrl} : {}), - ...(article.structureExportPath ? {structureExportPath: article.structureExportPath} : {}), - ...(article.contentStructureId ? {contentStructureId: article.contentStructureId} : {}), - ...(article.ddmTemplateKey ? {templateKey: article.ddmTemplateKey} : {}), - ...(article.ddmTemplateSiteFriendlyUrl ? {templateSiteFriendlyUrl: article.ddmTemplateSiteFriendlyUrl} : {}), - ...(article.templateExportPath ? {templateExportPath: article.templateExportPath} : {}), - ...(article.widgetDefaultTemplate ? {widgetDefaultTemplate: article.widgetDefaultTemplate} : {}), - ...(article.displayPageDefaultTemplate ? {displayPageDefaultTemplate: article.displayPageDefaultTemplate} : {}), - }; -} - -function buildDisplayRendering(articleDetails?: JournalArticleSummary) { - if (!articleDetails) { - return undefined; - } - - const derivedDisplayPageTemplate = articleDetails.renderedContents - ?.map((item) => item as Record) - .find( - (candidate) => - candidate.markedAsDefault === true && - typeof candidate.contentTemplateName === 'string' && - typeof candidate.renderedContentURL === 'string' && - candidate.renderedContentURL.includes('/rendered-content-by-display-page/'), - ); - const displayPageDefaultTemplate = - articleDetails.displayPageDefaultTemplate ?? - (derivedDisplayPageTemplate?.contentTemplateName as string | undefined); - - const hasWidgetRendering = Boolean( - articleDetails.widgetDefaultTemplate || - articleDetails.widgetHeadlessDefaultTemplate || - articleDetails.widgetTemplateCandidates?.length, - ); - const hasDisplayPageRendering = Boolean( - displayPageDefaultTemplate || articleDetails.displayPageTemplateCandidates?.length, - ); - - if (!hasWidgetRendering && !hasDisplayPageRendering) { - return undefined; - } - - return { - ...(articleDetails.widgetDefaultTemplate ? {widgetDefaultTemplate: articleDetails.widgetDefaultTemplate} : {}), - ...(displayPageDefaultTemplate ? {displayPageDefaultTemplate} : {}), - ...(articleDetails.displayPageDdmTemplates && articleDetails.displayPageDdmTemplates.length > 0 - ? {displayPageDdmTemplates: articleDetails.displayPageDdmTemplates} - : {}), - hasWidgetRendering, - hasDisplayPageRendering, - }; -} - -function buildDisplayLifecycle(articleDetails?: JournalArticleSummary) { - if (!articleDetails) { - return undefined; - } - - if ( - !articleDetails.availableLanguages?.length && - !articleDetails.dateCreated && - !articleDetails.dateModified && - !articleDetails.datePublished && - articleDetails.neverExpire === undefined - ) { - return undefined; - } - - return { - ...(articleDetails.availableLanguages?.length ? {availableLanguages: articleDetails.availableLanguages} : {}), - ...(articleDetails.dateCreated ? {dateCreated: articleDetails.dateCreated} : {}), - ...(articleDetails.dateModified ? {dateModified: articleDetails.dateModified} : {}), - ...(articleDetails.datePublished ? {datePublished: articleDetails.datePublished} : {}), - ...(articleDetails.neverExpire !== undefined ? {neverExpire: articleDetails.neverExpire} : {}), - }; -} - -function stripHtml(value: string): string { - return value - .replace(/<[^>]+>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - -function decodeHtmlEntities(value: string): string { - const entityMap: Record = { - ' ': ' ', - '&': '&', - '<': '<', - '>': '>', - '"': '"', - ''': "'", - }; - - return value - .replace(/&(nbsp|amp|lt|gt|quot|#39);/g, (entity) => entityMap[entity] ?? entity) - .replace(/\s+/g, ' ') - .trim(); -} - -function truncateText(value: string, maxLength: number): string { - return value.length > maxLength ? `${value.slice(0, maxLength - 1).trimEnd()}…` : value; -} - async function resolvePortalHomeRequest(config: AppConfig) { const redirectedUrl = await detectPathRedirect(config, '/'); if (!redirectedUrl) { diff --git a/src/features/liferay/inventory/liferay-inventory-shared.ts b/src/features/liferay/inventory/liferay-inventory-shared.ts index 27dd2e3a..e1ca7c4c 100644 --- a/src/features/liferay/inventory/liferay-inventory-shared.ts +++ b/src/features/liferay/inventory/liferay-inventory-shared.ts @@ -3,7 +3,7 @@ import type {AppConfig} from '../../../core/config/load-config.js'; import {createOAuthTokenClient, type OAuthTokenClient} from '../../../core/http/auth.js'; import {createLiferayApiClient, type HttpResponse, type HttpApiClient} from '../../../core/http/client.js'; import {expectJsonSuccess as expectJsonSuccessShared} from '../liferay-http-shared.js'; -import {createLiferayGateway} from '../liferay-gateway.js'; +import {createLiferayGateway, type LiferayGateway} from '../liferay-gateway.js'; import {LookupCache} from '../lookup-cache.js'; import {LiferayErrors} from '../errors/index.js'; import {type HeadlessPage} from '../portal/site-resolver.js'; @@ -86,6 +86,20 @@ export function expectJsonSuccess(response: HttpResponse, label: string): return expectJsonSuccessShared(response, label, 'LIFERAY_INVENTORY_ERROR'); } +export async function safeGatewayGet( + gateway: LiferayGateway, + requestPath: string, + label: string, + requestOptions?: {headers?: Record}, +): Promise<{ok: boolean; data: T | null}> { + try { + const data = await gateway.getJson(requestPath, label, requestOptions); + return {ok: true, data}; + } catch { + return {ok: false, data: null}; + } +} + export function createInventoryGateway( config: AppConfig, apiClient: HttpApiClient, diff --git a/src/features/liferay/inventory/liferay-inventory-where-used-display-pages.ts b/src/features/liferay/inventory/liferay-inventory-where-used-display-pages.ts deleted file mode 100644 index 1f7912a7..00000000 --- a/src/features/liferay/inventory/liferay-inventory-where-used-display-pages.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type {AppConfig} from '../../../core/config/load-config.js'; -import {mapConcurrent} from '../../../core/concurrency.js'; -import {isCliError} from '../../../core/errors.js'; -import {createLiferayApiClient} from '../../../core/http/client.js'; -import type {OAuthTokenClient} from '../../../core/http/auth.js'; -import type {HttpApiClient} from '../../../core/http/client.js'; -import { - fetchJournalArticleRowsInFolder, - fetchJournalFoldersByParent, - type JsonwsJournalArticleRow, -} from '../content/liferay-content-journal-shared.js'; -import type {LiferayGateway} from '../liferay-gateway.js'; -import {LookupCache} from '../lookup-cache.js'; -import {createInventoryGateway, fetchPagedItems} from './liferay-inventory-shared.js'; -import {buildDisplayPageUrl} from './liferay-inventory-url.js'; -import type {LiferayInventorySite} from './liferay-inventory-sites.js'; - -type StructuredContentListItem = { - friendlyUrlPath?: string; -}; - -export type DisplayPageCandidate = { - fullUrl: string; - origin: 'headlessStructuredContent' | 'jsonwsJournal'; -}; - -export type DisplayPageSource = { - origin: DisplayPageCandidate['origin']; - collect: ( - config: AppConfig, - site: LiferayInventorySite, - options: WhereUsedDisplayPageScanOptions, - ) => Promise; -}; - -export type WhereUsedDisplayPageScanOptions = { - concurrency: number; - pageSize: number; - dependencies: { - apiClient?: HttpApiClient; - tokenClient?: OAuthTokenClient; - }; -}; - -type DisplayPageSourceCollectionResult = - | {kind: 'collected'; candidates: DisplayPageCandidate[]} - | {kind: 'unsupported'}; - -const DISPLAY_PAGE_SOURCES: DisplayPageSource[] = [ - {origin: 'headlessStructuredContent', collect: collectHeadlessStructuredContentDisplayPages}, - {origin: 'jsonwsJournal', collect: collectJsonwsJournalDisplayPages}, -]; - -const unsupportedDisplayPageSourceCache = new LookupCache({ttlMs: 3_600_000}); - -export async function collectDisplayPageCandidates( - config: AppConfig, - site: LiferayInventorySite, - options: WhereUsedDisplayPageScanOptions, -): Promise { - return collectDisplayPageCandidatesFromSources(config, site, options, DISPLAY_PAGE_SOURCES); -} - -export async function collectDisplayPageCandidatesFromSources( - config: AppConfig, - site: LiferayInventorySite, - options: WhereUsedDisplayPageScanOptions, - sources: DisplayPageSource[], -): Promise { - const candidates: DisplayPageCandidate[] = []; - for (const source of sources) { - if (isUnsupportedDisplayPageSourceCached(config, site, source.origin)) { - continue; - } - - const result = await collectDisplayPageCandidatesFromSource(config, site, options, source); - if (result.kind === 'unsupported') { - cacheUnsupportedDisplayPageSource(config, site, source.origin); - continue; - } - - candidates.push(...result.candidates); - } - return dedupeDisplayPageCandidates(candidates); -} - -async function collectDisplayPageCandidatesFromSource( - config: AppConfig, - site: LiferayInventorySite, - options: WhereUsedDisplayPageScanOptions, - source: DisplayPageSource, -): Promise { - try { - return { - kind: 'collected', - candidates: await source.collect(config, site, options), - }; - } catch (error) { - if (isUnsupportedDisplayPageScanError(source.origin, error)) { - return {kind: 'unsupported'}; - } - throw error; - } -} - -async function collectHeadlessStructuredContentDisplayPages( - config: AppConfig, - site: LiferayInventorySite, - options: WhereUsedDisplayPageScanOptions, -): Promise { - const structuredContents = await fetchPagedItems( - config, - `/o/headless-delivery/v1.0/sites/${site.groupId}/structured-contents`, - options.pageSize, - options.dependencies, - ); - - return structuredContents - .map((item) => buildDisplayPageUrl(site.siteFriendlyUrl, item.friendlyUrlPath)) - .filter((fullUrl): fullUrl is string => fullUrl !== null) - .map((fullUrl) => ({fullUrl, origin: 'headlessStructuredContent'})); -} - -async function collectJsonwsJournalDisplayPages( - config: AppConfig, - site: LiferayInventorySite, - options: WhereUsedDisplayPageScanOptions, -): Promise { - const apiClient = options.dependencies.apiClient ?? createLiferayApiClient(); - const gateway = createInventoryGateway(config, apiClient, options.dependencies); - const folderIds = await collectJournalFolderIds(gateway, site.groupId, 0, new Set([0])); - const pages = await mapConcurrent(folderIds, Math.max(1, Math.min(options.concurrency, 4)), async (folderId) => { - const rows = await fetchJournalArticleRowsInFolder(gateway, site.groupId, folderId); - return rows - .filter((row) => row.status === undefined || Number(row.status) === 0) - .map((row) => buildDisplayPageUrl(site.siteFriendlyUrl, resolveJournalArticleUrlTitle(row))) - .filter((fullUrl): fullUrl is string => fullUrl !== null) - .map((fullUrl) => ({fullUrl, origin: 'jsonwsJournal' as const})); - }); - - return pages.flat(); -} - -async function collectJournalFolderIds( - gateway: LiferayGateway, - groupId: number, - parentFolderId: number, - seen: Set, -): Promise { - const folders = await fetchJournalFoldersByParent(gateway, groupId, parentFolderId); - const childIds: number[] = []; - - for (const folder of folders) { - if (seen.has(folder.folderId)) { - continue; - } - seen.add(folder.folderId); - childIds.push(folder.folderId); - childIds.push(...(await collectJournalFolderIds(gateway, groupId, folder.folderId, seen))); - } - - return parentFolderId === 0 ? [0, ...childIds] : childIds; -} - -function dedupeDisplayPageCandidates(candidates: DisplayPageCandidate[]): DisplayPageCandidate[] { - const unique = new Map(); - for (const candidate of candidates) { - if (!unique.has(candidate.fullUrl)) { - unique.set(candidate.fullUrl, candidate); - } - } - return [...unique.values()]; -} - -function resolveJournalArticleUrlTitle(row: JsonwsJournalArticleRow): string | undefined { - return row.urlTitle ?? row.urlTitleCurrentValue ?? row.friendlyURL; -} - -function buildDisplayPageSourceCacheKey( - config: AppConfig, - site: LiferayInventorySite, - origin: DisplayPageCandidate['origin'], -): string { - return `${config.liferay.url}|${site.groupId}|${origin}`; -} - -function isUnsupportedDisplayPageSourceCached( - config: AppConfig, - site: LiferayInventorySite, - origin: DisplayPageCandidate['origin'], -): boolean { - return unsupportedDisplayPageSourceCache.get(buildDisplayPageSourceCacheKey(config, site, origin)) ?? false; -} - -function cacheUnsupportedDisplayPageSource( - config: AppConfig, - site: LiferayInventorySite, - origin: DisplayPageCandidate['origin'], -): void { - unsupportedDisplayPageSourceCache.set(buildDisplayPageSourceCacheKey(config, site, origin), true); -} - -export function resetDisplayPageSourceSupportCache(): void { - unsupportedDisplayPageSourceCache.clear(); -} - -function isUnsupportedDisplayPageScanError(origin: DisplayPageCandidate['origin'], error: unknown): boolean { - if (!isCliError(error)) return false; - if (error.code !== 'LIFERAY_INVENTORY_ERROR' && error.code !== 'LIFERAY_GATEWAY_ERROR') { - return false; - } - - // A 404 from headless structured contents means the API surface is not available. - // Permission failures should still surface so the caller can diagnose them. - return origin === 'headlessStructuredContent' && error.message.includes('status=404'); -} diff --git a/src/features/liferay/inventory/liferay-inventory-where-used-errors.ts b/src/features/liferay/inventory/liferay-inventory-where-used-errors.ts deleted file mode 100644 index eb2c00a1..00000000 --- a/src/features/liferay/inventory/liferay-inventory-where-used-errors.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {CliError} from '../../../core/errors.js'; - -export type WhereUsedCandidateLike = { - fullUrl: string; - origin?: 'layout' | 'headlessStructuredContent' | 'jsonwsJournal'; -}; - -export function extractErrorMessage(error: unknown): string { - if (error instanceof CliError) return error.message; - if (error instanceof Error) return error.message; - return String(error); -} - -export function isSkippableWhereUsedCandidateError(candidate: WhereUsedCandidateLike, error: unknown): boolean { - if (candidate.origin !== 'jsonwsJournal') { - return false; - } - - const message = extractErrorMessage(error); - return message.includes('No structured content found with friendlyUrlPath='); -} diff --git a/src/features/liferay/inventory/liferay-inventory-where-used-format.ts b/src/features/liferay/inventory/liferay-inventory-where-used-format.ts index e6aa8a31..e718f7ad 100644 --- a/src/features/liferay/inventory/liferay-inventory-where-used-format.ts +++ b/src/features/liferay/inventory/liferay-inventory-where-used-format.ts @@ -1,4 +1,8 @@ -import type {WhereUsedPlanResult, WhereUsedResult, WhereUsedRunResult} from './liferay-inventory-where-used.js'; +import type { + WhereUsedPlanResult, + WhereUsedResult, + WhereUsedRunResult, +} from '../../../core/contracts/inventory.schema.js'; export function formatLiferayInventoryWhereUsed(result: WhereUsedRunResult): string { if (result.inventoryType === 'whereUsedPlan') { diff --git a/src/features/liferay/inventory/liferay-inventory-where-used-match.ts b/src/features/liferay/inventory/liferay-inventory-where-used-match.ts deleted file mode 100644 index 378bdab4..00000000 --- a/src/features/liferay/inventory/liferay-inventory-where-used-match.ts +++ /dev/null @@ -1,95 +0,0 @@ -import {extractPageEvidence, type PageEvidence, type PageEvidenceKind} from './liferay-inventory-page-evidence.js'; -import type {WhereUsedResourceTypeValue} from './liferay-inventory-evidence-contract.js'; -import type {LiferayInventoryPageResult} from './liferay-inventory-page.js'; -import {normalizeWhereUsedEvidence} from './liferay-inventory-where-used-normalize.js'; - -export type WhereUsedResourceType = WhereUsedResourceTypeValue; - -export type WhereUsedQuery = { - type: WhereUsedResourceType; - keys: string[]; -}; - -export type WhereUsedMatchKind = Exclude; - -export type WhereUsedMatch = { - resourceType: WhereUsedResourceType; - matchedKey: string; - matchKind: WhereUsedMatchKind; - label: string; - detail: string; - source: PageEvidence['source']; -}; - -export function matchEvidenceAgainstResource(evidence: PageEvidence[], query: WhereUsedQuery): WhereUsedMatch[] { - const keys = new Set(query.keys.map((key) => normalizeWhereUsedKey(key, query.type))); - const seen = new Set(); - const matchedEvidence = normalizeWhereUsedEvidence( - evidence - .filter((item) => isEvidenceForResourceType(item, query.type)) - .filter((item) => item.kind !== 'journalArticle') - .filter((item) => keys.has(normalizeWhereUsedKey(item.key, query.type))), - query.type, - ); - - return matchedEvidence.flatMap((item) => { - const match: WhereUsedMatch = { - resourceType: query.type, - matchedKey: item.key, - matchKind: item.kind as WhereUsedMatchKind, - label: labelForMatchKind(item.kind as WhereUsedMatchKind, item.source), - detail: item.detail, - source: item.source, - }; - const identity = `${match.resourceType}\u0000${match.matchedKey}\u0000${match.matchKind}\u0000${match.detail}\u0000${match.source}`; - if (seen.has(identity)) { - return []; - } - seen.add(identity); - return [match]; - }); -} - -export function matchPageAgainstResource(page: LiferayInventoryPageResult, query: WhereUsedQuery): WhereUsedMatch[] { - return matchEvidenceAgainstResource(extractPageEvidence(page), query); -} - -function normalizeWhereUsedKey(key: string, type: WhereUsedResourceType): string { - return type === 'fragment' ? key.toLowerCase() : key; -} - -function isEvidenceForResourceType(evidence: PageEvidence, type: WhereUsedResourceType): boolean { - if (type === 'widget' || type === 'portlet') { - return evidence.resourceType === 'widget' || evidence.resourceType === 'portlet'; - } - return evidence.resourceType === type; -} - -function labelForMatchKind(kind: WhereUsedMatchKind, source: PageEvidence['source']): string { - switch (kind) { - case 'fragmentEntry': - return 'Fragment on page'; - case 'widgetEntry': - return 'Widget on page'; - case 'widgetAdt': - return 'Widget ADT'; - case 'portlet': - return 'Portlet on layout'; - case 'journalArticleStructure': - return source === 'renderedHtmlJournalContent' - ? 'Journal article structure (static Journal Content rendered in HTML)' - : 'Journal article structure'; - case 'journalArticleTemplate': - return source === 'renderedHtmlJournalContent' - ? 'Journal article template (static Journal Content rendered in HTML)' - : 'Journal article template'; - case 'fragmentMappedStructure': - return 'Fragment mapped structure'; - case 'fragmentMappedTemplate': - return 'Fragment mapped template'; - case 'contentStructure': - return 'Content structure'; - case 'displayPageArticle': - return 'Display page article'; - } -} diff --git a/src/features/liferay/inventory/liferay-inventory-where-used-normalize.ts b/src/features/liferay/inventory/liferay-inventory-where-used-normalize.ts deleted file mode 100644 index b677b77c..00000000 --- a/src/features/liferay/inventory/liferay-inventory-where-used-normalize.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type {PageEvidence} from './liferay-inventory-page-evidence.js'; - -export function normalizeWhereUsedEvidence( - evidence: PageEvidence[], - queryType: 'fragment' | 'widget' | 'portlet' | 'structure' | 'template' | 'adt', -): PageEvidence[] { - if (queryType !== 'structure') { - return evidence; - } - - return evidence.filter((item) => !isRedundantStructureEvidence(item, evidence)); -} - -function isRedundantStructureEvidence(evidence: PageEvidence, matchedEvidence: PageEvidence[]): boolean { - if (evidence.kind !== 'contentStructure') { - return false; - } - - const evidenceStructureId = evidence.context?.contentStructureId ?? parseNumericKey(evidence.key); - - return matchedEvidence.some( - (candidate) => - candidate.kind === 'journalArticleStructure' && - (candidate.key === evidence.key || - (evidenceStructureId !== undefined && candidate.context?.contentStructureId === evidenceStructureId)), - ); -} - -function parseNumericKey(key: string): number | undefined { - const value = Number(key); - return Number.isFinite(value) ? value : undefined; -} diff --git a/src/features/liferay/inventory/liferay-inventory-where-used-page-candidates.ts b/src/features/liferay/inventory/liferay-inventory-where-used-page-candidates.ts deleted file mode 100644 index 2d88cf24..00000000 --- a/src/features/liferay/inventory/liferay-inventory-where-used-page-candidates.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type {AppConfig} from '../../../core/config/load-config.js'; -import type {OAuthTokenClient} from '../../../core/http/auth.js'; -import type {HttpApiClient} from '../../../core/http/client.js'; -import {isCliError} from '../../../core/errors.js'; -import {runLiferayInventoryPages, type LiferayInventoryPagesNode} from './liferay-inventory-pages.js'; -import type {LiferayInventorySite} from './liferay-inventory-sites.js'; -import {collectDisplayPageCandidates} from './liferay-inventory-where-used-display-pages.js'; -import type {WhereUsedQuery} from './liferay-inventory-where-used-match.js'; -import {flattenPages, type FlatPage} from './liferay-inventory-where-used-pages.js'; - -export type WhereUsedPageCandidateOrigin = 'layout' | 'headlessStructuredContent' | 'jsonwsJournal'; - -export type WhereUsedPageCandidate = FlatPage & { - origin: WhereUsedPageCandidateOrigin; -}; - -export type WhereUsedPageCandidateContext = { - layoutScopes: boolean[]; - maxDepth: number; - concurrency: number; - pageSize: number; - dependencies: { - apiClient?: HttpApiClient; - tokenClient?: OAuthTokenClient; - }; -}; - -export async function collectWhereUsedPageCandidates( - config: AppConfig, - site: LiferayInventorySite, - query: WhereUsedQuery, - context: WhereUsedPageCandidateContext, -): Promise { - return dedupePageCandidates([ - ...(await collectLayoutPageCandidates(config, site, context)), - ...(await collectStructuredContentDisplayPageCandidates(config, site, query, context)), - ]); -} - -async function collectLayoutPageCandidates( - config: AppConfig, - site: LiferayInventorySite, - context: WhereUsedPageCandidateContext, -): Promise { - const candidates: WhereUsedPageCandidate[] = []; - - for (const privateLayout of context.layoutScopes) { - let pages: LiferayInventoryPagesNode[]; - try { - const pagesResult = await runLiferayInventoryPages( - config, - {site: site.siteFriendlyUrl, privateLayout, maxDepth: context.maxDepth}, - context.dependencies, - ); - pages = pagesResult.pages; - } catch (error) { - if (isSkippablePageSourceError(error)) continue; - throw error; - } - - candidates.push(...flattenPages(pages, privateLayout).map((page) => ({...page, origin: 'layout' as const}))); - } - - return candidates; -} - -async function collectStructuredContentDisplayPageCandidates( - config: AppConfig, - site: LiferayInventorySite, - query: WhereUsedQuery, - context: WhereUsedPageCandidateContext, -): Promise { - if (query.type !== 'structure' && query.type !== 'template') { - return []; - } - - const displayPages = await collectDisplayPageCandidates(config, site, { - concurrency: context.concurrency, - pageSize: context.pageSize, - dependencies: context.dependencies, - }); - - return displayPages.map((candidate) => ({ - fullUrl: candidate.fullUrl, - friendlyUrl: candidate.fullUrl, - name: candidate.fullUrl, - layoutId: -1, - plid: -1, - hidden: false, - privateLayout: false, - origin: candidate.origin, - })); -} - -function dedupePageCandidates(candidates: WhereUsedPageCandidate[]): WhereUsedPageCandidate[] { - const unique = new Map(); - - for (const candidate of candidates) { - const key = `${candidate.privateLayout ? 'private' : 'public'}:${candidate.fullUrl}`; - if (!unique.has(key)) { - unique.set(key, candidate); - } - } - - return [...unique.values()]; -} - -function isSkippablePageSourceError(error: unknown): boolean { - if (!isCliError(error)) return false; - if (error.code !== 'LIFERAY_INVENTORY_ERROR' && error.code !== 'LIFERAY_GATEWAY_ERROR') { - return false; - } - return error.message.includes('status=403') || error.message.includes('status=404'); -} diff --git a/src/features/liferay/inventory/liferay-inventory-where-used-pages.ts b/src/features/liferay/inventory/liferay-inventory-where-used-pages.ts deleted file mode 100644 index 6fcef86f..00000000 --- a/src/features/liferay/inventory/liferay-inventory-where-used-pages.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type {LiferayInventoryPageResult} from './liferay-inventory-page.js'; -import type {LiferayInventoryPagesNode} from './liferay-inventory-pages.js'; -import {buildPortalAbsoluteUrl} from './liferay-inventory-url.js'; -import type {WhereUsedMatch} from './liferay-inventory-where-used-match.js'; - -export type FlatPage = { - fullUrl: string; - friendlyUrl: string; - name: string; - layoutId: number; - plid: number; - hidden: boolean; - privateLayout: boolean; -}; - -export type WhereUsedPageMatch = { - pageType: 'regularPage' | 'displayPage'; - pageName: string; - friendlyUrl: string; - fullUrl: string; - viewUrl?: string; - layoutId?: number; - plid?: number; - privateLayout: boolean; - hidden?: boolean; - editUrl?: string; - matches: WhereUsedMatch[]; -}; - -export function flattenPages(pages: LiferayInventoryPagesNode[], privateLayout: boolean): FlatPage[] { - const result: FlatPage[] = []; - const visit = (node: LiferayInventoryPagesNode): void => { - result.push({ - fullUrl: node.fullUrl, - friendlyUrl: node.friendlyUrl, - name: node.name, - layoutId: node.layoutId, - plid: node.plid, - hidden: node.hidden, - privateLayout, - }); - for (const child of node.children) { - visit(child); - } - }; - for (const node of pages) visit(node); - return result; -} - -export function buildPageMatch( - page: LiferayInventoryPageResult, - entry: FlatPage, - matches: WhereUsedMatch[], - portalBaseUrl?: string, -): WhereUsedPageMatch { - if (page.pageType === 'displayPage') { - const hasRenderableView = hasDisplayPageRendering(page); - - return { - pageType: 'displayPage', - pageName: page.article.title, - friendlyUrl: page.friendlyUrl, - fullUrl: page.url, - ...(hasRenderableView ? {viewUrl: buildPortalAbsoluteUrl(portalBaseUrl, page.url)} : {}), - privateLayout: entry.privateLayout, - ...(page.adminUrls ? {editUrl: page.adminUrls.edit} : {}), - matches, - }; - } - - if (page.pageType === 'regularPage') { - return { - pageType: 'regularPage', - pageName: page.pageName, - friendlyUrl: page.friendlyUrl, - fullUrl: page.url, - viewUrl: buildPortalAbsoluteUrl(portalBaseUrl, page.url), - layoutId: page.layout.layoutId, - plid: page.layout.plid, - hidden: page.layout.hidden, - privateLayout: page.privateLayout, - editUrl: page.adminUrls.edit, - matches, - }; - } - - return { - pageType: 'regularPage', - pageName: entry.name, - friendlyUrl: entry.friendlyUrl, - fullUrl: entry.fullUrl, - viewUrl: buildPortalAbsoluteUrl(portalBaseUrl, entry.fullUrl), - layoutId: entry.layoutId, - plid: entry.plid, - hidden: entry.hidden, - privateLayout: entry.privateLayout, - matches, - }; -} - -function hasDisplayPageRendering(page: Extract): boolean { - return ( - page.journalArticles?.some((article) => { - const renderedDisplayTemplate = article.renderedContents - ?.map((item) => item as Record) - .some( - (candidate) => - candidate.markedAsDefault === true && - typeof candidate.contentTemplateName === 'string' && - typeof candidate.renderedContentURL === 'string' && - candidate.renderedContentURL.includes('/rendered-content-by-display-page/'), - ); - - return Boolean( - article.displayPageDefaultTemplate || - article.displayPageTemplateCandidates?.length || - article.displayPageDdmTemplates?.length || - renderedDisplayTemplate, - ); - }) ?? false - ); -} diff --git a/src/features/liferay/inventory/liferay-inventory-where-used-query-resolver.ts b/src/features/liferay/inventory/liferay-inventory-where-used-query-resolver.ts deleted file mode 100644 index f83b045f..00000000 --- a/src/features/liferay/inventory/liferay-inventory-where-used-query-resolver.ts +++ /dev/null @@ -1,270 +0,0 @@ -import type {AppConfig} from '../../../core/config/load-config.js'; -import type {OAuthTokenClient} from '../../../core/http/auth.js'; -import type {HttpApiClient} from '../../../core/http/client.js'; -import {CliError} from '../../../core/errors.js'; -import {matchesAdtRow, matchesDdmTemplate} from '../liferay-identifiers.js'; -import {buildSiteChain} from '../portal/site-resolution.js'; -import {listDdmTemplates, resolveResourceSite, type DdmTemplatePayload} from '../portal/template-queries.js'; -import {runLiferayResourceListAdts, type LiferayResourceAdtRow} from '../resource/liferay-resource-list-adts.js'; -import { - runLiferayResourceListFragments, - type LiferayResourceFragmentRow, -} from '../resource/liferay-resource-list-fragments.js'; -import {runLiferayInventoryTemplates} from './liferay-inventory-templates.js'; -import {runLiferayInventorySitesIncludingGlobal} from './liferay-inventory-sites.js'; -import type {WhereUsedQuery} from './liferay-inventory-where-used-match.js'; - -type WhereUsedResolverOptions = { - sites?: string[]; - widgetType?: string; - className?: string; -}; - -type WhereUsedResolverDependencies = { - apiClient?: HttpApiClient; - tokenClient?: OAuthTokenClient; -}; - -type WhereUsedAdtReference = { - displayStyle: string; - templateKey: string; -}; - -type WhereUsedAdtRowReference = Pick; -type WhereUsedFragmentRowReference = Pick; -type WhereUsedTemplateRowReference = Pick< - DdmTemplatePayload, - 'templateId' | 'templateKey' | 'externalReferenceCode' | 'nameCurrentValue' | 'name' ->; -type WhereUsedSearchSite = {siteId: number; siteFriendlyUrl: string; siteName: string}; - -export async function resolveWhereUsedQuery( - config: AppConfig, - query: WhereUsedQuery, - options: WhereUsedResolverOptions, - dependencies: WhereUsedResolverDependencies, -): Promise { - if (query.type === 'adt') { - const resolvedKeys: string[] = []; - - for (const key of query.keys) { - const rows = await collectWhereUsedAdtRows(config, key, options, dependencies); - resolvedKeys.push(...collectWhereUsedAdtKeys(rows, key)); - } - - return { - type: query.type, - keys: Array.from(new Set(resolvedKeys)), - }; - } - - if (query.type === 'fragment') { - const rows = await collectWhereUsedFragmentRows(config, options, dependencies); - return resolveKeysFromCatalog(query, rows, collectWhereUsedFragmentKeys); - } - - if (query.type === 'template') { - const rows = await collectWhereUsedTemplateRows(config, options, dependencies); - return resolveKeysFromCatalog(query, rows, collectWhereUsedTemplateKeys); - } - - return query; -} - -export function buildWhereUsedAdtKeys(adt: WhereUsedAdtReference): string[] { - const keys = new Set(); - - const displayStyle = adt.displayStyle.trim(); - if (displayStyle !== '') { - keys.add(displayStyle); - } - - const templateKey = adt.templateKey.trim(); - if (templateKey !== '') { - keys.add(templateKey.startsWith('ddmTemplate_') ? templateKey : `ddmTemplate_${templateKey}`); - } - - return [...keys]; -} - -export function collectWhereUsedAdtKeys(rows: WhereUsedAdtRowReference[], identifier: string): string[] { - const keys = new Set(); - - for (const row of rows) { - if (!matchesAdtRow(row, identifier)) { - continue; - } - - for (const key of buildWhereUsedAdtKeys({ - displayStyle: `ddmTemplate_${String(row.templateId).trim()}`, - templateKey: row.templateKey, - })) { - keys.add(key); - } - } - - if (keys.size === 0) { - throw new CliError(`ADT not found: ${identifier}`, {code: 'LIFERAY_RESOURCE_ERROR'}); - } - - return [...keys]; -} - -export function collectWhereUsedFragmentKeys(rows: WhereUsedFragmentRowReference[], identifier: string): string[] { - const normalizedIdentifier = identifier.trim().toLowerCase(); - const keys = new Set(); - - for (const row of rows) { - const fragmentKey = row.fragmentKey.trim(); - const fragmentName = row.fragmentName.trim(); - - if (fragmentKey.toLowerCase() !== normalizedIdentifier && fragmentName.toLowerCase() !== normalizedIdentifier) { - continue; - } - - if (fragmentKey !== '') { - keys.add(fragmentKey); - } - } - - return keys.size > 0 ? [...keys] : [identifier]; -} - -export function collectWhereUsedTemplateKeys(rows: WhereUsedTemplateRowReference[], identifier: string): string[] { - const keys = new Set(); - - for (const row of rows) { - if (!matchesDdmTemplate(row, identifier)) { - continue; - } - - const templateKey = String(row.templateKey ?? '').trim(); - if (templateKey !== '') { - keys.add(templateKey); - } - } - - return keys.size > 0 ? [...keys] : [identifier]; -} - -async function resolveSearchSites( - config: AppConfig, - options: Pick, - dependencies: WhereUsedResolverDependencies, -): Promise { - if (options.sites?.length) { - return collectExplicitWhereUsedSites(config, options.sites, dependencies); - } - - return (await runLiferayInventorySitesIncludingGlobal(config, undefined, dependencies)).map((site) => ({ - siteId: site.groupId, - siteFriendlyUrl: site.siteFriendlyUrl, - siteName: site.name, - })); -} - -function resolveKeysFromCatalog( - query: WhereUsedQuery, - rows: T[], - resolveKeys: (rows: T[], key: string) => string[], -): WhereUsedQuery { - const resolvedKeys = query.keys.flatMap((key) => resolveKeys(rows, key)); - return {type: query.type, keys: Array.from(new Set(resolvedKeys))}; -} - -async function collectWhereUsedAdtRows( - config: AppConfig, - identifier: string, - options: Pick, - dependencies: WhereUsedResolverDependencies, -): Promise { - const searchSites = await resolveSearchSites(config, options, dependencies); - const rows: LiferayResourceAdtRow[] = []; - - for (const site of searchSites) { - const siteRows = await runLiferayResourceListAdts( - config, - { - site: site.siteFriendlyUrl, - widgetType: options.widgetType, - className: options.className, - }, - dependencies, - ); - rows.push(...siteRows.filter((row) => matchesAdtRow(row, identifier))); - } - - return rows; -} - -async function collectWhereUsedFragmentRows( - config: AppConfig, - options: Pick, - dependencies: WhereUsedResolverDependencies, -): Promise { - const searchSites = await resolveSearchSites(config, options, dependencies); - const rows: LiferayResourceFragmentRow[] = []; - - for (const site of searchSites) { - rows.push(...(await runLiferayResourceListFragments(config, {site: site.siteFriendlyUrl}, dependencies))); - } - - return rows; -} - -async function collectWhereUsedTemplateRows( - config: AppConfig, - options: Pick, - dependencies: WhereUsedResolverDependencies, -): Promise { - const searchSites = await resolveSearchSites(config, options, dependencies); - const rows: WhereUsedTemplateRowReference[] = []; - - for (const site of searchSites) { - const resolvedSite = await resolveResourceSite(config, site.siteFriendlyUrl, dependencies); - const ddmRows = await listDdmTemplates(config, resolvedSite, dependencies, { - includeCompanyFallback: site.siteFriendlyUrl === '/global', - }); - - if (ddmRows.length > 0) { - rows.push(...ddmRows); - continue; - } - - const inventoryRows = await runLiferayInventoryTemplates( - config, - {site: resolvedSite.friendlyUrlPath}, - dependencies, - ); - rows.push( - ...inventoryRows.map((row) => ({ - templateId: row.id, - templateKey: row.externalReferenceCode || row.id, - externalReferenceCode: row.externalReferenceCode, - nameCurrentValue: row.name, - name: row.name, - })), - ); - } - - return rows; -} - -async function collectExplicitWhereUsedSites( - config: AppConfig, - sites: string[], - dependencies: WhereUsedResolverDependencies, -): Promise { - const uniqueSites = new Map(); - - for (const site of sites) { - const siteChain = await buildSiteChain(config, site, dependencies); - for (const entry of siteChain) { - if (!uniqueSites.has(entry.siteFriendlyUrl)) { - uniqueSites.set(entry.siteFriendlyUrl, entry); - } - } - } - - return [...uniqueSites.values()]; -} diff --git a/src/features/liferay/inventory/liferay-inventory-where-used-query.ts b/src/features/liferay/inventory/liferay-inventory-where-used-query.ts new file mode 100644 index 00000000..7ee0e70a --- /dev/null +++ b/src/features/liferay/inventory/liferay-inventory-where-used-query.ts @@ -0,0 +1,400 @@ +import type {AppConfig} from '../../../core/config/load-config.js'; +import type {OAuthTokenClient} from '../../../core/http/auth.js'; +import type {HttpApiClient} from '../../../core/http/client.js'; +import {CliError} from '../../../core/errors.js'; +import {whereUsedResourceTypes} from '../../../core/contracts/inventory.schema.js'; +import type {WhereUsedQuery, WhereUsedResourceType} from '../../../core/contracts/inventory.schema.js'; +import type {ContentStatsSite} from '../content/liferay-content-stats.js'; +import {matchesAdtRow, matchesDdmTemplate} from '../liferay-identifiers.js'; +import {buildSiteChain, normalizeFriendlyUrl} from '../portal/site-resolution.js'; +import {listDdmTemplates, resolveResourceSite, type DdmTemplatePayload} from '../portal/template-queries.js'; +import {runLiferayResourceListAdts, type LiferayResourceAdtRow} from '../resource/liferay-resource-list-adts.js'; +import { + runLiferayResourceListFragments, + type LiferayResourceFragmentRow, +} from '../resource/liferay-resource-list-fragments.js'; +import {runLiferayInventoryTemplates} from './liferay-inventory-templates.js'; +import { + buildPagesCommand, + runLiferayInventorySitesIncludingGlobal, + type LiferayInventorySite, +} from './liferay-inventory-sites.js'; + +// ── Validation types ────────────────────────────────────────────────────────── + +export const whereUsedSiteOrderValues = ['site', 'name', 'content'] as const; +export type WhereUsedSiteOrder = (typeof whereUsedSiteOrderValues)[number]; + +export type ValidatedWhereUsedScopeOptions = { + siteOrder: WhereUsedSiteOrder; + siteLimit?: number; + excludedSites: string[]; + plan: boolean; +}; + +// ── Site selection types ────────────────────────────────────────────────────── + +export type WhereUsedPlanSite = { + rank: number; + siteFriendlyUrl: string; + siteName: string; + groupId: number; + structuredContents?: number; + selectionReason: 'explicitSite' | 'siteOrder' | 'contentOrder'; +}; + +export type WhereUsedSiteSelectionInput = { + sites: LiferayInventorySite[]; + explicitSites?: string[]; + siteOrder: WhereUsedSiteOrder; + siteLimit?: number; + excludedSites: string[]; + contentStatsSites?: ContentStatsSite[]; + contentStatsSkippedSites?: Array<{groupId: number; siteFriendlyUrl: string; reason: string}>; +}; + +export type WhereUsedSiteSelection = { + selectedSites: LiferayInventorySite[]; + planSites: WhereUsedPlanSite[]; + totalSites: number; + excludedCount: number; + skippedSites: Array<{siteFriendlyUrl: string; groupId: number; reason: string}>; +}; + +// ── Resolver types ──────────────────────────────────────────────────────────── + +type WhereUsedResolverOptions = {sites?: string[]; widgetType?: string; className?: string}; +type WhereUsedResolverDependencies = {apiClient?: HttpApiClient; tokenClient?: OAuthTokenClient}; +type WhereUsedAdtReference = {displayStyle: string; templateKey: string}; +type WhereUsedAdtRowReference = Pick; +type WhereUsedFragmentRowReference = Pick; +type WhereUsedTemplateRowReference = Pick< + DdmTemplatePayload, + 'templateId' | 'templateKey' | 'externalReferenceCode' | 'nameCurrentValue' | 'name' +>; +type WhereUsedSearchSite = {siteId: number; siteFriendlyUrl: string; siteName: string}; + +// ── Validation ──────────────────────────────────────────────────────────────── + +const VALID_RESOURCE_TYPES: WhereUsedResourceType[] = [...whereUsedResourceTypes]; +const VALID_SITE_ORDERS: WhereUsedSiteOrder[] = [...whereUsedSiteOrderValues]; + +export function validateWhereUsedQuery(options: {type: WhereUsedResourceType; keys: string[]}): WhereUsedQuery { + if (!VALID_RESOURCE_TYPES.includes(options.type)) { + throw new CliError(`--type must be one of: ${VALID_RESOURCE_TYPES.join(', ')}.`, {code: 'LIFERAY_INVENTORY_ERROR'}); + } + + const cleanedKeys = options.keys + .map((key) => (typeof key === 'string' ? key.trim() : '')) + .filter((key) => key.length > 0); + + if (cleanedKeys.length === 0) { + throw new CliError('Provide at least one --key value to look up.', {code: 'LIFERAY_INVENTORY_ERROR'}); + } + + return {type: options.type, keys: Array.from(new Set(cleanedKeys))}; +} + +export function validateWhereUsedScopeOptions(options: { + siteOrder?: string; + siteLimit?: number; + excludeSites?: string[]; + plan?: boolean; +}): ValidatedWhereUsedScopeOptions { + const siteOrder = (options.siteOrder ?? 'site').trim() as WhereUsedSiteOrder; + if (!VALID_SITE_ORDERS.includes(siteOrder)) { + throw new CliError(`--site-order must be one of: ${VALID_SITE_ORDERS.join(', ')}.`, { + code: 'LIFERAY_INVENTORY_ERROR', + }); + } + + const siteLimit = options.siteLimit; + if (siteLimit !== undefined && (!Number.isInteger(siteLimit) || siteLimit <= 0)) { + throw new CliError('--site-limit must be a positive integer.', {code: 'LIFERAY_INVENTORY_ERROR'}); + } + + const excludedSites = Array.from( + new Set( + (options.excludeSites ?? []).map((site) => normalizeFriendlyUrl(site.trim())).filter((site) => site.length > 0), + ), + ); + + return { + siteOrder, + ...(siteLimit !== undefined ? {siteLimit} : {}), + excludedSites, + plan: Boolean(options.plan), + }; +} + +// ── Site selection ──────────────────────────────────────────────────────────── + +export function selectWhereUsedSites(input: WhereUsedSiteSelectionInput): WhereUsedSiteSelection { + if (input.explicitSites && input.explicitSites.length > 0) { + const explicitSites = input.explicitSites.map((site) => site.trim()).filter((site) => site !== ''); + const selectedSites = explicitSites.map( + (explicitSite) => + input.sites.find( + (site) => + site.siteFriendlyUrl === explicitSite || + site.siteFriendlyUrl === `/${explicitSite}` || + String(site.groupId) === explicitSite, + ) ?? { + groupId: -1, + siteFriendlyUrl: explicitSite.startsWith('/') ? explicitSite : `/${explicitSite}`, + name: explicitSite, + pagesCommand: buildPagesCommand(explicitSite), + }, + ); + + const uniqueSelectedSites = selectedSites.filter( + (site, index, allSites) => + allSites.findIndex((candidate) => candidate.siteFriendlyUrl === site.siteFriendlyUrl) === index, + ); + + return { + selectedSites: uniqueSelectedSites, + planSites: uniqueSelectedSites.map((site, index) => ({ + rank: index + 1, + siteFriendlyUrl: site.siteFriendlyUrl, + siteName: site.name, + groupId: site.groupId, + selectionReason: 'explicitSite', + })), + totalSites: input.sites.length, + excludedCount: 0, + skippedSites: input.contentStatsSkippedSites ?? [], + }; + } + + const excludedSites = new Set(input.excludedSites); + const filteredSites = input.sites.filter((site) => !excludedSites.has(site.siteFriendlyUrl)); + const structuredContentsBySite = new Map( + (input.contentStatsSites ?? []).map((site) => [site.siteFriendlyUrl, site.structuredContents]), + ); + + const orderedSites = filteredSites.slice().sort((left, right) => { + if (input.siteOrder === 'content') { + const leftCount = structuredContentsBySite.get(left.siteFriendlyUrl); + const rightCount = structuredContentsBySite.get(right.siteFriendlyUrl); + if (leftCount !== undefined && rightCount !== undefined && leftCount !== rightCount) + return rightCount - leftCount; + if (leftCount !== undefined && rightCount === undefined) return -1; + if (leftCount === undefined && rightCount !== undefined) return 1; + return left.siteFriendlyUrl.localeCompare(right.siteFriendlyUrl); + } + if (input.siteOrder === 'name') { + const byName = left.name.localeCompare(right.name); + return byName !== 0 ? byName : left.siteFriendlyUrl.localeCompare(right.siteFriendlyUrl); + } + return left.siteFriendlyUrl.localeCompare(right.siteFriendlyUrl); + }); + + const limitedSites = input.siteLimit !== undefined ? orderedSites.slice(0, input.siteLimit) : orderedSites; + + return { + selectedSites: limitedSites, + planSites: limitedSites.map((site, index) => ({ + rank: index + 1, + siteFriendlyUrl: site.siteFriendlyUrl, + siteName: site.name, + groupId: site.groupId, + ...(structuredContentsBySite.has(site.siteFriendlyUrl) + ? {structuredContents: structuredContentsBySite.get(site.siteFriendlyUrl)} + : {}), + selectionReason: input.siteOrder === 'content' ? 'contentOrder' : 'siteOrder', + })), + totalSites: input.sites.length, + excludedCount: input.sites.length - filteredSites.length, + skippedSites: input.contentStatsSkippedSites ?? [], + }; +} + +// ── Query resolution ────────────────────────────────────────────────────────── + +export async function resolveWhereUsedQuery( + config: AppConfig, + query: WhereUsedQuery, + options: WhereUsedResolverOptions, + dependencies: WhereUsedResolverDependencies, +): Promise { + if (query.type === 'adt') { + const resolvedKeys: string[] = []; + for (const key of query.keys) { + const rows = await collectWhereUsedAdtRows(config, key, options, dependencies); + resolvedKeys.push(...collectWhereUsedAdtKeys(rows, key)); + } + return {type: query.type, keys: Array.from(new Set(resolvedKeys))}; + } + + if (query.type === 'fragment') { + const rows = await collectWhereUsedFragmentRows(config, options, dependencies); + return resolveKeysFromCatalog(query, rows, collectWhereUsedFragmentKeys); + } + + if (query.type === 'template') { + const rows = await collectWhereUsedTemplateRows(config, options, dependencies); + return resolveKeysFromCatalog(query, rows, collectWhereUsedTemplateKeys); + } + + return query; +} + +export function buildWhereUsedAdtKeys(adt: WhereUsedAdtReference): string[] { + const keys = new Set(); + const displayStyle = adt.displayStyle.trim(); + if (displayStyle !== '') keys.add(displayStyle); + const templateKey = adt.templateKey.trim(); + if (templateKey !== '') keys.add(templateKey.startsWith('ddmTemplate_') ? templateKey : `ddmTemplate_${templateKey}`); + return [...keys]; +} + +export function collectWhereUsedAdtKeys(rows: WhereUsedAdtRowReference[], identifier: string): string[] { + const keys = new Set(); + for (const row of rows) { + if (!matchesAdtRow(row, identifier)) continue; + for (const key of buildWhereUsedAdtKeys({ + displayStyle: `ddmTemplate_${String(row.templateId).trim()}`, + templateKey: row.templateKey, + })) { + keys.add(key); + } + } + if (keys.size === 0) { + throw new CliError(`ADT not found: ${identifier}`, {code: 'LIFERAY_RESOURCE_ERROR'}); + } + return [...keys]; +} + +export function collectWhereUsedFragmentKeys(rows: WhereUsedFragmentRowReference[], identifier: string): string[] { + const normalizedIdentifier = identifier.trim().toLowerCase(); + const keys = new Set(); + for (const row of rows) { + const fragmentKey = row.fragmentKey.trim(); + const fragmentName = row.fragmentName.trim(); + if (fragmentKey.toLowerCase() !== normalizedIdentifier && fragmentName.toLowerCase() !== normalizedIdentifier) { + continue; + } + if (fragmentKey !== '') keys.add(fragmentKey); + } + return keys.size > 0 ? [...keys] : [identifier]; +} + +export function collectWhereUsedTemplateKeys(rows: WhereUsedTemplateRowReference[], identifier: string): string[] { + const keys = new Set(); + for (const row of rows) { + if (!matchesDdmTemplate(row, identifier)) continue; + const templateKey = String(row.templateKey ?? '').trim(); + if (templateKey !== '') keys.add(templateKey); + } + return keys.size > 0 ? [...keys] : [identifier]; +} + +// ── Private helpers ─────────────────────────────────────────────────────────── + +function resolveKeysFromCatalog( + query: WhereUsedQuery, + rows: T[], + resolveKeys: (rows: T[], key: string) => string[], +): WhereUsedQuery { + const resolvedKeys = query.keys.flatMap((key) => resolveKeys(rows, key)); + return {type: query.type, keys: Array.from(new Set(resolvedKeys))}; +} + +async function resolveSearchSites( + config: AppConfig, + options: Pick, + dependencies: WhereUsedResolverDependencies, +): Promise { + if (options.sites?.length) { + return collectExplicitWhereUsedSites(config, options.sites, dependencies); + } + return (await runLiferayInventorySitesIncludingGlobal(config, undefined, dependencies)).map((site) => ({ + siteId: site.groupId, + siteFriendlyUrl: site.siteFriendlyUrl, + siteName: site.name, + })); +} + +async function collectWhereUsedAdtRows( + config: AppConfig, + identifier: string, + options: Pick, + dependencies: WhereUsedResolverDependencies, +): Promise { + const searchSites = await resolveSearchSites(config, options, dependencies); + const rows: LiferayResourceAdtRow[] = []; + for (const site of searchSites) { + const siteRows = await runLiferayResourceListAdts( + config, + {site: site.siteFriendlyUrl, widgetType: options.widgetType, className: options.className}, + dependencies, + ); + rows.push(...siteRows.filter((row) => matchesAdtRow(row, identifier))); + } + return rows; +} + +async function collectWhereUsedFragmentRows( + config: AppConfig, + options: Pick, + dependencies: WhereUsedResolverDependencies, +): Promise { + const searchSites = await resolveSearchSites(config, options, dependencies); + const rows: LiferayResourceFragmentRow[] = []; + for (const site of searchSites) { + rows.push(...(await runLiferayResourceListFragments(config, {site: site.siteFriendlyUrl}, dependencies))); + } + return rows; +} + +async function collectWhereUsedTemplateRows( + config: AppConfig, + options: Pick, + dependencies: WhereUsedResolverDependencies, +): Promise { + const searchSites = await resolveSearchSites(config, options, dependencies); + const rows: WhereUsedTemplateRowReference[] = []; + for (const site of searchSites) { + const resolvedSite = await resolveResourceSite(config, site.siteFriendlyUrl, dependencies); + const ddmRows = await listDdmTemplates(config, resolvedSite, dependencies, { + includeCompanyFallback: site.siteFriendlyUrl === '/global', + }); + if (ddmRows.length > 0) { + rows.push(...ddmRows); + continue; + } + const inventoryRows = await runLiferayInventoryTemplates( + config, + {site: resolvedSite.friendlyUrlPath}, + dependencies, + ); + rows.push( + ...inventoryRows.map((row) => ({ + templateId: row.id, + templateKey: row.externalReferenceCode || row.id, + externalReferenceCode: row.externalReferenceCode, + nameCurrentValue: row.name, + name: row.name, + })), + ); + } + return rows; +} + +async function collectExplicitWhereUsedSites( + config: AppConfig, + sites: string[], + dependencies: WhereUsedResolverDependencies, +): Promise { + const uniqueSites = new Map(); + for (const site of sites) { + const siteChain = await buildSiteChain(config, site, dependencies); + for (const entry of siteChain) { + if (!uniqueSites.has(entry.siteFriendlyUrl)) { + uniqueSites.set(entry.siteFriendlyUrl, entry); + } + } + } + return [...uniqueSites.values()]; +} diff --git a/src/features/liferay/inventory/liferay-inventory-where-used-scan.ts b/src/features/liferay/inventory/liferay-inventory-where-used-scan.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd7192e669e232cf47598b3e692bf2d48004a30a GIT binary patch literal 19476 zcmds9{chYwlE1(86dhpUnE^(Wf(`DEu_Zx~Rw%z z#Y_#NXiNS~i&Ae*xo;~g;$%~PkAABLPd8G5v7E&;eYz-1o!2WtNdNRMoO_U^hh6A!T$CBqP{*aKIj|(R$_2n*Ym9yBYPnHh`#-K`rz~_!w&!~r=^Z- zeVktEGQRslAhM4QrGB~ArG8WCug9I1pVB-DT2JCD-5vX13tirgH~Q%a4%THn zt*fmxzX7?5@-C{T*LoI*8^hw48d&_LSd@93?U!{r&Gcz;TOH*uiY(D3Vd4*w*6RDa zlb9rdx_M`Rsfzrz>c9yQB&;xJD-%MJoYD8@Z?gJkbq$zFfT2*$Q2qxn;WobWwrn9? zNl!3!d0Z497W0D``}U`B>*kc2!}0nf(~QV{c#|eNpX#w}&Tn#{+%_DEH|LnTsmXAj+0DR)mdFGru70= z1cw#b7ViDcw@ojoE*JTyEEu!&Qc1r` z^Qw+vD~d~H54VvjHLw6wI?tk+z$5H?r}3hz=NMSn8%Q9FjNfR4;ey4jS>9B;Sj%#)XuKTi*goc>Hjq1_>;b)UbARK$Kq(j z92dZq)_DW?nW+8l&>O*<)QQ~y4eqRBJBN@YrR>i6L#gO8&1zi^hd5DYjAmNd2``G$ z66_qP@sJSL)j$9(`T*a}s6*gU4Mr>HG1ac)YS`Td5sQ!{%7$2s=rB^kIVK`~gGl&k ztVN4V(Pb9bFNrs9*GmRr)RH8p&2f-c#t=}EA|lG9wJafGRs+HlDT>bjgQw;e;SW zggE31)@gkQdi?q)1VSKHNLLO0;6ex}9n1=8wSd&wTx zdq`-E%v!PoXXyG>1zKW{XmNx+fo7X_F(7+0cpIMN20!XjJ9Ls=mhsh$eTO{-m{DCE z7q_}Rh$}rDsR^|)K^;1)?T0lzq0xO1yY`9O;C7oPSDL(Dor_MEFRH7~j^%&P2&3Mt7cWdr_g6uo)3LLU^70SkE#OnjaXy*w96(JvA_0DwgoS zaj;;B>KG{jWk;CVkOTn?=U4rB{_=POX9*o?IqIAZ5IS;?G6{@^EJU_Qu`D$?v7m|?7pC5wtbLUNS>qIkx#ezR_JKwI!QiC;E~zZh~a#!_v9Ck z0vNWp2jMI${c17OWjckM>KIDCHG58mysbS&RWZ{NMl`WA1r5gDHL)?a16;b zN<5g2v4l3}gOo^77z1;LM2JErL*fp}`BBrqFc0RkG3=|o0Gf>N$~2ajrm%D+BIWT6 zzIWxaUE&8E!DAAV)0lad2H)3df)Kzd8xIjA z7)#v(4ymxa4pIe(ln^rnojXc`7$Ytza=Q>01i;CPxPzC*0yjkp9ouzoEt9N=F1)i!&KAjtO9b{n2h7yW9REo&H@eAupliqc4d z8hN9aMX82Doar@k+zP2hhQMF&y+-PWh&JSJp2PvGH;;zXv^jCr4yQ)n)(LA^%}nas zod=Re$7J@;$p8|he!(zNA;!SZpiGjL1KO3?Q;3v192p|yh+uM_{(Csz$krjHD{?>H z0IS__l1rY#T&H7`Fp1~~ADtWld0vW(f`otwqEFdbrL5@BDH2vY3T@uCfpk-9m0%hc znA5Gshoo5hi#ZbL^+>_P8(+@^~$7kiCbX z*+f6v@ulRq^Q|2*OUvg9>%wcB5q(C+?_XnU%u>C~UB^a?@(TV1Od(W=htQWUOQ=&A z!uBF+%&bRwD}!E>AmCI)CI=?|Gr18!FsjfnM0)MFEG}kVdKwm3D=+On=>sC$Ed`AN z*P|&k!gtKq*{X>;lq>6J>ymX#j~Tx4y5EJF^&ND>LGgB3m_kps+7oYhXzUy3&aH(M zYisiY#=P3d6*kw}L_6X@!qN(wFaPby)d7Vml9b0UlqNpas831EO~BY*#_igKI`a9A zi!P8F@7i_U-ux^}mnL+Lx&e3KfHcIid-5Lq9xczc? zd0zes04)l($Yz>^Tw^s#(pkey=a>pmUr*_LP zPht$I?9TltrDwDqc3^KDAwHSTuW&`@g*QDDpC>arw|9!&XiLVdc`^t1Jwl2-#UUg_ z42?l5!pvoR-|+U2eW|{&L9(b^;AXG5kJG9?s-Y|<)@WQ>y8m=u6)4bLdov_MgcF!! z$0Q5Ho_9?oB6qzo=wE!--d^;KFDA{37JMI47X#@Xye0p5T4b4?BBBssg+8LFZK7O# zn+O8ci*()9Gqg&7F{f*P-!Ju6K7-P|mH{-H?a>K}=F&<(^M*O9UA*1xlGk~#-V6`0 z**DO)MRP%n2d13i;?!?T^qf4|e41+DErvI?6>Z>iP~k?DLTOf0F^2)|B03sF#|`A7 zpEn0WY7k0%e6@oapVU%v_yfZUoeZ9ON;Fa4hCLF|4MH<_kynd3Ij5LS2!IYq9Xvle zJ2~F}>${Wv9}eH0y?%3gaCjEL6PZDPi5L>_mR4-mNaeVZ7=!SStExpj#H)Mad;>o$ zZ|K4d>fc&$Ahu?I-mbkdvMDZ9!@lTJ-78&tW{&9H;j*r?m*D)L-~H$I_Pg!vZHgCd zElphA=?O@2lnzVQ#P$JPu+*kv*I?Wfj5@e`?sw5pC=kaZfywBsf z-&)8qUsg0nKIH}kx}=vmGvH~QiytBWRQoe>%7zh-~*#ZfGQwycoM6e4$n zRMW!gV(;LIRDY2;e|43&(%d7=t3>&XrSG71#7J@vX(S_xHmvL% zIWWPFG7}?AhD}DMT0AsDGs0;DW??p8AajM@qvRgTNex{f45nVLsn4*7qc3>8Bu?Y& zvbdG{I>v!~(tdV6>V*30Onsys7rln37?xgAftctx*N>B%DfPn7R=TjezDp;5+OJ#1 zmi0I{6P=|uC|29L`8L|#qJyB?`qPsBj;_jLF+WP~w;DWbBvkzxxO|*CB;P7*^}Q?V zfwtkN5x)lLfgiWFLe`#wt%{4HY1 zAEnk|j6%sXW(lh@j=aF4v2buzC<*w=gQz2gV1N-s7hN0SC!=Ysu#H-&2^Yyomsv6P zd#HkOqEJ8lOzt*!15ZKP5LvS$AhIQL(Jx-If_s?Mh?c>L?mm1>JRRg~G^m(G&Uwo~ z-@8+j)#+Bv;NiOsu3}UABKhkm3EW_E6J6bAd!Trg78h|IA2Wuoh5}!@!!u_&94T6G zVN<{hQ;5xetU32Wl8M^jy5|B$2g_Pq-{LOG{?4-G!qCS#wt>^=Twg9A>GO$$>*YnB zqKpO>nZo9m@!Tbf`(~O#a*wCZha|STk022GMwZ}mTsOjAErY$LyFwj&r`vN8OK z{{jRwQ@Qe?8jcX3f%uMi589WB3eMriz`|#M$3hhm?FtRUSF(k;#bQfEc~bxTK;C%% z%vBtw&$%k6$03ewYiXQ%3a@@bl?@aKn%m9KkwfI{hdV{j&`Ku5`YC-ZY;u8YEzCBT zm}Rzx5^Y&B%H)|LZS?+A%T2_$tU^`SKG7c)_I~wk^<2w%@q|YgkKPdqgNG0BLnz~N zDC`eHgl(ogB*nu)@>iz=7km(NAKpFb-#}|k`6^A{J87*=*8hF z^0G&-em;D4{`&N#ho~#^Ss}_^j3b$&soSPOz@1W|As4)UUFu7N=kk1_81Bu~0?7Q#G z#F^Xyew_zo6v=vL#lN!Z?28NDT;7-y|L&@Swsv6CDzDi*;As8wmzFj=y=E1_xAj8m z3Hy3h+Sg$POV#AQ*Fa}hf28sN@Vbc14ytkj|*0D7+HH6B4Vg?6x(Hu&+I z$#woc?`GEKvHOYir8$1CNcM`axR9BSWRRl9BOR|R5EVn_z=7|_%sTYuv=#?3fK?4I zupnZdwqJY9)r_sNK+L-pFlb6eYY&Ky4e*S+ll%5n0~3XAe3%3R`|Y}*sTWZp;^(?T zX3>8h-2o8yY|c@I8m!_IbcR#|czgbGP`koDvrxs9EQxB_xCn2O_=X*e^cAR>g3_nV z?Iz>P!Uh~k%}~2XHJ(64BAiwK@PXtFNoue^k_U!K-<%zUI}Fz{>ZIX<-HcJIXl;NY zJEcX~D)40>fpeM=tZn1Bxo_8dv211+@E5jx9kiNY1p_tKupI|W24aja3yOA(C@`ACe5*NgP8ymoSKkx?~K&!l;D(=4@`{eHd_VZKp!zm>*?$t53uktZh(MyOanO)?Bnt;=D; -}; - -export type WhereUsedSiteSelection = { - selectedSites: LiferayInventorySite[]; - planSites: WhereUsedPlanSite[]; - totalSites: number; - excludedCount: number; - skippedSites: Array<{siteFriendlyUrl: string; groupId: number; reason: string}>; -}; - -export function selectWhereUsedSites(input: WhereUsedSiteSelectionInput): WhereUsedSiteSelection { - if (input.explicitSites && input.explicitSites.length > 0) { - const explicitSites = input.explicitSites.map((site) => site.trim()).filter((site) => site !== ''); - const selectedSites = explicitSites.map((explicitSite) => { - return ( - input.sites.find( - (site) => - site.siteFriendlyUrl === explicitSite || - site.siteFriendlyUrl === `/${explicitSite}` || - String(site.groupId) === explicitSite, - ) ?? { - groupId: -1, - siteFriendlyUrl: explicitSite.startsWith('/') ? explicitSite : `/${explicitSite}`, - name: explicitSite, - pagesCommand: buildPagesCommand(explicitSite), - } - ); - }); - - const uniqueSelectedSites = selectedSites.filter( - (site, index, allSites) => - allSites.findIndex((candidate) => candidate.siteFriendlyUrl === site.siteFriendlyUrl) === index, - ); - - return { - selectedSites: uniqueSelectedSites, - planSites: uniqueSelectedSites.map((site, index) => ({ - rank: index + 1, - siteFriendlyUrl: site.siteFriendlyUrl, - siteName: site.name, - groupId: site.groupId, - selectionReason: 'explicitSite', - })), - totalSites: input.sites.length, - excludedCount: 0, - skippedSites: input.contentStatsSkippedSites ?? [], - }; - } - - const excludedSites = new Set(input.excludedSites); - const filteredSites = input.sites.filter((site) => !excludedSites.has(site.siteFriendlyUrl)); - const structuredContentsBySite = new Map( - (input.contentStatsSites ?? []).map((site) => [site.siteFriendlyUrl, site.structuredContents]), - ); - - const orderedSites = filteredSites.slice().sort((left, right) => { - if (input.siteOrder === 'content') { - const leftCount = structuredContentsBySite.get(left.siteFriendlyUrl); - const rightCount = structuredContentsBySite.get(right.siteFriendlyUrl); - - if (leftCount !== undefined && rightCount !== undefined && leftCount !== rightCount) { - return rightCount - leftCount; - } - if (leftCount !== undefined && rightCount === undefined) return -1; - if (leftCount === undefined && rightCount !== undefined) return 1; - return left.siteFriendlyUrl.localeCompare(right.siteFriendlyUrl); - } - - if (input.siteOrder === 'name') { - const byName = left.name.localeCompare(right.name); - return byName !== 0 ? byName : left.siteFriendlyUrl.localeCompare(right.siteFriendlyUrl); - } - - return left.siteFriendlyUrl.localeCompare(right.siteFriendlyUrl); - }); - - const limitedSites = input.siteLimit !== undefined ? orderedSites.slice(0, input.siteLimit) : orderedSites; - - return { - selectedSites: limitedSites, - planSites: limitedSites.map((site, index) => ({ - rank: index + 1, - siteFriendlyUrl: site.siteFriendlyUrl, - siteName: site.name, - groupId: site.groupId, - ...(structuredContentsBySite.has(site.siteFriendlyUrl) - ? {structuredContents: structuredContentsBySite.get(site.siteFriendlyUrl)} - : {}), - selectionReason: input.siteOrder === 'content' ? 'contentOrder' : 'siteOrder', - })), - totalSites: input.sites.length, - excludedCount: input.sites.length - filteredSites.length, - skippedSites: input.contentStatsSkippedSites ?? [], - }; -} diff --git a/src/features/liferay/inventory/liferay-inventory-where-used-validation.ts b/src/features/liferay/inventory/liferay-inventory-where-used-validation.ts deleted file mode 100644 index c725f189..00000000 --- a/src/features/liferay/inventory/liferay-inventory-where-used-validation.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {CliError} from '../../../core/errors.js'; -import {normalizeFriendlyUrl} from '../portal/site-resolution.js'; -import {whereUsedResourceTypes} from './liferay-inventory-evidence-contract.js'; -import type {WhereUsedQuery, WhereUsedResourceType} from './liferay-inventory-where-used-match.js'; - -export const whereUsedSiteOrderValues = ['site', 'name', 'content'] as const; -export type WhereUsedSiteOrder = (typeof whereUsedSiteOrderValues)[number]; - -export type ValidatedWhereUsedScopeOptions = { - siteOrder: WhereUsedSiteOrder; - siteLimit?: number; - excludedSites: string[]; - plan: boolean; -}; - -const VALID_RESOURCE_TYPES: WhereUsedResourceType[] = [...whereUsedResourceTypes]; -const VALID_SITE_ORDERS: WhereUsedSiteOrder[] = [...whereUsedSiteOrderValues]; - -export function validateWhereUsedQuery(options: {type: WhereUsedResourceType; keys: string[]}): WhereUsedQuery { - if (!VALID_RESOURCE_TYPES.includes(options.type)) { - throw new CliError(`--type must be one of: ${VALID_RESOURCE_TYPES.join(', ')}.`, {code: 'LIFERAY_INVENTORY_ERROR'}); - } - - const cleanedKeys = options.keys - .map((key) => (typeof key === 'string' ? key.trim() : '')) - .filter((key) => key.length > 0); - - if (cleanedKeys.length === 0) { - throw new CliError('Provide at least one --key value to look up.', { - code: 'LIFERAY_INVENTORY_ERROR', - }); - } - - return {type: options.type, keys: Array.from(new Set(cleanedKeys))}; -} - -export function validateWhereUsedScopeOptions(options: { - siteOrder?: string; - siteLimit?: number; - excludeSites?: string[]; - plan?: boolean; -}): ValidatedWhereUsedScopeOptions { - const siteOrder = (options.siteOrder ?? 'site').trim() as WhereUsedSiteOrder; - if (!VALID_SITE_ORDERS.includes(siteOrder)) { - throw new CliError(`--site-order must be one of: ${VALID_SITE_ORDERS.join(', ')}.`, { - code: 'LIFERAY_INVENTORY_ERROR', - }); - } - - const siteLimit = options.siteLimit; - if (siteLimit !== undefined && (!Number.isInteger(siteLimit) || siteLimit <= 0)) { - throw new CliError('--site-limit must be a positive integer.', {code: 'LIFERAY_INVENTORY_ERROR'}); - } - - const excludedSites = Array.from( - new Set( - (options.excludeSites ?? []).map((site) => normalizeFriendlyUrl(site.trim())).filter((site) => site.length > 0), - ), - ); - - return { - siteOrder, - ...(siteLimit !== undefined ? {siteLimit} : {}), - excludedSites, - plan: Boolean(options.plan), - }; -} diff --git a/src/features/liferay/inventory/liferay-inventory-where-used.ts b/src/features/liferay/inventory/liferay-inventory-where-used.ts index 0cf6df74..1ab295cf 100644 --- a/src/features/liferay/inventory/liferay-inventory-where-used.ts +++ b/src/features/liferay/inventory/liferay-inventory-where-used.ts @@ -3,47 +3,46 @@ import type {OAuthTokenClient} from '../../../core/http/auth.js'; import type {HttpApiClient} from '../../../core/http/client.js'; import {createLiferayApiClient} from '../../../core/http/client.js'; import {mapConcurrent} from '../../../core/concurrency.js'; -import {CliError} from '../../../core/errors.js'; +import { + whereUsedResultSchema, + whereUsedPlanResultSchema, + type WhereUsedQuery, + type WhereUsedResult, + type WhereUsedRunResult, + type WhereUsedPageMatch, +} from '../../../core/contracts/inventory.schema.js'; import {runContentStats, type ContentStatsSite} from '../content/liferay-content-stats.js'; -import {normalizeFriendlyUrl} from '../portal/site-resolution.js'; -import {whereUsedResourceTypes} from './liferay-inventory-evidence-contract.js'; import {extractPageEvidence} from './liferay-inventory-page-evidence.js'; import {resolveInventoryPageRequest, runLiferayInventoryPage} from './liferay-inventory-page.js'; import {createInventoryGateway} from './liferay-inventory-shared.js'; +import {runLiferayInventorySitesIncludingGlobal, type LiferayInventorySite} from './liferay-inventory-sites.js'; import { - buildPagesCommand, - runLiferayInventorySitesIncludingGlobal, - type LiferayInventorySite, -} from './liferay-inventory-sites.js'; -import {resolveWhereUsedQuery as resolveWhereUsedPortalResourceQuery} from './liferay-inventory-where-used-query-resolver.js'; + resolveWhereUsedQuery, + validateWhereUsedQuery, + validateWhereUsedScopeOptions, + selectWhereUsedSites, + type ValidatedWhereUsedScopeOptions, + type WhereUsedSiteSelectionInput, + type WhereUsedSiteSelection, +} from './liferay-inventory-where-used-query.js'; import { matchEvidenceAgainstResource, - type WhereUsedQuery, - type WhereUsedResourceType, -} from './liferay-inventory-where-used-match.js'; -import {validateWhereUsedPlanResult, validateWhereUsedResult} from './liferay-inventory-where-used-schema.js'; -import {buildPageMatch, type WhereUsedPageMatch} from './liferay-inventory-where-used-pages.js'; -import {collectWhereUsedPageCandidates} from './liferay-inventory-where-used-page-candidates.js'; + buildPageMatch, + collectWhereUsedPageCandidates, + isSkippableWhereUsedCandidateError, + extractErrorMessage, +} from './liferay-inventory-where-used-scan.js'; -export {matchEvidenceAgainstResource, matchPageAgainstResource} from './liferay-inventory-where-used-match.js'; export {formatLiferayInventoryWhereUsed} from './liferay-inventory-where-used-format.js'; -export {validateWhereUsedPlanResult, validateWhereUsedResult} from './liferay-inventory-where-used-schema.js'; -export { - buildWhereUsedAdtKeys, - collectWhereUsedAdtKeys, - collectWhereUsedFragmentKeys, - collectWhereUsedTemplateKeys, -} from './liferay-inventory-where-used-query-resolver.js'; +export type {WhereUsedResourceType} from '../../../core/contracts/inventory.schema.js'; export type { - WhereUsedMatch, - WhereUsedMatchKind, - WhereUsedQuery, - WhereUsedResourceType, -} from './liferay-inventory-where-used-match.js'; -export type {WhereUsedPageMatch} from './liferay-inventory-where-used-pages.js'; + WhereUsedResult, + WhereUsedPlanResult, + WhereUsedRunResult, +} from '../../../core/contracts/inventory.schema.js'; export type WhereUsedOptions = { - type: WhereUsedResourceType; + type: string; keys: string[]; sites?: string[]; excludeSites?: string[]; @@ -63,22 +62,7 @@ export type WhereUsedDependencies = { tokenClient?: OAuthTokenClient; }; -export const whereUsedSiteOrderValues = ['site', 'name', 'content'] as const; -export type WhereUsedSiteOrder = (typeof whereUsedSiteOrderValues)[number]; - -export type ValidatedWhereUsedScopeOptions = { - siteOrder: WhereUsedSiteOrder; - siteLimit?: number; - excludedSites: string[]; - plan: boolean; -}; - -export type WhereUsedCandidateLike = { - fullUrl: string; - origin?: 'layout' | 'headlessStructuredContent' | 'jsonwsJournal'; -}; - -export type WhereUsedSiteResult = { +type WhereUsedSiteResult = { siteFriendlyUrl: string; siteName: string; groupId: number; @@ -88,138 +72,12 @@ export type WhereUsedSiteResult = { errors?: Array<{fullUrl: string; reason: string}>; }; -export type WhereUsedResult = { - inventoryType: 'whereUsed'; - query: WhereUsedQuery; - scope: { - sites: string[]; - includePrivate: boolean; - concurrency: number; - maxDepth: number; - siteOrder: WhereUsedSiteOrder; - siteLimit?: number; - excludedSites: string[]; - plan: false; - }; - summary: { - totalSites: number; - totalScannedPages: number; - totalMatchedPages: number; - totalMatches: number; - totalFailedPages: number; - }; - sites: WhereUsedSiteResult[]; - skippedSites?: Array<{siteFriendlyUrl: string; groupId: number; reason: string}>; -}; - -export type WhereUsedPlanSite = { - rank: number; - siteFriendlyUrl: string; - siteName: string; - groupId: number; - structuredContents?: number; - selectionReason: 'explicitSite' | 'siteOrder' | 'contentOrder'; -}; - -export type WhereUsedPlanResult = { - inventoryType: 'whereUsedPlan'; - query: WhereUsedQuery; - scope: { - sites: string[]; - includePrivate: boolean; - concurrency: number; - maxDepth: number; - siteOrder: WhereUsedSiteOrder; - siteLimit?: number; - excludedSites: string[]; - plan: true; - }; - summary: { - totalSites: number; - selectedSites: number; - excludedSites: number; - skippedSites: number; - }; - sites: WhereUsedPlanSite[]; - skippedSites?: Array<{siteFriendlyUrl: string; groupId: number; reason: string}>; -}; - -export type WhereUsedRunResult = WhereUsedResult | WhereUsedPlanResult; - -export type WhereUsedSiteSelectionInput = { - sites: LiferayInventorySite[]; - explicitSites?: string[]; - siteOrder: WhereUsedSiteOrder; - siteLimit?: number; - excludedSites: string[]; - contentStatsSites?: ContentStatsSite[]; - contentStatsSkippedSites?: Array<{groupId: number; siteFriendlyUrl: string; reason: string}>; -}; - -export type WhereUsedSiteSelection = { - selectedSites: LiferayInventorySite[]; - planSites: WhereUsedPlanSite[]; - totalSites: number; - excludedCount: number; - skippedSites: Array<{siteFriendlyUrl: string; groupId: number; reason: string}>; -}; - -const VALID_RESOURCE_TYPES: WhereUsedResourceType[] = [...whereUsedResourceTypes]; -const VALID_SITE_ORDERS: WhereUsedSiteOrder[] = [...whereUsedSiteOrderValues]; - -export function validateWhereUsedQuery(options: Pick): WhereUsedQuery { - if (!VALID_RESOURCE_TYPES.includes(options.type)) { - throw new CliError(`--type must be one of: ${VALID_RESOURCE_TYPES.join(', ')}.`, {code: 'LIFERAY_INVENTORY_ERROR'}); - } - - const cleanedKeys = options.keys - .map((key) => (typeof key === 'string' ? key.trim() : '')) - .filter((key) => key.length > 0); - - if (cleanedKeys.length === 0) { - throw new CliError('Provide at least one --key value to look up.', { - code: 'LIFERAY_INVENTORY_ERROR', - }); - } - - return {type: options.type, keys: Array.from(new Set(cleanedKeys))}; -} - -export function validateWhereUsedScopeOptions( - options: Pick, -): ValidatedWhereUsedScopeOptions { - const siteOrder = (options.siteOrder ?? 'site').trim() as WhereUsedSiteOrder; - if (!VALID_SITE_ORDERS.includes(siteOrder)) { - throw new CliError(`--site-order must be one of: ${VALID_SITE_ORDERS.join(', ')}.`, { - code: 'LIFERAY_INVENTORY_ERROR', - }); - } - - const siteLimit = options.siteLimit; - if (siteLimit !== undefined && (!Number.isInteger(siteLimit) || siteLimit <= 0)) { - throw new CliError('--site-limit must be a positive integer.', {code: 'LIFERAY_INVENTORY_ERROR'}); - } - - const excludedSites = Array.from( - new Set( - (options.excludeSites ?? []).map((site) => normalizeFriendlyUrl(site.trim())).filter((site) => site.length > 0), - ), - ); - - return { - siteOrder, - ...(siteLimit !== undefined ? {siteLimit} : {}), - excludedSites, - plan: Boolean(options.plan), - }; -} - export async function runLiferayInventoryWhereUsed( config: AppConfig, options: WhereUsedOptions, dependencies?: WhereUsedDependencies, ): Promise { - const baseQuery = validateWhereUsedQuery(options); + const baseQuery = validateWhereUsedQuery(options as Parameters[0]); const scopeOptions = validateWhereUsedScopeOptions(options); const apiClient = dependencies?.apiClient ?? createLiferayApiClient(); const gateway = createInventoryGateway(config, apiClient, { @@ -227,7 +85,7 @@ export async function runLiferayInventoryWhereUsed( tokenClient: dependencies?.tokenClient, }); const sharedDependencies = {apiClient, tokenClient: dependencies?.tokenClient, gateway}; - const query = await resolveWhereUsedPortalResourceQuery(config, baseQuery, options, sharedDependencies); + const query = await resolveWhereUsedQuery(config, baseQuery, options, sharedDependencies); const concurrency = Math.max(1, options.concurrency ?? 4); const maxDepth = Math.max(0, options.maxDepth ?? 12); const includePrivate = Boolean(options.includePrivate); @@ -242,7 +100,7 @@ export async function runLiferayInventoryWhereUsed( const layoutScopes: boolean[] = includePrivate ? [false, true] : [false]; if (scopeOptions.plan) { - return validateWhereUsedPlanResult({ + return whereUsedPlanResultSchema.parse({ inventoryType: 'whereUsedPlan', query, scope: { @@ -263,7 +121,7 @@ export async function runLiferayInventoryWhereUsed( }, sites: resolvedScope.planSites, ...(resolvedScope.skippedSites.length > 0 ? {skippedSites: resolvedScope.skippedSites} : {}), - }) as WhereUsedPlanResult; + }); } const siteResults: WhereUsedSiteResult[] = []; @@ -279,7 +137,7 @@ export async function runLiferayInventoryWhereUsed( ); } - return validateWhereUsedResult({ + return whereUsedResultSchema.parse({ inventoryType: 'whereUsed', query, scope: { @@ -295,7 +153,7 @@ export async function runLiferayInventoryWhereUsed( summary: summarize(siteResults), sites: siteResults, ...(resolvedScope.skippedSites.length > 0 ? {skippedSites: resolvedScope.skippedSites} : {}), - }) as WhereUsedResult; + }); } type ScanContext = { @@ -341,9 +199,7 @@ async function scanSite( if (matches.length === 0) return null; return buildPageMatch(page, candidate, matches, config.liferay.url); } catch (error) { - if (isSkippableWhereUsedCandidateError(candidate, error)) { - return null; - } + if (isSkippableWhereUsedCandidateError(candidate, error)) return null; errors.push({fullUrl: candidate.fullUrl, reason: extractErrorMessage(error)}); return 'failed' as const; } @@ -358,9 +214,7 @@ async function scanSite( result.matchedPages.push(item); } - if (errors.length > 0) { - result.errors = errors; - } + if (errors.length > 0) result.errors = errors; return result; } @@ -392,11 +246,7 @@ async function resolveTargetSites( if ((!siteOptions || siteOptions.length === 0) && scopeOptions.siteOrder === 'content' && sites.length > 0) { const contentStats = await runContentStats( config, - { - limit: sites.length, - excludeSites: scopeOptions.excludedSites, - sortBy: 'content', - }, + {limit: sites.length, excludeSites: scopeOptions.excludedSites, sortBy: 'content'}, dependencies, ); @@ -406,7 +256,7 @@ async function resolveTargetSites( } } - return selectWhereUsedSites({ + const input: WhereUsedSiteSelectionInput = { sites, ...(siteOptions && siteOptions.length > 0 ? {explicitSites: siteOptions} : {}), siteOrder: scopeOptions.siteOrder, @@ -414,106 +264,7 @@ async function resolveTargetSites( excludedSites: scopeOptions.excludedSites, ...(contentStatsSites ? {contentStatsSites} : {}), ...(contentStatsSkippedSites ? {contentStatsSkippedSites} : {}), - }); -} - -export function selectWhereUsedSites(input: WhereUsedSiteSelectionInput): WhereUsedSiteSelection { - if (input.explicitSites && input.explicitSites.length > 0) { - const explicitSites = input.explicitSites.map((site) => site.trim()).filter((site) => site !== ''); - const selectedSites = explicitSites.map((explicitSite) => { - return ( - input.sites.find( - (site) => - site.siteFriendlyUrl === explicitSite || - site.siteFriendlyUrl === `/${explicitSite}` || - String(site.groupId) === explicitSite, - ) ?? { - groupId: -1, - siteFriendlyUrl: explicitSite.startsWith('/') ? explicitSite : `/${explicitSite}`, - name: explicitSite, - pagesCommand: buildPagesCommand(explicitSite), - } - ); - }); - - const uniqueSelectedSites = selectedSites.filter( - (site, index, allSites) => - allSites.findIndex((candidate) => candidate.siteFriendlyUrl === site.siteFriendlyUrl) === index, - ); - - return { - selectedSites: uniqueSelectedSites, - planSites: uniqueSelectedSites.map((site, index) => ({ - rank: index + 1, - siteFriendlyUrl: site.siteFriendlyUrl, - siteName: site.name, - groupId: site.groupId, - selectionReason: 'explicitSite', - })), - totalSites: input.sites.length, - excludedCount: 0, - skippedSites: input.contentStatsSkippedSites ?? [], - }; - } - - const excludedSites = new Set(input.excludedSites); - const filteredSites = input.sites.filter((site) => !excludedSites.has(site.siteFriendlyUrl)); - const structuredContentsBySite = new Map( - (input.contentStatsSites ?? []).map((site) => [site.siteFriendlyUrl, site.structuredContents]), - ); - - const orderedSites = filteredSites.slice().sort((left, right) => { - if (input.siteOrder === 'content') { - const leftCount = structuredContentsBySite.get(left.siteFriendlyUrl); - const rightCount = structuredContentsBySite.get(right.siteFriendlyUrl); - - if (leftCount !== undefined && rightCount !== undefined && leftCount !== rightCount) { - return rightCount - leftCount; - } - if (leftCount !== undefined && rightCount === undefined) return -1; - if (leftCount === undefined && rightCount !== undefined) return 1; - return left.siteFriendlyUrl.localeCompare(right.siteFriendlyUrl); - } - - if (input.siteOrder === 'name') { - const byName = left.name.localeCompare(right.name); - return byName !== 0 ? byName : left.siteFriendlyUrl.localeCompare(right.siteFriendlyUrl); - } - - return left.siteFriendlyUrl.localeCompare(right.siteFriendlyUrl); - }); - - const limitedSites = input.siteLimit !== undefined ? orderedSites.slice(0, input.siteLimit) : orderedSites; - - return { - selectedSites: limitedSites, - planSites: limitedSites.map((site, index) => ({ - rank: index + 1, - siteFriendlyUrl: site.siteFriendlyUrl, - siteName: site.name, - groupId: site.groupId, - ...(structuredContentsBySite.has(site.siteFriendlyUrl) - ? {structuredContents: structuredContentsBySite.get(site.siteFriendlyUrl)} - : {}), - selectionReason: input.siteOrder === 'content' ? 'contentOrder' : 'siteOrder', - })), - totalSites: input.sites.length, - excludedCount: input.sites.length - filteredSites.length, - skippedSites: input.contentStatsSkippedSites ?? [], }; -} - -function extractErrorMessage(error: unknown): string { - if (error instanceof CliError) return error.message; - if (error instanceof Error) return error.message; - return String(error); -} - -export function isSkippableWhereUsedCandidateError(candidate: WhereUsedCandidateLike, error: unknown): boolean { - if (candidate.origin !== 'jsonwsJournal') { - return false; - } - const message = extractErrorMessage(error); - return message.includes('No structured content found with friendlyUrlPath='); + return selectWhereUsedSites(input); } diff --git a/tests/unit/liferay-inventory-page.test.ts b/tests/unit/liferay-inventory-page.test.ts index af55a5c7..ab38c112 100644 --- a/tests/unit/liferay-inventory-page.test.ts +++ b/tests/unit/liferay-inventory-page.test.ts @@ -8,7 +8,7 @@ import { resolveInventoryPageRequest, runLiferayInventoryPage, } from '../../src/features/liferay/inventory/liferay-inventory-page.js'; -import {matchPageAgainstResource} from '../../src/features/liferay/inventory/liferay-inventory-where-used.js'; +import {matchPageAgainstResource} from '../../src/features/liferay/inventory/liferay-inventory-where-used-scan.js'; import { isRegularPageRequest, isSiteRootRequest, diff --git a/tests/unit/liferay-inventory-where-used.test.ts b/tests/unit/liferay-inventory-where-used.test.ts index 56ac5fb7..8a7fb071 100644 --- a/tests/unit/liferay-inventory-where-used.test.ts +++ b/tests/unit/liferay-inventory-where-used.test.ts @@ -3,27 +3,29 @@ import {beforeEach, describe, expect, test, vi} from 'vitest'; import {CliError} from '../../src/core/errors.js'; import type {LiferayInventoryPageResult} from '../../src/features/liferay/inventory/liferay-inventory-page.js'; import { - collectDisplayPageCandidatesFromSources, - resetDisplayPageSourceSupportCache, - type DisplayPageSource, -} from '../../src/features/liferay/inventory/liferay-inventory-where-used-display-pages.js'; -import {buildPageMatch} from '../../src/features/liferay/inventory/liferay-inventory-where-used-pages.js'; -import {collectWhereUsedPageCandidates} from '../../src/features/liferay/inventory/liferay-inventory-where-used-page-candidates.js'; + whereUsedPlanResultSchema, + whereUsedResultSchema, + type WhereUsedResult, +} from '../../src/core/contracts/inventory.schema.js'; +import {formatLiferayInventoryWhereUsed} from '../../src/features/liferay/inventory/liferay-inventory-where-used-format.js'; import { buildWhereUsedAdtKeys, + collectWhereUsedAdtKeys, collectWhereUsedFragmentKeys, collectWhereUsedTemplateKeys, - collectWhereUsedAdtKeys, - formatLiferayInventoryWhereUsed, - isSkippableWhereUsedCandidateError, - matchPageAgainstResource, selectWhereUsedSites, - validateWhereUsedPlanResult, - validateWhereUsedResult, validateWhereUsedQuery, validateWhereUsedScopeOptions, - type WhereUsedResult, -} from '../../src/features/liferay/inventory/liferay-inventory-where-used.js'; +} from '../../src/features/liferay/inventory/liferay-inventory-where-used-query.js'; +import { + buildPageMatch, + collectDisplayPageCandidatesFromSources, + collectWhereUsedPageCandidates, + isSkippableWhereUsedCandidateError, + matchPageAgainstResource, + resetDisplayPageSourceSupportCache, + type DisplayPageSource, +} from '../../src/features/liferay/inventory/liferay-inventory-where-used-scan.js'; import {buildPortalAbsoluteUrl} from '../../src/features/liferay/inventory/liferay-inventory-url.js'; const REGULAR_PAGE_BASE: Extract = { @@ -484,7 +486,7 @@ describe('matchPageAgainstResource - structures and templates', () => { }); test('formats where-used plans and validates the dedicated plan contract', () => { - const result = validateWhereUsedPlanResult({ + const result = whereUsedPlanResultSchema.parse({ inventoryType: 'whereUsedPlan', query: {type: 'template', keys: ['UB_TPL_DESTACATS_MULTIMEDIA']}, scope: { @@ -521,7 +523,7 @@ describe('matchPageAgainstResource - structures and templates', () => { }); test('includes skipped ranking sites in real where-used results and formatter output', () => { - const result = validateWhereUsedResult({ + const result = whereUsedResultSchema.parse({ inventoryType: 'whereUsed', query: {type: 'template', keys: ['UB_TPL_DESTACATS_MULTIMEDIA']}, scope: { @@ -1034,9 +1036,9 @@ describe('formatLiferayInventoryWhereUsed', () => { }); }); -describe('validateWhereUsedResult', () => { +describe('whereUsedResultSchema', () => { test('coerces numeric portal groupId values returned as strings', () => { - const result = validateWhereUsedResult({ + const result = whereUsedResultSchema.parse({ inventoryType: 'whereUsed', query: {type: 'template', keys: ['TPL']}, scope: {sites: ['/actualitat'], includePrivate: false, concurrency: 4, maxDepth: 12}, @@ -1063,7 +1065,7 @@ describe('validateWhereUsedResult', () => { }); test('accepts adt query and match kind in the result schema', () => { - const result = validateWhereUsedResult({ + const result = whereUsedResultSchema.parse({ inventoryType: 'whereUsed', query: {type: 'adt', keys: ['ddmTemplate_40801']}, scope: {sites: ['/global'], includePrivate: false, concurrency: 4, maxDepth: 12}, From 0e2e70950dc3ea0eee0b5e69d19a98c6c1cbfb43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Ord=C3=B3=C3=B1ez?= Date: Tue, 12 May 2026 21:09:46 +0200 Subject: [PATCH 3/3] refactor(ai): remove project Claude agent overlays --- src/features/ai/ai-install-project.ts | 71 ------------------- src/features/ai/ai-install.ts | 18 ----- templates/ai/README.md | 2 +- templates/ai/docs/ASSET_INVENTORY.md | 3 +- .../project/.claude/agents/build-verifier.md | 44 ------------ .../project/.claude/agents/issue-resolver.md | 55 -------------- .../.claude/agents/runtime-verifier.md | 45 ------------ templates/ai/project/README.md | 2 +- .../project-agents.blade-workspace.txt | 3 - .../ai/project/project-agents.ldev-native.txt | 3 - .../ai/project/project-agents.unknown.txt | 1 - tests/integration/ai.integration.test.ts | 5 -- tests/unit/ai-install-modules.test.ts | 31 -------- tests/unit/ai.test.ts | 1 - 14 files changed, 3 insertions(+), 281 deletions(-) delete mode 100644 templates/ai/project/.claude/agents/build-verifier.md delete mode 100644 templates/ai/project/.claude/agents/issue-resolver.md delete mode 100644 templates/ai/project/.claude/agents/runtime-verifier.md delete mode 100644 templates/ai/project/project-agents.blade-workspace.txt delete mode 100644 templates/ai/project/project-agents.ldev-native.txt delete mode 100644 templates/ai/project/project-agents.unknown.txt diff --git a/src/features/ai/ai-install-project.ts b/src/features/ai/ai-install-project.ts index 6e8ace16..6d167c5a 100644 --- a/src/features/ai/ai-install-project.ts +++ b/src/features/ai/ai-install-project.ts @@ -261,59 +261,6 @@ export async function installClaudeSkillCommands( return installed; } -export async function installProjectAgents( - targetDir: string, - assets: AiAssets, - projectType: ProjectType, -): Promise { - const agentsDir = path.join(assets.projectDir, '.claude', 'agents'); - if (!(await fs.pathExists(agentsDir))) { - return []; - } - - const allowedAgents = await resolveProjectAgentNames(assets.projectDir, projectType); - if (allowedAgents.length === 0) { - return []; - } - - const destinationDir = path.join(targetDir, '.claude', 'agents'); - await fs.ensureDir(destinationDir); - - const entries = await fs.readdir(agentsDir, {withFileTypes: true}); - const agentFiles = entries.filter( - (e) => e.isFile() && e.name.endsWith('.md') && allowedAgents.includes(e.name.replace('.md', '')), - ); - const installed: string[] = []; - - for (const entry of agentFiles) { - const destination = path.join(destinationDir, entry.name); - if (await fs.pathExists(destination)) { - continue; - } - await copyAiTemplatePath(path.join(agentsDir, entry.name), destination); - installed.push(entry.name.replace('.md', '')); - } - - return installed; -} - -export function buildProjectOverlayWarnings(options: { - projectType: ProjectType; - projectSkillsInstalled: string[]; - projectAgentsInstalled: string[]; -}): string[] { - const warnings: string[] = []; - - if ( - options.projectAgentsInstalled.length > 0 && - !options.projectSkillsInstalled.includes('project-issue-engineering') - ) { - warnings.push('Some project Claude agents were installed without the expected project issue-engineering skill.'); - } - - return warnings; -} - export function buildWorkspaceCoexistenceWarnings( projectType: ProjectType, officialWorkspaceFilesDetected: string[], @@ -347,24 +294,6 @@ export function resolveProjectSkillsManifest(projectDir: string, projectType: Pr return path.join(projectDir, 'project-skills.txt'); } -export async function resolveProjectAgentNames(projectDir: string, projectType: ProjectType): Promise { - const manifestPath = path.join(projectDir, `project-agents.${projectType}.txt`); - if (!(await fs.pathExists(manifestPath))) { - if (projectType === 'unknown') { - const unknownManifestPath = path.join(projectDir, 'project-agents.unknown.txt'); - if (await fs.pathExists(unknownManifestPath)) { - return readSimpleManifest(unknownManifestPath); - } - } - if (projectType === 'ldev-native') { - return []; - } - return []; - } - - return readSimpleManifest(manifestPath); -} - export async function readSimpleManifest(manifestPath: string): Promise { const content = await fs.readFile(manifestPath, 'utf8'); return content diff --git a/src/features/ai/ai-install.ts b/src/features/ai/ai-install.ts index 428da17e..bd300a4a 100644 --- a/src/features/ai/ai-install.ts +++ b/src/features/ai/ai-install.ts @@ -24,13 +24,11 @@ import { } from './ai-install-rules.js'; import { buildNextSteps, - buildProjectOverlayWarnings, buildWorkspaceCoexistenceWarnings, collectExistingProjectSkills, collectLocalSkills, installAgentsFile, installClaudeSkillCommands, - installProjectAgents, installProjectFile, installProjectOwnedSkills, resolveSelectedSkills, @@ -55,7 +53,6 @@ export type AiCommandResult = { geminiInstalled: boolean; cursorrulesInstalled: boolean; projectSkillsInstalled: string[]; - projectAgentsInstalled: string[]; workspaceRulesInstalled: string[]; workspaceToolTargetsUpdated: string[]; rulesManifestPath: string; @@ -142,11 +139,6 @@ export function formatAiResult(result: AiCommandResult): string { `Installed project skills: ${result.projectSkillsInstalled.length} (${result.projectSkillsInstalled.join(', ')})`, ); } - if (result.projectAgentsInstalled.length > 0) { - lines.push( - `Installed project agents: ${result.projectAgentsInstalled.length} (${result.projectAgentsInstalled.join(', ')})`, - ); - } if (result.claudeSkillCommandsInstalled.length > 0) { lines.push(`Claude skills linked: ${result.claudeSkillCommandsInstalled.length} (.claude/skills/)`); } @@ -240,18 +232,9 @@ async function applyAiInstall(options: { ); let projectSkillsInstalled: string[] = []; - let projectAgentsInstalled: string[] = []; const warnings: string[] = []; if (options.project) { projectSkillsInstalled = await installProjectOwnedSkills(options.targetDir, options.assets, options.projectType); - projectAgentsInstalled = await installProjectAgents(options.targetDir, options.assets, options.projectType); - warnings.push( - ...buildProjectOverlayWarnings({ - projectType: options.projectType, - projectSkillsInstalled, - projectAgentsInstalled, - }), - ); } warnings.push(...buildWorkspaceCoexistenceWarnings(options.projectType, officialWorkspaceFilesDetected)); @@ -346,7 +329,6 @@ async function applyAiInstall(options: { geminiInstalled, cursorrulesInstalled, projectSkillsInstalled, - projectAgentsInstalled, workspaceRulesInstalled: workspaceRuleResult.installedRules, workspaceToolTargetsUpdated: workspaceRuleResult.touchedTargets, rulesManifestPath: aiRulesManifestPath, diff --git a/templates/ai/README.md b/templates/ai/README.md index 02890890..9305d62a 100644 --- a/templates/ai/README.md +++ b/templates/ai/README.md @@ -49,7 +49,7 @@ Re-scope the managed vendor set during update: ldev ai update --target . --skill liferay-expert ``` -Install project-owned overlays (`project-*` skills and `.claude/agents` templates): +Install project-owned overlays (`project-*` skills): ```bash ldev ai install --target . --project diff --git a/templates/ai/docs/ASSET_INVENTORY.md b/templates/ai/docs/ASSET_INVENTORY.md index 5439de84..941e17bb 100644 --- a/templates/ai/docs/ASSET_INVENTORY.md +++ b/templates/ai/docs/ASSET_INVENTORY.md @@ -50,7 +50,6 @@ Workspace rules are installed into editor/AI tool config directories (`.claude/` | Asset | Current location | Purpose | Reusable in `ldev` | Why not | |---|---|---|---|---| | Project skills overlay | `templates/ai/project/skills/` | Project-owned process and project-memory workflows | No | Project-owned overlay installed only with `--project`; should not be the main home of reusable `ldev` workflows | -| Project agent overlay | `templates/ai/project/.claude/agents/` | Claude sub-agents for the project overlay | No | Optional pipeline, not needed in every project | ## Notes @@ -61,7 +60,7 @@ Workspace rules are installed into editor/AI tool config directories (`.claude/` - `ldev ai install --project-context` additionally installs `docs/ai/project-context.md`, curated vendor skills and the vendor manifest. - `ldev ai install --project` additionally installs the project-owned skills - and Claude agents overlay. + overlay. - Project overlays should stay thin and process-specific. Reusable `ldev` operational knowledge belongs in vendor skills, not in `project/`. - In `blade-workspace`, official AI Workspace folders remain the base layer and diff --git a/templates/ai/project/.claude/agents/build-verifier.md b/templates/ai/project/.claude/agents/build-verifier.md deleted file mode 100644 index 5ae47da7..00000000 --- a/templates/ai/project/.claude/agents/build-verifier.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: build-verifier -description: Project build gate wrapper. Use repository-specific build verification after vendor deploy logic has already been chosen. -tools: Bash, Read -model: haiku -disallowedTools: Edit, Write ---- - -You are the project build verifier. Do not edit code. - -This agent is intentionally thin. - -It does not own the canonical deploy workflow. Vendor skills do. - -Use this agent only when the repository wants a small machine-readable build or -deploy gate after the technical plan is already known. - -Canonical technical guidance lives in: - -- `deploying-liferay` -- `developing-liferay` - -## Inputs - -- `.tmp/issue-/brief.md` -- `.tmp/issue-/solution-plan.md` - -## What this wrapper may do - -- execute the already-chosen deploy command -- report a build or deploy failure in a consistent project format -- enforce a project-specific success string if the repository wants one -- read the issue brief and solution plan already prepared by `issue-engineering` - -## What this wrapper must not do - -- decide the deploy strategy from scratch -- replace `deploying-liferay` -- become the canonical source for `ldev` deploy logic - -## Output - -- `BUILD_SUCCESS` -- `BUILD_FAILURE: ` diff --git a/templates/ai/project/.claude/agents/issue-resolver.md b/templates/ai/project/.claude/agents/issue-resolver.md deleted file mode 100644 index 5c364bcc..00000000 --- a/templates/ai/project/.claude/agents/issue-resolver.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -name: issue-resolver -description: Thin project issue coordinator that routes technical work to vendor skills and project process wrappers. -tools: Read, Glob, Grep, Bash, Edit, Write -model: sonnet ---- - -You are a thin project issue coordinator. - -This agent must not become the canonical source for technical `ldev` workflows. - -Use it only to coordinate project issue flow and hand off to the correct -technical layer. - -## Expected input - -- an issue number, issue URL, or explicit task description from the user -- the repository issue process in `.agents/skills/project-issue-engineering/SKILL.md` -- optional intake artifacts under `.tmp/issue-/` - -## Canonical technical sources - -- `issue-engineering` -- `troubleshooting-liferay` -- `developing-liferay` -- `deploying-liferay` -- `migrating-journal-structures` -- `automating-browser-tests` - -## Responsibilities - -- gather project issue context -- prepare `.tmp/issue-/brief.md` when the project wants a short issue brief -- prepare `.tmp/issue-/solution-plan.md` when the repository wants a technical handoff artifact -- route technical work to the correct vendor skill -- prepare a human-review handoff after technical validation - -## Must not do - -- redefine the incident diagnosis workflow -- redefine the deploy workflow -- redefine runtime verification -- open pull requests or comment that a PR exists before human validation -- become a second full playbook parallel to vendor skills - -If the technical flow is unclear, stop and route back to -`.agents/skills/project-issue-engineering/SKILL.md` instead of improvising. - -## Output - -- `READY_FOR_TECHNICAL_FLOW` -- `ESCALATE: ` -- optional artifacts: - - `.tmp/issue-/brief.md` - - `.tmp/issue-/solution-plan.md` diff --git a/templates/ai/project/.claude/agents/runtime-verifier.md b/templates/ai/project/.claude/agents/runtime-verifier.md deleted file mode 100644 index 8b2e1993..00000000 --- a/templates/ai/project/.claude/agents/runtime-verifier.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: runtime-verifier -description: Thin project runtime evidence wrapper after vendor verification logic has already been chosen. -tools: Bash, Read, Skill -model: haiku -disallowedTools: Edit, Write ---- - -You are the project runtime verifier. - -This agent is intentionally thin. - -Canonical runtime verification belongs to: - -- `deploying-liferay` -- `automating-browser-tests` - -Use this wrapper only when the repository wants a project-specific evidence or -status contract after the technical verification has already been performed. - -## Inputs - -- `.tmp/issue-/brief.md` -- `.tmp/issue-/solution-plan.md` when a technical plan was prepared -- any evidence artifacts already captured by the technical flow - -## Responsibilities - -- confirm that the project-required evidence exists -- emit a project-specific verification status -- optionally invoke `automating-browser-tests` when the project requires visual - confirmation and that has not already been done -- read the issue brief and solution plan prepared by `issue-engineering` - -## Must not do - -- replace `deploying-liferay` -- replace the technical runtime verification playbook -- become the canonical source for `ldev` functional verification - -## Output - -- `VERIFIED` -- `FAILED: ` -- `NEEDS_HUMAN_DECISION: ` diff --git a/templates/ai/project/README.md b/templates/ai/project/README.md index 9151c0cb..addd26d7 100644 --- a/templates/ai/project/README.md +++ b/templates/ai/project/README.md @@ -16,7 +16,7 @@ Rules for this folder: - Optional project menu-map scaffolding (for localized admin navigation) is installed with `ldev ai install --project-context` or `ldev ai install --project`: `docs/ai/menu/README.md`, `docs/ai/menu/navigation.i18n.json`. -- Project-owned skills and agents remain optional overlays installed only with +- Project-owned skills remain optional overlays installed only with `ldev ai install --project`. - Project-owned overlays should stay focused on repository-specific process and context. Reusable `ldev` technical workflows belong in vendor-managed skills. diff --git a/templates/ai/project/project-agents.blade-workspace.txt b/templates/ai/project/project-agents.blade-workspace.txt deleted file mode 100644 index 09afd99b..00000000 --- a/templates/ai/project/project-agents.blade-workspace.txt +++ /dev/null @@ -1,3 +0,0 @@ -# Workspace-specific project agent pack intentionally starts empty. -# The shared issue-engineering skill is installed, but these helper agents stay -# off by default until they add real repository-specific value in Workspace too. diff --git a/templates/ai/project/project-agents.ldev-native.txt b/templates/ai/project/project-agents.ldev-native.txt deleted file mode 100644 index e275bf07..00000000 --- a/templates/ai/project/project-agents.ldev-native.txt +++ /dev/null @@ -1,3 +0,0 @@ -build-verifier -issue-resolver -runtime-verifier diff --git a/templates/ai/project/project-agents.unknown.txt b/templates/ai/project/project-agents.unknown.txt deleted file mode 100644 index 1ba12b15..00000000 --- a/templates/ai/project/project-agents.unknown.txt +++ /dev/null @@ -1 +0,0 @@ -# Generic repositories intentionally receive no project Claude agents by default. diff --git a/tests/integration/ai.integration.test.ts b/tests/integration/ai.integration.test.ts index 6b014858..4c8daa5f 100644 --- a/tests/integration/ai.integration.test.ts +++ b/tests/integration/ai.integration.test.ts @@ -373,7 +373,6 @@ describe('ai integration', () => { expect(await fs.pathExists(path.join(targetDir, '.agents', 'skills', skill, 'SKILL.md'))).toBe(true); } expect(await fs.pathExists(path.join(targetDir, '.agents', 'skills', 'project-issue-engineering'))).toBe(false); - expect(await fs.pathExists(path.join(targetDir, '.claude', 'agents', 'issue-resolver.md'))).toBe(false); const agents = await fs.readFile(path.join(targetDir, 'AGENTS.md'), 'utf8'); expect(agents).not.toContain('## Project-Owned Skills Installed By `--project`'); @@ -396,8 +395,6 @@ describe('ai integration', () => { false, ); expect(await fs.pathExists(path.join(targetDir, '.agents', 'skills', 'project-issue-engineering'))).toBe(true); - expect(await fs.pathExists(path.join(targetDir, '.claude', 'agents', 'issue-resolver.md'))).toBe(false); - expect(await fs.pathExists(path.join(targetDir, '.claude', 'agents', 'build-verifier.md'))).toBe(false); }, 30000); test('install --project in ldev-native installs the full project-owned issue workflow pack', async () => { @@ -431,8 +428,6 @@ describe('ai integration', () => { path.join(targetDir, '.agents', 'skills', 'project-issue-engineering', 'scripts', 'png_to_evidence_svg.mjs'), ), ).toBe(true); - expect(await fs.pathExists(path.join(targetDir, '.claude', 'agents', 'issue-resolver.md'))).toBe(true); - expect(await fs.pathExists(path.join(targetDir, '.claude', 'agents', 'build-verifier.md'))).toBe(true); }, 30000); test('install --project-context creates CLAUDE.md, project-context docs and copilot-instructions.md but does not overwrite existing ones', async () => { diff --git a/tests/unit/ai-install-modules.test.ts b/tests/unit/ai-install-modules.test.ts index 261a6b4c..52ed36b4 100644 --- a/tests/unit/ai-install-modules.test.ts +++ b/tests/unit/ai-install-modules.test.ts @@ -17,7 +17,6 @@ import { uniqueSorted, resolveSelectedSkills, buildNextSteps, - buildProjectOverlayWarnings, buildWorkspaceCoexistenceWarnings, } from '../../src/features/ai/ai-install-project.js'; @@ -237,36 +236,6 @@ describe('buildNextSteps', () => { }); }); -describe('buildProjectOverlayWarnings', () => { - test('returns no warnings when no project agents installed', () => { - const warnings = buildProjectOverlayWarnings({ - projectType: 'blade-workspace', - projectSkillsInstalled: [], - projectAgentsInstalled: [], - }); - expect(warnings).toHaveLength(0); - }); - - test('warns when project agents installed without issue-engineering skill', () => { - const warnings = buildProjectOverlayWarnings({ - projectType: 'blade-workspace', - projectSkillsInstalled: [], - projectAgentsInstalled: ['my-agent'], - }); - expect(warnings.length).toBeGreaterThan(0); - expect(warnings[0]).toContain('issue-engineering'); - }); - - test('no warning when project agents installed with issue-engineering skill', () => { - const warnings = buildProjectOverlayWarnings({ - projectType: 'blade-workspace', - projectSkillsInstalled: ['project-issue-engineering'], - projectAgentsInstalled: ['my-agent'], - }); - expect(warnings).toHaveLength(0); - }); -}); - describe('buildWorkspaceCoexistenceWarnings', () => { test('returns no warnings for non-blade-workspace project type', () => { const warnings = buildWorkspaceCoexistenceWarnings('unknown', ['.workspace-rules/liferay-rules.md']); diff --git a/tests/unit/ai.test.ts b/tests/unit/ai.test.ts index cea664e0..d74d7b6e 100644 --- a/tests/unit/ai.test.ts +++ b/tests/unit/ai.test.ts @@ -152,7 +152,6 @@ function makeAiCommandResult(overrides?: Partial): AiCommandRes geminiInstalled: false, cursorrulesInstalled: false, projectSkillsInstalled: [], - projectAgentsInstalled: [], workspaceRulesInstalled: [], workspaceToolTargetsUpdated: [], rulesManifestPath: '/workspace/.ldev/ai/rules-manifest.json',