@nextera.one/tps-standard 0.6.0 → 0.8.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.
@@ -0,0 +1,3786 @@
1
+ /*! TPS v0.8.0 | Apache-2.0 */
2
+ var TPS = (function (exports) {
3
+ 'use strict';
4
+
5
+ /**
6
+ * TPS: Temporal Positioning System
7
+ * Shared types and interfaces.
8
+ */
9
+ /**
10
+ * Calendar codes are plain strings to allow arbitrary user-defined
11
+ * calendars. The library still exports constants for the built-in values.
12
+ */
13
+ const DefaultCalendars = {
14
+ TPS: "tps",
15
+ GREG: "greg",
16
+ HIJ: "hij",
17
+ PER: "per",
18
+ JUL: "jul",
19
+ HOLO: "holo",
20
+ UNIX: "unix",
21
+ CHIN: "chin",
22
+ };
23
+ /**
24
+ * Specifies the direction of the time-component hierarchy when serializing or
25
+ * deserializing a TPS string. The default is 'descending'.
26
+ */
27
+ exports.TimeOrder = void 0;
28
+ (function (TimeOrder) {
29
+ TimeOrder["DESC"] = "desc";
30
+ TimeOrder["ASC"] = "asc";
31
+ })(exports.TimeOrder || (exports.TimeOrder = {}));
32
+
33
+ const TPS_DAY_MS = 24 * 60 * 60 * 1000;
34
+ const TPS_DAYS_PER_WEEK = 7;
35
+ const TPS_WEEKS_PER_MONTH = 4;
36
+ const TPS_MONTHS_PER_YEAR = 12;
37
+ const TPS_DAYS_PER_MONTH = TPS_DAYS_PER_WEEK * TPS_WEEKS_PER_MONTH;
38
+ const TPS_DAYS_PER_YEAR = TPS_DAYS_PER_MONTH * TPS_MONTHS_PER_YEAR;
39
+ const TPS_EPOCH_START_MS = Date.UTC(1999, 7, 11, 7, 0, 0, 0);
40
+ function floorDiv(value, divisor) {
41
+ return Math.floor(value / divisor);
42
+ }
43
+ function mod(value, divisor) {
44
+ return ((value % divisor) + divisor) % divisor;
45
+ }
46
+ function getFractionalMilliseconds(second) {
47
+ if (second === undefined)
48
+ return 0;
49
+ const fractional = second - Math.floor(second);
50
+ return Math.round(fractional * 1000);
51
+ }
52
+ function normalizeIndexedParts(dayIndex, dayFraction) {
53
+ const roundedSubDayMilliseconds = Math.round(dayFraction * TPS_DAY_MS);
54
+ const dayCarry = floorDiv(roundedSubDayMilliseconds, TPS_DAY_MS);
55
+ const subDayMilliseconds = mod(roundedSubDayMilliseconds, TPS_DAY_MS);
56
+ const normalizedDayIndex = dayIndex + dayCarry;
57
+ return {
58
+ dayIndex: normalizedDayIndex,
59
+ dayFraction: subDayMilliseconds / TPS_DAY_MS,
60
+ subDayMilliseconds,
61
+ };
62
+ }
63
+ function splitTpsFullYear(fullYear) {
64
+ const millennium = floorDiv(fullYear, 1000) + 1;
65
+ const withinMillennium = mod(fullYear, 1000);
66
+ const century = floorDiv(withinMillennium, 100) + 1;
67
+ const year = mod(fullYear, 100);
68
+ return { millennium, century, year };
69
+ }
70
+ function getTpsFullYear(components) {
71
+ if (components.millennium !== undefined ||
72
+ components.century !== undefined) {
73
+ return (((components.millennium ?? 1) - 1) * 1000 +
74
+ ((components.century ?? 1) - 1) * 100 +
75
+ (components.year ?? 0));
76
+ }
77
+ return components.year ?? 0;
78
+ }
79
+ function getTpsDayOfMonth(components) {
80
+ const day = components.day ?? 1;
81
+ const week = components.week;
82
+ if (week !== undefined && day >= 1 && day <= TPS_DAYS_PER_WEEK) {
83
+ return (week - 1) * TPS_DAYS_PER_WEEK + day;
84
+ }
85
+ return day;
86
+ }
87
+ function getTpsSubDayMilliseconds(input) {
88
+ if (input instanceof Date) {
89
+ const indexed = getTpsIndexedFromDate(input);
90
+ return indexed.subDayMilliseconds;
91
+ }
92
+ if (input.subDayMilliseconds !== undefined) {
93
+ return input.subDayMilliseconds;
94
+ }
95
+ const hour = input.hour ?? 0;
96
+ const minute = input.minute ?? 0;
97
+ const second = Math.floor(input.second ?? 0);
98
+ const millisecond = input.millisecond ?? getFractionalMilliseconds(input.second);
99
+ return (hour * 60 * 60 * 1000 +
100
+ minute * 60 * 1000 +
101
+ second * 1000 +
102
+ millisecond);
103
+ }
104
+ function getTpsIndexedFromDate(date) {
105
+ const deltaMs = date.getTime() - TPS_EPOCH_START_MS;
106
+ const dayIndex = floorDiv(deltaMs, TPS_DAY_MS);
107
+ const subDayMilliseconds = mod(deltaMs, TPS_DAY_MS);
108
+ return {
109
+ dayIndex,
110
+ dayFraction: subDayMilliseconds / TPS_DAY_MS,
111
+ subDayMilliseconds,
112
+ };
113
+ }
114
+ function buildTpsComponentsFromDayIndex(dayIndex, dayFraction = 0) {
115
+ const indexed = normalizeIndexedParts(dayIndex, dayFraction);
116
+ const fullYear = floorDiv(indexed.dayIndex, TPS_DAYS_PER_YEAR);
117
+ const dayOfYear = mod(indexed.dayIndex, TPS_DAYS_PER_YEAR);
118
+ const month = floorDiv(dayOfYear, TPS_DAYS_PER_MONTH) + 1;
119
+ const dayOfMonth = mod(dayOfYear, TPS_DAYS_PER_MONTH) + 1;
120
+ const week = floorDiv(dayOfMonth - 1, TPS_DAYS_PER_WEEK) + 1;
121
+ let remainder = indexed.subDayMilliseconds;
122
+ const hour = floorDiv(remainder, 60 * 60 * 1000);
123
+ remainder = mod(remainder, 60 * 60 * 1000);
124
+ const minute = floorDiv(remainder, 60 * 1000);
125
+ remainder = mod(remainder, 60 * 1000);
126
+ const second = floorDiv(remainder, 1000);
127
+ const millisecond = mod(remainder, 1000);
128
+ return {
129
+ calendar: "tps",
130
+ ...splitTpsFullYear(fullYear),
131
+ month,
132
+ week,
133
+ day: dayOfMonth,
134
+ hour,
135
+ minute,
136
+ second,
137
+ millisecond,
138
+ dayIndex: indexed.dayIndex,
139
+ dayFraction: indexed.dayFraction,
140
+ subDayMilliseconds: indexed.subDayMilliseconds,
141
+ };
142
+ }
143
+ function normalizeTpsComponents(components) {
144
+ if (components.dayIndex !== undefined) {
145
+ const dayFraction = components.dayFraction ??
146
+ (getTpsSubDayMilliseconds(components) / TPS_DAY_MS);
147
+ const normalized = buildTpsComponentsFromDayIndex(components.dayIndex, dayFraction);
148
+ return {
149
+ ...components,
150
+ ...normalized,
151
+ calendar: "tps",
152
+ fractionPrecision: components.fractionPrecision,
153
+ };
154
+ }
155
+ const fullYear = getTpsFullYear(components);
156
+ const month = components.month ?? 1;
157
+ const dayOfMonth = getTpsDayOfMonth(components);
158
+ const subDayMilliseconds = getTpsSubDayMilliseconds(components);
159
+ const week = components.week ?? floorDiv(dayOfMonth - 1, TPS_DAYS_PER_WEEK) + 1;
160
+ const timeParts = buildTpsComponentsFromDayIndex(fullYear * TPS_DAYS_PER_YEAR +
161
+ (month - 1) * TPS_DAYS_PER_MONTH +
162
+ (dayOfMonth - 1), subDayMilliseconds / TPS_DAY_MS);
163
+ return {
164
+ ...components,
165
+ ...splitTpsFullYear(fullYear),
166
+ ...timeParts,
167
+ calendar: "tps",
168
+ month,
169
+ week,
170
+ day: dayOfMonth,
171
+ dayIndex: fullYear * TPS_DAYS_PER_YEAR +
172
+ (month - 1) * TPS_DAYS_PER_MONTH +
173
+ (dayOfMonth - 1),
174
+ dayFraction: subDayMilliseconds / TPS_DAY_MS,
175
+ subDayMilliseconds,
176
+ };
177
+ }
178
+ function getTpsDayIndex(input) {
179
+ if (input instanceof Date) {
180
+ return getTpsIndexedFromDate(input).dayIndex;
181
+ }
182
+ if (input.dayIndex !== undefined) {
183
+ return input.dayIndex;
184
+ }
185
+ const fullYear = getTpsFullYear(input);
186
+ const month = input.month ?? 1;
187
+ const dayOfMonth = getTpsDayOfMonth(input);
188
+ return (fullYear * TPS_DAYS_PER_YEAR +
189
+ (month - 1) * TPS_DAYS_PER_MONTH +
190
+ (dayOfMonth - 1));
191
+ }
192
+ function getTpsDayFraction(input) {
193
+ if (input instanceof Date) {
194
+ return getTpsIndexedFromDate(input).dayFraction;
195
+ }
196
+ if (input.dayFraction !== undefined) {
197
+ return input.dayFraction;
198
+ }
199
+ return getTpsSubDayMilliseconds(input) / TPS_DAY_MS;
200
+ }
201
+ function parseTpsIndexedToken(token) {
202
+ const match = token.trim().match(/^i(\d+)(?:\.(\d+))?$/i);
203
+ if (!match)
204
+ return null;
205
+ const dayIndex = Number(match[1]);
206
+ if (!Number.isSafeInteger(dayIndex) || dayIndex < 0) {
207
+ return null;
208
+ }
209
+ const digits = match[2];
210
+ if (digits && digits.endsWith("0")) {
211
+ return null;
212
+ }
213
+ const dayFraction = digits ? Number(`0.${digits}`) : 0;
214
+ if (!Number.isFinite(dayFraction) || dayFraction < 0 || dayFraction >= 1) {
215
+ return null;
216
+ }
217
+ const normalized = normalizeIndexedParts(dayIndex, dayFraction);
218
+ return {
219
+ ...normalized,
220
+ fractionPrecision: digits?.length,
221
+ };
222
+ }
223
+ function formatTpsIndexedToken(components, precision) {
224
+ const normalized = normalizeTpsComponents(components);
225
+ const dayIndex = normalized.dayIndex ?? 0;
226
+ const dayFraction = normalized.dayFraction ?? 0;
227
+ const effectivePrecision = precision ?? normalized.fractionPrecision ?? 9;
228
+ let fraction = "";
229
+ if (dayFraction > 0 && effectivePrecision > 0) {
230
+ fraction = dayFraction
231
+ .toFixed(effectivePrecision)
232
+ .slice(2)
233
+ .replace(/0+$/g, "");
234
+ }
235
+ return fraction ? `i${dayIndex}.${fraction}` : `i${dayIndex}`;
236
+ }
237
+ function isTpsIndexedToken(token) {
238
+ return /^i\d+(?:\.\d+)?$/i.test(token.trim());
239
+ }
240
+ function validateTpsComponents(components) {
241
+ const month = components.month ?? 1;
242
+ const day = components.day ?? 1;
243
+ const week = components.week;
244
+ const hour = components.hour ?? 0;
245
+ const minute = components.minute ?? 0;
246
+ const second = components.second ?? 0;
247
+ const millisecond = components.millisecond ?? getFractionalMilliseconds(components.second);
248
+ if (components.dayIndex !== undefined &&
249
+ (!Number.isSafeInteger(components.dayIndex) || components.dayIndex < 0)) {
250
+ return false;
251
+ }
252
+ if (components.dayFraction !== undefined &&
253
+ (!Number.isFinite(components.dayFraction) ||
254
+ components.dayFraction < 0 ||
255
+ components.dayFraction >= 1)) {
256
+ return false;
257
+ }
258
+ if (month < 1 || month > TPS_MONTHS_PER_YEAR)
259
+ return false;
260
+ if (day < 1 || day > TPS_DAYS_PER_MONTH)
261
+ return false;
262
+ if (week !== undefined && (week < 1 || week > TPS_WEEKS_PER_MONTH)) {
263
+ return false;
264
+ }
265
+ if (week !== undefined && day > TPS_DAYS_PER_WEEK) {
266
+ const expectedWeek = floorDiv(day - 1, TPS_DAYS_PER_WEEK) + 1;
267
+ if (expectedWeek !== week)
268
+ return false;
269
+ }
270
+ if (hour < 0 || hour > 23)
271
+ return false;
272
+ if (minute < 0 || minute > 59)
273
+ return false;
274
+ if (second < 0 || second >= 60)
275
+ return false;
276
+ if (millisecond < 0 || millisecond >= 1000)
277
+ return false;
278
+ return true;
279
+ }
280
+
281
+ /**
282
+ * Generate the canonical `T:` time string for a set of components.
283
+ */
284
+ function buildTimePart(comp, options) {
285
+ const calendar = (comp.calendar || "").toLowerCase();
286
+ if (!/^[a-z]{3,4}$/.test(calendar)) {
287
+ throw new Error(`Invalid calendar code '${comp.calendar}'. Calendar code width must be 3–4 lowercase letters.`);
288
+ }
289
+ let time = `T:${calendar}`;
290
+ if (calendar === DefaultCalendars.UNIX) {
291
+ if (comp.unixSeconds !== undefined) {
292
+ time += `.s${comp.unixSeconds}`;
293
+ }
294
+ return time;
295
+ }
296
+ if (calendar === DefaultCalendars.TPS && options?.timeMode === "indexed-fraction") {
297
+ time += `.${formatTpsIndexedToken(comp, options.indexedPrecision)}`;
298
+ if (comp.signature) {
299
+ time += `!${comp.signature}`;
300
+ }
301
+ return time;
302
+ }
303
+ const source = calendar === DefaultCalendars.TPS ? normalizeTpsComponents(comp) : comp;
304
+ const tokens = [
305
+ ["m", source.millennium, 8],
306
+ ["c", source.century, 7],
307
+ ["y", source.year, 6],
308
+ ["m", source.month, 5],
309
+ ...(calendar === DefaultCalendars.TPS && source.week !== undefined
310
+ ? [["w", source.week, 4.5]]
311
+ : []),
312
+ ["d", source.day, 4],
313
+ ["h", source.hour, 3],
314
+ ["m", source.minute, 2],
315
+ ["s", source.second, 1],
316
+ ["m", source.millisecond, 0],
317
+ ];
318
+ const order = options?.order || source.order || exports.TimeOrder.DESC;
319
+ const activeTokens = order === exports.TimeOrder.ASC ? [...tokens].reverse() : tokens;
320
+ for (const [pref, val] of activeTokens) {
321
+ if (val !== undefined) {
322
+ time += `.${pref}${val}`;
323
+ }
324
+ }
325
+ if (source.signature) {
326
+ time += `!${source.signature}`;
327
+ }
328
+ return time;
329
+ }
330
+ /**
331
+ * Parse the time portion of a TPS string into components.
332
+ */
333
+ function parseTimeString(input) {
334
+ let s = input.trim();
335
+ s = s.split(/[!;?#]/)[0];
336
+ if (s.startsWith("T:"))
337
+ s = s.slice(2);
338
+ const firstDot = s.indexOf(".");
339
+ const calendar = firstDot === -1 ? s : s.slice(0, firstDot);
340
+ const rawTokenString = firstDot === -1 ? "" : s.slice(firstDot + 1);
341
+ if (calendar === DefaultCalendars.TPS && isTpsIndexedToken(rawTokenString)) {
342
+ const indexed = parseTpsIndexedToken(rawTokenString);
343
+ if (!indexed)
344
+ return null;
345
+ return {
346
+ components: normalizeTpsComponents({
347
+ calendar,
348
+ ...indexed,
349
+ }),
350
+ order: exports.TimeOrder.DESC,
351
+ };
352
+ }
353
+ if (calendar === DefaultCalendars.TPS && /^i/i.test(rawTokenString)) {
354
+ return null;
355
+ }
356
+ const parts = s.split(".");
357
+ if (parts.length === 0)
358
+ return null;
359
+ const comp = { calendar };
360
+ const fixedRankMap = {
361
+ c: 7,
362
+ y: 6,
363
+ w: 4.5,
364
+ d: 4,
365
+ h: 3,
366
+ s: 1,
367
+ };
368
+ let initialOrder = exports.TimeOrder.DESC;
369
+ if (calendar !== DefaultCalendars.UNIX) {
370
+ const nonMRanks = [];
371
+ for (let i = 1; i < parts.length; i++) {
372
+ const pr = parts[i]?.charAt(0);
373
+ if (pr && pr in fixedRankMap)
374
+ nonMRanks.push(fixedRankMap[pr]);
375
+ }
376
+ if (nonMRanks.length >= 2) {
377
+ const isAsc = nonMRanks.every((v, i, a) => i === 0 || a[i - 1] <= v);
378
+ if (isAsc)
379
+ initialOrder = exports.TimeOrder.ASC;
380
+ }
381
+ }
382
+ const assignMRank = (lastRank, ord) => {
383
+ if (ord === exports.TimeOrder.DESC) {
384
+ if (lastRank === null)
385
+ return 8;
386
+ if (lastRank > 5)
387
+ return 5;
388
+ if (lastRank > 2)
389
+ return 2;
390
+ return 0;
391
+ }
392
+ else {
393
+ if (lastRank === null)
394
+ return 0;
395
+ if (lastRank < 2)
396
+ return 2;
397
+ if (lastRank < 5)
398
+ return 5;
399
+ return 8;
400
+ }
401
+ };
402
+ const ranks = [];
403
+ let lastAssignedRank = null;
404
+ for (let i = 1; i < parts.length; i++) {
405
+ const token = parts[i];
406
+ if (!token)
407
+ continue;
408
+ const prefix = token.charAt(0);
409
+ const value = token.slice(1);
410
+ if (calendar === DefaultCalendars.UNIX && prefix === "s") {
411
+ comp.unixSeconds = parseFloat(value);
412
+ ranks.push(9);
413
+ continue;
414
+ }
415
+ if (prefix === "m") {
416
+ const rank = assignMRank(lastAssignedRank, initialOrder);
417
+ switch (rank) {
418
+ case 8:
419
+ comp.millennium = parseInt(value, 10);
420
+ break;
421
+ case 5:
422
+ comp.month = parseInt(value, 10);
423
+ break;
424
+ case 2:
425
+ comp.minute = parseInt(value, 10);
426
+ break;
427
+ case 0:
428
+ comp.millisecond = parseInt(value, 10);
429
+ break;
430
+ }
431
+ ranks.push(rank);
432
+ lastAssignedRank = rank;
433
+ }
434
+ else {
435
+ const rank = fixedRankMap[prefix];
436
+ if (rank !== undefined) {
437
+ switch (prefix) {
438
+ case "c":
439
+ comp.century = parseInt(value, 10);
440
+ break;
441
+ case "y":
442
+ comp.year = parseInt(value, 10);
443
+ break;
444
+ case "w":
445
+ comp.week = parseInt(value, 10);
446
+ break;
447
+ case "d":
448
+ comp.day = parseInt(value, 10);
449
+ break;
450
+ case "h":
451
+ comp.hour = parseInt(value, 10);
452
+ break;
453
+ case "s":
454
+ comp.second = parseFloat(value);
455
+ break;
456
+ }
457
+ ranks.push(rank);
458
+ lastAssignedRank = rank;
459
+ }
460
+ }
461
+ }
462
+ let order = exports.TimeOrder.DESC;
463
+ if (ranks.length > 1) {
464
+ const isAsc = ranks.every((v, i, a) => i === 0 || a[i - 1] <= v);
465
+ const isDesc = ranks.every((v, i, a) => i === 0 || a[i - 1] >= v);
466
+ if (isAsc && !isDesc)
467
+ order = exports.TimeOrder.ASC;
468
+ }
469
+ if (calendar === DefaultCalendars.TPS &&
470
+ comp.month !== undefined &&
471
+ comp.day !== undefined &&
472
+ comp.month >= 1 &&
473
+ comp.day >= 1) {
474
+ return {
475
+ components: normalizeTpsComponents(comp),
476
+ order,
477
+ };
478
+ }
479
+ return { components: comp, order };
480
+ }
481
+
482
+ /**
483
+ * Gregorian calendar driver.
484
+ * Supports robust validation and canonical Date conversions.
485
+ */
486
+ class GregorianDriver {
487
+ constructor() {
488
+ this.code = "greg";
489
+ }
490
+ getComponentsFromDate(date) {
491
+ const fullYear = date.getUTCFullYear();
492
+ return {
493
+ calendar: this.code,
494
+ millennium: Math.floor(fullYear / 1000) + 1,
495
+ century: Math.floor((fullYear % 1000) / 100) + 1,
496
+ year: fullYear % 100,
497
+ month: date.getUTCMonth() + 1,
498
+ day: date.getUTCDate(),
499
+ hour: date.getUTCHours(),
500
+ minute: date.getUTCMinutes(),
501
+ second: date.getUTCSeconds(),
502
+ millisecond: date.getUTCMilliseconds(),
503
+ };
504
+ }
505
+ getDateFromComponents(components) {
506
+ const m = components.millennium ?? 0;
507
+ const c = components.century ?? 1;
508
+ const y = components.year ?? 0;
509
+ const fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
510
+ return new Date(Date.UTC(fullYear, (components.month || 1) - 1, components.day || 1, components.hour || 0, components.minute || 0, Math.floor(components.second || 0), components.millisecond ??
511
+ Math.round(((components.second || 0) % 1) * 1000)));
512
+ }
513
+ getFromDate(date) {
514
+ const comp = this.getComponentsFromDate(date);
515
+ return buildTimePart(comp);
516
+ }
517
+ // --- optional helpers --------------------------------------------------
518
+ parseDate(input, format) {
519
+ const s = input.trim();
520
+ const m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?)?$/);
521
+ if (!m) {
522
+ throw new Error(`GregorianDriver.parseDate: unsupported format "${input}"`);
523
+ }
524
+ const year = parseInt(m[1], 10);
525
+ const month = parseInt(m[2], 10);
526
+ const day = parseInt(m[3], 10);
527
+ const hour = m[4] !== undefined ? parseInt(m[4], 10) : undefined;
528
+ const minute = m[5] !== undefined ? parseInt(m[5], 10) : undefined;
529
+ const second = m[6] !== undefined ? parseInt(m[6], 10) : undefined;
530
+ const millisecond = m[7] !== undefined ? parseInt((m[7] + "000").slice(0, 3), 10) : undefined;
531
+ const comp = {
532
+ calendar: this.code,
533
+ year,
534
+ month,
535
+ day,
536
+ };
537
+ if (hour !== undefined)
538
+ comp.hour = hour;
539
+ if (minute !== undefined)
540
+ comp.minute = minute;
541
+ if (second !== undefined)
542
+ comp.second = second;
543
+ if (millisecond !== undefined)
544
+ comp.millisecond = millisecond;
545
+ return comp;
546
+ }
547
+ format(components, format) {
548
+ const y = components.year !== undefined
549
+ ? String(components.year).padStart(4, "0")
550
+ : "0000";
551
+ const mo = components.month !== undefined
552
+ ? String(components.month).padStart(2, "0")
553
+ : "01";
554
+ const d = components.day !== undefined
555
+ ? String(components.day).padStart(2, "0")
556
+ : "01";
557
+ let out = `${y}-${mo}-${d}`;
558
+ if (components.hour !== undefined ||
559
+ components.minute !== undefined ||
560
+ components.second !== undefined ||
561
+ components.millisecond !== undefined) {
562
+ const h = components.hour !== undefined
563
+ ? String(components.hour).padStart(2, "0")
564
+ : "00";
565
+ const mi = components.minute !== undefined
566
+ ? String(components.minute).padStart(2, "0")
567
+ : "00";
568
+ const s = components.second !== undefined
569
+ ? String(Math.floor(components.second)).padStart(2, "0")
570
+ : "00";
571
+ const ms = components.millisecond !== undefined
572
+ ? String(components.millisecond).padStart(3, "0")
573
+ : "000";
574
+ out += `T${h}:${mi}:${s}.${ms}`;
575
+ }
576
+ return out;
577
+ }
578
+ isLeap(y) {
579
+ return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
580
+ }
581
+ validate(input) {
582
+ if (typeof input === "string") {
583
+ return /^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)?$/.test(input.trim());
584
+ }
585
+ if (typeof input === "object") {
586
+ const y = input.year ?? 0;
587
+ const m = input.month ?? 1;
588
+ const d = input.day ?? 1;
589
+ if (y < 0 || m < 1 || m > 12 || d < 1)
590
+ return false;
591
+ const daysInMonth = [
592
+ 31,
593
+ this.isLeap(y) ? 29 : 28,
594
+ 31,
595
+ 30,
596
+ 31,
597
+ 30,
598
+ 31,
599
+ 31,
600
+ 30,
601
+ 31,
602
+ 30,
603
+ 31,
604
+ ];
605
+ return d <= daysInMonth[m - 1];
606
+ }
607
+ return false;
608
+ }
609
+ getMetadata() {
610
+ return {
611
+ name: "Gregorian",
612
+ monthNames: [
613
+ "January",
614
+ "February",
615
+ "March",
616
+ "April",
617
+ "May",
618
+ "June",
619
+ "July",
620
+ "August",
621
+ "September",
622
+ "October",
623
+ "November",
624
+ "December",
625
+ ],
626
+ dayNames: [
627
+ "Sunday",
628
+ "Monday",
629
+ "Tuesday",
630
+ "Wednesday",
631
+ "Thursday",
632
+ "Friday",
633
+ "Saturday",
634
+ ],
635
+ monthsPerYear: 12,
636
+ epochYear: 1,
637
+ };
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Unix calendar driver. Represents the epoch timestamp in seconds with
643
+ * fractional milliseconds.
644
+ */
645
+ class UnixDriver {
646
+ constructor() {
647
+ this.code = "unix";
648
+ }
649
+ getComponentsFromDate(date) {
650
+ const s = (date.getTime() / 1000).toFixed(3);
651
+ return { calendar: this.code, unixSeconds: parseFloat(s) };
652
+ }
653
+ getDateFromComponents(components) {
654
+ if (components.unixSeconds !== undefined) {
655
+ return new Date(components.unixSeconds * 1000);
656
+ }
657
+ return new Date(0);
658
+ }
659
+ getFromDate(date) {
660
+ const comp = this.getComponentsFromDate(date);
661
+ return buildTimePart(comp);
662
+ }
663
+ parseDate(input, _format) {
664
+ const s = input.trim();
665
+ if (!/^[0-9]+(?:\.[0-9]+)?$/.test(s)) {
666
+ throw new Error(`UnixDriver.parseDate: unsupported format "${input}"`);
667
+ }
668
+ return { calendar: this.code, unixSeconds: parseFloat(s) };
669
+ }
670
+ format(components, _format) {
671
+ if (components.unixSeconds === undefined)
672
+ throw new Error("UnixDriver.format: missing unixSeconds");
673
+ return new Date(components.unixSeconds * 1000).toISOString();
674
+ }
675
+ validate(input) {
676
+ if (typeof input === "string")
677
+ return /^[0-9]+(?:\.[0-9]+)?$/.test(input.trim());
678
+ if (typeof input === "object")
679
+ return typeof input.unixSeconds === "number" && !isNaN(input.unixSeconds);
680
+ return false;
681
+ }
682
+ getMetadata() {
683
+ return {
684
+ name: "Unix Epoch",
685
+ monthsPerYear: 0,
686
+ };
687
+ }
688
+ }
689
+
690
+ /**
691
+ * TPS calendar driver for canonical TPS time strings.
692
+ *
693
+ * TPS Calendar characteristics:
694
+ * - Epoch anchor: 1999-08-11T07:00:00.000Z
695
+ * - Day boundary: 07:00 Gregorian / UTC
696
+ * - Year shape: 12 months × 4 weeks × 7 days = 336 days
697
+ */
698
+ class TpsDriver {
699
+ constructor() {
700
+ this.code = "tps";
701
+ this.name = "TPS Indexed";
702
+ }
703
+ getComponentsFromDate(date) {
704
+ const deltaMs = date.getTime() - TPS_EPOCH_START_MS;
705
+ const dayIndex = Math.floor(deltaMs / TPS_DAY_MS);
706
+ const dayFraction = ((deltaMs % TPS_DAY_MS) + TPS_DAY_MS) % TPS_DAY_MS / TPS_DAY_MS;
707
+ return buildTpsComponentsFromDayIndex(dayIndex, dayFraction);
708
+ }
709
+ getDateFromComponents(components) {
710
+ const normalized = normalizeTpsComponents({
711
+ ...components,
712
+ calendar: this.code,
713
+ });
714
+ const dayIndex = normalized.dayIndex ?? 0;
715
+ const subDayMilliseconds = normalized.subDayMilliseconds ?? 0;
716
+ return new Date(TPS_EPOCH_START_MS + dayIndex * TPS_DAY_MS + subDayMilliseconds);
717
+ }
718
+ getFromDate(date) {
719
+ const comp = this.getComponentsFromDate(date);
720
+ return buildTimePart(comp);
721
+ }
722
+ parseDate(input, _format) {
723
+ const s = input.trim();
724
+ const indexed = parseTpsIndexedToken(s);
725
+ if (indexed) {
726
+ return normalizeTpsComponents({
727
+ calendar: this.code,
728
+ ...indexed,
729
+ });
730
+ }
731
+ const m = s.match(/^(-?\d{1,6})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?)?$/);
732
+ if (!m)
733
+ throw new Error(`TpsDriver.parseDate: unsupported format "${input}"`);
734
+ const year = parseInt(m[1], 10);
735
+ const month = parseInt(m[2], 10);
736
+ const day = parseInt(m[3], 10);
737
+ if (month < 1 || month > TPS_MONTHS_PER_YEAR) {
738
+ throw new Error(`TpsDriver.parseDate: invalid TPS month ${month} (expected 1-12)`);
739
+ }
740
+ if (day < 1 || day > TPS_DAYS_PER_MONTH) {
741
+ throw new Error(`TpsDriver.parseDate: invalid TPS day ${day} (expected 1-28)`);
742
+ }
743
+ const hour = m[4] !== undefined ? parseInt(m[4], 10) : undefined;
744
+ const minute = m[5] !== undefined ? parseInt(m[5], 10) : undefined;
745
+ const second = m[6] !== undefined ? parseInt(m[6], 10) : undefined;
746
+ const millisecond = m[7] !== undefined ? parseInt((m[7] + "000").slice(0, 3), 10) : undefined;
747
+ const comp = {
748
+ calendar: this.code,
749
+ year,
750
+ month,
751
+ day,
752
+ };
753
+ if (hour !== undefined)
754
+ comp.hour = hour;
755
+ if (minute !== undefined)
756
+ comp.minute = minute;
757
+ if (second !== undefined)
758
+ comp.second = second;
759
+ if (millisecond !== undefined)
760
+ comp.millisecond = millisecond;
761
+ return normalizeTpsComponents(comp);
762
+ }
763
+ format(components, _format) {
764
+ const normalized = normalizeTpsComponents({
765
+ ...components,
766
+ calendar: this.code,
767
+ });
768
+ const fullYear = getTpsFullYear(normalized);
769
+ const y = normalized.year !== undefined
770
+ ? String(fullYear).padStart(4, "0")
771
+ : "0000";
772
+ const mo = normalized.month !== undefined
773
+ ? String(normalized.month).padStart(2, "0")
774
+ : "01";
775
+ const d = normalized.day !== undefined
776
+ ? String(normalized.day).padStart(2, "0")
777
+ : "01";
778
+ let out = `${y}-${mo}-${d}`;
779
+ if (normalized.hour !== undefined ||
780
+ normalized.minute !== undefined ||
781
+ normalized.second !== undefined ||
782
+ normalized.millisecond !== undefined) {
783
+ const h = normalized.hour !== undefined
784
+ ? String(normalized.hour).padStart(2, "0")
785
+ : "00";
786
+ const mi = normalized.minute !== undefined
787
+ ? String(normalized.minute).padStart(2, "0")
788
+ : "00";
789
+ const s = normalized.second !== undefined
790
+ ? String(Math.floor(normalized.second)).padStart(2, "0")
791
+ : "00";
792
+ const ms = normalized.millisecond !== undefined
793
+ ? String(normalized.millisecond).padStart(3, "0")
794
+ : "000";
795
+ out += `T${h}:${mi}:${s}.${ms}`;
796
+ }
797
+ return out;
798
+ }
799
+ validate(input) {
800
+ if (typeof input === "string") {
801
+ if (parseTpsIndexedToken(input.trim())) {
802
+ return true;
803
+ }
804
+ try {
805
+ return validateTpsComponents(this.parseDate(input));
806
+ }
807
+ catch {
808
+ return false;
809
+ }
810
+ }
811
+ if (typeof input === "object") {
812
+ return validateTpsComponents(input);
813
+ }
814
+ return false;
815
+ }
816
+ getMetadata() {
817
+ return {
818
+ name: "TPS Native (epoch-based 12x4x7)",
819
+ monthNames: [
820
+ "Month 1",
821
+ "Month 2",
822
+ "Month 3",
823
+ "Month 4",
824
+ "Month 5",
825
+ "Month 6",
826
+ "Month 7",
827
+ "Month 8",
828
+ "Month 9",
829
+ "Month 10",
830
+ "Month 11",
831
+ "Month 12",
832
+ ],
833
+ dayNames: [
834
+ "Day 1",
835
+ "Day 2",
836
+ "Day 3",
837
+ "Day 4",
838
+ "Day 5",
839
+ "Day 6",
840
+ "Day 7",
841
+ ],
842
+ monthsPerYear: TPS_MONTHS_PER_YEAR,
843
+ epochYear: 1999,
844
+ isLunar: false,
845
+ };
846
+ }
847
+ }
848
+
849
+ /**
850
+ * Shared Calendar Math Utilities
851
+ */
852
+ /**
853
+ * Gregorian -> Julian Day Number
854
+ */
855
+ function gregorianToJdn(gy, gm, gd) {
856
+ const a = Math.floor((14 - gm) / 12);
857
+ const y = gy + 4800 - a;
858
+ const m = gm + 12 * a - 3;
859
+ return (gd +
860
+ Math.floor((153 * m + 2) / 5) +
861
+ 365 * y +
862
+ Math.floor(y / 4) -
863
+ Math.floor(y / 100) +
864
+ Math.floor(y / 400) -
865
+ 32045);
866
+ }
867
+ /**
868
+ * Julian Day Number -> Gregorian
869
+ */
870
+ function jdnToGregorian(jdn) {
871
+ const a = jdn + 32044;
872
+ const b = Math.floor((4 * a + 3) / 146097);
873
+ const c = a - Math.floor((146097 * b) / 4);
874
+ const d = Math.floor((4 * c + 3) / 1461);
875
+ const e = c - Math.floor((1461 * d) / 4);
876
+ const m = Math.floor((5 * e + 2) / 153);
877
+ const gd = e - Math.floor((153 * m + 2) / 5) + 1;
878
+ const gm = m + 3 - 12 * Math.floor(m / 10);
879
+ const gy = 100 * b + d - 4800 + Math.floor(m / 10);
880
+ return { gy, gm, gd };
881
+ }
882
+ /**
883
+ * Julian -> Julian Day Number
884
+ */
885
+ function julianToJdn(jy, jm, jd) {
886
+ const a = Math.floor((14 - jm) / 12);
887
+ const y = jy + 4800 - a;
888
+ const m = jm + 12 * a - 3;
889
+ return (jd + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - 32083);
890
+ }
891
+ /**
892
+ * Julian Day Number -> Julian
893
+ */
894
+ function jdnToJulian(jdn) {
895
+ const c = jdn + 32082;
896
+ const d = Math.floor((4 * c + 3) / 1461);
897
+ const e = c - Math.floor((1461 * d) / 4);
898
+ const m = Math.floor((5 * e + 2) / 153);
899
+ const jd = e - Math.floor((153 * m + 2) / 5) + 1;
900
+ const jm = m + 3 - 12 * Math.floor(m / 10);
901
+ const jy = d - 4800 + Math.floor(m / 10);
902
+ return { jy, jm, jd };
903
+ }
904
+ /**
905
+ * Persian -> Julian Day Number
906
+ */
907
+ function persianToJdn(jy, jm, jd) {
908
+ const EPOCH = 1948320;
909
+ const epbase = jy - (jy >= 0 ? 474 : 473);
910
+ const epyear = 474 + (epbase % 2820);
911
+ return (jd +
912
+ (jm <= 7 ? (jm - 1) * 31 : (jm - 1) * 30 + 6) +
913
+ Math.floor((epyear * 682 - 110) / 2816) +
914
+ (epyear - 1) * 365 +
915
+ Math.floor(epbase / 2820) * 1029983 +
916
+ EPOCH -
917
+ 1);
918
+ }
919
+ /**
920
+ * Julian Day Number -> Persian
921
+ */
922
+ function jdnToPersian(jdn) {
923
+ const depoch = jdn - persianToJdn(475, 1, 1);
924
+ const cycle = Math.floor(depoch / 1029983);
925
+ const cyear = depoch % 1029983;
926
+ let ycycle;
927
+ if (cyear === 1029982) {
928
+ ycycle = 2820;
929
+ }
930
+ else {
931
+ const aux1 = Math.floor(cyear / 366);
932
+ const aux2 = cyear % 366;
933
+ ycycle =
934
+ Math.floor((2134 * aux1 + 2816 * aux2 + 2815) / 1028522) + aux1 + 1;
935
+ }
936
+ const jy = ycycle + 2820 * cycle + 474;
937
+ const yday = jdn - persianToJdn(jy, 1, 1) + 1;
938
+ const jm = yday <= 186 ? Math.ceil(yday / 31) : Math.ceil((yday - 6) / 30);
939
+ const jd = jdn - persianToJdn(jy, jm, 1) + 1;
940
+ return { jy: jy <= 0 ? jy - 1 : jy, jm, jd };
941
+ }
942
+ /**
943
+ * Convert Gregorian to Hijri (Tabular Islamic Calendar).
944
+ */
945
+ function gregorianToHijri(gy, gm, gd) {
946
+ const jdn = gregorianToJdn(gy, gm, gd);
947
+ const L = jdn - 1948440 + 10632;
948
+ const N = Math.floor((L - 1) / 10631);
949
+ const L2 = L - 10631 * N + 354;
950
+ const J = Math.floor((10985 - L2) / 5316) * Math.floor((50 * L2) / 17719) +
951
+ Math.floor(L2 / 5670) * Math.floor((43 * L2) / 15238);
952
+ const L3 = L2 -
953
+ Math.floor((30 - J) / 15) * Math.floor((17719 * J) / 50) -
954
+ Math.floor(J / 16) * Math.floor((15238 * J) / 43) +
955
+ 29;
956
+ const hm = Math.floor((24 * L3) / 709);
957
+ const hd = L3 - Math.floor((709 * hm) / 24);
958
+ const hy = 30 * N + J - 30;
959
+ return { hy, hm, hd };
960
+ }
961
+ /**
962
+ * Convert Hijri to Gregorian.
963
+ */
964
+ function hijriToGregorian(hy, hm, hd) {
965
+ const jdn = Math.floor((11 * hy + 3) / 30) +
966
+ 354 * hy +
967
+ 30 * hm -
968
+ Math.floor((hm - 1) / 2) +
969
+ hd +
970
+ 1948440 -
971
+ 385;
972
+ return jdnToGregorian(jdn);
973
+ }
974
+
975
+ class PersianDriver {
976
+ constructor() {
977
+ this.code = "per";
978
+ this.name = "Persian (Jalali/Solar Hijri)";
979
+ this.MONTH_NAMES = [
980
+ "Farvardin",
981
+ "Ordibehesht",
982
+ "Khordad",
983
+ "Tir",
984
+ "Mordad",
985
+ "Shahrivar",
986
+ "Mehr",
987
+ "Aban",
988
+ "Azar",
989
+ "Dey",
990
+ "Bahman",
991
+ "Esfand",
992
+ ];
993
+ this.MONTH_NAMES_SHORT = [
994
+ "Far",
995
+ "Ord",
996
+ "Kho",
997
+ "Tir",
998
+ "Mor",
999
+ "Sha",
1000
+ "Meh",
1001
+ "Aba",
1002
+ "Aza",
1003
+ "Dey",
1004
+ "Bah",
1005
+ "Esf",
1006
+ ];
1007
+ this.DAY_NAMES = [
1008
+ "Yekshanbeh",
1009
+ "Doshanbeh",
1010
+ "Seshanbeh",
1011
+ "Chaharshanbeh",
1012
+ "Panjshanbeh",
1013
+ "Jomeh",
1014
+ "Shanbeh",
1015
+ ];
1016
+ /** Days per month (non-leap): 6×31 + 5×30 + 1×29 = 365 */
1017
+ this.DAYS_IN_MONTH = [
1018
+ 31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29,
1019
+ ];
1020
+ }
1021
+ getComponentsFromDate(date) {
1022
+ const jdn = gregorianToJdn(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
1023
+ const { jy, jm, jd } = jdnToPersian(jdn);
1024
+ return {
1025
+ calendar: this.code,
1026
+ millennium: Math.floor(jy / 1000) + 1,
1027
+ century: Math.floor((jy % 1000) / 100) + 1,
1028
+ year: jy % 100,
1029
+ month: jm,
1030
+ day: jd,
1031
+ hour: date.getUTCHours(),
1032
+ minute: date.getUTCMinutes(),
1033
+ second: date.getUTCSeconds(),
1034
+ millisecond: date.getUTCMilliseconds(),
1035
+ };
1036
+ }
1037
+ getDateFromComponents(components) {
1038
+ let jy;
1039
+ if (components.millennium !== undefined) {
1040
+ const m = components.millennium ?? 0;
1041
+ const c = components.century ?? 1;
1042
+ const y = components.year ?? 0;
1043
+ jy = (m - 1) * 1000 + (c - 1) * 100 + y;
1044
+ }
1045
+ else {
1046
+ jy = components.year ?? 1;
1047
+ }
1048
+ const jm = components.month ?? 1;
1049
+ const jd = components.day ?? 1;
1050
+ const jdn = persianToJdn(jy, jm, jd);
1051
+ const { gy, gm, gd } = jdnToGregorian(jdn);
1052
+ return new Date(Date.UTC(gy, gm - 1, gd, components.hour ?? 0, components.minute ?? 0, Math.floor(components.second ?? 0), components.millisecond ?? 0));
1053
+ }
1054
+ getFromDate(date) {
1055
+ const comp = this.getComponentsFromDate(date);
1056
+ return buildTimePart(comp);
1057
+ }
1058
+ parseDate(input, format) {
1059
+ const trimmed = input.trim();
1060
+ if (format === "short" ||
1061
+ (trimmed.includes("/") && trimmed.split("/")[0].length <= 2)) {
1062
+ const parts = trimmed.split("/").map(Number);
1063
+ let fullYear, month, day;
1064
+ if (parts[0] > 31) {
1065
+ [fullYear, month, day] = parts;
1066
+ }
1067
+ else {
1068
+ [day, month, fullYear] = parts;
1069
+ }
1070
+ return {
1071
+ calendar: this.code,
1072
+ millennium: Math.floor(fullYear / 1000) + 1,
1073
+ century: Math.floor((fullYear % 1000) / 100) + 1,
1074
+ year: fullYear % 100,
1075
+ month,
1076
+ day,
1077
+ };
1078
+ }
1079
+ const segments = trimmed.split(/[\s,T]+/);
1080
+ const [parsedYear, month, day] = segments[0].split(/[-/]/).map(Number);
1081
+ const result = { calendar: this.code };
1082
+ const fullYear = parsedYear;
1083
+ result.millennium = Math.floor(fullYear / 1000) + 1;
1084
+ result.century = Math.floor((fullYear % 1000) / 100) + 1;
1085
+ result.year = fullYear % 100;
1086
+ result.month = month;
1087
+ result.day = day;
1088
+ if (segments[1]) {
1089
+ const [h, m, s] = segments[1].split(":").map(Number);
1090
+ result.hour = h ?? 0;
1091
+ result.minute = m ?? 0;
1092
+ result.second = s ?? 0;
1093
+ }
1094
+ return result;
1095
+ }
1096
+ format(components, format) {
1097
+ const pad = (n) => String(n ?? 0).padStart(2, "0");
1098
+ let fullYear;
1099
+ if (components.millennium !== undefined) {
1100
+ const m = components.millennium ?? 0;
1101
+ const c = components.century ?? 1;
1102
+ const y = components.year ?? 0;
1103
+ fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
1104
+ }
1105
+ else {
1106
+ fullYear = components.year ?? 0;
1107
+ }
1108
+ if (format === "short")
1109
+ return `${components.day}/${pad(components.month)}/${fullYear}`;
1110
+ if (format === "long") {
1111
+ const mn = this.MONTH_NAMES[(components.month ?? 1) - 1];
1112
+ return `${components.day} ${mn} ${fullYear}`;
1113
+ }
1114
+ let out = `${fullYear}-${pad(components.month)}-${pad(components.day)}`;
1115
+ if (components.hour !== undefined) {
1116
+ out += ` ${pad(components.hour)}:${pad(components.minute)}:${pad(Math.floor(components.second ?? 0))}`;
1117
+ }
1118
+ return out;
1119
+ }
1120
+ validate(input) {
1121
+ let comp;
1122
+ if (typeof input === "string") {
1123
+ try {
1124
+ comp = this.parseDate(input);
1125
+ }
1126
+ catch {
1127
+ return false;
1128
+ }
1129
+ }
1130
+ else {
1131
+ comp = input;
1132
+ }
1133
+ const { year, month, day } = comp;
1134
+ if (year === undefined || year < 1)
1135
+ return false;
1136
+ if (month === undefined || month < 1 || month > 12)
1137
+ return false;
1138
+ if (day === undefined || day < 1)
1139
+ return false;
1140
+ // leap check
1141
+ const leapYears = [1, 5, 9, 13, 17, 22, 26, 30];
1142
+ const cycle = ((year - 1) % 33) + 1;
1143
+ const isLeap = leapYears.includes(cycle);
1144
+ let max = this.DAYS_IN_MONTH[month - 1];
1145
+ if (month === 12 && isLeap)
1146
+ max = 30;
1147
+ return day <= max;
1148
+ }
1149
+ getMetadata() {
1150
+ return {
1151
+ name: "Persian (Jalali/Solar Hijri)",
1152
+ monthNames: this.MONTH_NAMES,
1153
+ monthNamesShort: this.MONTH_NAMES_SHORT,
1154
+ dayNames: this.DAY_NAMES,
1155
+ dayNamesShort: this.DAY_NAMES.map((d) => d.slice(0, 3)),
1156
+ isLunar: false,
1157
+ monthsPerYear: 12,
1158
+ epochYear: 622,
1159
+ };
1160
+ }
1161
+ }
1162
+
1163
+ class HijriDriver {
1164
+ constructor() {
1165
+ this.code = "hij";
1166
+ this.name = "Hijri (Islamic Calendar)";
1167
+ this.MONTH_NAMES = [
1168
+ "Muharram",
1169
+ "Safar",
1170
+ "Rabi' al-Awwal",
1171
+ "Rabi' al-Thani",
1172
+ "Jumada al-Ula",
1173
+ "Jumada al-Thani",
1174
+ "Rajab",
1175
+ "Sha'ban",
1176
+ "Ramadan",
1177
+ "Shawwal",
1178
+ "Dhu al-Qi'dah",
1179
+ "Dhu al-Hijjah",
1180
+ ];
1181
+ this.MONTH_NAMES_SHORT = [
1182
+ "Muh",
1183
+ "Saf",
1184
+ "Rab I",
1185
+ "Rab II",
1186
+ "Jum I",
1187
+ "Jum II",
1188
+ "Raj",
1189
+ "Sha",
1190
+ "Ram",
1191
+ "Shaw",
1192
+ "Dhu Q",
1193
+ "Dhu H",
1194
+ ];
1195
+ this.DAY_NAMES = [
1196
+ "al-Ahad",
1197
+ "al-Ithnayn",
1198
+ "ath-Thulatha",
1199
+ "al-Arbi'a",
1200
+ "al-Khamis",
1201
+ "al-Jumu'ah",
1202
+ "as-Sabt",
1203
+ ];
1204
+ }
1205
+ getComponentsFromDate(date) {
1206
+ const { hy, hm, hd } = gregorianToHijri(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
1207
+ return {
1208
+ calendar: this.code,
1209
+ millennium: Math.floor(hy / 1000) + 1,
1210
+ century: Math.floor((hy % 1000) / 100) + 1,
1211
+ year: hy % 100,
1212
+ month: hm,
1213
+ day: hd,
1214
+ hour: date.getUTCHours(),
1215
+ minute: date.getUTCMinutes(),
1216
+ second: date.getUTCSeconds(),
1217
+ millisecond: date.getUTCMilliseconds(),
1218
+ };
1219
+ }
1220
+ getDateFromComponents(components) {
1221
+ let hy;
1222
+ if (components.millennium !== undefined) {
1223
+ const m = components.millennium ?? 0;
1224
+ const c = components.century ?? 1;
1225
+ const y = components.year ?? 0;
1226
+ hy = (m - 1) * 1000 + (c - 1) * 100 + y;
1227
+ }
1228
+ else {
1229
+ hy = components.year ?? 1;
1230
+ }
1231
+ const hm = components.month ?? 1;
1232
+ const hd = components.day ?? 1;
1233
+ const { gy, gm, gd } = hijriToGregorian(hy, hm, hd);
1234
+ return new Date(Date.UTC(gy, gm - 1, gd, components.hour ?? 0, components.minute ?? 0, Math.floor(components.second ?? 0), components.millisecond ?? 0));
1235
+ }
1236
+ getFromDate(date) {
1237
+ const comp = this.getComponentsFromDate(date);
1238
+ return buildTimePart(comp);
1239
+ }
1240
+ parseDate(input, format) {
1241
+ const trimmed = input.trim();
1242
+ if (format === "short" ||
1243
+ (trimmed.includes("/") && trimmed.split("/")[0].length <= 2)) {
1244
+ const [day, month, year] = trimmed.split("/").map(Number);
1245
+ return {
1246
+ calendar: this.code,
1247
+ millennium: Math.floor(year / 1000) + 1,
1248
+ century: Math.floor((year % 1000) / 100) + 1,
1249
+ year: year % 100,
1250
+ month,
1251
+ day,
1252
+ };
1253
+ }
1254
+ const segments = trimmed.split(/[\s,T]+/);
1255
+ const [fullYear, month, day] = segments[0].split("-").map(Number);
1256
+ const result = {
1257
+ calendar: this.code,
1258
+ millennium: Math.floor(fullYear / 1000) + 1,
1259
+ century: Math.floor((fullYear % 1000) / 100) + 1,
1260
+ year: fullYear % 100,
1261
+ month,
1262
+ day,
1263
+ };
1264
+ if (segments[1]) {
1265
+ const [h, m, s] = segments[1].split(":").map(Number);
1266
+ result.hour = h ?? 0;
1267
+ result.minute = m ?? 0;
1268
+ result.second = s ?? 0;
1269
+ }
1270
+ return result;
1271
+ }
1272
+ format(components, format) {
1273
+ const pad = (n) => String(n ?? 0).padStart(2, "0");
1274
+ let fullYear;
1275
+ if (components.millennium !== undefined) {
1276
+ const m = components.millennium ?? 0;
1277
+ const c = components.century ?? 1;
1278
+ const y = components.year ?? 0;
1279
+ fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
1280
+ }
1281
+ else {
1282
+ fullYear = components.year ?? 0;
1283
+ }
1284
+ if (format === "short")
1285
+ return `${components.day}/${pad(components.month)}/${fullYear}`;
1286
+ if (format === "long") {
1287
+ const mn = this.MONTH_NAMES[(components.month ?? 1) - 1];
1288
+ return `${components.day} ${mn} ${fullYear}`;
1289
+ }
1290
+ let out = `${fullYear}-${pad(components.month)}-${pad(components.day)}`;
1291
+ if (components.hour !== undefined) {
1292
+ out += ` ${pad(components.hour)}:${pad(components.minute)}:${pad(Math.floor(components.second ?? 0))}`;
1293
+ }
1294
+ return out;
1295
+ }
1296
+ validate(input) {
1297
+ let comp;
1298
+ if (typeof input === "string") {
1299
+ try {
1300
+ comp = this.parseDate(input);
1301
+ }
1302
+ catch {
1303
+ return false;
1304
+ }
1305
+ }
1306
+ else {
1307
+ comp = input;
1308
+ }
1309
+ const { year, month, day } = comp;
1310
+ let fullYear;
1311
+ if (comp.millennium !== undefined) {
1312
+ fullYear =
1313
+ ((comp.millennium ?? 0) - 1) * 1000 +
1314
+ ((comp.century ?? 1) - 1) * 100 +
1315
+ (year ?? 0);
1316
+ }
1317
+ else {
1318
+ fullYear = year ?? 0;
1319
+ }
1320
+ if (fullYear < 1)
1321
+ return false;
1322
+ if (!month || month < 1 || month > 12)
1323
+ return false;
1324
+ if (!day || day < 1)
1325
+ return false;
1326
+ // leap check (cycle of 30 years)
1327
+ const isLeap = new Set([2, 5, 7, 10, 13, 16, 18, 21, 24, 26, 29]).has(((fullYear - 1) % 30) + 1);
1328
+ const maxDays = month === 12 && isLeap ? 30 : month % 2 === 1 ? 30 : 29;
1329
+ return day <= maxDays;
1330
+ }
1331
+ getMetadata() {
1332
+ return {
1333
+ name: "Hijri (Islamic Calendar)",
1334
+ monthNames: this.MONTH_NAMES,
1335
+ monthNamesShort: this.MONTH_NAMES_SHORT,
1336
+ dayNames: this.DAY_NAMES,
1337
+ dayNamesShort: this.DAY_NAMES.map((d) => d.slice(0, 3)),
1338
+ isLunar: true,
1339
+ monthsPerYear: 12,
1340
+ epochYear: 1,
1341
+ };
1342
+ }
1343
+ }
1344
+
1345
+ class JulianDriver {
1346
+ constructor() {
1347
+ this.code = "jul";
1348
+ this.name = "Julian Calendar";
1349
+ this.MONTH_NAMES = [
1350
+ "Januarius",
1351
+ "Februarius",
1352
+ "Martius",
1353
+ "Aprilis",
1354
+ "Maius",
1355
+ "Junius",
1356
+ "Julius",
1357
+ "Augustus",
1358
+ "September",
1359
+ "October",
1360
+ "November",
1361
+ "December",
1362
+ ];
1363
+ this.MONTH_NAMES_SHORT = [
1364
+ "Jan",
1365
+ "Feb",
1366
+ "Mar",
1367
+ "Apr",
1368
+ "May",
1369
+ "Jun",
1370
+ "Jul",
1371
+ "Aug",
1372
+ "Sep",
1373
+ "Oct",
1374
+ "Nov",
1375
+ "Dec",
1376
+ ];
1377
+ this.DAYS_IN_MONTH = [
1378
+ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
1379
+ ];
1380
+ }
1381
+ getComponentsFromDate(date) {
1382
+ const jdn = gregorianToJdn(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
1383
+ const { jy, jm, jd } = jdnToJulian(jdn);
1384
+ return {
1385
+ calendar: this.code,
1386
+ millennium: Math.floor(jy / 1000) + 1,
1387
+ century: Math.floor((jy % 1000) / 100) + 1,
1388
+ year: jy % 100,
1389
+ month: jm,
1390
+ day: jd,
1391
+ hour: date.getUTCHours(),
1392
+ minute: date.getUTCMinutes(),
1393
+ second: date.getUTCSeconds(),
1394
+ millisecond: date.getUTCMilliseconds(),
1395
+ };
1396
+ }
1397
+ getDateFromComponents(components) {
1398
+ let fullYear;
1399
+ if (components.millennium !== undefined) {
1400
+ const m = components.millennium ?? 0;
1401
+ const c = components.century ?? 1;
1402
+ const y = components.year ?? 0;
1403
+ fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
1404
+ }
1405
+ else {
1406
+ fullYear = components.year ?? 1;
1407
+ }
1408
+ const jm = components.month ?? 1;
1409
+ const jd = components.day ?? 1;
1410
+ const jdn = julianToJdn(fullYear, jm, jd);
1411
+ const { gy, gm, gd } = jdnToGregorian(jdn);
1412
+ return new Date(Date.UTC(gy, gm - 1, gd, components.hour ?? 0, components.minute ?? 0, Math.floor(components.second ?? 0), components.millisecond ?? 0));
1413
+ }
1414
+ getFromDate(date) {
1415
+ const comp = this.getComponentsFromDate(date);
1416
+ return buildTimePart(comp);
1417
+ }
1418
+ parseDate(input, _format) {
1419
+ const trimmed = input.trim();
1420
+ const m = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?)?$/);
1421
+ if (!m)
1422
+ throw new Error(`JulianDriver.parseDate: unsupported format "${input}"`);
1423
+ const fullYear = parseInt(m[1], 10);
1424
+ const result = {
1425
+ calendar: this.code,
1426
+ millennium: Math.floor(fullYear / 1000) + 1,
1427
+ century: Math.floor((fullYear % 1000) / 100) + 1,
1428
+ year: fullYear % 100,
1429
+ month: parseInt(m[2], 10),
1430
+ day: parseInt(m[3], 10),
1431
+ };
1432
+ if (m[4] !== undefined)
1433
+ result.hour = parseInt(m[4], 10);
1434
+ if (m[5] !== undefined)
1435
+ result.minute = parseInt(m[5], 10);
1436
+ if (m[6] !== undefined)
1437
+ result.second = parseInt(m[6], 10);
1438
+ if (m[7] !== undefined)
1439
+ result.millisecond = parseInt((m[7] + "000").slice(0, 3), 10);
1440
+ return result;
1441
+ }
1442
+ format(components, _format) {
1443
+ const pad = (n, w = 2) => String(n ?? 0).padStart(w, "0");
1444
+ let fullYear;
1445
+ if (components.millennium !== undefined) {
1446
+ const m = components.millennium ?? 0;
1447
+ const c = components.century ?? 1;
1448
+ const y = components.year ?? 0;
1449
+ fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
1450
+ }
1451
+ else {
1452
+ fullYear = components.year ?? 0;
1453
+ }
1454
+ let out = `${pad(fullYear, 4)}-${pad(components.month)}-${pad(components.day)}`;
1455
+ if (components.hour !== undefined ||
1456
+ components.minute !== undefined ||
1457
+ components.second !== undefined ||
1458
+ components.millisecond !== undefined) {
1459
+ out += `T${pad(components.hour)}:${pad(components.minute)}:${pad(Math.floor(components.second ?? 0))}`;
1460
+ if (components.millisecond !== undefined)
1461
+ out += `.${pad(components.millisecond, 3)}`;
1462
+ }
1463
+ return out;
1464
+ }
1465
+ validate(input) {
1466
+ if (typeof input === "string") {
1467
+ return /^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)?$/.test(input.trim());
1468
+ }
1469
+ if (typeof input === "object") {
1470
+ let fullYear;
1471
+ if (input.millennium !== undefined) {
1472
+ fullYear =
1473
+ ((input.millennium ?? 0) - 1) * 1000 +
1474
+ ((input.century ?? 1) - 1) * 100 +
1475
+ (input.year ?? 0);
1476
+ }
1477
+ else {
1478
+ fullYear = input.year ?? 0;
1479
+ }
1480
+ const { month, day } = input;
1481
+ if (month === undefined || day === undefined)
1482
+ return false;
1483
+ if (month < 1 || month > 12 || day < 1)
1484
+ return false;
1485
+ let maxDay = this.DAYS_IN_MONTH[month - 1];
1486
+ if (month === 2 && fullYear % 4 === 0)
1487
+ maxDay = 29;
1488
+ return day <= maxDay;
1489
+ }
1490
+ return false;
1491
+ }
1492
+ getMetadata() {
1493
+ return {
1494
+ name: "Julian Calendar",
1495
+ monthNames: this.MONTH_NAMES,
1496
+ monthNamesShort: this.MONTH_NAMES_SHORT,
1497
+ isLunar: false,
1498
+ monthsPerYear: 12,
1499
+ epochYear: 1,
1500
+ };
1501
+ }
1502
+ }
1503
+
1504
+ /**
1505
+ * Holocene (Human Era) Calendar Driver
1506
+ */
1507
+ class HoloceneDriver {
1508
+ constructor() {
1509
+ this.code = "holo";
1510
+ this.name = "Holocene (Human Era)";
1511
+ this.gregorian = new GregorianDriver();
1512
+ this.YEAR_OFFSET = 10000;
1513
+ }
1514
+ getComponentsFromDate(date) {
1515
+ const greg = this.gregorian.getComponentsFromDate(date);
1516
+ const fullYear = date.getUTCFullYear() + this.YEAR_OFFSET;
1517
+ return {
1518
+ ...greg,
1519
+ calendar: this.code,
1520
+ millennium: Math.floor(fullYear / 1000) + 1,
1521
+ century: Math.floor((fullYear % 1000) / 100) + 1,
1522
+ year: fullYear % 100,
1523
+ };
1524
+ }
1525
+ getDateFromComponents(components) {
1526
+ const m = components.millennium ?? 0;
1527
+ const c = components.century ?? 1;
1528
+ const y = components.year ?? 0;
1529
+ const holoYear = (m - 1) * 1000 + (c - 1) * 100 + y;
1530
+ const gregYear = holoYear - this.YEAR_OFFSET;
1531
+ return new Date(Date.UTC(gregYear, (components.month ?? 1) - 1, components.day ?? 1, components.hour ?? 0, components.minute ?? 0, Math.floor(components.second ?? 0), components.millisecond ??
1532
+ Math.round(((components.second ?? 0) % 1) * 1000)));
1533
+ }
1534
+ getFromDate(date) {
1535
+ const comp = this.getComponentsFromDate(date);
1536
+ return buildTimePart(comp);
1537
+ }
1538
+ parseDate(input, _format) {
1539
+ const m = input
1540
+ .trim()
1541
+ .match(/^(\d{4,5})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?)?$/);
1542
+ if (!m)
1543
+ throw new Error(`HoloceneDriver.parseDate: unsupported format "${input}"`);
1544
+ const result = {
1545
+ calendar: this.code,
1546
+ year: parseInt(m[1], 10),
1547
+ month: parseInt(m[2], 10),
1548
+ day: parseInt(m[3], 10),
1549
+ };
1550
+ if (m[4] !== undefined)
1551
+ result.hour = parseInt(m[4], 10);
1552
+ if (m[5] !== undefined)
1553
+ result.minute = parseInt(m[5], 10);
1554
+ if (m[6] !== undefined)
1555
+ result.second = parseInt(m[6], 10);
1556
+ if (m[7] !== undefined)
1557
+ result.millisecond = parseInt((m[7] + "000").slice(0, 3), 10);
1558
+ return result;
1559
+ }
1560
+ format(components, _format) {
1561
+ const pad = (n, w = 2) => String(n ?? 0).padStart(w, "0");
1562
+ let holoYear;
1563
+ if (components.millennium !== undefined) {
1564
+ const m = components.millennium ?? 0;
1565
+ const c = components.century ?? 1;
1566
+ const y = components.year ?? 0;
1567
+ holoYear = (m - 1) * 1000 + (c - 1) * 100 + y;
1568
+ }
1569
+ else {
1570
+ holoYear = components.year ?? 0;
1571
+ }
1572
+ let out = `${String(holoYear).padStart(5, "0")}-${pad(components.month)}-${pad(components.day)}`;
1573
+ if (components.hour !== undefined ||
1574
+ components.minute !== undefined ||
1575
+ components.second !== undefined) {
1576
+ out += `T${pad(components.hour)}:${pad(components.minute)}:${pad(Math.floor(components.second ?? 0))}`;
1577
+ }
1578
+ return out;
1579
+ }
1580
+ validate(input) {
1581
+ if (typeof input === "string") {
1582
+ return /^\d{4,5}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)?$/.test(input.trim());
1583
+ }
1584
+ if (typeof input === "object") {
1585
+ return this.gregorian.validate({
1586
+ year: input.year,
1587
+ month: input.month,
1588
+ day: input.day,
1589
+ });
1590
+ }
1591
+ return false;
1592
+ }
1593
+ getMetadata() {
1594
+ return {
1595
+ name: "Holocene (Human Era)",
1596
+ monthNames: [
1597
+ "January",
1598
+ "February",
1599
+ "March",
1600
+ "April",
1601
+ "May",
1602
+ "June",
1603
+ "July",
1604
+ "August",
1605
+ "September",
1606
+ "October",
1607
+ "November",
1608
+ "December",
1609
+ ],
1610
+ isLunar: false,
1611
+ monthsPerYear: 12,
1612
+ epochYear: -1e4,
1613
+ };
1614
+ }
1615
+ }
1616
+
1617
+ /**
1618
+ * Chinese Lunisolar Calendar Driver
1619
+ *
1620
+ * Calendar characteristics:
1621
+ * - Traditional lunisolar calendar (月 months follow lunar phases, years follow solar)
1622
+ * - Year expressed as Sexagenary (干支 Ganzhi) cycle: 60-year repeating pattern
1623
+ * - Also expressed relative to the legendary emperor Huangdi (epoch ~2698 BCE)
1624
+ * - Months: 12 or 13 (leap month / 闰月 rùnyuè in some years)
1625
+ * - This implementation uses a simplified tabular algorithm accurate from ~1900–2100
1626
+ *
1627
+ * Data source: Pre-computed month start Julian Day Numbers for 1900–2100
1628
+ * based on the Hong Kong Observatory almanac algorithm.
1629
+ */
1630
+ // ─────────────────────────────────────────────────────────────────────────────
1631
+ // Core Chinese Calendar Arithmetic
1632
+ // ─────────────────────────────────────────────────────────────────────────────
1633
+ /**
1634
+ * Approximate start of Chinese Lunar Month 1 (正月) for each Gregorian year.
1635
+ * Derived from the New Moon nearest to 'rain water' (雨水, around Feb 19).
1636
+ * Each entry is [month, day] in Gregorian for the start of that year's month 1.
1637
+ *
1638
+ * For simplicity we use the Gregorian Spring Festival date as month-1 start,
1639
+ * which is accurate to within ±1 day for the intended display purpose.
1640
+ */
1641
+ const SPRING_FESTIVAL = {
1642
+ 1900: [1, 31],
1643
+ 1901: [2, 19],
1644
+ 1902: [2, 8],
1645
+ 1903: [1, 29],
1646
+ 1904: [2, 16],
1647
+ 1905: [2, 4],
1648
+ 1906: [1, 25],
1649
+ 1907: [2, 13],
1650
+ 1908: [2, 2],
1651
+ 1909: [1, 22],
1652
+ 1910: [2, 10],
1653
+ 1911: [1, 30],
1654
+ 1912: [2, 18],
1655
+ 1913: [2, 6],
1656
+ 1914: [1, 26],
1657
+ 1915: [2, 14],
1658
+ 1916: [2, 3],
1659
+ 1917: [1, 23],
1660
+ 1918: [2, 11],
1661
+ 1919: [2, 1],
1662
+ 1920: [2, 20],
1663
+ 1921: [2, 8],
1664
+ 1922: [1, 28],
1665
+ 1923: [2, 16],
1666
+ 1924: [2, 5],
1667
+ 1925: [1, 25],
1668
+ 1926: [2, 13],
1669
+ 1927: [2, 2],
1670
+ 1928: [1, 23],
1671
+ 1929: [2, 10],
1672
+ 1930: [1, 30],
1673
+ 1931: [2, 17],
1674
+ 1932: [2, 6],
1675
+ 1933: [1, 26],
1676
+ 1934: [2, 14],
1677
+ 1935: [2, 4],
1678
+ 1936: [1, 24],
1679
+ 1937: [2, 11],
1680
+ 1938: [1, 31],
1681
+ 1939: [2, 19],
1682
+ 1940: [2, 8],
1683
+ 1941: [1, 27],
1684
+ 1942: [2, 15],
1685
+ 1943: [2, 5],
1686
+ 1944: [1, 25],
1687
+ 1945: [2, 13],
1688
+ 1946: [2, 2],
1689
+ 1947: [1, 22],
1690
+ 1948: [2, 10],
1691
+ 1949: [1, 29],
1692
+ 1950: [2, 17],
1693
+ 1951: [2, 6],
1694
+ 1952: [1, 27],
1695
+ 1953: [2, 14],
1696
+ 1954: [2, 3],
1697
+ 1955: [1, 24],
1698
+ 1956: [2, 12],
1699
+ 1957: [1, 31],
1700
+ 1958: [2, 18],
1701
+ 1959: [2, 8],
1702
+ 1960: [1, 28],
1703
+ 1961: [2, 15],
1704
+ 1962: [2, 5],
1705
+ 1963: [1, 25],
1706
+ 1964: [2, 13],
1707
+ 1965: [2, 2],
1708
+ 1966: [1, 21],
1709
+ 1967: [2, 9],
1710
+ 1968: [1, 30],
1711
+ 1969: [2, 17],
1712
+ 1970: [2, 6],
1713
+ 1971: [1, 27],
1714
+ 1972: [2, 15],
1715
+ 1973: [2, 3],
1716
+ 1974: [1, 23],
1717
+ 1975: [2, 11],
1718
+ 1976: [1, 31],
1719
+ 1977: [2, 18],
1720
+ 1978: [2, 7],
1721
+ 1979: [1, 28],
1722
+ 1980: [2, 16],
1723
+ 1981: [2, 5],
1724
+ 1982: [1, 25],
1725
+ 1983: [2, 13],
1726
+ 1984: [2, 2],
1727
+ 1985: [2, 20],
1728
+ 1986: [2, 9],
1729
+ 1987: [1, 29],
1730
+ 1988: [2, 17],
1731
+ 1989: [2, 6],
1732
+ 1990: [1, 27],
1733
+ 1991: [2, 15],
1734
+ 1992: [2, 4],
1735
+ 1993: [1, 23],
1736
+ 1994: [2, 10],
1737
+ 1995: [1, 31],
1738
+ 1996: [2, 19],
1739
+ 1997: [2, 7],
1740
+ 1998: [1, 28],
1741
+ 1999: [2, 16],
1742
+ 2000: [2, 5],
1743
+ 2001: [1, 24],
1744
+ 2002: [2, 12],
1745
+ 2003: [2, 1],
1746
+ 2004: [1, 22],
1747
+ 2005: [2, 9],
1748
+ 2006: [1, 29],
1749
+ 2007: [2, 18],
1750
+ 2008: [2, 7],
1751
+ 2009: [1, 26],
1752
+ 2010: [2, 14],
1753
+ 2011: [2, 3],
1754
+ 2012: [1, 23],
1755
+ 2013: [2, 10],
1756
+ 2014: [1, 31],
1757
+ 2015: [2, 19],
1758
+ 2016: [2, 8],
1759
+ 2017: [1, 28],
1760
+ 2018: [2, 16],
1761
+ 2019: [2, 5],
1762
+ 2020: [1, 25],
1763
+ 2021: [2, 12],
1764
+ 2022: [2, 1],
1765
+ 2023: [1, 22],
1766
+ 2024: [2, 10],
1767
+ 2025: [1, 29],
1768
+ 2026: [2, 17],
1769
+ 2027: [2, 6],
1770
+ 2028: [1, 26],
1771
+ 2029: [2, 13],
1772
+ 2030: [2, 3],
1773
+ 2031: [1, 23],
1774
+ 2032: [2, 11],
1775
+ 2033: [1, 31],
1776
+ 2034: [2, 19],
1777
+ 2035: [2, 8],
1778
+ 2036: [1, 28],
1779
+ 2037: [2, 15],
1780
+ 2038: [2, 4],
1781
+ 2039: [1, 24],
1782
+ 2040: [2, 12],
1783
+ 2041: [2, 1],
1784
+ 2042: [1, 22],
1785
+ 2043: [2, 10],
1786
+ 2044: [1, 30],
1787
+ 2045: [2, 17],
1788
+ 2046: [2, 6],
1789
+ 2047: [1, 26],
1790
+ 2048: [2, 14],
1791
+ 2049: [2, 2],
1792
+ 2050: [1, 23],
1793
+ 2051: [2, 11],
1794
+ 2052: [2, 1],
1795
+ 2053: [2, 19],
1796
+ 2054: [2, 8],
1797
+ 2055: [1, 28],
1798
+ 2056: [2, 15],
1799
+ 2057: [2, 4],
1800
+ 2058: [1, 24],
1801
+ 2059: [2, 12],
1802
+ 2060: [2, 2],
1803
+ 2061: [1, 21],
1804
+ 2062: [2, 9],
1805
+ 2063: [1, 29],
1806
+ 2064: [2, 17],
1807
+ 2065: [2, 5],
1808
+ 2066: [1, 26],
1809
+ 2067: [2, 14],
1810
+ 2068: [2, 3],
1811
+ 2069: [1, 23],
1812
+ 2070: [2, 11],
1813
+ 2071: [2, 1],
1814
+ 2072: [1, 21],
1815
+ 2073: [2, 8],
1816
+ 2074: [1, 28],
1817
+ 2075: [2, 15],
1818
+ 2076: [2, 5],
1819
+ 2077: [1, 24],
1820
+ 2078: [2, 12],
1821
+ 2079: [2, 2],
1822
+ 2080: [1, 22],
1823
+ 2081: [2, 9],
1824
+ 2082: [1, 29],
1825
+ 2083: [2, 17],
1826
+ 2084: [2, 6],
1827
+ 2085: [1, 26],
1828
+ 2086: [2, 14],
1829
+ 2087: [2, 3],
1830
+ 2088: [1, 24],
1831
+ 2089: [2, 10],
1832
+ 2090: [1, 30],
1833
+ 2091: [2, 17],
1834
+ 2092: [2, 7],
1835
+ 2093: [1, 27],
1836
+ 2094: [2, 15],
1837
+ 2095: [2, 4],
1838
+ 2096: [1, 25],
1839
+ 2097: [2, 12],
1840
+ 2098: [2, 1],
1841
+ 2099: [1, 21],
1842
+ 2100: [2, 9],
1843
+ };
1844
+ /** Chinese Heavenly Stems (天干 Tiāngān) */
1845
+ const HEAVENLY_STEMS = [
1846
+ "甲",
1847
+ "乙",
1848
+ "丙",
1849
+ "丁",
1850
+ "戊",
1851
+ "己",
1852
+ "庚",
1853
+ "辛",
1854
+ "壬",
1855
+ "癸",
1856
+ ];
1857
+ const HEAVENLY_STEMS_ROMAN = [
1858
+ "Jiǎ",
1859
+ "Yǐ",
1860
+ "Bǐng",
1861
+ "Dīng",
1862
+ "Wù",
1863
+ "Jǐ",
1864
+ "Gēng",
1865
+ "Xīn",
1866
+ "Rén",
1867
+ "Guǐ",
1868
+ ];
1869
+ /** Chinese Earthly Branches (地支 Dìzhī) — zodiac animals */
1870
+ const EARTHLY_BRANCHES = [
1871
+ "子",
1872
+ "丑",
1873
+ "寅",
1874
+ "卯",
1875
+ "辰",
1876
+ "巳",
1877
+ "午",
1878
+ "未",
1879
+ "申",
1880
+ "酉",
1881
+ "戌",
1882
+ "亥",
1883
+ ];
1884
+ const ZODIAC_EN = [
1885
+ "Rat",
1886
+ "Ox",
1887
+ "Tiger",
1888
+ "Rabbit",
1889
+ "Dragon",
1890
+ "Snake",
1891
+ "Horse",
1892
+ "Goat",
1893
+ "Monkey",
1894
+ "Rooster",
1895
+ "Dog",
1896
+ "Pig",
1897
+ ];
1898
+ /** Chinese month names */
1899
+ const MONTH_NAMES_ZH = [
1900
+ "正月",
1901
+ "二月",
1902
+ "三月",
1903
+ "四月",
1904
+ "五月",
1905
+ "六月",
1906
+ "七月",
1907
+ "八月",
1908
+ "九月",
1909
+ "十月",
1910
+ "十一月",
1911
+ "十二月",
1912
+ ];
1913
+ const MONTH_NAMES_EN = [
1914
+ "Zhēngyuè",
1915
+ "Èryuè",
1916
+ "Sānyuè",
1917
+ "Sìyuè",
1918
+ "Wǔyuè",
1919
+ "Liùyuè",
1920
+ "Qīyuè",
1921
+ "Bāyuè",
1922
+ "Jiǔyuè",
1923
+ "Shíyuè",
1924
+ "Shíyīyuè",
1925
+ "Shíèryuè",
1926
+ ];
1927
+ /** Huangdi Epoch: Year 2698 BCE is year 1 of the Chinese Calendar */
1928
+ const HUANGDI_OFFSET = 2698;
1929
+ /**
1930
+ * Convert a Gregorian date to Chinese lunar date components.
1931
+ * Returns { chineseYear, month, day, heavenlyStem, earthlyBranch, zodiac }
1932
+ */
1933
+ function gregorianToChinese(gy, gm, gd) {
1934
+ // Locate the Chinese year whose Spring Festival (month 1, day 1) is on or before the given date
1935
+ const inputJdn = gregorianToJdn(gy, gm, gd);
1936
+ let chineseYear = gy; // The Chinese New Year typically starts in the same Gregorian year
1937
+ // Check if input is before this year's Spring Festival → use previous year's cycle
1938
+ let sf = SPRING_FESTIVAL[chineseYear];
1939
+ if (!sf) {
1940
+ // Clamp to data range
1941
+ chineseYear = Math.max(1900, Math.min(2100, chineseYear));
1942
+ sf = SPRING_FESTIVAL[chineseYear] ?? [2, 1];
1943
+ }
1944
+ const sfJdn = gregorianToJdn(chineseYear, sf[0], sf[1]);
1945
+ if (inputJdn < sfJdn) {
1946
+ chineseYear -= 1;
1947
+ sf = SPRING_FESTIVAL[chineseYear] ?? [2, 1];
1948
+ }
1949
+ const yearStartJdn = gregorianToJdn(chineseYear, sf[0], sf[1]);
1950
+ const dayOfYear = inputJdn - yearStartJdn; // 0-indexed
1951
+ // Approximate month and day. Chinese months alternate 29/30 days.
1952
+ // Average lunar month ≈ 29.53 days
1953
+ const approxMonth = Math.floor(dayOfYear / 29.53);
1954
+ const month = Math.min(approxMonth + 1, 12); // 1-indexed, cap at 12
1955
+ const monthStartDay = Math.round(approxMonth * 29.53);
1956
+ const day = dayOfYear - monthStartDay + 1;
1957
+ // Sexagenary cycle (干支 gānzhī) — 60-year cycle, epoch 4 BCE = year 1
1958
+ const cycleYear = (((chineseYear - 4) % 60) + 60) % 60;
1959
+ const heavenlyStem = HEAVENLY_STEMS_ROMAN[cycleYear % 10];
1960
+ const earthlyBranch = ZODIAC_EN[cycleYear % 12];
1961
+ const zodiac = earthlyBranch;
1962
+ const huangdiYear = chineseYear + HUANGDI_OFFSET;
1963
+ return {
1964
+ chineseYear: huangdiYear,
1965
+ month: Math.max(1, month),
1966
+ day: Math.max(1, day),
1967
+ heavenlyStem,
1968
+ earthlyBranch,
1969
+ zodiac,
1970
+ };
1971
+ }
1972
+ /**
1973
+ * Convert a Chinese Huangdi year + month + day back to an approximate Gregorian date.
1974
+ */
1975
+ function chineseToGregorian(huangdiYear, month, day) {
1976
+ const chineseYear = huangdiYear - HUANGDI_OFFSET;
1977
+ const yearClamped = Math.max(1900, Math.min(2100, chineseYear));
1978
+ const sf = SPRING_FESTIVAL[yearClamped] ?? [2, 1];
1979
+ // Start JDN of Chinese year 1st month
1980
+ const yearStartJdn = gregorianToJdn(yearClamped, sf[0], sf[1]);
1981
+ // Approx JDN for given month/day
1982
+ const approxJdn = yearStartJdn + Math.round((month - 1) * 29.53) + (day - 1);
1983
+ return jdnToGregorian(approxJdn);
1984
+ }
1985
+ // ─────────────────────────────────────────────────────────────────────────────
1986
+ // Driver Class
1987
+ // ─────────────────────────────────────────────────────────────────────────────
1988
+ class ChineseDriver {
1989
+ constructor() {
1990
+ this.code = "chin";
1991
+ this.name = "Chinese Lunisolar";
1992
+ }
1993
+ getComponentsFromDate(date) {
1994
+ const gy = date.getUTCFullYear();
1995
+ const gm = date.getUTCMonth() + 1;
1996
+ const gd = date.getUTCDate();
1997
+ const { chineseYear, month, day } = gregorianToChinese(gy, gm, gd);
1998
+ // Store as TPS year field (full Huangdi year); millennium/century/year decomposition
1999
+ const millennium = Math.floor(chineseYear / 1000) + 1;
2000
+ const century = Math.floor((chineseYear % 1000) / 100) + 1;
2001
+ const year = chineseYear % 100;
2002
+ return {
2003
+ calendar: this.code,
2004
+ millennium,
2005
+ century,
2006
+ year,
2007
+ month,
2008
+ day,
2009
+ hour: date.getUTCHours(),
2010
+ minute: date.getUTCMinutes(),
2011
+ second: date.getUTCSeconds(),
2012
+ millisecond: date.getUTCMilliseconds(),
2013
+ };
2014
+ }
2015
+ getDateFromComponents(components) {
2016
+ // Reconstruct full Huangdi year
2017
+ const millennium = components.millennium ?? 5;
2018
+ const century = components.century ?? 1;
2019
+ const year = components.year ?? 0;
2020
+ const huangdiYear = (millennium - 1) * 1000 + (century - 1) * 100 + year;
2021
+ const { gy, gm, gd } = chineseToGregorian(huangdiYear, components.month ?? 1, components.day ?? 1);
2022
+ return new Date(Date.UTC(gy, gm - 1, gd, components.hour ?? 0, components.minute ?? 0, Math.floor(components.second ?? 0), components.millisecond ?? 0));
2023
+ }
2024
+ getFromDate(date) {
2025
+ const comp = this.getComponentsFromDate(date);
2026
+ return buildTimePart(comp);
2027
+ }
2028
+ parseDate(input, _format) {
2029
+ const trimmed = input.trim();
2030
+ // Accept ISO-like: "4722-03-15" or "4722/03/15"
2031
+ const parts = trimmed.split(/[-/]/).map(Number);
2032
+ if (parts.length < 2)
2033
+ return { calendar: this.code };
2034
+ const [huangdiYear, month, day = 1] = parts;
2035
+ const millennium = Math.floor(huangdiYear / 1000) + 1;
2036
+ const century = Math.floor((huangdiYear % 1000) / 100) + 1;
2037
+ const year = huangdiYear % 100;
2038
+ return { calendar: this.code, millennium, century, year, month, day };
2039
+ }
2040
+ format(components, format) {
2041
+ const millennium = components.millennium ?? 5;
2042
+ const century = components.century ?? 1;
2043
+ const year = components.year ?? 0;
2044
+ const huangdiYear = (millennium - 1) * 1000 + (century - 1) * 100 + year;
2045
+ const month = components.month ?? 1;
2046
+ const day = components.day ?? 1;
2047
+ if (format === "zh") {
2048
+ const monthName = MONTH_NAMES_ZH[month - 1] ?? `${month}月`;
2049
+ return `${huangdiYear}年${monthName}${day}日`;
2050
+ }
2051
+ if (format === "ganzhi") {
2052
+ const chineseYear = huangdiYear - HUANGDI_OFFSET;
2053
+ const cycleYear = (((chineseYear - 4) % 60) + 60) % 60;
2054
+ const stem = HEAVENLY_STEMS[cycleYear % 10];
2055
+ const branch = EARTHLY_BRANCHES[cycleYear % 12];
2056
+ const zodiac = ZODIAC_EN[cycleYear % 12];
2057
+ return `${stem}${branch} (${zodiac}) Year, Month ${month}, Day ${day}`;
2058
+ }
2059
+ // Default: ISO-like with Huangdi year
2060
+ const pad = (n) => String(n).padStart(2, "0");
2061
+ return `${huangdiYear}-${pad(month)}-${pad(day)}`;
2062
+ }
2063
+ validate(input) {
2064
+ let comp;
2065
+ if (typeof input === "string") {
2066
+ try {
2067
+ comp = this.parseDate(input);
2068
+ }
2069
+ catch {
2070
+ return false;
2071
+ }
2072
+ }
2073
+ else {
2074
+ comp = input;
2075
+ }
2076
+ const { month, day } = comp;
2077
+ if (!month || month < 1 || month > 12)
2078
+ return false;
2079
+ if (!day || day < 1 || day > 30)
2080
+ return false;
2081
+ return true;
2082
+ }
2083
+ getMetadata() {
2084
+ return {
2085
+ name: "Chinese Lunisolar",
2086
+ monthNames: MONTH_NAMES_EN,
2087
+ monthNamesShort: MONTH_NAMES_EN.map((n) => n.slice(0, 3)),
2088
+ dayNames: ["Rì", "Yuè", "Huǒ", "Shuǐ", "Mù", "Jīn", "Tǔ"], // Sun/Mon/Tue…Sat equivalents
2089
+ isLunar: true,
2090
+ monthsPerYear: 12,
2091
+ epochYear: -2698, // 2698 BCE
2092
+ };
2093
+ }
2094
+ }
2095
+
2096
+ /**
2097
+ * Environment & Compatibility Layer
2098
+ * Abstracts Node.js vs Browser differences.
2099
+ */
2100
+ /* eslint-disable @typescript-eslint/no-require-imports */
2101
+ const isNode = typeof require !== "undefined";
2102
+ /**
2103
+ * Node.js crypto module (conditional)
2104
+ */
2105
+ const crypto = isNode ? require("crypto") : null;
2106
+ /**
2107
+ * Node.js zlib module (conditional)
2108
+ */
2109
+ const zlib = isNode ? require("zlib") : null;
2110
+ const Env = {
2111
+ isNode,
2112
+ randomBytes(length) {
2113
+ if (isNode && crypto) {
2114
+ return new Uint8Array(crypto.randomBytes(length));
2115
+ }
2116
+ if (typeof window !== "undefined" && window.crypto) {
2117
+ return window.crypto.getRandomValues(new Uint8Array(length));
2118
+ }
2119
+ throw new Error("TPS: randomBytes not available in this environment");
2120
+ },
2121
+ deflate(data) {
2122
+ if (isNode && zlib) {
2123
+ return new Uint8Array(zlib.deflateRawSync(data));
2124
+ }
2125
+ throw new Error("TPS: deflate not available in this environment");
2126
+ },
2127
+ inflate(data) {
2128
+ if (isNode && zlib) {
2129
+ return new Uint8Array(zlib.inflateRawSync(data));
2130
+ }
2131
+ throw new Error("TPS: inflate not available in this environment");
2132
+ },
2133
+ signEd25519(data, privateKey) {
2134
+ if (isNode && crypto) {
2135
+ let key;
2136
+ if (typeof privateKey === "string") {
2137
+ if (privateKey.includes("PRIVATE KEY")) {
2138
+ key = privateKey;
2139
+ }
2140
+ else {
2141
+ key = crypto.createPrivateKey({
2142
+ key: Buffer.from(privateKey, "hex"),
2143
+ format: "der",
2144
+ type: "pkcs8",
2145
+ });
2146
+ }
2147
+ }
2148
+ else if (typeof privateKey === "object" &&
2149
+ privateKey !== null &&
2150
+ "asymmetricKeyType" in privateKey) {
2151
+ key = privateKey;
2152
+ }
2153
+ else {
2154
+ key = crypto.createPrivateKey({
2155
+ key: Buffer.from(privateKey),
2156
+ format: "der",
2157
+ type: "pkcs8",
2158
+ });
2159
+ }
2160
+ return new Uint8Array(crypto.sign(null, data, key));
2161
+ }
2162
+ throw new Error("TPS: signEd25519 not available in this environment");
2163
+ },
2164
+ verifyEd25519(data, signature, publicKey) {
2165
+ if (isNode && crypto) {
2166
+ return crypto.verify(null, data, publicKey, signature);
2167
+ }
2168
+ throw new Error("TPS: verifyEd25519 not available in this environment");
2169
+ },
2170
+ };
2171
+
2172
+ /**
2173
+ * TPS-UID v1 — Temporal Positioning System Identifier (Binary Reversible)
2174
+ */
2175
+ class TPSUID7RB {
2176
+ static encodeBinary(tps, opts = {}) {
2177
+ tps = TPS.expandIndexedTime(tps) ?? tps;
2178
+ const compress = opts.compress ?? false;
2179
+ const epochMs = opts.epochMs ?? this.epochMsFromTPSString(tps);
2180
+ if (!Number.isInteger(epochMs) || epochMs < 0 || epochMs > 0xffffffffffff) {
2181
+ throw new Error("TPSUID7RB: Invalid epochMs (must be 48-bit non-negative integer)");
2182
+ }
2183
+ const flags = compress ? 0x01 : 0x00;
2184
+ const nonceBuf = Env.randomBytes(4);
2185
+ const tpsUtf8 = new TextEncoder().encode(tps);
2186
+ const payload = compress ? Env.deflate(tpsUtf8) : tpsUtf8;
2187
+ const lenVar = this.uvarintEncode(payload.length);
2188
+ const out = new Uint8Array(4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length);
2189
+ let offset = 0;
2190
+ out.set(this.MAGIC, offset);
2191
+ offset += 4;
2192
+ out[offset++] = this.VER;
2193
+ out[offset++] = flags;
2194
+ out.set(this.writeU48(epochMs), offset);
2195
+ offset += 6;
2196
+ out.set(nonceBuf, offset);
2197
+ offset += 4;
2198
+ out.set(lenVar, offset);
2199
+ offset += lenVar.length;
2200
+ out.set(payload, offset);
2201
+ return out;
2202
+ }
2203
+ static decodeBinary(bytes) {
2204
+ if (bytes.length < 17)
2205
+ throw new Error("TPSUID7RB: too short");
2206
+ if (bytes[0] !== 0x54 ||
2207
+ bytes[1] !== 0x50 ||
2208
+ bytes[2] !== 0x55 ||
2209
+ bytes[3] !== 0x37) {
2210
+ throw new Error("TPSUID7RB: bad magic");
2211
+ }
2212
+ const ver = bytes[4];
2213
+ if (ver !== this.VER)
2214
+ throw new Error(`TPSUID7RB: unsupported version ${ver}`);
2215
+ const flags = bytes[5];
2216
+ const compressed = (flags & 0x01) === 0x01;
2217
+ const epochMs = this.readU48(bytes, 6);
2218
+ const nonce = ((bytes[12] << 24) >>> 0) +
2219
+ ((bytes[13] << 16) >>> 0) +
2220
+ ((bytes[14] << 8) >>> 0) +
2221
+ bytes[15];
2222
+ let offset = 16;
2223
+ const { value: tpsLen, bytesRead } = this.uvarintDecode(bytes, offset);
2224
+ offset += bytesRead;
2225
+ if (offset + tpsLen > bytes.length)
2226
+ throw new Error("TPSUID7RB: length overflow");
2227
+ const payload = bytes.slice(offset, offset + tpsLen);
2228
+ const tpsUtf8 = compressed ? Env.inflate(payload) : payload;
2229
+ const tps = new TextDecoder().decode(tpsUtf8);
2230
+ return { version: "tpsuid7rb", epochMs, compressed, nonce, tps };
2231
+ }
2232
+ static encodeBinaryB64(tps, opts) {
2233
+ const bytes = this.encodeBinary(tps, opts ?? {});
2234
+ return `${this.PREFIX}${this.base64UrlEncode(bytes)}`;
2235
+ }
2236
+ static decodeBinaryB64(id) {
2237
+ const s = id.trim();
2238
+ if (!s.startsWith(this.PREFIX))
2239
+ throw new Error("TPSUID7RB: missing prefix");
2240
+ return this.decodeBinary(this.base64UrlDecode(s.slice(this.PREFIX.length)));
2241
+ }
2242
+ static validateBinaryB64(id) {
2243
+ return this.REGEX.test(id.trim());
2244
+ }
2245
+ static generate(opts) {
2246
+ const now = new Date();
2247
+ const time = TPS.fromDate(now, DefaultCalendars.TPS, {
2248
+ order: opts?.order,
2249
+ });
2250
+ let space = "unknown";
2251
+ if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
2252
+ space = `${opts.latitude},${opts.longitude}`;
2253
+ if (opts.altitude !== undefined)
2254
+ space += `,${opts.altitude}m`;
2255
+ }
2256
+ const tps = `tps://${space}@${time}`;
2257
+ return this.encodeBinaryB64(tps, {
2258
+ compress: opts?.compress,
2259
+ epochMs: now.getTime(),
2260
+ });
2261
+ }
2262
+ static seal(tps, privateKey, opts) {
2263
+ tps = TPS.expandIndexedTime(tps) ?? tps;
2264
+ const compress = opts?.compress ?? false;
2265
+ const epochMs = opts?.epochMs ?? this.epochMsFromTPSString(tps);
2266
+ if (!Number.isInteger(epochMs) || epochMs < 0 || epochMs > 0xffffffffffff) {
2267
+ throw new Error("TPSUID7RB: Invalid epochMs");
2268
+ }
2269
+ const flags = (compress ? 0x01 : 0x00) | 0x02; // Set SEAL bit
2270
+ const nonceBuf = Env.randomBytes(4);
2271
+ const tpsUtf8 = new TextEncoder().encode(tps);
2272
+ const payload = compress ? Env.deflate(tpsUtf8) : tpsUtf8;
2273
+ const lenVar = this.uvarintEncode(payload.length);
2274
+ const contentLen = 4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length;
2275
+ const content = new Uint8Array(contentLen);
2276
+ let offset = 0;
2277
+ content.set(this.MAGIC, offset);
2278
+ offset += 4;
2279
+ content[offset++] = this.VER;
2280
+ content[offset++] = flags;
2281
+ content.set(this.writeU48(epochMs), offset);
2282
+ offset += 6;
2283
+ content.set(nonceBuf, offset);
2284
+ offset += 4;
2285
+ content.set(lenVar, offset);
2286
+ offset += lenVar.length;
2287
+ content.set(payload, offset);
2288
+ const signature = Env.signEd25519(content, privateKey);
2289
+ const final = new Uint8Array(contentLen + 1 + signature.length);
2290
+ final.set(content, 0);
2291
+ final.set([0x01], contentLen); // Ed25519 type
2292
+ final.set(signature, contentLen + 1);
2293
+ return final;
2294
+ }
2295
+ static verifyAndDecode(sealedBytes, publicKey) {
2296
+ if (sealedBytes.length < 18)
2297
+ throw new Error("TPSUID7RB: too short");
2298
+ if (sealedBytes[5] & 0x02 ? false : true)
2299
+ throw new Error("TPSUID7RB: not sealed");
2300
+ let offset = 16;
2301
+ const { value: tpsLen, bytesRead } = this.uvarintDecode(sealedBytes, offset);
2302
+ const payloadEnd = offset + bytesRead + tpsLen;
2303
+ const content = sealedBytes.slice(0, payloadEnd);
2304
+ const signature = sealedBytes.slice(payloadEnd + 1);
2305
+ if (!Env.verifyEd25519(content, signature, publicKey)) {
2306
+ throw new Error("TPSUID7RB: verification failed");
2307
+ }
2308
+ return this.decodeBinary(content);
2309
+ }
2310
+ static epochMsFromTPSString(tps) {
2311
+ tps = TPS.expandIndexedTime(tps) ?? tps;
2312
+ const date = TPS.toDate(tps);
2313
+ if (date)
2314
+ return date.getTime();
2315
+ const stripped = tps.replace(/;[^?#]*/, "").replace(/[?#].*$/, "");
2316
+ const retryDate = TPS.toDate(stripped);
2317
+ if (!retryDate)
2318
+ throw new Error("TPS: unable to parse date for epoch");
2319
+ return retryDate.getTime();
2320
+ }
2321
+ static writeU48(epochMs) {
2322
+ const b = new Uint8Array(6);
2323
+ const v = BigInt(epochMs);
2324
+ b[0] = Number((v >> 40n) & 0xffn);
2325
+ b[1] = Number((v >> 32n) & 0xffn);
2326
+ b[2] = Number((v >> 24n) & 0xffn);
2327
+ b[3] = Number((v >> 16n) & 0xffn);
2328
+ b[4] = Number((v >> 8n) & 0xffn);
2329
+ b[5] = Number(v & 0xffn);
2330
+ return b;
2331
+ }
2332
+ static readU48(bytes, offset) {
2333
+ const v = (BigInt(bytes[offset]) << 40n) +
2334
+ (BigInt(bytes[offset + 1]) << 32n) +
2335
+ (BigInt(bytes[offset + 2]) << 24n) +
2336
+ (BigInt(bytes[offset + 3]) << 16n) +
2337
+ (BigInt(bytes[offset + 4]) << 8n) +
2338
+ BigInt(bytes[offset + 5]);
2339
+ return Number(v);
2340
+ }
2341
+ static uvarintEncode(n) {
2342
+ const out = [];
2343
+ let x = n >>> 0;
2344
+ while (x >= 0x80) {
2345
+ out.push((x & 0x7f) | 0x80);
2346
+ x >>>= 7;
2347
+ }
2348
+ out.push(x);
2349
+ return new Uint8Array(out);
2350
+ }
2351
+ static uvarintDecode(bytes, offset) {
2352
+ let x = 0, s = 0, i = 0;
2353
+ while (true) {
2354
+ const b = bytes[offset + i];
2355
+ if (b < 0x80) {
2356
+ x |= b << s;
2357
+ return { value: x >>> 0, bytesRead: i + 1 };
2358
+ }
2359
+ x |= (b & 0x7f) << s;
2360
+ s += 7;
2361
+ i++;
2362
+ }
2363
+ }
2364
+ static base64UrlEncode(bytes) {
2365
+ if (typeof Buffer !== "undefined") {
2366
+ return Buffer.from(bytes)
2367
+ .toString("base64")
2368
+ .replace(/\+/g, "-")
2369
+ .replace(/\//g, "_")
2370
+ .replace(/=+$/g, "");
2371
+ }
2372
+ return btoa(String.fromCharCode(...bytes))
2373
+ .replace(/\+/g, "-")
2374
+ .replace(/\//g, "_")
2375
+ .replace(/=+$/g, "");
2376
+ }
2377
+ static base64UrlDecode(b64url) {
2378
+ const padLen = (4 - (b64url.length % 4)) % 4;
2379
+ const b64 = (b64url + "=".repeat(padLen))
2380
+ .replace(/-/g, "+")
2381
+ .replace(/_/g, "/");
2382
+ if (typeof Buffer !== "undefined")
2383
+ return new Uint8Array(Buffer.from(b64, "base64"));
2384
+ const binary = atob(b64);
2385
+ return new Uint8Array(Array.from(binary).map((c) => c.charCodeAt(0)));
2386
+ }
2387
+ }
2388
+ TPSUID7RB.MAGIC = new Uint8Array([0x54, 0x50, 0x55, 0x37]);
2389
+ TPSUID7RB.VER = 0x01;
2390
+ TPSUID7RB.PREFIX = "tpsuid7rb_";
2391
+ TPSUID7RB.REGEX = /^tpsuid7rb_[A-Za-z0-9_-]+$/;
2392
+
2393
+ /**
2394
+ * TpsDate Date-like wrapper with native TPS conversion helpers.
2395
+ */
2396
+ class TpsDate {
2397
+ constructor(...args) {
2398
+ this._cachedComponents = null;
2399
+ this._cachedTps = null;
2400
+ if (args.length === 0) {
2401
+ this.internal = new Date();
2402
+ return;
2403
+ }
2404
+ if (args.length === 1) {
2405
+ const value = args[0];
2406
+ if (value instanceof TpsDate) {
2407
+ this.internal = new Date(value.getTime());
2408
+ return;
2409
+ }
2410
+ if (value instanceof Date) {
2411
+ this.internal = new Date(value.getTime());
2412
+ return;
2413
+ }
2414
+ if (typeof value === "string" && TpsDate.looksLikeTPS(value)) {
2415
+ const parsed = TPS.toDate(value);
2416
+ if (!parsed) {
2417
+ throw new RangeError(`Invalid TPS date string: ${value}`);
2418
+ }
2419
+ this.internal = parsed;
2420
+ return;
2421
+ }
2422
+ this.internal = new Date(value);
2423
+ return;
2424
+ }
2425
+ const [year, monthIndex, day, hours, minutes, seconds, ms] = args;
2426
+ this.internal = new Date(year, monthIndex, day ?? 1, hours ?? 0, minutes ?? 0, seconds ?? 0, ms ?? 0);
2427
+ }
2428
+ static looksLikeTPS(input) {
2429
+ const s = input.trim();
2430
+ return s.startsWith("tps://") || s.startsWith("T:") || s.startsWith("t:");
2431
+ }
2432
+ getTpsComponents() {
2433
+ const currentTps = this.toTPS(DefaultCalendars.TPS);
2434
+ if (this._cachedTps === currentTps && this._cachedComponents) {
2435
+ return this._cachedComponents;
2436
+ }
2437
+ const parsed = TPS.parse(currentTps);
2438
+ if (!parsed) {
2439
+ throw new Error("TpsDate: failed to derive TPS components");
2440
+ }
2441
+ this._cachedTps = currentTps;
2442
+ this._cachedComponents = parsed;
2443
+ return parsed;
2444
+ }
2445
+ getTpsFullYear() {
2446
+ const comp = this.getTpsComponents();
2447
+ return (comp.millennium - 1) * 1000 + (comp.century - 1) * 100 + comp.year;
2448
+ }
2449
+ static now() {
2450
+ return Date.now();
2451
+ }
2452
+ static parse(input) {
2453
+ if (this.looksLikeTPS(input)) {
2454
+ const d = TPS.toDate(input);
2455
+ return d ? d.getTime() : Number.NaN;
2456
+ }
2457
+ return Date.parse(input);
2458
+ }
2459
+ static UTC(year, monthIndex, day, hours, minutes, seconds, ms) {
2460
+ return Date.UTC(year, monthIndex, day ?? 1, hours ?? 0, minutes ?? 0, seconds ?? 0, ms ?? 0);
2461
+ }
2462
+ static fromTPS(tps) {
2463
+ return new TpsDate(tps);
2464
+ }
2465
+ toGregorianDate() {
2466
+ return new Date(this.internal.getTime());
2467
+ }
2468
+ toDate() {
2469
+ return this.toGregorianDate();
2470
+ }
2471
+ toTPS(calendar = DefaultCalendars.TPS, opts) {
2472
+ return TPS.fromDate(this.internal, calendar, opts);
2473
+ }
2474
+ toTPSURI(calendar = DefaultCalendars.TPS, opts) {
2475
+ const time = this.toTPS(calendar, {
2476
+ order: opts?.order,
2477
+ timeMode: opts?.timeMode,
2478
+ indexedPrecision: opts?.indexedPrecision,
2479
+ });
2480
+ const comp = TPS.parse(time);
2481
+ if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
2482
+ comp.latitude = opts.latitude;
2483
+ comp.longitude = opts.longitude;
2484
+ if (opts.altitude !== undefined)
2485
+ comp.altitude = opts.altitude;
2486
+ }
2487
+ else if (opts?.isHiddenLocation) {
2488
+ comp.isHiddenLocation = true;
2489
+ }
2490
+ else if (opts?.isRedactedLocation) {
2491
+ comp.isRedactedLocation = true;
2492
+ }
2493
+ else {
2494
+ comp.isUnknownLocation = true;
2495
+ }
2496
+ return TPS.toURI(comp);
2497
+ }
2498
+ getTime() {
2499
+ return this.internal.getTime();
2500
+ }
2501
+ valueOf() {
2502
+ return this.internal.valueOf();
2503
+ }
2504
+ toString() {
2505
+ return this.toTPS(DefaultCalendars.TPS);
2506
+ }
2507
+ toISOString() {
2508
+ return this.internal.toISOString();
2509
+ }
2510
+ toUTCString() {
2511
+ return this.internal.toUTCString();
2512
+ }
2513
+ toJSON() {
2514
+ return this.internal.toJSON();
2515
+ }
2516
+ getFullYear() {
2517
+ return this.getTpsFullYear();
2518
+ }
2519
+ getUTCFullYear() {
2520
+ return this.getTpsFullYear();
2521
+ }
2522
+ getMonth() {
2523
+ return this.getTpsComponents().month - 1;
2524
+ }
2525
+ getUTCMonth() {
2526
+ return this.getTpsComponents().month - 1;
2527
+ }
2528
+ getDate() {
2529
+ return this.getTpsComponents().day;
2530
+ }
2531
+ getUTCDate() {
2532
+ return this.getTpsComponents().day;
2533
+ }
2534
+ getHours() {
2535
+ return this.getTpsComponents().hour;
2536
+ }
2537
+ getUTCHours() {
2538
+ return this.getTpsComponents().hour;
2539
+ }
2540
+ getMinutes() {
2541
+ return this.getTpsComponents().minute;
2542
+ }
2543
+ getUTCMinutes() {
2544
+ return this.getTpsComponents().minute;
2545
+ }
2546
+ getSeconds() {
2547
+ return this.getTpsComponents().second;
2548
+ }
2549
+ getUTCSeconds() {
2550
+ return this.getTpsComponents().second;
2551
+ }
2552
+ getMilliseconds() {
2553
+ return this.getTpsComponents().millisecond;
2554
+ }
2555
+ getUTCMilliseconds() {
2556
+ return this.getTpsComponents().millisecond;
2557
+ }
2558
+ [Symbol.toPrimitive](hint) {
2559
+ if (hint === "number")
2560
+ return this.valueOf();
2561
+ return this.toString();
2562
+ }
2563
+ }
2564
+
2565
+ /**
2566
+ * TPS DriverManager
2567
+ * Manages the registry of calendar driver plugins.
2568
+ */
2569
+ /**
2570
+ * A dedicated registry for TPS calendar driver plugins.
2571
+ * The global `TPS` class exposes a shared singleton instance (`TPS.driverManager`).
2572
+ */
2573
+ class DriverManager {
2574
+ constructor() {
2575
+ this.registry = new Map();
2576
+ }
2577
+ /**
2578
+ * Registers a calendar driver.
2579
+ * Overwrites any previously registered driver with the same code.
2580
+ */
2581
+ register(driver) {
2582
+ if (!driver || !driver.code) {
2583
+ throw new Error("DriverManager: driver must have a valid `code` string.");
2584
+ }
2585
+ this.registry.set(driver.code, driver);
2586
+ }
2587
+ /**
2588
+ * Returns the driver registered for the given calendar code, or `undefined`.
2589
+ */
2590
+ get(code) {
2591
+ return this.registry.get(code);
2592
+ }
2593
+ /**
2594
+ * Returns `true` if a driver with the given code has been registered.
2595
+ */
2596
+ has(code) {
2597
+ return this.registry.has(code);
2598
+ }
2599
+ /**
2600
+ * Returns an array of all registered calendar codes.
2601
+ */
2602
+ list() {
2603
+ return Array.from(this.registry.keys());
2604
+ }
2605
+ /**
2606
+ * Removes a driver from the registry.
2607
+ * Returns `true` if the driver existed and was removed.
2608
+ */
2609
+ unregister(code) {
2610
+ return this.registry.delete(code);
2611
+ }
2612
+ }
2613
+
2614
+ /**
2615
+ * Timezone Utilities
2616
+ *
2617
+ * Provides lightweight, zero-dependency helpers for converting between
2618
+ * UTC timestamps and local timestamps in a given IANA timezone or
2619
+ * fixed UTC offset.
2620
+ *
2621
+ * Used by `TPS.toDate()` when the parsed extensions include a `tz` key.
2622
+ *
2623
+ * Examples:
2624
+ * tz=Asia/Tehran → IANA timezone name
2625
+ * tz=+03:30 → fixed UTC offset
2626
+ * tz=IRST → abbreviated names (best-effort, common ones mapped)
2627
+ */
2628
+ /** Map of common abbreviated timezone names to IANA names. */
2629
+ const TZ_ABBR = {
2630
+ // Middle East
2631
+ IRST: "Asia/Tehran",
2632
+ IRDT: "Asia/Tehran",
2633
+ AST: "Asia/Riyadh",
2634
+ AST3: "Asia/Riyadh",
2635
+ // Europe
2636
+ CET: "Europe/Paris",
2637
+ CEST: "Europe/Paris",
2638
+ EET: "Europe/Athens",
2639
+ EEST: "Europe/Athens",
2640
+ WET: "Europe/Lisbon",
2641
+ WEST: "Europe/Lisbon",
2642
+ // Americas
2643
+ EST: "America/New_York",
2644
+ EDT: "America/New_York",
2645
+ CST: "America/Chicago",
2646
+ CDT: "America/Chicago",
2647
+ MST: "America/Denver",
2648
+ MDT: "America/Denver",
2649
+ PST: "America/Los_Angeles",
2650
+ PDT: "America/Los_Angeles",
2651
+ // Asia Pacific
2652
+ JST: "Asia/Tokyo",
2653
+ KST: "Asia/Seoul",
2654
+ IST: "Asia/Kolkata",
2655
+ CST8: "Asia/Shanghai",
2656
+ AEST: "Australia/Sydney",
2657
+ AEDT: "Australia/Sydney",
2658
+ // UTC variants
2659
+ UTC: "UTC",
2660
+ GMT: "UTC",
2661
+ Z: "UTC",
2662
+ };
2663
+ /**
2664
+ * Parse a `tz` string into an IANA timezone name, or return null if unrecognized.
2665
+ */
2666
+ function resolveIANA(tz) {
2667
+ if (!tz)
2668
+ return null;
2669
+ // Direct IANA name (e.g. "Asia/Tehran")
2670
+ if (tz.includes("/"))
2671
+ return tz;
2672
+ // Common abbreviation lookup
2673
+ const abbr = TZ_ABBR[tz.toUpperCase()];
2674
+ if (abbr)
2675
+ return abbr;
2676
+ return null;
2677
+ }
2678
+ /**
2679
+ * Parses a fixed offset string like "+03:30", "-05:00", "+0530" into ms.
2680
+ * Returns NaN if the string is not a valid offset.
2681
+ */
2682
+ function parseFixedOffset(tz) {
2683
+ const m = tz.match(/^([+-])(\d{1,2}):?(\d{2})$/);
2684
+ if (!m)
2685
+ return NaN;
2686
+ const sign = m[1] === "+" ? 1 : -1;
2687
+ const h = parseInt(m[2], 10);
2688
+ const min = parseInt(m[3], 10);
2689
+ return sign * (h * 60 + min) * 60000;
2690
+ }
2691
+ /**
2692
+ * Returns the UTC offset in milliseconds for a given IANA timezone at a
2693
+ * specific UTC moment. Uses `Intl.DateTimeFormat` for correctness.
2694
+ *
2695
+ * Returns 0 (UTC) if the timezone is unrecognised by the runtime.
2696
+ */
2697
+ function getIANAOffsetMs(ianaName, atUtcMs) {
2698
+ try {
2699
+ // Build a formatter that reports both UTC and local time parts
2700
+ const fmt = new Intl.DateTimeFormat("en-US", {
2701
+ timeZone: ianaName,
2702
+ year: "numeric",
2703
+ month: "2-digit",
2704
+ day: "2-digit",
2705
+ hour: "2-digit",
2706
+ minute: "2-digit",
2707
+ second: "2-digit",
2708
+ hour12: false,
2709
+ });
2710
+ const parts = Object.fromEntries(fmt.formatToParts(new Date(atUtcMs)).map((p) => [p.type, p.value]));
2711
+ // Reconstruct the local time as UTC
2712
+ const localMs = Date.UTC(parseInt(parts.year), parseInt(parts.month) - 1, parseInt(parts.day), parseInt(parts.hour === "24" ? "0" : parts.hour), parseInt(parts.minute), parseInt(parts.second));
2713
+ return localMs - atUtcMs;
2714
+ }
2715
+ catch {
2716
+ return 0;
2717
+ }
2718
+ }
2719
+ /**
2720
+ * Given a UTC timestamp (ms), returns the equivalent *local* millisecond
2721
+ * value for the given `tz` string.
2722
+ *
2723
+ * This is useful for display: `utcMs + offsetMs` gives local wall-clock time.
2724
+ */
2725
+ function utcToLocal(utcMs, tz) {
2726
+ // Try fixed offset first (e.g. "+03:30")
2727
+ const fixed = parseFixedOffset(tz);
2728
+ if (!isNaN(fixed))
2729
+ return utcMs + fixed;
2730
+ const iana = resolveIANA(tz);
2731
+ if (!iana)
2732
+ return utcMs; // Unknown tz — return UTC unchanged
2733
+ return utcMs + getIANAOffsetMs(iana, utcMs);
2734
+ }
2735
+ /**
2736
+ * Given a *local* timestamp (ms) in `tz`, returns the equivalent UTC ms.
2737
+ *
2738
+ * Used when converting a calendar date expressed in local time back to UTC.
2739
+ */
2740
+ function localToUtc(localMs, tz) {
2741
+ // Try fixed offset first
2742
+ const fixed = parseFixedOffset(tz);
2743
+ if (!isNaN(fixed))
2744
+ return localMs - fixed;
2745
+ const iana = resolveIANA(tz);
2746
+ if (!iana)
2747
+ return localMs;
2748
+ // Approximation: compute the offset at the approximate UTC moment
2749
+ // (offset iteration is overkill for a scheduling library)
2750
+ const approxUtcMs = localMs - getIANAOffsetMs(iana, localMs);
2751
+ // One correction pass for DST edge-cases
2752
+ const correctedOffset = getIANAOffsetMs(iana, approxUtcMs);
2753
+ return localMs - correctedOffset;
2754
+ }
2755
+ /**
2756
+ * Returns the UTC offset string (e.g. "+03:30") for a given IANA timezone
2757
+ * at the current moment. Useful for formatting and display.
2758
+ */
2759
+ function getOffsetString(tz, atUtcMs = Date.now()) {
2760
+ // Fast-path for UTC/GMT/Z
2761
+ const upper = tz.toUpperCase();
2762
+ if (upper === "UTC" || upper === "GMT" || upper === "Z")
2763
+ return "+00:00";
2764
+ const fixed = parseFixedOffset(tz);
2765
+ if (!isNaN(fixed))
2766
+ return tz;
2767
+ const iana = resolveIANA(tz);
2768
+ if (!iana)
2769
+ return "+00:00";
2770
+ const offsetMs = getIANAOffsetMs(iana, atUtcMs);
2771
+ const sign = offsetMs >= 0 ? "+" : "-";
2772
+ const abs = Math.abs(offsetMs);
2773
+ const h = Math.floor(abs / 3600000)
2774
+ .toString()
2775
+ .padStart(2, "0");
2776
+ const m = Math.floor((abs % 3600000) / 60000)
2777
+ .toString()
2778
+ .padStart(2, "0");
2779
+ return `${sign}${h}:${m}`;
2780
+ }
2781
+
2782
+ /**
2783
+ * TPS: Temporal Positioning System
2784
+ * The Universal Protocol for Space-Time Coordinates.
2785
+ * @packageDocumentation
2786
+ * @version 0.8.0
2787
+ * @license Apache-2.0
2788
+ * @copyright 2026 TPS Standards Working Group
2789
+ *
2790
+ * v0.5.35 Changes:
2791
+ * - Added TPS.now(), TPS.diff(), TPS.add() convenience methods
2792
+ * - Added Chinese Lunisolar (chin) calendar driver
2793
+ * - Added DriverManager (driver registry separated from TPS class)
2794
+ * - Added timezone utility (src/utils/timezone.ts) with IANA + offset support
2795
+ * - TPS.toDate() now respects ;tz= extensions when present
2796
+ * - ESM dual-mode exports + browser IIFE bundle
2797
+ *
2798
+ * v0.5.0 Changes:
2799
+ * - Added Actor anchor (A:) for provenance tracking
2800
+ * - Added Signature (!) for cryptographic verification
2801
+ * - Added structural anchors (bldg, floor, room, zone)
2802
+ * - Added geospatial cell systems (S2, H3, Plus Code, what3words)
2803
+ */
2804
+ // built-in drivers are registered automatically; importing them here
2805
+ // ensures they are included when the library bundler/tree-shaker runs.
2806
+ class TPS {
2807
+ /**
2808
+ * Registers a calendar driver plugin.
2809
+ * @param driver - The driver instance to register.
2810
+ */
2811
+ static registerDriver(driver) {
2812
+ this.driverManager.register(driver);
2813
+ }
2814
+ /**
2815
+ * Gets a registered calendar driver.
2816
+ * @param code - The calendar code.
2817
+ * @returns The driver or undefined.
2818
+ */
2819
+ static getDriver(code) {
2820
+ return this.driverManager.get(code);
2821
+ }
2822
+ // --- CORE METHODS ---
2823
+ /**
2824
+ * SANITIZER: Normalises a raw TPS input string before validation.
2825
+ *
2826
+ * Pure string-based — no parsing into components, no regex beyond simple
2827
+ * character checks, no re-serialisation via buildTimePart / toURI.
2828
+ *
2829
+ * Token ranks (descending): m(8) c(7) y(6) m(5) d(4) h(3) m(2) s(1) m(0)
2830
+ */
2831
+ static sanitizeTimeInput(input) {
2832
+ // ── 1. Whitespace ────────────────────────────────────────────────────────
2833
+ let s = input.trim().replace(/\s+/g, "");
2834
+ if (!s)
2835
+ return s;
2836
+ // ── 1.2 Compact scheme normalization (v0.6.0) ──────────────────────────
2837
+ // TPS:... → tps://... (generic compact)
2838
+ // NIP4:x → tps://net:ip4:x (IPv4 shorthand)
2839
+ // NIP6:x → tps://net:ip6:x (IPv6 shorthand)
2840
+ // NODE:x → tps://node:x (logical node shorthand)
2841
+ if (/^TPS:/i.test(s) && !s.toLowerCase().startsWith("tps://")) {
2842
+ // TPS:L:... or TPS:lat,lon... → tps://...
2843
+ s = "tps://" + s.slice(4); // strip 'TPS:'
2844
+ }
2845
+ else if (/^NIP4:/i.test(s)) {
2846
+ s = "tps://net:ip4:" + s.slice(5);
2847
+ }
2848
+ else if (/^NIP6:/i.test(s)) {
2849
+ s = "tps://net:ip6:" + s.slice(5);
2850
+ }
2851
+ else if (/^NODE:/i.test(s)) {
2852
+ s = "tps://node:" + s.slice(5);
2853
+ }
2854
+ // ── 1.5 Convert legacy "/T:" separators to the new canonical "@T:".
2855
+ // The input may contain "/T:" from older versions; we normalise early so
2856
+ // subsequent logic can assume only the '@' form.
2857
+ if (s.includes("/T:")) {
2858
+ s = s.replace(/\/T:/g, "@T:");
2859
+ }
2860
+ // ── 2. Scheme casing ─────────────────────────────────────────────────────
2861
+ if (s.slice(0, 6).toLowerCase() === "tps://") {
2862
+ s = "tps://" + s.slice(6);
2863
+ }
2864
+ // ── 3. T: prefix casing (time-only strings) ──────────────────────────────
2865
+ if (!s.startsWith("tps://") && s.slice(0, 2).toLowerCase() === "t:") {
2866
+ s = "T:" + s.slice(2);
2867
+ }
2868
+ // ── 4. Locate T: section ─────────────────────────────────────────────────
2869
+ let tStart = -1;
2870
+ if (s.startsWith("T:")) {
2871
+ tStart = 0;
2872
+ }
2873
+ else {
2874
+ const atT = s.indexOf("@T:");
2875
+ if (atT !== -1)
2876
+ tStart = atT + 1;
2877
+ }
2878
+ if (tStart === -1)
2879
+ return s; // no T: section — return as-is
2880
+ const beforeT = s.slice(0, tStart); // URI prefix or empty
2881
+ const timeAndRest = s.slice(tStart); // T:cal.tok... [!sig][;ext]
2882
+ // Isolate token section from any trailing suffix (!sig / ;ext / ?q / #f)
2883
+ const suffixIdx = timeAndRest.search(/[!;?#]/);
2884
+ const timeSuffix = suffixIdx !== -1 ? timeAndRest.slice(suffixIdx) : "";
2885
+ const timePart = suffixIdx !== -1 ? timeAndRest.slice(0, suffixIdx) : timeAndRest;
2886
+ // timePart = "T:greg.m3.c1.y26.m01.d07.h13.m20.s45"
2887
+ // Split off calendar code
2888
+ const afterColon = timePart.slice(timePart.indexOf(":") + 1); // "greg.m3.c1..."
2889
+ const firstDot = afterColon.indexOf(".");
2890
+ const cal = (firstDot !== -1 ? afterColon.slice(0, firstDot) : afterColon).toLowerCase();
2891
+ const tokenStr = firstDot !== -1 ? afterColon.slice(firstDot + 1) : "";
2892
+ // If no calendar code was provided at all (e.g. "T:"), bail out early
2893
+ // rather than inventing a default calendar. The string will remain
2894
+ // unparsable so validation can report it as invalid.
2895
+ if (!cal) {
2896
+ return s;
2897
+ }
2898
+ // No tokens at all — fill every slot with 0 and return
2899
+ if (!tokenStr) {
2900
+ return `${beforeT}T:${cal}.m0.c0.y0.m0.d0.h0.m0.s0.m0${timeSuffix}`;
2901
+ }
2902
+ if (/^i/i.test(tokenStr)) {
2903
+ return `${beforeT}T:${cal}.${tokenStr}${timeSuffix}`;
2904
+ }
2905
+ const tokens = tokenStr
2906
+ .split(".")
2907
+ .filter((t) => t.length >= 2 && /^[a-z]/.test(t))
2908
+ .map((t) => ({ p: t[0], v: t.slice(1) }));
2909
+ // ── 6. Detect order from non-m tokens (c=7, y=6, w=4.5, d=4, h=3, s=1) ──
2910
+ const nonMRank = {
2911
+ c: 7,
2912
+ y: 6,
2913
+ w: 4.5,
2914
+ d: 4,
2915
+ h: 3,
2916
+ s: 1,
2917
+ };
2918
+ const nonMSeq = tokens
2919
+ .filter((t) => t.p !== "m" && nonMRank[t.p] !== undefined)
2920
+ .map((t) => nonMRank[t.p]);
2921
+ let isAsc = false;
2922
+ if (nonMSeq.length >= 2) {
2923
+ // ascending when every consecutive rank-diff is positive
2924
+ isAsc = nonMSeq.every((r, i) => i === 0 || r > nonMSeq[i - 1]);
2925
+ }
2926
+ // ── 7. Reverse tokens if ascending ───────────────────────────────────────
2927
+ if (isAsc)
2928
+ tokens.reverse();
2929
+ // ── 8. Disambiguate 'm' tokens by DESC position ──────────────────────────
2930
+ // DESC slot order for m tokens: rank 8 (millennium), 5 (month), 2 (minute), 0 (ms)
2931
+ const mDescRanks = [8, 5, 2, 0];
2932
+ const byRank = new Map();
2933
+ let mIdx = 0;
2934
+ for (const tok of tokens) {
2935
+ if (tok.p === "m") {
2936
+ if (mIdx < mDescRanks.length)
2937
+ byRank.set(mDescRanks[mIdx++], tok.v);
2938
+ }
2939
+ else {
2940
+ const r = nonMRank[tok.p];
2941
+ if (r !== undefined)
2942
+ byRank.set(r, tok.v);
2943
+ }
2944
+ }
2945
+ // ── 9. Build complete DESC token string, filling gaps with '0' ───────────
2946
+ const descSlots = [
2947
+ ["m", 8],
2948
+ ["c", 7],
2949
+ ["y", 6],
2950
+ ["m", 5],
2951
+ ...(tokens.some((t) => t.p === "w") ? [["w", 4.5]] : []),
2952
+ ["d", 4],
2953
+ ["h", 3],
2954
+ ["m", 2],
2955
+ ["s", 1],
2956
+ ["m", 0],
2957
+ ];
2958
+ const finalTokenStr = descSlots
2959
+ .map(([p, r]) => p + (byRank.get(r) ?? "0"))
2960
+ .join(".");
2961
+ return `${beforeT}T:${cal}.${finalTokenStr}${timeSuffix}`;
2962
+ }
2963
+ static validate(input) {
2964
+ const sanitized = this.sanitizeTimeInput(input);
2965
+ const matchesRegex = sanitized.startsWith("tps://")
2966
+ ? this.REGEX_URI.test(sanitized)
2967
+ : this.REGEX_TIME.test(sanitized);
2968
+ if (!matchesRegex)
2969
+ return false;
2970
+ const indexedMatch = sanitized.match(/(?:^T:|@T:)([a-z]{3,4})\.(i[^!;?#]+)/i);
2971
+ if (indexedMatch) {
2972
+ if (indexedMatch[1].toLowerCase() !== DefaultCalendars.TPS) {
2973
+ return false;
2974
+ }
2975
+ if (!isTpsIndexedToken(indexedMatch[2])) {
2976
+ return this.parse(sanitized) !== null;
2977
+ }
2978
+ return this.parse(sanitized) !== null;
2979
+ }
2980
+ return true;
2981
+ }
2982
+ static parse(input) {
2983
+ // Always sanitize first so we operate on the canonical form. This also
2984
+ // rewrites any legacy "/T:" separators to "@T:" so the regex below can
2985
+ // remain strict.
2986
+ input = this.sanitizeTimeInput(input);
2987
+ // quick fail via regex to rule out obviously bad strings
2988
+ if (input.startsWith("tps://")) {
2989
+ const match = this.REGEX_URI.exec(input);
2990
+ if (!match || !match.groups)
2991
+ return null;
2992
+ const comp = this._mapGroupsToComponents(match.groups);
2993
+ // extract the raw time portion and parse it separately
2994
+ const atIdx = input.indexOf("@T:");
2995
+ let timeStr = "";
2996
+ let signature;
2997
+ if (atIdx !== -1) {
2998
+ timeStr = input.slice(atIdx + 1); // include the leading 'T:'
2999
+ // if there's a signature, capture it first
3000
+ const sigMatch = timeStr.match(/!(?<sig>[^;?#]+)/);
3001
+ if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
3002
+ signature = sigMatch.groups.sig;
3003
+ }
3004
+ // cut off signature, extensions, query, or fragment
3005
+ timeStr = timeStr.split(/[!;?#]/)[0];
3006
+ }
3007
+ if (timeStr) {
3008
+ const parsed = parseTimeString(timeStr);
3009
+ if (!parsed)
3010
+ return null;
3011
+ Object.assign(comp, parsed.components);
3012
+ comp.order = parsed.order;
3013
+ }
3014
+ if (signature) {
3015
+ comp.signature = signature;
3016
+ }
3017
+ return comp;
3018
+ }
3019
+ // time-only string
3020
+ const match = this.REGEX_TIME.exec(input);
3021
+ if (!match || !match.groups)
3022
+ return null;
3023
+ // isolate signature if present
3024
+ let timeOnly = input;
3025
+ let signature;
3026
+ const sigMatch = input.match(/!(?<sig>[^;?#]+)/);
3027
+ if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
3028
+ signature = sigMatch.groups.sig;
3029
+ timeOnly = input.split(/[!;?#]/)[0];
3030
+ }
3031
+ else {
3032
+ // Strip extension/query/fragment suffix so parseTimeString sees only tokens
3033
+ timeOnly = input.split(/[;?#]/)[0];
3034
+ }
3035
+ const parsed = parseTimeString(timeOnly);
3036
+ if (!parsed)
3037
+ return null;
3038
+ const comp = parsed.components;
3039
+ if (signature)
3040
+ comp.signature = signature;
3041
+ comp.order = parsed.order;
3042
+ // Route through the same group mapper used by REGEX_URI for consistency
3043
+ // (handles extensions ;KEY:val and context #C:key=val)
3044
+ const syntheticGroups = {
3045
+ calendar: match.groups.calendar ?? "",
3046
+ signature: match.groups.signature ?? "",
3047
+ extensions: match.groups.extensions ?? "",
3048
+ context: match.groups.context ?? "",
3049
+ location: "", // no location in time-only string
3050
+ actor: "",
3051
+ };
3052
+ const mappedComp = this._mapGroupsToComponents(syntheticGroups);
3053
+ // Merge temporal components from parseTimeString with mapped metadata
3054
+ Object.assign(comp, {
3055
+ signature: mappedComp.signature || comp.signature,
3056
+ extensions: mappedComp.extensions || comp.extensions,
3057
+ context: mappedComp.context,
3058
+ });
3059
+ return comp;
3060
+ }
3061
+ static buildTimeString(comp, opts) {
3062
+ let time = buildTimePart(comp, opts);
3063
+ if (comp.extensions && Object.keys(comp.extensions).length > 0) {
3064
+ const extStrings = Object.entries(comp.extensions).map(([k, v]) => {
3065
+ return `${k.toUpperCase()}:${v}`;
3066
+ });
3067
+ time += `;${extStrings.join(";")}`;
3068
+ }
3069
+ if (comp.context && Object.keys(comp.context).length > 0) {
3070
+ const ctxStrings = Object.entries(comp.context).map(([k, v]) => `${k}=${v}`);
3071
+ time += `#C:${ctxStrings.join(";")}`;
3072
+ }
3073
+ return time;
3074
+ }
3075
+ static toTpsNativeComponents(input) {
3076
+ if (input instanceof Date) {
3077
+ return normalizeTpsComponents({
3078
+ calendar: DefaultCalendars.TPS,
3079
+ ...buildTpsComponentsFromDayIndex(getTpsDayIndex(input), getTpsDayFraction(input)),
3080
+ });
3081
+ }
3082
+ if (typeof input === "string") {
3083
+ const parsed = this.parse(input);
3084
+ if (!parsed || parsed.calendar !== DefaultCalendars.TPS)
3085
+ return null;
3086
+ return normalizeTpsComponents(parsed);
3087
+ }
3088
+ if ((input.calendar ?? DefaultCalendars.TPS) !== DefaultCalendars.TPS) {
3089
+ return null;
3090
+ }
3091
+ return normalizeTpsComponents({
3092
+ ...input,
3093
+ calendar: DefaultCalendars.TPS,
3094
+ });
3095
+ }
3096
+ static renderTpsLikeInput(originalInput, comp, opts) {
3097
+ const sanitized = this.sanitizeTimeInput(originalInput);
3098
+ return sanitized.startsWith("tps://")
3099
+ ? this.toURI(comp, opts)
3100
+ : this.buildTimeString(comp, opts);
3101
+ }
3102
+ /**
3103
+ * SERIALIZER: Converts a components object into a full TPS URI.
3104
+ * @param comp - The TPS components.
3105
+ * @returns Full URI string (e.g. "tps://...").
3106
+ */
3107
+ static toURI(comp, opts) {
3108
+ // ── 1. Location layers (v0.6.0) ──────────────────────────────────────────
3109
+ // Build an ordered list of location layer strings, then join with ";"
3110
+ const layers = [];
3111
+ // Privacy shorthand takes priority
3112
+ if (comp.isHiddenLocation) {
3113
+ layers.push("L:~");
3114
+ }
3115
+ else if (comp.isRedactedLocation) {
3116
+ layers.push("L:redacted");
3117
+ }
3118
+ else if (comp.isUnknownLocation) {
3119
+ layers.push("L:-");
3120
+ }
3121
+ else if (comp.spaceAnchor) {
3122
+ // Generic / legacy anchor (adm:, planet:, etc.)
3123
+ layers.push(comp.spaceAnchor);
3124
+ }
3125
+ else if (comp.ipv4) {
3126
+ layers.push(`net:ip4:${comp.ipv4}`);
3127
+ }
3128
+ else if (comp.ipv6) {
3129
+ layers.push(`net:ip6:${comp.ipv6}`);
3130
+ }
3131
+ else if (comp.nodeName) {
3132
+ layers.push(`node:${comp.nodeName}`);
3133
+ }
3134
+ else if (comp.s2Cell) {
3135
+ layers.push(`S2:${comp.s2Cell}`);
3136
+ }
3137
+ else if (comp.h3Cell) {
3138
+ layers.push(`H3:${comp.h3Cell}`);
3139
+ }
3140
+ else if (comp.what3words) {
3141
+ layers.push(`3W:${comp.what3words}`);
3142
+ }
3143
+ else if (comp.plusCode) {
3144
+ layers.push(`plus:${comp.plusCode}`);
3145
+ }
3146
+ else if (comp.building) {
3147
+ layers.push(`bldg:${comp.building}`);
3148
+ if (comp.floor)
3149
+ layers.push(`floor:${comp.floor}`);
3150
+ if (comp.room)
3151
+ layers.push(`room:${comp.room}`);
3152
+ if (comp.door)
3153
+ layers.push(`door:${comp.door}`);
3154
+ if (comp.zone)
3155
+ layers.push(`zone:${comp.zone}`);
3156
+ }
3157
+ else if (comp.latitude !== undefined && comp.longitude !== undefined) {
3158
+ let gps = `L:${comp.latitude},${comp.longitude}`;
3159
+ if (comp.altitude !== undefined)
3160
+ gps += `,${comp.altitude}m`;
3161
+ layers.push(gps);
3162
+ }
3163
+ else {
3164
+ layers.push("L:-"); // unknown fallback
3165
+ }
3166
+ // Place layer (P:) — appended after primary location
3167
+ if (comp.placeCountryCode || comp.placeCountryName ||
3168
+ comp.placeCityCode || comp.placeCityName) {
3169
+ const pParts = [];
3170
+ if (comp.placeCountryCode)
3171
+ pParts.push(`cc=${comp.placeCountryCode}`);
3172
+ if (comp.placeCountryName)
3173
+ pParts.push(`cn=${comp.placeCountryName}`);
3174
+ if (comp.placeCityCode)
3175
+ pParts.push(`ci=${comp.placeCityCode}`);
3176
+ if (comp.placeCityName)
3177
+ pParts.push(`ct=${comp.placeCityName}`);
3178
+ layers.push(`P:${pParts.join(",")}`);
3179
+ }
3180
+ const locationStr = layers.join(";");
3181
+ // ── 2. Actor (/A:...) ─────────────────────────────────────────────────────
3182
+ const actorPart = comp.actor ? `/A:${comp.actor}` : "";
3183
+ // ── 3. Time (mandatory 9 tokens) ─────────────────────────────────────────
3184
+ const timePart = buildTimePart(comp, opts);
3185
+ // ── 4. Extensions (;KEY:val;...) ─────────────────────────────────────────
3186
+ let extPart = "";
3187
+ if (comp.extensions && Object.keys(comp.extensions).length > 0) {
3188
+ const extStrings = Object.entries(comp.extensions).map(([k, v]) => {
3189
+ // Emit as KEY:val (preferred v0.6.0 style)
3190
+ return `${k.toUpperCase()}:${v}`;
3191
+ });
3192
+ extPart = `;${extStrings.join(";")}`;
3193
+ }
3194
+ // ── 5. Context (#C:key=val;...) ──────────────────────────────────────────
3195
+ let contextPart = "";
3196
+ if (comp.context && Object.keys(comp.context).length > 0) {
3197
+ const ctxStrings = Object.entries(comp.context).map(([k, v]) => `${k}=${v}`);
3198
+ contextPart = `#C:${ctxStrings.join(";")}`;
3199
+ }
3200
+ return `tps://${locationStr}${actorPart}@${timePart}${extPart}${contextPart}`;
3201
+ }
3202
+ /**
3203
+ * CONVERTER: Creates a TPS Time Object string from a JavaScript Date.
3204
+ * Supports plugin drivers for non-Gregorian calendars.
3205
+ * @param date - The JS Date object (defaults to Now).
3206
+ * @param calendar - The target calendar driver (default `"tps"`).
3207
+ * @param opts - Optional parameters; for built-in calendars the only
3208
+ * supported key is `order` which may be `'ascending'` or `'descending'`.
3209
+ * @returns Canonical string (e.g., "T:tps.m3.c1.y26...").
3210
+ */
3211
+ static fromDate(date = new Date(), calendar = DefaultCalendars.TPS, opts) {
3212
+ const normalizedCalendar = calendar.toLowerCase();
3213
+ const driver = this.driverManager.get(normalizedCalendar);
3214
+ if (driver) {
3215
+ // when caller requested an explicit order we can bypass the driver's
3216
+ // `fromDate` helper and instead generate components ourselves so that
3217
+ // order is honoured even if the driver doesn't know about it. This
3218
+ // keeps behaviour identical to the old built-in implementation.
3219
+ if (opts?.order || opts?.timeMode || opts?.indexedPrecision !== undefined) {
3220
+ const comp = driver.getComponentsFromDate(date);
3221
+ comp.calendar = normalizedCalendar;
3222
+ if (opts?.order)
3223
+ comp.order = opts.order;
3224
+ return buildTimePart(comp, opts);
3225
+ }
3226
+ return driver.getFromDate(date);
3227
+ }
3228
+ // Fallback for old built-in calendars (shouldn't happen once drivers are
3229
+ // registered, but kept for backwards compatibility).
3230
+ const comp = { calendar: normalizedCalendar };
3231
+ if (normalizedCalendar === DefaultCalendars.UNIX) {
3232
+ const s = (date.getTime() / 1000).toFixed(3);
3233
+ comp.unixSeconds = parseFloat(s);
3234
+ if (opts?.order)
3235
+ comp.order = opts.order;
3236
+ return buildTimePart(comp, opts);
3237
+ }
3238
+ if (normalizedCalendar === DefaultCalendars.GREG) {
3239
+ const fullYear = date.getUTCFullYear();
3240
+ comp.millennium = Math.floor(fullYear / 1000) + 1;
3241
+ comp.century = Math.floor((fullYear % 1000) / 100) + 1;
3242
+ comp.year = fullYear % 100;
3243
+ comp.month = date.getUTCMonth() + 1;
3244
+ comp.day = date.getUTCDate();
3245
+ comp.hour = date.getUTCHours();
3246
+ comp.minute = date.getUTCMinutes();
3247
+ comp.second = date.getUTCSeconds();
3248
+ comp.millisecond = date.getUTCMilliseconds();
3249
+ if (opts?.order)
3250
+ comp.order = opts.order;
3251
+ return buildTimePart(comp, opts);
3252
+ }
3253
+ throw new Error(`Calendar driver '${normalizedCalendar}' not implemented. Register a driver.`);
3254
+ }
3255
+ /**
3256
+ * CONVERTER: Converts a TPS string to a Date in a target calendar format.
3257
+ * Uses plugin drivers for cross-calendar conversion.
3258
+ * @param tpsString - The source TPS string (any calendar).
3259
+ * @param targetCalendar - The target calendar code (e.g., 'hij').
3260
+ * @returns A TPS string in the target calendar, or null if invalid.
3261
+ */
3262
+ static to(targetCalendar, tpsString) {
3263
+ // 1. Parse to components and convert to Gregorian Date
3264
+ const gregDate = this.toDate(tpsString);
3265
+ if (!gregDate)
3266
+ return null;
3267
+ // 2. Convert Gregorian to target calendar using driver
3268
+ return this.fromDate(gregDate, targetCalendar);
3269
+ }
3270
+ /**
3271
+ * CONVERTER: Reconstructs a JavaScript Date object from a TPS string.
3272
+ * Supports plugin drivers for non-Gregorian calendars.
3273
+ * @param tpsString - The TPS string.
3274
+ * @returns JS Date object or `null` if invalid.
3275
+ */
3276
+ static toDate(tpsString) {
3277
+ const parsed = this.parse(tpsString);
3278
+ if (!parsed)
3279
+ return null;
3280
+ const cal = parsed.calendar || DefaultCalendars.TPS;
3281
+ const driver = this.driverManager.get(cal);
3282
+ if (!driver) {
3283
+ console.error(`Calendar driver '${cal}' not registered.`);
3284
+ return null;
3285
+ }
3286
+ const date = driver.getDateFromComponents(parsed);
3287
+ // If the URI has a ;tz= extension, the calendar date was expressed in local
3288
+ // time. Convert from local → UTC using the timezone utility.
3289
+ const tz = parsed.extensions?.["tz"];
3290
+ if (tz && date) {
3291
+ const localMs = date.getTime();
3292
+ const utcMs = localToUtc(localMs, tz);
3293
+ return new Date(utcMs);
3294
+ }
3295
+ return date;
3296
+ }
3297
+ static toDayIndex(input) {
3298
+ const comp = this.toTpsNativeComponents(input);
3299
+ return comp ? getTpsDayIndex(comp) : null;
3300
+ }
3301
+ static fromDayIndex(dayIndex, dayFraction = 0, opts) {
3302
+ if (!Number.isSafeInteger(dayIndex) || dayIndex < 0) {
3303
+ throw new Error("TPS.fromDayIndex: dayIndex must be a non-negative integer");
3304
+ }
3305
+ if (!Number.isFinite(dayFraction) || dayFraction < 0 || dayFraction >= 1) {
3306
+ throw new Error("TPS.fromDayIndex: dayFraction must be in [0, 1)");
3307
+ }
3308
+ const comp = buildTpsComponentsFromDayIndex(dayIndex, dayFraction);
3309
+ if (opts?.order)
3310
+ comp.order = opts.order;
3311
+ return buildTimePart(comp, opts);
3312
+ }
3313
+ static getDayFraction(input) {
3314
+ const comp = this.toTpsNativeComponents(input);
3315
+ return comp ? getTpsDayFraction(comp) : null;
3316
+ }
3317
+ static getSubDayMilliseconds(input) {
3318
+ const comp = this.toTpsNativeComponents(input);
3319
+ return comp ? getTpsSubDayMilliseconds(comp) : null;
3320
+ }
3321
+ static expandIndexedTime(input) {
3322
+ const sanitized = this.sanitizeTimeInput(input);
3323
+ const indexedMatch = sanitized.match(/(?:^T:|@T:)([a-z]{3,4})\.(i[^!;?#]+)/i);
3324
+ if (!indexedMatch ||
3325
+ indexedMatch[1].toLowerCase() !== DefaultCalendars.TPS ||
3326
+ !isTpsIndexedToken(indexedMatch[2])) {
3327
+ const parsed = this.parse(sanitized);
3328
+ if (!parsed || parsed.calendar !== DefaultCalendars.TPS)
3329
+ return null;
3330
+ return input.trim();
3331
+ }
3332
+ const comp = this.toTpsNativeComponents(sanitized);
3333
+ if (!comp)
3334
+ return null;
3335
+ return this.renderTpsLikeInput(sanitized, comp);
3336
+ }
3337
+ static expandIndex(input) {
3338
+ return this.expandIndexedTime(input);
3339
+ }
3340
+ static compactIndexedTime(input, opts) {
3341
+ const comp = this.toTpsNativeComponents(input);
3342
+ if (!comp)
3343
+ return null;
3344
+ return this.renderTpsLikeInput(input, comp, {
3345
+ timeMode: "indexed-fraction",
3346
+ indexedPrecision: opts?.precision,
3347
+ });
3348
+ }
3349
+ static compact(input, opts) {
3350
+ return this.compactIndexedTime(input, opts);
3351
+ }
3352
+ static toIndexedTime(input, opts) {
3353
+ const comp = this.toTpsNativeComponents(input);
3354
+ if (!comp)
3355
+ return null;
3356
+ return this.buildTimeString(comp, {
3357
+ timeMode: "indexed-fraction",
3358
+ indexedPrecision: opts?.precision,
3359
+ });
3360
+ }
3361
+ static toIndexedURI(input, opts) {
3362
+ const comp = this.toTpsNativeComponents(input);
3363
+ if (!comp)
3364
+ return null;
3365
+ return this.toURI(comp, {
3366
+ timeMode: "indexed-fraction",
3367
+ indexedPrecision: opts?.precision,
3368
+ });
3369
+ }
3370
+ // --- DRIVER CONVENIENCE METHODS ---
3371
+ /**
3372
+ * Parse a calendar-specific date string into TPS components.
3373
+ * Requires the driver to implement `parseDate`.
3374
+ *
3375
+ * @param calendar - The calendar code (e.g., 'hij')
3376
+ * @param dateString - Date string in calendar-native format (e.g., '1447-07-21')
3377
+ * @param format - Optional format string (driver-specific)
3378
+ * @returns TPS components or null if parsing fails
3379
+ *
3380
+ * @example
3381
+ * ```ts
3382
+ * const components = TPS.parseCalendarDate('hij', '1447-07-21');
3383
+ * // { calendar: 'hij', year: 1447, month: 7, day: 21 }
3384
+ *
3385
+ * const uri = TPS.toURI({ ...components, latitude: 31.95, longitude: 35.91 });
3386
+ * // "tps://31.95,35.91@T:hij.y1447.m07.d21"
3387
+ * ```
3388
+ */
3389
+ static parseCalendarDate(calendar, dateString, format) {
3390
+ const driver = this.driverManager.get(calendar);
3391
+ if (!driver) {
3392
+ throw new Error(`Calendar driver '${calendar}' not found. Register a driver first.`);
3393
+ }
3394
+ // parseDate is guaranteed by the interface, so we can call it directly.
3395
+ return driver.parseDate(dateString, format);
3396
+ }
3397
+ /**
3398
+ * Convert a calendar-specific date string directly to a TPS URI.
3399
+ * This is a convenience method that combines parseDate + toURI.
3400
+ *
3401
+ * @param calendar - The calendar code (e.g., 'hij')
3402
+ * @param dateString - Date string in calendar-native format
3403
+ * @param location - Optional location (lat/lon/alt or privacy flag)
3404
+ * @returns Full TPS URI string
3405
+ *
3406
+ * @example
3407
+ * ```ts
3408
+ * // With coordinates
3409
+ * TPS.fromCalendarDate('hij', '1447-07-21', { latitude: 31.95, longitude: 35.91 });
3410
+ * // "tps://31.95,35.91@T:hij.y1447.m07.d21"
3411
+ *
3412
+ * // With privacy flag
3413
+ * TPS.fromCalendarDate('hij', '1447-07-21', { isHiddenLocation: true });
3414
+ * // "tps://hidden@T:hij.y1447.m07.d21"
3415
+ *
3416
+ * // Without location
3417
+ * TPS.fromCalendarDate('hij', '1447-07-21');
3418
+ * // "tps://unknown@T:hij.y1447.m07.d21"
3419
+ * ```
3420
+ */
3421
+ static fromCalendarDate(calendar, dateString, location) {
3422
+ const components = this.parseCalendarDate(calendar, dateString);
3423
+ if (!components) {
3424
+ throw new Error(`Failed to parse date string: ${dateString}`);
3425
+ }
3426
+ // Merge with location
3427
+ const fullComponents = {
3428
+ calendar: calendar,
3429
+ ...components,
3430
+ ...location,
3431
+ };
3432
+ return this.toURI(fullComponents);
3433
+ }
3434
+ /**
3435
+ * Format TPS components to a calendar-specific date string.
3436
+ * Requires the driver to implement `format`.
3437
+ *
3438
+ * @param calendar - The calendar code
3439
+ * @param components - TPS components to format
3440
+ * @param format - Optional format string (driver-specific)
3441
+ * @returns Formatted date string in calendar-native format
3442
+ *
3443
+ * @example
3444
+ * ```ts
3445
+ * const tps = TPS.parse('tps://unknown@T:hij.y1447.m07.d21');
3446
+ * const formatted = TPS.formatCalendarDate('hij', tps);
3447
+ * // "1447-07-21"
3448
+ * ```
3449
+ */
3450
+ static formatCalendarDate(calendar, components, format) {
3451
+ const driver = this.driverManager.get(calendar);
3452
+ if (!driver) {
3453
+ throw new Error(`Calendar driver '${calendar}' not found.`);
3454
+ }
3455
+ // format is guaranteed by the interface, so we can call it directly.
3456
+ return driver.format(components, format);
3457
+ }
3458
+ // --- CONVENIENCE METHODS ---
3459
+ /**
3460
+ * Returns a TPS time string for the current moment.
3461
+ * Shorthand for `TPS.fromDate(new Date(), calendar, opts)`.
3462
+ *
3463
+ * @param calendar - Calendar code. Defaults to 'greg'.
3464
+ * @param opts - Optional `order` (ASC/DESC) parameter.
3465
+ * @returns TPS time string.
3466
+ *
3467
+ * @example
3468
+ * ```ts
3469
+ * TPS.now(); // "T:greg.m3.c1.y26.m3.d4.h06.m30.s00.m0"
3470
+ * TPS.now('hij'); // "T:hij.y1447.m09.d05.h06.m30.s00"
3471
+ * ```
3472
+ */
3473
+ static now(calendar = DefaultCalendars.GREG, opts) {
3474
+ return this.fromDate(new Date(), calendar, opts);
3475
+ }
3476
+ /**
3477
+ * Returns the difference in milliseconds between two TPS strings.
3478
+ * The result is `t2 - t1`; negative if t1 is after t2.
3479
+ *
3480
+ * @param t1 - First TPS string (subtracted from t2).
3481
+ * @param t2 - Second TPS string.
3482
+ * @returns Milliseconds between the two moments, or NaN on parse failure.
3483
+ *
3484
+ * @example
3485
+ * ```ts
3486
+ * const ms = TPS.diff('T:greg.m3.c1.y26.m1.d1.h0.m0.s0.m0',
3487
+ * 'T:greg.m3.c1.y26.m1.d2.h0.m0.s0.m0');
3488
+ * // 86_400_000 (one day)
3489
+ * ```
3490
+ */
3491
+ static diff(t1, t2) {
3492
+ const d1 = this.toDate(t1);
3493
+ const d2 = this.toDate(t2);
3494
+ if (!d1 || !d2)
3495
+ return NaN;
3496
+ return d2.getTime() - d1.getTime();
3497
+ }
3498
+ /**
3499
+ * Returns a new TPS string shifted by the given duration.
3500
+ * The result is in the same calendar as the original string.
3501
+ *
3502
+ * @param tpsStr - Source TPS string.
3503
+ * @param duration - Object with optional `days`, `hours`, `minutes`, `seconds`, `milliseconds`.
3504
+ * @returns Shifted TPS string, or null if the input is invalid.
3505
+ *
3506
+ * @example
3507
+ * ```ts
3508
+ * const t = 'T:greg.m3.c1.y26.m1.d9.h14.m30.s25.m0';
3509
+ * TPS.add(t, { days: 7 }); // one week later
3510
+ * TPS.add(t, { hours: -2 }); // two hours earlier
3511
+ * ```
3512
+ */
3513
+ static add(tpsStr, duration) {
3514
+ const date = this.toDate(tpsStr);
3515
+ if (!date)
3516
+ return null;
3517
+ const parsed = this.parse(tpsStr);
3518
+ const calendar = parsed?.calendar ?? DefaultCalendars.GREG;
3519
+ const order = parsed?.order;
3520
+ const deltaMs = (duration.days ?? 0) * 86400000 +
3521
+ (duration.hours ?? 0) * 3600000 +
3522
+ (duration.minutes ?? 0) * 60000 +
3523
+ (duration.seconds ?? 0) * 1000 +
3524
+ (duration.milliseconds ?? 0);
3525
+ const shifted = new Date(date.getTime() + deltaMs);
3526
+ return this.fromDate(shifted, calendar, order ? { order } : undefined);
3527
+ }
3528
+ // --- INTERNAL HELPERS ---
3529
+ static _mapGroupsToComponents(g) {
3530
+ const components = {};
3531
+ components.calendar = g.calendar;
3532
+ // ── Signature ────────────────────────────────────────────────────────────
3533
+ if (g.signature) {
3534
+ components.signature = g.signature;
3535
+ }
3536
+ // ── Actor (/A:...) ────────────────────────────────────────────────────────
3537
+ if (g.actor) {
3538
+ components.actor = g.actor.trim();
3539
+ }
3540
+ // ── Location layers (v0.6.0: multi-layer, ;-separated) ───────────────────
3541
+ if (g.location) {
3542
+ this._parseLocationLayers(g.location, components);
3543
+ }
3544
+ // ── Extensions (;KEY:val or ;key=val after T: tokens) ────────────────────
3545
+ if (g.extensions) {
3546
+ const extObj = {};
3547
+ g.extensions.split(";").forEach((part) => {
3548
+ part = part.trim();
3549
+ if (!part)
3550
+ return;
3551
+ const colonIdx = part.indexOf(":");
3552
+ const eqIdx = part.indexOf("=");
3553
+ if (colonIdx > 0 && (eqIdx < 0 || colonIdx < eqIdx)) {
3554
+ // KEY:val form (e.g. TZ:+03:00)
3555
+ const key = part.substring(0, colonIdx).toLowerCase();
3556
+ const val = part.substring(colonIdx + 1);
3557
+ if (key && val !== undefined)
3558
+ extObj[key] = val;
3559
+ }
3560
+ else if (eqIdx > 0) {
3561
+ // key=val form (e.g. tz=+03:00)
3562
+ const key = part.substring(0, eqIdx).toLowerCase();
3563
+ const val = part.substring(eqIdx + 1);
3564
+ if (key && val !== undefined)
3565
+ extObj[key] = val;
3566
+ }
3567
+ });
3568
+ if (Object.keys(extObj).length > 0)
3569
+ components.extensions = extObj;
3570
+ }
3571
+ // ── Context (#C:key=val;key=val) ─────────────────────────────────────────
3572
+ if (g.context) {
3573
+ const ctx = {};
3574
+ g.context.split(";").forEach((part) => {
3575
+ part = part.trim();
3576
+ if (!part)
3577
+ return;
3578
+ const eqIdx = part.indexOf("=");
3579
+ if (eqIdx > 0) {
3580
+ ctx[part.substring(0, eqIdx)] = part.substring(eqIdx + 1);
3581
+ }
3582
+ });
3583
+ if (Object.keys(ctx).length > 0)
3584
+ components.context = ctx;
3585
+ }
3586
+ return components;
3587
+ }
3588
+ /**
3589
+ * Parses a multi-layer location string (before @T:) into component fields.
3590
+ * Layers are `;`-separated. Each layer is identified by its prefix token.
3591
+ *
3592
+ * Supported layers:
3593
+ * L:lat,lon[,altm] — GPS
3594
+ * L:~|L:-|L:redacted — Privacy markers
3595
+ * P:cc=JO,ci=AMM,... — Place (country/city codes and names)
3596
+ * S2:token — S2 cell
3597
+ * H3:token — H3 cell
3598
+ * 3W:word.word.word — What3Words
3599
+ * plus:token — Plus Code
3600
+ * net:ip4:x.x.x.x — IPv4
3601
+ * net:ip6:x::x — IPv6
3602
+ * node:name — Logical node/host
3603
+ * bldg:name — Building
3604
+ * floor:x — Floor
3605
+ * room:x — Room
3606
+ * door:x — Door
3607
+ * zone:x — Zone
3608
+ */
3609
+ static _parseLocationLayers(location, components) {
3610
+ const layers = location.trim().split(";");
3611
+ for (const layer of layers) {
3612
+ const l = layer.trim();
3613
+ if (!l)
3614
+ continue;
3615
+ // Privacy shorthand
3616
+ if (l === "L:~" || l === "L:hidden") {
3617
+ components.isHiddenLocation = true;
3618
+ continue;
3619
+ }
3620
+ if (l === "L:-" || l === "L:unknown") {
3621
+ components.isUnknownLocation = true;
3622
+ continue;
3623
+ }
3624
+ if (l === "L:redacted") {
3625
+ components.isRedactedLocation = true;
3626
+ continue;
3627
+ }
3628
+ // P: Place layer — P:cc=JO,ci=AMM,cn=Jordan,ct=Amman
3629
+ if (l.startsWith("P:")) {
3630
+ l.slice(2).split(",").forEach((pair) => {
3631
+ const eq = pair.indexOf("=");
3632
+ if (eq < 1)
3633
+ return;
3634
+ const k = pair.substring(0, eq).toLowerCase();
3635
+ const v = pair.substring(eq + 1);
3636
+ if (k === "cc")
3637
+ components.placeCountryCode = v;
3638
+ else if (k === "cn")
3639
+ components.placeCountryName = v;
3640
+ else if (k === "ci")
3641
+ components.placeCityCode = v;
3642
+ else if (k === "ct")
3643
+ components.placeCityName = v;
3644
+ });
3645
+ continue;
3646
+ }
3647
+ // GPS coordinates (L:lat,lon[,alt])
3648
+ if (l.startsWith("L:")) {
3649
+ const coords = l.slice(2);
3650
+ const m = coords.match(/^(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)(?:,(-?\d+(?:\.\d+)?)m?)?$/);
3651
+ if (m) {
3652
+ components.latitude = parseFloat(m[1]);
3653
+ components.longitude = parseFloat(m[2]);
3654
+ if (m[3])
3655
+ components.altitude = parseFloat(m[3]);
3656
+ }
3657
+ continue;
3658
+ }
3659
+ // Geospatial cells
3660
+ if (/^S2:/i.test(l)) {
3661
+ components.s2Cell = l.slice(3);
3662
+ continue;
3663
+ }
3664
+ if (/^H3:/i.test(l)) {
3665
+ components.h3Cell = l.slice(3);
3666
+ continue;
3667
+ }
3668
+ if (/^3W:/i.test(l)) {
3669
+ components.what3words = l.slice(3);
3670
+ continue;
3671
+ }
3672
+ if (/^plus:/i.test(l)) {
3673
+ components.plusCode = l.slice(5);
3674
+ continue;
3675
+ }
3676
+ // Network
3677
+ if (/^net:ip4:/i.test(l)) {
3678
+ components.ipv4 = l.slice(8);
3679
+ continue;
3680
+ }
3681
+ if (/^net:ip6:/i.test(l)) {
3682
+ components.ipv6 = l.slice(8);
3683
+ continue;
3684
+ }
3685
+ if (/^node:/i.test(l)) {
3686
+ components.nodeName = l.slice(5);
3687
+ continue;
3688
+ }
3689
+ // Structural
3690
+ if (/^bldg:/i.test(l)) {
3691
+ components.building = l.slice(5);
3692
+ continue;
3693
+ }
3694
+ if (/^floor:/i.test(l)) {
3695
+ components.floor = l.slice(6);
3696
+ continue;
3697
+ }
3698
+ if (/^room:/i.test(l)) {
3699
+ components.room = l.slice(5);
3700
+ continue;
3701
+ }
3702
+ if (/^door:/i.test(l)) {
3703
+ components.door = l.slice(5);
3704
+ continue;
3705
+ }
3706
+ if (/^zone:/i.test(l)) {
3707
+ components.zone = l.slice(5);
3708
+ continue;
3709
+ }
3710
+ // Fallback: generic space anchor (adm:, planet:, legacy strings)
3711
+ if (l) {
3712
+ components.spaceAnchor = components.spaceAnchor
3713
+ ? components.spaceAnchor + ";" + l
3714
+ : l;
3715
+ }
3716
+ }
3717
+ }
3718
+ }
3719
+ // --- PLUGIN REGISTRY ---
3720
+ /** Shared DriverManager instance — use TPS.driverManager for direct access. */
3721
+ TPS.driverManager = new DriverManager();
3722
+ // --- REGEX (v0.6.0) ---
3723
+ // The URI and time regexes are intentionally permissive in the location &
3724
+ // extension sections — detailed semantic parsing happens in
3725
+ // _mapGroupsToComponents() and the layer parsers below.
3726
+ //
3727
+ // Structure:
3728
+ // tps://[location]/A:[actor]@T:[cal].[tokens];[ext];...#C:[ctx];...
3729
+ //
3730
+ // The `;` separator is used consistently:
3731
+ // - between location layers (before @T:)
3732
+ // - between extensions (after T: tokens, before #)
3733
+ // - between context key=val pairs (after #C:)
3734
+ TPS.REGEX_URI = new RegExp("^tps://" +
3735
+ // Location: everything up to optional /A: actor and then @T:
3736
+ "(?<location>[^@]+?)" +
3737
+ // Optional actor overlay
3738
+ "(?:/A:(?<actor>[^@]+))?" +
3739
+ // Time section
3740
+ "@T:(?<calendar>[a-z]{3,4})" +
3741
+ "(?<tokens>(?:\\.[a-z]-?[\\d.]+)*)" +
3742
+ // Optional signature
3743
+ "(?:!(?<signature>[^;#]+))?" +
3744
+ // Optional extensions (;KEY:val;key=val;...)
3745
+ "(?:;(?<extensions>[^#]+))?" +
3746
+ // Optional context fragment (#C:key=val;...)
3747
+ "(?:#C:(?<context>.+))?$");
3748
+ TPS.REGEX_TIME = new RegExp("^T:(?<calendar>[a-z]{3,4})" +
3749
+ "(?<tokens>(?:\\.[a-z]-?[\\d.]+)*)" +
3750
+ "(?:!(?<signature>[^;#]+))?" +
3751
+ "(?:;(?<extensions>[^#]+))?" +
3752
+ "(?:#C:(?<context>.+))?$");
3753
+ // register built-in drivers and set default
3754
+ // (tps and gregorian provide canonical conversions before unix)
3755
+ TPS.registerDriver(new TpsDriver());
3756
+ TPS.registerDriver(new GregorianDriver());
3757
+ TPS.registerDriver(new UnixDriver());
3758
+ TPS.registerDriver(new PersianDriver());
3759
+ TPS.registerDriver(new HijriDriver());
3760
+ TPS.registerDriver(new JulianDriver());
3761
+ TPS.registerDriver(new HoloceneDriver());
3762
+ TPS.registerDriver(new ChineseDriver());
3763
+ /**
3764
+ * `TpsDate` is a Date-like wrapper with native TPS conversion helpers.
3765
+ *
3766
+ * It mirrors common JavaScript `Date` construction patterns:
3767
+ * - `new TpsDate()`
3768
+ * - `new TpsDate(ms)`
3769
+ * - `new TpsDate(isoString)`
3770
+ * - `new TpsDate(tpsString)`
3771
+ * - `new TpsDate(year, monthIndex, day?, hour?, minute?, second?, ms?)`
3772
+ */
3773
+
3774
+ exports.DefaultCalendars = DefaultCalendars;
3775
+ exports.DriverManager = DriverManager;
3776
+ exports.Env = Env;
3777
+ exports.TPS = TPS;
3778
+ exports.TPSUID7RB = TPSUID7RB;
3779
+ exports.TpsDate = TpsDate;
3780
+ exports.getOffsetString = getOffsetString;
3781
+ exports.localToUtc = localToUtc;
3782
+ exports.utcToLocal = utcToLocal;
3783
+
3784
+ return exports;
3785
+
3786
+ })({});