| | |
| | | * - node src/scripts/update-social-icons.ts --json |
| | | */ |
| | | |
| | | import { promises as fs } from 'node:fs'; |
| | | import path from 'node:path'; |
| | | import process from 'node:process'; |
| | | import { promises as fs } from "node:fs"; |
| | | import path from "node:path"; |
| | | import process from "node:process"; |
| | | |
| | | type CliOptions = { |
| | | dir: string; |
| | |
| | | }; |
| | | |
| | | type FileResultStatus = |
| | | | 'same' |
| | | | 'changed' |
| | | | 'missing' |
| | | | 'would-create' |
| | | | 'created' |
| | | | 'would-delete' |
| | | | 'deleted' |
| | | | 'updated' |
| | | | 'error'; |
| | | | "same" |
| | | | "changed" |
| | | | "missing" |
| | | | "would-create" |
| | | | "created" |
| | | | "would-delete" |
| | | | "deleted" |
| | | | "updated" |
| | | | "error"; |
| | | |
| | | type FileResult = { |
| | | fileName: string; |
| | |
| | | |
| | | type ProcessingTask = |
| | | | { |
| | | kind: 'upstream'; |
| | | kind: "upstream"; |
| | | fileName: string; |
| | | } |
| | | | { |
| | | kind: 'local-only'; |
| | | kind: "local-only"; |
| | | fileName: string; |
| | | }; |
| | | |
| | | const DEFAULT_DIRECTORY = 'assets/ananke/socials'; |
| | | const DEFAULT_BRANCH = 'develop'; |
| | | const SIMPLE_ICONS_OWNER = 'simple-icons'; |
| | | const SIMPLE_ICONS_REPO = 'simple-icons'; |
| | | const DEFAULT_DIRECTORY = "assets/ananke/socials"; |
| | | const DEFAULT_BRANCH = "develop"; |
| | | const SIMPLE_ICONS_OWNER = "simple-icons"; |
| | | const SIMPLE_ICONS_REPO = "simple-icons"; |
| | | const SIMPLE_ICONS_RAW_BASE = |
| | | 'https://raw.githubusercontent.com/simple-icons/simple-icons'; |
| | | "https://raw.githubusercontent.com/simple-icons/simple-icons"; |
| | | |
| | | const EXIT_SUCCESS = 0; |
| | | const EXIT_FAILURE = 1; |
| | |
| | | * Print CLI help. |
| | | */ |
| | | function printHelp(): void { |
| | | const scriptName = path.basename(process.argv[1] ?? 'update-social-icons.ts'); |
| | | const scriptName = path.basename(process.argv[1] ?? "update-social-icons.ts"); |
| | | |
| | | console.log(` |
| | | console.log( |
| | | ` |
| | | Usage: |
| | | ${scriptName} [options] |
| | | |
| | |
| | | * deleted local file missing upstream and was deleted |
| | | * updated local file was replaced because --apply was used |
| | | * error processing failed |
| | | `.trim()); |
| | | `.trim(), |
| | | ); |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | for (let index = 0; index < argv.length; index += 1) { |
| | | const arg = argv[index]; |
| | | if (arg === undefined) { |
| | | continue; |
| | | } |
| | | |
| | | if (arg === '--help') { |
| | | if (arg === "--help") { |
| | | options.help = true; |
| | | continue; |
| | | } |
| | | |
| | | if (arg === '--apply') { |
| | | if (arg === "--apply") { |
| | | options.apply = true; |
| | | continue; |
| | | } |
| | | |
| | | if (arg === '--delete') { |
| | | if (arg === "--delete") { |
| | | options.deleteMissing = true; |
| | | continue; |
| | | } |
| | | |
| | | if (arg === '--only-changed') { |
| | | if (arg === "--only-changed") { |
| | | options.onlyChanged = true; |
| | | continue; |
| | | } |
| | | |
| | | if (arg === '--json') { |
| | | if (arg === "--json") { |
| | | options.json = true; |
| | | continue; |
| | | } |
| | | |
| | | if (arg === '--verbose') { |
| | | if (arg === "--verbose") { |
| | | options.verbose = true; |
| | | continue; |
| | | } |
| | | |
| | | if (arg === '--all') { |
| | | if (arg === "--all") { |
| | | options.all = true; |
| | | continue; |
| | | } |
| | | |
| | | if (arg === '--dir') { |
| | | if (arg === "--dir") { |
| | | const value = argv[index + 1]; |
| | | if (!value) { |
| | | throw new Error('Missing value for --dir'); |
| | | throw new Error("Missing value for --dir"); |
| | | } |
| | | options.dir = value; |
| | | index += 1; |
| | | continue; |
| | | } |
| | | |
| | | if (arg.startsWith('--dir=')) { |
| | | options.dir = arg.slice('--dir='.length); |
| | | if (arg.startsWith("--dir=")) { |
| | | options.dir = arg.slice("--dir=".length); |
| | | continue; |
| | | } |
| | | |
| | | if (arg === '--branch') { |
| | | if (arg === "--branch") { |
| | | const value = argv[index + 1]; |
| | | if (!value) { |
| | | throw new Error('Missing value for --branch'); |
| | | throw new Error("Missing value for --branch"); |
| | | } |
| | | options.branch = value; |
| | | index += 1; |
| | | continue; |
| | | } |
| | | |
| | | if (arg.startsWith('--branch=')) { |
| | | options.branch = arg.slice('--branch='.length); |
| | | if (arg.startsWith("--branch=")) { |
| | | options.branch = arg.slice("--branch=".length); |
| | | continue; |
| | | } |
| | | |
| | | if (arg === '--concurrency') { |
| | | if (arg === "--concurrency") { |
| | | const value = argv[index + 1]; |
| | | if (!value) { |
| | | throw new Error('Missing value for --concurrency'); |
| | | throw new Error("Missing value for --concurrency"); |
| | | } |
| | | |
| | | const parsed = Number.parseInt(value, 10); |
| | | if (!Number.isInteger(parsed) || parsed < 1) { |
| | | throw new Error('--concurrency must be a positive integer'); |
| | | throw new Error("--concurrency must be a positive integer"); |
| | | } |
| | | |
| | | options.concurrency = parsed; |
| | |
| | | continue; |
| | | } |
| | | |
| | | if (arg.startsWith('--concurrency=')) { |
| | | const value = arg.slice('--concurrency='.length); |
| | | if (arg.startsWith("--concurrency=")) { |
| | | const value = arg.slice("--concurrency=".length); |
| | | const parsed = Number.parseInt(value, 10); |
| | | if (!Number.isInteger(parsed) || parsed < 1) { |
| | | throw new Error('--concurrency must be a positive integer'); |
| | | throw new Error("--concurrency must be a positive integer"); |
| | | } |
| | | |
| | | options.concurrency = parsed; |
| | |
| | | return entries |
| | | .filter((entry) => entry.isFile()) |
| | | .map((entry) => entry.name) |
| | | .filter((fileName) => fileName.toLowerCase().endsWith('.svg')) |
| | | .filter((fileName) => fileName.toLowerCase().endsWith(".svg")) |
| | | .sort((left, right) => left.localeCompare(right)); |
| | | } |
| | | |
| | |
| | | const apiUrl = new URL( |
| | | `https://api.github.com/repos/${encodeURIComponent(SIMPLE_ICONS_OWNER)}/${encodeURIComponent(SIMPLE_ICONS_REPO)}/contents/icons`, |
| | | ); |
| | | apiUrl.searchParams.set('ref', branch); |
| | | apiUrl.searchParams.set("ref", branch); |
| | | |
| | | const response = await fetch(apiUrl.toString(), { |
| | | method: 'GET', |
| | | method: "GET", |
| | | headers: { |
| | | Accept: 'application/vnd.github+json', |
| | | 'User-Agent': 'davidsneighbour-social-icon-updater', |
| | | Accept: "application/vnd.github+json", |
| | | "User-Agent": "davidsneighbour-social-icon-updater", |
| | | }, |
| | | }); |
| | | |
| | |
| | | const data: unknown = await response.json(); |
| | | |
| | | if (!Array.isArray(data)) { |
| | | throw new Error('Unexpected response while retrieving upstream icon listing'); |
| | | throw new Error( |
| | | "Unexpected response while retrieving upstream icon listing", |
| | | ); |
| | | } |
| | | |
| | | const fileNames = data |
| | | .flatMap((entry: unknown) => { |
| | | if ( |
| | | typeof entry === 'object' && |
| | | typeof entry === "object" && |
| | | entry !== null && |
| | | 'type' in entry && |
| | | 'name' in entry && |
| | | entry.type === 'file' && |
| | | typeof entry.name === 'string' && |
| | | entry.name.toLowerCase().endsWith('.svg') |
| | | "type" in entry && |
| | | "name" in entry && |
| | | entry.type === "file" && |
| | | typeof entry.name === "string" && |
| | | entry.name.toLowerCase().endsWith(".svg") |
| | | ) { |
| | | return [entry.name]; |
| | | } |
| | |
| | | */ |
| | | async function fetchRemoteSvg(remoteUrl: string): Promise<string | null> { |
| | | const response = await fetch(remoteUrl, { |
| | | method: 'GET', |
| | | method: "GET", |
| | | headers: { |
| | | Accept: 'image/svg+xml,text/plain;q=0.9,*/*;q=0.8', |
| | | 'User-Agent': 'davidsneighbour-social-icon-updater', |
| | | Accept: "image/svg+xml,text/plain;q=0.9,*/*;q=0.8", |
| | | "User-Agent": "davidsneighbour-social-icon-updater", |
| | | }, |
| | | }); |
| | | |
| | |
| | | } |
| | | |
| | | if (!response.ok) { |
| | | throw new Error(`Request failed with HTTP ${response.status} for ${remoteUrl}`); |
| | | throw new Error( |
| | | `Request failed with HTTP ${response.status} for ${remoteUrl}`, |
| | | ); |
| | | } |
| | | |
| | | return await response.text(); |
| | |
| | | fileName, |
| | | localPath, |
| | | remoteUrl, |
| | | status: 'missing', |
| | | message: 'No matching upstream file found', |
| | | status: "missing", |
| | | message: "No matching upstream file found", |
| | | }; |
| | | } |
| | | |
| | |
| | | fileName, |
| | | localPath, |
| | | remoteUrl, |
| | | status: 'would-create', |
| | | message: 'Upstream file exists and would be created locally', |
| | | status: "would-create", |
| | | message: "Upstream file exists and would be created locally", |
| | | }; |
| | | } |
| | | |
| | | await fs.writeFile(localPath, remoteContent, 'utf8'); |
| | | await fs.writeFile(localPath, remoteContent, "utf8"); |
| | | |
| | | return { |
| | | fileName, |
| | | localPath, |
| | | remoteUrl, |
| | | status: 'created', |
| | | message: 'Upstream file existed and was created locally', |
| | | status: "created", |
| | | message: "Upstream file existed and was created locally", |
| | | }; |
| | | } |
| | | |
| | | const localContent = await fs.readFile(localPath, 'utf8'); |
| | | const localContent = await fs.readFile(localPath, "utf8"); |
| | | |
| | | if (localContent === remoteContent) { |
| | | return { |
| | | fileName, |
| | | localPath, |
| | | remoteUrl, |
| | | status: 'same', |
| | | message: 'Local file matches upstream', |
| | | status: "same", |
| | | message: "Local file matches upstream", |
| | | }; |
| | | } |
| | | |
| | |
| | | fileName, |
| | | localPath, |
| | | remoteUrl, |
| | | status: 'changed', |
| | | message: 'Upstream file differs from local file', |
| | | status: "changed", |
| | | message: "Upstream file differs from local file", |
| | | }; |
| | | } |
| | | |
| | | await fs.writeFile(localPath, remoteContent, 'utf8'); |
| | | await fs.writeFile(localPath, remoteContent, "utf8"); |
| | | |
| | | return { |
| | | fileName, |
| | | localPath, |
| | | remoteUrl, |
| | | status: 'updated', |
| | | message: 'Local file replaced with upstream version', |
| | | status: "updated", |
| | | message: "Local file replaced with upstream version", |
| | | }; |
| | | } catch (error: unknown) { |
| | | const message = |
| | | error instanceof Error ? error.message : 'Unknown error while processing file'; |
| | | error instanceof Error |
| | | ? error.message |
| | | : "Unknown error while processing file"; |
| | | |
| | | return { |
| | | fileName, |
| | | localPath, |
| | | remoteUrl, |
| | | status: 'error', |
| | | status: "error", |
| | | message, |
| | | }; |
| | | } |
| | |
| | | fileName, |
| | | localPath, |
| | | remoteUrl, |
| | | status: 'missing', |
| | | message: 'No matching upstream file found', |
| | | status: "missing", |
| | | message: "No matching upstream file found", |
| | | }; |
| | | } |
| | | |
| | |
| | | fileName, |
| | | localPath, |
| | | remoteUrl, |
| | | status: 'would-delete', |
| | | message: 'No matching upstream file found; local file would be deleted', |
| | | status: "would-delete", |
| | | message: "No matching upstream file found; local file would be deleted", |
| | | }; |
| | | } |
| | | |
| | |
| | | fileName, |
| | | localPath, |
| | | remoteUrl, |
| | | status: 'deleted', |
| | | message: 'No matching upstream file found; local file deleted', |
| | | status: "deleted", |
| | | message: "No matching upstream file found; local file deleted", |
| | | }; |
| | | } catch (error: unknown) { |
| | | const message = |
| | | error instanceof Error ? error.message : 'Unknown error while processing file'; |
| | | error instanceof Error |
| | | ? error.message |
| | | : "Unknown error while processing file"; |
| | | |
| | | return { |
| | | fileName, |
| | | localPath, |
| | | remoteUrl, |
| | | status: 'error', |
| | | status: "error", |
| | | message, |
| | | }; |
| | | } |
| | |
| | | return true; |
| | | } |
| | | |
| | | return status !== 'same'; |
| | | return status !== "same"; |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | function printResult(result: FileResult, verbose: boolean): void { |
| | | const prefixByStatus: Record<FileResultStatus, string> = { |
| | | same: 'SAME', |
| | | changed: 'CHANGED', |
| | | missing: 'MISS', |
| | | 'would-create': 'WOULD-CREATE', |
| | | created: 'CREATED', |
| | | 'would-delete': 'WOULD-DELETE', |
| | | deleted: 'DELETED', |
| | | updated: 'UPDATED', |
| | | error: 'ERR', |
| | | same: "SAME", |
| | | changed: "CHANGED", |
| | | missing: "MISS", |
| | | "would-create": "WOULD-CREATE", |
| | | created: "CREATED", |
| | | "would-delete": "WOULD-DELETE", |
| | | deleted: "DELETED", |
| | | updated: "UPDATED", |
| | | error: "ERR", |
| | | }; |
| | | |
| | | console.log(`${prefixByStatus[result.status]} ${result.fileName} - ${result.message}`); |
| | | console.log( |
| | | `${prefixByStatus[result.status]} ${result.fileName} - ${result.message}`, |
| | | ); |
| | | |
| | | if (verbose) { |
| | | console.log(` local: ${result.localPath}`); |
| | |
| | | */ |
| | | function buildSummary(results: FileResult[]): Summary { |
| | | return { |
| | | same: results.filter((entry) => entry.status === 'same').length, |
| | | changed: results.filter((entry) => entry.status === 'changed').length, |
| | | missing: results.filter((entry) => entry.status === 'missing').length, |
| | | wouldCreate: results.filter((entry) => entry.status === 'would-create').length, |
| | | created: results.filter((entry) => entry.status === 'created').length, |
| | | wouldDelete: results.filter((entry) => entry.status === 'would-delete').length, |
| | | deleted: results.filter((entry) => entry.status === 'deleted').length, |
| | | updated: results.filter((entry) => entry.status === 'updated').length, |
| | | errors: results.filter((entry) => entry.status === 'error').length, |
| | | same: results.filter((entry) => entry.status === "same").length, |
| | | changed: results.filter((entry) => entry.status === "changed").length, |
| | | missing: results.filter((entry) => entry.status === "missing").length, |
| | | wouldCreate: results.filter((entry) => entry.status === "would-create") |
| | | .length, |
| | | created: results.filter((entry) => entry.status === "created").length, |
| | | wouldDelete: results.filter((entry) => entry.status === "would-delete") |
| | | .length, |
| | | deleted: results.filter((entry) => entry.status === "deleted").length, |
| | | updated: results.filter((entry) => entry.status === "updated").length, |
| | | errors: results.filter((entry) => entry.status === "error").length, |
| | | }; |
| | | } |
| | | |
| | |
| | | * @param summary Summary counts. |
| | | */ |
| | | function printSummary(summary: Summary): void { |
| | | console.log(''); |
| | | console.log('Summary'); |
| | | console.log(""); |
| | | console.log("Summary"); |
| | | console.log(` same: ${summary.same}`); |
| | | console.log(` changed: ${summary.changed}`); |
| | | console.log(` updated: ${summary.updated}`); |
| | |
| | | total: number, |
| | | active: number, |
| | | ): void { |
| | | process.stdout.write(`\rProcessing ${processed}/${total} (active: ${active})`); |
| | | process.stdout.write( |
| | | `\rProcessing ${processed}/${total} (active: ${active})`, |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Clear the progress line from the terminal. |
| | | */ |
| | | function clearProgressLine(): void { |
| | | process.stdout.write('\r\x1b[K'); |
| | | process.stdout.write("\r\x1b[K"); |
| | | } |
| | | |
| | | /** |
| | |
| | | active += 1; |
| | | |
| | | try { |
| | | const result = await worker(items[currentIndex]); |
| | | // Index is bounds-checked above; TInput itself may include undefined. |
| | | const result = await worker(items[currentIndex] as TInput); |
| | | results[currentIndex] = result; |
| | | } finally { |
| | | active -= 1; |
| | |
| | | |
| | | const stats = await fs.stat(targetDirectory).catch(() => null); |
| | | if (!stats || !stats.isDirectory()) { |
| | | throw new Error(`Directory does not exist or is not a directory: ${targetDirectory}`); |
| | | throw new Error( |
| | | `Directory does not exist or is not a directory: ${targetDirectory}`, |
| | | ); |
| | | } |
| | | |
| | | const localFiles = await getLocalSvgFiles(targetDirectory); |
| | |
| | | |
| | | for (const fileName of upstreamFiles) { |
| | | tasks.push({ |
| | | kind: 'upstream', |
| | | kind: "upstream", |
| | | fileName, |
| | | }); |
| | | } |
| | |
| | | for (const fileName of localFiles) { |
| | | if (!upstreamSet.has(fileName)) { |
| | | tasks.push({ |
| | | kind: 'local-only', |
| | | kind: "local-only", |
| | | fileName, |
| | | }); |
| | | } |
| | |
| | | |
| | | for (const fileName of localFiles) { |
| | | tasks.push({ |
| | | kind: 'upstream', |
| | | kind: "upstream", |
| | | fileName, |
| | | }); |
| | | } |
| | |
| | | console.log(`Found ${upstreamFiles.length} upstream SVG file(s).`); |
| | | console.log(`Found ${localFiles.length} local SVG file(s).`); |
| | | } else { |
| | | console.log(`Checking ${localFiles.length} local SVG file(s) in ${targetDirectory}`); |
| | | console.log( |
| | | `Checking ${localFiles.length} local SVG file(s) in ${targetDirectory}`, |
| | | ); |
| | | } |
| | | |
| | | console.log(`Using simple-icons branch: ${options.branch}`); |
| | | console.log(`Concurrency: ${options.concurrency}`); |
| | | console.log( |
| | | options.apply |
| | | ? 'Apply mode enabled. Changed files will be written.' |
| | | : 'Dry run mode enabled. No files will be written.', |
| | | ? "Apply mode enabled. Changed files will be written." |
| | | : "Dry run mode enabled. No files will be written.", |
| | | ); |
| | | |
| | | if (options.deleteMissing) { |
| | | console.log( |
| | | options.apply |
| | | ? 'Delete mode enabled. Local files missing upstream will be deleted.' |
| | | : 'Delete mode enabled. Local files missing upstream will be reported as would-delete.', |
| | | ? "Delete mode enabled. Local files missing upstream will be deleted." |
| | | : "Delete mode enabled. Local files missing upstream will be reported as would-delete.", |
| | | ); |
| | | } |
| | | |
| | | if (options.onlyChanged) { |
| | | console.log('Only changed results will be shown.'); |
| | | console.log("Only changed results will be shown."); |
| | | } |
| | | |
| | | console.log(''); |
| | | console.log(""); |
| | | } |
| | | |
| | | const results = await runTasksWithConcurrency<ProcessingTask, FileResult>({ |
| | | items: tasks, |
| | | concurrency: options.concurrency, |
| | | worker: async (task): Promise<FileResult> => { |
| | | if (task.kind === 'upstream') { |
| | | if (task.kind === "upstream") { |
| | | const result = await processUpstreamFile( |
| | | targetDirectory, |
| | | task.fileName, |
| | |
| | | options.apply, |
| | | ); |
| | | |
| | | if (result.status === 'missing' && !options.all) { |
| | | if (result.status === "missing" && !options.all) { |
| | | return await processLocalOnlyFile( |
| | | targetDirectory, |
| | | task.fileName, |
| | |
| | | printSummary(summary); |
| | | } |
| | | |
| | | const hasErrors = results.some((entry) => entry.status === 'error'); |
| | | const hasErrors = results.some((entry) => entry.status === "error"); |
| | | process.exit(hasErrors ? EXIT_FAILURE : EXIT_SUCCESS); |
| | | } |
| | | |
| | | main().catch((error: unknown) => { |
| | | const message = |
| | | error instanceof Error ? error.message : 'Unknown fatal error'; |
| | | error instanceof Error ? error.message : "Unknown fatal error"; |
| | | console.error(`Fatal error: ${message}`); |
| | | process.exit(EXIT_FAILURE); |
| | | }); |