@utilix-tech/sdk 0.22.0 → 0.24.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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Full release notes for all Utilix surfaces (browser tools, REST API, SDKs, MCP server) live at [utilix.tech/changelog](https://utilix.tech/changelog). This file tracks just this package.
4
4
 
5
+ ## 0.24.0
6
+ - Add `calculateDiscount` / `applySuccessiveDiscounts` (discount-calculator): solve a sale price, amount saved, or discount percent from any one of them, and stack discounts in sequence where 20% then 10% is an effective 28% off.
7
+ - Add `parseSubtitles`, `convertSubtitles`, `shiftSubtitles`, and their timestamp/format helpers (subtitle-converter): convert between SubRip and WebVTT and shift every cue by a fixed offset.
8
+
9
+ ## 0.23.0
10
+ - Add `calculateMarginMarkup` (margin-markup-calculator): given exactly two of cost, price, margin percent, and markup percent, solve for the other two plus the gross profit.
11
+ - Add `marginToMarkup` / `markupToMargin` helpers for converting between the two percentage bases.
12
+
5
13
  ## 0.22.0
6
14
  - Add `convertAprApy` (apr-apy-converter): convert between a nominal APR and effective APY for a given compounding frequency, in both directions.
7
15
  - Add `parseIso8601Duration` / `formatIso8601Duration` (iso8601-duration): parse an ISO 8601 duration into components and total seconds, or format seconds into a canonical duration string.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/sdk
2
2
 
3
- **558 developer utility functions for Node.js: runs locally, no API key required.**
3
+ **573 developer utility functions for Node.js: runs locally, no API key required.**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@utilix-tech/sdk?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@utilix-tech/sdk)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/@utilix-tech/sdk?style=flat-square)](https://www.npmjs.com/package/@utilix-tech/sdk)
@@ -170,12 +170,13 @@ detectFillerWords("At the end of the day, I just really think we should circle b
170
170
 
171
171
  ---
172
172
 
173
- ### `/data`: CSV / YAML / TOML / XML / INI / NDJSON / .env diff
173
+ ### `/data`: CSV / YAML / TOML / XML / INI / NDJSON / .env diff / subtitles
174
174
 
175
175
  ```ts
176
176
  import { parseCsv, csvToJson, validateYaml, tomlToJson, jsonToToml,
177
177
  formatXml, xmlToJson, jsonToXml, parseIni,
178
- validateNdjson, formatNdjson, ndjsonToJsonArray, diffEnvExample } from "@utilix-tech/sdk/data";
178
+ validateNdjson, formatNdjson, ndjsonToJsonArray, diffEnvExample,
179
+ parseSubtitles, convertSubtitles, shiftSubtitles } from "@utilix-tech/sdk/data";
179
180
 
180
181
  // CSV
181
182
  const rows = parseCsv("name,age\nalice,30\nbob,25");
@@ -203,6 +204,12 @@ ndjsonToJsonArray('{"id":1}\n{"id":2}'); // '[\n { "id": 1 },\n { "id": 2
203
204
  // Diff a .env file against its .env.example template
204
205
  diffEnvExample("FOO=1\nBAR=2", "FOO=\nBAR=y\nBAZ=z");
205
206
  // { missingInExample: [], missingInEnv: ["BAZ"], emptyValueInExample: ["FOO"], keysInBoth: 2 }
207
+
208
+ // Subtitles: SubRip <-> WebVTT, with an optional resync offset
209
+ convertSubtitles("1\n00:00:01,000 --> 00:00:04,000\nHello there.\n", "vtt");
210
+ // { format: "vtt", sourceFormat: "srt", output: "WEBVTT\n\n00:00:01.000 --> ...", cueCount: 1, ... }
211
+ shiftSubtitles("1\n00:00:01,000 --> 00:00:04,000\nHi\n", -2.5);
212
+ // shifts every cue 2.5s earlier, clamping anything below zero to 00:00:00
206
213
  ```
207
214
 
208
215
  ---
@@ -261,7 +268,7 @@ formatIso8601Duration(90061); // { iso: "P1DT1H1M1S", ... }
261
268
  ### `/units`: Conversions
262
269
 
263
270
  ```ts
264
- import { convertBytes, pxToAll, convert, formatValue, validateCardNumber, validateIban, calculateLoan, validateBarcode, calculateTax, calculateCompoundInterest, validateIsbn, calculateTipSplit, calculateSimpleInterest, calculateTimeValue, calculatePercentageChange, convertAprApy } from "@utilix-tech/sdk/units";
271
+ import { convertBytes, pxToAll, convert, formatValue, validateCardNumber, validateIban, calculateLoan, validateBarcode, calculateTax, calculateCompoundInterest, validateIsbn, calculateTipSplit, calculateSimpleInterest, calculateTimeValue, calculatePercentageChange, convertAprApy, calculateMarginMarkup, calculateDiscount, applySuccessiveDiscounts } from "@utilix-tech/sdk/units";
265
272
 
266
273
  // File sizes
267
274
  convertBytes(1073741824, "GB"); // 1
@@ -327,6 +334,18 @@ calculatePercentageChange(200, 250);
327
334
  // APR <-> APY conversion for a given compounding frequency
328
335
  convertAprApy(12, 12, "apr-to-apy");
329
336
  // { apr: 12, apy: 12.682503, difference: 0.682503, compoundsPerYear: 12, ... }
337
+
338
+ // Margin/markup: pass any TWO of cost, price, marginPercent, markupPercent
339
+ calculateMarginMarkup({ cost: 80, price: 100 });
340
+ // { cost: 80, price: 100, profit: 20, marginPercent: 20, markupPercent: 25, ... }
341
+
342
+ // Discount: original price plus exactly ONE of percent, amount, or final price
343
+ calculateDiscount({ originalPrice: 80, discountPercent: 25 });
344
+ // { originalPrice: 80, finalPrice: 60, discountAmount: 20, discountPercent: 25, ... }
345
+
346
+ // Stacked discounts: 20% then a further 10% is 28% off, not 30%
347
+ applySuccessiveDiscounts(100, [20, 10]);
348
+ // { finalPrice: 72, totalSaved: 28, effectiveDiscountPercent: 28, sumOfDiscountPercents: 30, ... }
330
349
  ```
331
350
 
332
351
  ---
@@ -577,7 +596,7 @@ readIcoInfo(new Uint8Array(icoBytes));
577
596
  | `@utilix-tech/sdk/encoding` | Base64, URL, HTML entities, Base32/Base58/Base62 encode/decode |
578
597
  | `@utilix-tech/sdk/hashing` | MD5, SHA-1/256/512, bcrypt, HMAC, .htpasswd |
579
598
  | `@utilix-tech/sdk/text` | Case convert, slugify, word count, lorem ipsum, line ops, HTML→Markdown |
580
- | `@utilix-tech/sdk/data` | CSV, YAML, TOML, XML, INI, NDJSON parse and convert |
599
+ | `@utilix-tech/sdk/data` | CSV, YAML, TOML, XML, INI, NDJSON parse and convert, SRT/WebVTT subtitles |
581
600
  | `@utilix-tech/sdk/generators` | UUID v4/v7, ULID, passwords, strength check, fake data |
582
601
  | `@utilix-tech/sdk/time` | Date diff, timezone convert, cron parse, relative time |
583
602
  | `@utilix-tech/sdk/units` | Bytes, CSS px/rem, number bases, currency format, credit card/IBAN/barcode/ISBN validation, loan calculator, compound/simple interest calculators, VAT/sales tax calculator, tip/bill split calculator |
@@ -5,12 +5,15 @@ import { parse, stringify } from 'smol-toml';
5
5
  // src/tools/data.ts
6
6
  var data_exports = {};
7
7
  __export(data_exports, {
8
+ convertSubtitles: () => convertSubtitles,
8
9
  csvToJson: () => csvToJson,
9
10
  detectDelimiter: () => detectDelimiter,
11
+ detectSubtitleFormat: () => detectSubtitleFormat,
10
12
  diffEnvExample: () => diffEnvExample,
11
13
  envToExport: () => envToExport,
12
14
  envToJson: () => envToJson,
13
15
  formatNdjson: () => formatNdjson,
16
+ formatSubtitleTimestamp: () => formatSubtitleTimestamp,
14
17
  formatToml: () => formatToml,
15
18
  formatXml: () => formatXml,
16
19
  getSection: () => getSection,
@@ -30,7 +33,11 @@ __export(data_exports, {
30
33
  parseCsv: () => parseCsv,
31
34
  parseEnv: () => parseEnv,
32
35
  parseIni: () => parseIni,
36
+ parseSubtitleTimestamp: () => parseSubtitleTimestamp,
37
+ parseSubtitles: () => parseSubtitles,
33
38
  parseXmlToObject: () => parseXmlToObject,
39
+ serializeSubtitles: () => serializeSubtitles,
40
+ shiftSubtitles: () => shiftSubtitles,
34
41
  stringifyIni: () => stringifyIni,
35
42
  toCsvString: () => toCsvString,
36
43
  tomlToJson: () => tomlToJson,
@@ -1157,5 +1164,175 @@ function jsonArrayToNdjson(input) {
1157
1164
  if (!Array.isArray(parsed)) return { error: "Input must be a JSON array" };
1158
1165
  return parsed.map((item) => JSON.stringify(item)).join("\n");
1159
1166
  }
1167
+ var _SUBTITLE_TIMESTAMP_RE = /(\d+):(\d{1,2}):(\d{1,2})[.,](\d{1,3})|(\d{1,3}):(\d{1,2})[.,](\d{1,3})/;
1168
+ var _VTT_BLOCK_KEYWORDS = ["NOTE", "STYLE", "REGION"];
1169
+ function parseSubtitleTimestamp(raw) {
1170
+ const m = _SUBTITLE_TIMESTAMP_RE.exec(raw.trim());
1171
+ if (!m || m.index !== 0) return null;
1172
+ let hours;
1173
+ let minutes;
1174
+ let seconds;
1175
+ let millis;
1176
+ if (m[1] !== void 0) {
1177
+ hours = Number(m[1]);
1178
+ minutes = Number(m[2]);
1179
+ seconds = Number(m[3]);
1180
+ millis = m[4];
1181
+ } else {
1182
+ hours = 0;
1183
+ minutes = Number(m[5]);
1184
+ seconds = Number(m[6]);
1185
+ millis = m[7];
1186
+ }
1187
+ if (minutes > 59 || seconds > 59) return null;
1188
+ return hours * 3600 + minutes * 60 + seconds + Number(millis.padEnd(3, "0")) / 1e3;
1189
+ }
1190
+ function formatSubtitleTimestamp(totalSeconds, format) {
1191
+ const totalMs = Math.round(Math.max(0, totalSeconds) * 1e3);
1192
+ const ms = totalMs % 1e3;
1193
+ const totalSecs = Math.floor(totalMs / 1e3);
1194
+ const pad = (n, width = 2) => String(n).padStart(width, "0");
1195
+ return `${pad(Math.floor(totalSecs / 3600))}:${pad(Math.floor(totalSecs / 60) % 60)}:${pad(totalSecs % 60)}${format === "srt" ? "," : "."}${pad(ms, 3)}`;
1196
+ }
1197
+ function _stripBom(input) {
1198
+ return input.charCodeAt(0) === 65279 ? input.slice(1) : input;
1199
+ }
1200
+ function detectSubtitleFormat(input) {
1201
+ const text = _stripBom(input).trimStart();
1202
+ if (/^WEBVTT/.test(text)) return "vtt";
1203
+ const arrow = /(\d{1,3}):(\d{1,2})(?::(\d{1,2}))?([.,])(\d{1,3})\s*-->/.exec(text);
1204
+ if (arrow && arrow[4] === ".") return "vtt";
1205
+ return "srt";
1206
+ }
1207
+ function parseSubtitles(input, format) {
1208
+ if (typeof input !== "string" || input.trim().length === 0) {
1209
+ return { error: "Subtitle input is empty" };
1210
+ }
1211
+ const sourceFormat = format ?? detectSubtitleFormat(input);
1212
+ const normalised = _stripBom(input).replace(/\r\n?/g, "\n");
1213
+ const warnings = [];
1214
+ let body = normalised;
1215
+ if (sourceFormat === "vtt") {
1216
+ const headerMatch = /^WEBVTT[^\n]*\n?/.exec(body.trimStart());
1217
+ if (headerMatch) {
1218
+ body = body.trimStart().slice(headerMatch[0].length);
1219
+ } else {
1220
+ warnings.push('WebVTT input is missing its "WEBVTT" header line');
1221
+ }
1222
+ }
1223
+ const blocks = body.split(/\n[ \t]*\n+/).map((b) => b.trim()).filter((b) => b.length > 0);
1224
+ const cues = [];
1225
+ for (const block of blocks) {
1226
+ const lines = block.split("\n");
1227
+ const arrowIdx = lines.findIndex((l) => l.includes("-->"));
1228
+ if (arrowIdx === -1) {
1229
+ const keyword = _VTT_BLOCK_KEYWORDS.find((k) => lines[0] === k || lines[0].startsWith(`${k} `));
1230
+ if (sourceFormat === "vtt" && keyword) {
1231
+ warnings.push(`Dropped a WebVTT ${keyword} block, which has no SRT equivalent`);
1232
+ } else {
1233
+ warnings.push(`Skipped a block with no "-->" timing line: ${JSON.stringify(lines[0].slice(0, 40))}`);
1234
+ }
1235
+ continue;
1236
+ }
1237
+ const timingLine = lines[arrowIdx];
1238
+ const parts = timingLine.split("-->");
1239
+ const start = parseSubtitleTimestamp(parts[0] ?? "");
1240
+ const rightTrimmed = (parts[1] ?? "").trim();
1241
+ const end = parseSubtitleTimestamp(rightTrimmed);
1242
+ if (start === null || end === null) {
1243
+ warnings.push(`Skipped a cue with an unparseable timing line: ${JSON.stringify(timingLine.slice(0, 60))}`);
1244
+ continue;
1245
+ }
1246
+ const endMatch = _SUBTITLE_TIMESTAMP_RE.exec(rightTrimmed);
1247
+ const settings = endMatch ? rightTrimmed.slice(endMatch[0].length).trim() : "";
1248
+ const idLines = lines.slice(0, arrowIdx).filter((l) => l.trim().length > 0);
1249
+ const text = lines.slice(arrowIdx + 1).join("\n").trim();
1250
+ const cue = { index: cues.length + 1, start, end, text };
1251
+ if (sourceFormat === "vtt" && idLines.length > 0) cue.id = idLines[idLines.length - 1].trim();
1252
+ if (settings.length > 0) cue.settings = settings;
1253
+ if (end < start) warnings.push(`Cue ${cue.index} ends before it starts`);
1254
+ if (text.length === 0) warnings.push(`Cue ${cue.index} has no text`);
1255
+ if (cues.length > 0 && start < cues[cues.length - 1].end) {
1256
+ warnings.push(`Cue ${cue.index} starts before cue ${cue.index - 1} ends (overlapping cues)`);
1257
+ }
1258
+ cues.push(cue);
1259
+ }
1260
+ if (cues.length === 0) {
1261
+ return { error: "No subtitle cues found: check that the input is a valid SRT or WebVTT file" };
1262
+ }
1263
+ return {
1264
+ format: sourceFormat,
1265
+ cues,
1266
+ cueCount: cues.length,
1267
+ duration: Math.round(Math.max(...cues.map((c) => c.end)) * 1e3) / 1e3,
1268
+ warnings
1269
+ };
1270
+ }
1271
+ function serializeSubtitles(cues, format) {
1272
+ const blocks = cues.map((cue, i) => {
1273
+ const time = `${formatSubtitleTimestamp(cue.start, format)} --> ${formatSubtitleTimestamp(cue.end, format)}`;
1274
+ if (format === "srt") return `${i + 1}
1275
+ ${time}
1276
+ ${cue.text}`;
1277
+ const timing = cue.settings ? `${time} ${cue.settings}` : time;
1278
+ return cue.id ? `${cue.id}
1279
+ ${timing}
1280
+ ${cue.text}` : `${timing}
1281
+ ${cue.text}`;
1282
+ });
1283
+ const body = blocks.join("\n\n");
1284
+ return format === "vtt" ? `WEBVTT
1285
+
1286
+ ${body}
1287
+ ` : `${body}
1288
+ `;
1289
+ }
1290
+ function convertSubtitles(input, target, sourceFormat) {
1291
+ if (target !== "srt" && target !== "vtt") return { error: 'target must be "srt" or "vtt"' };
1292
+ const parsed = parseSubtitles(input, sourceFormat);
1293
+ if ("error" in parsed) return parsed;
1294
+ const warnings = [...parsed.warnings];
1295
+ if (target === "srt") {
1296
+ const droppedSettings = parsed.cues.filter((c) => c.settings).length;
1297
+ const droppedIds = parsed.cues.filter((c) => c.id).length;
1298
+ if (droppedSettings > 0) {
1299
+ warnings.push(`Dropped WebVTT cue settings on ${droppedSettings} cue(s): SRT has no equivalent`);
1300
+ }
1301
+ if (droppedIds > 0) {
1302
+ warnings.push(`Dropped ${droppedIds} WebVTT cue identifier(s): SRT numbers cues sequentially instead`);
1303
+ }
1304
+ }
1305
+ return {
1306
+ format: target,
1307
+ sourceFormat: parsed.format,
1308
+ output: serializeSubtitles(parsed.cues, target),
1309
+ cueCount: parsed.cueCount,
1310
+ duration: parsed.duration,
1311
+ warnings
1312
+ };
1313
+ }
1314
+ function shiftSubtitles(input, offsetSeconds, target) {
1315
+ if (!Number.isFinite(offsetSeconds)) return { error: "offsetSeconds must be a finite number" };
1316
+ const parsed = parseSubtitles(input);
1317
+ if ("error" in parsed) return parsed;
1318
+ const warnings = [...parsed.warnings];
1319
+ let clamped = 0;
1320
+ const shifted = parsed.cues.map((cue) => {
1321
+ const start = cue.start + offsetSeconds;
1322
+ const end = cue.end + offsetSeconds;
1323
+ if (start < 0) clamped += 1;
1324
+ return { ...cue, start: Math.max(0, start), end: Math.max(0, end) };
1325
+ });
1326
+ if (clamped > 0) warnings.push(`${clamped} cue(s) would start before zero and were clamped to 00:00:00`);
1327
+ const outFormat = target ?? parsed.format;
1328
+ return {
1329
+ format: outFormat,
1330
+ sourceFormat: parsed.format,
1331
+ output: serializeSubtitles(shifted, outFormat),
1332
+ cueCount: shifted.length,
1333
+ duration: Math.round(Math.max(...shifted.map((c) => c.end)) * 1e3) / 1e3,
1334
+ warnings
1335
+ };
1336
+ }
1160
1337
 
1161
- export { csvToJson, data_exports, detectDelimiter, diffEnvExample, envToExport, envToJson, formatNdjson, formatToml, formatXml, getSection, getSectionNames, getTomlStats, getXmlStats, getYamlStats, iniToJson, jsonArrayToNdjson, jsonToIni, jsonToToml, jsonToXml, minifyNdjson, minifyToml, minifyXml, ndjsonToJsonArray, parseCsv, parseEnv, parseIni, parseXmlToObject, stringifyIni, toCsvString, tomlToJson, validateEnvKey, validateIni, validateNdjson, validateToml, validateXml, validateYaml, xmlToJson };
1338
+ export { convertSubtitles, csvToJson, data_exports, detectDelimiter, detectSubtitleFormat, diffEnvExample, envToExport, envToJson, formatNdjson, formatSubtitleTimestamp, formatToml, formatXml, getSection, getSectionNames, getTomlStats, getXmlStats, getYamlStats, iniToJson, jsonArrayToNdjson, jsonToIni, jsonToToml, jsonToXml, minifyNdjson, minifyToml, minifyXml, ndjsonToJsonArray, parseCsv, parseEnv, parseIni, parseSubtitleTimestamp, parseSubtitles, parseXmlToObject, serializeSubtitles, shiftSubtitles, stringifyIni, toCsvString, tomlToJson, validateEnvKey, validateIni, validateNdjson, validateToml, validateXml, validateYaml, xmlToJson };
@@ -14,12 +14,15 @@ __export(units_exports, {
14
14
  COMMON_TAX_RATES: () => COMMON_TAX_RATES,
15
15
  COMMON_TIP_PERCENTAGES: () => COMMON_TIP_PERCENTAGES,
16
16
  COMMON_VIEWPORTS: () => COMMON_VIEWPORTS,
17
+ applySuccessiveDiscounts: () => applySuccessiveDiscounts,
17
18
  autoFormat: () => autoFormat,
18
19
  bumpVersion: () => bumpVersion,
19
20
  calcAspectRatio: () => calcAspectRatio,
20
21
  calcSubnet: () => calcSubnet,
21
22
  calculateCompoundInterest: () => calculateCompoundInterest,
23
+ calculateDiscount: () => calculateDiscount,
22
24
  calculateLoan: () => calculateLoan,
25
+ calculateMarginMarkup: () => calculateMarginMarkup,
23
26
  calculatePercentageChange: () => calculatePercentageChange,
24
27
  calculateSimpleInterest: () => calculateSimpleInterest,
25
28
  calculateTax: () => calculateTax,
@@ -52,6 +55,8 @@ __export(units_exports, {
52
55
  isValidCidr: () => isValidCidr,
53
56
  isValidIp: () => isValidIp,
54
57
  isValidIpv4: () => isValidIpv4,
58
+ marginToMarkup: () => marginToMarkup,
59
+ markupToMargin: () => markupToMargin,
55
60
  octalToPermissions: () => octalToPermissions,
56
61
  parseFileSize: () => parseFileSize,
57
62
  parseInput: () => parseInput,
@@ -1394,5 +1399,154 @@ function convertAprApy(rate, compoundsPerYear, direction) {
1394
1399
  difference: round6(apy - apr)
1395
1400
  };
1396
1401
  }
1402
+ var _MARGIN_FIELDS = ["cost", "price", "marginPercent", "markupPercent"];
1403
+ function calculateMarginMarkup(input) {
1404
+ const provided = (v) => typeof v === "number" && Number.isFinite(v);
1405
+ const given = _MARGIN_FIELDS.filter((f) => provided(input[f]));
1406
+ if (given.length !== 2) {
1407
+ return { error: `Provide exactly two of cost, price, marginPercent, markupPercent (got ${given.length})` };
1408
+ }
1409
+ const has = (f) => given.includes(f);
1410
+ const cost0 = input.cost;
1411
+ const price0 = input.price;
1412
+ const margin0 = input.marginPercent;
1413
+ const markup0 = input.markupPercent;
1414
+ if (has("cost") && cost0 <= 0) return { error: "Cost must be greater than zero" };
1415
+ if (has("price") && price0 <= 0) return { error: "Price must be greater than zero" };
1416
+ if (has("marginPercent") && margin0 >= 100) {
1417
+ return { error: "Margin percent must be below 100 (a 100% margin implies a zero cost)" };
1418
+ }
1419
+ if (has("markupPercent") && markup0 <= -100) {
1420
+ return { error: "Markup percent must be greater than -100" };
1421
+ }
1422
+ let cost;
1423
+ let price;
1424
+ if (has("cost") && has("price")) {
1425
+ cost = cost0;
1426
+ price = price0;
1427
+ } else if (has("cost") && has("marginPercent")) {
1428
+ cost = cost0;
1429
+ price = cost0 / (1 - margin0 / 100);
1430
+ } else if (has("cost") && has("markupPercent")) {
1431
+ cost = cost0;
1432
+ price = cost0 * (1 + markup0 / 100);
1433
+ } else if (has("price") && has("marginPercent")) {
1434
+ price = price0;
1435
+ cost = price0 * (1 - margin0 / 100);
1436
+ } else if (has("price") && has("markupPercent")) {
1437
+ price = price0;
1438
+ cost = price0 / (1 + markup0 / 100);
1439
+ } else {
1440
+ return { error: "Margin and markup alone cannot be solved: also provide a cost or a price" };
1441
+ }
1442
+ if (!Number.isFinite(cost) || !Number.isFinite(price)) return { error: "Inputs produce a non-finite result" };
1443
+ if (cost <= 0) return { error: "Those inputs imply a cost of zero or less" };
1444
+ if (price <= 0) return { error: "Those inputs imply a price of zero or less" };
1445
+ const round4 = (n) => Math.round(n * 1e4) / 1e4;
1446
+ const profit = price - cost;
1447
+ return {
1448
+ cost: round4(cost),
1449
+ price: round4(price),
1450
+ profit: round4(profit),
1451
+ marginPercent: round4(profit / price * 100),
1452
+ markupPercent: round4(profit / cost * 100),
1453
+ given,
1454
+ solvedFor: _MARGIN_FIELDS.filter((f) => !given.includes(f))
1455
+ };
1456
+ }
1457
+ function marginToMarkup(marginPercent) {
1458
+ if (!Number.isFinite(marginPercent) || marginPercent >= 100) {
1459
+ return { error: "Margin percent must be a finite number below 100" };
1460
+ }
1461
+ return Math.round(marginPercent / (100 - marginPercent) * 100 * 1e4) / 1e4;
1462
+ }
1463
+ function markupToMargin(markupPercent) {
1464
+ if (!Number.isFinite(markupPercent) || markupPercent <= -100) {
1465
+ return { error: "Markup percent must be a finite number greater than -100" };
1466
+ }
1467
+ return Math.round(markupPercent / (100 + markupPercent) * 100 * 1e4) / 1e4;
1468
+ }
1469
+ var _DISCOUNT_FIELDS = ["discountPercent", "discountAmount", "finalPrice"];
1470
+ function calculateDiscount(input) {
1471
+ const provided = (v) => typeof v === "number" && Number.isFinite(v);
1472
+ if (!provided(input.originalPrice)) {
1473
+ return { error: "originalPrice is required and must be a finite number" };
1474
+ }
1475
+ const originalPrice = input.originalPrice;
1476
+ if (originalPrice <= 0) return { error: "Original price must be greater than zero" };
1477
+ const given = _DISCOUNT_FIELDS.filter((f) => provided(input[f]));
1478
+ if (given.length !== 1) {
1479
+ return { error: `Provide exactly one of discountPercent, discountAmount, finalPrice (got ${given.length})` };
1480
+ }
1481
+ const field = given[0];
1482
+ let discountAmount;
1483
+ if (field === "discountPercent") {
1484
+ const pct = input.discountPercent;
1485
+ if (pct < 0) return { error: "Discount percent cannot be negative" };
1486
+ if (pct > 100) return { error: "Discount percent cannot exceed 100" };
1487
+ discountAmount = originalPrice * (pct / 100);
1488
+ } else if (field === "discountAmount") {
1489
+ const amt = input.discountAmount;
1490
+ if (amt < 0) return { error: "Discount amount cannot be negative" };
1491
+ if (amt > originalPrice) return { error: "Discount amount cannot exceed the original price" };
1492
+ discountAmount = amt;
1493
+ } else {
1494
+ const fin = input.finalPrice;
1495
+ if (fin < 0) return { error: "Final price cannot be negative" };
1496
+ if (fin > originalPrice) {
1497
+ return { error: "Final price cannot exceed the original price: that is a price increase, not a discount" };
1498
+ }
1499
+ discountAmount = originalPrice - fin;
1500
+ }
1501
+ const round4 = (n) => Math.round(n * 1e4) / 1e4;
1502
+ const finalPrice = originalPrice - discountAmount;
1503
+ return {
1504
+ originalPrice: round4(originalPrice),
1505
+ finalPrice: round4(finalPrice),
1506
+ discountAmount: round4(discountAmount),
1507
+ discountPercent: round4(discountAmount / originalPrice * 100),
1508
+ given: field,
1509
+ solvedFor: _DISCOUNT_FIELDS.filter((f) => f !== field)
1510
+ };
1511
+ }
1512
+ function applySuccessiveDiscounts(originalPrice, discountPercents) {
1513
+ if (!Number.isFinite(originalPrice) || originalPrice <= 0) {
1514
+ return { error: "Original price must be a finite number greater than zero" };
1515
+ }
1516
+ if (!Array.isArray(discountPercents) || discountPercents.length === 0) {
1517
+ return { error: "Provide at least one discount percent" };
1518
+ }
1519
+ if (discountPercents.length > 20) {
1520
+ return { error: "Provide at most 20 successive discounts" };
1521
+ }
1522
+ const round4 = (n) => Math.round(n * 1e4) / 1e4;
1523
+ const steps = [];
1524
+ let running = originalPrice;
1525
+ let sumOfDiscountPercents = 0;
1526
+ for (const pct of discountPercents) {
1527
+ if (!Number.isFinite(pct)) return { error: "Every discount percent must be a finite number" };
1528
+ if (pct < 0) return { error: "Discount percent cannot be negative" };
1529
+ if (pct > 100) return { error: "Discount percent cannot exceed 100" };
1530
+ const priceBefore = running;
1531
+ const saved = priceBefore * (pct / 100);
1532
+ running = priceBefore - saved;
1533
+ sumOfDiscountPercents += pct;
1534
+ steps.push({
1535
+ discountPercent: round4(pct),
1536
+ priceBefore: round4(priceBefore),
1537
+ priceAfter: round4(running),
1538
+ saved: round4(saved)
1539
+ });
1540
+ }
1541
+ const totalSaved = originalPrice - running;
1542
+ return {
1543
+ originalPrice: round4(originalPrice),
1544
+ finalPrice: round4(running),
1545
+ totalSaved: round4(totalSaved),
1546
+ effectiveDiscountPercent: round4(totalSaved / originalPrice * 100),
1547
+ sumOfDiscountPercents: round4(sumOfDiscountPercents),
1548
+ steps
1549
+ };
1550
+ }
1397
1551
 
1398
- export { BASE_LABELS, BASE_PREFIXES, BYTE_UNITS, COMMON_CURRENCIES, COMMON_LOCALES, COMMON_PERMISSIONS, COMMON_RATIOS, COMMON_SIZES, COMMON_TAX_RATES, COMMON_TIP_PERCENTAGES, COMMON_VIEWPORTS, autoFormat, bumpVersion, calcAspectRatio, calcSubnet, calculateCompoundInterest, calculateLoan, calculatePercentageChange, calculateSimpleInterest, calculateTax, calculateTimeValue, calculateTipSplit, compareSemver, convert, convertAprApy, convertBytes, convertIp, coverBox, decimalToIp, decimalToIpCidr, describePermissions, fitIntoBox, formatNumber, formatValue, formatVwValue, formatWithSeparator, gcd, ipFromBinary, ipFromDecimal, ipFromHex, ipToBinary, ipToDecimal, ipToDecimalCidr, ipToHex, ipToOctal, isValid, isValidCidr, isValidIp, isValidIpv4, octalToPermissions, parseFileSize, parseInput, parseNumberString, parseSemver, permissionsToChmod, permissionsToOctal, permissionsToSymbolic, ptToPx, pxToAll, pxToViewport, remToPx, satisfiesRange, scaleByHeight, scaleByWidth, sortVersions, splitSubnet, symbolicToPermissions, units_exports, validateBarcode, validateCardNumber, validateIban, validateIsbn, viewportToPx };
1552
+ export { BASE_LABELS, BASE_PREFIXES, BYTE_UNITS, COMMON_CURRENCIES, COMMON_LOCALES, COMMON_PERMISSIONS, COMMON_RATIOS, COMMON_SIZES, COMMON_TAX_RATES, COMMON_TIP_PERCENTAGES, COMMON_VIEWPORTS, applySuccessiveDiscounts, autoFormat, bumpVersion, calcAspectRatio, calcSubnet, calculateCompoundInterest, calculateDiscount, calculateLoan, calculateMarginMarkup, calculatePercentageChange, calculateSimpleInterest, calculateTax, calculateTimeValue, calculateTipSplit, compareSemver, convert, convertAprApy, convertBytes, convertIp, coverBox, decimalToIp, decimalToIpCidr, describePermissions, fitIntoBox, formatNumber, formatValue, formatVwValue, formatWithSeparator, gcd, ipFromBinary, ipFromDecimal, ipFromHex, ipToBinary, ipToDecimal, ipToDecimalCidr, ipToHex, ipToOctal, isValid, isValidCidr, isValidIp, isValidIpv4, marginToMarkup, markupToMargin, octalToPermissions, parseFileSize, parseInput, parseNumberString, parseSemver, permissionsToChmod, permissionsToOctal, permissionsToSymbolic, ptToPx, pxToAll, pxToViewport, remToPx, satisfiesRange, scaleByHeight, scaleByWidth, sortVersions, splitSubnet, symbolicToPermissions, units_exports, validateBarcode, validateCardNumber, validateIban, validateIsbn, viewportToPx };
@@ -12,6 +12,7 @@
12
12
  * - env-parser : parseEnv, envToJson, envToExport, validateEnvKey
13
13
  * - env-diff-checker: diffEnvExample
14
14
  * - ndjson-formatter: validateNdjson, minifyNdjson, formatNdjson, ndjsonToJsonArray, jsonArrayToNdjson
15
+ * - subtitle-converter: parseSubtitleTimestamp, formatSubtitleTimestamp, detectSubtitleFormat, parseSubtitles, serializeSubtitles, convertSubtitles, shiftSubtitles
15
16
  */
16
17
  interface ValidationResult {
17
18
  valid: boolean;
@@ -210,7 +211,69 @@ declare function ndjsonToJsonArray(input: string): string | {
210
211
  declare function jsonArrayToNdjson(input: string): string | {
211
212
  error: string;
212
213
  };
214
+ type SubtitleFormat = "srt" | "vtt";
215
+ interface SubtitleCue {
216
+ index: number;
217
+ /** Cue identifier line, when the source had one (WebVTT only). */
218
+ id?: string;
219
+ /** Start time in seconds. */
220
+ start: number;
221
+ /** End time in seconds. */
222
+ end: number;
223
+ /** WebVTT cue settings that followed the timestamp (e.g. `line:0 align:start`). */
224
+ settings?: string;
225
+ text: string;
226
+ }
227
+ interface ParsedSubtitles {
228
+ format: SubtitleFormat;
229
+ cues: SubtitleCue[];
230
+ cueCount: number;
231
+ /** End time of the last cue, in seconds. */
232
+ duration: number;
233
+ warnings: string[];
234
+ }
235
+ interface ConvertedSubtitles {
236
+ format: SubtitleFormat;
237
+ sourceFormat: SubtitleFormat;
238
+ output: string;
239
+ cueCount: number;
240
+ duration: number;
241
+ warnings: string[];
242
+ }
243
+ /** Parse `HH:MM:SS,mmm`, `HH:MM:SS.mmm`, or WebVTT's `MM:SS.mmm` into seconds. */
244
+ declare function parseSubtitleTimestamp(raw: string): number | null;
245
+ /** Format seconds as `HH:MM:SS,mmm` (SRT) or `HH:MM:SS.mmm` (WebVTT). */
246
+ declare function formatSubtitleTimestamp(totalSeconds: number, format: SubtitleFormat): string;
247
+ /** Auto-detect the source format from the WEBVTT signature or timestamp separator. */
248
+ declare function detectSubtitleFormat(input: string): SubtitleFormat;
249
+ /**
250
+ * Parse an SRT or WebVTT file into normalised cues. The format is auto-detected
251
+ * unless you pass one. Malformed blocks are reported as warnings rather than
252
+ * throwing, so a mostly-good file still converts.
253
+ */
254
+ declare function parseSubtitles(input: string, format?: SubtitleFormat): ParsedSubtitles | {
255
+ error: string;
256
+ };
257
+ /** Serialise parsed cues back into SRT or WebVTT text. */
258
+ declare function serializeSubtitles(cues: SubtitleCue[], format: SubtitleFormat): string;
259
+ /**
260
+ * Convert a subtitle file to the target format. Converting WebVTT to SRT drops
261
+ * cue identifiers, cue settings, and NOTE/STYLE/REGION blocks, since SRT has no
262
+ * equivalent for any of them; each drop is reported as a warning.
263
+ */
264
+ declare function convertSubtitles(input: string, target: SubtitleFormat, sourceFormat?: SubtitleFormat): ConvertedSubtitles | {
265
+ error: string;
266
+ };
267
+ /**
268
+ * Shift every cue by a fixed offset in seconds, keeping the source format
269
+ * unless a target is given. Negative offsets clamp at zero, so a cue that would
270
+ * start before the video does starts at 00:00:00 instead.
271
+ */
272
+ declare function shiftSubtitles(input: string, offsetSeconds: number, target?: SubtitleFormat): ConvertedSubtitles | {
273
+ error: string;
274
+ };
213
275
 
276
+ type data_ConvertedSubtitles = ConvertedSubtitles;
214
277
  type data_CsvParseResult = CsvParseResult;
215
278
  type data_EnvDiffError = EnvDiffError;
216
279
  type data_EnvDiffResult = EnvDiffResult;
@@ -221,16 +284,22 @@ type data_NdjsonLineResult = NdjsonLineResult;
221
284
  type data_NdjsonValidateResult = NdjsonValidateResult;
222
285
  type data_ParseOptions = ParseOptions;
223
286
  type data_ParsedIni = ParsedIni;
287
+ type data_ParsedSubtitles = ParsedSubtitles;
288
+ type data_SubtitleCue = SubtitleCue;
289
+ type data_SubtitleFormat = SubtitleFormat;
224
290
  type data_TomlFormatResult = TomlFormatResult;
225
291
  type data_ValidationResult = ValidationResult;
226
292
  type data_XmlFormatResult = XmlFormatResult;
227
293
  type data_XmlToJsonOptions = XmlToJsonOptions;
294
+ declare const data_convertSubtitles: typeof convertSubtitles;
228
295
  declare const data_csvToJson: typeof csvToJson;
229
296
  declare const data_detectDelimiter: typeof detectDelimiter;
297
+ declare const data_detectSubtitleFormat: typeof detectSubtitleFormat;
230
298
  declare const data_diffEnvExample: typeof diffEnvExample;
231
299
  declare const data_envToExport: typeof envToExport;
232
300
  declare const data_envToJson: typeof envToJson;
233
301
  declare const data_formatNdjson: typeof formatNdjson;
302
+ declare const data_formatSubtitleTimestamp: typeof formatSubtitleTimestamp;
234
303
  declare const data_formatToml: typeof formatToml;
235
304
  declare const data_formatXml: typeof formatXml;
236
305
  declare const data_getSection: typeof getSection;
@@ -250,7 +319,11 @@ declare const data_ndjsonToJsonArray: typeof ndjsonToJsonArray;
250
319
  declare const data_parseCsv: typeof parseCsv;
251
320
  declare const data_parseEnv: typeof parseEnv;
252
321
  declare const data_parseIni: typeof parseIni;
322
+ declare const data_parseSubtitleTimestamp: typeof parseSubtitleTimestamp;
323
+ declare const data_parseSubtitles: typeof parseSubtitles;
253
324
  declare const data_parseXmlToObject: typeof parseXmlToObject;
325
+ declare const data_serializeSubtitles: typeof serializeSubtitles;
326
+ declare const data_shiftSubtitles: typeof shiftSubtitles;
254
327
  declare const data_stringifyIni: typeof stringifyIni;
255
328
  declare const data_toCsvString: typeof toCsvString;
256
329
  declare const data_tomlToJson: typeof tomlToJson;
@@ -262,7 +335,7 @@ declare const data_validateXml: typeof validateXml;
262
335
  declare const data_validateYaml: typeof validateYaml;
263
336
  declare const data_xmlToJson: typeof xmlToJson;
264
337
  declare namespace data {
265
- export { type data_CsvParseResult as CsvParseResult, type data_EnvDiffError as EnvDiffError, type data_EnvDiffResult as EnvDiffResult, type data_EnvEntry as EnvEntry, type data_EnvParseResult as EnvParseResult, type data_IniSection as IniSection, type data_NdjsonLineResult as NdjsonLineResult, type data_NdjsonValidateResult as NdjsonValidateResult, type data_ParseOptions as ParseOptions, type data_ParsedIni as ParsedIni, type data_TomlFormatResult as TomlFormatResult, type data_ValidationResult as ValidationResult, type data_XmlFormatResult as XmlFormatResult, type data_XmlToJsonOptions as XmlToJsonOptions, data_csvToJson as csvToJson, data_detectDelimiter as detectDelimiter, data_diffEnvExample as diffEnvExample, data_envToExport as envToExport, data_envToJson as envToJson, data_formatNdjson as formatNdjson, data_formatToml as formatToml, data_formatXml as formatXml, data_getSection as getSection, data_getSectionNames as getSectionNames, data_getTomlStats as getTomlStats, data_getXmlStats as getXmlStats, data_getYamlStats as getYamlStats, data_iniToJson as iniToJson, data_jsonArrayToNdjson as jsonArrayToNdjson, data_jsonToIni as jsonToIni, data_jsonToToml as jsonToToml, data_jsonToXml as jsonToXml, data_minifyNdjson as minifyNdjson, data_minifyToml as minifyToml, data_minifyXml as minifyXml, data_ndjsonToJsonArray as ndjsonToJsonArray, data_parseCsv as parseCsv, data_parseEnv as parseEnv, data_parseIni as parseIni, data_parseXmlToObject as parseXmlToObject, data_stringifyIni as stringifyIni, data_toCsvString as toCsvString, data_tomlToJson as tomlToJson, data_validateEnvKey as validateEnvKey, data_validateIni as validateIni, data_validateNdjson as validateNdjson, data_validateToml as validateToml, data_validateXml as validateXml, data_validateYaml as validateYaml, data_xmlToJson as xmlToJson };
338
+ export { type data_ConvertedSubtitles as ConvertedSubtitles, type data_CsvParseResult as CsvParseResult, type data_EnvDiffError as EnvDiffError, type data_EnvDiffResult as EnvDiffResult, type data_EnvEntry as EnvEntry, type data_EnvParseResult as EnvParseResult, type data_IniSection as IniSection, type data_NdjsonLineResult as NdjsonLineResult, type data_NdjsonValidateResult as NdjsonValidateResult, type data_ParseOptions as ParseOptions, type data_ParsedIni as ParsedIni, type data_ParsedSubtitles as ParsedSubtitles, type data_SubtitleCue as SubtitleCue, type data_SubtitleFormat as SubtitleFormat, type data_TomlFormatResult as TomlFormatResult, type data_ValidationResult as ValidationResult, type data_XmlFormatResult as XmlFormatResult, type data_XmlToJsonOptions as XmlToJsonOptions, data_convertSubtitles as convertSubtitles, data_csvToJson as csvToJson, data_detectDelimiter as detectDelimiter, data_detectSubtitleFormat as detectSubtitleFormat, data_diffEnvExample as diffEnvExample, data_envToExport as envToExport, data_envToJson as envToJson, data_formatNdjson as formatNdjson, data_formatSubtitleTimestamp as formatSubtitleTimestamp, data_formatToml as formatToml, data_formatXml as formatXml, data_getSection as getSection, data_getSectionNames as getSectionNames, data_getTomlStats as getTomlStats, data_getXmlStats as getXmlStats, data_getYamlStats as getYamlStats, data_iniToJson as iniToJson, data_jsonArrayToNdjson as jsonArrayToNdjson, data_jsonToIni as jsonToIni, data_jsonToToml as jsonToToml, data_jsonToXml as jsonToXml, data_minifyNdjson as minifyNdjson, data_minifyToml as minifyToml, data_minifyXml as minifyXml, data_ndjsonToJsonArray as ndjsonToJsonArray, data_parseCsv as parseCsv, data_parseEnv as parseEnv, data_parseIni as parseIni, data_parseSubtitleTimestamp as parseSubtitleTimestamp, data_parseSubtitles as parseSubtitles, data_parseXmlToObject as parseXmlToObject, data_serializeSubtitles as serializeSubtitles, data_shiftSubtitles as shiftSubtitles, data_stringifyIni as stringifyIni, data_toCsvString as toCsvString, data_tomlToJson as tomlToJson, data_validateEnvKey as validateEnvKey, data_validateIni as validateIni, data_validateNdjson as validateNdjson, data_validateToml as validateToml, data_validateXml as validateXml, data_validateYaml as validateYaml, data_xmlToJson as xmlToJson };
266
339
  }
267
340
 
268
- export { minifyToml as A, minifyXml as B, type CsvParseResult as C, ndjsonToJsonArray as D, type EnvDiffError as E, parseCsv as F, parseEnv as G, parseIni as H, type IniSection as I, parseXmlToObject as J, stringifyIni as K, toCsvString as L, tomlToJson as M, type NdjsonLineResult as N, validateEnvKey as O, type ParseOptions as P, validateIni as Q, validateNdjson as R, validateToml as S, type TomlFormatResult as T, validateXml as U, type ValidationResult as V, validateYaml as W, type XmlFormatResult as X, xmlToJson as Y, type EnvDiffResult as a, type EnvEntry as b, type EnvParseResult as c, data as d, type NdjsonValidateResult as e, type ParsedIni as f, type XmlToJsonOptions as g, csvToJson as h, detectDelimiter as i, diffEnvExample as j, envToExport as k, envToJson as l, formatNdjson as m, formatToml as n, formatXml as o, getSection as p, getSectionNames as q, getTomlStats as r, getXmlStats as s, getYamlStats as t, iniToJson as u, jsonArrayToNdjson as v, jsonToIni as w, jsonToToml as x, jsonToXml as y, minifyNdjson as z };
341
+ export { toCsvString as $, iniToJson as A, jsonArrayToNdjson as B, type ConvertedSubtitles as C, jsonToIni as D, type EnvDiffError as E, jsonToToml as F, jsonToXml as G, minifyNdjson as H, type IniSection as I, minifyToml as J, minifyXml as K, ndjsonToJsonArray as L, parseCsv as M, type NdjsonLineResult as N, parseEnv as O, type ParseOptions as P, parseIni as Q, parseSubtitleTimestamp as R, type SubtitleCue as S, type TomlFormatResult as T, parseSubtitles as U, type ValidationResult as V, parseXmlToObject as W, type XmlFormatResult as X, serializeSubtitles as Y, shiftSubtitles as Z, stringifyIni as _, type CsvParseResult as a, tomlToJson as a0, validateEnvKey as a1, validateIni as a2, validateNdjson as a3, validateToml as a4, validateXml as a5, validateYaml as a6, xmlToJson as a7, type EnvDiffResult as b, type EnvEntry as c, data as d, type EnvParseResult as e, type NdjsonValidateResult as f, type ParsedIni as g, type ParsedSubtitles as h, type SubtitleFormat as i, type XmlToJsonOptions as j, convertSubtitles as k, csvToJson as l, detectDelimiter as m, detectSubtitleFormat as n, diffEnvExample as o, envToExport as p, envToJson as q, formatNdjson as r, formatSubtitleTimestamp as s, formatToml as t, formatXml as u, getSection as v, getSectionNames as w, getTomlStats as x, getXmlStats as y, getYamlStats as z };