571 lines
19 KiB
JavaScript
571 lines
19 KiB
JavaScript
import supplierAutomat from '../../automats/supplierAutomat';
|
||
|
||
/** Groups that are not sales customers (filter client-side). */
|
||
export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']);
|
||
|
||
export function entityDisplayName(entity) {
|
||
if (!entity) return '';
|
||
return [entity.firstName, entity.name].filter(Boolean).join(' ');
|
||
}
|
||
|
||
export function sortEntities(entities) {
|
||
return [...entities].sort((a, b) => {
|
||
const byName = String(a.name).localeCompare(String(b.name), 'de');
|
||
if (byName !== 0) return byName;
|
||
return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Customers = entities whose customergroup.name is not Lieferanten/POS.
|
||
*/
|
||
export function filterCustomerEntities(entities, groups) {
|
||
const excludedIds = new Set(
|
||
groups
|
||
.filter((g) => EXCLUDED_CUSTOMER_GROUP_NAMES.has(g.name))
|
||
.map((g) => g.id),
|
||
);
|
||
return sortEntities(
|
||
entities.filter((e) => !excludedIds.has(e.customergroupId)),
|
||
);
|
||
}
|
||
|
||
export function sortSuppliers(suppliers) {
|
||
return [...suppliers].sort((a, b) =>
|
||
String(a.name).localeCompare(String(b.name), 'de'),
|
||
);
|
||
}
|
||
|
||
export function sortDocs(docs) {
|
||
return [...docs].sort((a, b) => String(b.date).localeCompare(String(a.date)));
|
||
}
|
||
|
||
export function formatDate(iso) {
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return iso;
|
||
return d.toLocaleDateString('de-DE');
|
||
}
|
||
|
||
/**
|
||
* Delivery documents have a server-allocated, read-only `number` plus an
|
||
* optional user-entered `externalNumber`. Returns the display value for a
|
||
* field, defaulting to a dash when empty.
|
||
*/
|
||
export function numberDisplay(value) {
|
||
if (value == null || String(value).trim() === '') return '–';
|
||
return String(value);
|
||
}
|
||
|
||
export function dateInputValue(iso) {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return String(iso).slice(0, 10);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
export function emptyLine() {
|
||
return {
|
||
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
amount: '',
|
||
sku: '',
|
||
tax: '',
|
||
rebate: '',
|
||
net: '',
|
||
name: '',
|
||
pu: '',
|
||
unit: '',
|
||
netprice: '',
|
||
taxrate: '',
|
||
};
|
||
}
|
||
|
||
export function lineFromApi(row) {
|
||
return {
|
||
key: `line-${row.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`}`,
|
||
amount: row.amount == null ? '' : String(row.amount),
|
||
sku: row.sku ?? '',
|
||
tax: row.tax == null ? '' : String(row.tax),
|
||
rebate: row.rebate == null ? '' : String(row.rebate),
|
||
net: row.net == null ? '' : String(row.net),
|
||
name: row.name ?? '',
|
||
pu: row.pu == null ? '' : String(row.pu),
|
||
unit: row.unit ?? '',
|
||
netprice: row.netprice != null ? String(row.netprice) : row.net != null ? String(row.net) : '',
|
||
taxrate: row.taxrate != null ? String(row.taxrate) : row.tax != null ? String(row.tax) : '',
|
||
};
|
||
}
|
||
|
||
function parseNum(value) {
|
||
if (value == null || value === '') return null;
|
||
const n = Number(String(value).trim().replace(',', '.'));
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
|
||
/** Build create/update line array. Throws Error on client validation. */
|
||
export function buildLines(lines, { requireAtLeastOne }) {
|
||
const outLines = [];
|
||
for (const line of lines) {
|
||
const sku = String(line.sku ?? '').trim();
|
||
const name = String(line.name ?? '').trim();
|
||
const amount = parseNum(line.amount);
|
||
const net = parseNum(line.net ?? line.netprice);
|
||
const tax = parseNum(line.tax ?? line.taxrate);
|
||
const rebate = parseNum(line.rebate);
|
||
const pu = parseNum(line.pu);
|
||
const blank =
|
||
!sku && !name && amount == null && net == null && tax == null && rebate == null;
|
||
if (blank) continue;
|
||
if ((!sku && !name) || amount == null || net == null || tax == null) {
|
||
throw new Error('Jede Zeile braucht SKU oder Name, Amount, Net und Taxrate.');
|
||
}
|
||
const row = { amount, net, taxrate: tax };
|
||
if (sku) row.sku = sku;
|
||
if (name) row.name = name;
|
||
if (rebate != null) row.rebate = rebate;
|
||
if (pu != null) row.pu = pu;
|
||
outLines.push(row);
|
||
}
|
||
if (requireAtLeastOne && outLines.length === 0) {
|
||
throw new Error('Mindestens eine Artikelzeile nötig.');
|
||
}
|
||
return outLines;
|
||
}
|
||
|
||
/**
|
||
* Convert File objects to base64 for API upload
|
||
* @param {Array<{file: File, name: string, type: string, thumbnail?: string, text?: string, parentPdfId?: string|number, isPersisted?: boolean, isDirty?: boolean}>} attachments
|
||
* @param {{ onlyNew?: boolean }} [options]
|
||
* @returns {Promise<Array<{mimetype: string, name: string, content: string, thumbavif?: string, meta?: object}>>}
|
||
*/
|
||
export async function buildFilesForUpload(attachments = [], { onlyNew = false } = {}) {
|
||
const files = [];
|
||
for (const attachment of attachments) {
|
||
if (!attachment) continue;
|
||
// Skip already persisted attachments in update mode unless modified
|
||
if (onlyNew && attachment.isPersisted && !attachment.isDirty) {
|
||
continue;
|
||
}
|
||
if (attachment.isPersisted && !attachment.isDirty && (!attachment.file || attachment.file.size === 0)) {
|
||
continue;
|
||
}
|
||
|
||
let fileToUpload = attachment.file;
|
||
if (!fileToUpload || fileToUpload.size === 0) {
|
||
if (attachment.text) {
|
||
fileToUpload = new File([attachment.text], attachment.name || 'file.txt', {
|
||
type: 'text/plain;charset=utf-8',
|
||
});
|
||
} else {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const base64 = await fileToBase64(fileToUpload);
|
||
const filePayload = {
|
||
mimetype: attachment.type || fileToUpload.type,
|
||
name: attachment.name || fileToUpload.name,
|
||
content: base64,
|
||
};
|
||
|
||
if (attachment.thumbnail) {
|
||
const thumbBase64 = attachment.thumbnail.includes(',')
|
||
? attachment.thumbnail.split(',')[1]
|
||
: attachment.thumbnail;
|
||
filePayload.thumbavif = thumbBase64;
|
||
}
|
||
|
||
files.push(filePayload);
|
||
} catch (err) {
|
||
console.error('Fehler beim Konvertieren von Datei zu Base64:', err);
|
||
}
|
||
}
|
||
return files;
|
||
}
|
||
|
||
/**
|
||
* Convert File to base64 string
|
||
* @param {File} file
|
||
* @returns {Promise<string>}
|
||
*/
|
||
function fileToBase64(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const result = reader.result;
|
||
if (typeof result === 'string') {
|
||
// Remove data URL prefix (e.g., "data:application/pdf;base64,")
|
||
const base64 = result.split(',')[1] || result;
|
||
resolve(base64);
|
||
} else {
|
||
reject(new Error('Invalid file reader result'));
|
||
}
|
||
};
|
||
reader.onerror = () => reject(new Error('File reader error'));
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Convert base64 string to Blob
|
||
* @param {string} base64
|
||
* @param {string} mimeType
|
||
* @returns {Blob}
|
||
*/
|
||
function base64ToBlob(base64, mimeType) {
|
||
const clean = base64.includes(',') ? base64.split(',')[1] : base64;
|
||
const byteCharacters = atob(clean);
|
||
const byteArrays = [];
|
||
const sliceSize = 512;
|
||
|
||
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
|
||
const slice = byteCharacters.slice(offset, offset + sliceSize);
|
||
const byteNumbers = new Array(slice.length);
|
||
for (let i = 0; i < slice.length; i++) {
|
||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||
}
|
||
const byteArray = new Uint8Array(byteNumbers);
|
||
byteArrays.push(byteArray);
|
||
}
|
||
|
||
return new Blob(byteArrays, { type: mimeType });
|
||
}
|
||
|
||
/**
|
||
* Restore attachments from API response
|
||
* @param {Array<{id: number, fileId?: number, manifestId?: number, mimetype: string, name: string, thumbavif?: any, size: number, created?: string, meta?: object}>} apiFiles
|
||
* @param {Array<object>} [existingAttachments]
|
||
* @returns {Promise<Array<{id: number, fileId: number, manifestId: number, file: File, name: string, type: string, thumbnail: string|null, size: number, text: string|null, parentPdfId: string|null, isPersisted: boolean}>>}
|
||
*/
|
||
export async function restoreAttachmentsFromApi(apiFiles = [], existingAttachments = []) {
|
||
const existingMap = new Map();
|
||
for (const existing of existingAttachments) {
|
||
if (!existing) continue;
|
||
if (existing.id != null) existingMap.set(String(existing.id), existing);
|
||
if (existing.fileId != null) existingMap.set(String(existing.fileId), existing);
|
||
if (existing.name) existingMap.set(existing.name, existing);
|
||
}
|
||
|
||
const attachments = [];
|
||
for (const apiFile of apiFiles) {
|
||
try {
|
||
let file = null;
|
||
let thumbnail = null;
|
||
|
||
// 1. Try to restore thumbnail from apiFile.thumbavif
|
||
if (apiFile.thumbavif) {
|
||
try {
|
||
if (typeof apiFile.thumbavif === 'string') {
|
||
thumbnail = apiFile.thumbavif.startsWith('data:')
|
||
? apiFile.thumbavif
|
||
: `data:image/avif;base64,${apiFile.thumbavif}`;
|
||
} else if (typeof apiFile.thumbavif === 'object') {
|
||
const rawBytes = apiFile.thumbavif.data || apiFile.thumbavif;
|
||
if (Array.isArray(rawBytes) || rawBytes instanceof Uint8Array) {
|
||
const thumbBlob = new Blob([new Uint8Array(rawBytes)], { type: 'image/avif' });
|
||
thumbnail = URL.createObjectURL(thumbBlob);
|
||
}
|
||
}
|
||
} catch (thumbErr) {
|
||
console.error('Fehler beim Dekodieren von thumbavif:', thumbErr);
|
||
}
|
||
}
|
||
|
||
// 2. Fallback to existing matching attachment thumbnail
|
||
const matchingExisting =
|
||
existingMap.get(String(apiFile.id)) ||
|
||
existingMap.get(String(apiFile.fileId)) ||
|
||
existingMap.get(apiFile.name);
|
||
|
||
if (!thumbnail && matchingExisting?.thumbnail) {
|
||
thumbnail = matchingExisting.thumbnail;
|
||
}
|
||
|
||
const placeholderName = apiFile.name || 'file';
|
||
const placeholderType = apiFile.mimetype || 'application/octet-stream';
|
||
file =
|
||
matchingExisting?.file instanceof File && matchingExisting.file.size > 0
|
||
? matchingExisting.file
|
||
: new File([''], placeholderName, { type: placeholderType });
|
||
|
||
const text = matchingExisting?.text || null;
|
||
const parentPdfId = matchingExisting?.parentPdfId || null;
|
||
|
||
attachments.push({
|
||
id: apiFile.id,
|
||
fileId: apiFile.fileId ?? apiFile.id,
|
||
manifestId: apiFile.manifestId,
|
||
isPersisted: true,
|
||
file,
|
||
name: apiFile.name,
|
||
type: apiFile.mimetype,
|
||
thumbnail,
|
||
size: apiFile.size,
|
||
text,
|
||
parentPdfId,
|
||
uploadedAt: apiFile.created,
|
||
});
|
||
} catch (err) {
|
||
console.error('Fehler beim Wiederherstellen von Datei:', err);
|
||
}
|
||
}
|
||
return attachments;
|
||
}
|
||
|
||
/**
|
||
* Collect new or modified attachments for multipart upload
|
||
* @param {Array<{file: File, name: string, type: string, thumbnail?: string, text?: string, parentPdfId?: string|number, isPersisted?: boolean, isDirty?: boolean}>} attachments
|
||
* @param {{ onlyNew?: boolean }} [options]
|
||
* @returns {Array<{file: File, name: string, mimetype: string, meta?: object}>}
|
||
*/
|
||
export function collectFilesForUpload(attachments = [], { onlyNew = false } = {}) {
|
||
const files = [];
|
||
for (const attachment of attachments) {
|
||
if (!attachment) continue;
|
||
// Skip already persisted attachments in update mode unless modified
|
||
if (onlyNew && attachment.isPersisted && !attachment.isDirty) {
|
||
continue;
|
||
}
|
||
if (attachment.isPersisted && !attachment.isDirty && (!attachment.file || attachment.file.size === 0)) {
|
||
continue;
|
||
}
|
||
|
||
let fileToUpload = attachment.file;
|
||
if (!fileToUpload || fileToUpload.size === 0) {
|
||
if (attachment.text) {
|
||
fileToUpload = new File([attachment.text], attachment.name || 'file.txt', {
|
||
type: 'text/plain;charset=utf-8',
|
||
});
|
||
} else {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
let thumbavif = null;
|
||
if (attachment.thumbnail) {
|
||
thumbavif = attachment.thumbnail.includes(',')
|
||
? attachment.thumbnail.split(',')[1]
|
||
: attachment.thumbnail;
|
||
}
|
||
|
||
files.push({
|
||
file: fileToUpload,
|
||
name: attachment.name || fileToUpload.name,
|
||
mimetype: attachment.type || fileToUpload.type,
|
||
thumbavif,
|
||
});
|
||
}
|
||
return files;
|
||
}
|
||
|
||
/**
|
||
* @param {{ number: string, externalNumber?: string, date: string, partyId: number|null, lines: object[], attachments?: object[] }} form
|
||
* @param {{ partyField: 'supplierId'|'entityId', requireAtLeastOne: boolean, externalNumber?: boolean }} opts
|
||
*/
|
||
export async function buildManifestBody(
|
||
form,
|
||
{ partyField, requireAtLeastOne, externalNumber: useExternalNumber },
|
||
) {
|
||
const date = form.date;
|
||
if (!date) throw new Error('Datum fehlt.');
|
||
|
||
let body;
|
||
if (useExternalNumber) {
|
||
// Delivery: number is server-allocated/read-only. The caller-supplied
|
||
// reference goes in externalNumber (nullable; empty string → null).
|
||
const externalNumber = String(form.externalNumber ?? '').trim();
|
||
body = {
|
||
externalNumber: externalNumber || null,
|
||
date,
|
||
lines: buildLines(form.lines, { requireAtLeastOne }),
|
||
};
|
||
} else {
|
||
const number = String(form.number ?? '').trim();
|
||
if (!number) throw new Error('Nummer fehlt.');
|
||
body = {
|
||
number,
|
||
date,
|
||
lines: buildLines(form.lines, { requireAtLeastOne }),
|
||
};
|
||
}
|
||
if (form.partyId != null) body[partyField] = form.partyId;
|
||
else if (!requireAtLeastOne) body[partyField] = null;
|
||
|
||
// Add files if attachments exist
|
||
if (form.attachments && form.attachments.length > 0) {
|
||
const isUpdate = !requireAtLeastOne;
|
||
const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isUpdate });
|
||
if (filesToUpload.length > 0) {
|
||
const formData = new FormData();
|
||
body.filesMeta = filesToUpload.map((item) => ({
|
||
name: item.name,
|
||
thumbavif: item.thumbavif ?? null,
|
||
}));
|
||
formData.append('data', JSON.stringify(body));
|
||
for (const item of filesToUpload) {
|
||
formData.append('files', item.file, item.name);
|
||
}
|
||
return formData;
|
||
}
|
||
}
|
||
|
||
return body;
|
||
}
|
||
|
||
export const LINE_COLUMNS = [
|
||
{ id: 'amount', label: 'Amount', width: 88, align: 'right', type: 'number' },
|
||
{ id: 'sku', label: 'SKU', width: 140, align: 'left', type: 'text' },
|
||
{ id: 'tax', label: 'Tax', width: 80, align: 'right', type: 'number' },
|
||
{ id: 'rebate', label: 'Rebate', width: 88, align: 'right', type: 'number' },
|
||
{ id: 'net', label: 'Net', width: 104, align: 'right', type: 'number' },
|
||
];
|
||
|
||
export const DELIVERY_LINE_COLUMNS = [
|
||
{ id: 'sku', label: 'SKU', width: 130, align: 'left', type: 'text' },
|
||
{ id: 'name', label: 'Name', width: 220, align: 'left', type: 'text' },
|
||
{ id: 'amount', label: 'Amount', width: 88, align: 'right', type: 'number' },
|
||
{ id: 'pu', label: 'PU', width: 80, align: 'right', type: 'number' },
|
||
{ id: 'taxrate', label: 'Tax', width: 80, align: 'right', type: 'number' },
|
||
{ id: 'netprice', label: 'Net', width: 104, align: 'right', type: 'number' },
|
||
];
|
||
|
||
/** @typedef {'delivery'|'quote'|'order'} ManifestKind */
|
||
|
||
/**
|
||
* Load party options for Autocomplete / list name lookup.
|
||
* @param {ManifestKind} kind
|
||
* @param {object} ctx StorageManager instance
|
||
*/
|
||
export async function loadParties(kind, ctx) {
|
||
if (kind === 'delivery') {
|
||
if (supplierAutomat.state.status === 'ready') {
|
||
return sortSuppliers(supplierAutomat.state.suppliers);
|
||
}
|
||
const list = await supplierAutomat.actions.load();
|
||
return sortSuppliers(list);
|
||
}
|
||
const [entities, groups] = await Promise.all([
|
||
ctx.entities ?? ctx.entityPromise,
|
||
ctx.customergroups ?? ctx.customergroupPromise,
|
||
]);
|
||
return filterCustomerEntities(entities, groups);
|
||
}
|
||
|
||
export function partyDisplayName(kind, party) {
|
||
if (!party) return '';
|
||
if (kind === 'delivery') return String(party.name ?? '');
|
||
return entityDisplayName(party);
|
||
}
|
||
|
||
/**
|
||
* @param {ManifestKind} kind
|
||
*/
|
||
export function manifestConfig(kind) {
|
||
if (kind === 'delivery') {
|
||
return {
|
||
kind: 'delivery',
|
||
api: 'delivery',
|
||
listField: 'deliveries',
|
||
docField: 'delivery',
|
||
partyField: 'supplierId',
|
||
listPath: '/lieferung',
|
||
createPath: '/lieferung/neu',
|
||
editPath: (id) => `/lieferung/${id}`,
|
||
dateLabel: 'Lieferdatum',
|
||
partyLabel: 'Lieferant',
|
||
emptyList: 'Noch keine Lieferungen.',
|
||
createButton: 'Neue Lieferung',
|
||
createdMsg: 'Lieferung angelegt.',
|
||
savedMsg: 'Lieferung gespeichert.',
|
||
deletedMsg: 'Lieferung gelöscht.',
|
||
confirmDelete: (number) => `Lieferung „${number}“ wirklich löschen?`,
|
||
invalidId: 'Ungültige Lieferungs-ID.',
|
||
partyGateMessage: 'Lieferant wählen, um Artikel zu erfassen.',
|
||
getPathLabel: 'GET /api/delivery/:id',
|
||
postPathLabel: 'POST /api/delivery',
|
||
patchPathLabel: 'PATCH /api/delivery/:id',
|
||
listColor: '#fed7aa',
|
||
headerColor: '#fed7aa',
|
||
linesColor: '#bbf7d0',
|
||
requirePartyBeforeLines: true,
|
||
updateCache: 'updateDelivery',
|
||
listPromise: (ctx) => ctx.deliveries ?? ctx.deliveryPromise,
|
||
partiesByIdFromList: (parties) =>
|
||
Object.fromEntries(parties.map((row) => [row.id, row])),
|
||
// Delivery uses a server-allocated read-only number plus an optional
|
||
// caller-supplied external reference.
|
||
externalNumber: true,
|
||
columns: DELIVERY_LINE_COLUMNS,
|
||
};
|
||
}
|
||
if (kind === 'quote') {
|
||
return {
|
||
kind: 'quote',
|
||
api: 'quote',
|
||
listField: 'quotes',
|
||
docField: 'quote',
|
||
partyField: 'entityId',
|
||
listPath: '/auftrag',
|
||
createPath: '/auftrag/neu',
|
||
editPath: (id) => `/auftrag/${id}`,
|
||
dateLabel: 'Gültig bis',
|
||
partyLabel: 'Kunde',
|
||
emptyList: 'Noch keine Angebote.',
|
||
createButton: 'Neues Angebot',
|
||
createdMsg: 'Angebot angelegt.',
|
||
savedMsg: 'Angebot gespeichert.',
|
||
deletedMsg: 'Angebot gelöscht.',
|
||
confirmDelete: (number) => `Angebot „${number}“ wirklich löschen?`,
|
||
invalidId: 'Ungültige Angebots-ID.',
|
||
partyGateMessage: null,
|
||
getPathLabel: 'GET /api/quote/:id',
|
||
postPathLabel: 'POST /api/quote',
|
||
patchPathLabel: 'PATCH /api/quote/:id',
|
||
listColor: '#fde68a',
|
||
headerColor: '#fde68a',
|
||
linesColor: '#bbf7d0',
|
||
requirePartyBeforeLines: false,
|
||
updateCache: 'updateQuote',
|
||
listPromise: (ctx) => ctx.quotes ?? ctx.quotePromise,
|
||
partiesByIdFromList: (parties) =>
|
||
Object.fromEntries(parties.map((row) => [row.id, row])),
|
||
columns: LINE_COLUMNS,
|
||
};
|
||
}
|
||
if (kind === 'order') {
|
||
return {
|
||
kind: 'order',
|
||
api: 'order',
|
||
listField: 'orders',
|
||
docField: 'order',
|
||
partyField: 'entityId',
|
||
listPath: '/auftrag/bestellung',
|
||
createPath: '/auftrag/bestellung/neu',
|
||
editPath: (id) => `/auftrag/bestellung/${id}`,
|
||
dateLabel: 'Lieferdatum',
|
||
partyLabel: 'Kunde',
|
||
emptyList: 'Noch keine Bestellungen.',
|
||
createButton: 'Neue Bestellung',
|
||
createdMsg: 'Bestellung angelegt.',
|
||
savedMsg: 'Bestellung gespeichert.',
|
||
deletedMsg: 'Bestellung gelöscht.',
|
||
confirmDelete: (number) => `Bestellung „${number}“ wirklich löschen?`,
|
||
invalidId: 'Ungültige Bestellungs-ID.',
|
||
partyGateMessage: null,
|
||
getPathLabel: 'GET /api/order/:id',
|
||
postPathLabel: 'POST /api/order',
|
||
patchPathLabel: 'PATCH /api/order/:id',
|
||
listColor: '#c4b5fd',
|
||
headerColor: '#c4b5fd',
|
||
linesColor: '#bbf7d0',
|
||
requirePartyBeforeLines: false,
|
||
updateCache: 'updateOrder',
|
||
listPromise: (ctx) => ctx.orders ?? ctx.orderPromise,
|
||
partiesByIdFromList: (parties) =>
|
||
Object.fromEntries(parties.map((row) => [row.id, row])),
|
||
columns: LINE_COLUMNS,
|
||
};
|
||
}
|
||
throw new Error(`Unknown manifest kind: ${kind}`);
|
||
}
|