・コマンドライン スクリプトのテンプレート ・単一の.tsファイル ・サブコマンド対応 ・環境 deno 2.7.10 (stable, release, x86_64-pc-windows-msvc) v8 14.7.173.7-rusty typescript 5.9.2 ・ダウンロード#!/usr/bin/env -S deno run --allow-all /** * 汎用コマンドラインツールのテンプレート - fuzzyutilsTS * * ### 提供コマンド: * - `renumber`: 指定したディレクトリ内の画像を連番で一括リネームする。 * - `downloadfiles`: ファイルをダウンロードする(連番対応) * * ### 使用例: * ```bash * deno run -A fuzzyutilsTS.ts renumber --prefix "vacation_" --start 1 * ``` * * Deno 2.7.10 / TypeScript 準拠 * * 【備忘録】Java/Python と似ているけど実は全然違う点 * 1. メソッドチェーン(.then/.catch/.pipe / .map/.filter) * 「未来の予約」=「ヒープアロケーション」。 * 高負荷時:ループ内では関数オブジェクト生成を避け、 * try-catch / for-await / for...of を使う。 * 一般領域:可読性優先なら許容するが、裏でアロケーションが走るコストを * 意識する。 * 2. スプレッド演算子([...])による隠れた O(N) コピーがあるので多用は無用。 * 大量データ時は push() 等の破壊的変更(In-place)による最適化を選択する。 * 3. オブジェクトの構造は生成時に固定し、後からのプロパティ追加は禁止。 * 生成後にプロパティを追加するとランタイムの最適化(Hidden Class)から * 外れる。 * * 【備忘録】 設定しておくと便利なコンパイラオプション * deno.jsonc * { * "compilerOptions": { * "strict": true, // 厳格モード(デフォルトだが明記) * "exactOptionalPropertyTypes": true, // 正確なプロパティタイプに限定 * "noImplicitOverride": true, // 暗黙のオーバーライドを禁止 * "noImplicitReturns": true, // 暗黙の戻り値を禁止 * "noFallthroughCasesInSwitch": true // switch文の突き抜け防止 * } * } * @module */ // deno-lint-ignore-file no-external-import no-import-prefix import { parseArgs } from "jsr:@std/cli@^1.0.28/parse-args"; import * as path from "jsr:@std/path@^1.1.4"; import * as posix from "jsr:@std/path@^1.1.4/posix"; import { delay } from "jsr:@std/async@^1.2.0"; const PROG_VERSION = "2026.04.18"; // ================================================================== // ファイルシステム操作のラッパー // ================================================================== /** * ファイル操作を抽象化するインターフェース。 * * @remark * テスト時のモックや、他のI/Oやネットワークに差し替えれれるようにするためのIF。 * @example * // テスト用(ディレクトリ内のファイル一覧を偽装する) * const mockFS: FileSystem = { * readDir: async function* () { yield { name: "file1.txt", isFile: true, isDirectory: false, isSymlink: false }; }, * exists: async () => false, * rename: async (s, d) => console.log(`Moved: ${s} -> ${d}`), * }; * const cmd = new ReNumberCommand(mockFS); * @internal */ export interface FileSystem { readDir(path: string): AsyncIterable<{ name: string; isFile: boolean; isDirectory: boolean; isSymlink: boolean; }>; rename(oldPath: string, newPath: string): Promise<void>; exists(path: string): Promise<boolean>; writeBinary( path: string, stream: ReadableStream<Uint8Array> | null, ): Promise<boolean>; } /** * 実際の OS ファイルシステムを使用する {@link FileSystem} の本番用実装。 * * @remarks * `Deno.readDir` や `Deno.rename` などの標準 API のラップ。 * 通常はデフォルトで使用し、テスト時はテスト用のモックを用意する。 */ const DenoFS: FileSystem = { readDir: Deno.readDir, rename: Deno.rename, // @std/fs/existsの競合状態回避策 exists: async (path) => { try { await Deno.lstat(path); return true; } catch { return false; } }, /** * 読み込み可能なストリームからバイナリデータを読み取り、ファイルに書き込む。 * * @param path 書き込み先パス * @param stream 入力ストリーム */ async writeBinary( path: string, stream: ReadableStream<Uint8Array> | null, ): Promise<boolean> { try { using file = await Deno.open(path, { write: true, create: true, truncate: true, }); if (stream !== null) { await stream.pipeTo(file.writable); } return true; } catch (_error) { return false; } }, }; // ================================================================== // fetchのラッパー // ================================================================== /** * fetch 実行の結果を分類して表す。 * * 判別共用体 (Discriminated Union)。 * * @remarks * ネットワークの成否だけでなく、HTTP ステータスコードに基づく * 論理的な成功・失敗も型レベルで区別するために使用する。 */ type SafeFetchResult = | { type: "success" | "http_error"; response: Response } | { type: "network_error"; error: Error }; /** * fetch を実行し、例外をスローせずに実行結果を型安全なオブジェクトとして返す。 * * @param url - リクエスト対象の URL 文字列 * @returns 実行結果の状態 (`type`) に応じた {@link SafeFetchResult} を含む * * @remarks * 素のfetchではエラーハンドリングが面倒くさいので例外を全て吸収し * 戻り値として返す。 */ async function safeFetch(url: string): Promise<SafeFetchResult> { try { const response = await fetch(url); return { type: response.ok ? "success" : "http_error", response, }; } catch (error) { return { type: "network_error", error: error instanceof Error ? error : new Error(String(error)), }; } } // ================================================================== // コマンドの定義 // ================================================================== /** * コマンドの実行結果を表す型定義。 * * `success` プロパティの値によって、成功時と失敗時の状態を区別する。 * 判別共用体 (Discriminated Union) */ export type CommandResult = | { success: true; code: 0; message?: string } | { success: false; code: number; message: string; error?: Error }; /** * サブコマンドの「クラス定義そのもの」が満たすべき共通ルール。 * * @remarks * 抽象クラスでは制限できない `static` メンバの実装を、コンパイル時に強制するた * めの仕組み。 * * これにより、クラスをインスタンス化(new)する前の段階で、 * 識別用の `static ID` などが正しく定義されているかがチェックされる。 */ interface SubCommandConstructor { /** * コマンドの識別名(例: "renumber", "download")。 * * @remarks * 具象クラスでは `static readonly ID: string` として定義する。 */ readonly ID: string; /** * ヘルプ画面に表示されるコマンドの簡潔な説明文。 * * @remaerks * 具象クラスでは `static readonly ID: string` として定義する。 */ readonly DESCRIPTION: string; /** * サブコマンドのインスタンスを生成するためのコンストラクタ。 * * @param fs 使用するファイルシステムの実装 * @returns BaseCommand を継承したサブコマンドのインスタンス */ new (fs?: FileSystem): BaseCommand<unknown>; } /** * 全てのサブコマンドの基底となる抽象クラス。 * * 引数の解析(Template Methodパターンの構成)と実行フローを制御する。 * 継承先では、具体的なオプション型 `T` を定義し、解析と実行のロジックを * 実装すること。 * * @template T コマンドが使用する解析済みオプションの型 */ abstract class BaseCommand<T> { /** * コンストラクター * * @param fs - 使用するファイルシステム操作の実装。 * デフォルトは `RealFS`(Deno 標準 API を使用する実体)を使用。 * 単体テスト時には、ここへモックオブジェクトを注入することで、 * 実際のファイルシステムを汚さずにロジックを検証できる。 * @example * // 本番用(通常はこちらを使用) * const cmd = new ReNumberCommand(); * @example * // テスト用(ディレクトリ内のファイル一覧を偽装する) * const mockFS: FileSystem = { * readDir: async function* () { yield { name: "file1.txt", isFile: true, isDirectory: false, isSymlink: false }; }, * exists: async () => false, * rename: async (s, d) => console.log(`Moved: ${s} -> ${d}`), * }; * const cmd = new ReNumberCommand(mockFS); */ constructor(protected fs: FileSystem = DenoFS) {} /** * コマンド固有のヘルプ情報(使用法、オプションの詳細など)を * 標準出力に表示する。 * @protected */ protected abstract showHelp(): void; /** * コマンドライン引数の配列を解析し、型 `T` のオブジェクトに変換する。 * * @param args サブコマンド名を除いた、解析対象の引数リスト * @returns 解析・構造化されたオプションオブジェクト * @protected */ protected abstract parseOptions(args: string[]): T; /** * コマンドの具体的な実行ロジックを実装する。 * * @param options `parseOptions` によって解析されたオプションオブジェクト * @returns 実行結果(成功・失敗のステータスとメッセージ)を含むPromise * @protected */ protected abstract runImpl(options: T): Promise<CommandResult>; /** * コマンドの実行エントリポイント。 * * 引数の解析 (`parseOptions`) を行い、その結果を元に実行 (`runImpl`) を * 呼び出す。 * * @param args コマンドラインから渡された未解析の引数リスト * @returns コマンドの実行結果を保持するオブジェクト */ async run(args: string[]): Promise<CommandResult> { const subArgs = parseArgs(args, { boolean: ["help"], alias: { h: "help" }, }); if (args.length === 0 || subArgs.help) { this.showHelp(); return { success: true, code: 0 }; } const options = this.parseOptions(args); return await this.runImpl(options); } } // ================================================================== // SubCommand: renumber (連番リネーム) // ================================================================== /** * `renumber` コマンドで使用するパース済みオプション */ export interface ReNumberOptions { /** 出力ファイル名の先頭に付与する文字列 */ prefix: string; /** 連番の開始番号 */ startNo: number; /** リネーム実施フラグ(trueで実際にリネームをする) */ isFix: boolean; /** 最低桁数 */ minDigits: number; } interface RenameFileInfo { fullPath: string; baseName: string; ext: string; } interface RenamePlan { file: RenameFileInfo; newName: string; isNew: boolean; } /** * ディレクトリ内の画像ファイルを連番で一括リネームするコマンド */ export class ReNumberCommand extends BaseCommand<ReNumberOptions> { static readonly ID = "renumber"; static readonly DESCRIPTION = "画像ファイルを連番でリネームします。"; /** * ヘルプ情報を標準出力。 */ protected override showHelp(): void { console.log(` Description: ${ReNumberCommand.DESCRIPTION} Command: ${ReNumberCommand.ID} Usage: deno run -A fuzzyutilsTS.ts ${ReNumberCommand.ID} [options] Options: -e, --prefix <string> 出力ファイル名の接頭辞 (デフォルト: "") -n, --start <number> 開始番号 (デフォルト: 1) -d, --digits <number> 最低桁数 (デフォルト: 自動計算) --fix シミュレーションではなく実際にリネームを実行 -h, --help このヘルプを表示 FEATURES: - 自然順ソート: "file2.txt" が "file10.txt" より前に並びます。 CAUTION: - リネーム先に「対象外の同名ファイル」が存在する場合は 上書き防止のため処理を中断します。`); } /** * 引数を `ReNumberOptions` 型にパースする。 * * @param rawArgs コマンドライン引数の配列 * @returns パース済みのオプションオブジェクト */ protected override parseOptions(rawArgs: string[]): ReNumberOptions { const args = parseArgs(rawArgs, { string: ["e"], boolean: ["fix"], alias: { e: "prefix", n: "start", d: "digits", }, default: { e: "", n: 1, d: 0, // 0は自動計算を意味する fix: false, }, }); return { prefix: String(args.e), startNo: Number(args.start ?? 1), minDigits: Number(args.d) || 0, isFix: Boolean(args.fix), }; } /** * コマンドのメインロジックを実行する。 * * @param options `parseOptions` によって解析されたオプションオブジェク * * @returns 実行結果(成功・失敗のステータスとメッセージ)を含むPromise * @throws {Error} ファイルシステム操作に失敗した場合 */ protected override async runImpl( options: ReNumberOptions, ): Promise<CommandResult> { const targetFiles = await this.collectFiles(); if (targetFiles.length === 0) { return { success: true, code: 0, message: "対象となる画像ファイルが見つかりませんでした。", }; } console.log(`対象ファイル: ${targetFiles.length} 件`); const autoDigits = String(targetFiles.length + options.startNo - 1).length; const digits = Math.max(autoDigits, options.minDigits); const renamePlan = this.createRenamePlan( targetFiles, options.prefix, options.startNo, digits, ); const resValid = await this.validatePlan(renamePlan); if (resValid.type === "skipped") { return { success: false, code: 1, message: resValid.reason || "安全のため処理を中断しました。", }; } const resExec = await this.executeRename(renamePlan, options.isFix); if (resExec.type != "success") { return { success: false, code: 2, message: resExec.reason || "実行エラーが発生しました。", ...(resExec.type === "error" ? { error: resExec.error } : {}), }; } if (!options.isFix) { console.log( "\n【シミュレーション】" + " 実際に変更するには --fix を指定してください", ); } return { success: true, code: 0 }; } /** * 処理対象とする拡張子のリスト。 * 全て小文字、ドット付きで定義。 */ // deno-fmt-ignore private static readonly TARGET_EXTS: readonly string[] = [ ".jpg", ".png", ".webp", ".bmp", ".jpeg", ".gif", ]; /** * カレントディレクトリから対象ファイルを収集し、自然順でソートし取得する。 * * @returns ソート済みのファイル情報(FileInfo)の配列 * * @remarks * - 拡張子の判定は大文字小文字を区別しない。 * - ソートは「自然順(数値順)」かつ * 「大文字小文字・全角半角を区別する」設定で行う。 */ private async collectFiles(): Promise<RenameFileInfo[]> { const files: RenameFileInfo[] = []; for await (const entry of this.fs.readDir(".")) { if (!entry.isFile) continue; const { name: basename, ext } = path.parse(entry.name); if (ReNumberCommand.TARGET_EXTS.includes(ext.toLowerCase())) { files.push({ fullPath: entry.name, ext: ext, baseName: basename, }); } } const collator = new Intl.Collator("ja", { numeric: true, sensitivity: "variant", }); files.sort((a, b) => collator.compare(a.baseName, b.baseName)); return files; } /** * 対象ファイル群に対して、新しいファイル名を含むリネーム計画を作成する。 * * @param targetFiles - リネーム対象となるファイルの情報の配列 * @param prefix - ファイル名の先頭に付与する文字列 * @param startNo - 連番の開始番号 * @param digits - 連番部分の最小桁数(足りない場合は `0` で埋められる) * @returns 各ファイルの新旧名と変更の有無を含む計画オブジェクトの配列 * * @remarks * - 実際のファイル名は元の拡張子の大小文字を維持する。 * - 変更の有無判定においては、ベース名は厳密に比較するが、 * 拡張子のみ大文字小文字の違いを無視して比較を行う。 * - 例:`Photo.JPG` から `Photo.jpg` への変更は「変更なし」と判定する。 */ private createRenamePlan( targetFiles: RenameFileInfo[], prefix: string, startNo: number, digits: number, ): RenamePlan[] { return targetFiles.map((file, index) => { const numPart = String(startNo + index).padStart(digits, "0"); const newName = `${prefix}${numPart}${file.ext}`; const isNew = file.fullPath !== newName; return { file, newName, isNew, }; }); } /** * 作成されたリネーム計画の整合性を検証し、上書き事故を未然に防ぐ。 * * @param renamePlan - 検証対象となるリネーム計画の配列。 * @returns 計画が安全な場合は `{ type: "success" }` * リスクがある場合は `skipped` 判定を返す。 * * @remarks * 本メソッドは実行時のソートで解決できない衝突を検知する。 * 判定には自然順(数値順)の比較を用い、`executeRename` の反転ロジックと同期 * させている。 * * 1. **最終的な名前の重複**: * 計画を完了した時点で複数のファイルが同じ名前になる状態(データ消失)を禁止 * する。 * * 2. **移動方向の混在チェック**: * 番号が「大きくなる方向」と「小さくなる方向」のリネームが混在している場合、 * 単純なソート順の制御(`executeRename` による反転)では解決できない * 循環的な衝突が発生する可能性があるため、`--prefix` 等による一時退避を促す。 * * 3. **外部ファイル(居座り)との衝突**: * リネーム先にファイルが存在し、かつそのファイルが今回のリネーム対象 * (場所を空ける予定のファイル)ではない場合、外部ファイルの上書きを避けるた * めエラーとする。 */ private async validatePlan( renamePlan: RenamePlan[], ): Promise< | { type: "success" } | { type: "skipped"; status: number; reason: string } > { const originalCount = renamePlan.length; const newNamesSet = new Set<string>(); const oldNamesSet = new Set<string>(); let hasIncrease = false; let hasDecrease = false; for (const plan of renamePlan) { const oldName = plan.file.fullPath.toLowerCase(); const newName = plan.newName.toLowerCase(); oldNamesSet.add(oldName); newNamesSet.add(newName); if (!plan.isNew) continue; // 移動の方向をチェック const cmp = newName.localeCompare(oldName, undefined, { numeric: true }); if (cmp > 0) hasIncrease = true; if (cmp < 0) hasDecrease = true; } if (newNamesSet.size !== originalCount) { const lostCount = originalCount - newNamesSet.size; return { type: "skipped", status: 0, reason: "中止: ファイル名が重複しています。" + "\nこのまま実行すると、上書きにより " + `${lostCount} 個のファイルが消失します。`, }; } // 方向の混在チェック // 「大きくなる」と「小さくなる」が混在している場合、単純なソートでは解決で // きないため --prefix を促す if (hasIncrease && hasDecrease) { return { type: "skipped", status: 1, reason: "中止: リネームの方向(番号の増減)が混在しています。" + "\n安全のため --prefix 等で別名に変えてから実行してください。", }; } // 外部ファイルとの衝突チェック for (const plan of renamePlan) { if (!plan.isNew) continue; const newNameLow = plan.newName.toLowerCase(); if (!oldNamesSet.has(newNameLow) && await this.fs.exists(plan.newName)) { return { type: "skipped", status: 1, reason: `中止: 既存の別ファイルと衝突します: ${plan.newName}`, }; } } return { type: "success" }; } /** * 作成されたリネーム計画の整合性を検証し、上書き事故を未然に防ぐ。 * * @param renamePlan - 検証対象となるリネーム計画の配列。 * @returns 計画が安全な場合は `{ type: "success" }`、 * リスクがある場合は `skipped` 判定を返す。 * * @remarks * 本メソッドは、実行時のソートで解決できない衝突を検知する。 * 1. **最終的な名前の重複**: * 計画を完了した時点で複数のファイルが同じ名前になる状態を禁止する。 * 2. **移動方向の混在チェック**: * 番号が「大きくなる方向」と「小さくなる方向」のリネームが混在している場合、 * 単純なソート順の制御(`executeRename` による反転)では解決できない * 循環的な衝突が発生する可能性があるため、`--prefix` 等による一時退避を促す。 * 3. **外部ファイル(居座り)との衝突**: * リネーム先にファイルが存在し、かつそのファイルが今回のリネーム対象 * (場所を空ける予定のファイル)ではない場合、外部ファイルの上書きを避けるた * めエラーとする。 */ private async executeRename( renamePlan: RenamePlan[], isFix: boolean, ): Promise< | { type: "success" } | { type: "skipped"; status: number; reason: string } | { type: "error"; error: Error; reason?: string } > { if (renamePlan.length === 0) return { type: "success" }; // 全件走査して、一つでも「後ろ(番号増)」への移動があるか確認 // 一つでもあれば、玉突き衝突を防ぐために逆順(降順)と判断する let shouldReverse = false; for (const plan of renamePlan) { if (!plan.isNew) continue; const cmp = plan.newName.localeCompare( plan.file.fullPath, undefined, { numeric: true }, ) > 0; if (cmp) { shouldReverse = true; break; } } // 向きに合わせてインデックスの並びを作成 // 番号が増えるリネームなら後ろから(逆順) const indices = renamePlan.map((_, i) => i); if (shouldReverse) { indices.reverse(); } for (const i of indices) { const { file, newName, isNew } = renamePlan[i]; const status = isNew ? "" : " (変更なし)"; const logPrefix = isFix ? "" : "Simulation:"; console.log(`${logPrefix} ${file.fullPath} -> ${newName}${status}`); if (!isNew || !isFix) continue; try { // 最終確認 if (await this.fs.exists(newName)) { return { type: "skipped", status: 0, reason: `移動先 "${newName}" が既に存在します。`, }; } await this.fs.rename(file.fullPath, newName); } catch (error) { return { type: "error", reason: `${file.fullPath} のリネームに失敗しました。`, error: error instanceof Error ? error : new Error(String(error)), }; } } return { type: "success" }; } } // ================================================================== // SubCommand: downloadfiles (ファイルダウンロード) // ================================================================== /** * `downloadfiles` コマンドで使用するパース済みオプション */ export interface DownloadImagesOptions { url: string; } /** * ファイルをダウンロードするコマンド(連番対応)。 */ export class DownloadFilesCommand extends BaseCommand<DownloadImagesOptions> { static readonly ID = "downloadfiles"; static readonly DESCRIPTION = "ファイルをダウンロードする。"; /** 画像ファイルとして認識する拡張子のリスト */ // deno-fmt-ignore private readonly imgExts: readonly string[] = [ ".webp", ".jpg", ".png", ".jpeg", ".bmp", ".gif" ]; /** ドキュメントファイルとして認識する拡張子のリスト */ private readonly docExts: readonly string[] = [".docx", ".doc"]; protected override showHelp(): void { console.log(` Description: ${DownloadFilesCommand.DESCRIPTION} Command: ${DownloadFilesCommand.ID} Usage: deno run -A fuzzyutilsTS.ts ${DownloadFilesCommand.ID} [Url] Url: ダウンロードファイルのURL FEATURES: 数字で連番を指定可能 - "aaa/bbb[1-10].txt" --> "aaa/bbb1.txt", "aaa/bbb2.txt"... - "aaa/bbb[01-10].txt" --> "aaa/bbb01.txt", "aaa/bbb02.txt"... - "aaa/[1-10]bbb.txt" --> "aaa/1bbb.txt", "aaa/2bbb.txt"... - "aaa/b[1-10]bb.txt" --> "aaa/b1bb.txt", "aaa/b2bb.txt"... CAUTION: - 同名ファイル」が存在する場合は上書き。`); } protected override parseOptions(rawArgs: string[]): DownloadImagesOptions { const args = parseArgs(rawArgs); const target: string = String(args._[0]); return { url: target }; } /** * コマンドのメインロジックを実行する。 * * @param options `parseOptions` によって解析されたオプションオブジェクト。 * @returns 実行結果(成功・失敗のステータスとメッセージ)を含むPromise。 * * @remarks * 以下の流れでダウンロード処理を行う: * 1. 連番パターンを含む全ての対象URLを配列として一括取得。 * 2. 各URLに対し、画像および文書の主要な拡張子を補完候補として生成。 * 3. サーバー負荷とアクセス制限に配慮し、1件ごとに 1000ms の待機時間を挿入。 * 4. 進行状況を `[現在の件数/総件数]` の形式で標準出力に表示する。 */ protected override async runImpl( options: DownloadImagesOptions, ): Promise<CommandResult> { const urls = [...this.rangeToUrls(options.url)]; const totalCount = urls.length; const countLen = String(totalCount).length; for (let i = 0; i < totalCount; i++) { const parsed = posix.parse(urls[i]); const originalExt = parsed.ext.toLowerCase(); const extsToTry = this.getAltCandidates(originalExt); parsed.base = ""; // ファイル名を作り変える時の誤動作防止策 let isMissing = true; for (const ext of extsToTry) { parsed.ext = ext; const tryUrl = posix.format(parsed); const fileName = posix.basename(tryUrl); const res = await this.downloadfile(tryUrl, fileName); if (res.type === "success") { const currentNo = String(i + 1).padStart(countLen, " "); console.log(`[${currentNo}/${totalCount}] ${fileName}`); isMissing = false; break; } if (res.type === "error") { return { success: false, code: 3, message: `予期せぬエラーが発生しました: ${res.error.message}`, }; } if (res.status === 404) { await delay(1000); continue; } // その他のHTTPエラー (404以外の500エラーなど) // メッセージを表示して「正常終了」扱い return { success: true, code: 0, message: res.reason, }; } if (isMissing) { return { success: false, code: 1, message: `ファイルが見つかりませんでした: ${parsed.name}`, }; } const hasNext = i < totalCount - 1; if (hasNext) { await delay(1000); } } return { success: true, code: 0 }; } /** * URL内の連番指定パターンを解析し、展開された URL を逐次生成する。 * * @param url 解析対象の URL 文字列(例: "image[01-10].jpg")。 * @yields パターンに基づいて生成された各 URL。 * * @example * // "file[1-3].txt" -> "file1.txt", "file2.txt", "file3.txt" * // "img[08-10].png" -> "img08.png", "img09.png", "img10.png" (桁数維持) * @remarks * - パターンが含まれない場合は、元の URL を 1 回だけ yield する。 * - 開始番号の文字列長(例: "01" なら 2 桁)を基準にゼロ埋め。 */ private *rangeToUrls(url: string): Generator<string> { const rangeRegex = /\[(\d+)-(\d+)\]/; const match = url.match(rangeRegex); if (!match) { yield url; return; } const [fullMatch, startStr, endStr] = match; const start = parseInt(startStr, 10); const end = parseInt(endStr, 10); const digits = startStr.length; for (let i = start; i <= end; i++) { const numPart = String(i).padStart(digits, "0"); yield url.replace(fullMatch, numPart); } } /** * 指定された拡張子の代替候補の拡張子一覧を取得する。 * * @remarks * 返却される配列には、引数で渡された `targetExt` 自身も含まれる。 * 該当するグループがない場合は、`targetExt` のみが格納された配列を返す。 * * @param targetExt - 判定対象となる拡張子 (例: ".jpg", ".docx") * @returns 重複を除いた代替候補拡張子の配列 * * @example * ```ts * // ".jpg" を渡した場合、imgExts の全要素が返える * const candidates = getAltCandidates(".jpg"); * ``` */ private getAltCandidates(targetExt: string): string[] { // deno-fmt-ignore const candidates = this.imgExts.includes(targetExt) ? this.imgExts : this.docExts.includes(targetExt) ? this.docExts : []; return [...new Set([targetExt, ...candidates])]; } /** * 指定された URL からファイルをダウンロードし、ローカルに保存する。 * * @param url ダウンロード対象の完全な URL。 * @param localFileName 保存先のローカルファイル名。 * @returns * - `type: "success"`: 保存成功。 * - `type: "skipped"`: 404 等の HTTP エラー(続行可能)。 * - `type: "error"`: 通信断やディスク書き込み失敗(致命的エラー)。 * * @remarks * - ネットワーク接続自体に失敗した場合は `type: "error"` を返す。 * - サーバーから応答があったものの `ok` でない場合(404, 500等)は * `type: "skipped"` を返す。 * - 保存時に `res.body` が空の場合は、空のファイルを作成する。 */ private async downloadfile( url: string, localFileName: string, ): Promise< | { type: "success" } | { type: "skipped"; status: number; reason: string } | { type: "error"; error: Error } > { const res = await safeFetch(url); if (res.type === "network_error") { const error = res.error; return { type: "error", error: error instanceof Error ? error : new Error(String(error)), }; } const response = res.response; if (res.type === "http_error") { // deno-fmt-ignore const reason = response.status === 404 ? `ファイルが見つかりませんでした: ${url}` : `[サーバーエラー]: ${response.status} ${response.statusText} - ${url}`; return { type: "skipped", status: response.status, reason: reason, }; } try { const success = await this.fs.writeBinary(localFileName, response.body); if (!success) { return { type: "error", error: new Error(`FileSystem.writeBinary failed: ${localFileName}`), }; } return { type: "success" }; } catch (error) { return { type: "error", error: error instanceof Error ? error : new Error(String(error)), }; } } } // ================================================================== // メイン処理 // ================================================================== /** * ツール全体のライフサイクルとコマンド実行を管理するメインクラス。 * * 本プログラムはシンプルなコマンドラインプログラムなので、高度な * ハンドリングは必要ない。 */ class Main { // ここで SubCommandConstructor型 を指定することで // サブコマンドにstaticメンバーとコンストラクタを継承(強制)させられる // deno-fmt-ignore readonly COMMANDS: ReadonlyMap<string, SubCommandConstructor> = new Map<string, SubCommandConstructor>([ [ReNumberCommand.ID, ReNumberCommand], [DownloadFilesCommand.ID, DownloadFilesCommand], ]); /** * ヘルプ情報を標準出力。 * * @returns 常に0(正常終了コード) */ private showHelp(): void { console.log( "Usage: deno run -A fuzzyutils.ts [command] [options]", "\n\n -h, --help : Show help", "\n -v, --version : Show version", "\n -e, --env : Show environment info", "\n\nSubCommands:", ); for (const cmd of this.COMMANDS.values()) { console.log(` ${cmd.ID.padEnd(15)} : ${cmd.DESCRIPTION}`); } } /** * 実行環境情報を出力。 */ private outputEnv(): void { console.log("--- Deno Built-in Properties ---"); const envInfo = { "Deno.hostname": Deno.hostname(), "Deno.execPath()": Deno.execPath(), "Deno.noColor": Deno.noColor, "Deno.version": Deno.inspect(Deno.version), "Deno.build": Deno.inspect(Deno.build), }; for (const [key, value] of Object.entries(envInfo)) { console.log(`${key}: ${value}`); } console.log("--- Deno Related Environment Variables ---"); // deno-fmt-ignore const denoRelatedVars = [ "DENO_DIR", // キャッシュルート "DENO_INSTALL", // インストール先 "DENO_AUTH_TOKENS", // 秘密リポジトリ等の認証情報 "DENO_CERT", // TLS証明書のパス "DENO_WEBGPU_ADAPTER", // WebGPUの使用アダプタ "NO_COLOR", // コンソール出力の色抑制 "HTTP_PROXY", // ネットワークプロキシ "HTTPS_PROXY", // ネットワークプロキシ "NO_PROXY", // プロキシ除外設定 ]; for (const name of denoRelatedVars) { const value = Deno.env.get(name); console.log(`${name}: ${value ?? "(not set)"}`); } } /** * アプリケーションのメインエントリーポイント。 * * 引数を解析し、適切なサブコマンドを呼び出す。 * * @returns プロセスの終了コード */ async exec(): Promise<number> { const args = parseArgs(Deno.args, { stopEarly: true, // 最初の引数をサブコマンドとして扱う boolean: ["help", "version", "env"], alias: { h: "help", v: "version", e: "env" }, }); if (args.version) { console.log(`${PROG_VERSION}`); return 0; } if (args.env) { this.outputEnv(); return 0; } const [subCommandName, ...remainingArgs] = args._.map(String); if (args.help || !subCommandName) { this.showHelp(); return 0; } const command = this.COMMANDS.get(subCommandName); if (!command) { console.error(`Unknown command: ${subCommandName}`); return 1; } try { const cmd = new command(); const result = await cmd.run(remainingArgs); if (result.success) { if (result.message) { console.log(`${result.message}`); } } else { console.error(`[Error] ${result.message}`); if (result.error) console.debug(result.error.stack); } return result.code; } // 想定外のエラー(権限不足やディスクフルなど) catch (error) { const msg = error instanceof Error ? error.message : String(error); console.error(`[Fatal Error] ${command.ID}: ${msg}`); return 1; } } } /** * 直接実行された場合のみアプリケーションを起動 */ if (import.meta.main) { const exitCode = await new Main().exec(); Deno.exit(exitCode); }