@mux-magic/tools 1.0.0 → 1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mux-magic/tools",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "Reusable utilities (file system, logging, rxjs operators) shared between @mux-magic/server and sibling tools like Gallery-Downloader.",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "vitest"
2
+
3
+ import { applyRenameRegex } from "./applyRenameRegex.js"
4
+
5
+ describe(applyRenameRegex.name, () => {
6
+ test("returns the input unchanged when renameRegex is undefined", () => {
7
+ expect(
8
+ applyRenameRegex("My Show - 01.mkv", undefined),
9
+ ).toBe("My Show - 01.mkv")
10
+ })
11
+
12
+ test("returns the input unchanged when the pattern does not match", () => {
13
+ expect(
14
+ applyRenameRegex("My Show - 01.mkv", {
15
+ pattern: "^\\[Group\\] ",
16
+ replacement: "",
17
+ }),
18
+ ).toBe("My Show - 01.mkv")
19
+ })
20
+
21
+ test("applies a simple replacement", () => {
22
+ expect(
23
+ applyRenameRegex("[Group] My Show - 01.mkv", {
24
+ pattern: "^\\[Group\\] ",
25
+ replacement: "",
26
+ }),
27
+ ).toBe("My Show - 01.mkv")
28
+ })
29
+
30
+ test("supports numbered capture groups", () => {
31
+ expect(
32
+ applyRenameRegex("[Group] My Show - 01 [1080p].mkv", {
33
+ pattern: "^\\[.*?\\] (.+?) \\[.*?\\](\\.\\w+)$",
34
+ replacement: "$1$2",
35
+ }),
36
+ ).toBe("My Show - 01.mkv")
37
+ })
38
+
39
+ test("supports named capture groups", () => {
40
+ expect(
41
+ applyRenameRegex("Show.s01e03.Episode.mkv", {
42
+ pattern:
43
+ "^(?<series>.+?)\\.(?<season>s\\d+)(?<episode>e\\d+)\\.(?<title>.+)\\.mkv$",
44
+ replacement:
45
+ "$<series> - $<season>$<episode> - $<title>.mkv",
46
+ }),
47
+ ).toBe("Show - s01e03 - Episode.mkv")
48
+ })
49
+ })
@@ -0,0 +1,22 @@
1
+ // Canonical rename-regex shape consumed by `copyFiles`, `moveFiles`,
2
+ // and `renameFiles`. Worker 65 added optional `flags` (passed to the
3
+ // `RegExp` ctor as the second arg) and `sample` (UI-only documentation
4
+ // the runtime ignores). Pre-flags templates still satisfy this type
5
+ // because both fields are optional.
6
+ export type RenameRegex = {
7
+ pattern: string
8
+ replacement: string
9
+ flags?: string
10
+ sample?: string
11
+ }
12
+
13
+ export const applyRenameRegex = (
14
+ name: string,
15
+ renameRegex: RenameRegex | undefined,
16
+ ): string =>
17
+ renameRegex
18
+ ? name.replace(
19
+ new RegExp(renameRegex.pattern, renameRegex.flags),
20
+ renameRegex.replacement,
21
+ )
22
+ : name
package/src/index.ts CHANGED
@@ -14,6 +14,10 @@ export {
14
14
  type CopyProgressEvent,
15
15
  } from "./aclSafeCopyFile.js"
16
16
  export { addFolderNameBeforeFilename } from "./addFolderNameBeforeFilename.js"
17
+ export {
18
+ applyRenameRegex,
19
+ type RenameRegex,
20
+ } from "./applyRenameRegex.js"
17
21
  export { captureConsoleMessage } from "./captureConsoleMessage.js"
18
22
  export { captureLogMessage } from "./captureLogMessage.js"
19
23
  export { cleanupFilename } from "./cleanupFilename.js"
@@ -42,6 +46,29 @@ export {
42
46
  } from "./listDirectoryEntries.js"
43
47
  export { logAndRethrowPipelineError } from "./logAndRethrowPipelineError.js"
44
48
  export { logAndSwallowPipelineError } from "./logAndSwallowPipelineError.js"
