@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.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@mux-magic/tools",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Reusable utilities (file system, logging, rxjs operators) shared between @mux-magic/server and sibling tools like Gallery-Downloader.",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "default": "./src/index.ts"
12
+ },
13
+ "./src/*": "./src/*"
14
+ },
15
+ "files": [
16
+ "src"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest"
25
+ },
26
+ "dependencies": {
27
+ "chalk": "^5.6.2",
28
+ "fast-sort": "^3.4.1",
29
+ "rxjs": "^7.8.2"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^25.6.2",
33
+ "memfs": "^4.57.2",
34
+ "typescript": "^6.0.3",
35
+ "vitest": "^4.1.5"
36
+ }
37
+ }
@@ -0,0 +1,2 @@
1
+ // eslint-disable-next-line @typescript-eslint/naming-convention -- intentional: test fixture for the is/has ESLint rule
2
+ export const wrongBool: boolean = true
@@ -0,0 +1,136 @@
1
+ import { vol } from "memfs"
2
+ import { describe, expect, test, vi } from "vitest"
3
+
4
+ import {
5
+ aclSafeCopyFile,
6
+ type CopyProgressEvent,
7
+ } from "./aclSafeCopyFile.js"
8
+
9
+ describe(aclSafeCopyFile.name, () => {
10
+ test("copies file contents to a new destination", async () => {
11
+ vol.fromJSON({
12
+ "/cache/source.mkv": "anime episode bytes",
13
+ "/anime": null,
14
+ })
15
+
16
+ await expect(
17
+ aclSafeCopyFile(
18
+ "/cache/source.mkv",
19
+ "/anime/target.mkv",
20
+ ),
21
+ ).resolves.toBeUndefined()
22
+
23
+ expect(
24
+ vol.readFileSync("/anime/target.mkv", "utf8"),
25
+ ).toBe("anime episode bytes")
26
+ })
27
+
28
+ test("overwrites an existing destination", async () => {
29
+ vol.fromJSON({
30
+ "/cache/source.mkv": "fresh bytes",
31
+ "/anime/target.mkv": "stale bytes",
32
+ })
33
+
34
+ await expect(
35
+ aclSafeCopyFile(
36
+ "/cache/source.mkv",
37
+ "/anime/target.mkv",
38
+ ),
39
+ ).resolves.toBeUndefined()
40
+
41
+ expect(
42
+ vol.readFileSync("/anime/target.mkv", "utf8"),
43
+ ).toBe("fresh bytes")
44
+ })
45
+
46
+ test("rejects when the source is missing", async () => {
47
+ await expect(
48
+ aclSafeCopyFile(
49
+ "/cache/missing.mkv",
50
+ "/anime/target.mkv",
51
+ ),
52
+ ).rejects.toThrow("no such file or directory")
53
+ })
54
+
55
+ test("leaves the source untouched", async () => {
56
+ vol.fromJSON({
57
+ "/cache/source.mkv": "original bytes",
58
+ "/anime": null,
59
+ })
60
+
61
+ await aclSafeCopyFile(
62
+ "/cache/source.mkv",
63
+ "/anime/target.mkv",
64
+ )
65
+
66
+ expect(
67
+ vol.readFileSync("/cache/source.mkv", "utf8"),
68
+ ).toBe("original bytes")
69
+ })
70
+
71
+ test("reports progress when onProgress is supplied", async () => {
72
+ vol.fromJSON({
73
+ "/cache/source.mkv": "twelve bytes",
74
+ "/anime": null,
75
+ })
76
+
77
+ const progressEvents: CopyProgressEvent[] = []
78
+
79
+ await aclSafeCopyFile(
80
+ "/cache/source.mkv",
81
+ "/anime/target.mkv",
82
+ {
83
+ onProgress: (event) => {
84
+ progressEvents.push(event)
85
+ },
86
+ },
87
+ )
88
+
89
+ expect(progressEvents.length).toBeGreaterThan(0)
90
+
91
+ const finalEvent = progressEvents.at(-1)
92
+ if (finalEvent == null)
93
+ throw new Error("no progress events")
94
+
95
+ expect(finalEvent.source).toBe("/cache/source.mkv")
96
+ expect(finalEvent.destination).toBe("/anime/target.mkv")
97
+ expect(finalEvent.totalBytes).toBe(12)
98
+ expect(finalEvent.bytesWritten).toBe(12)
99
+ })
100
+
101
+ test("does not call onProgress when options are omitted", async () => {
102
+ vol.fromJSON({
103
+ "/cache/source.mkv": "anything",
104
+ "/anime": null,
105
+ })
106
+
107
+ const onProgress = vi.fn()
108
+
109
+ await aclSafeCopyFile(
110
+ "/cache/source.mkv",
111
+ "/anime/target.mkv",
112
+ )
113
+
114
+ expect(onProgress).not.toHaveBeenCalled()
115
+ })
116
+
117
+ test("aborts via signal and unlinks the partial destination", async () => {
118
+ vol.fromJSON({
119
+ "/cache/source.mkv": "x".repeat(1024 * 64),
120
+ "/anime": null,
121
+ })
122
+
123
+ const abortController = new AbortController()
124
+ abortController.abort()
125
+
126
+ await expect(
127
+ aclSafeCopyFile(
128
+ "/cache/source.mkv",
129
+ "/anime/target.mkv",
130
+ { signal: abortController.signal },
131
+ ),
132
+ ).rejects.toBeDefined()
133
+
134
+ expect(vol.existsSync("/anime/target.mkv")).toBe(false)
135
+ })
136
+ })
@@ -0,0 +1,136 @@
1
+ import {
2
+ createReadStream,
3
+ createWriteStream,
4
+ } from "node:fs"
5
+ import { stat, unlink } from "node:fs/promises"
6
+ import { Transform } from "node:stream"
7
+ import { pipeline } from "node:stream/promises"
8
+
9
+ /**
10
+ * Per-chunk progress notification fired while a single file is being
11
+ * copied. `bytesWritten` accumulates monotonically up to `totalBytes`
12
+ * across the lifetime of one file copy. `source`/`destination`
13
+ * identify which file the event belongs to.
14
+ */
15
+ export type CopyProgressEvent = {
16
+ source: string
17
+ destination: string
18
+ bytesWritten: number
19
+ totalBytes: number
20
+ }
21
+
22
+ /**
23
+ * Optional behavior toggles for `aclSafeCopyFile`. Pass `onProgress`
24
+ * to receive byte-level updates as the copy streams through; omit
25
+ * the options object entirely (the common case) to skip the progress
26
+ * instrumentation and the upfront `stat` call it requires.
27
+ *
28
+ * Pass `signal` to make the copy abortable mid-stream — `pipeline`
29
+ * destroys the read/write streams when the signal fires and the
30
+ * returned promise rejects with an AbortError. Callers (e.g.
31
+ * `copyFiles`, `flattenOutput`) wire this into a per-job
32
+ * `AbortController` whose lifetime tracks the outer Observable, so
33
+ * an unsubscribe (sequence cancel / sibling-fail cascade) interrupts
34
+ * the in-flight copy instead of letting it finish.
35
+ */
36
+ export type CopyOptions = {
37
+ onProgress?: (event: CopyProgressEvent) => void
38
+ signal?: AbortSignal
39
+ }
40
+
41
+ /**
42
+ * Copies a single file's bytes from `source` to `destination` via a
43
+ * stream pipeline. Equivalent to GNU `cp` without flags: data only,
44
+ * no mode preservation, no timestamp preservation, no ownership
45
+ * preservation.
46
+ *
47
+ * Built to work around an EPERM that `node:fs.copyFile` and
48
+ * `node:fs.cp` hit on TrueNAS ZFS datasets configured with
49
+ * `aclmode=restricted`. libuv's internal copyfile path calls
50
+ * `fchmod()` on the destination after the data copy to preserve the
51
+ * source's mode bits, and that chmod fails against NFSv4 ACLs even
52
+ * when the mode is unchanged. Streaming the bytes never invokes
53
+ * chmod, so the copy goes through.
54
+ *
55
+ * Files only — does not handle directory copies. The destination's
56
+ * parent directory must already exist; callers are expected to
57
+ * `mkdir`-recursive first (the underlying `createWriteStream` rejects
58
+ * with ENOENT otherwise). Callers needing recursive copy should
59
+ * compose with `readFilesAtDepth` and RxJS `mergeAll(cpus().length)`
60
+ * the same way `syncAnimeDownloads` and `syncMangaFolders` already
61
+ * do — that gives per-file concurrency control, observable progress,
62
+ * and cancellation that a sequential walker cannot.
63
+ *
64
+ * Pass `options.onProgress` to receive per-chunk byte updates. When
65
+ * omitted, the function takes a fast path that skips the upfront
66
+ * `stat` call and the in-pipeline counting transform.
67
+ */
68
+ // On abort, the destination is left as a half-written partial. The
69
+ // next run would either skip it (if same size by accident) or worse,
70
+ // be inspected by the user as if the copy had succeeded. Best-effort
71
+ // unlink of the partial: ignore ENOENT (file never opened) and any
72
+ // other unlink error — abort cleanup must not mask the original
73
+ // AbortError that the caller is about to receive.
74
+ const removePartialOnAbort = async (
75
+ signal: AbortSignal | undefined,
76
+ destination: string,
77
+ ): Promise<void> => {
78
+ if (signal === undefined || !signal.aborted) return
79
+ try {
80
+ await unlink(destination)
81
+ } catch {
82
+ // partial may not exist (createWriteStream lazy-opens) — ignore
83
+ }
84
+ }
85
+
86
+ export const aclSafeCopyFile = async (
87
+ source: string,
88
+ destination: string,
89
+ options?: CopyOptions,
90
+ ): Promise<void> => {
91
+ const signal = options?.signal
92
+
93
+ if (!options?.onProgress) {
94
+ try {
95
+ await pipeline(
96
+ createReadStream(source),
97
+ createWriteStream(destination),
98
+ signal === undefined ? {} : { signal },
99
+ )
100
+ return
101
+ } catch (error) {
102
+ await removePartialOnAbort(signal, destination)
103
+ throw error
104
+ }
105
+ }
106
+
107
+ const onProgress = options.onProgress
108
+
109
+ const { size: totalBytes } = await stat(source)
110
+
111
+ let bytesWritten = 0
112
+ const progressTransform = new Transform({
113
+ transform(chunk, _encoding, callback) {
114
+ bytesWritten += chunk.length
115
+ onProgress({
116
+ source,
117
+ destination,
118
+ bytesWritten,
119
+ totalBytes,
120
+ })
121
+ callback(null, chunk)
122
+ },
123
+ })
124
+
125
+ try {
126
+ await pipeline(
127
+ createReadStream(source),
128
+ progressTransform,
129
+ createWriteStream(destination),
130
+ signal === undefined ? {} : { signal },
131
+ )
132
+ } catch (error) {
133
+ await removePartialOnAbort(signal, destination)
134
+ throw error
135
+ }
136
+ }
@@ -0,0 +1,13 @@
1
+ import { dirname, join } from "node:path"
2
+
3
+ export const addFolderNameBeforeFilename = ({
4
+ filePath,
5
+ folderName,
6
+ }: {
7
+ filePath: string
8
+ folderName: string
9
+ }) =>
10
+ filePath.replace(
11
+ dirname(filePath),
12
+ join(dirname(filePath), folderName),
13
+ )
@@ -0,0 +1,127 @@
1
+ import { describe, expect, test } from "vitest"
2
+
3
+ import { captureConsoleMessage } from "./captureConsoleMessage.js"
4
+
5
+ describe(captureConsoleMessage.name, () => {
6
+ test("captures a console log message", async () => {
7
+ const testMessage = "test message"
8
+
9
+ captureConsoleMessage("log", (consoleSpy) => {
10
+ console.log(testMessage)
11
+
12
+ expect(consoleSpy).toHaveBeenCalledOnce()
13
+
14
+ expect(consoleSpy).toHaveBeenCalledWith(testMessage)
15
+ })
16
+ })
17
+
18
+ test("captures multiple console log args", async () => {
19
+ const testMessages = [
20
+ "test message 1",
21
+ "test message 2",
22
+ "test message 3",
23
+ ]
24
+
25
+ captureConsoleMessage("log", (consoleSpy) => {
26
+ console.log(
27
+ testMessages[0],
28
+ testMessages[1],
29
+ testMessages[2],
30
+ )
31
+
32
+ expect(consoleSpy).toHaveBeenCalledOnce()
33
+
34
+ expect(consoleSpy).toHaveBeenCalledWith(
35
+ testMessages[0],
36
+ testMessages[1],
37
+ testMessages[2],
38
+ )
39
+ })
40
+ })
41
+
42
+ test("captures multiple console log messages", async () => {
43
+ const testMessages = [
44
+ "test message 1",
45
+ "test message 2",
46
+ "test message 3",
47
+ ]
48
+
49
+ captureConsoleMessage("log", (consoleSpy) => {
50
+ console.log(testMessages[0])
51
+
52
+ console.log(testMessages[1])
53
+
54
+ console.log(testMessages[2])
55
+
56
+ expect(consoleSpy).toHaveBeenCalledTimes(3)
57
+
58
+ expect(consoleSpy).toHaveBeenNthCalledWith(
59
+ 1,
60
+ testMessages[0],
61
+ )
62
+
63
+ expect(consoleSpy).toHaveBeenNthCalledWith(
64
+ 2,
65
+ testMessages[1],
66
+ )
67
+
68
+ expect(consoleSpy).toHaveBeenNthCalledWith(
69
+ 3,
70
+ testMessages[2],
71
+ )
72
+ })
73
+ })
74
+
75
+ test("clears mock after task complete", async () => {
76
+ const testMessage = "test message"
77
+
78
+ const capturedConsoleSpy = captureConsoleMessage(
79
+ "log",
80
+ (consoleSpy) => {
81
+ console.log(testMessage)
82
+
83
+ return consoleSpy
84
+ },
85
+ )
86
+
87
+ expect(capturedConsoleSpy).not.toHaveBeenCalled()
88
+ })
89
+
90
+ test("captures async console log messages", async () => {
91
+ const testMessage = "test message"
92
+
93
+ await captureConsoleMessage(
94
+ "log",
95
+ async (consoleSpy) => {
96
+ console.log(testMessage)
97
+
98
+ await Promise.resolve(consoleSpy)
99
+
100
+ expect(consoleSpy).toHaveBeenCalledOnce()
101
+
102
+ expect(consoleSpy).toHaveBeenCalledWith(testMessage)
103
+ },
104
+ )
105
+ })
106
+
107
+ test("clears mock after async task complete", async () => {
108
+ const testMessage = "test message"
109
+
110
+ const capturedConsoleSpy = await captureConsoleMessage(
111
+ "log",
112
+ async (consoleSpy) => {
113
+ console.log(testMessage)
114
+
115
+ return Promise.resolve(consoleSpy)
116
+ },
117
+ )
118
+
119
+ expect(capturedConsoleSpy).not.toHaveBeenCalled()
120
+ })
121
+
122
+ test("doesn't capture a message when no message logged", async () => {
123
+ captureConsoleMessage("log", (consoleSpy) => {
124
+ expect(consoleSpy).not.toHaveBeenCalled()
125
+ })
126
+ })
127
+ })
@@ -0,0 +1,22 @@
1
+ import { type MockInstance, vi } from "vitest"
2
+
3
+ export const captureConsoleMessage = <TaskResponse>(
4
+ logType: "error" | "info" | "log" | "warn",
5
+ task: (consoleSpy: MockInstance) => TaskResponse,
6
+ ) => {
7
+ const consoleSpy = vi
8
+ .spyOn(console, logType)
9
+ .mockImplementation(() => {})
10
+
11
+ const taskResponse = task(consoleSpy)
12
+
13
+ if (taskResponse instanceof Promise) {
14
+ taskResponse.finally(() => {
15
+ consoleSpy.mockClear()
16
+ })
17
+ } else {
18
+ consoleSpy.mockClear()
19
+ }
20
+
21
+ return taskResponse
22
+ }
@@ -0,0 +1,11 @@
1
+ import { captureConsoleMessage } from "./captureConsoleMessage.js"
2
+ import type { createLogMessage } from "./logMessage.js"
3
+
4
+ export const captureLogMessage = <TaskResponse>(
5
+ logType: Parameters<
6
+ typeof createLogMessage
7
+ >[0]["logType"],
8
+ task: Parameters<
9
+ typeof captureConsoleMessage<TaskResponse>
10
+ >[1],
11
+ ) => captureConsoleMessage(logType, task)
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "vitest"
2
+
3
+ import { cleanupFilename } from "./cleanupFilename.js"
4
+
5
+ describe(cleanupFilename.name, () => {
6
+ ;[
7
+ {
8
+ input:
9
+ "Super String: Marco Polo’s Travel to the Multiverse",
10
+ expected:
11
+ "Super String - Marco Polo’s Travel to the Multiverse",
12
+ },
13
+ {
14
+ input: "Some Title:Marco",
15
+ expected: "Some Title-Marco",
16
+ },
17
+ {
18
+ input: "Some Title : Marco",
19
+ expected: "Some Title - Marco",
20
+ },
21
+ ].forEach(({ input, expected }) => {
22
+ test(`from "${input}"`, () => {
23
+ expect(cleanupFilename(input)).toBe(expected)
24
+ })
25
+ })
26
+ })
@@ -0,0 +1,14 @@
1
+ export const cleanupFilename = (filename: string) =>
2
+ filename
3
+ .replaceAll("\n", " ")
4
+ .replaceAll(/(\w): /g, "$1 - ")
5
+ .replaceAll(/:/g, "-")
6
+ .replaceAll("?", "_")
7
+ .replaceAll('"', "")
8
+ .replaceAll("/", "-")
9
+ .replaceAll("<", "[")
10
+ .replaceAll(">", "]")
11
+ .replaceAll("*", "@")
12
+ .replaceAll("...", "--")
13
+ .replaceAll(" | ", " - ")
14
+ .trim()