SJTU-AIA-ppt-skill/scripts/sync-htmlslide.mjs
2022Hao 9150654846 Add initial project scaffold, docs, and assets
Initial import of the guizang-ppt-skill project: add SKILL.md, CONTRIBUTING.md, LICENSE (MIT), populated README.md and README.en.md, references (layouts, themes, prompts, checklist, etc.), templates and screenshot background assets, motion.min.js, and helper scripts (validate-swiss-deck.mjs, sync-htmlslide.mjs). Sets up templates, theme presets, image/screenshot guidance, and tooling to generate, validate, and sync single-file HTML slide decks.
2026-05-24 22:08:51 +08:00

365 lines
12 KiB
JavaScript

#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, writeFileSync, watch } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
const DEFAULT_API_BASE = "http://127.0.0.1:3001/api";
const DEFAULT_APP_BASE = "http://127.0.0.1:5173";
const SIDECAR_NAME = ".htmlslide-sync.json";
const CONFIG_DIR = join(homedir(), ".htmlslide-agent");
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
const parsed = parseArgs(process.argv.slice(2));
const command = parsed.command || "push";
if (command === "login") {
await login(parsed).catch(fail);
} else if (command === "list") {
await listProjects(parsed).catch(fail);
} else if (command === "pull") {
await pullProject(parsed).catch(fail);
} else if (command === "push") {
await pushProject(parsed).catch(fail);
} else {
usage(`Unknown command: ${command}`);
}
async function login(args) {
const config = readConfig();
const apiBase = stripTrailingSlash(args.apiBase || process.env.HTMLSLIDE_API_BASE || config.apiBase || DEFAULT_API_BASE);
const appBase = stripTrailingSlash(args.appBase || process.env.HTMLSLIDE_APP_BASE || config.appBase || DEFAULT_APP_BASE);
const deviceName = args.deviceName || `${process.env.COMPUTERNAME || process.env.HOSTNAME || "Local"} Agent`;
const start = await apiJson(`${apiBase}/agent/oauth/start`, {
method: "POST",
body: { deviceName }
});
const url = `${appBase}${start.verificationUriComplete || `/agent/authorize?code=${encodeURIComponent(start.userCode)}`}`;
console.log("Open this URL in HTMLSlide while logged in:");
console.log(url);
console.log(`Authorization code: ${start.userCode}`);
console.log("Waiting for approval...");
const deadline = Date.now() + start.expiresIn * 1000;
while (Date.now() < deadline) {
await sleep((start.interval || 2) * 1000);
const tokenRes = await apiJson(`${apiBase}/agent/oauth/token`, {
method: "POST",
body: { deviceCode: start.deviceCode },
soft: true
});
if (tokenRes.ok === false) continue;
const nextConfig = {
...config,
apiBase,
appBase,
accessToken: tokenRes.accessToken,
userId: tokenRes.userId,
tokenType: tokenRes.tokenType || "Bearer",
updatedAt: new Date().toISOString()
};
writeConfig(nextConfig);
console.log(`HTMLSlide agent login complete. Bound user: ${tokenRes.userId}`);
return;
}
throw new Error("Authorization timed out. Run login again.");
}
async function listProjects(args) {
const client = authedClient(args);
const projects = await client.get("/projects");
projects.forEach((project) => {
console.log(`${project.id}\t${project.kind || "slides"}\t${project.title}\t${project.updatedAt || ""}`);
});
}
async function pullProject(args) {
const outPath = args.out ? resolve(args.out) : resolve("ppt", "index.html");
const client = authedClient(args);
const project = await resolveProject(client, args);
if (project.kind !== "html" || !project.html) {
throw new Error(`Project "${project.title}" is not an HTML PPT project.`);
}
const outDir = dirname(outPath);
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
writeFileSync(outPath, project.html, "utf8");
writeSidecar(join(outDir, SIDECAR_NAME), {
apiBase: client.apiBase,
projectId: project.id,
title: project.title,
originalFileName: project.originalFileName || "index.html",
pulledAt: new Date().toISOString()
});
console.log(`[htmlslide-sync] pulled "${project.title}" -> ${outPath}`);
}
async function pushProject(args) {
const htmlFile = args.file ? resolve(args.file) : null;
if (!htmlFile) usage("Missing HTML file. Example: node sync-htmlslide.mjs push path/to/ppt/index.html");
if (!existsSync(htmlFile)) throw new Error(`HTML file not found: ${htmlFile}`);
const deckDir = dirname(htmlFile);
const sidecarPath = join(deckDir, SIDECAR_NAME);
let sidecar = readJson(sidecarPath, {});
const client = authedClient({ ...sidecar, ...args });
const watchMode = Boolean(args.watch);
let syncing = false;
let pending = false;
async function syncOnce() {
if (syncing) {
pending = true;
return;
}
syncing = true;
try {
const { html, assets, title } = buildHtmlPayload(htmlFile, args.title || sidecar.title);
const result = await client.post("/agent/projects/html", {
projectId: args.projectId || sidecar.projectId,
title,
html,
assets,
originalFileName: basename(htmlFile),
source: "guizang-ppt-skill 同步",
author: "本地 Agent"
});
sidecar = {
...sidecar,
apiBase: client.apiBase,
projectId: result.project.id,
title: result.project.title,
originalFileName: basename(htmlFile),
updatedAt: result.project.updatedAt
};
writeSidecar(sidecarPath, sidecar);
console.log(`[htmlslide-sync] pushed "${result.project.title}" -> ${result.project.id}`);
} finally {
syncing = false;
if (pending) {
pending = false;
await syncOnce();
}
}
}
await syncOnce();
if (watchMode) {
console.log(`[htmlslide-sync] watching ${deckDir}`);
let timer = null;
watch(deckDir, { recursive: true }, (_event, changed) => {
if (!changed || String(changed).endsWith(SIDECAR_NAME)) return;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
syncOnce().catch((err) => console.error(`[htmlslide-sync] ${err.message}`));
}, 450);
});
}
}
async function resolveProject(client, args) {
if (args.projectId) return client.get(`/projects/${encodeURIComponent(args.projectId)}`);
if (!args.title) throw new Error("Pass --project-id <id> or --title <title>.");
const projects = await client.get("/projects");
const exact = projects.filter((project) => project.title === args.title);
if (exact.length === 1) return exact[0];
if (exact.length > 1) throw new Error(`Multiple projects named "${args.title}". Use --project-id.`);
const fuzzy = projects.filter((project) => project.title.includes(args.title));
if (fuzzy.length === 1) return fuzzy[0];
if (fuzzy.length > 1) {
throw new Error(`Multiple projects match "${args.title}": ${fuzzy.map((project) => project.title).join(", ")}. Use --project-id.`);
}
throw new Error(`Project not found: ${args.title}`);
}
function authedClient(args) {
const config = readConfig();
const apiBase = stripTrailingSlash(args.apiBase || process.env.HTMLSLIDE_API_BASE || config.apiBase || DEFAULT_API_BASE);
const accessToken = args.token || process.env.HTMLSLIDE_AGENT_TOKEN || config.accessToken;
if (!accessToken) {
throw new Error("Not logged in. Run: node scripts/sync-htmlslide.mjs login");
}
return {
apiBase,
get: (path) => apiJson(`${apiBase}${path}`, { token: accessToken }),
post: (path, body) => apiJson(`${apiBase}${path}`, { method: "POST", token: accessToken, body })
};
}
function buildHtmlPayload(htmlPath, titleOverride) {
const rawHtml = readFileSync(htmlPath, "utf8");
const assets = {};
const html = inlineLocalAssets(rawHtml, htmlPath, assets);
return {
html,
assets,
title: titleOverride || titleFromHtml(rawHtml) || basename(htmlPath).replace(/\.html?$/i, "")
};
}
function inlineLocalAssets(html, htmlPath, assets) {
const root = dirname(htmlPath);
let next = html;
next = next.replace(/\b(src|href)=["']([^"']+)["']/gi, (match, attr, ref) => {
const dataUrl = localRefToDataUrl(ref, root, assets);
return dataUrl ? `${attr}="${dataUrl}"` : match;
});
next = next.replace(/url\((["']?)([^"')]+)\1\)/gi, (match, _quote, ref) => {
const dataUrl = localRefToDataUrl(ref, root, assets);
return dataUrl ? `url("${dataUrl}")` : match;
});
return next;
}
function localRefToDataUrl(ref, root, assets) {
if (!ref || ref.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(ref) || ref.startsWith("//")) return "";
const cleanRef = decodeURIComponent(ref.split("#")[0].split("?")[0]);
const filePath = resolve(root, cleanRef);
if (!isInside(root, filePath) || !existsSync(filePath)) return "";
const ext = extname(filePath).toLowerCase();
if (!isEmbeddable(ext)) return "";
const rel = normalize(relative(root, filePath)).split(sep).join("/");
const mime = mimeFromExt(ext);
const base64 = readFileSync(filePath).toString("base64");
const dataUrl = `data:${mime};base64,${base64}`;
assets[rel] = dataUrl;
return dataUrl;
}
async function apiJson(url, options = {}) {
const res = await fetch(url, {
method: options.method || "GET",
headers: {
"content-type": "application/json",
...(options.token ? { authorization: `Bearer ${options.token}` } : {})
},
body: options.body ? JSON.stringify(options.body) : undefined
});
const text = await res.text();
const body = text ? JSON.parse(text) : {};
if (!res.ok) {
if (options.soft) return { ok: false, status: res.status, ...body };
throw new Error(body.error || `HTMLSlide API ${res.status}`);
}
return body;
}
function readConfig() {
return readJson(CONFIG_PATH, {});
}
function writeConfig(config) {
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
}
function readJson(path, fallback) {
if (!existsSync(path)) return fallback;
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch {
return fallback;
}
}
function writeSidecar(path, data) {
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
}
function isInside(root, filePath) {
const rel = relative(root, filePath);
return rel && !rel.startsWith("..") && !isAbsolute(rel);
}
function isEmbeddable(ext) {
return [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".avif", ".ico", ".woff", ".woff2", ".ttf", ".otf"].includes(ext);
}
function mimeFromExt(ext) {
return {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".svg": "image/svg+xml",
".avif": "image/avif",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".otf": "font/otf"
}[ext] || "application/octet-stream";
}
function titleFromHtml(html) {
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
return match ? decodeHtml(match[1].replace(/\s+/g, " ").trim()) : "";
}
function decodeHtml(value) {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function stripTrailingSlash(value) {
return String(value || "").replace(/\/+$/, "");
}
function sleep(ms) {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}
function parseArgs(argv) {
const result = {};
const commands = new Set(["login", "list", "pull", "push"]);
if (commands.has(argv[0])) result.command = argv.shift();
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--watch") result.watch = true;
else if (arg === "--api-base") result.apiBase = argv[++i];
else if (arg === "--app-base") result.appBase = argv[++i];
else if (arg === "--device-name") result.deviceName = argv[++i];
else if (arg === "--token") result.token = argv[++i];
else if (arg === "--title") result.title = argv[++i];
else if (arg === "--project-id") result.projectId = argv[++i];
else if (arg === "--out") result.out = argv[++i];
else if (!result.file && result.command !== "list" && result.command !== "login") result.file = arg;
else usage(`Unknown argument: ${arg}`);
}
if (!result.command && result.file) result.command = "push";
return result;
}
function usage(message) {
if (message) console.error(message);
console.error(`Usage:
node scripts/sync-htmlslide.mjs login [--api-base http://127.0.0.1:3001/api] [--app-base http://127.0.0.1:5173]
node scripts/sync-htmlslide.mjs list
node scripts/sync-htmlslide.mjs pull --title "Project title" --out path/to/ppt/index.html
node scripts/sync-htmlslide.mjs pull --project-id <id> --out path/to/ppt/index.html
node scripts/sync-htmlslide.mjs push path/to/ppt/index.html [--title "Project title"] [--project-id <id>] [--watch]`);
process.exit(2);
}
function fail(err) {
console.error(`[htmlslide-sync] ${err.message}`);
process.exit(1);
}