@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 +37 -0
- package/src/__fixtures__/badBooleanName.ts +2 -0
- package/src/aclSafeCopyFile.test.ts +136 -0
- package/src/aclSafeCopyFile.ts +136 -0
- package/src/addFolderNameBeforeFilename.ts +13 -0
- package/src/captureConsoleMessage.test.ts +127 -0
- package/src/captureConsoleMessage.ts +22 -0
- package/src/captureLogMessage.ts +11 -0
- package/src/cleanupFilename.test.ts +26 -0
- package/src/cleanupFilename.ts +14 -0
- package/src/createRenameFileOrFolder.test.ts +220 -0
- package/src/createRenameFileOrFolder.ts +79 -0
- package/src/eslintBooleanPrefixRule.test.ts +30 -0
- package/src/eslintWebtypesGuardRule.test.ts +26 -0
- package/src/getFiles.ts +72 -0
- package/src/getFilesAtDepth.test.ts +158 -0
- package/src/getFilesAtDepth.ts +32 -0
- package/src/getFolder.test.ts +90 -0
- package/src/getFolder.ts +74 -0
- package/src/index.ts +60 -0
- package/src/insertIntoArray.test.ts +35 -0
- package/src/insertIntoArray.ts +10 -0
- package/src/listDirectoryEntries.ts +56 -0
- package/src/logAndRethrowPipelineError.test.ts +53 -0
- package/src/logAndRethrowPipelineError.ts +26 -0
- package/src/logAndSwallowPipelineError.test.ts +60 -0
- package/src/logAndSwallowPipelineError.ts +26 -0
- package/src/logMessage.test.ts +207 -0
- package/src/logMessage.ts +143 -0
- package/src/makeDirectory.test.ts +71 -0
- package/src/makeDirectory.ts +7 -0
- package/src/naturalSort.ts +8 -0
- package/src/replaceFileExtension.ts +9 -0
- package/src/test-runners.ts +43 -0
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,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()
|