#!/usr/bin/env node /** * Upload overview screenshots to a Gitea issue and update its attachment index. * The manifest is produced by the preview step and contains local PNG paths and hashes. */ import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { ScriptError, expectOk, fetchIssue, giteaRequest, giteaUpload, main, parseFlags, parseIndex, parseRepo, preflight, resolveLogin, } from './lib.js'; import { upsertLineInSection } from './issue-body.js'; const INDEX_LINE = '圖解版總覽附件:'; main(async () => { const flags = parseFlags(process.argv.slice(2), { required: ['repo', 'index', 'manifest'], optional: ['host'], booleans: ['dry-run'], }); const repo = parseRepo(flags.repo); const index = parseIndex(flags.index); const manifest = JSON.parse(readFileSync(resolve(flags.manifest), 'utf8')); const screenshots = manifest.screenshots; if (!Array.isArray(screenshots) || screenshots.length === 0) { throw new ScriptError('MANIFEST_INVALID', 'manifest 必須包含至少一張 screenshots'); } for (const item of screenshots) { if (!item.role || !item.path || !item.fileName || !item.sha256 || !existsSync(resolve(item.path))) { throw new ScriptError('MANIFEST_INVALID', '每張截圖都必須有 role、path、fileName、sha256 且檔案存在'); } } const login = resolveLogin({ host: flags.host }); const issuePath = `/repos/${repo}/issues/${index}`; const assetsPath = `${issuePath}/assets`; if (flags['dry-run']) { return { dryRun: true, repo, index, requests: [ { method: 'GET', path: issuePath }, { method: 'GET', path: assetsPath }, ...screenshots.map((item) => ({ method: 'POST', path: assetsPath, file: item.path })), ], }; } await preflight(login, repo); const issue = await fetchIssue(login, repo, index); const assets = expectOk(await giteaRequest(login, 'GET', assetsPath), `GET ${assetsPath}`); const existing = Array.isArray(assets) ? assets : []; const indexed = parseIndexedAssets(issue.body ?? ''); const uploaded = []; for (const item of screenshots) { const indexedAsset = indexed.find((asset) => asset.fileName === item.fileName && asset.sha256 === item.sha256); const existingAsset = existing.find((asset) => asset.name === item.fileName && asset.sha256 === item.sha256); const asset = indexedAsset ?? existingAsset; if (asset) { uploaded.push({ ...item, url: asset.url ?? asset.browser_download_url ?? asset.download_url, reused: true }); continue; } const response = expectOk( await giteaUpload(login, `${assetsPath}?name=${encodeURIComponent(item.fileName)}`, resolve(item.path), item.fileName), `POST ${assetsPath}`, ); uploaded.push({ ...item, url: response.browser_download_url ?? response.download_url, reused: false }); } const body = updateIndex(issue.body ?? '', uploaded); const update = body === issue.body ? null : expectOk(await giteaRequest(login, 'PATCH', issuePath, { body: { body } }), `PATCH ${issuePath}`); return { repo, index, uploaded, updated: update !== null, url: update?.html_url ?? issue.html_url }; }); function parseIndexedAssets(body) { const line = body.split('\n').find((row) => row.startsWith(INDEX_LINE)) ?? ''; return [...line.matchAll(/([^=;\s]+)=([^;\s]+)\s+sha256=([a-f0-9]{64})\s+(\S+)/g)].map((match) => ({ role: match[1], fileName: match[2], sha256: match[3], url: match[4], })); } function updateIndex(body, assets) { const lines = assets.map((asset) => `${asset.role}=${asset.fileName} sha256=${asset.sha256} ${asset.url ?? '(網址未回傳)'}`); return upsertLineInSection(body, '總覽', `${INDEX_LINE}${lines.join(';')}`); }