Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
58 changes: 48 additions & 10 deletions aas-web-ui/src/composables/AAS/AASXImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useSMEFile } from '@/composables/AAS/SubmodelElements/File';
import { useAASRepositoryClient } from '@/composables/Client/AASRepositoryClient';
import { useCDRepositoryClient } from '@/composables/Client/CDRepositoryClient';
import { useSMRepositoryClient } from '@/composables/Client/SMRepositoryClient';
import { safeSegment } from '@/utils/StringUtils';
import { deserializeXml } from '../../../node_modules/basyx-typescript-sdk/dist/lib/aas-dataformat-xml/xmlization.js';

type JsonRecord = Record<string, unknown>;
Expand Down Expand Up @@ -79,7 +80,7 @@ function isExternalHttpUrl(path: string): boolean {
}
}

function normalizePackagePath(path: string): string {
export function normalizePackagePath(path: string): string {
if (!path) return '';

let normalized = path.trim();
Expand All @@ -95,7 +96,7 @@ function normalizePackagePath(path: string): string {
return normalized;
}

function packagePathCandidates(path: string): string[] {
export function packagePathCandidates(path: string): string[] {
const candidates = new Set<string>();
const normalized = normalizePackagePath(path);
if (normalized) candidates.add(normalized);
Expand Down Expand Up @@ -126,7 +127,7 @@ function buildSupplementaryMap(parts: Part[]): Map<string, Part> {
return map;
}

function pickSupplementaryPart(path: string, supplementaryMap: Map<string, Part>): Part | null {
export function pickSupplementaryPart(path: string, supplementaryMap: Map<string, Part>): Part | null {
for (const candidate of packagePathCandidates(path)) {
const part = supplementaryMap.get(candidate);
if (part) return part;
Expand All @@ -136,9 +137,35 @@ function pickSupplementaryPart(path: string, supplementaryMap: Map<string, Part>
const fileName = normalized.split('/').pop() || '';
if (fileName === '') return null;

const matchingParts = Array.from(supplementaryMap.values()).filter(
(part) => (part.URI.pathname.split('/').pop() || '') === fileName
);
const decodedFileName = (() => {
try {
return decodeURIComponent(fileName);
} catch {
return fileName;
}
})();

const sanitizedFileName = safeSegment(decodedFileName, '');
if (sanitizedFileName !== '') {
const directSanitizedMatch =
supplementaryMap.get(`/aasx-suppl/${sanitizedFileName}`) ||
supplementaryMap.get(`aasx-suppl/${sanitizedFileName}`);
if (directSanitizedMatch) return directSanitizedMatch;
}

const fileNameCandidates = new Set<string>([fileName, decodedFileName]);
if (sanitizedFileName !== '') fileNameCandidates.add(sanitizedFileName);

const matchingParts = Array.from(supplementaryMap.values()).filter((part) => {
const partFileName = part.URI.pathname.split('/').pop() || '';
if (fileNameCandidates.has(partFileName)) return true;

try {
return fileNameCandidates.has(decodeURIComponent(partFileName));
} catch {
return false;
}
});

return matchingParts.length === 1 ? matchingParts[0] : null;
}
Expand Down Expand Up @@ -195,13 +222,23 @@ function collectAttachmentUploads(

const visited = new WeakSet<object>();

const visit = (node: unknown, idShortPath: string[]): void => {
const visit = (node: unknown, idShortPath: string[], parentModelType: string = ''): void => {
if (!node || typeof node !== 'object') return;
if (visited.has(node as object)) return;
visited.add(node as object);

if (Array.isArray(node)) {
for (const item of node) visit(item, idShortPath);
for (const [index, item] of node.entries()) {
let indexedPath = idShortPath;

// AAS paths address SubmodelElementList children using bracket index notation (e.g., Markings[0]).
if (parentModelType === 'SubmodelElementList' && idShortPath.length > 0) {
indexedPath = [...idShortPath];
indexedPath[indexedPath.length - 1] = `${indexedPath[indexedPath.length - 1]}[${index}]`;
}

visit(item, indexedPath, parentModelType);
}
return;
}

Expand Down Expand Up @@ -246,8 +283,9 @@ function collectAttachmentUploads(
});
}

for (const value of Object.values(record)) {
visit(value, nextPath);
for (const [key, value] of Object.entries(record)) {
const nestedParentModelType = key === 'value' || key === 'statements' ? modelType : '';
visit(value, nextPath, nestedParentModelType);
}
};

Expand Down
17 changes: 7 additions & 10 deletions aas-web-ui/src/composables/AAS/AASXPackaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useSMRepositoryClient } from '@/composables/Client/SMRepositoryClient';
import { useRequestHandling } from '@/composables/RequestHandling';
import { extractId as extractIdFromReference } from '@/utils/AAS/ReferenceUtil';
import { base64Encode } from '@/utils/EncodeDecodeUtils';
import { safeSegment } from '@/utils/StringUtils';
import { serializeXml } from '../../../node_modules/basyx-typescript-sdk/dist/lib/aas-dataformat-xml/xmlization.js';
import { BaSyxEnvironment } from '../../../node_modules/basyx-typescript-sdk/dist/models/BaSyxEnvironment.js';

Expand Down Expand Up @@ -155,14 +156,6 @@ function normalizeId(id: string): string {
return id?.trim() || '';
}

function safeSegment(value: string, fallback: string): string {
const cleaned = value
?.trim()
.replace(/[^a-zA-Z0-9._-]/g, '-')
.replace(/-+/g, '-');
return cleaned && cleaned !== '' ? cleaned : fallback;
}

function isExternalHttpUrl(path: string): boolean {
if (!path || path.trim() === '') return false;

Expand Down Expand Up @@ -255,7 +248,7 @@ function collectFileBindings(raw: unknown, clean: unknown, bindings: FileBinding
}
}

function resolveAttachmentFilename(file: JsonRecord, index: number, contentType: string): string {
export function resolveAttachmentFilename(file: JsonRecord, index: number, contentType: string): string {
const value = asString(file.value);
const idShort = asString(file.idShort) || `file-${index + 1}`;

Expand Down Expand Up @@ -439,7 +432,11 @@ export function useAASXPackaging(): {
const thumbnailPath = asString(defaultThumbnail.path).trim();
if (thumbnailPath === '') return null;

if (isExternalHttpUrl(thumbnailPath)) {
const isExternalThumbnail =
defaultThumbnail.isExternal === true ||
(defaultThumbnail.isExternal === undefined && isExternalHttpUrl(thumbnailPath));

if (isExternalThumbnail) {
warnings.push(`Skipped external thumbnail URL: ${thumbnailPath}`);
return null;
}
Expand Down
16 changes: 16 additions & 0 deletions aas-web-ui/src/utils/StringUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,19 @@ export function stripLastCharacter(string: string): string {
export function isEmptyString(val: string): boolean {
return !val || val.trim() === '';
}

/**
* Sanitizes a string to be safe for path/file segments by replacing unsupported characters.
*
* @param {string} value - The value to sanitize.
* @param {string} fallback - The value to return if sanitization results in an empty string.
* @returns {string} A sanitized segment or the fallback.
*/
export function safeSegment(value: string, fallback: string): string {
const cleaned = value
?.trim()
.replace(/[^a-zA-Z0-9._-]/g, '-')
.replace(/-+/g, '-');

return cleaned && cleaned !== '' ? cleaned : fallback;
}
69 changes: 69 additions & 0 deletions aas-web-ui/tests/composables/AAS/AASXImport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { Part } from 'aasx-package-ts';
import { describe, expect, it } from 'vitest';
import {
buildAttachmentSmePath,
normalizePackagePath,
packagePathCandidates,
pickSupplementaryPart,
} from '@/composables/AAS/AASXImport';

type MinimalPart = Pick<Part, 'URI'>;

function partWithPath(path: string): MinimalPart {
return {
URI: new URL(`https://package.local${path}`),
};
}

describe('AASXImport.ts; pure helper tests', () => {
it('builds client import SME attachment path with dot-separated idShort segments', () => {
const smEndpoint = 'https://example.test/submodels/encoded-submodel-id';
const idShortPath = ['Documents', 'Manual', 'Pdf File'];

const path = buildAttachmentSmePath(smEndpoint, idShortPath);

expect(path).toBe(
'https://example.test/submodels/encoded-submodel-id/submodel-elements/Documents.Manual.Pdf%20File'
);
});

it('normalizes package path by trimming, removing query, and adding leading slash', () => {
const normalized = normalizePackagePath(' aasx-suppl/file.png?cache=1 ');

expect(normalized).toBe('/aasx-suppl/file.png');
});

it('creates decoded package path candidates for encoded path segments', () => {
const candidates = packagePathCandidates('/aasx-suppl/Markings%5B0%5D.png');

expect(candidates).toContain('/aasx-suppl/Markings%5B0%5D.png');
expect(candidates).toContain('/aasx-suppl/Markings[0].png');
});

it('matches supplementary parts whose filenames were sanitized during packaging', () => {
const supplementaryMap = new Map<string, Part>();
const packagedPart = partWithPath(
'/aasx-suppl/aHR0cHM6Ly9hZG1pbi1zaGVsbC5pby9pZHRhL1N1Ym1vZGVsVGVtcGxhdGUvRGlnaXRhbE5hbWVwbGF0ZS8zLzA-Markings-0-.MarkingFile-Schwindegg.png'
);
supplementaryMap.set(packagedPart.URI.pathname, packagedPart as Part);

const foundPart = pickSupplementaryPart(
'aHR0cHM6Ly9hZG1pbi1zaGVsbC5pby9pZHRhL1N1Ym1vZGVsVGVtcGxhdGUvRGlnaXRhbE5hbWVwbGF0ZS8zLzA-Markings[0].MarkingFile-Schwindegg.png',
supplementaryMap
);

expect(foundPart).toBe(packagedPart as Part);
});

it('returns null when filename-only matching would be ambiguous', () => {
const supplementaryMap = new Map<string, Part>();
const firstPart = partWithPath('/aasx-suppl/folderA/shared.png');
const secondPart = partWithPath('/aasx-suppl/folderB/shared.png');
supplementaryMap.set(firstPart.URI.pathname, firstPart as Part);
supplementaryMap.set(secondPart.URI.pathname, secondPart as Part);

const foundPart = pickSupplementaryPart('shared.png', supplementaryMap);

expect(foundPart).toBeNull();
});
});
47 changes: 47 additions & 0 deletions aas-web-ui/tests/composables/AAS/AASXPackaging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { resolveAttachmentFetchPath, resolveAttachmentFilename } from '@/composables/AAS/AASXPackaging';

describe('AASXPackaging.ts; pure helper tests', () => {
it('preserves repository attachment fetch path for download packaging', () => {
const path = ' https://example.test/submodels/abc/submodel-elements/Documents.Manual.Pdf%20File ';

const fetchPath = resolveAttachmentFetchPath(path);

expect(fetchPath).toBe('https://example.test/submodels/abc/submodel-elements/Documents.Manual.Pdf%20File');
});

it('sanitizes filename derived from file.value to match package path rules', () => {
const file = {
value: 'aHR0cHM6Ly9hZG1pbi1zaGVsbC5pby9pZHRhL1N1Ym1vZGVsVGVtcGxhdGUvRGlnaXRhbE5hbWVwbGF0ZS8zLzA-Markings[0].MarkingFile-Schwindegg.png',
idShort: 'MarkingFile',
};

const fileName = resolveAttachmentFilename(file, 0, 'image/png');

expect(fileName).toBe(
'aHR0cHM6Ly9hZG1pbi1zaGVsbC5pby9pZHRhL1N1Ym1vZGVsVGVtcGxhdGUvRGlnaXRhbE5hbWVwbGF0ZS8zLzA-Markings-0-.MarkingFile-Schwindegg.png'
);
});

it('falls back to sanitized idShort and extension when value has no filename extension', () => {
const file = {
value: 'urn:example:attachment',
idShort: 'Manual File',
};

const fileName = resolveAttachmentFilename(file, 1, 'application/pdf');

expect(fileName).toBe('Manual-File-2.pdf');
});

it('falls back to generic file prefix when idShort is empty', () => {
const file = {
value: 'urn:example:attachment',
idShort: '',
};

const fileName = resolveAttachmentFilename(file, 2, 'image/png');

expect(fileName).toBe('file-3-3.png');
});
});
24 changes: 0 additions & 24 deletions aas-web-ui/tests/composables/AAS/AASXPathHandling.test.ts

This file was deleted.

35 changes: 34 additions & 1 deletion aas-web-ui/tests/utils/StringUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { capitalizeFirstLetter, firstLetterToLowerCase, stripLastCharacter } from '@/utils/StringUtils';
import { capitalizeFirstLetter, firstLetterToLowerCase, safeSegment, stripLastCharacter } from '@/utils/StringUtils';

describe("StringUtils.ts; Tests for 'capitalizeFirstLetter()'", () => {
// Define test data for capitalizeFirstLetter()
Expand Down Expand Up @@ -124,3 +124,36 @@ describe("StringUtils.ts; Tests for 'stripLastCharacter()'", () => {
});
});
});

describe("StringUtils.ts; Tests for 'safeSegment()'", () => {
const safeSegmentTestCombinations = [
{
testId: '6d3661d9-37ea-4f56-a480-2ea74dc2f120',
input: 'Markings[0].MarkingFile',
fallback: 'fallback',
output: 'Markings-0-.MarkingFile',
},
{
testId: 'f58cc267-4e6e-495c-bf49-2c0d5e59041a',
input: ' a///b ',
fallback: 'fallback',
output: 'a-b',
},
{
testId: '01b5ed09-3ba3-4cb3-adf4-7f322f91fca2',
input: ' ',
fallback: 'fallback',
output: 'fallback',
},
];

safeSegmentTestCombinations.forEach(function (safeSegmentTestCombination) {
const input = safeSegmentTestCombination.input;
const fallback = safeSegmentTestCombination.fallback;
const output = safeSegmentTestCombination.output;

it(`${safeSegmentTestCombination.testId}: safeSegment('${input}', '${fallback}') should be '${output}'`, () => {
expect(safeSegment(input, fallback)).toBe(output);
});
});
});
Loading