chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:41 +08:00
commit bcc7968ff6
9988 changed files with 1016778 additions and 0 deletions
+357
View File
@@ -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/);
});
+361
View File
@@ -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();
}
+162
View File
@@ -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/);
});
+7
View File
@@ -0,0 +1,7 @@
____ __
/ __ \________ ________ ____ / /_____ ____
/ /_/ / ___/ _ \/ ___/ _ \/ __ \/ __/ __ \/ __ \
/ ____/ / / __(__ ) __/ / / / /_/ /_/ / / / /
/_/ /_/ \___/____/\___/_/ /_/\__/\____/_/ /_/
+191
View File
@@ -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);
}
+333
View File
@@ -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()
+379
View File
@@ -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);
});
+213
View File
@@ -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,
};