diff --git a/.gitignore b/.gitignore index 27aaaf7..2244834 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ error/ # Build lib/ +build/ # Env files .env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cd88524 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# we need some image and video processing tools too, so use Debian 12 "Bookworm" as the base so we can get them. +FROM docker.io/node:bookworm + +# get our image and video processing dependencies +RUN apt-get update && apt-get install -y ffmpeg exiftool + +COPY . /app + +WORKDIR /app +# build the application +RUN yarn && yarn build + +ENTRYPOINT [ "yarn", "start" ] diff --git a/README.md b/README.md index c33d1c4..f3233f3 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,109 @@ A tool like [google-photos-exif](https://github.com/mattwilson1024/google-photos ## Run this tool +### Natively + +**Prerec**: Must have at least node 18 & yarn installed. + +#### Full structured migration + +If you wish to migrate an entire takeout folder (and keep the album directory structure): + ```bash mkdir output error -npx google-photos-migrate@latest '/path/to/takeout/Google Fotos' './output' './error' --timeout 60000 +npx google-photos-migrate@latest full '/path/to/takeout' './output' './error' --timeout 60000 +``` + +The folder names in the `output` and `error` directories will now correspond to the original album names. + +#### Flat migration + +If you wish to migrate your Google Photos folder into a flat directory (and don't care about albums): + +```bash +mkdir output error + +npx google-photos-migrate@latest flat '/path/to/takeout/Google Photos' './output' './error' --timeout 60000 +``` + +#### Optional flags (see `--help` for all details): + +``` +--timeout integer + Shorthand: -t integer + Meaning: Sets the timeout for exiftool, default is 30000 (30s) +--force + Shorthand: -f + Meaning: Forces the migration and overwrites files in the target directory. +``` + +### Docker + +**Prerec:** You must have a working `docker` or `podman` install. + +A Dockerfile is also provided to make running this tool easier on most hosts. The image must be built manually (see below), no pre-built images are provided. Using it will by default use only software-based format conversion, hardware accelerated format conversion is beyond these instructions. + +Build the image once before you run it: + +```shell +git clone https://github.com/garzj/google-photos-migrate +cd google-photos-migrate + +# build the image +docker build -f Dockerfile -t localhost/google-photos-migrate:latest . +``` + +To run the full migration: + +```shell +mkdir output error +docker run --rm -it -security-opt=label=disable \ + -v $(readlink -e path/to/takeout):/takeout \ + -v $(readlink -e ./output):/output \ + -v $(readlink -e ./error):/error \ + localhost/google-photos-migrate:latest \ + fullMigrate '/takeout' '/output' '/error' --timeout=60000 +``` + +To run the flat migration: + +```shell +mkdir output error +docker run --rm -it --security-opt=label=disable \ + -v $(readlink -e path/to/takeout):/takeout \ + -v $(readlink -e ./output):/output \ + -v $(readlink -e ./error):/error \ + localhost/google-photos-migrate:latest \ + flat '/takeout/Google Fotos' '/output' '/error' --timeout=60000 +``` + +All other options are also available. The only difference from running it natively is the lack of (possible) hardware acceleration, and the need to explicitly add any folders the command will need to reference as host-mounts for the container. + +For the overall help: + +```shell +docker run --rm -it --security-opt=label=disable \ + localhost/google-photos-migrate:latest \ + --help ``` ## Further steps - If you use Linux + Android, you might want to check out the scripts I used to locate duplicate media and keep the better versions in the [android-dups](./android-dups/) directory. - Use a tool like [Immich](https://github.com/immich-app/immich) and upload your photos + +## Development + +**Prerec**: Must have node 18 & yarn installed. + +To test the app: + +```bash +git clone https://github.com/garzj/google-photos-migrate +cd google-photos-migrate +yarn +yarn dev +``` + +The entrypoint of the cli is in `src/cli.ts` and library code should be exported from `src/index.ts`. diff --git a/package.json b/package.json index a48dcde..e92f111 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,12 @@ "dependencies": { "cmd-ts": "^0.12.1", "exiftool-vendored": "^22.0.0", - "sanitize-filename": "^1.6.3" + "glob": "^10.3.3", + "sanitize-filename": "^1.6.3", + "ts-node": "^10.9.1" + }, + "resolutions": { + "string-width-cjs": "5.1.1" }, "keywords": [ "google", diff --git a/src/cli.ts b/src/cli.ts index 5342b00..85b01b4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,102 +1,14 @@ #!/usr/bin/env node -import { existsSync } from 'fs'; -import { command, run, string, positional, flag, number, option } from 'cmd-ts'; -import { migrateGoogleDirGen } from './media/migrate-google-dir'; -import { isEmptyDir } from './fs/is-empty-dir'; -import { ExifTool } from 'exiftool-vendored'; +import { subcommands, run } from 'cmd-ts'; +import { migrateFull } from './commands/migrate-full'; +import { migrateFlat } from './commands/migrate-flat'; -const app = command({ +const app = subcommands({ name: 'google-photos-migrate', - args: { - googleDir: positional({ - type: string, - displayName: 'google_dir', - description: 'The path to your "Google Photos" directory.', - }), - outputDir: positional({ - type: string, - displayName: 'output_dir', - description: 'The path to your flat output directory.', - }), - errorDir: positional({ - type: string, - displayName: 'error_dir', - description: 'Failed media will be saved here.', - }), - force: flag({ - short: 'f', - long: 'force', - description: - "Forces the operation if the given directories aren't empty.", - }), - timeout: option({ - type: number, - defaultValue: () => 30000, - short: 't', - long: 'timeout', - description: - 'Sets the task timeout in milliseconds that will be passed to ExifTool.', - }), - }, - handler: async ({ googleDir, outputDir, errorDir, force, timeout }) => { - const errs: string[] = []; - if (!existsSync(googleDir)) { - errs.push('The specified google directory does not exist.'); - } - if (!existsSync(outputDir)) { - errs.push('The specified output directory does not exist.'); - } - if (!existsSync(errorDir)) { - errs.push('The specified error directory does not exist.'); - } - if (errs.length !== 0) { - errs.forEach((e) => console.error(e)); - process.exit(1); - } - - if (!force && !(await isEmptyDir(outputDir))) { - errs.push( - 'The output directory is not empty. Pass "-f" to force the operation.' - ); - } - if (!force && !(await isEmptyDir(errorDir))) { - errs.push( - 'The error directory is not empty. Pass "-f" to force the operation.' - ); - } - if (await isEmptyDir(googleDir)) { - errs.push('The google directory is empty. Nothing to do.'); - } - if (errs.length !== 0) { - errs.forEach((e) => console.error(e)); - process.exit(1); - } - - console.log(`Started migration.`); - const migGen = migrateGoogleDirGen({ - googleDir, - outputDir, - errorDir, - warnLog: console.error, - exiftool: new ExifTool({ taskTimeoutMillis: timeout }), - endExifTool: true, - }); - - const counts = { err: 0, suc: 0 }; - for await (const result of migGen) { - if (result instanceof Error) { - console.error(`Error: ${result}`); - counts.err++; - continue; - } - - counts.suc++; - } - - console.log(`Done! Processed ${counts.suc + counts.err} files.`); - console.log(`Files migrated: ${counts.suc}`); - console.log(`Files failed: ${counts.err}`); + cmds: { + full: migrateFull, + flat: migrateFlat, }, }); diff --git a/src/commands/common.ts b/src/commands/common.ts new file mode 100644 index 0000000..ad2106f --- /dev/null +++ b/src/commands/common.ts @@ -0,0 +1,22 @@ +import { flag, number, option, positional, string } from 'cmd-ts'; + +export const errorDirArg = positional({ + type: string, + displayName: 'error_dir', + description: 'Failed media will be saved here.', +}); + +export const timeoutArg = option({ + type: number, + defaultValue: () => 30000, + short: 't', + long: 'timeout', + description: + 'Sets the task timeout in milliseconds that will be passed to ExifTool.', +}); + +export const forceArg = flag({ + short: 'f', + long: 'force', + description: "Forces the operation if the given directories aren't empty.", +}); diff --git a/src/commands/migrate-flat.ts b/src/commands/migrate-flat.ts new file mode 100644 index 0000000..c0070c9 --- /dev/null +++ b/src/commands/migrate-flat.ts @@ -0,0 +1,86 @@ +import { command, string, positional } from 'cmd-ts'; +import { isEmptyDir } from '../fs/is-empty-dir'; +import { fileExists } from '../fs/file-exists'; +import { errorDirArg, forceArg, timeoutArg } from './common'; +import { migrateDirFlatGen } from '../dir/migrate-flat'; +import { ExifTool } from 'exiftool-vendored'; +import { MediaMigrationError } from '../media/MediaMigrationError'; + +export const migrateFlat = command({ + name: 'google-photos-migrate-flat', + args: { + inputDir: positional({ + type: string, + displayName: 'input_dir', + description: 'The path to your "Google Photos" directory.', + }), + outputDir: positional({ + type: string, + displayName: 'output_dir', + description: 'The path to your flat output directory.', + }), + errorDir: errorDirArg, + force: forceArg, + timeout: timeoutArg, + }, + handler: async ({ inputDir, outputDir, errorDir, force, timeout }) => { + const errs: string[] = []; + const checkErrs = () => { + if (errs.length !== 0) { + errs.forEach((e) => console.error(e)); + process.exit(1); + } + }; + + if (!(await fileExists(inputDir))) { + errs.push(`The specified google directory does not exist: ${inputDir}`); + } + if (!(await fileExists(outputDir))) { + errs.push(`The specified output directory does not exist: ${inputDir}`); + } + if (!(await fileExists(errorDir))) { + errs.push(`The specified error directory does not exist: ${inputDir}`); + } + checkErrs(); + + if (!force && !(await isEmptyDir(outputDir))) { + errs.push( + 'The output directory is not empty. Pass "-f" to force the operation.' + ); + } + if (!force && !(await isEmptyDir(errorDir))) { + errs.push( + 'The error directory is not empty. Pass "-f" to force the operation.' + ); + } + if (await isEmptyDir(inputDir)) { + errs.push(`Nothing to do, the source directory is empty: ${inputDir}`); + } + checkErrs(); + + console.log('Started migration.'); + const migGen = migrateDirFlatGen({ + inputDir, + outputDir, + errorDir, + log: console.log, + warnLog: console.error, + exiftool: new ExifTool({ taskTimeoutMillis: timeout }), + endExifTool: true, + }); + const counts = { err: 0, suc: 0 }; + for await (const result of migGen) { + if (result instanceof MediaMigrationError) { + console.error(`Error: ${result}`); + counts.err++; + continue; + } else { + counts.suc++; + } + } + + console.log(`Done! Processed ${counts.suc + counts.err} files.`); + console.log(`Files migrated: ${counts.suc}`); + console.log(`Files failed: ${counts.err}`); + }, +}); diff --git a/src/commands/migrate-full.ts b/src/commands/migrate-full.ts new file mode 100644 index 0000000..281366d --- /dev/null +++ b/src/commands/migrate-full.ts @@ -0,0 +1,84 @@ +import { command, string, positional } from 'cmd-ts'; +import { isEmptyDir } from '../fs/is-empty-dir'; +import { fileExists } from '../fs/file-exists'; +import { errorDirArg, forceArg, timeoutArg } from './common'; +import { migrateDirFullGen } from '..'; +import { ExifTool } from 'exiftool-vendored'; +import { MediaMigrationError } from '../media/MediaMigrationError'; +import { DirMigrationError } from '../dir/DirMigrationError'; + +export const migrateFull = command({ + name: 'google-photos-migrate-full', + args: { + inputDir: positional({ + type: string, + displayName: 'input_dir', + description: 'The path to your "Takeout" directory.', + }), + outputDir: positional({ + type: string, + displayName: 'output_dir', + description: 'The path where you want the processed directories to go.', + }), + errorDir: errorDirArg, + force: forceArg, + timeout: timeoutArg, + }, + handler: async ({ inputDir, outputDir, errorDir, force, timeout }) => { + const errs: string[] = []; + const checkErrs = () => { + if (errs.length !== 0) { + errs.forEach((e) => console.error(e)); + process.exit(1); + } + }; + + if (!(await fileExists(inputDir))) { + errs.push(`The specified takeout directory does not exist: ${inputDir}`); + } + if (!(await fileExists(outputDir))) { + errs.push(`The specified target directory does not exist: ${outputDir}`); + } + checkErrs(); + + if (!force && !(await isEmptyDir(outputDir))) { + errs.push( + `The target directory is not empty. Pass "-f" to force the operation.` + ); + } + if (await isEmptyDir(inputDir)) { + errs.push(`Nothing to do, the source directory is empty: ${inputDir}`); + } + checkErrs(); + + console.log('Started migration.'); + const migGen = migrateDirFullGen({ + inputDir: inputDir, + errorDir, + outputDir, + log: console.log, + warnLog: console.error, + exiftool: new ExifTool({ taskTimeoutMillis: timeout }), + endExifTool: true, + }); + const counts = { err: 0, suc: 0 }; + for await (const result of migGen) { + if (result instanceof DirMigrationError) { + console.error(`Error: ${result}`); + process.exit(1); + } + + if (result instanceof MediaMigrationError) { + console.error(`Error: ${result}`); + counts.err++; + continue; + } + + counts.suc++; + } + + console.log(`Done! Processed ${counts.suc + counts.err} files.`); + console.log(`Files migrated: ${counts.suc}`); + console.log(`Files failed: ${counts.err}`); + }, +}); diff --git a/src/config/langs.ts b/src/config/langs.ts index 51fe6ab..b875d38 100644 --- a/src/config/langs.ts +++ b/src/config/langs.ts @@ -1 +1,3 @@ export const editedSuffices = ['edited', 'bearbeitet', 'modifié']; +export const photosDirs = ['Google Photos', 'Google Fotos']; +export const untitledDirs = ['Untitled', 'Unbenannt']; diff --git a/src/dir/DirMigrationError.ts b/src/dir/DirMigrationError.ts new file mode 100644 index 0000000..b17a622 --- /dev/null +++ b/src/dir/DirMigrationError.ts @@ -0,0 +1,15 @@ +export class DirMigrationError extends Error { + constructor(public folder: string) { + super(); + } +} + +export class NoPhotosDirError extends DirMigrationError { + constructor(public folder: string) { + super(folder); + } + + toString() { + return `Failed to find Google Photos directory in folder: ${this.folder}`; + } +} diff --git a/src/dir/migrate-flat.ts b/src/dir/migrate-flat.ts new file mode 100644 index 0000000..9b0d0ca --- /dev/null +++ b/src/dir/migrate-flat.ts @@ -0,0 +1,32 @@ +import { walkDir } from '../fs/walk-dir'; +import { MediaFile } from '../media/MediaFile'; +import { migrateMediaFile } from '../media/migrate-media-file'; +import { MigrationArgs, migrationArgsDefaults } from './migration-args'; +import { asyncGenToAsync } from '../ts'; +import { indexJsonFiles } from '../meta/index-meta-files'; +import { MediaMigrationError } from '../media/MediaMigrationError'; + +export type MigrationContext = Required & { + titleJsonMap: Map; +}; + +export const migrateDirFlat = asyncGenToAsync(migrateDirFlatGen); + +export async function* migrateDirFlatGen( + _args: MigrationArgs +): AsyncGenerator { + const args = await migrationArgsDefaults(_args); + const migCtx: MigrationContext = { + ...args, + titleJsonMap: await indexJsonFiles(args.inputDir), + endExifTool: false, + }; + + for await (const mediaPath of walkDir(args.inputDir)) { + if (mediaPath.endsWith('.json')) continue; + + yield migrateMediaFile(mediaPath, migCtx); + } + + migCtx.endExifTool && migCtx.exiftool.end(); +} diff --git a/src/dir/migrate-full.ts b/src/dir/migrate-full.ts new file mode 100644 index 0000000..589cd5c --- /dev/null +++ b/src/dir/migrate-full.ts @@ -0,0 +1,43 @@ +import { restructureAndProcess } from './restructure-and-process'; +import { fileExists } from '../fs/file-exists'; +import { photosDirs } from '../config/langs'; +import { asyncGenToAsync } from '../ts'; +import { MediaFile } from '../media/MediaFile'; +import { MigrationArgs, migrationArgsDefaults } from './migration-args'; +import { MediaMigrationError } from '../media/MediaMigrationError'; +import { DirMigrationError, NoPhotosDirError } from './DirMigrationError'; +import { basename } from 'path'; + +export type FullMigrationContext = Required; + +export const migrateDirFull = asyncGenToAsync(migrateDirFullGen); + +export async function* migrateDirFullGen( + args: MigrationArgs +): AsyncGenerator { + const migCtx: FullMigrationContext = await migrationArgsDefaults(args); + + // at least in my takeout, the Takeout folder contains a subfolder + // Takeout/Google Foto + // rootdir refers to that subfolder + // Can add more language support here in the future + let googlePhotosDir: string = ''; + for (const photosDir of photosDirs) { + if (await fileExists(`${migCtx.inputDir}/${photosDir}`)) { + googlePhotosDir = `${migCtx.inputDir}/${photosDir}`; + break; + } + if (basename(migCtx.inputDir) === photosDir) { + googlePhotosDir = migCtx.inputDir; + break; + } + } + if (googlePhotosDir == '') { + yield new NoPhotosDirError(migCtx.inputDir); + return; + } + + yield* restructureAndProcess(googlePhotosDir, migCtx); + + migCtx.endExifTool && migCtx.exiftool.end(); +} diff --git a/src/dir/migration-args.ts b/src/dir/migration-args.ts new file mode 100644 index 0000000..fd42af4 --- /dev/null +++ b/src/dir/migration-args.ts @@ -0,0 +1,25 @@ +import { ExifTool } from 'exiftool-vendored'; + +export type MigrationArgs = { + inputDir: string; + outputDir: string; + errorDir: string; + log?: (msg: string) => void; + warnLog?: (msg: string) => void; + exiftool?: ExifTool; + endExifTool?: boolean; + migrationLocks?: Map>; +}; + +export async function migrationArgsDefaults( + args: MigrationArgs +): Promise> { + return { + ...args, + migrationLocks: args.migrationLocks ?? new Map(), + exiftool: args.exiftool ?? new ExifTool(), + endExifTool: args.endExifTool ?? !args.exiftool, + log: args.log ?? (() => {}), + warnLog: args.warnLog ?? (() => {}), + }; +} diff --git a/src/dir/restructure-and-process.ts b/src/dir/restructure-and-process.ts new file mode 100644 index 0000000..a70bd85 --- /dev/null +++ b/src/dir/restructure-and-process.ts @@ -0,0 +1,69 @@ +import { glob } from 'glob'; +import { basename } from 'path'; +import { mkdir } from 'fs/promises'; +import { migrateDirFlatGen } from './migrate-flat'; +import { FullMigrationContext } from './migrate-full'; +import { untitledDirs } from '../config/langs'; + +async function* _restructureAndProcess( + folders: string[], + processingAlbums: boolean, // true for Albums, false for Photos + migCtx: FullMigrationContext +) { + migCtx.log(`Starting restructure of ${folders.length} directories.`); + + for (const folder of folders) { + processingAlbums && migCtx.log(`Processing album ${folder}...`); + + let albumName = processingAlbums ? basename(folder) : 'Photos'; + for (const untitledName of untitledDirs) { + if (albumName.startsWith(`${untitledName}(`)) { + albumName = untitledName; + } + } + + const outDir = `${migCtx.outputDir}/${albumName}`; + const errDir = `${migCtx.errorDir}/${albumName}`; + + await mkdir(outDir, { recursive: true }); + await mkdir(errDir, { recursive: true }); + yield* migrateDirFlatGen({ + ...migCtx, + inputDir: folder, + outputDir: outDir, + errorDir: errDir, + }); + } + + console.log(`Sucsessfully restructured ${folders.length} directories`); +} + +export async function* restructureAndProcess( + sourceDir: string, + migCtx: FullMigrationContext +) { + // before + // $rootdir/My Album 1/* + // $rootdir/My Album 2/* + // $rootdir/Photos from 2008/* + + // after + // $rootdir/AlbumsProcessed/My Album 1/* + // $rootdir/AlbumsProcessed/My Album 2/* + // $rootdir/PhotosProcessed/* + + // move the "Photos from $YEAR" directories to Photos/ + migCtx.log('Processing photos...'); + const photoSet = new Set( + await glob([`${sourceDir}/Photos`, `${sourceDir}/Photos from */`]) + ); + yield* _restructureAndProcess([...photoSet], false, migCtx); + + // move everythingg else to Albums/, so we end up with two top level folders + migCtx.log('Processing albums...'); + const fullSet: Set = new Set(await glob(`${sourceDir}/*/`)); + const everythingExceptPhotosDir = [ + ...new Set([...fullSet].filter((x) => !photoSet.has(x))), + ]; + yield* _restructureAndProcess(everythingExceptPhotosDir, true, migCtx); +} diff --git a/src/fs/is-dir.ts b/src/fs/is-dir.ts new file mode 100644 index 0000000..8e8f0fb --- /dev/null +++ b/src/fs/is-dir.ts @@ -0,0 +1,11 @@ +import { lstat } from 'fs/promises'; + +export async function isDir(path: string) { + try { + const stat = await lstat(path); + return stat.isDirectory(); + } catch (e) { + // lstatSync throws an error if path doesn't exist + return false; + } +} diff --git a/src/index.ts b/src/index.ts index 4b16d43..af8d387 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,16 @@ import './config/env'; -import { migrateGoogleDir } from './media/migrate-google-dir'; +import { migrateDirFlat, migrateDirFlatGen } from './dir/migrate-flat'; +import { migrateDirFull, migrateDirFullGen } from './dir/migrate-full'; import type { MediaFileExtension } from './media/MediaFileExtension'; import { supportedExtensions } from './config/extensions'; +import type { MigrationArgs } from './dir/migration-args'; -export { migrateGoogleDir, supportedExtensions }; -export type { MediaFileExtension }; +export { + migrateDirFlat, + migrateDirFlatGen, + migrateDirFull, + migrateDirFullGen, + supportedExtensions, +}; +export type { MigrationArgs, MediaFileExtension }; diff --git a/src/media/migrate-google-dir.ts b/src/media/migrate-media-file.ts similarity index 65% rename from src/media/migrate-google-dir.ts rename to src/media/migrate-media-file.ts index 186d9bb..a9e27ce 100644 --- a/src/media/migrate-google-dir.ts +++ b/src/media/migrate-media-file.ts @@ -1,65 +1,18 @@ -import { walkDir } from '../fs/walk-dir'; import { basename } from 'path'; import { findMetaFile } from '../meta/find-meta-file'; import { MediaFileExtension } from './MediaFileExtension'; import { MediaFile, MediaFileInfo } from './MediaFile'; import { applyMetaFile } from '../meta/apply-meta-file'; -import { indexJsonFiles } from '../meta/index-meta-files'; import { supportedExtensions } from '../config/extensions'; import { MediaMigrationError } from './MediaMigrationError'; import { InvalidExtError } from './InvalidExtError'; import { NoMetaFileError } from '../meta/NoMetaFileError'; import { WrongExtensionError } from '../meta/apply-meta-errors'; -import { ExifTool } from 'exiftool-vendored'; import { readMetaTitle } from '../meta/read-meta-title'; import { saveToDir } from './save-to-dir'; +import { MigrationContext } from '../dir/migrate-flat'; -export type MigrationArgs = { - googleDir: string; - outputDir: string; - errorDir: string; - warnLog?: (msg: string) => void; - exiftool?: ExifTool; - endExifTool?: boolean; -}; - -export type MigrationContext = Required & { - titleJsonMap: Map; - migrationLocks: Map>; -}; - -export async function* migrateGoogleDir( - args: MigrationArgs -): AsyncGenerator { - const wg: (MediaFile | MediaMigrationError)[] = []; - for await (const result of migrateGoogleDirGen(args)) { - wg.push(result); - } - return await Promise.all(wg); -} - -export async function* migrateGoogleDirGen( - args: MigrationArgs -): AsyncGenerator { - const migCtx: MigrationContext = { - titleJsonMap: await indexJsonFiles(args.googleDir), - migrationLocks: new Map(), - ...args, - exiftool: args.exiftool ?? new ExifTool(), - endExifTool: args.endExifTool ?? !args.exiftool, - warnLog: args.warnLog ?? (() => {}), - }; - - for await (const mediaPath of walkDir(args.googleDir)) { - if (mediaPath.endsWith('.json')) continue; - - yield migrateMediaFile(mediaPath, migCtx); - } - - migCtx.endExifTool && migCtx.exiftool.end(); -} - -async function migrateMediaFile( +export async function migrateMediaFile( originalPath: string, migCtx: MigrationContext ): Promise { diff --git a/src/media/save-to-dir.ts b/src/media/save-to-dir.ts index 2ad65fb..6782fcf 100644 --- a/src/media/save-to-dir.ts +++ b/src/media/save-to-dir.ts @@ -2,7 +2,7 @@ import { basename, resolve } from 'path'; import { fileExists } from '../fs/file-exists'; import sanitize = require('sanitize-filename'); import { copyFile, mkdir, rename } from 'fs/promises'; -import { MigrationContext } from './migrate-google-dir'; +import { MigrationContext } from '../dir/migrate-flat'; async function _saveToDir( file: string, diff --git a/src/meta/apply-meta-file.ts b/src/meta/apply-meta-file.ts index 5827de5..6d5be4b 100644 --- a/src/meta/apply-meta-file.ts +++ b/src/meta/apply-meta-file.ts @@ -10,7 +10,7 @@ import { MissingMetaError, WrongExtensionError, } from './apply-meta-errors'; -import { MigrationContext } from '../media/migrate-google-dir'; +import { MigrationContext } from '../dir/migrate-flat'; export async function applyMetaFile( mediaFile: MediaFile, diff --git a/src/meta/find-meta-file.ts b/src/meta/find-meta-file.ts index b1b60e5..8e5f06a 100644 --- a/src/meta/find-meta-file.ts +++ b/src/meta/find-meta-file.ts @@ -1,5 +1,5 @@ import { basename, dirname } from 'path'; -import { MigrationContext } from '../media/migrate-google-dir'; +import { MigrationContext } from '../dir/migrate-flat'; import { MediaFileExtension } from '../media/MediaFileExtension'; import { fileExists } from '../fs/file-exists'; import { editedSuffices } from '../config/langs'; diff --git a/src/ts.ts b/src/ts.ts index a12364d..e161119 100644 --- a/src/ts.ts +++ b/src/ts.ts @@ -1,3 +1,17 @@ export const exhaustiveCheck = (_: never): never => { throw new Error('Exhaustive type check failed.'); }; + +export function asyncGenToAsync< + T extends (...args: P) => AsyncGenerator, + P extends any[] = Parameters, + R = ReturnType extends AsyncGenerator ? R : never, +>(f: T) { + return async (...args: P) => { + const wg: R[] = []; + for await (const result of f(...args)) { + wg.push(result); + } + return await Promise.all(wg); + }; +} diff --git a/yarn.lock b/yarn.lock index 5ed7703..df72f61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -65,6 +65,18 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + "@jridgewell/resolve-uri@^3.0.3": version "3.1.1" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721" @@ -109,6 +121,11 @@ resolved "https://registry.yarnpkg.com/@photostructure/tz-lookup/-/tz-lookup-7.0.0.tgz#b079412e7fea59033e441c08fcdb37d7c3c05901" integrity sha512-pTRsZz7Sn4yAtItC7I4+0segDHosMyOtJgAXg+xvDOolT0Xz4IFWqBV33OMCWoaNd3oQb60wbWhLeCQgJCyZAA== +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + "@pkgr/utils@^2.3.1": version "2.4.2" resolved "https://registry.yarnpkg.com/@pkgr/utils/-/utils-2.4.2.tgz#9e638bbe9a6a6f165580dc943f138fd3309a2cbc" @@ -291,13 +308,23 @@ ansi-regex@^5.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-styles@^4.1.0: +ansi-regex@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" + integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== dependencies: color-convert "^2.0.1" +ansi-styles@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + anymatch@~3.1.2: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" @@ -356,6 +383,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + braces@^3.0.2, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" @@ -435,7 +469,7 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -515,6 +549,21 @@ dynamic-dedupe@^0.3.0: dependencies: xtend "^4.0.0" +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" @@ -755,6 +804,14 @@ flatted@^3.2.7: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf" integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ== +foreground-child@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d" + integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^4.0.1" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -789,6 +846,17 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob@^10.3.3: + version "10.3.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.3.tgz#8360a4ffdd6ed90df84aa8d52f21f452e86a123b" + integrity sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw== + dependencies: + foreground-child "^3.1.0" + jackspeak "^2.0.3" + minimatch "^9.0.1" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + path-scurry "^1.10.1" + glob@^7.1.3: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -912,6 +980,11 @@ is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -958,6 +1031,15 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +jackspeak@^2.0.3: + version "2.3.1" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.1.tgz#ce2effa4c458e053640e61938865a5b5fae98456" + integrity sha512-4iSY3Bh1Htv+kLhiiZunUhQ+OYXIn0ze3ulq8JeWrFKmhPAJSySV2+kdtRh2pGcCeF0s6oR8Oc+pYZynJj4t8A== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" @@ -1014,6 +1096,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +"lru-cache@^9.1.1 || ^10.0.0": + version "10.0.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.1.tgz#0a3be479df549cca0e5d693ac402ff19537a6b7a" + integrity sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g== + luxon@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.3.0.tgz#d73ab5b5d2b49a461c47cedbc7e73309b4805b48" @@ -1059,11 +1146,23 @@ minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" +minimatch@^9.0.1: + version "9.0.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" + integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0": + version "7.0.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.3.tgz#05ea638da44e475037ed94d1c7efcc76a25e1974" + integrity sha512-LhbbwCfz3vsb12j/WkWQPZfKTsgqIe1Nf/ti1pKjYESGLHIVjWU96G9/ljLH4F9mWNVhlQOm0VySdAWzf05dpg== + mkdirp@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" @@ -1187,6 +1286,14 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== +path-scurry@^1.10.1: + version "1.10.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.10.1.tgz#9ba6bf5aa8500fe9fd67df4f0d9483b2b0bfc698" + integrity sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ== + dependencies: + lru-cache "^9.1.1 || ^10.0.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -1314,6 +1421,11 @@ signal-exit@^3.0.3, signal-exit@^3.0.7: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -1332,13 +1444,44 @@ source-map@^0.6.0: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +string-width-cjs@5.1.1, "string-width-cjs@npm:string-width@^4.2.0": + version "5.1.1" + resolved "https://registry.yarnpkg.com/string-width-cjs/-/string-width-cjs-5.1.1.tgz#9db7e00211027471870621778b089402cf9bdee3" + integrity sha512-N5/f0RNFtXPNDLtEvo8Y/+mH4xZWzIAnnqXPzCB4BBAQL8P3sHlz0dqDzTkGQoVsL56aCsORFFlyvA70k5yq3A== + +string-width@^4.1.0: + name string-width-cjs + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + 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" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: ansi-regex "^5.0.1" +strip-ansi@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" @@ -1514,6 +1657,24 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"