@mux-magic/tools 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,220 @@
1
+ import { join } from "node:path"
2
+ import { vol } from "memfs"
3
+ import { EmptyError, firstValueFrom } from "rxjs"
4
+ import { describe, expect, test } from "vitest"
5
+ import { captureLogMessage } from "./captureLogMessage.js"
6
+ import {
7
+ createRenameFileOrFolderObservable,
8
+ getLastItemInFilePath,
9
+ renameFileOrFolder,
10
+ } from "./createRenameFileOrFolder.js"
11
+
12
+ describe(getLastItemInFilePath.name, () => {
13
+ test("gets filename when no path", async () => {
14
+ expect(
15
+ getLastItemInFilePath("Star Wars (1977).mkv"),
16
+ ).toBe("Star Wars (1977)")
17
+ })
18
+
19
+ test("gets filename from shallow path", async () => {
20
+ expect(
21
+ getLastItemInFilePath(
22
+ "~/movies/Star Wars (1977) {edition-4K77}.mkv",
23
+ ),
24
+ ).toBe("Star Wars (1977) {edition-4K77}")
25
+ })
26
+
27
+ test("gets filename from deep path", async () => {
28
+ expect(
29
+ getLastItemInFilePath(
30
+ "~/movies/Star Wars (1977)/Star Wars (1977) {edition-4K77}.mkv",
31
+ ),
32
+ ).toBe("Star Wars (1977) {edition-4K77}")
33
+ })
34
+
35
+ test("gets folder name from path if no file", async () => {
36
+ expect(
37
+ getLastItemInFilePath("~/movies/Star Wars (1977)"),
38
+ ).toBe("Star Wars (1977)")
39
+ })
40
+ })
41
+
42
+ describe(renameFileOrFolder.name, () => {
43
+ test("errors when the current file is missing", async () => {
44
+ expect(
45
+ firstValueFrom(
46
+ renameFileOrFolder({
47
+ newPath:
48
+ "/movies/Star Wars (1977)/Star Wars (1977) {edition-4K77}.mkv",
49
+ oldPath:
50
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv",
51
+ }),
52
+ ),
53
+ ).rejects.toThrow("no such file or directory")
54
+ })
55
+
56
+ test("errors when the renamed file already exists", async () => {
57
+ vol.fromJSON({
58
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv": "",
59
+ "/movies/Star Wars (1977)/Star Wars (1977) {edition-4K77}.mkv":
60
+ "",
61
+ })
62
+
63
+ expect(
64
+ firstValueFrom(
65
+ renameFileOrFolder({
66
+ newPath:
67
+ "/movies/Star Wars (1977)/Star Wars (1977) {edition-4K77}.mkv",
68
+ oldPath:
69
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv",
70
+ }),
71
+ ),
72
+ ).rejects.toThrow("already exists")
73
+ })
74
+
75
+ test("renames a file", async () => {
76
+ vol.fromJSON({
77
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv": "",
78
+ })
79
+
80
+ expect(
81
+ firstValueFrom(
82
+ renameFileOrFolder({
83
+ newPath:
84
+ "/movies/Star Wars (1977)/Star Wars (1977) {edition-4K77}.mkv",
85
+ oldPath:
86
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv",
87
+ }),
88
+ ),
89
+ ).resolves.toBeUndefined()
90
+ })
91
+ })
92
+
93
+ describe(createRenameFileOrFolderObservable.name, () => {
94
+ test("completes when the old and new names are the same", async () => {
95
+ expect(
96
+ firstValueFrom(
97
+ createRenameFileOrFolderObservable({
98
+ fullPath:
99
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv",
100
+ sourcePath: "/movies/Star Wars (1977)",
101
+ })("Star Wars (1977)"),
102
+ ),
103
+ ).rejects.toThrow(EmptyError)
104
+ })
105
+
106
+ test("errors when the current file is missing", async () => {
107
+ await captureLogMessage(
108
+ "error",
109
+ async (logMessageSpy) => {
110
+ await expect(
111
+ firstValueFrom(
112
+ createRenameFileOrFolderObservable({
113
+ fullPath:
114
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv",
115
+ sourcePath: "/movies/Star Wars (1977)",
116
+ })("Star Wars (1977) {edition-4K77}"),
117
+ ),
118
+ ).rejects.toThrow(EmptyError)
119
+
120
+ expect(
121
+ logMessageSpy.mock.calls[0].find(
122
+ (error) =>
123
+ error instanceof Error && error.message,
124
+ ).message,
125
+ ).toContain("no such file or directory")
126
+ },
127
+ )
128
+ })
129
+
130
+ test("errors when the renamed file already exists", async () => {
131
+ vol.fromJSON({
132
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv": "",
133
+ "/movies/Star Wars (1977)/Star Wars (1977) {edition-4K77}.mkv":
134
+ "",
135
+ })
136
+
137
+ await captureLogMessage(
138
+ "error",
139
+ async (logMessageSpy) => {
140
+ await expect(
141
+ firstValueFrom(
142
+ createRenameFileOrFolderObservable({
143
+ fullPath:
144
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv",
145
+ sourcePath: "/movies/Star Wars (1977)",
146
+ })("Star Wars (1977) {edition-4K77}"),
147
+ ),
148
+ ).rejects.toThrow(EmptyError)
149
+
150
+ expect(
151
+ logMessageSpy.mock.calls[0].find((text) =>
152
+ text.includes("already exists"),
153
+ ),
154
+ ).toContain("already exists")
155
+ },
156
+ )
157
+ })
158
+
159
+ test("renames a file", async () => {
160
+ vol.fromJSON({
161
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv": "",
162
+ })
163
+
164
+ await captureLogMessage(
165
+ "info",
166
+ async (logMessageSpy) => {
167
+ await expect(
168
+ firstValueFrom(
169
+ createRenameFileOrFolderObservable({
170
+ fullPath:
171
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv",
172
+ sourcePath: "/movies/Star Wars (1977)",
173
+ })("Star Wars (1977) {edition-4K77}"),
174
+ ),
175
+ ).resolves.toEqual({
176
+ newPath: join(
177
+ "/movies/Star Wars (1977)",
178
+ "Star Wars (1977) {edition-4K77}.mkv",
179
+ ),
180
+ oldPath:
181
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv",
182
+ })
183
+
184
+ const logMessageArgs = logMessageSpy.mock.calls[0]
185
+
186
+ expect(
187
+ logMessageArgs.find((text) =>
188
+ text.includes("RENAMED"),
189
+ ),
190
+ ).toContain("RENAMED")
191
+
192
+ expect(
193
+ logMessageArgs.find((text) =>
194
+ text.includes(
195
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv",
196
+ ),
197
+ ),
198
+ ).toContain(
199
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv",
200
+ )
201
+
202
+ expect(
203
+ logMessageArgs.find((text) =>
204
+ text.includes(
205
+ join(
206
+ "/movies/Star Wars (1977)",
207
+ "Star Wars (1977) {edition-4K77}.mkv",
208
+ ),
209
+ ),
210
+ ),
211
+ ).toContain(
212
+ join(
213
+ "/movies/Star Wars (1977)",
214
+ "Star Wars (1977) {edition-4K77}.mkv",
215
+ ),
216
+ )
217
+ },
218
+ )
219
+ })
220
+ })
@@ -0,0 +1,79 @@
1
+ import { rename, stat } from "node:fs/promises"
2
+ import { basename, extname, join } from "node:path"
3
+ import {
4
+ catchError,
5
+ concatMap,
6
+ filter,
7
+ from,
8
+ map,
9
+ of,
10
+ tap,
11
+ } from "rxjs"
12
+
13
+ import { logAndSwallowPipelineError } from "./logAndSwallowPipelineError.js"
14
+ import { logInfo } from "./logMessage.js"
15
+
16
+ export const getLastItemInFilePath = (filePath: string) =>
17
+ basename(filePath, extname(filePath))
18
+
19
+ export const renameFileOrFolder = ({
20
+ newPath,
21
+ oldPath,
22
+ }: {
23
+ newPath: string
24
+ oldPath: string
25
+ }) =>
26
+ from(stat(newPath)).pipe(
27
+ // If the file or folder doesn't already exist, this function throws an error. We can safely perform the rename in that case..
28
+ catchError(() => rename(oldPath, newPath)),
29
+ // If the `stat` succeeded, then we'll hit this `tap`. That means we can't rename the file because the one we're renaming to already exists.
30
+ tap((stats) => {
31
+ if (stats) {
32
+ throw "File or folder already exists for".concat(
33
+ " ",
34
+ '"',
35
+ getLastItemInFilePath(newPath),
36
+ '".',
37
+ " ",
38
+ `Cannot rename "${oldPath}".`,
39
+ )
40
+ }
41
+ }),
42
+ )
43
+
44
+ export const createRenameFileOrFolderObservable =
45
+ ({
46
+ fullPath: oldPath,
47
+ sourcePath,
48
+ }: {
49
+ fullPath: string
50
+ sourcePath: string
51
+ }) =>
52
+ (newName: string) =>
53
+ of({
54
+ oldPath,
55
+ newPath: join(sourcePath, newName).concat(
56
+ extname(oldPath),
57
+ ),
58
+ }).pipe(
59
+ filter(({ newPath, oldPath }) => newPath !== oldPath),
60
+ concatMap(({ newPath, oldPath }) =>
61
+ renameFileOrFolder({
62
+ newPath,
63
+ oldPath,
64
+ }).pipe(
65
+ tap(() => {
66
+ logInfo("RENAMED", oldPath, newPath)
67
+ }),
68
+ // Emit { oldPath, newPath } instead of void so callers
69
+ // (renameDemos / renameMovieClipDownloads / nameAnimeEpisodes /
70
+ // nameTvShowEpisodes — every command that streams renameFile
71
+ // calls into job.results) get a useful per-rename record
72
+ // instead of a chain of nulls.
73
+ map(() => ({ newPath, oldPath })),
74
+ ),
75
+ ),
76
+ logAndSwallowPipelineError(
77
+ createRenameFileOrFolderObservable,
78
+ ),
79
+ )
@@ -0,0 +1,30 @@
1
+ import { resolve } from "node:path"
2
+ import { fileURLToPath } from "node:url"
3
+ import { ESLint } from "eslint"
4
+ import { expect, test } from "vitest"
5
+
6
+ const workspaceRoot = resolve(
7
+ fileURLToPath(import.meta.url),
8
+ "../../../..",
9
+ )
10
+
11
+ const fixturePath = resolve(
12
+ workspaceRoot,
13
+ "packages/tools/src/__fixtures__/badBooleanName.ts",
14
+ )
15
+
16
+ test("boolean variable without is/has prefix triggers naming-convention error", async () => {
17
+ const eslint = new ESLint({ cwd: workspaceRoot })
18
+ const [result] = await eslint.lintFiles([fixturePath])
19
+ // suppressedMessages items are LintMessage objects extended with `suppressions`
20
+ // (not wrapped in a { message } envelope), so spread them directly.
21
+ const namingViolations = [
22
+ ...result.messages,
23
+ ...result.suppressedMessages,
24
+ ].filter(
25
+ (message) =>
26
+ message.ruleId ===
27
+ "@typescript-eslint/naming-convention",
28
+ )
29
+ expect(namingViolations.length).toBeGreaterThan(0)
30
+ }, 30_000)
@@ -0,0 +1,26 @@
1
+ import { resolve } from "node:path"
2
+ import { fileURLToPath } from "node:url"
3
+ import { ESLint } from "eslint"
4
+ import { expect, test } from "vitest"
5
+
6
+ const workspaceRoot = resolve(
7
+ fileURLToPath(import.meta.url),
8
+ "../../../..",
9
+ )
10
+
11
+ const fixturePath = resolve(
12
+ workspaceRoot,
13
+ "packages/web/src/__eslintFixtures__/localApiShape.tsx",
14
+ )
15
+
16
+ test("local API-shape type declarations in packages/web trigger no-restricted-syntax error", async () => {
17
+ const eslint = new ESLint({ cwd: workspaceRoot })
18
+ const [result] = await eslint.lintFiles([fixturePath])
19
+ const apiShapeViolations = [
20
+ ...result.messages,
21
+ ...result.suppressedMessages,
22
+ ].filter(
23
+ (message) => message.ruleId === "no-restricted-syntax",
24
+ )
25
+ expect(apiShapeViolations.length).toBeGreaterThan(0)
26
+ }, 30_000)
@@ -0,0 +1,72 @@
1
+ import { readdir, stat } from "node:fs/promises"
2
+ import { join } from "node:path"
3
+ import {
4
+ catchError,
5
+ concatAll,
6
+ concatMap,
7
+ defer,
8
+ EMPTY,
9
+ filter,
10
+ from,
11
+ map,
12
+ type Observable,
13
+ type OperatorFunction,
14
+ } from "rxjs"
15
+
16
+ import {
17
+ createRenameFileOrFolderObservable,
18
+ getLastItemInFilePath,
19
+ } from "./createRenameFileOrFolder.js"
20
+ import { logAndRethrowPipelineError } from "./logAndRethrowPipelineError.js"
21
+
22
+ export type FileInfo = {
23
+ filename: string
24
+ fullPath: string
25
+ renameFile: (renamedFilename: string) => Observable<{
26
+ newPath: string
27
+ oldPath: string
28
+ }>
29
+ }
30
+
31
+ export const filterFileAtPath = <PipelineValue>(
32
+ getFullPath: (pipelineValue: PipelineValue) => string,
33
+ ): OperatorFunction<PipelineValue, PipelineValue> =>
34
+ concatMap((pipelineValue) =>
35
+ from(stat(getFullPath(pipelineValue))).pipe(
36
+ filter((stats) => stats.isFile()),
37
+ map(() => pipelineValue),
38
+ catchError((error) => {
39
+ if (error.code === "ENOENT") {
40
+ return EMPTY
41
+ }
42
+
43
+ throw error
44
+ }),
45
+ ),
46
+ )
47
+
48
+ export const getFiles = ({
49
+ sourcePath,
50
+ }: {
51
+ sourcePath: string
52
+ }): Observable<FileInfo> =>
53
+ defer(() => readdir(sourcePath)).pipe(
54
+ concatAll(),
55
+ map((filePath) => ({
56
+ filename: getLastItemInFilePath(filePath),
57
+ fullPath: join(sourcePath, filePath),
58
+ })),
59
+ filterFileAtPath(({ fullPath }) => fullPath),
60
+ map(
61
+ ({ filename, fullPath }) =>
62
+ ({
63
+ filename,
64
+ fullPath,
65
+ renameFile: createRenameFileOrFolderObservable({
66
+ fullPath,
67
+ sourcePath,
68
+ }),
69
+ }) satisfies FileInfo as FileInfo,
70
+ ),
71
+ logAndRethrowPipelineError(getFiles),
72
+ )
@@ -0,0 +1,158 @@
1
+ import { join } from "node:path"
2
+ import { vol } from "memfs"
3
+ import { firstValueFrom, toArray } from "rxjs"
4
+ import { beforeEach, describe, expect, test } from "vitest"
5
+
6
+ import { captureLogMessage } from "./captureLogMessage.js"
7
+ import type { FileInfo } from "./getFiles.js"
8
+ import { getFilesAtDepth } from "./getFilesAtDepth.js"
9
+
10
+ describe(getFilesAtDepth.name, () => {
11
+ beforeEach(() => {
12
+ vol.fromJSON({
13
+ "/demos/Dolby/[Dolby] 747 (Audio) {FHD SDR & Dolby Atmos TrueHD}.mkv":
14
+ "",
15
+ "/movies/Star Wars (1977)/Star Wars (1977).mkv": "",
16
+ "/movies/Star Wars (1977)/Star Wars (1977) {edition-4K77}.mkv":
17
+ "",
18
+ "/movies/Super Mario Bros (1993)/Super Mario Bros (1993).mkv":
19
+ "",
20
+ })
21
+ })
22
+
23
+ test("errors if source path can't be found", async () => {
24
+ captureLogMessage("error", async () => {
25
+ expect(
26
+ firstValueFrom(
27
+ getFilesAtDepth({
28
+ depth: 0,
29
+ sourcePath: "non-existent-path",
30
+ }),
31
+ ),
32
+ ).rejects.toThrow("ENOENT")
33
+ })
34
+ })
35
+
36
+ test("emits no files when source only contains folders", async () => {
37
+ await expect(
38
+ firstValueFrom(
39
+ getFilesAtDepth({
40
+ depth: 0,
41
+ sourcePath: "/movies",
42
+ }).pipe(toArray()),
43
+ ),
44
+ ).resolves.toEqual([] satisfies FileInfo[])
45
+ })
46
+
47
+ test("emits files when source contains files", async () => {
48
+ const expected: FileInfo[] = [
49
+ {
50
+ filename: "Star Wars (1977)",
51
+ fullPath: join(
52
+ "/movies/Star Wars (1977)",
53
+ "Star Wars (1977).mkv",
54
+ ),
55
+ renameFile: expect.any(Function),
56
+ },
57
+ {
58
+ filename: "Star Wars (1977) {edition-4K77}",
59
+ fullPath: join(
60
+ "/movies/Star Wars (1977)",
61
+ "Star Wars (1977) {edition-4K77}.mkv",
62
+ ),
63
+ renameFile: expect.any(Function),
64
+ },
65
+ ]
66
+ const actual = await firstValueFrom(
67
+ getFilesAtDepth({
68
+ depth: 0,
69
+ sourcePath: "/movies/Star Wars (1977)",
70
+ }).pipe(toArray()),
71
+ )
72
+ expect(actual).toEqual(expect.arrayContaining(expected))
73
+ expect(actual).toHaveLength(expected.length)
74
+ })
75
+
76
+ test("emits files when source contains files 1-level deep", async () => {
77
+ const expected: FileInfo[] = [
78
+ {
79
+ filename: "Star Wars (1977)",
80
+ fullPath: join(
81
+ "/movies/Star Wars (1977)",
82
+ "Star Wars (1977).mkv",
83
+ ),
84
+ renameFile: expect.any(Function),
85
+ },
86
+ {
87
+ filename: "Star Wars (1977) {edition-4K77}",
88
+ fullPath: join(
89
+ "/movies/Star Wars (1977)",
90
+ "Star Wars (1977) {edition-4K77}.mkv",
91
+ ),
92
+ renameFile: expect.any(Function),
93
+ },
94
+ {
95
+ filename: "Super Mario Bros (1993)",
96
+ fullPath: join(
97
+ "/movies/Super Mario Bros (1993)",
98
+ "Super Mario Bros (1993).mkv",
99
+ ),
100
+ renameFile: expect.any(Function),
101
+ },
102
+ ]
103
+ const actual = await firstValueFrom(
104
+ getFilesAtDepth({
105
+ depth: 1,
106
+ sourcePath: "/movies",
107
+ }).pipe(toArray()),
108
+ )
109
+ expect(actual).toEqual(expect.arrayContaining(expected))
110
+ expect(actual).toHaveLength(expected.length)
111
+ })
112
+
113
+ test("emits files when source contains files 2-levels deep", async () => {
114
+ const expected: FileInfo[] = [
115
+ {
116
+ filename:
117
+ "[Dolby] 747 (Audio) {FHD SDR & Dolby Atmos TrueHD}",
118
+ fullPath: join(
119
+ "/demos/Dolby",
120
+ "[Dolby] 747 (Audio) {FHD SDR & Dolby Atmos TrueHD}.mkv",
121
+ ),
122
+ renameFile: expect.any(Function),
123
+ },
124
+ {
125
+ filename: "Star Wars (1977)",
126
+ fullPath: join(
127
+ "/movies/Star Wars (1977)",
128
+ "Star Wars (1977).mkv",
129
+ ),
130
+ renameFile: expect.any(Function),
131
+ },
132
+ {
133
+ filename: "Star Wars (1977) {edition-4K77}",
134
+ fullPath: join(
135
+ "/movies/Star Wars (1977)",
136
+ "Star Wars (1977) {edition-4K77}.mkv",
137
+ ),
138
+ renameFile: expect.any(Function),
139
+ },
140
+ {
141
+ filename: "Super Mario Bros (1993)",
142
+ fullPath: join(
143
+ "/movies/Super Mario Bros (1993)",
144
+ "Super Mario Bros (1993).mkv",
145
+ ),
146
+ renameFile: expect.any(Function),
147
+ },
148
+ ]
149
+ const actual = await firstValueFrom(
150
+ getFilesAtDepth({
151
+ depth: 2,
152
+ sourcePath: "/",
153
+ }).pipe(toArray()),
154
+ )
155
+ expect(actual).toEqual(expect.arrayContaining(expected))
156
+ expect(actual).toHaveLength(expected.length)
157
+ })
158
+ })
@@ -0,0 +1,32 @@
1
+ import { concat, concatMap, EMPTY, filter, iif } from "rxjs"
2
+ import { getFiles } from "./getFiles.js"
3
+ import { getFolder } from "./getFolder.js"
4
+ import { logAndRethrowPipelineError } from "./logAndRethrowPipelineError.js"
5
+
6
+ export const getFilesAtDepth = ({
7
+ depth,
8
+ sourcePath,
9
+ }: {
10
+ depth: number
11
+ sourcePath: string
12
+ }): ReturnType<typeof getFiles> =>
13
+ concat(
14
+ getFiles({
15
+ sourcePath,
16
+ }),
17
+ iif(
18
+ () => depth > 0,
19
+ getFolder({
20
+ sourcePath,
21
+ }).pipe(
22
+ concatMap((folderInfo) =>
23
+ getFilesAtDepth({
24
+ depth: depth - 1,
25
+ sourcePath: folderInfo.fullPath,
26
+ }),
27
+ ),
28
+ filter(Boolean),
29
+ ),
30
+ EMPTY,
31
+ ),
32
+ ).pipe(logAndRethrowPipelineError(getFilesAtDepth))