chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { convertTemplateData } from "./convert-template.mjs";
|
||||
|
||||
function usage() {
|
||||
return `Usage: node scripts/convert-presentation-template.mjs <presentation.json> [options]
|
||||
|
||||
Options:
|
||||
--output <path> Output JSON path (default: overwrite the input)
|
||||
--app-data <dir> App-data root (default: APP_DATA_DIRECTORY or ./app_data)
|
||||
--id <id> Override the template id
|
||||
--name <name> Override the template name
|
||||
--description <text> Override the template description
|
||||
--thumbnail <path> Thumbnail URL or asset path to package
|
||||
--help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {};
|
||||
const valueOptions = new Map([
|
||||
["--output", "output"],
|
||||
["-o", "output"],
|
||||
["--app-data", "appData"],
|
||||
["--id", "id"],
|
||||
["--name", "name"],
|
||||
["--description", "description"],
|
||||
["--thumbnail", "thumbnail"],
|
||||
]);
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--help" || argument === "-h") return { help: true };
|
||||
const optionName = valueOptions.get(argument);
|
||||
if (optionName) {
|
||||
const value = argv[++index];
|
||||
if (!value) throw new Error(`${argument} requires a value`);
|
||||
options[optionName] = value;
|
||||
continue;
|
||||
}
|
||||
if (argument.startsWith("-")) throw new Error(`Unknown option: ${argument}`);
|
||||
if (options.input) throw new Error("Only one presentation JSON file may be provided");
|
||||
options.input = argument;
|
||||
}
|
||||
|
||||
if (!options.input) throw new Error("A presentation JSON file is required");
|
||||
return { ...options, help: false };
|
||||
}
|
||||
|
||||
function asObject(value, label) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function unwrapArray(value, wrapperKey, label) {
|
||||
const array = Array.isArray(value) ? value : value?.[wrapperKey];
|
||||
if (!Array.isArray(array)) {
|
||||
throw new Error(`${label} must be an array or contain an array at .${wrapperKey}`);
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
function firstNonEmptyString(...values) {
|
||||
return values.find((value) => typeof value === "string" && value.trim())?.trim();
|
||||
}
|
||||
|
||||
function displayName(value) {
|
||||
return value
|
||||
.replaceAll(/[-_]+/g, " ")
|
||||
.replaceAll(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
function findVariant(mergedComponents, component) {
|
||||
const matches = [];
|
||||
for (let groupIndex = 0; groupIndex < mergedComponents.length; groupIndex += 1) {
|
||||
const variants = mergedComponents[groupIndex].variants;
|
||||
if (!Array.isArray(variants)) {
|
||||
throw new Error(
|
||||
`merged_components[${groupIndex}].variants must be an array`,
|
||||
);
|
||||
}
|
||||
for (let variantIndex = 0; variantIndex < variants.length; variantIndex += 1) {
|
||||
if (isDeepStrictEqual(variants[variantIndex], component)) {
|
||||
matches.push({ groupIndex, variantIndex });
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function uniqueGroupId(componentId, usedIds) {
|
||||
const base = firstNonEmptyString(componentId) ?? "component";
|
||||
if (!usedIds.has(base)) {
|
||||
usedIds.add(base);
|
||||
return base;
|
||||
}
|
||||
|
||||
for (let suffix = 2; ; suffix += 1) {
|
||||
const suffixText = `_${suffix}`;
|
||||
const candidate = `${base.slice(0, 80 - suffixText.length)}${suffixText}`;
|
||||
if (!usedIds.has(candidate)) {
|
||||
usedIds.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function takeComponentWithId(components, componentId) {
|
||||
const index = components.findIndex((component) => component?.id === componentId);
|
||||
if (index === -1) return null;
|
||||
return components.splice(index, 1)[0];
|
||||
}
|
||||
|
||||
function assertSingleVariantMatch(matches, layoutId, componentId) {
|
||||
if (matches.length === 1) return matches[0];
|
||||
const reason = matches.length === 0 ? "was not found" : "is ambiguous";
|
||||
throw new Error(
|
||||
`Original component ${layoutId}/${componentId ?? "<no id>"} ${reason} in merged_components`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the editor's latest slides[].ui trees to the reusable layout and merged
|
||||
* component catalogs carried by a presentation-detail response.
|
||||
*/
|
||||
export function buildTemplateFromPresentation(rawPresentation, options = {}) {
|
||||
const raw = asObject(rawPresentation, "The presentation");
|
||||
const sourceLayouts = structuredClone(
|
||||
unwrapArray(raw.layout ?? raw.layouts, "layouts", "presentation.layout"),
|
||||
);
|
||||
const mergedComponents = structuredClone(
|
||||
unwrapArray(raw.merged_components, "components", "presentation.merged_components"),
|
||||
);
|
||||
const originalMergedComponents = structuredClone(mergedComponents);
|
||||
const removedVariant = Symbol("removed merged component variant");
|
||||
const slides = raw.slides == null ? [] : unwrapArray(raw.slides, "slides", "presentation.slides");
|
||||
const layoutIndexById = new Map();
|
||||
|
||||
sourceLayouts.forEach((layout, index) => {
|
||||
asObject(layout, `presentation.layout.layouts[${index}]`);
|
||||
if (typeof layout.id !== "string" || !layout.id.trim()) {
|
||||
throw new Error(`presentation.layout.layouts[${index}].id must be a non-empty string`);
|
||||
}
|
||||
if (layoutIndexById.has(layout.id)) {
|
||||
throw new Error(`Duplicate source layout id: ${layout.id}`);
|
||||
}
|
||||
if (!Array.isArray(layout.components)) {
|
||||
throw new Error(`Layout ${layout.id}.components must be an array`);
|
||||
}
|
||||
layoutIndexById.set(layout.id, index);
|
||||
});
|
||||
|
||||
mergedComponents.forEach((group, index) => {
|
||||
asObject(group, `presentation.merged_components.components[${index}]`);
|
||||
if (!Array.isArray(group.variants)) {
|
||||
throw new Error(
|
||||
`presentation.merged_components.components[${index}].variants must be an array`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const editedLayoutBySourceId = new Map();
|
||||
for (let index = 0; index < slides.length; index += 1) {
|
||||
const slide = asObject(slides[index], `presentation.slides[${index}]`);
|
||||
if (slide.ui == null) continue;
|
||||
const ui = structuredClone(asObject(slide.ui, `presentation.slides[${index}].ui`));
|
||||
if (!Array.isArray(ui.components)) {
|
||||
throw new Error(`presentation.slides[${index}].ui.components must be an array`);
|
||||
}
|
||||
const sourceId = firstNonEmptyString(slide.layout, ui.id);
|
||||
if (!sourceId) {
|
||||
throw new Error(`presentation.slides[${index}] needs a layout id when ui is present`);
|
||||
}
|
||||
|
||||
const existing = editedLayoutBySourceId.get(sourceId);
|
||||
if (existing && !isDeepStrictEqual(existing, ui)) {
|
||||
throw new Error(`Slides contain conflicting UI edits for layout ${sourceId}`);
|
||||
}
|
||||
editedLayoutBySourceId.set(sourceId, ui);
|
||||
}
|
||||
|
||||
let updatedLayoutCount = 0;
|
||||
let updatedVariantCount = 0;
|
||||
let removedVariantCount = 0;
|
||||
let addedVariantCount = 0;
|
||||
|
||||
for (const [sourceId, ui] of editedLayoutBySourceId) {
|
||||
const layoutIndex = layoutIndexById.get(sourceId);
|
||||
if (layoutIndex == null) {
|
||||
const newLayout = { ...ui, id: firstNonEmptyString(ui.id, sourceId) };
|
||||
sourceLayouts.push(newLayout);
|
||||
layoutIndexById.set(newLayout.id, sourceLayouts.length - 1);
|
||||
for (const component of newLayout.components) {
|
||||
if (findVariant(mergedComponents, component).length === 0) {
|
||||
mergedComponents.push({
|
||||
id: component.id,
|
||||
description: typeof component.description === "string" ? component.description : "",
|
||||
variants: [component],
|
||||
});
|
||||
addedVariantCount += 1;
|
||||
}
|
||||
}
|
||||
updatedLayoutCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceLayout = sourceLayouts[layoutIndex];
|
||||
const remainingUiComponents = [...ui.components];
|
||||
for (const sourceComponent of sourceLayout.components) {
|
||||
const match = assertSingleVariantMatch(
|
||||
findVariant(originalMergedComponents, sourceComponent),
|
||||
sourceId,
|
||||
sourceComponent.id,
|
||||
);
|
||||
const editedComponent = takeComponentWithId(remainingUiComponents, sourceComponent.id);
|
||||
if (editedComponent) {
|
||||
mergedComponents[match.groupIndex].variants[match.variantIndex] = editedComponent;
|
||||
if (!isDeepStrictEqual(sourceComponent, editedComponent)) updatedVariantCount += 1;
|
||||
} else {
|
||||
// Keep catalog indexes stable while other source components are mapped
|
||||
// against the original catalog. Filtering happens after all UI edits.
|
||||
mergedComponents[match.groupIndex].variants[match.variantIndex] = removedVariant;
|
||||
removedVariantCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const addedComponent of remainingUiComponents) {
|
||||
if (findVariant(mergedComponents, addedComponent).length > 0) continue;
|
||||
mergedComponents.push({
|
||||
id: addedComponent.id,
|
||||
description:
|
||||
typeof addedComponent.description === "string" ? addedComponent.description : "",
|
||||
variants: [addedComponent],
|
||||
});
|
||||
addedVariantCount += 1;
|
||||
}
|
||||
|
||||
sourceLayouts[layoutIndex] = { ...sourceLayout, ...ui };
|
||||
if (!isDeepStrictEqual(sourceLayout, sourceLayouts[layoutIndex])) updatedLayoutCount += 1;
|
||||
}
|
||||
|
||||
const nonEmptyMergedComponents = mergedComponents
|
||||
.map((group) => ({
|
||||
...group,
|
||||
variants: group.variants.filter((variant) => variant !== removedVariant),
|
||||
}))
|
||||
.filter((group) => group.variants.length > 0);
|
||||
const usedGroupIds = new Set();
|
||||
for (const group of nonEmptyMergedComponents) {
|
||||
group.id = uniqueGroupId(group.id, usedGroupIds);
|
||||
}
|
||||
|
||||
// A valid final catalog must contain every component from every final layout.
|
||||
for (const layout of sourceLayouts) {
|
||||
for (const component of layout.components) {
|
||||
const matches = findVariant(nonEmptyMergedComponents, component);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(
|
||||
`Final component ${layout.id}/${component.id ?? "<no id>"} is missing from merged_components`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const outputDirectoryName = path.basename(
|
||||
path.dirname(path.resolve(options.output ?? options.input ?? ".")),
|
||||
);
|
||||
const previewImages = Array.isArray(raw.assets?.slide_image_urls)
|
||||
? raw.assets.slide_image_urls
|
||||
: [];
|
||||
const id = firstNonEmptyString(options.id, raw.template_id, raw.id, outputDirectoryName);
|
||||
const name = firstNonEmptyString(options.name, raw.name, displayName(outputDirectoryName), id);
|
||||
|
||||
return {
|
||||
template: {
|
||||
id,
|
||||
name,
|
||||
description:
|
||||
options.description ?? (typeof raw.description === "string" ? raw.description : ""),
|
||||
thumbnail: firstNonEmptyString(options.thumbnail, raw.thumbnail, ...previewImages) ?? "",
|
||||
merged_components: nonEmptyMergedComponents,
|
||||
layouts: sourceLayouts,
|
||||
fonts:
|
||||
raw.assets?.fonts &&
|
||||
typeof raw.assets.fonts === "object" &&
|
||||
!Array.isArray(raw.assets.fonts)
|
||||
? raw.assets.fonts
|
||||
: raw.fonts && typeof raw.fonts === "object" && !Array.isArray(raw.fonts)
|
||||
? raw.fonts
|
||||
: {},
|
||||
},
|
||||
stats: {
|
||||
updatedLayoutCount,
|
||||
updatedVariantCount,
|
||||
removedVariantCount,
|
||||
addedVariantCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function convertPresentationTemplate({
|
||||
input,
|
||||
output = input,
|
||||
appData,
|
||||
...metadata
|
||||
} = {}) {
|
||||
if (!input) throw new Error("input is required");
|
||||
const inputPath = path.resolve(input);
|
||||
const outputPath = path.resolve(output);
|
||||
const raw = JSON.parse(await readFile(inputPath, "utf8"));
|
||||
const { template, stats } = buildTemplateFromPresentation(raw, {
|
||||
...metadata,
|
||||
input: inputPath,
|
||||
output: outputPath,
|
||||
});
|
||||
const result = await convertTemplateData({
|
||||
raw: template,
|
||||
input: inputPath,
|
||||
output: outputPath,
|
||||
appData,
|
||||
});
|
||||
return { ...result, ...stats };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
process.stdout.write(usage());
|
||||
return;
|
||||
}
|
||||
const result = await convertPresentationTemplate(options);
|
||||
const editSummary =
|
||||
`Applied UI edits to ${result.updatedLayoutCount} layout(s), ` +
|
||||
`updated ${result.updatedVariantCount} merged variant(s), ` +
|
||||
`removed ${result.removedVariantCount}, and added ${result.addedVariantCount}.\n`;
|
||||
process.stdout.write(
|
||||
`Converted ${result.outputPath}\n` +
|
||||
editSummary +
|
||||
`Packaged ${result.assetCount} referenced asset(s).\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
process.stderr.write(`Presentation template conversion failed: ${error.message}\n\n${usage()}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
||||
await main();
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { convertPresentationTemplate } from "./convert-presentation-template.mjs";
|
||||
|
||||
function component(id, data, extra = {}) {
|
||||
return {
|
||||
id,
|
||||
description: `${id} description`,
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 100 },
|
||||
elements: [
|
||||
{
|
||||
type: "image",
|
||||
data,
|
||||
decorative: true,
|
||||
is_icon: false,
|
||||
},
|
||||
],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("converts presentation UI edits into bundled layouts and merged variants", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "presentation-template-converter-"));
|
||||
const appData = path.join(root, "app_data");
|
||||
const inputDirectory = path.join(root, "templates", "dynamic");
|
||||
const input = path.join(inputDirectory, "presentation.json");
|
||||
const output = path.join(inputDirectory, "template.json");
|
||||
await mkdir(path.join(appData, "images"), { recursive: true });
|
||||
await mkdir(path.join(appData, "fonts"), { recursive: true });
|
||||
await mkdir(inputDirectory, { recursive: true });
|
||||
await writeFile(path.join(appData, "images", "updated.png"), "updated");
|
||||
await writeFile(path.join(appData, "images", "untouched.png"), "untouched");
|
||||
await writeFile(path.join(appData, "images", "added.png"), "added");
|
||||
await writeFile(path.join(appData, "images", "thumbnail.png"), "thumbnail");
|
||||
await writeFile(path.join(appData, "fonts", "custom.ttf"), "font");
|
||||
|
||||
const originalHero = component("hero", "/app_data/images/original.png");
|
||||
const originalRemoved = component("removed", "/app_data/images/removed.png");
|
||||
const untouched = component("untouched", "/app_data/images/untouched.png");
|
||||
const updatedHero = component("hero", "/app_data/images/updated.png", {
|
||||
position: { x: 25, y: 30 },
|
||||
});
|
||||
const added = component("added", "/app_data/images/added.png");
|
||||
|
||||
await writeFile(
|
||||
input,
|
||||
JSON.stringify({
|
||||
id: "presentation-id",
|
||||
layout: {
|
||||
layouts: [
|
||||
{
|
||||
id: "edited-layout",
|
||||
description: "Original layout",
|
||||
components: [originalHero, originalRemoved],
|
||||
},
|
||||
{
|
||||
id: "unused-layout",
|
||||
description: "Unused layout",
|
||||
components: [untouched],
|
||||
},
|
||||
],
|
||||
},
|
||||
merged_components: {
|
||||
components: [
|
||||
{
|
||||
id: "hero-group",
|
||||
description: "Hero variants",
|
||||
variants: [originalHero, untouched],
|
||||
},
|
||||
{
|
||||
id: "removed",
|
||||
description: "Removed variants",
|
||||
variants: [originalRemoved],
|
||||
},
|
||||
],
|
||||
},
|
||||
slides: [
|
||||
{
|
||||
layout: "edited-layout",
|
||||
ui: {
|
||||
id: "edited-layout",
|
||||
description: "Updated layout",
|
||||
components: [updatedHero, added],
|
||||
},
|
||||
},
|
||||
],
|
||||
fonts: { Custom: "/app_data/fonts/custom.ttf" },
|
||||
thumbnail: "/app_data/images/thumbnail.png",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await convertPresentationTemplate({
|
||||
input,
|
||||
output,
|
||||
appData,
|
||||
name: "Dynamic",
|
||||
});
|
||||
const converted = JSON.parse(await readFile(output, "utf8"));
|
||||
|
||||
assert.deepEqual(Object.keys(converted), [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"thumbnail",
|
||||
"merged_components",
|
||||
"layouts",
|
||||
"fonts",
|
||||
]);
|
||||
assert.equal(converted.name, "Dynamic");
|
||||
assert.equal(converted.layouts.length, 2);
|
||||
assert.equal(converted.layouts[0].description, "Updated layout");
|
||||
assert.equal(converted.layouts[0].components[0].position.x, 25);
|
||||
assert.equal(converted.layouts[0].components[0].elements[0].data, "static/updated.png");
|
||||
assert.equal(converted.layouts[1].components[0].elements[0].data, "static/untouched.png");
|
||||
|
||||
const variants = converted.merged_components.flatMap((group) => group.variants);
|
||||
assert.equal(variants.some((variant) => variant.id === "removed"), false);
|
||||
assert.equal(
|
||||
variants.find((variant) => variant.id === "hero").elements[0].data,
|
||||
"static/updated.png",
|
||||
);
|
||||
assert.equal(
|
||||
variants.find((variant) => variant.id === "added").elements[0].data,
|
||||
"static/added.png",
|
||||
);
|
||||
assert.equal(converted.thumbnail, "static/thumbnail.png");
|
||||
assert.equal(converted.fonts.Custom, "static/custom.ttf");
|
||||
assert.equal(result.updatedLayoutCount, 1);
|
||||
assert.equal(result.updatedVariantCount, 1);
|
||||
assert.equal(result.removedVariantCount, 1);
|
||||
assert.equal(result.addedVariantCount, 1);
|
||||
assert.equal(result.assetCount, 5);
|
||||
});
|
||||
|
||||
test("fails when an original layout component cannot be mapped to merged_components", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "presentation-template-invalid-"));
|
||||
const input = path.join(root, "presentation.json");
|
||||
const output = path.join(root, "template.json");
|
||||
const original = component("missing", "/static/images/replaceable_template_image.png");
|
||||
await writeFile(
|
||||
input,
|
||||
JSON.stringify({
|
||||
layout: { layouts: [{ id: "layout", components: [original] }] },
|
||||
merged_components: { components: [] },
|
||||
slides: [{ layout: "layout", ui: { id: "layout", components: [original] } }],
|
||||
}),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
convertPresentationTemplate({ input, output, appData: path.join(root, "app_data") }),
|
||||
/was not found in merged_components/,
|
||||
);
|
||||
await assert.rejects(readFile(output), /ENOENT/);
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFile,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
rmdir,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const TOP_LEVEL_KEYS = [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"thumbnail",
|
||||
"merged_components",
|
||||
"layouts",
|
||||
"fonts",
|
||||
];
|
||||
|
||||
const REPLACEABLE_IMAGE = "/static/images/replaceable_template_image.png";
|
||||
const ICON_PLACEHOLDER = "/static/icons/placeholder.svg";
|
||||
|
||||
function usage() {
|
||||
return `Usage: node scripts/convert-template.mjs <input.json> [options]
|
||||
|
||||
Options:
|
||||
--output <path> Output JSON path (default: overwrite the input)
|
||||
--app-data <dir> App-data root (default: APP_DATA_DIRECTORY or ./app_data)
|
||||
--help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
let input;
|
||||
let output;
|
||||
let appData;
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--help" || argument === "-h") return { help: true };
|
||||
if (argument === "--output" || argument === "-o") {
|
||||
output = argv[++index];
|
||||
if (!output) throw new Error(`${argument} requires a path`);
|
||||
continue;
|
||||
}
|
||||
if (argument === "--app-data") {
|
||||
appData = argv[++index];
|
||||
if (!appData) throw new Error(`${argument} requires a directory`);
|
||||
continue;
|
||||
}
|
||||
if (argument.startsWith("-")) throw new Error(`Unknown option: ${argument}`);
|
||||
if (input) throw new Error("Only one input JSON file may be provided");
|
||||
input = argument;
|
||||
}
|
||||
|
||||
if (!input) throw new Error("An input JSON file is required");
|
||||
return { input, output, appData, help: false };
|
||||
}
|
||||
|
||||
function unwrapArray(value, wrapperKey, fieldName) {
|
||||
const unwrapped = Array.isArray(value) ? value : value?.[wrapperKey];
|
||||
if (!Array.isArray(unwrapped)) {
|
||||
throw new Error(
|
||||
`Top-level ${fieldName} must be an array or an object containing an array at .${wrapperKey}`,
|
||||
);
|
||||
}
|
||||
return unwrapped;
|
||||
}
|
||||
|
||||
function firstNonEmptyString(...values) {
|
||||
return values.find((value) => typeof value === "string" && value.trim())?.trim();
|
||||
}
|
||||
|
||||
function buildTargetShape(raw, outputPath) {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw new Error("The input template must be a JSON object");
|
||||
}
|
||||
|
||||
const id = firstNonEmptyString(raw.id, path.basename(path.dirname(outputPath)));
|
||||
const name = firstNonEmptyString(raw.name, id);
|
||||
const previewImages = Array.isArray(raw.assets?.slide_image_urls)
|
||||
? raw.assets.slide_image_urls
|
||||
: [];
|
||||
const thumbnailSource = firstNonEmptyString(raw.thumbnail, ...previewImages) ?? "";
|
||||
const fonts =
|
||||
raw.assets?.fonts && typeof raw.assets.fonts === "object" && !Array.isArray(raw.assets.fonts)
|
||||
? raw.assets.fonts
|
||||
: raw.fonts && typeof raw.fonts === "object" && !Array.isArray(raw.fonts)
|
||||
? raw.fonts
|
||||
: {};
|
||||
|
||||
return {
|
||||
template: {
|
||||
id,
|
||||
name,
|
||||
description: typeof raw.description === "string" ? raw.description : "",
|
||||
thumbnail: thumbnailSource,
|
||||
merged_components:
|
||||
raw.merged_components == null
|
||||
? []
|
||||
: unwrapArray(raw.merged_components, "components", "merged_components"),
|
||||
layouts: unwrapArray(raw.layouts, "layouts", "layouts"),
|
||||
fonts,
|
||||
},
|
||||
thumbnailSource,
|
||||
};
|
||||
}
|
||||
|
||||
function replaceEditableImages(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(replaceEditableImages);
|
||||
}
|
||||
if (!value || typeof value !== "object") return value;
|
||||
|
||||
const converted = Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [key, replaceEditableImages(child)]),
|
||||
);
|
||||
if (value.type !== "image") return converted;
|
||||
if (value.is_icon === true) {
|
||||
return { ...converted, data: ICON_PLACEHOLDER };
|
||||
}
|
||||
if (value.decorative !== true) {
|
||||
return { ...converted, data: REPLACEABLE_IMAGE };
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
function collectStrings(value, result = new Set()) {
|
||||
if (typeof value === "string") {
|
||||
result.add(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const child of value) collectStrings(child, result);
|
||||
} else if (value && typeof value === "object") {
|
||||
for (const child of Object.values(value)) collectStrings(child, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function appDataRelativePath(value) {
|
||||
let pathname = value;
|
||||
try {
|
||||
pathname = decodeURIComponent(new URL(value).pathname);
|
||||
} catch {
|
||||
// Plain filesystem and template-relative paths are handled below.
|
||||
}
|
||||
|
||||
const normalized = pathname.replaceAll("\\", "/");
|
||||
const marker = "/app_data/";
|
||||
if (normalized.startsWith(marker)) return normalized.slice(marker.length);
|
||||
if (normalized.startsWith("app_data/")) return normalized.slice("app_data/".length);
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertInside(root, candidate, label) {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error(`${label} resolves outside ${root}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function isFile(filePath) {
|
||||
try {
|
||||
return (await stat(filePath)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function contentHash(filePath) {
|
||||
return createHash("sha256").update(await readFile(filePath)).digest("hex");
|
||||
}
|
||||
|
||||
async function resolveAsset(value, context) {
|
||||
const appDataRelative = appDataRelativePath(value);
|
||||
if (appDataRelative != null) {
|
||||
const source = path.resolve(context.appDataRoot, appDataRelative);
|
||||
assertInside(context.appDataRoot, source, value);
|
||||
return { source, preferredName: path.basename(source) };
|
||||
}
|
||||
|
||||
if (value.startsWith("static/")) {
|
||||
const relative = value.slice("static/".length);
|
||||
const source = path.resolve(context.inputDirectory, "static", relative);
|
||||
assertInside(path.join(context.inputDirectory, "static"), source, value);
|
||||
return { source, preferredName: relative };
|
||||
}
|
||||
|
||||
const absolute = path.resolve(value);
|
||||
if (path.isAbsolute(value) && absolute.startsWith(`${context.appDataRoot}${path.sep}`)) {
|
||||
return { source: absolute, preferredName: path.basename(absolute) };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function planAssets(template, thumbnailSource, context) {
|
||||
const plans = new Map();
|
||||
const plansBySource = new Map();
|
||||
const claimedNames = new Map();
|
||||
|
||||
for (const value of collectStrings(template)) {
|
||||
const asset = await resolveAsset(value, context);
|
||||
if (!asset) continue;
|
||||
if (!(await isFile(asset.source))) {
|
||||
throw new Error(`Referenced asset does not exist: ${asset.source} (from ${value})`);
|
||||
}
|
||||
|
||||
const sourceKey = path.resolve(asset.source);
|
||||
const existing = plansBySource.get(sourceKey);
|
||||
if (existing) {
|
||||
plans.set(value, existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
let targetName =
|
||||
value === thumbnailSource
|
||||
? `thumbnail${path.extname(asset.preferredName) || ".png"}`
|
||||
: asset.preferredName;
|
||||
targetName = targetName.replaceAll("\\", "/").replace(/^\/+/, "");
|
||||
assertInside(
|
||||
context.staticDirectory,
|
||||
path.resolve(context.staticDirectory, targetName),
|
||||
targetName,
|
||||
);
|
||||
|
||||
const claimed = claimedNames.get(targetName);
|
||||
if (claimed && claimed.sourceKey !== sourceKey) {
|
||||
const extension = path.extname(targetName);
|
||||
const stem = extension ? targetName.slice(0, -extension.length) : targetName;
|
||||
targetName = `${stem}-${(await contentHash(asset.source)).slice(0, 12)}${extension}`;
|
||||
}
|
||||
|
||||
const plan = {
|
||||
source: asset.source,
|
||||
sourceKey,
|
||||
target: path.resolve(context.staticDirectory, targetName),
|
||||
outputValue: `static/${targetName}`,
|
||||
};
|
||||
claimedNames.set(targetName, plan);
|
||||
plansBySource.set(sourceKey, plan);
|
||||
plans.set(value, plan);
|
||||
}
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
function rewriteStrings(value, plans) {
|
||||
if (typeof value === "string") return plans.get(value)?.outputValue ?? value;
|
||||
if (Array.isArray(value)) return value.map((child) => rewriteStrings(child, plans));
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [key, rewriteStrings(child, plans)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function cleanStaticDirectory(directory, retainedTargets) {
|
||||
async function clean(currentDirectory) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(currentDirectory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(currentDirectory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await clean(entryPath);
|
||||
try {
|
||||
await rmdir(entryPath);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOTEMPTY" && error.code !== "EEXIST") throw error;
|
||||
}
|
||||
} else if (!retainedTargets.has(path.resolve(entryPath))) {
|
||||
await rm(entryPath, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await clean(directory);
|
||||
}
|
||||
|
||||
export async function convertTemplateData({ raw, input, output = input, appData } = {}) {
|
||||
if (!input) throw new Error("input is required");
|
||||
if (raw === undefined) throw new Error("raw template data is required");
|
||||
|
||||
const inputPath = path.resolve(input);
|
||||
const outputPath = path.resolve(output);
|
||||
const appDataRoot = path.resolve(
|
||||
appData ?? process.env.APP_DATA_DIRECTORY ?? path.join(process.cwd(), "app_data"),
|
||||
);
|
||||
const inputDirectory = path.dirname(inputPath);
|
||||
const outputDirectory = path.dirname(outputPath);
|
||||
const staticDirectory = path.join(outputDirectory, "static");
|
||||
const { template: targetShape, thumbnailSource } = buildTargetShape(raw, outputPath);
|
||||
const template = replaceEditableImages(targetShape);
|
||||
const plans = await planAssets(template, thumbnailSource, {
|
||||
appDataRoot,
|
||||
inputDirectory,
|
||||
staticDirectory,
|
||||
});
|
||||
const converted = rewriteStrings(template, plans);
|
||||
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
for (const plan of new Set(plans.values())) {
|
||||
await mkdir(path.dirname(plan.target), { recursive: true });
|
||||
if (path.resolve(plan.source) !== path.resolve(plan.target)) {
|
||||
await copyFile(plan.source, plan.target);
|
||||
}
|
||||
}
|
||||
await cleanStaticDirectory(
|
||||
staticDirectory,
|
||||
new Set([...plans.values()].map((plan) => path.resolve(plan.target))),
|
||||
);
|
||||
await writeFile(outputPath, `${JSON.stringify(converted, null, 2)}\n`, "utf8");
|
||||
|
||||
return {
|
||||
outputPath,
|
||||
assetCount: new Set([...plans.values()].map((plan) => plan.target)).size,
|
||||
topLevelKeys: TOP_LEVEL_KEYS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function convertTemplate({ input, output = input, appData } = {}) {
|
||||
if (!input) throw new Error("input is required");
|
||||
|
||||
const inputPath = path.resolve(input);
|
||||
const raw = JSON.parse(await readFile(inputPath, "utf8"));
|
||||
return convertTemplateData({ raw, input: inputPath, output, appData });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
process.stdout.write(usage());
|
||||
return;
|
||||
}
|
||||
const result = await convertTemplate(options);
|
||||
process.stdout.write(
|
||||
`Converted ${result.outputPath}\nPackaged ${result.assetCount} referenced asset(s).\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
process.stderr.write(`Template conversion failed: ${error.message}\n\n${usage()}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
||||
await main();
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { convertTemplate } from "./convert-template.mjs";
|
||||
|
||||
test("converts an exported template to the bundled default-template shape", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "template-converter-"));
|
||||
const appData = path.join(root, "app_data");
|
||||
const inputDirectory = path.join(root, "templates", "modern");
|
||||
const input = path.join(inputDirectory, "source.json");
|
||||
const output = path.join(inputDirectory, "template.json");
|
||||
const decorativeImage = path.join(
|
||||
appData,
|
||||
"pptx-to-json",
|
||||
"session",
|
||||
"images",
|
||||
"decoration.png",
|
||||
);
|
||||
const preview = path.join(appData, "uploads", "template-previews", "id", "slide_1.png");
|
||||
const staticDirectory = path.join(inputDirectory, "static");
|
||||
await mkdir(path.dirname(decorativeImage), { recursive: true });
|
||||
await mkdir(path.dirname(preview), { recursive: true });
|
||||
await mkdir(staticDirectory, { recursive: true });
|
||||
await writeFile(decorativeImage, "decorative image bytes");
|
||||
await writeFile(preview, "preview bytes");
|
||||
await writeFile(path.join(staticDirectory, "stale.png"), "stale bytes");
|
||||
await writeFile(
|
||||
input,
|
||||
JSON.stringify({
|
||||
id: "modern",
|
||||
name: "Modern",
|
||||
description: null,
|
||||
created_at: "remove only this top-level key",
|
||||
merged_components: {
|
||||
components: [{ id: "merged", metadata: { created_at: "keep nested" } }],
|
||||
},
|
||||
layouts: {
|
||||
layouts: [
|
||||
{
|
||||
id: "cover",
|
||||
custom_key: "keep me",
|
||||
components: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
type: "image",
|
||||
data: "/app_data/pptx-to-json/session/images/editable.png",
|
||||
decorative: false,
|
||||
is_icon: false,
|
||||
custom_image_key: true,
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
data: "/app_data/pptx-to-json/session/images/icon.svg",
|
||||
decorative: true,
|
||||
is_icon: true,
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
data: "/app_data/pptx-to-json/session/images/decoration.png",
|
||||
decorative: true,
|
||||
is_icon: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
assets: {
|
||||
fonts: { Montserrat: "https://fonts.example/montserrat.css" },
|
||||
images: ["/app_data/unused.png"],
|
||||
slide_image_urls: [
|
||||
"http://127.0.0.1:8000/app_data/uploads/template-previews/id/slide_1.png",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await convertTemplate({ input, output, appData });
|
||||
const converted = JSON.parse(await readFile(output, "utf8"));
|
||||
|
||||
assert.deepEqual(Object.keys(converted), [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"thumbnail",
|
||||
"merged_components",
|
||||
"layouts",
|
||||
"fonts",
|
||||
]);
|
||||
assert.equal(converted.description, "");
|
||||
assert.equal(converted.thumbnail, "static/thumbnail.png");
|
||||
assert.equal(converted.merged_components[0].metadata.created_at, "keep nested");
|
||||
assert.equal(converted.layouts[0].custom_key, "keep me");
|
||||
assert.equal(converted.layouts[0].components[0].elements[0].custom_image_key, true);
|
||||
assert.equal(
|
||||
converted.layouts[0].components[0].elements[0].data,
|
||||
"/static/images/replaceable_template_image.png",
|
||||
);
|
||||
assert.equal(
|
||||
converted.layouts[0].components[0].elements[1].data,
|
||||
"/static/icons/placeholder.svg",
|
||||
);
|
||||
assert.equal(
|
||||
converted.layouts[0].components[0].elements[2].data,
|
||||
"static/decoration.png",
|
||||
);
|
||||
assert.deepEqual(converted.fonts, {
|
||||
Montserrat: "https://fonts.example/montserrat.css",
|
||||
});
|
||||
assert.equal(
|
||||
await readFile(path.join(staticDirectory, "decoration.png"), "utf8"),
|
||||
"decorative image bytes",
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(path.join(staticDirectory, "thumbnail.png"), "utf8"),
|
||||
"preview bytes",
|
||||
);
|
||||
await assert.rejects(readFile(path.join(staticDirectory, "stale.png")), /ENOENT/);
|
||||
assert.equal(result.assetCount, 2);
|
||||
});
|
||||
|
||||
test("fails before writing output when a retained asset is missing", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "template-converter-missing-"));
|
||||
const input = path.join(root, "source.json");
|
||||
const output = path.join(root, "output", "template.json");
|
||||
await writeFile(
|
||||
input,
|
||||
JSON.stringify({
|
||||
id: "missing",
|
||||
name: "Missing",
|
||||
layouts: [
|
||||
{
|
||||
components: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
type: "image",
|
||||
data: "/app_data/no.png",
|
||||
decorative: true,
|
||||
is_icon: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
merged_components: [],
|
||||
assets: { fonts: {} },
|
||||
}),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
convertTemplate({ input, output, appData: path.join(root, "app_data") }),
|
||||
/Referenced asset does not exist/,
|
||||
);
|
||||
await assert.rejects(readFile(output), /ENOENT/);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
____ __
|
||||
/ __ \________ ________ ____ / /_____ ____
|
||||
/ /_/ / ___/ _ \/ ___/ _ \/ __ \/ __/ __ \/ __ \
|
||||
/ ____/ / / __(__ ) __/ / / / /_/ /_/ / / / /
|
||||
/_/ /_/ \___/____/\___/_/ /_/\__/\____/_/ /_/
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Docker / startup terminal banner for Presenton.
|
||||
* Renders a compact brand logo + startup status.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
const BOLD = "\x1b[1m";
|
||||
const DIM = "\x1b[2m";
|
||||
|
||||
function fgRgb(r, g, b, text) {
|
||||
return `\x1b[38;2;${r};${g};${b}m${text}${RESET}`;
|
||||
}
|
||||
|
||||
function brand(text) {
|
||||
return BOLD + fgRgb(138, 99, 255, text);
|
||||
}
|
||||
|
||||
function accent(text) {
|
||||
return fgRgb(184, 176, 255, text);
|
||||
}
|
||||
|
||||
function muted(text) {
|
||||
return DIM + fgRgb(160, 150, 210, text);
|
||||
}
|
||||
|
||||
function styleAsciiArt(rawAscii) {
|
||||
const lines = rawAscii.replace(/\r/g, "").split("\n");
|
||||
const palette = [
|
||||
[153, 108, 255],
|
||||
[145, 103, 250],
|
||||
[136, 98, 245],
|
||||
[127, 92, 239],
|
||||
[118, 86, 233],
|
||||
];
|
||||
|
||||
return lines
|
||||
.map((line, lineIdx) => {
|
||||
const [r, g, b] = palette[lineIdx % palette.length];
|
||||
return line.replace(/[^\s]/g, (ch) => fgRgb(r, g, b, ch));
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function loadAsciiBanner() {
|
||||
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const asciiPath = path.join(thisDir, "presenton-ascii.txt");
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(asciiPath, "utf8").trimEnd();
|
||||
if (!raw) return "";
|
||||
return styleAsciiArt(raw);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function loadPackageVersion() {
|
||||
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageJsonPath = path.join(thisDir, "..", "package.json");
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||
return typeof pkg.version === "string" ? pkg.version : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Visible width (strips SGR sequences). */
|
||||
function visLen(s) {
|
||||
return s.replace(/\x1b\[[0-9;:]*m/g, "").length;
|
||||
}
|
||||
|
||||
/** Pad styled fragment to fixed visible width. */
|
||||
function padVis(styled, width) {
|
||||
return styled + " ".repeat(Math.max(0, width - visLen(styled)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [opts]
|
||||
* @param {"development" | "production"} [opts.mode]
|
||||
* @param {number} [opts.nextPort]
|
||||
* @param {number} [opts.fastapiPort]
|
||||
* @param {string} [opts.hostHttpPort] — host-published HTTP port (docker -p HOST:80). Default from env or "5001".
|
||||
*/
|
||||
export function printPresentonStartupBanner(opts = {}) {
|
||||
const mode = opts.mode === "development" ? "development" : "production";
|
||||
const nextPort = opts.nextPort ?? 3000;
|
||||
const fastapiPort = opts.fastapiPort ?? 8000;
|
||||
const version = opts.version ?? loadPackageVersion();
|
||||
const hostHttpPort =
|
||||
opts.hostHttpPort ??
|
||||
process.env.PRESENTON_HTTP_HOST_PORT ??
|
||||
process.env.PRESENTON_HOST_HTTP_PORT ??
|
||||
process.env.PRESENTON_PUBLIC_PORT ??
|
||||
"5001";
|
||||
|
||||
const nextUrl = `http://127.0.0.1:${nextPort}`;
|
||||
const apiUrl = `http://127.0.0.1:${fastapiPort}`;
|
||||
const publicUrl =
|
||||
String(hostHttpPort) === "80"
|
||||
? "http://127.0.0.1"
|
||||
: `http://127.0.0.1:${hostHttpPort}`;
|
||||
|
||||
const iconBlock = loadAsciiBanner();
|
||||
|
||||
const title = [
|
||||
"",
|
||||
BOLD + fgRgb(138, 99, 255, " Open Source AI Presentation Generator"),
|
||||
...(mode === "development"
|
||||
? [
|
||||
" " +
|
||||
accent("Love the Project? ") +
|
||||
brand("Star us on github: ") +
|
||||
BOLD +
|
||||
fgRgb(224, 218, 255, "https://github.com/presenton/presenton"),
|
||||
]
|
||||
: []),
|
||||
muted(" ─────────────────────────────────────────────────────────"),
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const W = 68;
|
||||
const pipe = (inner) => brand(" ║") + inner + brand("║");
|
||||
|
||||
const boxTop = brand(
|
||||
" ╔════════════════════════════════════════════════════════════════════╗",
|
||||
);
|
||||
const boxDivider = brand(
|
||||
" ╠════════════════════════════════════════════════════════════════════╣",
|
||||
);
|
||||
const boxBottom = brand(
|
||||
" ╚════════════════════════════════════════════════════════════════════╝",
|
||||
);
|
||||
|
||||
const summaryLines =
|
||||
mode === "development"
|
||||
? [
|
||||
pipe(padVis(" " + BOLD + "Routing summary" + RESET, W)),
|
||||
boxDivider,
|
||||
pipe(padVis(" " + muted("Mode: ") + mode, W)),
|
||||
...(version
|
||||
? [pipe(padVis(" " + muted("Version: ") + version, W))]
|
||||
: []),
|
||||
pipe(padVis(" " + accent("/ ") + muted("→ Next.js"), W)),
|
||||
pipe(padVis(" " + accent("/api/v1/ ") + muted("→ FastAPI"), W)),
|
||||
pipe(padVis(" " + muted("Next.js docker URL: ") + nextUrl, W)),
|
||||
pipe(padVis(" " + muted("FastAPI docker URL: ") + apiUrl, W)),
|
||||
pipe(
|
||||
padVis(
|
||||
" " +
|
||||
muted("Public URL (Ctrl+Click to open): ") +
|
||||
BOLD +
|
||||
fgRgb(255, 255, 255, publicUrl),
|
||||
W,
|
||||
),
|
||||
),
|
||||
]
|
||||
: [
|
||||
pipe(padVis(" " + BOLD + "Application URL" + RESET, W)),
|
||||
boxDivider,
|
||||
pipe(padVis(" " + muted("Mode: ") + mode, W)),
|
||||
...(version
|
||||
? [pipe(padVis(" " + muted("Version: ") + version, W))]
|
||||
: []),
|
||||
pipe(
|
||||
padVis(
|
||||
" " +
|
||||
muted("Open Presenton: ") +
|
||||
BOLD +
|
||||
fgRgb(255, 255, 255, publicUrl),
|
||||
W,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
const summary = [
|
||||
boxTop,
|
||||
...summaryLines,
|
||||
boxBottom,
|
||||
"",
|
||||
" " + muted("Made with ❤️ by the Presenton team"),
|
||||
].join("\n");
|
||||
|
||||
const bannerHeader = iconBlock ? `${iconBlock}\n` : "";
|
||||
console.log("\n" + bannerHeader + title + summary);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
FASTAPI_DIR = REPO_ROOT / "servers" / "fastapi"
|
||||
NEXT_DIR = REPO_ROOT / "servers" / "nextjs"
|
||||
NOTICE_PATH = REPO_ROOT / "NOTICE"
|
||||
|
||||
PY_LICENSE_CANDIDATES = [
|
||||
"LICENSE",
|
||||
"LICENSE.txt",
|
||||
"LICENSE.md",
|
||||
"LICENCE",
|
||||
"COPYING",
|
||||
"COPYING.txt",
|
||||
"NOTICE",
|
||||
"NOTICE.txt",
|
||||
]
|
||||
|
||||
NODE_LICENSE_CANDIDATES = [
|
||||
"LICENSE",
|
||||
"LICENSE.txt",
|
||||
"LICENSE.md",
|
||||
"LICENCE",
|
||||
"LICENCE.txt",
|
||||
"COPYING",
|
||||
"COPYING.txt",
|
||||
"NOTICE",
|
||||
"NOTICE.txt",
|
||||
]
|
||||
|
||||
|
||||
def read_text_safe(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def parse_rfc822_metadata(text: str) -> Dict[str, str]:
|
||||
data: Dict[str, str] = {}
|
||||
key: Optional[str] = None
|
||||
for raw_line in text.splitlines():
|
||||
if not raw_line:
|
||||
key = None
|
||||
continue
|
||||
if raw_line[0] in " \t" and key:
|
||||
data[key] += "\n" + raw_line.strip()
|
||||
continue
|
||||
if ":" in raw_line:
|
||||
k, v = raw_line.split(":", 1)
|
||||
key = k.strip()
|
||||
data[key] = v.strip()
|
||||
return data
|
||||
|
||||
|
||||
def find_python_site_packages(venv_dir: Path) -> Optional[Path]:
|
||||
# Linux/mac
|
||||
lib_dir = venv_dir / "lib"
|
||||
if lib_dir.exists():
|
||||
for child in lib_dir.iterdir():
|
||||
if child.is_dir() and child.name.startswith("python"):
|
||||
sp = child / "site-packages"
|
||||
if sp.exists():
|
||||
return sp
|
||||
# Windows
|
||||
sp = venv_dir / "Lib" / "site-packages"
|
||||
if sp.exists():
|
||||
return sp
|
||||
return None
|
||||
|
||||
|
||||
def detect_python_venv() -> Optional[Path]:
|
||||
env_path = os.environ.get("NOTICE_PYTHON_VENV")
|
||||
if env_path:
|
||||
v = Path(env_path)
|
||||
if v.exists():
|
||||
return v
|
||||
default = FASTAPI_DIR / ".venv"
|
||||
if default.exists():
|
||||
return default
|
||||
active = os.environ.get("VIRTUAL_ENV")
|
||||
if active and FASTAPI_DIR.as_posix() in Path(active).as_posix():
|
||||
return Path(active)
|
||||
return None
|
||||
|
||||
|
||||
def scan_python_packages(site_packages_dir: Path) -> List[Dict[str, str]]:
|
||||
entries: List[Dict[str, str]] = []
|
||||
dist_infos = sorted(site_packages_dir.glob("*.dist-info"))
|
||||
for dist in dist_infos:
|
||||
metadata_path = dist / "METADATA"
|
||||
if not metadata_path.exists():
|
||||
continue
|
||||
meta = parse_rfc822_metadata(read_text_safe(metadata_path))
|
||||
name = meta.get("Name", "").strip()
|
||||
version = meta.get("Version", "").strip()
|
||||
license_name = meta.get("License", "").strip()
|
||||
if not name:
|
||||
# Fallback to folder name pattern
|
||||
# e.g., requests-2.32.3.dist-info
|
||||
base = dist.name[:-10]
|
||||
if "-" in base:
|
||||
parts = base.rsplit("-", 1)
|
||||
if len(parts) == 2:
|
||||
name = parts[0]
|
||||
version = version or parts[1]
|
||||
author = meta.get("Author", meta.get("Maintainer", meta.get("Author-email", ""))).strip()
|
||||
|
||||
# License text candidates inside dist-info
|
||||
license_text = ""
|
||||
for cand in PY_LICENSE_CANDIDATES:
|
||||
p = dist / cand
|
||||
if p.exists():
|
||||
license_text = read_text_safe(p)
|
||||
if license_text:
|
||||
break
|
||||
|
||||
# Search via RECORD for license files elsewhere
|
||||
if not license_text:
|
||||
record = dist / "RECORD"
|
||||
if record.exists():
|
||||
for line in read_text_safe(record).splitlines():
|
||||
path_part = line.split(",", 1)[0]
|
||||
lower = path_part.lower()
|
||||
if any(token in lower for token in ["license", "licence", "copying", "notice"]):
|
||||
target = site_packages_dir / path_part
|
||||
if target.exists():
|
||||
license_text = read_text_safe(target)
|
||||
if license_text:
|
||||
break
|
||||
|
||||
# As last resort, embed the License: field content
|
||||
if not license_text and license_name:
|
||||
license_text = f"License field from METADATA:\n{license_name}"
|
||||
|
||||
entries.append({
|
||||
"name": name or dist.name,
|
||||
"version": version,
|
||||
"license": license_name,
|
||||
"author": author,
|
||||
"license_text": license_text,
|
||||
})
|
||||
|
||||
# Sort by name for stability
|
||||
entries.sort(key=lambda e: (e["name"].lower(), e["version"]))
|
||||
return entries
|
||||
|
||||
|
||||
def find_license_file_in_dir(base_dir: Path, depth_limit: int = 2) -> Optional[Path]:
|
||||
# First, try immediate candidates
|
||||
for cand in NODE_LICENSE_CANDIDATES:
|
||||
p = base_dir / cand
|
||||
if p.exists():
|
||||
return p
|
||||
# case-insensitive check
|
||||
for child in base_dir.iterdir():
|
||||
if child.is_file() and child.name.lower() == cand.lower():
|
||||
return child
|
||||
|
||||
# Recursive limited-depth scan excluding nested node_modules
|
||||
def walk(dir_path: Path, depth: int) -> Optional[Path]:
|
||||
if depth > depth_limit:
|
||||
return None
|
||||
try:
|
||||
it = list(dir_path.iterdir())
|
||||
except Exception:
|
||||
return None
|
||||
for child in it:
|
||||
name_lower = child.name.lower()
|
||||
if child.is_dir():
|
||||
if child.name == "node_modules" or child.name.startswith('.'):
|
||||
continue
|
||||
found = walk(child, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
else:
|
||||
if any(tok in name_lower for tok in ["license", "licence", "copying", "notice"]):
|
||||
return child
|
||||
return None
|
||||
|
||||
return walk(base_dir, 0)
|
||||
|
||||
|
||||
def scan_node_modules(node_modules_dir: Path) -> List[Dict[str, str]]:
|
||||
entries: List[Dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def visit_pkg(pkg_dir: Path):
|
||||
pkg_json = pkg_dir / "package.json"
|
||||
if not pkg_json.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(read_text_safe(pkg_json) or "{}")
|
||||
except Exception:
|
||||
return
|
||||
name = data.get("name") or pkg_dir.name
|
||||
version = str(data.get("version") or "")
|
||||
key = f"{name}@{version}"
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
|
||||
license_name = ""
|
||||
lic_field = data.get("license")
|
||||
if isinstance(lic_field, str):
|
||||
license_name = lic_field
|
||||
elif isinstance(lic_field, dict):
|
||||
license_name = lic_field.get("type", "")
|
||||
elif isinstance(data.get("licenses"), list):
|
||||
license_name = ", ".join([str(x.get("type", "")) for x in data["licenses"] if isinstance(x, dict)])
|
||||
|
||||
author = ""
|
||||
a = data.get("author")
|
||||
if isinstance(a, str):
|
||||
author = a
|
||||
elif isinstance(a, dict):
|
||||
author = a.get("name", "")
|
||||
|
||||
license_text = ""
|
||||
lic_file = find_license_file_in_dir(pkg_dir, depth_limit=2)
|
||||
if lic_file:
|
||||
license_text = read_text_safe(lic_file)
|
||||
|
||||
entries.append({
|
||||
"name": name,
|
||||
"version": version,
|
||||
"license": license_name,
|
||||
"author": author,
|
||||
"license_text": license_text,
|
||||
})
|
||||
|
||||
def walk_node_modules(base: Path):
|
||||
if not base.exists():
|
||||
return
|
||||
for entry in base.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if entry.name == ".bin":
|
||||
continue
|
||||
if entry.name.startswith("@"): # scoped packages
|
||||
for scoped in entry.iterdir():
|
||||
if scoped.is_dir():
|
||||
visit_pkg(scoped)
|
||||
# nested node_modules inside the package
|
||||
nested = scoped / "node_modules"
|
||||
walk_node_modules(nested)
|
||||
continue
|
||||
visit_pkg(entry)
|
||||
nested = entry / "node_modules"
|
||||
walk_node_modules(nested)
|
||||
|
||||
walk_node_modules(node_modules_dir)
|
||||
# Sort by package name
|
||||
entries.sort(key=lambda e: (e["name"].lower(), e["version"]))
|
||||
return entries
|
||||
|
||||
|
||||
def format_section(title: str, entries: List[Dict[str, str]]) -> str:
|
||||
header = [
|
||||
"-------------------------------------",
|
||||
title,
|
||||
"-------------------------------------",
|
||||
"",
|
||||
]
|
||||
lines: List[str] = ["\n".join(header)]
|
||||
for e in entries:
|
||||
block = [
|
||||
e.get("name", "").strip(),
|
||||
e.get("version", "").strip(),
|
||||
e.get("license", "").strip(),
|
||||
e.get("author", "").strip(),
|
||||
"",
|
||||
(e.get("license_text", "") or "LICENSE TEXT NOT FOUND").strip(),
|
||||
"",
|
||||
"",
|
||||
]
|
||||
lines.append("\n".join(block))
|
||||
return "".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
# Optional CLI overrides
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Rebuild NOTICE from installed packages")
|
||||
parser.add_argument("--python-venv", dest="python_venv", default=None, help="Path to Python venv to scan")
|
||||
parser.add_argument("--node-modules", dest="node_modules", default=None, help="Path to node_modules to scan")
|
||||
args = parser.parse_args()
|
||||
python_entries: List[Dict[str, str]] = []
|
||||
node_entries: List[Dict[str, str]] = []
|
||||
|
||||
# Python scan
|
||||
venv = Path(args.python_venv) if args.python_venv else detect_python_venv()
|
||||
if venv:
|
||||
sp = find_python_site_packages(venv)
|
||||
if sp and sp.exists():
|
||||
python_entries = scan_python_packages(sp)
|
||||
else:
|
||||
print(f"Warning: site-packages not found under {venv}", file=sys.stderr)
|
||||
else:
|
||||
print("Warning: Python venv not found. Set NOTICE_PYTHON_VENV or create servers/fastapi/.venv", file=sys.stderr)
|
||||
|
||||
# Node scan
|
||||
node_modules_dir = Path(args.node_modules or os.environ.get("NOTICE_NODE_MODULES") or (NEXT_DIR / "node_modules"))
|
||||
if node_modules_dir.exists():
|
||||
node_entries = scan_node_modules(node_modules_dir)
|
||||
else:
|
||||
print(f"Warning: node_modules not found at {node_modules_dir}", file=sys.stderr)
|
||||
|
||||
# Build NOTICE content
|
||||
parts: List[str] = []
|
||||
if python_entries:
|
||||
parts.append(format_section("PYTHON PACKAGES", python_entries))
|
||||
if node_entries:
|
||||
parts.append(format_section("NODE PACKAGES", node_entries))
|
||||
if not parts:
|
||||
print("Error: No sections generated. Ensure .venv and node_modules exist.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
content = "\n".join(parts)
|
||||
NOTICE_PATH.write_text(content, encoding="utf-8")
|
||||
print("NOTICE rebuilt from installed packages")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Download presenton-export release into repo-root `presentation-export/`.
|
||||
* Same release host as Electron (`electron/scripts/sync-export-runtime.cjs`); Docker uses this at build time.
|
||||
*
|
||||
* Version resolution (first match):
|
||||
* 1. EXPORT_RUNTIME_VERSION env
|
||||
* 2. package.json → presentationExportVersion
|
||||
*
|
||||
* CLI: --force re-download even if valid runtime already exists
|
||||
* --check-only verify index.cjs + converter exist and exit 0/1
|
||||
*
|
||||
* On every run (including --check-only), index.cjs is overwritten from index.js
|
||||
* so the CommonJS entrypoint never drifts from the bundled ESM build.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const repoRoot = path.join(__dirname, "..");
|
||||
const targetRoot = path.join(repoRoot, "presentation-export");
|
||||
const targetPyDir = path.join(targetRoot, "py");
|
||||
const targetIndexJs = path.join(targetRoot, "index.js");
|
||||
const targetIndexCjs = path.join(targetRoot, "index.cjs");
|
||||
const packageJsonFile = path.join(repoRoot, "package.json");
|
||||
const cacheDir = path.join(repoRoot, ".cache", "presentation-export");
|
||||
const exportRepoBase =
|
||||
"https://github.com/presenton/presenton-export/releases/download";
|
||||
|
||||
const cliArgs = new Set(process.argv.slice(2));
|
||||
const forceDownload = cliArgs.has("--force");
|
||||
const checkOnly = cliArgs.has("--check-only");
|
||||
|
||||
function resolveLinuxAssetName() {
|
||||
const arch = (
|
||||
process.env.EXPORT_RUNTIME_ARCH ||
|
||||
process.env.TARGETARCH ||
|
||||
process.arch
|
||||
).toLowerCase();
|
||||
|
||||
if (arch === "amd64" || arch === "x64") {
|
||||
return "export-Linux-X64.zip";
|
||||
}
|
||||
if (arch === "arm64" || arch === "aarch64") {
|
||||
return "export-Linux-ARM64.zip";
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Linux export arch: ${arch}`);
|
||||
}
|
||||
|
||||
const linuxAssetName = resolveLinuxAssetName();
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function readPinnedVersion() {
|
||||
if (!fs.existsSync(packageJsonFile)) {
|
||||
throw new Error(
|
||||
`Missing ${path.relative(repoRoot, packageJsonFile)}. Add \"presentationExportVersion\": \"vX.Y.Z\".`
|
||||
);
|
||||
}
|
||||
const raw = JSON.parse(fs.readFileSync(packageJsonFile, "utf8"));
|
||||
const v = (raw.presentationExportVersion || "").trim();
|
||||
if (!v) {
|
||||
throw new Error(
|
||||
`${path.relative(repoRoot, packageJsonFile)} must set \"presentationExportVersion\" (e.g. \"v0.2.0\").`
|
||||
);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
async function getTargetVersion() {
|
||||
const fromEnv = (process.env.EXPORT_RUNTIME_VERSION || "").trim();
|
||||
if (fromEnv) {
|
||||
return fromEnv === "latest" ? await resolveLatestTag() : fromEnv;
|
||||
}
|
||||
const pinned = readPinnedVersion();
|
||||
if (pinned === "latest") {
|
||||
return await resolveLatestTag();
|
||||
}
|
||||
return pinned;
|
||||
}
|
||||
|
||||
function requestJson(url, redirects = 5) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith("https:") ? https : http;
|
||||
const req = client.get(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
"User-Agent": "presenton-presentation-export-sync",
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
||||
if (redirects <= 0) {
|
||||
reject(new Error(`Too many redirects for JSON request: ${url}`));
|
||||
return;
|
||||
}
|
||||
requestJson(res.headers.location, redirects - 1).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new Error(`Failed to fetch ${url}. HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
let payload = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
payload += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(payload));
|
||||
} catch (e) {
|
||||
reject(new Error(`Invalid JSON from ${url}: ${e.message}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveLatestTag() {
|
||||
const apiUrl =
|
||||
"https://api.github.com/repos/presenton/presenton-export/releases/latest";
|
||||
const latest = await requestJson(apiUrl);
|
||||
if (!latest.tag_name) {
|
||||
throw new Error(`Could not resolve latest tag from ${apiUrl}`);
|
||||
}
|
||||
return latest.tag_name;
|
||||
}
|
||||
|
||||
function chmodIfPossible(filePath) {
|
||||
if (process.platform !== "win32") {
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCurrentConverterLink(converterPath) {
|
||||
const currentPath = path.join(targetPyDir, "convert-linux-current");
|
||||
fs.rmSync(currentPath, { force: true });
|
||||
|
||||
if (process.platform === "win32") {
|
||||
fs.copyFileSync(converterPath, currentPath);
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
fs.symlinkSync(path.basename(converterPath), currentPath);
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
function getConverterCandidates(baseDir = targetPyDir) {
|
||||
if (linuxAssetName === "export-Linux-ARM64.zip") {
|
||||
return [
|
||||
path.join(baseDir, "convert-linux-arm64"),
|
||||
path.join(baseDir, "convert"),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
path.join(baseDir, "convert-linux-x64"),
|
||||
path.join(baseDir, "convert-linux-amd64"),
|
||||
path.join(baseDir, "convert"),
|
||||
];
|
||||
}
|
||||
|
||||
function hasRuntimeBundle(baseDir) {
|
||||
const indexPath = path.join(baseDir, "index.js");
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pyCandidates = getConverterCandidates(path.join(baseDir, "py"));
|
||||
const rootCandidates = getConverterCandidates(baseDir);
|
||||
return [...pyCandidates, ...rootCandidates].some((candidate) =>
|
||||
fs.existsSync(candidate)
|
||||
);
|
||||
}
|
||||
|
||||
function moveFileAtomic(src, dest) {
|
||||
try {
|
||||
fs.renameSync(src, dest);
|
||||
} catch {
|
||||
fs.copyFileSync(src, dest);
|
||||
fs.rmSync(src, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuntimeLayout() {
|
||||
if (!fs.existsSync(targetRoot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureDir(targetPyDir);
|
||||
|
||||
const rootCandidates = getConverterCandidates(targetRoot);
|
||||
for (const sourcePath of rootCandidates) {
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const destinationPath = path.join(targetPyDir, path.basename(sourcePath));
|
||||
if (!fs.existsSync(destinationPath)) {
|
||||
moveFileAtomic(sourcePath, destinationPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCommonJsEntrypoint() {
|
||||
if (!fs.existsSync(targetIndexJs)) {
|
||||
return { ok: false, reason: `Missing runtime bundle: ${targetIndexJs}` };
|
||||
}
|
||||
|
||||
try {
|
||||
fs.copyFileSync(targetIndexJs, targetIndexCjs);
|
||||
return { ok: true, entrypointPath: targetIndexCjs };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Failed to create CommonJS entrypoint ${targetIndexCjs}: ${err.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function validateExistingRuntime() {
|
||||
normalizeRuntimeLayout();
|
||||
|
||||
const entrypoint = ensureCommonJsEntrypoint();
|
||||
if (!entrypoint.ok) {
|
||||
return { ok: false, reason: entrypoint.reason };
|
||||
}
|
||||
|
||||
const candidates = getConverterCandidates();
|
||||
const converterPath = candidates.find((c) => fs.existsSync(c));
|
||||
if (!converterPath) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `No Linux converter binary under ${targetPyDir} or ${targetRoot}.`,
|
||||
};
|
||||
}
|
||||
chmodIfPossible(converterPath);
|
||||
const currentConverterPath = ensureCurrentConverterLink(converterPath);
|
||||
return {
|
||||
ok: true,
|
||||
entrypointPath: entrypoint.entrypointPath,
|
||||
converterPath,
|
||||
currentConverterPath,
|
||||
};
|
||||
}
|
||||
|
||||
function downloadFile(url, outputPath, redirects = 5) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith("https:") ? https : http;
|
||||
const req = client.get(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
"User-Agent": "presenton-presentation-export-sync",
|
||||
Accept: "application/octet-stream",
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
||||
if (redirects <= 0) {
|
||||
reject(new Error(`Too many redirects while downloading ${url}`));
|
||||
return;
|
||||
}
|
||||
downloadFile(res.headers.location, outputPath, redirects - 1)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new Error(`Failed to download ${url}. HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
ensureDir(path.dirname(outputPath));
|
||||
const fileStream = fs.createWriteStream(outputPath);
|
||||
res.pipe(fileStream);
|
||||
fileStream.on("finish", () => {
|
||||
fileStream.close(resolve);
|
||||
});
|
||||
fileStream.on("error", reject);
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function unzipArchive(zipPath, destDir) {
|
||||
ensureDir(destDir);
|
||||
execFileSync("unzip", ["-o", zipPath, "-d", destDir], { stdio: "inherit" });
|
||||
}
|
||||
|
||||
function resolveExtractedRoot(extractDir) {
|
||||
if (hasRuntimeBundle(extractDir)) {
|
||||
return extractDir;
|
||||
}
|
||||
|
||||
const children = fs.readdirSync(extractDir, { withFileTypes: true });
|
||||
for (const entry of children) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const candidate = path.join(extractDir, entry.name);
|
||||
if (hasRuntimeBundle(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new Error(`Unable to locate export runtime root under ${extractDir}`);
|
||||
}
|
||||
|
||||
async function downloadAndInstallRuntime() {
|
||||
const tag = await getTargetVersion();
|
||||
const downloadUrl = `${exportRepoBase}/${tag}/${linuxAssetName}`;
|
||||
|
||||
ensureDir(cacheDir);
|
||||
const zipPath = path.join(cacheDir, linuxAssetName);
|
||||
const extractDir = path.join(cacheDir, `extract-${Date.now()}`);
|
||||
|
||||
console.log(`[presentation-export] Downloading ${downloadUrl}`);
|
||||
await downloadFile(downloadUrl, zipPath);
|
||||
|
||||
console.log(`[presentation-export] Extracting ${zipPath}`);
|
||||
unzipArchive(zipPath, extractDir);
|
||||
|
||||
const sourceRoot = resolveExtractedRoot(extractDir);
|
||||
fs.rmSync(targetRoot, { recursive: true, force: true });
|
||||
ensureDir(targetRoot);
|
||||
fs.cpSync(sourceRoot, targetRoot, { recursive: true, force: true });
|
||||
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
|
||||
return { tag, downloadUrl };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const existing = validateExistingRuntime();
|
||||
|
||||
if (checkOnly) {
|
||||
if (!existing.ok) {
|
||||
throw new Error(existing.reason);
|
||||
}
|
||||
console.log("[presentation-export] OK");
|
||||
console.log(` - ${existing.entrypointPath}`);
|
||||
console.log(` - ${existing.converterPath}`);
|
||||
console.log(` - ${existing.currentConverterPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.ok && !forceDownload) {
|
||||
console.log("[presentation-export] Using existing runtime:");
|
||||
console.log(` - ${existing.entrypointPath}`);
|
||||
console.log(` - ${existing.converterPath}`);
|
||||
console.log(` - ${existing.currentConverterPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { tag, downloadUrl } = await downloadAndInstallRuntime();
|
||||
const installed = validateExistingRuntime();
|
||||
if (!installed.ok) {
|
||||
throw new Error(installed.reason);
|
||||
}
|
||||
|
||||
console.log("[presentation-export] Synced successfully:");
|
||||
console.log(` - release: ${tag}`);
|
||||
console.log(` - url: ${downloadUrl}`);
|
||||
console.log(` - ${installed.entrypointPath}`);
|
||||
console.log(` - ${installed.converterPath}`);
|
||||
console.log(` - ${installed.currentConverterPath}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`[presentation-export] ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
const VALID_LLM_PROVIDERS = new Set([
|
||||
"ollama",
|
||||
"openai",
|
||||
"deepseek",
|
||||
"google",
|
||||
"vertex",
|
||||
"azure",
|
||||
"bedrock",
|
||||
"openrouter",
|
||||
"fireworks",
|
||||
"together",
|
||||
"cerebras",
|
||||
"anthropic",
|
||||
"litellm",
|
||||
"lmstudio",
|
||||
"custom",
|
||||
"codex",
|
||||
]);
|
||||
|
||||
const USER_CONFIG_ENV_KEYS = [
|
||||
"LLM",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_MODEL",
|
||||
"DEEPSEEK_API_KEY",
|
||||
"DEEPSEEK_MODEL",
|
||||
"DEEPSEEK_BASE_URL",
|
||||
"GOOGLE_API_KEY",
|
||||
"GOOGLE_MODEL",
|
||||
"VERTEX_API_KEY",
|
||||
"VERTEX_MODEL",
|
||||
"VERTEX_PROJECT",
|
||||
"VERTEX_LOCATION",
|
||||
"VERTEX_BASE_URL",
|
||||
"AZURE_OPENAI_API_KEY",
|
||||
"AZURE_OPENAI_MODEL",
|
||||
"AZURE_OPENAI_ENDPOINT",
|
||||
"AZURE_OPENAI_BASE_URL",
|
||||
"AZURE_OPENAI_API_VERSION",
|
||||
"AZURE_OPENAI_DEPLOYMENT",
|
||||
"BEDROCK_REGION",
|
||||
"BEDROCK_API_KEY",
|
||||
"BEDROCK_AWS_ACCESS_KEY_ID",
|
||||
"BEDROCK_AWS_SECRET_ACCESS_KEY",
|
||||
"BEDROCK_AWS_SESSION_TOKEN",
|
||||
"BEDROCK_PROFILE_NAME",
|
||||
"BEDROCK_MODEL",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENROUTER_MODEL",
|
||||
"OPENROUTER_BASE_URL",
|
||||
"FIREWORKS_API_KEY",
|
||||
"FIREWORKS_MODEL",
|
||||
"FIREWORKS_BASE_URL",
|
||||
"TOGETHER_API_KEY",
|
||||
"TOGETHER_MODEL",
|
||||
"TOGETHER_BASE_URL",
|
||||
"CEREBRAS_API_KEY",
|
||||
"CEREBRAS_MODEL",
|
||||
"CEREBRAS_BASE_URL",
|
||||
"OLLAMA_URL",
|
||||
"OLLAMA_MODEL",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_MODEL",
|
||||
"CUSTOM_LLM_URL",
|
||||
"CUSTOM_LLM_API_KEY",
|
||||
"CUSTOM_MODEL",
|
||||
"LITELLM_BASE_URL",
|
||||
"LITELLM_API_KEY",
|
||||
"LITELLM_MODEL",
|
||||
"LMSTUDIO_BASE_URL",
|
||||
"LMSTUDIO_API_KEY",
|
||||
"LMSTUDIO_MODEL",
|
||||
"PEXELS_API_KEY",
|
||||
"PIXABAY_API_KEY",
|
||||
"IMAGE_PROVIDER",
|
||||
"DISABLE_IMAGE_GENERATION",
|
||||
"DISABLE_THINKING",
|
||||
"EXTENDED_REASONING",
|
||||
"WEB_GROUNDING",
|
||||
"WEB_SEARCH_PROVIDER",
|
||||
"WEB_SEARCH_MAX_RESULTS",
|
||||
"SEARXNG_BASE_URL",
|
||||
"TAVILY_API_KEY",
|
||||
"EXA_API_KEY",
|
||||
"BRAVE_SEARCH_API_KEY",
|
||||
"SERPER_API_KEY",
|
||||
"USE_CUSTOM_URL",
|
||||
"COMFYUI_URL",
|
||||
"COMFYUI_WORKFLOW",
|
||||
"OPEN_WEBUI_IMAGE_URL",
|
||||
"OPEN_WEBUI_IMAGE_API_KEY",
|
||||
"OPENAI_COMPAT_IMAGE_BASE_URL",
|
||||
"OPENAI_COMPAT_IMAGE_API_KEY",
|
||||
"OPENAI_COMPAT_IMAGE_MODEL",
|
||||
"DALL_E_3_QUALITY",
|
||||
"GPT_IMAGE_1_5_QUALITY",
|
||||
"CODEX_MODEL",
|
||||
"CODEX_ACCESS_TOKEN",
|
||||
"CODEX_REFRESH_TOKEN",
|
||||
"CODEX_TOKEN_EXPIRES",
|
||||
"CODEX_ACCOUNT_ID",
|
||||
"CODEX_USERNAME",
|
||||
"CODEX_EMAIL",
|
||||
"CODEX_IS_PRO",
|
||||
"DISABLE_ANONYMOUS_TRACKING",
|
||||
];
|
||||
|
||||
const BOOLEAN_CONFIG_KEYS = new Set([
|
||||
"DISABLE_IMAGE_GENERATION",
|
||||
"DISABLE_THINKING",
|
||||
"EXTENDED_REASONING",
|
||||
"WEB_GROUNDING",
|
||||
"USE_CUSTOM_URL",
|
||||
"CODEX_IS_PRO",
|
||||
]);
|
||||
|
||||
const envValue = (env, key) => {
|
||||
const value = env[key];
|
||||
return value === undefined || value === "" ? undefined : value;
|
||||
};
|
||||
|
||||
const parseBooleanLike = (value) => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (["0", "false", "no", "off"].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const readUserConfigEnv = (env) => {
|
||||
const config = {};
|
||||
for (const key of USER_CONFIG_ENV_KEYS) {
|
||||
const value = envValue(env, key);
|
||||
if (value !== undefined) {
|
||||
config[key] = value;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
||||
const normalizeConfigTypes = (config) => {
|
||||
for (const key of BOOLEAN_CONFIG_KEYS) {
|
||||
const parsedValue = parseBooleanLike(config[key]);
|
||||
if (parsedValue !== undefined) {
|
||||
config[key] = parsedValue;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
||||
const normalizeImageConfig = (config) => {
|
||||
if (config.DISABLE_IMAGE_GENERATION || config.IMAGE_PROVIDER) {
|
||||
return config;
|
||||
}
|
||||
|
||||
if (
|
||||
config.OPENAI_COMPAT_IMAGE_BASE_URL &&
|
||||
config.OPENAI_COMPAT_IMAGE_API_KEY &&
|
||||
config.OPENAI_COMPAT_IMAGE_MODEL
|
||||
) {
|
||||
config.IMAGE_PROVIDER = "openai_compatible";
|
||||
} else if (config.OPEN_WEBUI_IMAGE_URL) {
|
||||
config.IMAGE_PROVIDER = "open_webui";
|
||||
} else if (config.COMFYUI_URL) {
|
||||
config.IMAGE_PROVIDER = "comfyui";
|
||||
} else if (config.PEXELS_API_KEY) {
|
||||
config.IMAGE_PROVIDER = "pexels";
|
||||
} else if (config.PIXABAY_API_KEY) {
|
||||
config.IMAGE_PROVIDER = "pixabay";
|
||||
} else if (config.LLM === "openai" && config.OPENAI_API_KEY) {
|
||||
config.IMAGE_PROVIDER = "gpt-image-1.5";
|
||||
config.GPT_IMAGE_1_5_QUALITY = config.GPT_IMAGE_1_5_QUALITY || "medium";
|
||||
} else if (config.LLM === "google" && config.GOOGLE_API_KEY) {
|
||||
config.IMAGE_PROVIDER = "gemini_flash";
|
||||
} else {
|
||||
config.DISABLE_IMAGE_GENERATION = true;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
const sanitizeExistingConfig = (existingConfig) => {
|
||||
const config = { ...existingConfig };
|
||||
if (config.LLM && !VALID_LLM_PROVIDERS.has(config.LLM)) {
|
||||
delete config.LLM;
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
||||
const buildUserConfigFromEnv = (existingConfig = {}, env = process.env) =>
|
||||
normalizeImageConfig(
|
||||
normalizeConfigTypes({
|
||||
...sanitizeExistingConfig(existingConfig),
|
||||
...readUserConfigEnv(env),
|
||||
})
|
||||
);
|
||||
|
||||
export {
|
||||
buildUserConfigFromEnv,
|
||||
parseBooleanLike,
|
||||
readUserConfigEnv,
|
||||
USER_CONFIG_ENV_KEYS,
|
||||
VALID_LLM_PROVIDERS,
|
||||
};
|
||||
Reference in New Issue
Block a user