49
+ export {
50
+ getLoggingContext,
51
+ type LoggerContext,
52
+ loggingContext,
53
+ withLoggingContext,
54
+ } from "./logging/context.js"
55
+ export { formatLogLine } from "./logging/lineSink.js"
56
+ export {
57
+ __resetLogSinksForTests,
58
+ getLogger,
59
+ type Logger,
60
+ type LogLevel,
61
+ type LogRecord,
62
+ type LogSink,
63
+ registerLogSink,
64
+ } from "./logging/logger.js"
65
+ export {
66
+ __resetLoggingModeForTests,
67
+ getLoggingMode,
68
+ type LoggingMode,
69
+ setLoggingMode,
70
+ } from "./logging/mode.js"
71
+ export { startSpan } from "./logging/startSpan.js"
45
72
  export {
46
73
  createAddColorToChalk,
47
74
  createLogMessage,
@@ -53,6 +80,21 @@ export {
53
80
  export { makeDirectory } from "./makeDirectory.js"
54
81
  export { naturalSort } from "./naturalSort.js"
55
82
  export { replaceFileExtension } from "./replaceFileExtension.js"
83
+ export {
84
+ SOURCE_PATH_FIELD_NAME,
85
+ SOURCE_PATH_LABEL,
86
+ type SourcePath,
87
+ } from "./sourcePath.js"
88
+ export {
89
+ __resetTaskSchedulerForTests,
90
+ initTaskScheduler,
91
+ mergeMapOrdered,
92
+ registerJobClaim,
93
+ runTask,
94
+ runTasks,
95
+ runTasksOrdered,
96
+ unregisterJobClaim,
97
+ } from "./taskScheduler.js"
56
98
  export {
57
99
  getOperatorValue,
58
100
  runPromiseScheduler,
@@ -1,6 +1,15 @@
1
1
  import { Chalk } from "chalk"
2
2
  import { describe, expect, test } from "vitest"
3
3
  import { captureConsoleMessage } from "./captureConsoleMessage.js"
4
+ import {
5
+ __resetLogSinksForTests,
6
+ type LogRecord,
7
+ registerLogSink,
8
+ } from "./logging/logger.js"
9
+ import {
10
+ __resetLoggingModeForTests,
11
+ setLoggingMode,
12
+ } from "./logging/mode.js"
4
13
  import {
5
14
  createAddColorToChalk,
6
15
  createLogMessage,
@@ -118,9 +127,11 @@ describe(createLogMessage.name, () => {
118
127
  })("HELLO WORLD")
119
128
 
120
129
  expect(consoleSpy).toHaveBeenCalledOnce()
121
-
122
- expect(consoleSpy.mock.calls.at(0)?.at(0)).toContain(
123
- "HELLO WORLD",
130
+ expect(consoleSpy).toHaveBeenCalledWith(
131
+ expect.stringContaining("HELLO WORLD"),
132
+ expect.anything(),
133
+ expect.anything(),
134
+ expect.anything(),
124
135
  )
125
136
  })
126
137
  })
@@ -175,8 +186,11 @@ describe(logError.name, () => {
175
186
  captureConsoleMessage("error", (consoleSpy) => {
176
187
  logError("ERROR")
177
188
 
178
- expect(consoleSpy.mock.calls.at(0)?.at(0)).toContain(
179
- "ERROR",
189
+ expect(consoleSpy).toHaveBeenCalledWith(
190
+ expect.stringContaining("ERROR"),
191
+ expect.anything(),
192
+ expect.anything(),
193
+ expect.anything(),
180
194
  )
181
195
  })
182
196
  })
@@ -187,8 +201,11 @@ describe(logInfo.name, () => {
187
201
  captureConsoleMessage("info", (consoleSpy) => {
188
202
  logInfo("INFO")
189
203
 
190
- expect(consoleSpy.mock.calls.at(0)?.at(0)).toContain(
191
- "INFO",
204
+ expect(consoleSpy).toHaveBeenCalledWith(
205
+ expect.stringContaining("INFO"),
206
+ expect.anything(),
207
+ expect.anything(),
208
+ expect.anything(),
192
209
  )
193
210
  })
194
211
  })
@@ -199,9 +216,95 @@ describe(logWarning.name, () => {
199
216
  captureConsoleMessage("warn", (consoleSpy) => {
200
217
  logWarning("WARNING")
201
218
 
202
- expect(consoleSpy.mock.calls.at(0)?.at(0)).toContain(
203
- "WARNING",
219
+ expect(consoleSpy).toHaveBeenCalledWith(
220
+ expect.stringContaining("WARNING"),
221
+ expect.anything(),
222
+ expect.anything(),
223
+ expect.anything(),
204
224
  )
205
225
  })
206
226
  })
207
227
  })
228
+
229
+ describe("logMessage mode-awareness", () => {
230
+ test('"api" mode emits a structured record AND skips chalk console', () => {
231
+ __resetLogSinksForTests()
232
+ let records: readonly LogRecord[] = []
233
+ registerLogSink((record) => {
234
+ records = records.concat(record)
235
+ })
236
+ setLoggingMode("api")
237
+
238
+ captureConsoleMessage("info", (consoleSpy) => {
239
+ logInfo("SEQUENCE", "Step step1 starting.")
240
+ expect(consoleSpy).not.toHaveBeenCalled()
241
+ })
242
+
243
+ __resetLoggingModeForTests()
244
+ __resetLogSinksForTests()
245
+
246
+ expect(records).toHaveLength(1)
247
+ expect(records[0]).toMatchObject({
248
+ level: "info",
249
+ tag: "SEQUENCE",
250
+ msg: "Step step1 starting.",
251
+ })
252
+ })
253
+
254
+ test('"cli" mode (default) emits NO structured record', () => {
255
+ __resetLogSinksForTests()
256
+ let records: readonly LogRecord[] = []
257
+ registerLogSink((record) => {
258
+ records = records.concat(record)
259
+ })
260
+
261
+ captureConsoleMessage("info", () => {
262
+ logInfo("SEQUENCE", "Step step1 starting.")
263
+ })
264
+
265
+ expect(records).toHaveLength(0)
266
+ __resetLogSinksForTests()
267
+ })
268
+
269
+ test('"cli-debug" mode emits BOTH a structured record AND the chalk console line', () => {
270
+ __resetLogSinksForTests()
271
+ let records: readonly LogRecord[] = []
272
+ registerLogSink((record) => {
273
+ records = records.concat(record)
274
+ })
275
+ setLoggingMode("cli-debug")
276
+
277
+ captureConsoleMessage("info", (consoleSpy) => {
278
+ logInfo("SEQUENCE", "Step step1 starting.")
279
+ expect(consoleSpy).toHaveBeenCalled()
280
+ })
281
+
282
+ __resetLoggingModeForTests()
283
+ __resetLogSinksForTests()
284
+
285
+ expect(records).toHaveLength(1)
286
+ expect(records[0]?.tag).toBe("SEQUENCE")
287
+ })
288
+
289
+ test('"api" mode for logError emits a structured "error" record', () => {
290
+ __resetLogSinksForTests()
291
+ let records: readonly LogRecord[] = []
292
+ registerLogSink((record) => {
293
+ records = records.concat(record)
294
+ })
295
+ setLoggingMode("api")
296
+
297
+ captureConsoleMessage("error", () => {
298
+ logError("SEQUENCE", "boom")
299
+ })
300
+
301
+ __resetLoggingModeForTests()
302
+ __resetLogSinksForTests()
303
+
304
+ expect(records[0]).toMatchObject({
305
+ level: "error",
306
+ tag: "SEQUENCE",
307
+ msg: "boom",
308
+ })
309
+ })
310
+ })
package/src/logMessage.ts CHANGED
@@ -6,6 +6,12 @@ import {
6
6
  type ForegroundColorName,
7
7
  } from "chalk"
8
8
 
9
+ import {
10
+ getLogger,
11
+ type LogLevel,
12
+ } from "./logging/logger.js"
13
+ import { getLoggingMode } from "./logging/mode.js"
14
+
9
15
  export const createAddColorToChalk =
10
16
  (chalkColor?: ColorName) =>
11
17
  (chalkInstance: ChalkInstance) =>
@@ -92,6 +98,29 @@ export const createLogMessage =
92
98
  )
93
99
  : null
94
100
 
101
+ const mode = getLoggingMode()
102
+ const messageArray = message || content
103
+
104
+ if (mode === "api" || mode === "cli-debug") {
105
+ const structuredMsg = (
106
+ messageArray as readonly unknown[]
107
+ )
108
+ .map((part) =>
109
+ typeof part === "string" ? part : String(part),
110
+ )
111
+ .join(" ")
112
+ .trim()
113
+ const structuredLevel: LogLevel =
114
+ logType === "log" ? "info" : logType
115
+ getLogger()[structuredLevel](structuredMsg, {
116
+ tag: title,
117
+ })
118
+ }
119
+
120
+ if (mode === "api") {
121
+ return
122
+ }
123
+
95
124
  console[logType](
96
125
  optionallyColoredChalk(`[${title}]`),
97
126
  "\n",
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from "vitest"
2
+
3
+ import {
4
+ getLoggingContext,
5
+ loggingContext,
6
+ withLoggingContext,
7
+ } from "./context.js"
8
+
9
+ describe(withLoggingContext.name, () => {
10
+ test("seeds the context for the duration of fn", () => {
11
+ const seen = withLoggingContext({ jobId: "j1" }, () =>
12
+ getLoggingContext(),
13
+ )
14
+
15
+ expect(seen).toEqual({ jobId: "j1" })
16
+ })
17
+
18
+ test("returns an empty context outside any run", () => {
19
+ expect(getLoggingContext()).toEqual({})
20
+ })
21
+
22
+ test("nested call merges parent bindings under the child", () => {
23
+ const seen = withLoggingContext(
24
+ { jobId: "j1", stepIndex: 0 },
25
+ () =>
26
+ withLoggingContext(
27
+ { stepIndex: 2, fileId: "f" },
28
+ () => getLoggingContext(),
29
+ ),
30
+ )
31
+
32
+ expect(seen).toEqual({
33
+ jobId: "j1",
34
+ stepIndex: 2,
35
+ fileId: "f",
36
+ })
37
+ })
38
+
39
+ test("propagates through awaited promises", async () => {
40
+ const seen = await withLoggingContext(
41
+ { jobId: "j-async" },
42
+ async () => {
43
+ await Promise.resolve()
44
+ await new Promise((resolve) =>
45
+ setTimeout(resolve, 0),
46
+ )
47
+ return getLoggingContext()
48
+ },
49
+ )
50
+
51
+ expect(seen.jobId).toBe("j-async")
52
+ })
53
+
54
+ test("`loggingContext` is a single AsyncLocalStorage instance", () => {
55
+ expect(loggingContext.getStore).toBeTypeOf("function")
56
+ })
57
+ })
@@ -0,0 +1,23 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks"
2
+
3
+ export type LoggerContext = {
4
+ jobId?: string
5
+ stepIndex?: number
6
+ fileId?: string
7
+ traceId?: string
8
+ spanId?: string
9
+ }
10
+
11
+ export const loggingContext =
12
+ new AsyncLocalStorage<LoggerContext>()
13
+
14
+ export const getLoggingContext = (): LoggerContext =>
15
+ loggingContext.getStore() ?? {}
16
+
17
+ export const withLoggingContext = <T>(
18
+ bindings: LoggerContext,
19
+ fn: () => T,
20
+ ): T => {
21
+ const merged = { ...getLoggingContext(), ...bindings }
22
+ return loggingContext.run(merged, fn)
23
+ }
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test } from "vitest"
2
+
3
+ import { formatLogLine } from "./lineSink.js"
4
+
5
+ describe(formatLogLine.name, () => {
6
+ test("uses bracketed local-time timestamp prefix matching legacy logCapture", () => {
7
+ const now = new Date()
8
+ now.setHours(8, 21, 33, 512)
9
+
10
+ const line = formatLogLine(
11
+ { level: "info", msg: "copy finished" },
12
+ now,
13
+ )
14
+
15
+ expect(line.startsWith("[08:21:33.512]")).toBe(true)
16
+ })
17
+
18
+ test("emits `<timestamp> <level> <msg>` for a record with no extra fields", () => {
19
+ const now = new Date()
20
+ now.setHours(0, 0, 0, 0)
21
+
22
+ const line = formatLogLine(
23
+ { level: "warn", msg: "be careful" },
24
+ now,
25
+ )
26
+
27
+ expect(line).toBe("[00:00:00.000] warn be careful")
28
+ })
29
+
30
+ test("renders extra fields inline as field=value before the msg", () => {
31
+ const now = new Date()
32
+ now.setHours(12, 0, 0, 0)
33
+
34
+ const line = formatLogLine(
35
+ {
36
+ level: "info",
37
+ msg: "step started",
38
+ stepIndex: 2,
39
+ fileId: "foo.mkv",
40
+ },
41
+ now,
42
+ )
43
+
44
+ expect(line).toBe(
45
+ "[12:00:00.000] info stepIndex=2 fileId=foo.mkv step started",
46
+ )
47
+ })
48
+
49
+ test("does not render undefined fields", () => {
50
+ const now = new Date()
51
+ now.setHours(0, 0, 0, 0)
52
+
53
+ const line = formatLogLine(
54
+ {
55
+ level: "info",
56
+ msg: "m",
57
+ jobId: undefined,
58
+ stepIndex: 1,
59
+ },
60
+ now,
61
+ )
62
+
63
+ expect(line).not.toContain("jobId")
64
+ expect(line).toContain("stepIndex=1")
65
+ })
66
+
67
+ test("quotes string values that contain whitespace", () => {
68
+ const now = new Date()
69
+ now.setHours(0, 0, 0, 0)
70
+
71
+ const line = formatLogLine(
72
+ {
73
+ level: "info",
74
+ msg: "named",
75
+ detail: "two words",
76
+ },
77
+ now,
78
+ )
79
+
80
+ expect(line).toContain(`detail="two words"`)
81
+ })
82
+
83
+ test("renders `[ts] [TAG] msg` when a `tag` field is present (preserves legacy chalk-stripped shape)", () => {
84
+ const now = new Date()
85
+ now.setHours(8, 21, 33, 512)
86
+
87
+ const line = formatLogLine(
88
+ {
89
+ level: "info",
90
+ msg: "Step step1 starting.",
91
+ tag: "SEQUENCE",
92
+ },
93
+ now,
94
+ )
95
+
96
+ expect(line).toBe(
97
+ "[08:21:33.512] [SEQUENCE] Step step1 starting.",
98
+ )
99
+ })
100
+
101
+ test("tag-mode line still appends remaining extra fields", () => {
102
+ const now = new Date()
103
+ now.setHours(0, 0, 0, 0)
104
+
105
+ const line = formatLogLine(
106
+ {
107
+ level: "info",
108
+ msg: "Step step1 starting.",
109
+ tag: "SEQUENCE",
110
+ stepIndex: 1,
111
+ },
112
+ now,
113
+ )
114
+
115
+ expect(line).toBe(
116
+ "[00:00:00.000] [SEQUENCE] stepIndex=1 Step step1 starting.",
117
+ )
118
+ })
119
+
120
+ test("serialises nested objects via JSON", () => {
121
+ const now = new Date()
122
+ now.setHours(0, 0, 0, 0)
123
+
124
+ const line = formatLogLine(
125
+ {
126
+ level: "error",
127
+ msg: "boom",
128
+ meta: { code: 42 },
129
+ },
130
+ now,
131
+ )
132
+
133
+ expect(line).toContain(`meta={"code":42}`)
134
+ })
135
+ })
@@ -0,0 +1,74 @@
1
+ import type { LogRecord } from "./logger.js"
2
+
3
+ const RESERVED_KEYS = new Set(["level", "msg"])
4
+
5
+ const pad = (value: number, width: number): string =>
6
+ String(value).padStart(width, "0")
7
+
8
+ const formatTimestamp = (date: Date): string =>
9
+ `[${pad(date.getHours(), 2)}:${pad(date.getMinutes(), 2)}:${pad(
10
+ date.getSeconds(),
11
+ 2,
12
+ )}.${pad(date.getMilliseconds(), 3)}]`
13
+
14
+ const formatValue = (value: unknown): string => {
15
+ if (value === null) {
16
+ return "null"
17
+ }
18
+ if (typeof value === "string") {
19
+ return /\s/.test(value) ? `"${value}"` : value
20
+ }
21
+ if (
22
+ typeof value === "number" ||
23
+ typeof value === "boolean"
24
+ ) {
25
+ return String(value)
26
+ }
27
+ try {
28
+ return JSON.stringify(value)
29
+ } catch {
30
+ return String(value)
31
+ }
32
+ }
33
+
34
+ const renderField = ([key, value]: [
35
+ string,
36
+ unknown,
37
+ ]): string => `${key}=${formatValue(value)}`
38
+
39
+ const isRenderableEntry =
40
+ (excludedKeys: ReadonlySet<string>) =>
41
+ ([key, value]: [string, unknown]): boolean =>
42
+ !excludedKeys.has(key) && value !== undefined
43
+
44
+ const joinFields = (fields: readonly string[]): string =>
45
+ fields.length > 0 ? ` ${fields.join(" ")}` : ""
46
+
47
+ const TAG_MODE_EXCLUDED_KEYS: ReadonlySet<string> = new Set(
48
+ [...RESERVED_KEYS, "tag"],
49
+ )
50
+
51
+ // When a record carries a `tag` field (set by the `logInfo/logError/logWarning`
52
+ // bridge in api mode), render as `[ts] [TAG] msg` so the SSE feed stays byte-
53
+ // identical to today's chalk-stripped console-patch output. Anything else
54
+ // renders in the structured form `[ts] level field=value... msg`.
55
+ export const formatLogLine = (
56
+ record: LogRecord,
57
+ now: Date = new Date(),
58
+ ): string => {
59
+ if (typeof record.tag === "string") {
60
+ const extraPart = joinFields(
61
+ Object.entries(record)
62
+ .filter(isRenderableEntry(TAG_MODE_EXCLUDED_KEYS))
63
+ .map(renderField),
64
+ )
65
+ return `${formatTimestamp(now)} [${record.tag}]${extraPart} ${record.msg}`
66
+ }
67
+
68
+ const fieldPart = joinFields(
69
+ Object.entries(record)
70
+ .filter(isRenderableEntry(RESERVED_KEYS))
71
+ .map(renderField),
72
+ )
73
+ return `${formatTimestamp(now)} ${record.level}${fieldPart} ${record.msg}`
74
+ }