From 3a583ad327e7a16352cd11e85a9981afaf656ca2 Mon Sep 17 00:00:00 2001
From: Evan Strat <5790137+evan10s@users.noreply.github.com>
Date: Mon, 12 Feb 2024 00:01:16 -0500
Subject: [PATCH] feat(server): Restructure videos directory by label (#92)
BREAKING CHANGE: The structure of the videos directory has changed to include video labels as subdirectories, and you will need to adjust your workflow accordingly. Details of the new structure are available in the README at https://github.com/gafirst/match-uploader/blob/main/README.md#video-directory-structure.
---
.github/workflows/task-list-checker.yml | 14 +++++
README.md | 45 ++++++++++++++++
.../help/MissingMatchVideosHelp.vue | 4 --
.../help/NameMatchVideoFilesHelp.vue | 36 +++++++++----
.../components/matches/MatchVideoListItem.vue | 13 ++++-
.../matches/MatchVideosUploader.vue | 8 +--
client/src/stores/match.ts | 20 +++++--
client/src/types/MatchVideoInfo.ts | 1 +
server/.eslintrc.json | 3 +-
server/README.md | 2 +-
server/package.json | 1 +
server/src/models/MatchVideoInfo.ts | 5 +-
server/src/repos/FileStorageRepo.ts | 18 +++++--
server/src/repos/YouTubeRepo.ts | 19 ++++++-
server/src/services/MatchesService.ts | 54 +++++++------------
server/src/tasks/uploadVideo.ts | 54 ++++++++++++++++++-
server/src/util/{file.ts.ts => file.ts} | 0
server/yarn.lock | 53 +++++++++++++++---
18 files changed, 275 insertions(+), 75 deletions(-)
create mode 100644 .github/workflows/task-list-checker.yml
rename server/src/util/{file.ts.ts => file.ts} (100%)
diff --git a/.github/workflows/task-list-checker.yml b/.github/workflows/task-list-checker.yml
new file mode 100644
index 0000000..0a26cdd
--- /dev/null
+++ b/.github/workflows/task-list-checker.yml
@@ -0,0 +1,14 @@
+# https://github.com/Shopify/task-list-checker
+name: pr
+on:
+ pull_request:
+ types: [opened, edited, synchronize, reopened]
+jobs:
+ task-list-checker:
+ runs-on: ubuntu-latest
+ if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' }}
+ steps:
+ - name: Check for incomplete task list items
+ uses: Shopify/task-list-checker@main
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/README.md b/README.md
index c16ead2..839f47f 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,50 @@ To get started:
4. YouTube playlist mappings: If you have playlists that you'd like match videos added to, follow the instructions in this section to set this up.
5. Video description template: While no warning appears for this, you should double-check that the default video
description template fits your needs and adjust it as needed.
+ 6. Read below on how to structure your `videos` directory, which is where you'll place the video files for matches.
+
+### Video directory structure
+
+Match Uploader expects a specific directory structure for your videos. When running Match Uploader in Docker, you can
+mount any directory (such as one that your video production software writes recordings to) as the videos volume (for
+specifics, see [Docker volumes](#docker-volumes), below).
+
+The expected directory structure is as follows:
+```
+videos/
+├─ unlabeled/
+│ ├─ Qualification 1.mp4
+├─ $LABEL/
+│ ├─ Qualification 1.mp4
+```
+
+> [!TIP]
+> The `.mp4` video extension is just an example. You can use any file type that YouTube supports.
+
+A video label is an extra description for when you have multiple videos to upload for one match. Match Uploader will
+include the video label in the middle of the video title, e.g., `Qualification Match 1 - $LABEL - Event Name`.
+
+**What if I don't want a label in the video title?** A video with no label is labeled `unlabeled` (so you would put
+videos that should be unlabeled in a directory called `unlabeled`). This will title the video like
+`Qualification Match 1 - Event Name`.
+
+After being uploaded, videos are moved to a directory called `uploaded` within each label directory. (You don't need to
+create the `uploaded` directories; they'll get created automatically when needed.) For instance:
+```
+videos/
+├─ unlabeled/
+│ ├─ uploaded/
+│ │ ├─ Qualification 1.mp4
+| ├─ Qualification 2.mp4
+├─ $LABEL/
+│ ├─ uploaded/
+│ │ ├─ Qualification 1.mp4
+| ├─ Qualification 2.mp4
+```
+
+> [!CAUTION]
+> Don't mount your videos directory as a read-only Docker volume. Otherwise, the server won't be able to move videos to
+> the `uploaded` directories.
### Docker Compose setup in-depth
@@ -76,6 +120,7 @@ defined in the file that are not specified below; please leave those intact.
There are 3 required Docker volumes for the `web` and `worker` containers:
- **Videos:** Mount your local videos directory as a volume to `/home/node/app/server/videos`
+ - The directory structure is described in [Video directory structure](#video-directory-structure)
- **Environment variables:** Server environment variables located in `/home/node/app/server/env`
- Make a copy of [`server/env/production.env.example`](server/env/production.env.example) and fill in the values.
Descriptions of what you need to fill in are described [above](#environment-variables).
diff --git a/client/src/components/help/MissingMatchVideosHelp.vue b/client/src/components/help/MissingMatchVideosHelp.vue
index 5f40b66..c0817a9 100644
--- a/client/src/components/help/MissingMatchVideosHelp.vue
+++ b/client/src/components/help/MissingMatchVideosHelp.vue
@@ -9,10 +9,6 @@
Ensure your video files are named correctly (review How to name match video files).
-
- Go to Settings and confirm that the value of the Video search directory
- setting is correct.
-
diff --git a/client/src/components/help/NameMatchVideoFilesHelp.vue b/client/src/components/help/NameMatchVideoFilesHelp.vue
index d858fff..c37ca6d 100644
--- a/client/src/components/help/NameMatchVideoFilesHelp.vue
+++ b/client/src/components/help/NameMatchVideoFilesHelp.vue
@@ -5,6 +5,14 @@
How to name match video files
+
+ For a more detailed explanation, see the
+
+ Video directory structure section in the Match Uploader README.
+
+
File name matching is not case-sensitive.
@@ -12,19 +20,27 @@
accepted for uploads on YouTube.
+
+ Within the videos directory, place match videos within subdirectories for each video label (or
+ use unlabeled
for no label) that you have:
+
+
+videos/
+├─ unlabeled/
+│ ├─ Qualification 1.mp4
+│ ├─ Qualification 2.mp4
+├─ $LABEL/
+│ ├─ Qualification 1.mp4
+│ ├─ Qualification 2.mp4
+
Qualification matches:
- Qualification #[ Label].mp4
- Qualification #[ Label].mp4
- Examples: Qualification 1.mp4, Qualification 1 Overhead.mp4, Qualification 1.wav
+ $Label/Qualification #.mp4
+ Examples: unlabeled/Qualification 1.mp4, Overhead/Qualification 1.mp4
Double elimination playoff matches:
- Playoff #[ Label].mp4
-
- Best of 3 playoff matches:
- Quarterfinal #[ Label].mp4
- Semifinal #[ Label].mp4
- Final #[ Label].mp4
-
+ $Label/Playoff #.mp4
+ $Label/Final #.mp4
+ Examples: Overhead/Playoff 10.mp4, Feed B/Final 1.mp4
Be sure to set your playoff type in Settings so we know how to parse your playoff matches!
diff --git a/client/src/components/matches/MatchVideoListItem.vue b/client/src/components/matches/MatchVideoListItem.vue
index a4a3207..dc99467 100644
--- a/client/src/components/matches/MatchVideoListItem.vue
+++ b/client/src/components/matches/MatchVideoListItem.vue
@@ -8,7 +8,7 @@
size="large"
/>
-
+
();
const uploadStatus = computed(() => {
+ if (props.video.isUploaded) {
+ return "Uploaded";
+ }
+
if (props.video.isRequestingJob) {
return "Creating job";
}
@@ -122,6 +126,13 @@ const icon = computed((): {
icon: string;
color: string;
} => {
+ if (props.video.isUploaded) {
+ return {
+ icon: "mdi-cloud-check-variant",
+ color: "success",
+ };
+ }
+
if (props.video.isRequestingJob) {
return {
icon: "mdi-loading mdi-spin",
diff --git a/client/src/components/matches/MatchVideosUploader.vue b/client/src/components/matches/MatchVideosUploader.vue
index bab1c66..e9d14da 100644
--- a/client/src/components/matches/MatchVideosUploader.vue
+++ b/client/src/components/matches/MatchVideosUploader.vue
@@ -44,7 +44,6 @@
color="success"
variant="tonal"
icon="mdi-check-circle"
- class="mt-2 mb-4"
>
All videos uploaded!
@@ -57,8 +56,8 @@
>
{{ queueAllBtnText }}
-
-
+
+
@@ -97,6 +96,9 @@ const queueAllBtnText = computed(() => {
if (matchStore.uploadInProgress) {
return "Uploading...";
}
+ if (matchStore.someMatchVideosUploaded) {
+ return "Queue all remaining";
+ }
return "Queue all";
});
diff --git a/client/src/stores/match.ts b/client/src/stores/match.ts
index 5be4988..6b2eb32 100644
--- a/client/src/stores/match.ts
+++ b/client/src/stores/match.ts
@@ -82,14 +82,21 @@ export const useMatchStore = defineStore("match", () => {
const allMatchVideosQueued = computed(() => {
return matchVideos.value.length > 0 &&
- matchVideos.value.every(video => !!video.workerJobId);
+ matchVideos.value.every(video => video.isUploaded || !!video.workerJobId);
});
+ function videoIsUploaded(video: MatchVideoInfo): boolean {
+ return video.isUploaded || workerStore.jobHasStatus(video.workerJobId,[WorkerJobStatus.COMPLETED]);
+ }
+
const allMatchVideosUploaded = computed(() => {
return matchVideos.value.length > 0 &&
- matchVideos.value.every(
- video => workerStore.jobHasStatus(video.workerJobId,[WorkerJobStatus.COMPLETED]),
- );
+ matchVideos.value.every(video => videoIsUploaded(video));
+ });
+
+ const someMatchVideosUploaded = computed(() => {
+ return matchVideos.value.length > 0 &&
+ matchVideos.value.some(video => videoIsUploaded(video));
});
async function uploadVideo(video: MatchVideoInfo): Promise {
@@ -153,7 +160,9 @@ export const useMatchStore = defineStore("match", () => {
async function uploadVideos(): Promise {
uploadInProgress.value = true;
for (const video of matchVideos.value) {
- await uploadVideo(video);
+ if (!video.isUploaded) {
+ await uploadVideo(video);
+ }
}
uploadInProgress.value = false;
}
@@ -232,6 +241,7 @@ export const useMatchStore = defineStore("match", () => {
postUploadStepsSucceeded,
selectMatch,
selectedMatchKey,
+ someMatchVideosUploaded,
uploadInProgress,
uploadSingleVideo,
uploadVideos,
diff --git a/client/src/types/MatchVideoInfo.ts b/client/src/types/MatchVideoInfo.ts
index cfaca29..b9f9ac1 100644
--- a/client/src/types/MatchVideoInfo.ts
+++ b/client/src/types/MatchVideoInfo.ts
@@ -8,4 +8,5 @@ export interface MatchVideoInfo {
workerJobId: string | null;
jobCreationError: string | null;
isRequestingJob: boolean;
+ isUploaded: boolean;
}
diff --git a/server/.eslintrc.json b/server/.eslintrc.json
index 87407ab..f4dd6f8 100644
--- a/server/.eslintrc.json
+++ b/server/.eslintrc.json
@@ -50,7 +50,8 @@
"@typescript-eslint/no-misused-promises": "off",
"no-extra-boolean-cast": "off",
"node/no-missing-import": "off",
- "node/no-unpublished-import": "off"
+ "node/no-unpublished-import": "off",
+ "no-unused-vars": "off"
},
"ignorePatterns": ["**/.eslintrc.*", "src/public/"],
"settings": {
diff --git a/server/README.md b/server/README.md
index 0ee8995..d6eac70 100644
--- a/server/README.md
+++ b/server/README.md
@@ -34,7 +34,7 @@ Run the production build (Must be built first).
Run production build with a different env file.
## Linting
-## Linting
+
```
# lint
yarn lint
diff --git a/server/package.json b/server/package.json
index 3b5f1a2..668616e 100644
--- a/server/package.json
+++ b/server/package.json
@@ -48,6 +48,7 @@
"module-alias": "^2.2.2",
"morgan": "^1.10.0",
"mustache": "^4.2.0",
+ "mv": "^2.1.1",
"node-fetch": "2",
"prisma": "^5.7.1",
"sanitize-filename": "^1.6.3",
diff --git a/server/src/models/MatchVideoInfo.ts b/server/src/models/MatchVideoInfo.ts
index f4a1742..ff5117b 100644
--- a/server/src/models/MatchVideoInfo.ts
+++ b/server/src/models/MatchVideoInfo.ts
@@ -2,11 +2,13 @@ export class MatchVideoInfo {
path: string;
videoLabel: string | null;
videoTitle: string;
+ isUploaded: boolean;
- constructor(path: string, videoLabel: string | null, videoTitle: string) {
+ constructor(path: string, videoLabel: string | null, videoTitle: string, isUploaded: boolean = false) {
this.path = path;
this.videoLabel = videoLabel;
this.videoTitle = videoTitle;
+ this.isUploaded = isUploaded;
}
toJson(): object {
@@ -14,6 +16,7 @@ export class MatchVideoInfo {
path: this.path,
videoLabel: this.videoLabel,
videoTitle: this.videoTitle,
+ isUploaded: this.isUploaded,
};
}
}
diff --git a/server/src/repos/FileStorageRepo.ts b/server/src/repos/FileStorageRepo.ts
index e03bac0..c293e11 100644
--- a/server/src/repos/FileStorageRepo.ts
+++ b/server/src/repos/FileStorageRepo.ts
@@ -1,16 +1,28 @@
import { type PathLike } from "fs";
import fastGlob from "fast-glob";
import logger from "jet-logger";
-import { isFileDoesNotExistError } from "@src/util/file.ts";
+import { isFileDoesNotExistError } from "@src/util/file";
import { copyFile, readFile, writeFile } from "fs/promises";
const DEFAULT_ENCODING = "utf-8";
-export async function getFilesMatchingPattern(dir: PathLike, pattern: string): Promise {
- logger.info(`getFilesMatchingPattern: dir: ${dir}, pattern: ${pattern}`);
+/**
+ * Get all files in a directory that match a given pattern (glob syntax from the fast-glob library is available)
+ *
+ * @param dir What to set as the current working directory for glob lookup
+ * @param pattern Glob pattern to match files against
+ * @param depth Corresponds to fast-glob's `deep` option, see fast-glob docs for more information
+ */
+export async function getFilesMatchingPattern(
+ dir: PathLike,
+ pattern: string,
+ depth: number = Infinity,
+): Promise {
+ logger.info(`getFilesMatchingPattern: dir: ${dir}, pattern: ${pattern}, deep: ${depth}`);
return await fastGlob.async(pattern, {
cwd: dir.toString(), // cwd is relative to the directory the server is running out of
onlyFiles: true,
+ deep: depth,
});
}
diff --git a/server/src/repos/YouTubeRepo.ts b/server/src/repos/YouTubeRepo.ts
index ee6cb0e..eb72454 100644
--- a/server/src/repos/YouTubeRepo.ts
+++ b/server/src/repos/YouTubeRepo.ts
@@ -96,6 +96,22 @@ export async function uploadYouTubeVideo(title: string,
};
}
+ const videoPathForUpload = path.join(videoSearchDirectory, videoPath);
+
+ if (!(await fs.exists(videoPathForUpload))) {
+ return {
+ error: `File ${videoPathForUpload} does not exist`,
+ };
+ }
+
+ let uploadBody: fs.ReadStream;
+ try {
+ uploadBody = fs.createReadStream(videoPathForUpload);
+ } catch (e: unknown) {
+ return {
+ error: `Error reading file ${videoPathForUpload} for upload: ${JSON.stringify(e)}`,
+ };
+ }
const uploadParams = {
part: ["snippet", "status"],
requestBody: {
@@ -109,8 +125,7 @@ export async function uploadYouTubeVideo(title: string,
},
},
media: {
- // TODO(#78): Confirm that the file actually exists first to avoid crashing the Node process otherwise
- body: fs.createReadStream(path.join(videoSearchDirectory, videoPath)),
+ body: uploadBody,
},
};
diff --git a/server/src/services/MatchesService.ts b/server/src/services/MatchesService.ts
index 80b3c68..5aa40ee 100644
--- a/server/src/services/MatchesService.ts
+++ b/server/src/services/MatchesService.ts
@@ -10,8 +10,7 @@ import { type TbaFrcTeam } from "@src/models/theBlueAlliance/tbaFrcTeam";
import { FrcEventsRepo } from "@src/repos/FrcEventsRepo";
import { PlayoffsType } from "@src/models/PlayoffsType";
import { getFrcApiMatchNumber } from "@src/models/frcEvents/frcScoredMatch";
-import { CompLevel, toFrcEventsUrlTournamentLevel } from "@src/models/CompLevel";
-import logger from "jet-logger";
+import { toFrcEventsUrlTournamentLevel } from "@src/models/CompLevel";
import Mustache from "mustache";
export async function getLocalVideoFilesForMatch(matchKey: MatchKey): Promise {
@@ -20,40 +19,24 @@ export async function getLocalVideoFilesForMatch(matchKey: MatchKey): Promise {
- const proposedVideoLabel = parseVideoLabelsRegex.exec(file);
-
- // Filter out this file if the pattern is incorrect
- if (!proposedVideoLabel || proposedVideoLabel.length < 3) {
- return false;
- }
-
- // Pulls the actual match number out of the file name using the 2nd capture group in parseVideoLabelsRegex
- const fileMatchNumber = proposedVideoLabel[1];
-
- if (!fileMatchNumber) {
- return false;
- }
-
- // In double eliminations, playoff matches (before finals) have their sequence number in the set number, not
- // the match number
- if (match.key.playoffsType === PlayoffsType.DoubleElimination && match.key.compLevel === CompLevel.Semifinal) {
- return Number.parseInt(fileMatchNumber, 10) === match.key.setNumber;
- }
-
- // Otherwise, check that when parsed as a number, the match number in the file name matches the match for which
- // we are finding videos
- return Number.parseInt(fileMatchNumber, 10) === match.key.matchNumber;
- }).map((file) => {
- const proposedVideoLabel = parseVideoLabelsRegex.exec(file);
+ const files = await getFilesMatchingPattern(videoSearchDirectory, `**/${videoFileMatchingName}.*`, 2);
+ const uploadedFiles = await getFilesMatchingPattern(
+ videoSearchDirectory,
+ `**/uploaded/${videoFileMatchingName}.*`,
+ 3);
+
+ files.push(...uploadedFiles);
+ return files
+ .filter(filePath => filePath.includes("/")) // Files must be within a subdirectory
+ .map(filePath => {
+ // Video label is the first part of the file path
+ const proposedVideoLabel = filePath.split("/")[0];
let videoLabel: string | null = null;
let videoTitle: string;
- if (proposedVideoLabel !== null && proposedVideoLabel.length >= 3 && proposedVideoLabel[2] !== "") {
- videoLabel = proposedVideoLabel[2];
+ // Don't set a video label if the proposed label name is "unlabeled" (case-insensitive)
+ if (proposedVideoLabel.toLowerCase() !== "unlabeled") {
+ videoLabel = proposedVideoLabel;
}
if (!videoLabel) {
@@ -62,7 +45,9 @@ export async function getLocalVideoFilesForMatch(matchKey: MatchKey): Promise {
const templateString = await getDescriptionTemplate();
- logger.info(templateString);
const matchKey = match.key;
const matchInfo = await getMatch(matchKey);
diff --git a/server/src/tasks/uploadVideo.ts b/server/src/tasks/uploadVideo.ts
index 7427ab9..8d1ce24 100644
--- a/server/src/tasks/uploadVideo.ts
+++ b/server/src/tasks/uploadVideo.ts
@@ -4,12 +4,14 @@ import {
isYouTubeVideoUploadError, isYouTubeVideoUploadInSandboxMode,
isYouTubeVideoUploadSuccess,
} from "@src/models/YouTubeVideoUploadResult";
-import { type JobHelpers } from "graphile-worker";
+import { type JobHelpers, type Logger } from "graphile-worker";
import { prisma } from "@src/worker";
import { handleMatchVideoPostUploadSteps, uploadYouTubeVideo } from "@src/repos/YouTubeRepo";
import MatchKey from "@src/models/MatchKey";
import { type PlayoffsType } from "@src/models/PlayoffsType";
import { isPrismaClientKnownRequestError } from "@src/util/prisma";
+import path from "path";
+import fs from "fs-extra";
// This file runs on the worker, not the server. This means that functions in this file should not
// depend on anything in YouTubeService.ts or directly or indirectly depend on anything in server/src/index.ts (e.g.,
@@ -39,9 +41,48 @@ function assertIsUploadVideoTaskPayload(payload: unknown): asserts payload is Up
}
}
+/**
+ * Moves a video file from the videos directory to the uploaded directory
+ *
+ * @param logger
+ * @param videosDirectory The directory where videos are stored
+ * @param videoPath The path to the video file within the videos directory
+ * @param dryRun When true, just prints the from/to paths instead of moving the file
+ */
+async function moveToUploadedDirectory(
+ logger: Logger,
+ videosDirectory: string,
+ videoPath: string,
+ dryRun: boolean = false,
+): Promise {
+ // Paths should be like "$label/video.ext" (this is separate from the video search directory)
+ // New path would be "$label/uploaded/video.ext"
+ const fromPath = path.join(videosDirectory, videoPath);
+ const splitPath = videoPath.split("/");
+ const toPath = path.join(videosDirectory, splitPath[0], "uploaded", splitPath[1]);
+
+ if (dryRun) {
+ logger.info(`[Sandbox mode] Woul dhave moved video file ${fromPath} to uploaded directory ${toPath}`);
+ }
+
+ return await fs.move(fromPath, toPath);
+}
+
+/**
+ * Checks if a video path is allowed to be uploaded
+ * @param videoPath The path to the video file
+ */
+function isAllowedUploadPath(videoPath: string): boolean {
+ return !videoPath.includes("uploaded");
+}
+
export async function uploadVideo(payload: unknown, { logger, job }: JobHelpers): Promise {
assertIsUploadVideoTaskPayload(payload);
+ if (!isAllowedUploadPath(payload.videoPath)) {
+ throw new Error(`Video path ${payload.videoPath} may not be uploaded`);
+ }
+
const settings = await getSettings();
const matchKeyObject = MatchKey.fromString(payload.matchKey, payload.playoffsType as PlayoffsType);
logger.info(`Uploading video ${payload.title} for match ${matchKeyObject.matchKey}`);
@@ -72,6 +113,12 @@ export async function uploadVideo(payload: unknown, { logger, job }: JobHelpers)
}
}
+ try {
+ await moveToUploadedDirectory(logger, settings.videoSearchDirectory, payload.videoPath);
+ } catch (e: unknown) {
+ logger.error(`Unable to move video file ${payload.videoPath} to uploaded directory: ${JSON.stringify(e)}`);
+ }
+
const postUploadStepsResult =
await handleMatchVideoPostUploadSteps(uploadResult.videoId, payload.label, matchKeyObject);
@@ -85,7 +132,7 @@ export async function uploadVideo(payload: unknown, { logger, job }: JobHelpers)
linkedOnTheBlueAlliance: postUploadStepsResult.linkOnTheBlueAlliance,
},
});
- } catch (e) { // Catch the prisma update error
+ } catch (e: unknown) { // Catch the prisma update error
if (isPrismaClientKnownRequestError(e, "P2025")) {
logger.warn(`Unable to record post-upload step results for job with ID ${job.id}: Job does ` +
"not exist in match-uploader WorkerJob table");
@@ -99,6 +146,9 @@ export async function uploadVideo(payload: unknown, { logger, job }: JobHelpers)
throw new Error(uploadResult.error);
} else if (isYouTubeVideoUploadInSandboxMode(uploadResult)) {
logger.info(`Successfully uploaded video ${payload.title} in sandbox mode`);
+
+ await moveToUploadedDirectory(logger, settings.videoSearchDirectory, payload.videoPath, true);
+
try {
await prisma.workerJob.update({
where: {
diff --git a/server/src/util/file.ts.ts b/server/src/util/file.ts
similarity index 100%
rename from server/src/util/file.ts.ts
rename to server/src/util/file.ts
diff --git a/server/yarn.lock b/server/yarn.lock
index 565044c..04c4763 100644
--- a/server/yarn.lock
+++ b/server/yarn.lock
@@ -1942,6 +1942,17 @@ glob@^10.2.2:
minipass "^5.0.0 || ^6.0.2"
path-scurry "^1.7.0"
+glob@^6.0.1:
+ version "6.0.4"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22"
+ integrity sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==
+ dependencies:
+ inflight "^1.0.4"
+ inherits "2"
+ minimatch "2 || 3"
+ once "^1.3.0"
+ path-is-absolute "^1.0.0"
+
glob@^7.1.3:
version "7.2.3"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
@@ -2604,6 +2615,13 @@ mime@2.6.0:
resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367"
integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
+"minimatch@2 || 3", minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
+ integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
+ dependencies:
+ brace-expansion "^1.1.7"
+
minimatch@9.0.3:
version "9.0.3"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825"
@@ -2611,13 +2629,6 @@ minimatch@9.0.3:
dependencies:
brace-expansion "^2.0.1"
-minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
- version "3.1.2"
- resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
- integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
- dependencies:
- brace-expansion "^1.1.7"
-
minimatch@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253"
@@ -2635,6 +2646,13 @@ minimist@^1.2.0, minimist@^1.2.6:
resolved "https://registry.yarnpkg.com/minipass/-/minipass-6.0.2.tgz#542844b6c4ce95b202c0995b0a471f1229de4c81"
integrity sha512-MzWSV5nYVT7mVyWCwn2o7JH13w2TBRmmSqSRCKzTw+lmft9X4z+3wjvs06Tzijo5z4W/kahUCDpRXTF+ZrmF/w==
+mkdirp@~0.5.1:
+ version "0.5.6"
+ resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
+ integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
+ dependencies:
+ minimist "^1.2.6"
+
mock-fs@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-5.2.0.tgz#3502a9499c84c0a1218ee4bf92ae5bf2ea9b2b5e"
@@ -2676,11 +2694,25 @@ mustache@^4.2.0:
resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64"
integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==
+mv@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/mv/-/mv-2.1.1.tgz#ae6ce0d6f6d5e0a4f7d893798d03c1ea9559b6a2"
+ integrity sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==
+ dependencies:
+ mkdirp "~0.5.1"
+ ncp "~2.0.0"
+ rimraf "~2.4.0"
+
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
+ncp@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
+ integrity sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==
+
negotiator@0.6.3:
version "0.6.3"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
@@ -3173,6 +3205,13 @@ rimraf@^3.0.2:
dependencies:
glob "^7.1.3"
+rimraf@~2.4.0:
+ version "2.4.5"
+ resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da"
+ integrity sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==
+ dependencies:
+ glob "^6.0.1"
+
run-parallel@^1.1.9:
version "1.2.0"
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"