diff --git a/package-lock.json b/package-lock.json index d0faa73..b4cd968 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,14 @@ "name": "memcard-autoupload", "version": "0.1.0", "license": "MIT", + "dependencies": { + "cli-progress": "^3.12.0" + }, "devDependencies": { "@tsconfig/node20": "^20.1.2", "@types/chai": "^4.3.9", "@types/chai-as-promised": "^7.1.8", + "@types/cli-progress": "^3.11.5", "@types/jest": "^29.5.8", "@types/node": "^20.8.10", "@typescript-eslint/eslint-plugin": "^6.9.1", @@ -1518,6 +1522,15 @@ "@types/chai": "*" } }, + "node_modules/@types/cli-progress": { + "version": "3.11.5", + "resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.5.tgz", + "integrity": "sha512-D4PbNRbviKyppS5ivBGyFO29POlySLmA2HyUFE4p5QGazAMM3CwkKWcvTl8gvElSuxRh6FPKL8XmidX873ou4g==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1875,7 +1888,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -2285,6 +2297,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-progress/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/cli-progress/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-progress/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cli-truncate": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz", @@ -5260,7 +5309,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, diff --git a/package.json b/package.json index 1f91736..23d9b3e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@tsconfig/node20": "^20.1.2", "@types/chai": "^4.3.9", "@types/chai-as-promised": "^7.1.8", + "@types/cli-progress": "^3.11.5", "@types/jest": "^29.5.8", "@types/node": "^20.8.10", "@typescript-eslint/eslint-plugin": "^6.9.1", @@ -47,5 +48,8 @@ }, "lint-staged": { "**/*": "prettier --write --ignore-unknown" + }, + "dependencies": { + "cli-progress": "^3.12.0" } } diff --git a/src/downloader/downloader.ts b/src/downloader/downloader.ts index b988ecd..e7eb600 100644 --- a/src/downloader/downloader.ts +++ b/src/downloader/downloader.ts @@ -9,16 +9,16 @@ import { } from "./configuration"; import { FileCopier } from "./file-copier"; import { GroupByDatePlacementStrategy, TargetPlacementStrategy } from "./placement-strategy"; +import { ProgressTracker } from "./progress"; export class Downloader { private readonly configurationPromise: Promise = readAutoDownloadConfiguration(); + + private readonly progressTracker = new ProgressTracker(); private readonly fileCopier: FileCopier = new FileCopier({ - async onTargetAlreadyExists(sourceFile, targetFile) { - console.warn( - "%s already exists in %s: skip copying %s", - path.basename(targetFile), - path.dirname(targetFile), - sourceFile + onTargetAlreadyExists: async (sourceFile, targetFile) => { + this.progressTracker.log( + `${path.basename(targetFile)} already exists in ${path.dirname(targetFile)}: skip copying ${sourceFile}` ); return { action: "skip" }; }, @@ -47,6 +47,8 @@ export class Downloader { // to avoid having multiple 'threads' reading and writing from/to the same physical device. await this.downloadNewFilesFromDir(sourceDir, dirDownloadConfig); } + + this.progressTracker.stop(); } else { console.warn("[%s] Could not find any supported directories: do nothing", drivePath); } @@ -55,9 +57,8 @@ export class Downloader { private async downloadNewFilesFromDir(sourceDir: string, configuration: DirectoryDownloadConfig) { const cursor = await readDirectoryCursor(sourceDir); if (!cursor) { - console.warn( - "[%s] Cursor file not found: this appears to be the first time we are processing this directory", - sourceDir + this.progressTracker.log( + `[${sourceDir}] Cursor file not found: this appears to be the first time we are processing this directory` ); } @@ -67,27 +68,34 @@ export class Downloader { }); if (newFiles.length > 0) { - console.info("[%s] Found %i new files in total (in this source directory)", sourceDir, newFiles.length); + this.progressTracker.log( + `[${sourceDir}] Found ${newFiles.length} new files in total (in this source directory)` + ); + const progressBar = this.progressTracker.startTracking(sourceDir, newFiles.length); const placementStrategy = this.resolveTargetPlacementStrategy(configuration.target); for (const fileRelativePath of newFiles) { const targetFilePath = await placementStrategy.resolveTargetPath(`${sourceDir}/${fileRelativePath}`); - console.log("[%s] Copy %s to %s", sourceDir, fileRelativePath, targetFilePath); + + progressBar.setStatusSummary(`${fileRelativePath} => ${targetFilePath}`); await this.fileCopier.copy(`${sourceDir}/${fileRelativePath}`, targetFilePath); + progressBar.increment(); } const lastProcessedFile = newFiles[newFiles.length - 1]; - console.info("[%s] Save final cursor position: %s", sourceDir, lastProcessedFile); + progressBar.setStatusSummary(`Saving final cursor position: ${lastProcessedFile}`); - saveDirectoryCursor(sourceDir, { + await saveDirectoryCursor(sourceDir, { ...cursor, sequential: { ...cursor?.sequential, lastProcessedFile, }, }); + progressBar.setStatusSummary(`Saved final cursor position: ${lastProcessedFile}`); + progressBar.stop(); } else { - console.info("[%s] No new files found: do nothing", sourceDir); + this.progressTracker.log(`[${sourceDir}] No new files found`); } } diff --git a/src/downloader/progress.ts b/src/downloader/progress.ts new file mode 100644 index 0000000..2656599 --- /dev/null +++ b/src/downloader/progress.ts @@ -0,0 +1,63 @@ +import { MultiBar, Presets, SingleBar } from "cli-progress"; + +export class ProgressTracker { + private readonly multiBar: MultiBar; + + constructor() { + this.multiBar = new MultiBar( + { + format: "[{sourceDir}] Downloading new files {bar} {percentage}% | {value}/{total} | Speed: {speed} | {summary}", + barCompleteChar: "\u2588", + barIncompleteChar: "\u2591", + hideCursor: true, + }, + Presets.shades_grey + ); + } + + /** + * Start tracking progress of copying a new batch of files. + * @param sourceDir + * @param total total number of new files in this batch + * @returns new single-line progress bar + */ + public startTracking(sourceDir: string, total: number): SingleDirBar { + return new SingleDirBar(this.multiBar.create(total, 0, { sourceDir, speed: "N/A" })); + } + + /** + * Log a message above all progress bars. + * @param message + */ + public log(message: string) { + this.multiBar.log(`${message}\n`); + } + + public stop() { + this.multiBar.stop(); + } +} + +/** + * Tracks the progress of copying a given batch of files. + */ +export class SingleDirBar { + constructor(private readonly progressBar: SingleBar) {} + + /** + * Set short summary of the current state of the overall operation progress + * (shown to the right of the progress bar). + * @param summary + */ + public setStatusSummary(summary: string) { + this.progressBar.increment(0, { summary }); + } + + public increment() { + return this.progressBar.increment(); + } + + public stop() { + this.progressBar.stop(); + } +}