trackparse 0.2.1 → 0.3.1
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/README.md +87 -5
- package/dist/chunk-3TS4DITR.js +2314 -0
- package/dist/index.cjs +73 -6
- package/dist/index.d.cts +4 -86
- package/dist/index.d.ts +4 -86
- package/dist/index.js +3 -2189
- package/dist/tracklist.cjs +3135 -0
- package/dist/tracklist.d.cts +114 -0
- package/dist/tracklist.d.ts +114 -0
- package/dist/tracklist.js +827 -0
- package/dist/types-DqJSn18b.d.cts +87 -0
- package/dist/types-DqJSn18b.d.ts +87 -0
- package/package.json +15 -4
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
import { headerWordsData, createListParser, SPEC_VERSION, stringList, PhraseTable, markerWordsData, digitRun, isWhitespace, asciiLower, splitWords, inVocab, isDigit } from './chunk-3TS4DITR.js';
|
|
2
|
+
|
|
3
|
+
// src/tracklist/split.ts
|
|
4
|
+
function splitLines(text) {
|
|
5
|
+
const lines = [];
|
|
6
|
+
let start = 0;
|
|
7
|
+
for (let i = 0; i < text.length; i++) {
|
|
8
|
+
const c = text.charCodeAt(i);
|
|
9
|
+
if (c === 10) {
|
|
10
|
+
lines.push({ raw: text.slice(start, i), eol: "\n" });
|
|
11
|
+
start = i + 1;
|
|
12
|
+
} else if (c === 13) {
|
|
13
|
+
if (text.charCodeAt(i + 1) === 10) {
|
|
14
|
+
lines.push({ raw: text.slice(start, i), eol: "\r\n" });
|
|
15
|
+
i++;
|
|
16
|
+
} else {
|
|
17
|
+
lines.push({ raw: text.slice(start, i), eol: "\r" });
|
|
18
|
+
}
|
|
19
|
+
start = i + 1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (start < text.length) lines.push({ raw: text.slice(start), eol: "" });
|
|
23
|
+
return lines;
|
|
24
|
+
}
|
|
25
|
+
function joinLines(lines) {
|
|
26
|
+
let out = "";
|
|
27
|
+
for (const l of lines) out += l.raw + l.eol;
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
function hasLineBreak(s) {
|
|
31
|
+
for (let i = 0; i < s.length; i++) {
|
|
32
|
+
const c = s.charCodeAt(i);
|
|
33
|
+
if (c === 10 || c === 13) return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/tracklist/times.ts
|
|
39
|
+
function isGapSeparator(ch) {
|
|
40
|
+
return ch === "-" || ch === "\u2013" || ch === "\u2014" || ch === "|" || ch === "." || ch === ":";
|
|
41
|
+
}
|
|
42
|
+
function skipWhitespace(s, from) {
|
|
43
|
+
let i = from;
|
|
44
|
+
while (i < s.length && isWhitespace(s[i])) i++;
|
|
45
|
+
return i;
|
|
46
|
+
}
|
|
47
|
+
function isBullet(ch) {
|
|
48
|
+
return ch === "-" || ch === "\u2013" || ch === "\u2014" || ch === "*" || ch === "\u2022";
|
|
49
|
+
}
|
|
50
|
+
function untimed(raw, invalid) {
|
|
51
|
+
const at = skipWhitespace(raw, 0);
|
|
52
|
+
let lead = "";
|
|
53
|
+
if (isBullet(raw[at]) && isWhitespace(raw[at + 1]))
|
|
54
|
+
lead = raw.slice(0, skipWhitespace(raw, at + 1));
|
|
55
|
+
return {
|
|
56
|
+
parts: { lead, time: "", gap: "", rest: raw.slice(lead.length) },
|
|
57
|
+
time: null,
|
|
58
|
+
style: null,
|
|
59
|
+
invalid
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function secondsOf(whole, frac) {
|
|
63
|
+
return frac.length === 0 ? whole : Number(`${whole}.${frac}`);
|
|
64
|
+
}
|
|
65
|
+
function shapeAt(s, at) {
|
|
66
|
+
const na = digitRun(s, at);
|
|
67
|
+
if (na === 0 || s[at + na] !== ":" || !isDigit(s[at + na + 1])) return null;
|
|
68
|
+
const parts = [s.slice(at, at + na)];
|
|
69
|
+
let end = at + na;
|
|
70
|
+
while (s[end] === ":" && isDigit(s[end + 1])) {
|
|
71
|
+
const n = digitRun(s, end + 1);
|
|
72
|
+
parts.push(s.slice(end + 1, end + 1 + n));
|
|
73
|
+
end += 1 + n;
|
|
74
|
+
}
|
|
75
|
+
return { parts, end };
|
|
76
|
+
}
|
|
77
|
+
function shapeSeconds(parts) {
|
|
78
|
+
if (parts.length < 2 || parts.length > 3) return null;
|
|
79
|
+
const [a, b, c] = parts;
|
|
80
|
+
if (c !== void 0) {
|
|
81
|
+
if (a.length < 1 || a.length > 2 || b.length !== 2 || c.length !== 2) return null;
|
|
82
|
+
const m = Number(b);
|
|
83
|
+
const sec2 = Number(c);
|
|
84
|
+
if (m > 59 || sec2 > 59) return null;
|
|
85
|
+
return Number(a) * 3600 + m * 60 + sec2;
|
|
86
|
+
}
|
|
87
|
+
if (a.length < 1 || a.length > 3 || b.length !== 2) return null;
|
|
88
|
+
const sec = Number(b);
|
|
89
|
+
if (sec > 59) return null;
|
|
90
|
+
return Number(a) * 60 + sec;
|
|
91
|
+
}
|
|
92
|
+
function scanTime(raw) {
|
|
93
|
+
let i = skipWhitespace(raw, 0);
|
|
94
|
+
const closer = raw[i] === "[" ? "]" : raw[i] === "(" ? ")" : null;
|
|
95
|
+
if (closer) i++;
|
|
96
|
+
const leadEnd = i;
|
|
97
|
+
const shape = shapeAt(raw, i);
|
|
98
|
+
if (!shape) return untimed(raw, false);
|
|
99
|
+
const whole = shapeSeconds(shape.parts);
|
|
100
|
+
if (whole === null) return untimed(raw, true);
|
|
101
|
+
let j = shape.end;
|
|
102
|
+
let fracSep = "";
|
|
103
|
+
let frac = "";
|
|
104
|
+
const sep = raw[j];
|
|
105
|
+
if (sep === "." || sep === ",") {
|
|
106
|
+
const n = digitRun(raw, j + 1);
|
|
107
|
+
if (n >= 1 && n <= 3) {
|
|
108
|
+
fracSep = sep;
|
|
109
|
+
frac = raw.slice(j + 1, j + 1 + n);
|
|
110
|
+
j += 1 + n;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const timeEnd = j;
|
|
114
|
+
if (closer) {
|
|
115
|
+
if (raw[j] !== closer) return untimed(raw, false);
|
|
116
|
+
j++;
|
|
117
|
+
} else if (j < raw.length && !isWhitespace(raw[j]) && !isGapSeparator(raw[j])) {
|
|
118
|
+
return untimed(raw, false);
|
|
119
|
+
}
|
|
120
|
+
j = skipWhitespace(raw, j);
|
|
121
|
+
if (isGapSeparator(raw[j])) j = skipWhitespace(raw, j + 1);
|
|
122
|
+
if (raw.startsWith("==>", j)) j = skipWhitespace(raw, j + 3);
|
|
123
|
+
else if (raw.startsWith("=>", j)) j = skipWhitespace(raw, j + 2);
|
|
124
|
+
const timeRaw = raw.slice(leadEnd, timeEnd);
|
|
125
|
+
const hours = shape.parts.length === 3;
|
|
126
|
+
const first = shape.parts[0];
|
|
127
|
+
const longMinutes = !hours && Number(first) >= 60;
|
|
128
|
+
return {
|
|
129
|
+
parts: {
|
|
130
|
+
lead: raw.slice(0, leadEnd),
|
|
131
|
+
time: timeRaw,
|
|
132
|
+
gap: raw.slice(timeEnd, j),
|
|
133
|
+
rest: raw.slice(j)
|
|
134
|
+
},
|
|
135
|
+
time: { raw: timeRaw, seconds: secondsOf(whole, frac) },
|
|
136
|
+
style: {
|
|
137
|
+
hours,
|
|
138
|
+
hourWidth: hours ? first.length : 0,
|
|
139
|
+
// R13.9.2: a long-minute time's minimum minute width is min(width, 2), so it never
|
|
140
|
+
// keeps 3 minute digits once it is short again (`125:30` − 7000 s → `08:50`).
|
|
141
|
+
minuteWidth: hours ? shape.parts[1].length : longMinutes ? Math.min(first.length, 2) : first.length,
|
|
142
|
+
longMinutes,
|
|
143
|
+
fracSep,
|
|
144
|
+
fracDigits: frac.length
|
|
145
|
+
},
|
|
146
|
+
invalid: false
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
var pad = (n, width) => String(n).padStart(width, "0");
|
|
150
|
+
function formatTime(seconds, style, oneDigitHours2) {
|
|
151
|
+
const scale = 10 ** style.fracDigits;
|
|
152
|
+
const units = Math.floor(seconds * scale + 0.5);
|
|
153
|
+
if (!Number.isSafeInteger(units) || units < 0) return null;
|
|
154
|
+
const whole = Math.floor(units / scale);
|
|
155
|
+
const fracUnits = units - whole * scale;
|
|
156
|
+
const frac = style.fracDigits > 0 ? style.fracSep + pad(fracUnits, style.fracDigits) : "";
|
|
157
|
+
const sec = whole % 60;
|
|
158
|
+
if (whole >= 100 * 3600) return null;
|
|
159
|
+
if (style.hours) {
|
|
160
|
+
const h = Math.floor(whole / 3600);
|
|
161
|
+
const m2 = Math.floor(whole % 3600 / 60);
|
|
162
|
+
return {
|
|
163
|
+
raw: `${pad(h, style.hourWidth)}:${pad(m2, style.minuteWidth)}:${pad(sec, 2)}${frac}`,
|
|
164
|
+
upgraded: false
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (style.longMinutes) {
|
|
168
|
+
const m2 = Math.floor(whole / 60);
|
|
169
|
+
if (m2 > 999) return null;
|
|
170
|
+
return { raw: `${pad(m2, style.minuteWidth)}:${pad(sec, 2)}${frac}`, upgraded: false };
|
|
171
|
+
}
|
|
172
|
+
if (whole >= 3600) {
|
|
173
|
+
const h = Math.floor(whole / 3600);
|
|
174
|
+
const m2 = Math.floor(whole % 3600 / 60);
|
|
175
|
+
return {
|
|
176
|
+
raw: `${pad(h, oneDigitHours2 ? 1 : 2)}:${pad(m2, 2)}:${pad(sec, 2)}${frac}`,
|
|
177
|
+
upgraded: true
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const m = Math.floor(whole / 60);
|
|
181
|
+
return { raw: `${pad(m, style.minuteWidth)}:${pad(sec, 2)}${frac}`, upgraded: false };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/tracklist/edits.ts
|
|
185
|
+
var InvalidEdit = class extends Error {
|
|
186
|
+
};
|
|
187
|
+
function workLine(raw, eol, changed) {
|
|
188
|
+
return { raw, eol, changed, scan: scanTime(raw) };
|
|
189
|
+
}
|
|
190
|
+
function countHours(ctx, line, by) {
|
|
191
|
+
const style = line.scan.style;
|
|
192
|
+
if (!style?.hours) return;
|
|
193
|
+
if (style.hourWidth === 1) ctx.oneDigit += by;
|
|
194
|
+
else ctx.wideHours += by;
|
|
195
|
+
}
|
|
196
|
+
function countEol(ctx, eol, by) {
|
|
197
|
+
if (eol !== "") ctx.eols.set(eol, (ctx.eols.get(eol) ?? 0) + by);
|
|
198
|
+
}
|
|
199
|
+
function setEol(ctx, line, eol) {
|
|
200
|
+
countEol(ctx, line.eol, -1);
|
|
201
|
+
line.eol = eol;
|
|
202
|
+
line.changed = true;
|
|
203
|
+
countEol(ctx, eol, 1);
|
|
204
|
+
}
|
|
205
|
+
function countLine(ctx, line, by) {
|
|
206
|
+
countHours(ctx, line, by);
|
|
207
|
+
countEol(ctx, line.eol, by);
|
|
208
|
+
}
|
|
209
|
+
function setRaw(ctx, line, raw) {
|
|
210
|
+
countHours(ctx, line, -1);
|
|
211
|
+
line.raw = raw;
|
|
212
|
+
line.scan = scanTime(raw);
|
|
213
|
+
line.changed = true;
|
|
214
|
+
countHours(ctx, line, 1);
|
|
215
|
+
}
|
|
216
|
+
function warnEdit(ctx, code, line) {
|
|
217
|
+
ctx.warnings.push({ code, edit: ctx.index, line });
|
|
218
|
+
}
|
|
219
|
+
function field(edit, key) {
|
|
220
|
+
return edit[key];
|
|
221
|
+
}
|
|
222
|
+
function has(edit, key) {
|
|
223
|
+
return field(edit, key) !== void 0;
|
|
224
|
+
}
|
|
225
|
+
function lineIndex(ctx, value) {
|
|
226
|
+
if (typeof value !== "number" || !Number.isInteger(value)) throw new InvalidEdit();
|
|
227
|
+
if (value < 1 || value > ctx.lines.length) throw new InvalidEdit();
|
|
228
|
+
return value - 1;
|
|
229
|
+
}
|
|
230
|
+
function finite(value) {
|
|
231
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new InvalidEdit();
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
function singleLine(value) {
|
|
235
|
+
if (typeof value !== "string" || hasLineBreak(value)) throw new InvalidEdit();
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
function oneDigitHours(ctx) {
|
|
239
|
+
return ctx.oneDigit > 0 && ctx.wideHours === 0;
|
|
240
|
+
}
|
|
241
|
+
function retime(ctx, index, value, oneDigit) {
|
|
242
|
+
const scan = ctx.lines[index].scan;
|
|
243
|
+
if (!scan.style || !scan.time) throw new InvalidEdit();
|
|
244
|
+
const clamped = value < 0;
|
|
245
|
+
const f = formatTime(clamped ? 0 : value, scan.style, oneDigit);
|
|
246
|
+
if (!f) throw new InvalidEdit();
|
|
247
|
+
const p = scan.parts;
|
|
248
|
+
return { index, raw: p.lead + f.raw + p.gap + p.rest, clamped, upgraded: f.upgraded };
|
|
249
|
+
}
|
|
250
|
+
function commit(ctx, changes) {
|
|
251
|
+
for (const c of changes) {
|
|
252
|
+
setRaw(ctx, ctx.lines[c.index], c.raw);
|
|
253
|
+
if (c.clamped) warnEdit(ctx, "time-clamped", c.index + 1);
|
|
254
|
+
if (c.upgraded) warnEdit(ctx, "style-upgraded", c.index + 1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function setTime(ctx, edit) {
|
|
258
|
+
const index = lineIndex(ctx, field(edit, "line"));
|
|
259
|
+
const value = finite(field(edit, "seconds"));
|
|
260
|
+
commit(ctx, [retime(ctx, index, value, oneDigitHours(ctx))]);
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
function shift(ctx, edit) {
|
|
264
|
+
const delta = finite(field(edit, "seconds"));
|
|
265
|
+
let targets;
|
|
266
|
+
if (has(edit, "lines")) {
|
|
267
|
+
const list = field(edit, "lines");
|
|
268
|
+
if (!Array.isArray(list)) throw new InvalidEdit();
|
|
269
|
+
const set = new Set(list.map((v) => lineIndex(ctx, v)));
|
|
270
|
+
targets = [...set].sort((a, b) => a - b);
|
|
271
|
+
} else {
|
|
272
|
+
const from = has(edit, "from") ? lineIndex(ctx, field(edit, "from")) : 0;
|
|
273
|
+
targets = [];
|
|
274
|
+
for (let i = from; i < ctx.lines.length; i++) targets.push(i);
|
|
275
|
+
}
|
|
276
|
+
const oneDigit = oneDigitHours(ctx);
|
|
277
|
+
const changes = [];
|
|
278
|
+
for (const i of targets) {
|
|
279
|
+
const time = ctx.lines[i].scan.time;
|
|
280
|
+
if (time) changes.push(retime(ctx, i, time.seconds + delta, oneDigit));
|
|
281
|
+
}
|
|
282
|
+
commit(ctx, changes);
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
function sortByTime(ctx) {
|
|
286
|
+
const lines = ctx.lines;
|
|
287
|
+
const first = lines.findIndex((l) => l.scan.time !== null);
|
|
288
|
+
if (first < 0) return [];
|
|
289
|
+
const blocks = [];
|
|
290
|
+
for (let i = first; i < lines.length; i++) {
|
|
291
|
+
const t = lines[i].scan.time;
|
|
292
|
+
if (t) blocks.push({ seconds: t.seconds, from: i, to: i + 1 });
|
|
293
|
+
else blocks[blocks.length - 1].to = i + 1;
|
|
294
|
+
}
|
|
295
|
+
const sorted = blocks.map((b, k) => ({ b, k })).sort((x, y) => x.b.seconds - y.b.seconds || x.k - y.k).map((x) => x.b);
|
|
296
|
+
const order = [];
|
|
297
|
+
for (let i = 0; i < first; i++) order.push(i);
|
|
298
|
+
for (const b of sorted) for (let i = b.from; i < b.to; i++) order.push(i);
|
|
299
|
+
const old = ctx.lines;
|
|
300
|
+
ctx.lines = order.map((src, slot) => {
|
|
301
|
+
const line = old[src];
|
|
302
|
+
const eol = old[slot].eol;
|
|
303
|
+
return { raw: line.raw, eol, changed: line.changed || src !== slot, scan: line.scan };
|
|
304
|
+
});
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
function setRest(ctx, edit) {
|
|
308
|
+
const index = lineIndex(ctx, field(edit, "line"));
|
|
309
|
+
const rest = singleLine(field(edit, "rest"));
|
|
310
|
+
const line = ctx.lines[index];
|
|
311
|
+
const p = line.scan.parts;
|
|
312
|
+
const spaced = p.gap.length > 0 && isWhitespace(p.gap[p.gap.length - 1]);
|
|
313
|
+
const gap = p.time !== "" && !spaced && rest !== "" ? `${p.gap} ` : p.gap;
|
|
314
|
+
setRaw(ctx, line, p.lead + p.time + gap + rest);
|
|
315
|
+
return [index];
|
|
316
|
+
}
|
|
317
|
+
function commonEol(ctx) {
|
|
318
|
+
let best = 0;
|
|
319
|
+
const top = [];
|
|
320
|
+
for (const [eol, n] of ctx.eols) {
|
|
321
|
+
if (n <= 0 || n < best) continue;
|
|
322
|
+
if (n > best) top.length = 0;
|
|
323
|
+
best = n;
|
|
324
|
+
top.push(eol);
|
|
325
|
+
}
|
|
326
|
+
if (top.length === 0) return "\n";
|
|
327
|
+
if (top.length === 1) return top[0];
|
|
328
|
+
for (const l of ctx.lines) if (top.includes(l.eol)) return l.eol;
|
|
329
|
+
return "\n";
|
|
330
|
+
}
|
|
331
|
+
function insert(ctx, edit) {
|
|
332
|
+
const after = field(edit, "after");
|
|
333
|
+
if (typeof after !== "number" || !Number.isInteger(after)) throw new InvalidEdit();
|
|
334
|
+
if (after < 0 || after > ctx.lines.length) throw new InvalidEdit();
|
|
335
|
+
const text = singleLine(field(edit, "text"));
|
|
336
|
+
const eol = commonEol(ctx);
|
|
337
|
+
const last = ctx.lines[ctx.lines.length - 1];
|
|
338
|
+
const line = workLine(text, ctx.lines.length === 0 ? "" : eol, true);
|
|
339
|
+
if (after === ctx.lines.length && last && last.eol === "") {
|
|
340
|
+
setEol(ctx, last, eol);
|
|
341
|
+
line.eol = "";
|
|
342
|
+
}
|
|
343
|
+
ctx.lines.splice(after, 0, line);
|
|
344
|
+
countLine(ctx, line, 1);
|
|
345
|
+
return [after];
|
|
346
|
+
}
|
|
347
|
+
function remove(ctx, edit) {
|
|
348
|
+
const index = lineIndex(ctx, field(edit, "line"));
|
|
349
|
+
const [gone] = ctx.lines.splice(index, 1);
|
|
350
|
+
if (gone) countLine(ctx, gone, -1);
|
|
351
|
+
const last = ctx.lines[ctx.lines.length - 1];
|
|
352
|
+
if (gone && index === ctx.lines.length && last && last.eol !== gone.eol) {
|
|
353
|
+
setEol(ctx, last, gone.eol);
|
|
354
|
+
}
|
|
355
|
+
return [index];
|
|
356
|
+
}
|
|
357
|
+
function mergeAt(ctx, k) {
|
|
358
|
+
const prev = ctx.lines[k];
|
|
359
|
+
const next = ctx.lines[k + 1];
|
|
360
|
+
if (!prev || !next || prev.eol !== "\r" || next.raw !== "" || next.eol !== "\n") return;
|
|
361
|
+
setEol(ctx, prev, "\r\n");
|
|
362
|
+
ctx.lines.splice(k + 1, 1);
|
|
363
|
+
countLine(ctx, next, -1);
|
|
364
|
+
}
|
|
365
|
+
function canonical(ctx, touched) {
|
|
366
|
+
const lines = ctx.lines;
|
|
367
|
+
let starts;
|
|
368
|
+
if (touched === null) {
|
|
369
|
+
starts = [];
|
|
370
|
+
for (let k = lines.length - 2; k >= 0; k--) starts.push(k);
|
|
371
|
+
} else {
|
|
372
|
+
const set = /* @__PURE__ */ new Set([lines.length - 2]);
|
|
373
|
+
for (const t of touched) {
|
|
374
|
+
set.add(t - 1);
|
|
375
|
+
set.add(t);
|
|
376
|
+
}
|
|
377
|
+
starts = [...set].sort((a, b) => b - a);
|
|
378
|
+
}
|
|
379
|
+
for (const k of starts) mergeAt(ctx, k);
|
|
380
|
+
const last = lines[lines.length - 1];
|
|
381
|
+
if (last && last.raw === "" && last.eol === "") {
|
|
382
|
+
lines.pop();
|
|
383
|
+
countLine(ctx, last, -1);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function applyOne(ctx, edit) {
|
|
387
|
+
if (typeof edit !== "object" || edit === null || Array.isArray(edit)) throw new InvalidEdit();
|
|
388
|
+
const op = field(edit, "op");
|
|
389
|
+
if (op === "set-time") return setTime(ctx, edit);
|
|
390
|
+
if (op === "shift") return shift(ctx, edit);
|
|
391
|
+
if (op === "sort-by-time") return sortByTime(ctx);
|
|
392
|
+
if (op === "set-rest") return setRest(ctx, edit);
|
|
393
|
+
if (op === "insert") return insert(ctx, edit);
|
|
394
|
+
if (op === "delete") return remove(ctx, edit);
|
|
395
|
+
throw new InvalidEdit();
|
|
396
|
+
}
|
|
397
|
+
function invalidLine(edit) {
|
|
398
|
+
if (typeof edit !== "object" || edit === null) return null;
|
|
399
|
+
try {
|
|
400
|
+
for (const key of ["line", "after"]) {
|
|
401
|
+
const value = field(edit, key);
|
|
402
|
+
if (typeof value === "number" && Number.isInteger(value)) return value;
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
} catch {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function applyEdits(text, edits, options) {
|
|
410
|
+
const source = typeof text === "string" ? text : "";
|
|
411
|
+
const list = Array.isArray(edits) ? edits : [];
|
|
412
|
+
if (list.length === 0) return { text: source, changed: [], warnings: [] };
|
|
413
|
+
const ctx = {
|
|
414
|
+
lines: splitLines(source).map((l) => workLine(l.raw, l.eol, false)),
|
|
415
|
+
index: 0,
|
|
416
|
+
warnings: [],
|
|
417
|
+
oneDigit: 0,
|
|
418
|
+
wideHours: 0,
|
|
419
|
+
eols: /* @__PURE__ */ new Map()
|
|
420
|
+
};
|
|
421
|
+
for (const l of ctx.lines) countLine(ctx, l, 1);
|
|
422
|
+
for (let i = 0; i < list.length; i++) {
|
|
423
|
+
ctx.index = i;
|
|
424
|
+
const warningsBefore = ctx.warnings.length;
|
|
425
|
+
try {
|
|
426
|
+
canonical(ctx, applyOne(ctx, list[i]));
|
|
427
|
+
} catch {
|
|
428
|
+
ctx.warnings.length = warningsBefore;
|
|
429
|
+
warnEdit(ctx, "edit-invalid", invalidLine(list[i]));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const changed = [];
|
|
433
|
+
ctx.lines.forEach((l, i) => {
|
|
434
|
+
if (l.changed) changed.push(i + 1);
|
|
435
|
+
});
|
|
436
|
+
return { text: joinLines(ctx.lines), changed, warnings: ctx.warnings };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/tracklist/kinds.ts
|
|
440
|
+
var headerWords = new Set(headerWordsData.words);
|
|
441
|
+
var defaultMarkers = null;
|
|
442
|
+
function markerTable(extra) {
|
|
443
|
+
const more = stringList(extra);
|
|
444
|
+
const entry = (w) => [markerWords(w).join(" "), true];
|
|
445
|
+
if (more.length === 0) {
|
|
446
|
+
defaultMarkers ?? (defaultMarkers = new PhraseTable(markerWordsData.words.map(entry)));
|
|
447
|
+
return defaultMarkers;
|
|
448
|
+
}
|
|
449
|
+
return new PhraseTable([...markerWordsData.words, ...more].map(entry));
|
|
450
|
+
}
|
|
451
|
+
var isMarkerBreak = (ch) => ch === "/" || ch === "-" || isWhitespace(ch);
|
|
452
|
+
var isBracket = (ch) => ch === "(" || ch === ")" || ch === "[" || ch === "]" || ch === "{" || ch === "}";
|
|
453
|
+
function markerWords(text) {
|
|
454
|
+
const out = [];
|
|
455
|
+
let current = "";
|
|
456
|
+
const flush = () => {
|
|
457
|
+
let a = 0;
|
|
458
|
+
let b = current.length;
|
|
459
|
+
while (a < b && isBracket(current[a])) a++;
|
|
460
|
+
while (b > a && isBracket(current[b - 1])) b--;
|
|
461
|
+
if (b > a) out.push(current.slice(a, b));
|
|
462
|
+
current = "";
|
|
463
|
+
};
|
|
464
|
+
for (const ch of text) {
|
|
465
|
+
if (isMarkerBreak(ch)) flush();
|
|
466
|
+
else current += ch;
|
|
467
|
+
}
|
|
468
|
+
flush();
|
|
469
|
+
return out;
|
|
470
|
+
}
|
|
471
|
+
function isBlank(raw) {
|
|
472
|
+
for (let i = 0; i < raw.length; i++) if (!isWhitespace(raw[i])) return false;
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
function trimWs(s) {
|
|
476
|
+
let a = 0;
|
|
477
|
+
let b = s.length;
|
|
478
|
+
while (a < b && isWhitespace(s[a])) a++;
|
|
479
|
+
while (b > a && isWhitespace(s[b - 1])) b--;
|
|
480
|
+
return s.slice(a, b);
|
|
481
|
+
}
|
|
482
|
+
function isLink(rest) {
|
|
483
|
+
const t = asciiLower(trimWs(rest).slice(0, 8));
|
|
484
|
+
return t.startsWith("http://") || t.startsWith("https://") || t.startsWith("www.");
|
|
485
|
+
}
|
|
486
|
+
function isHeader(rest) {
|
|
487
|
+
const t = trimWs(rest);
|
|
488
|
+
if (t.endsWith(":")) return true;
|
|
489
|
+
const words = splitWords(t);
|
|
490
|
+
const first = words[0];
|
|
491
|
+
return first !== void 0 && words.length <= 2 && inVocab(first, headerWords);
|
|
492
|
+
}
|
|
493
|
+
function hasMarkerWord(trackPart, markers) {
|
|
494
|
+
const words = markerWords(trackPart);
|
|
495
|
+
for (let i = 0; i < words.length; i++) if (markers.matchAt(words, i)) return true;
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
function hasSeparator(track, empty) {
|
|
499
|
+
if (empty || track.warnings.includes("noSeparator")) return false;
|
|
500
|
+
return track.flags.unknownArtist || track.artists.some((a) => a.role === "primary");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/tracklist/options.ts
|
|
504
|
+
var MODES = /* @__PURE__ */ new Set(["auto", "clean", "youtube", "filename", "tracklist"]);
|
|
505
|
+
var SPLIT_AND = /* @__PURE__ */ new Set(["auto", "always", "never"]);
|
|
506
|
+
function read(options, key) {
|
|
507
|
+
try {
|
|
508
|
+
return options[key];
|
|
509
|
+
} catch {
|
|
510
|
+
return void 0;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
function resolveOptions(options) {
|
|
514
|
+
const o = typeof options === "object" && options !== null ? options : {};
|
|
515
|
+
const mode = read(o, "mode");
|
|
516
|
+
const splitAnd = read(o, "splitAnd");
|
|
517
|
+
const keywords = read(o, "keywords");
|
|
518
|
+
const known = read(o, "knownArtists");
|
|
519
|
+
const audio = read(o, "audioSeconds");
|
|
520
|
+
const parse = {
|
|
521
|
+
mode: typeof mode === "string" && MODES.has(mode) ? mode : "tracklist"
|
|
522
|
+
};
|
|
523
|
+
if (Array.isArray(known)) parse.knownArtists = known;
|
|
524
|
+
if (typeof splitAnd === "string" && SPLIT_AND.has(splitAnd)) {
|
|
525
|
+
parse.splitAnd = splitAnd;
|
|
526
|
+
}
|
|
527
|
+
if (typeof keywords === "object" && keywords !== null && !Array.isArray(keywords)) {
|
|
528
|
+
parse.keywords = keywords;
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
parse,
|
|
532
|
+
audioSeconds: typeof audio === "number" && Number.isFinite(audio) && audio >= 0 ? audio : null,
|
|
533
|
+
markerWords: read(o, "markerWords")
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/tracklist/positions.ts
|
|
538
|
+
function bareCandidate(rest, positionTaken) {
|
|
539
|
+
if (positionTaken) return null;
|
|
540
|
+
const n = digitRun(rest, 0);
|
|
541
|
+
if (n < 1 || n > 3 || rest[n] !== " ") return null;
|
|
542
|
+
const stripped = rest.slice(n + 1);
|
|
543
|
+
for (let i = 0; i < stripped.length; i++) {
|
|
544
|
+
if (!isWhitespace(stripped[i])) {
|
|
545
|
+
const raw = rest.slice(0, n);
|
|
546
|
+
return { raw, number: Number(raw), stripped };
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
function confirmPositions(bare, candidateLine) {
|
|
552
|
+
const n = bare.length;
|
|
553
|
+
const prev = new Array(n).fill(-1);
|
|
554
|
+
const next = new Array(n).fill(-1);
|
|
555
|
+
let last = -1;
|
|
556
|
+
for (let i = 0; i < n; i++) {
|
|
557
|
+
prev[i] = last;
|
|
558
|
+
if (candidateLine[i]) last = i;
|
|
559
|
+
}
|
|
560
|
+
last = -1;
|
|
561
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
562
|
+
next[i] = last;
|
|
563
|
+
if (candidateLine[i]) last = i;
|
|
564
|
+
}
|
|
565
|
+
const adjacent = (b, j) => {
|
|
566
|
+
const o = j >= 0 ? bare[j] : null;
|
|
567
|
+
return o != null && (o.number === b.number - 1 || o.number === b.number + 1);
|
|
568
|
+
};
|
|
569
|
+
return bare.map(
|
|
570
|
+
(b, i) => b !== null && (adjacent(b, prev[i]) || adjacent(b, next[i]))
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// src/tracklist/warnings.ts
|
|
575
|
+
var ORDER = [
|
|
576
|
+
"time-missing",
|
|
577
|
+
"time-out-of-order",
|
|
578
|
+
"time-duplicate",
|
|
579
|
+
"no-start-at-zero",
|
|
580
|
+
"time-after-audio-end",
|
|
581
|
+
"invalid-time",
|
|
582
|
+
"position-gap",
|
|
583
|
+
"position-duplicate",
|
|
584
|
+
"line-unparsed"
|
|
585
|
+
];
|
|
586
|
+
function searchTime(raw, lo, hi) {
|
|
587
|
+
let fracStart = raw.length;
|
|
588
|
+
for (let i = 0; i < raw.length; i++) {
|
|
589
|
+
if (raw[i] === "." || raw[i] === ",") {
|
|
590
|
+
fracStart = i;
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const frac = raw.slice(fracStart + 1);
|
|
595
|
+
const values = [0];
|
|
596
|
+
const slots = [];
|
|
597
|
+
for (let i = 0; i < fracStart; i++) {
|
|
598
|
+
const ch = raw[i];
|
|
599
|
+
if (ch === ":") values.push(0);
|
|
600
|
+
else if (isDigit(ch)) {
|
|
601
|
+
const f = values.length - 1;
|
|
602
|
+
values[f] = values[f] * 10 + Number(ch);
|
|
603
|
+
slots.push({ at: i, field: f, place: 0, digit: Number(ch) });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
for (let k = slots.length - 1, place = 1, field2 = -1; k >= 0; k--) {
|
|
607
|
+
const slot = slots[k];
|
|
608
|
+
if (slot.field !== field2) {
|
|
609
|
+
field2 = slot.field;
|
|
610
|
+
place = 1;
|
|
611
|
+
}
|
|
612
|
+
slot.place = place;
|
|
613
|
+
place *= 10;
|
|
614
|
+
}
|
|
615
|
+
const mult = values.length === 3 ? [3600, 60, 1] : [60, 1];
|
|
616
|
+
const valueWith = (changes) => {
|
|
617
|
+
const v = [...values];
|
|
618
|
+
for (const [slot, d] of changes) {
|
|
619
|
+
v[slot.field] = v[slot.field] + (d - slot.digit) * slot.place;
|
|
620
|
+
}
|
|
621
|
+
let whole = 0;
|
|
622
|
+
for (let f = 0; f < v.length; f++) {
|
|
623
|
+
if (f > 0 && v[f] > 59) return null;
|
|
624
|
+
whole += v[f] * mult[f];
|
|
625
|
+
}
|
|
626
|
+
const t = secondsOf(whole, frac);
|
|
627
|
+
return (lo === null || lo < t) && (hi === null || t < hi) ? t : null;
|
|
628
|
+
};
|
|
629
|
+
const render = (changes) => {
|
|
630
|
+
const chars = [...raw];
|
|
631
|
+
for (const [slot, d] of changes) chars[slot.at] = String(d);
|
|
632
|
+
return chars.join("");
|
|
633
|
+
};
|
|
634
|
+
let found = null;
|
|
635
|
+
let count = 0;
|
|
636
|
+
for (const slot of slots) {
|
|
637
|
+
for (let d = 0; d <= 9; d++) {
|
|
638
|
+
if (d === slot.digit) continue;
|
|
639
|
+
const change = [[slot, d]];
|
|
640
|
+
if (valueWith(change) === null) continue;
|
|
641
|
+
count++;
|
|
642
|
+
found = change;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (count === 0) {
|
|
646
|
+
for (let x = 0; x < slots.length && count < 2; x++) {
|
|
647
|
+
for (let y = x + 1; y < slots.length && count < 2; y++) {
|
|
648
|
+
const a = slots[x];
|
|
649
|
+
const b = slots[y];
|
|
650
|
+
for (let da = 0; da <= 9 && count < 2; da++) {
|
|
651
|
+
if (da === a.digit) continue;
|
|
652
|
+
for (let db = 0; db <= 9 && count < 2; db++) {
|
|
653
|
+
if (db === b.digit) continue;
|
|
654
|
+
const change = [
|
|
655
|
+
[a, da],
|
|
656
|
+
[b, db]
|
|
657
|
+
];
|
|
658
|
+
if (valueWith(change) === null) continue;
|
|
659
|
+
count++;
|
|
660
|
+
found = change;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (count === 0 || found === null) return { kind: "none" };
|
|
667
|
+
if (count > 1) return { kind: "many" };
|
|
668
|
+
return { kind: "one", time: { raw: render(found), seconds: valueWith(found) } };
|
|
669
|
+
}
|
|
670
|
+
function suggestFix(times, k) {
|
|
671
|
+
const cur = times[k];
|
|
672
|
+
const prev = times[k - 1];
|
|
673
|
+
const next = times[k + 1];
|
|
674
|
+
const own = searchTime(cur.time.raw, prev.time.seconds, next ? next.time.seconds : null);
|
|
675
|
+
if (own.kind === "one") return { line: cur.line, ...own.time };
|
|
676
|
+
if (own.kind === "many") return null;
|
|
677
|
+
const before = times[k - 2];
|
|
678
|
+
const back = searchTime(prev.time.raw, before ? before.time.seconds : null, cur.time.seconds);
|
|
679
|
+
return back.kind === "one" ? { line: prev.line, ...back.time } : null;
|
|
680
|
+
}
|
|
681
|
+
function tracklistWarnings(input) {
|
|
682
|
+
const { entries, invalid, unparsed, audioSeconds } = input;
|
|
683
|
+
const perLine = entries.map(() => []);
|
|
684
|
+
const add = (i, code, suggestion = null) => {
|
|
685
|
+
const line = entries[i].line;
|
|
686
|
+
const severity = code === "no-start-at-zero" ? "info" : "warning";
|
|
687
|
+
perLine[i].push({ code, line, severity, suggestion });
|
|
688
|
+
};
|
|
689
|
+
const timed = [];
|
|
690
|
+
entries.forEach((e, i) => {
|
|
691
|
+
if (e.time) timed.push(i);
|
|
692
|
+
});
|
|
693
|
+
entries.forEach((e, i) => {
|
|
694
|
+
if (e.kind === "track" && e.time === null && timed.length > 0) add(i, "time-missing");
|
|
695
|
+
if (invalid[i]) add(i, "invalid-time");
|
|
696
|
+
if (unparsed[i]) add(i, "line-unparsed");
|
|
697
|
+
});
|
|
698
|
+
const times = timed.map((i) => {
|
|
699
|
+
const e = entries[i];
|
|
700
|
+
return { line: e.line, time: e.time };
|
|
701
|
+
});
|
|
702
|
+
timed.forEach((i, k) => {
|
|
703
|
+
const t = times[k].time;
|
|
704
|
+
if (k === 0 && t.seconds !== 0) add(i, "no-start-at-zero");
|
|
705
|
+
if (k > 0) {
|
|
706
|
+
const p = times[k - 1].time.seconds;
|
|
707
|
+
if (t.seconds < p) add(i, "time-out-of-order", suggestFix(times, k));
|
|
708
|
+
else if (t.seconds === p) add(i, "time-duplicate");
|
|
709
|
+
}
|
|
710
|
+
if (audioSeconds !== null && t.seconds > audioSeconds) add(i, "time-after-audio-end");
|
|
711
|
+
});
|
|
712
|
+
let prevPos = null;
|
|
713
|
+
entries.forEach((e, i) => {
|
|
714
|
+
const pos = e.kind === "track" ? e.track?.position : null;
|
|
715
|
+
if (!pos) return;
|
|
716
|
+
if (prevPos !== null) {
|
|
717
|
+
if (pos.number === prevPos) add(i, "position-duplicate");
|
|
718
|
+
else if (pos.number !== prevPos + 1) add(i, "position-gap");
|
|
719
|
+
}
|
|
720
|
+
prevPos = pos.number;
|
|
721
|
+
});
|
|
722
|
+
const rank = (c) => ORDER.indexOf(c);
|
|
723
|
+
return perLine.flatMap((ws) => ws.sort((a, b) => rank(a.code) - rank(b.code)));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// src/tracklist/parse.ts
|
|
727
|
+
function parseTracklist(text, options) {
|
|
728
|
+
const opts = resolveOptions(options);
|
|
729
|
+
const markers = markerTable(opts.markerWords);
|
|
730
|
+
const lines = splitLines(typeof text === "string" ? text : "");
|
|
731
|
+
const lp = createListParser(opts.parse);
|
|
732
|
+
const rows = lines.map((line) => {
|
|
733
|
+
const scan = scanTime(line.raw);
|
|
734
|
+
const blank = isBlank(line.raw);
|
|
735
|
+
const row = {
|
|
736
|
+
line,
|
|
737
|
+
scan,
|
|
738
|
+
blank,
|
|
739
|
+
candidate: false,
|
|
740
|
+
dash: false,
|
|
741
|
+
probe: null,
|
|
742
|
+
bare: null,
|
|
743
|
+
track: null,
|
|
744
|
+
separated: false,
|
|
745
|
+
marker: false
|
|
746
|
+
};
|
|
747
|
+
if (blank) return row;
|
|
748
|
+
const probe = lp.probe(scan.parts.rest);
|
|
749
|
+
row.probe = probe;
|
|
750
|
+
row.dash = probe.spacedDash;
|
|
751
|
+
row.candidate = scan.time !== null || row.dash;
|
|
752
|
+
row.bare = bareCandidate(scan.parts.rest, probe.position);
|
|
753
|
+
return row;
|
|
754
|
+
});
|
|
755
|
+
let candidates = 0;
|
|
756
|
+
let dashes = 0;
|
|
757
|
+
for (const r of rows) {
|
|
758
|
+
if (!r.candidate) continue;
|
|
759
|
+
candidates++;
|
|
760
|
+
if (r.dash) dashes++;
|
|
761
|
+
}
|
|
762
|
+
const listContext = candidates > 0 && 2 * dashes >= candidates;
|
|
763
|
+
const confirmed = confirmPositions(
|
|
764
|
+
rows.map((r) => r.bare),
|
|
765
|
+
rows.map((r) => r.candidate)
|
|
766
|
+
);
|
|
767
|
+
rows.forEach((r, i) => {
|
|
768
|
+
if (r.blank || !r.probe) return;
|
|
769
|
+
const bare = confirmed[i] ? r.bare : null;
|
|
770
|
+
const probe = bare ? lp.probe(bare.stripped) : r.probe;
|
|
771
|
+
r.marker = hasMarkerWord(bare ? bare.stripped : r.scan.parts.rest, markers);
|
|
772
|
+
const track = probe.finish(listContext && r.scan.time !== null && !r.marker);
|
|
773
|
+
if (bare) track.position = { raw: bare.raw, number: bare.number };
|
|
774
|
+
r.track = track;
|
|
775
|
+
r.separated = hasSeparator(track, probe.empty);
|
|
776
|
+
});
|
|
777
|
+
const entries = rows.map((r, i) => {
|
|
778
|
+
const kind = kindOf(r);
|
|
779
|
+
const keep = kind === "track" || kind === "marker";
|
|
780
|
+
return {
|
|
781
|
+
line: i + 1,
|
|
782
|
+
kind,
|
|
783
|
+
eol: r.line.eol,
|
|
784
|
+
raw: r.line.raw,
|
|
785
|
+
parts: r.scan.parts,
|
|
786
|
+
time: r.scan.time,
|
|
787
|
+
track: keep ? r.track : null
|
|
788
|
+
};
|
|
789
|
+
});
|
|
790
|
+
const warnings = tracklistWarnings({
|
|
791
|
+
entries,
|
|
792
|
+
invalid: rows.map((r) => r.scan.invalid),
|
|
793
|
+
// R13.7 line-unparsed: a track entry whose track part has no separator (R13.3).
|
|
794
|
+
unparsed: rows.map((r, i) => entries[i].kind === "track" && !r.separated),
|
|
795
|
+
audioSeconds: opts.audioSeconds
|
|
796
|
+
});
|
|
797
|
+
return { specVersion: SPEC_VERSION, entries, warnings, stats: statsOf(entries) };
|
|
798
|
+
}
|
|
799
|
+
function kindOf(r) {
|
|
800
|
+
if (r.blank || !r.track) return "blank";
|
|
801
|
+
const marker = !r.separated && r.marker;
|
|
802
|
+
if (r.scan.time) return marker ? "marker" : "track";
|
|
803
|
+
const rest = r.scan.parts.rest;
|
|
804
|
+
if (isLink(rest)) return "link";
|
|
805
|
+
if (isHeader(rest)) return "header";
|
|
806
|
+
if (marker) return "marker";
|
|
807
|
+
return r.separated ? "track" : "note";
|
|
808
|
+
}
|
|
809
|
+
function statsOf(entries) {
|
|
810
|
+
let tracks = 0;
|
|
811
|
+
let markers = 0;
|
|
812
|
+
let timed = 0;
|
|
813
|
+
let firstTime = null;
|
|
814
|
+
let lastTime = null;
|
|
815
|
+
for (const e of entries) {
|
|
816
|
+
if (e.kind === "track") tracks++;
|
|
817
|
+
if (e.kind === "marker") markers++;
|
|
818
|
+
if (e.time) {
|
|
819
|
+
timed++;
|
|
820
|
+
firstTime ?? (firstTime = e.time.seconds);
|
|
821
|
+
lastTime = e.time.seconds;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return { lines: entries.length, tracks, markers, timed, firstTime, lastTime };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export { applyEdits, parseTracklist };
|