Files
soft/tools/screenshotCatalog.ts
T
2026-08-25 14:55:38 +03:00

458 lines
15 KiB
JavaScript

#!/usr/bin/env node
import { spawn } from "node:child_process";
import { access, copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { readCatalogIndex } from "../scripts/catalogIndex.js";
type ProgramFile = {
path?: unknown;
platform?: unknown;
};
type ProgramVersion = {
status?: unknown;
files?: ProgramFile[];
};
type ProgramIndex = {
type?: unknown;
name?: unknown;
screenshots?: unknown;
versions?: ProgramVersion[];
};
type Program = {
archive: string;
directory: string;
indexPath: string;
name: string;
slug: string;
};
type RemoteResult = {
success?: boolean;
reason?: string;
title?: string;
error?: string;
selected?: string;
installer?: boolean;
width?: number;
height?: number;
windows?: Array<{ title?: string }>;
};
type Failure = {
name: string;
path: string;
reason: string;
details: string;
};
type Options = {
catalogDirectory: string;
host: string;
importDirectory?: string;
limit?: number;
program?: string;
replace: boolean;
waitSeconds: number;
write: boolean;
};
const remoteRoot = "C:\\codex\\catalog-screenshots";
const remoteScpRoot = "C:/codex/catalog-screenshots";
const programsWithoutStandaloneGui = new Set([
"fardes",
"farobex",
"filescomparer",
"hex2vkp",
"midletsigner",
"openall",
"sifs",
"slfc",
"txt2wmlc",
"vmo2wav",
"vsofs",
"wav-amr-converter",
"zeesiemens",
]);
const errorTitle = /error|exception|failed|failure|cannot|can't|not found|access violation|achtung|ошибка|предупреждение/i;
const installerTitle = /setup|installer|installshield|installation|self-extract|winrar|установк|мастер установки|самораспаковывающ/i;
const initialKeys = new Map([
[
"brew-graphicpatch-creator",
"ENTER,TEXT=C:\\codex\\catalog-screenshots\\work\\brew-graphicpatch-creator\\brewrc.exe,ENTER",
],
["brew-apploader", "CLOSE-SMALL,CLOSE-SMALL"],
["efs-explorer-lite", "ENTER"],
["flash-imager", "ENTER"],
["flex-table-edit", "ENTER"],
["gagin", "CLOSE-SMALL,CLOSE-SMALL"],
["freia", "SELECT-TITLE=Engine"],
["initmap", "CLOSE-LARGEST"],
["s65-key-definer", "ENTER"],
["siemens-screenshot", "ENTER"],
]);
function usage(): void {
console.log(`Usage: pnpm screenshots [options] [catalog-directory]
Capture one screenshot for each Windows program.
--host HOST Windows SSH host (default: win)
--import DIR import reviewed SLUG.png files without connecting to Windows
--program SLUG process one program directory
--limit COUNT stop after COUNT programs
--replace recapture only existing img/SLUG.png screenshots
--wait SECONDS wait for a program window (default: 8)
--write save successful screenshots and update index.md
--help show this help message`);
}
function parseArguments(args: string[]): Options {
const targets: string[] = [];
let host = "win";
let importDirectory: string | undefined;
let limit: number | undefined;
let program: string | undefined;
let replace = false;
let waitSeconds = 8;
let write = false;
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (argument === "--host") {
host = args[++index] ?? "";
} else if (argument === "--import") {
importDirectory = path.resolve(args[++index] ?? "");
} else if (argument === "--limit") {
limit = Number(args[++index]);
} else if (argument === "--program") {
program = args[++index];
} else if (argument === "--replace") {
replace = true;
} else if (argument === "--wait") {
waitSeconds = Number(args[++index]);
} else if (argument === "--write") {
write = true;
} else if (argument === "--help" || argument === "-h") {
usage();
process.exit(0);
} else if (argument.startsWith("-")) {
throw new Error(`Unknown option: ${argument}`);
} else {
targets.push(argument);
}
}
if (targets.length > 1) {
throw new Error("Expected at most one catalog directory");
}
if (host.length === 0) {
throw new Error("--host must not be empty");
}
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
throw new Error("--limit must be a positive integer");
}
if (!Number.isInteger(waitSeconds) || waitSeconds < 1 || waitSeconds > 60) {
throw new Error("--wait must be an integer between 1 and 60");
}
return {
catalogDirectory: path.resolve(targets[0] ?? "catalog"),
host,
importDirectory,
limit,
program,
replace,
waitSeconds,
write,
};
}
async function run(command: string, args: string[], allowFailure = false): Promise<string> {
return await new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
child.on("error", reject);
child.on("close", (code) => {
const output = Buffer.concat(stdout).toString("utf8");
const error = Buffer.concat(stderr).toString("utf8").trim();
if (code === 0 || allowFailure) {
resolve(output);
} else {
reject(new Error(`${command} exited with code ${code}${error.length > 0 ? `: ${error}` : ""}`));
}
});
});
}
async function findIndexes(directory: string, result: string[]): Promise<void> {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isFile() && entry.name === "index.md") {
result.push(entryPath);
} else if (entry.isDirectory() && entry.name !== "files" && entry.name !== "img") {
await findIndexes(entryPath, result);
}
}
}
function selectArchive(data: ProgramIndex, indexPath: string): string | undefined {
const versions = data.versions ?? [];
const orderedVersions = [
...versions.filter((entry) => entry.status === "current"),
...versions.filter((entry) => entry.status !== "current"),
];
for (const version of orderedVersions) {
const files = version.files ?? [];
const file =
files.find((entry) => entry.platform === "win32" || entry.platform === "win64") ??
files.find((entry) => entry.platform === "java");
if (typeof file?.path === "string") {
return path.resolve(path.dirname(indexPath), file.path);
}
}
return undefined;
}
async function collectPrograms(options: Options): Promise<{ programs: Program[]; failures: Failure[] }> {
const indexes: string[] = [];
const programs: Program[] = [];
const failures: Failure[] = [];
await findIndexes(options.catalogDirectory, indexes);
for (const indexPath of indexes.sort()) {
const { metadata } = await readCatalogIndex<ProgramIndex>(indexPath);
if (metadata.type !== "program") {
continue;
}
const directory = path.dirname(indexPath);
const slug = path.basename(directory);
if (programsWithoutStandaloneGui.has(slug)) {
continue;
}
if (options.program !== undefined && slug !== options.program) {
continue;
}
if (typeof metadata.name !== "string" || metadata.name.length === 0) {
throw new Error(`${indexPath}: name is missing`);
}
const expectedScreenshot = `img/${slug}.png`;
const screenshots = Array.isArray(metadata.screenshots) ? metadata.screenshots : [];
if (options.replace) {
if (!screenshots.includes(expectedScreenshot)) {
continue;
}
} else if (screenshots.length > 0) {
continue;
}
const archive = selectArchive(metadata, indexPath);
if (archive === undefined) {
failures.push({
name: metadata.name,
path: path.relative(process.cwd(), indexPath),
reason: "No desktop file",
details: "The current version has no file with platform: win32, win64, or java",
});
continue;
}
programs.push({ archive, directory, indexPath, name: metadata.name, slug });
}
if (options.limit !== undefined) {
programs.splice(options.limit);
}
return { programs, failures };
}
function parseRemoteResult(output: string): RemoteResult {
const start = output.indexOf("{");
const end = output.lastIndexOf("}");
if (start === -1 || end === -1) {
throw new Error(`Windows worker returned no JSON: ${output.trim()}`);
}
return JSON.parse(output.slice(start, end + 1)) as RemoteResult;
}
function failureReason(result: RemoteResult, program: Program): { reason: string; details: string } | undefined {
if (result.success !== true) {
const reasons: Record<string, string> = {
"no-executable": "No desktop executable",
"installer-only": "Installer only",
"process-exited": "Process exited without a window",
"no-window": "No window appeared",
error: "Launch error",
};
return {
reason: reasons[result.reason ?? ""] ?? "Unknown error",
details: result.error ?? result.reason ?? "",
};
}
if (result.installer === true) {
return { reason: "Installer only", details: result.selected ?? "" };
}
const titles = [result.title, ...(result.windows ?? []).map((window) => window.title)].filter(
(title): title is string => typeof title === "string",
);
const failedTitle = titles.find((title) => errorTitle.test(title));
if (failedTitle !== undefined) {
return { reason: "Error window", details: failedTitle };
}
const setupTitle = program.slug === "java-midlet-installer" ? undefined : titles.find((title) => installerTitle.test(title));
if (setupTitle !== undefined) {
return { reason: "Installer opened", details: setupTitle };
}
return undefined;
}
async function addScreenshot(program: Program, screenshotPath: string, replace: boolean): Promise<void> {
const imageDirectory = path.join(program.directory, "img");
const imagePath = path.join(imageDirectory, `${program.slug}.png`);
await mkdir(imageDirectory, { recursive: true });
await run("scp", [`${screenshotPath}`, imagePath]);
if (replace) {
return;
}
const source = await readFile(program.indexPath, "utf8");
const replacement = `screenshots:\n - img/${program.slug}.png`;
if (!source.includes("screenshots: []")) {
throw new Error(`${program.indexPath}: expected screenshots: []`);
}
await writeFile(program.indexPath, source.replace("screenshots: []", replacement), "utf8");
}
async function importScreenshots(programs: Program[], directory: string): Promise<number> {
let imported = 0;
for (const program of programs) {
const sourcePath = path.join(directory, `${program.slug}.png`);
try {
await access(sourcePath);
} catch {
continue;
}
const imageDirectory = path.join(program.directory, "img");
const imagePath = path.join(imageDirectory, `${program.slug}.png`);
await mkdir(imageDirectory, { recursive: true });
await copyFile(sourcePath, imagePath);
const source = await readFile(program.indexPath, "utf8");
if (!source.includes("screenshots: []")) {
throw new Error(`${program.indexPath}: expected screenshots: []`);
}
await writeFile(
program.indexPath,
source.replace("screenshots: []", `screenshots:\n - img/${program.slug}.png`),
"utf8",
);
imported += 1;
console.log(`Imported: ${program.name}`);
}
return imported;
}
function escapeTable(value: string): string {
return value.replaceAll("|", "\\|").replaceAll("\n", " ").trim();
}
async function main(): Promise<void> {
const options = parseArguments(process.argv.slice(2));
const temporaryDirectory = "/tmp/codex/catalog-screenshots";
await mkdir(temporaryDirectory, { recursive: true });
const { programs, failures } = await collectPrograms(options);
if (options.importDirectory !== undefined) {
const imported = await importScreenshots(programs, options.importDirectory);
console.log(`Done. Imported: ${imported}`);
return;
}
let captured = 0;
const tools = ["screenshotApp.ps1", "runScreenshotTask.ps1", "screenshotArchive.ps1"];
await run("ssh", [
options.host,
`powershell -NoProfile -Command "New-Item -ItemType Directory -Force -Path '${remoteRoot}\\input','${remoteRoot}\\results' | Out-Null"`,
]);
await run("scp", tools.map((file) => path.resolve("tools", file)).concat(`${options.host}:${remoteScpRoot}/`));
console.log(`Programs to process: ${programs.length}`);
for (const [index, program] of programs.entries()) {
const prefix = `[${index + 1}/${programs.length}] ${program.name}`;
const remoteArchive = `${remoteRoot}\\input\\${program.slug}.zip`;
try {
await run("scp", [program.archive, `${options.host}:${remoteScpRoot}/input/${program.slug}.zip`]);
const remoteCommand = [
"powershell",
"-NoProfile",
"-ExecutionPolicy Bypass",
`-File ${remoteRoot}\\screenshotArchive.ps1`,
`-Archive ${remoteArchive}`,
`-Slug ${program.slug}`,
`-RootDirectory ${remoteRoot}`,
`-WaitSeconds ${options.waitSeconds}`,
...(initialKeys.has(program.slug) ? [`-InitialKeys ${initialKeys.get(program.slug)}`] : []),
].join(" ");
const output = await run("ssh", [options.host, remoteCommand], true);
const result = parseRemoteResult(output);
const failure = failureReason(result, program);
if (failure !== undefined) {
failures.push({
name: program.name,
path: path.relative(process.cwd(), program.indexPath),
...failure,
});
console.log(`${prefix}: failed (${failure.reason})`);
continue;
}
if (options.write) {
await addScreenshot(program, `${options.host}:${remoteScpRoot}/results/${program.slug}.png`, options.replace);
}
captured += 1;
console.log(`${prefix}: ${options.write ? "saved" : "captured"} (${result.title ?? result.selected ?? "window"})`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push({
name: program.name,
path: path.relative(process.cwd(), program.indexPath),
reason: "Tool error",
details: message,
});
console.log(`${prefix}: failed (${message})`);
}
}
const table = [
"| Program | Index | Reason | Details |",
"|---|---|---|---|",
...failures.map(
(failure) =>
`| ${escapeTable(failure.name)} | ${escapeTable(failure.path)} | ${escapeTable(failure.reason)} | ${escapeTable(failure.details)} |`,
),
"",
].join("\n");
const reportPath = path.join(temporaryDirectory, "failures.md");
await writeFile(reportPath, table, "utf8");
console.log(`Done. Captured: ${captured}; failures: ${failures.length}`);
console.log(`Failure report: ${reportPath}`);
}
try {
await main();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: ${message}`);
process.exitCode = 1;
}