Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/funny-socks-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@sap-ux/project-access': minor
'@sap-ux/fe-fpm-writer': patch
---

Added validateId function to @sap-ux/project-access with sync/async overloads and reused it in @sap-ux/fe-fpm-writer.
Exported findFilesByExtension from @sap-ux/project-access public API and removed deep import from @sap-ux/fe-fpm-writer.
18 changes: 3 additions & 15 deletions packages/fe-fpm-writer/src/common/file.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { CopyOptions, Editor } from 'mem-fs-editor';
import type { TabInfo } from '../common/types';
import { sep, normalize } from 'node:path';
import { findFilesByExtension } from '@sap-ux/project-access/dist/file';
import { DOMParser } from '@xmldom/xmldom';
import { findFilesByExtension, validateId } from '@sap-ux/project-access';

/**
* Options for creating an ID generator with cached file contents.
Expand Down Expand Up @@ -168,25 +167,14 @@ export const CONFIG = {
function generateUniqueElementId(baseId: string, filteredFilesContent: string[], validatedIds: string[] = []): string {
const maxAttempts = 1000;

function checkElementIdAvailable(id: string, xmlContent: string): boolean {
const xmlDocument = new DOMParser({ errorHandler: (): void => {} }).parseFromString(xmlContent);
return xmlDocument.documentElement ? !xmlDocument.getElementById(id) : true;
}

if (
filteredFilesContent.every((content) => content === '' || checkElementIdAvailable(baseId, content)) &&
!validatedIds.includes(baseId)
) {
if (validateId(baseId, validatedIds, { files: filteredFilesContent })) {
return baseId;
}

for (let counter = 1; counter < maxAttempts; counter++) {
const candidateId = `${baseId}${counter}`;

if (
filteredFilesContent.every((content) => content === '' || checkElementIdAvailable(candidateId, content)) &&
!validatedIds.includes(candidateId)
) {
if (validateId(candidateId, validatedIds, { files: filteredFilesContent })) {
return candidateId;
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/project-access/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export {
MinCdsVersion,
fioriToolsDirectory
} from './constants';
export { getFilePaths } from './file';
export { getFilePaths, findFilesByExtension } from './file';
export { normalizePath } from './path';
export {
addPackageDevDependency,
Expand Down Expand Up @@ -71,6 +71,6 @@ export {
} from './project';
export { execNpmCommand } from './command/npm-command';
export * from './types';
export * from './library';
export { checkDependencies, getReuseLibs, validateId } from './library';
export { findRecursiveHierarchyKey, getTableCapabilitiesByEntitySet } from './odata';
export { hasDependency } from './project';
171 changes: 170 additions & 1 deletion packages/project-access/src/library/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ import {
type ReuseLib,
type LibraryXml
} from '../types';
import { findFiles, readJSON } from '../file';
import { findFiles, findFilesByExtension, readJSON } from '../file';
import { FileName } from '../constants';
import { existsSync, promises as fs } from 'node:fs';
import { XMLParser } from 'fast-xml-parser';
import { getI18nPropertiesPaths } from '../project/i18n';
import { getPropertiesI18nBundle } from '@sap-ux/i18n';
import type { Editor } from 'mem-fs-editor';
import { create as createStorage } from 'mem-fs';
import { create } from 'mem-fs-editor';

/**
* Reads the manifest file and returns the reuse library.
Expand Down Expand Up @@ -307,3 +310,169 @@ export function getManifestDependencies(manifest: Manifest): string[] {

return result;
}

/**
* Recursively searches for an element with the specified id attribute in a parsed XML object.
*
* @param obj - parsed XML object to search in
* @param id - id attribute value to search for
* @param attrPrefix - attribute prefix used by the parser (default: '@_')
* @returns true if an element with the specified id is found
*/
function hasElementWithId(obj: unknown, id: string, attrPrefix: string = '@_'): boolean {
if (!obj || typeof obj !== 'object') {
return false;
}

const objRecord = obj as Record<string, unknown>;

// Check if current object has the id attribute
if (objRecord[`${attrPrefix}id`] === id) {
return true;
}

// Recursively search in all properties
for (const key in objRecord) {
if (key.startsWith(attrPrefix)) {
continue; // Skip attributes
}

if (checkIdInValue(objRecord[key], id, attrPrefix)) {
return true;
}
}

return false;
}

