94 lines
3.6 KiB
TypeScript
94 lines
3.6 KiB
TypeScript
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
|
|
import { collectionSchema, type Recipe } from './recipe';
|
|
export function parseImport(raw: string, format: 'json' | 'xml') {
|
|
if (Buffer.byteLength(raw, 'utf8') > 2 * 1024 * 1024)
|
|
throw new Error('Importdatei ist größer als 2 MB.');
|
|
let value: unknown;
|
|
if (format === 'json') value = JSON.parse(raw);
|
|
else {
|
|
if (/<!DOCTYPE|<!ENTITY/i.test(raw))
|
|
throw new Error('DOCTYPE und Entities sind nicht erlaubt.');
|
|
if (XMLValidator.validate(raw) !== true) throw new Error('XML ist nicht wohlgeformt.');
|
|
const parser = new XMLParser({
|
|
ignoreAttributes: false,
|
|
parseTagValue: false,
|
|
processEntities: false,
|
|
isArray: (_name, path) =>
|
|
/\.(recipe|ingredient|step|timer|category|tag|ref)$|\.images\.image$/.test(String(path)),
|
|
});
|
|
const root = parser.parse(raw).hamham;
|
|
if (!root) throw new Error('XML benötigt das Wurzelelement hamham.');
|
|
const num = (x: unknown, fallback = 0) => (x === undefined || x === '' ? fallback : Number(x));
|
|
const bool = (x: unknown) => x === 'true';
|
|
const decode = (s: string) =>
|
|
s
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&');
|
|
const decodeTree = (v: unknown): unknown =>
|
|
typeof v === 'string'
|
|
? decode(v)
|
|
: Array.isArray(v)
|
|
? v.map(decodeTree)
|
|
: v && typeof v === 'object'
|
|
? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, decodeTree(x)]))
|
|
: v;
|
|
const recipes = (root.recipes?.recipe ?? []).map((r: Record<string, any>) => ({
|
|
...r,
|
|
servings: num(r.servings),
|
|
prepMinutes: num(r.prepMinutes),
|
|
cookMinutes: num(r.cookMinutes),
|
|
restMinutes: num(r.restMinutes),
|
|
published: bool(r.published),
|
|
categories: r.categories?.category ?? [],
|
|
tags: r.tags?.tag ?? [],
|
|
images: r.images?.image ?? [],
|
|
ingredients: (r.ingredients?.ingredient ?? []).map((i: Record<string, any>) => ({
|
|
...i,
|
|
amount: i.amount === '' || i.amount === undefined ? null : Number(i.amount),
|
|
optional: bool(i.optional),
|
|
})),
|
|
steps: (r.steps?.step ?? []).map((s: Record<string, any>) => ({
|
|
...s,
|
|
ingredients: s.ingredients?.ref ?? [],
|
|
timers: (s.timers?.timer ?? []).map((t: Record<string, any>) => ({
|
|
...t,
|
|
seconds: num(t.seconds),
|
|
})),
|
|
})),
|
|
nutrition:
|
|
r.nutrition && typeof r.nutrition === 'object'
|
|
? Object.fromEntries(Object.entries(r.nutrition).map(([k, v]) => [k, num(v)]))
|
|
: null,
|
|
}));
|
|
value = { version: Number(root['@_version']), recipes: decodeTree(recipes) };
|
|
}
|
|
return collectionSchema.parse(value);
|
|
}
|
|
export function exportRecipes(recipes: Recipe[], format: 'json' | 'xml') {
|
|
if (format === 'json') return JSON.stringify({ version: 1, recipes }, null, 2);
|
|
const data = recipes.map((r) => ({
|
|
...r,
|
|
categories: { category: r.categories },
|
|
tags: { tag: r.tags },
|
|
images: { image: r.images },
|
|
ingredients: { ingredient: r.ingredients.map((i) => ({ ...i, amount: i.amount ?? '' })) },
|
|
steps: {
|
|
step: r.steps.map((s) => ({
|
|
...s,
|
|
ingredients: { ref: s.ingredients },
|
|
timers: { timer: s.timers },
|
|
})),
|
|
},
|
|
nutrition: r.nutrition ?? '',
|
|
}));
|
|
return (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
new XMLBuilder({ ignoreAttributes: false, format: true }).build({
|
|
hamham: { '@_version': 1, recipes: { recipe: data } },
|
|
})
|
|
);
|
|
}
|