/**
* Checks if a value (object or array) contains an element with the specified id.
*
* @param value - value to check (can be array or object)
* @param id - id to search for
* @param attrPrefix - attribute prefix used by the parser
* @returns true if id is found in the value
*/
function checkIdInValue(value: unknown, id: string, attrPrefix: string): boolean {
if (Array.isArray(value)) {
return value.some((item) => hasElementWithId(item, id, attrPrefix));
}
if (typeof value === 'object' && value !== null) {
return hasElementWithId(value, id, attrPrefix);
}
return false;
}

/**
* Checks if an element with the specified id is available (does not exist) in the XML content.
*
* @param id - id to check for availability
* @param xmlContent - XML content as string
* @returns true if the id is available (not found), false if it exists
*/
function checkElementIdAvailable(id: string, xmlContent: string): boolean {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
parseAttributeValue: false
});

try {
const xmlDocument: unknown = parser.parse(xmlContent);
return xmlDocument ? !hasElementWithId(xmlDocument, id) : true;
} catch {
// Parse error = no valid document = no element with id
return true;
}
}

/**
* Validates if an id is unique across XML files (fragments and views) in the project.
* Synchronous overload - when files are provided directly.
*
* @param baseId - id to validate
* @param validatedIds - array of ids that are already validated/used
* @param options - validation options with files array
* @param options.files - array of XML file contents to check
* @param options.appPath - must be undefined for synchronous overload
* @param options.memFs - must be undefined for synchronous overload
* @returns true if the id is unique (available), false if it already exists
*/
export function validateId(
baseId: string,
validatedIds: string[] | undefined,
options: { files: string[]; appPath?: never; memFs?: never }
): boolean;

/**
* Validates if an id is unique across XML files (fragments and views) in the project.
* Asynchronous overload - when appPath is provided (requires file system access).
*
* @param baseId - id to validate
* @param validatedIds - array of ids that are already validated/used
* @param options - validation options with appPath
* @param options.files - must be undefined for asynchronous overload
* @param options.appPath - path to search for XML files
* @param options.memFs - optional mem-fs-editor instance for reading files
* @returns Promise that resolves to true if the id is unique (available), false if it already exists
*/
export function validateId(
baseId: string,
validatedIds: string[] | undefined,
options: { files?: never; appPath: string; memFs?: Editor }
): Promise<boolean>;

// Implementation
export function validateId(
baseId: string,
validatedIds: string[] | undefined,
options: { files?: string[]; appPath?: string; memFs?: Editor }
): boolean | Promise<boolean> {
const { memFs, appPath, files: fileContents } = options;

/**
* Validates the ID against the provided files.
*
* @param files - array of XML file contents to validate against
* @returns true if the id is unique (available), false if it already exists
*/
function validateAgainstFiles(files: string[]): boolean {
// Check validatedIds first - fast O(n) check avoids expensive XML parsing
if (validatedIds?.includes(baseId)) {
return false;
}

// Only parse XML files if validatedIds check passed
return files.every((content) => content === '' || checkElementIdAvailable(baseId, content));
}

// Synchronous path: when files are provided directly
if (fileContents !== undefined) {
return validateAgainstFiles(fileContents);
}

// Asynchronous path: when appPath is provided
if (!appPath) {
throw new Error('validateId requires either files or appPath to be provided in options');
}

return (async (): Promise<boolean> => {
// Ensure we have a memFs instance
const fsEditor = memFs ?? create(createStorage());

const xmlFilePaths = await findFilesByExtension(
'.xml',
appPath,
['.git', 'node_modules', 'dist', 'annotations', 'localService'],
fsEditor
);
const lookupFiles = ['.fragment.xml', '.view.xml'];
const filteredPaths = xmlFilePaths.filter((fileName: string) =>
lookupFiles.some((lookupFile) => fileName.endsWith(lookupFile))
);

// Read file contents from paths using memFs
const files = filteredPaths.map((path: string) => fsEditor.read(path));
return validateAgainstFiles(files);
})();
}
2 changes: 1 addition & 1 deletion packages/project-access/src/library/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { getReuseLibs, checkDependencies } from './helpers';
export { getReuseLibs, checkDependencies, validateId } from './helpers';
Comment thread
Jimmy-Joseph19 marked this conversation as resolved.
Outdated
Loading
Loading