@nextera.one/tps-standard 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -13,19 +13,54 @@
13
13
  * - Added geospatial cell systems (S2, H3, Plus Code, what3words)
14
14
  */
15
15
 
16
- export type CalendarCode = 'greg' | 'hij' | 'jul' | 'holo' | 'unix';
16
+ // built-in drivers are registered automatically; importing them here
17
+ // ensures they are included when the library bundler/tree-shaker runs.
18
+ import { GregorianDriver } from "./drivers/gregorian";
19
+ import { UnixDriver } from "./drivers/unix";
20
+ import { TpsDriver } from "./drivers/tps";
21
+
22
+ // Calendar codes are plain strings to allow arbitrary user-defined
23
+ // calendars. The library still exports constants for the built-in values but
24
+ // callers may also supply their own codes.
25
+ export const DefaultCalendars = {
26
+ TPS: "tps",
27
+ GREG: "greg",
28
+ HIJ: "hij",
29
+ JUL: "jul",
30
+ HOLO: "holo",
31
+ UNIX: "unix",
32
+ } as const;
33
+
34
+ /**
35
+ * Specifies the direction of the time-component hierarchy when serializing or
36
+ * deserializing a TPS string. The default is `'descending'` (millennium → … →
37
+ * second), but `'ascending'` produces the reverse order.
38
+ */
39
+ export enum TimeOrder {
40
+ DESC = "desc",
41
+ ASC = "asc",
42
+ }
17
43
 
18
44
  export interface TPSComponents {
19
45
  // --- TEMPORAL ---
20
- calendar: CalendarCode;
21
- millennium?: number;
22
- century?: number;
23
- year?: number;
24
- month?: number;
25
- day?: number;
26
- hour?: number;
27
- minute?: number;
28
- second?: number;
46
+ calendar: string;
47
+ // --- REQUIRED TEMPORAL FIELDS ---
48
+ // All of the traditional Gregorian components are now mandatory. This
49
+ // reflects the fact that a valid TPS time object must contain a complete
50
+ // timestamp when using the canonical calendar formats.
51
+ millennium: number;
52
+ century: number;
53
+ year: number;
54
+ month: number;
55
+ day: number;
56
+ hour: number;
57
+ minute: number;
58
+ second: number;
59
+ /** Sub-second precision (0–999). Encoded as the last `m` token. */
60
+ millisecond: number;
61
+ // --- OPTIONAL UNIX BACKUP ---
62
+ // `unixSeconds` remains optional to support the Unix driver and other
63
+ // cases where a simple epoch value is preferred.
29
64
  unixSeconds?: number;
30
65
 
31
66
  // --- SPATIAL: GPS Coordinates ---
@@ -53,6 +88,9 @@ export interface TPSComponents {
53
88
  /** Logical area within building */
54
89
  zone?: string;
55
90
 
91
+ /** Raw pre-@ space anchor (e.g. adm:city:SA:riyadh, node:api-1, net:ip4:203.0.113.10) */
92
+ spaceAnchor?: string;
93
+
56
94
  // --- SPATIAL: Privacy Markers ---
57
95
  /** Technical missing data (e.g. server log without GPS) */
58
96
  isUnknownLocation?: boolean;
@@ -69,6 +107,8 @@ export interface TPSComponents {
69
107
 
70
108
  // --- CONTEXT ---
71
109
  extensions?: Record<string, string>;
110
+
111
+ order?: TimeOrder;
72
112
  }
73
113
 
74
114
  // --- PLUGIN ARCHITECTURE ---
@@ -124,7 +164,7 @@ export interface TPSComponents {
124
164
  */
125
165
  export interface CalendarDriver {
126
166
  /** The calendar code this driver handles (e.g., 'hij', 'jul'). */
127
- readonly code: CalendarCode;
167
+ readonly code: string;
128
168
 
129
169
  /**
130
170
  * Human-readable name for this calendar (optional).
@@ -133,25 +173,25 @@ export interface CalendarDriver {
133
173
  readonly name?: string;
134
174
 
135
175
  /**
136
- * Converts a Gregorian Date to this calendar's components.
176
+ * Converts a Date to this calendar's components.
137
177
  * @param date - The Gregorian Date object.
138
178
  * @returns Partial TPS components for year, month, day, etc.
139
179
  */
140
- fromGregorian(date: Date): Partial<TPSComponents>;
180
+ getComponentsFromDate(date: Date): Partial<TPSComponents>;
141
181
 
142
182
  /**
143
- * Converts this calendar's components to a Gregorian Date.
183
+ * Converts this calendar's components to a Date.
144
184
  * @param components - Partial TPS components (year, month, day, etc.).
145
185
  * @returns A JavaScript Date object.
146
186
  */
147
- toGregorian(components: Partial<TPSComponents>): Date;
187
+ getDateFromComponents(components: Partial<TPSComponents>): Date;
148
188
 
149
189
  /**
150
190
  * Generates a TPS time string for this calendar from a Date.
151
191
  * @param date - The Gregorian Date object.
152
- * @returns A TPS time string (e.g., "T:hij.y1447.M07.d21...").
192
+ * @returns A TPS time string (e.g., "T:hij.y1447.m07.d21...").
153
193
  */
154
- fromDate(date: Date): string;
194
+ getFromDate(date: Date): string;
155
195
 
156
196
  // --- NEW ENHANCED METHODS (Optional) ---
157
197
 
@@ -172,7 +212,7 @@ export interface CalendarDriver {
172
212
  * driver.parseDate('1447-07-21 14:30:00'); // → { year: 1447, month: 7, day: 21, hour: 14, ... }
173
213
  * ```
174
214
  */
175
- parseDate?(input: string, format?: string): Partial<TPSComponents>;
215
+ parseDate(input: string, format?: string): Partial<TPSComponents>;
176
216
 
177
217
  /**
178
218
  * Format TPS components to a calendar-specific date string.
@@ -188,7 +228,7 @@ export interface CalendarDriver {
188
228
  * driver.format({ year: 1447, month: 7, day: 21 }, 'short'); // → '21/7/1447'
189
229
  * ```
190
230
  */
191
- format?(components: Partial<TPSComponents>, format?: string): string;
231
+ format(components: Partial<TPSComponents>, format?: string): string;
192
232
 
193
233
  /**
194
234
  * Validate a calendar-specific date string or components.
@@ -202,7 +242,7 @@ export interface CalendarDriver {
202
242
  * driver.validate({ year: 1447, month: 7, day: 31 }); // → false (Rajab has 30 days)
203
243
  * ```
204
244
  */
205
- validate?(input: string | Partial<TPSComponents>): boolean;
245
+ validate(input: string | Partial<TPSComponents>): boolean;
206
246
 
207
247
  /**
208
248
  * Get calendar metadata (month names, day names, etc.).
@@ -214,7 +254,7 @@ export interface CalendarDriver {
214
254
  * // → ['Muharram', 'Safar', 'Rabi I', ...]
215
255
  * ```
216
256
  */
217
- getMetadata?(): CalendarMetadata;
257
+ getMetadata(): CalendarMetadata;
218
258
  }
219
259
 
220
260
  /**
@@ -241,8 +281,7 @@ export interface CalendarMetadata {
241
281
 
242
282
  export class TPS {
243
283
  // --- PLUGIN REGISTRY ---
244
- private static readonly drivers: Map<CalendarCode, CalendarDriver> =
245
- new Map();
284
+ private static readonly drivers: Map<string, CalendarDriver> = new Map();
246
285
 
247
286
  /**
248
287
  * Registers a calendar driver plugin.
@@ -257,63 +296,238 @@ export class TPS {
257
296
  * @param code - The calendar code.
258
297
  * @returns The driver or undefined.
259
298
  */
260
- static getDriver(code: CalendarCode): CalendarDriver | undefined {
299
+ static getDriver(code: string): CalendarDriver | undefined {
261
300
  return this.drivers.get(code);
262
301
  }
263
302
 
264
303
  // --- REGEX ---
265
304
  // Updated for v0.5.0: supports L: anchors, A: actor, ! signature, structural & geospatial anchors
266
- // Note: Complex regex - carefully balanced parentheses
305
+ // Tokens may appear in any order; actual semantic parsing happens in
306
+ // `parseTimeString()` so these patterns are intentionally permissive.
307
+ // regex simply ensures prefix, space, calendar, and token characters;
308
+ // token order is not enforced (parseTimeString handles semantics).
267
309
  private static readonly REGEX_URI = new RegExp(
268
- '^tps://' +
269
- // Location part (L: prefix optional for backward compat)
270
- '(?:L:)?(?<space>' +
271
- '~|-|unknown|redacted|hidden|' + // Privacy markers
272
- 's2=(?<s2>[a-fA-F0-9]+)|' + // S2 cell
273
- 'h3=(?<h3>[a-fA-F0-9]+)|' + // H3 cell
274
- 'plus=(?<plus>[A-Z0-9+]+)|' + // Plus Code
275
- 'w3w=(?<w3w>[a-z]+\\.[a-z]+\\.[a-z]+)|' + // what3words
276
- 'bldg=(?<bldg>[\\w-]+)(?:\\.floor=(?<floor>[\\w-]+))?(?:\\.room=(?<room>[\\w-]+))?(?:\\.zone=(?<zone>[\\w-]+))?|' + // Structural
277
- '(?<lat>-?\\d+(?:\\.\\d+)?),(?<lon>-?\\d+(?:\\.\\d+)?)(?:,(?<alt>-?\\d+(?:\\.\\d+)?)m?)?' + // GPS
278
- ')' +
279
- // Optional Actor anchor
280
- '(?:/A:(?<actor>[^/@]+))?' +
281
- // Time part separator
282
- '[/@]T:(?<calendar>[a-z]{3,4})\\.' +
283
- // Time components
284
- '(?:(?<unix>s\\d+(?:\\.\\d+)?)|m(?<millennium>-?\\d+)(?:\\.c(?<century>\\d+)(?:\\.y(?<year>\\d+)(?:\\.M(?<month>\\d{1,2})(?:\\.d(?<day>\\d{1,2})(?:\\.h(?<hour>\\d{1,2})(?:\\.n(?<minute>\\d{1,2})(?:\\.s(?<second>\\d{1,2}(?:\\.\\d+)?))?)?)?)?)?)?)?)' +
285
- // Optional signature
286
- '(?:!(?<signature>[^;?#]+))?' +
287
- // Optional extensions
288
- '(?:;(?<extensions>[a-z0-9.\\-_=]+))?' +
289
- // Optional query params
290
- '(?:\\?(?<params>[^#]+))?' +
291
- // Optional context
292
- '(?:#(?<context>.+))?$',
310
+ "^tps://" +
311
+ // Location part (preserve named captures for space subfields)
312
+ "(?:L:)?(?<space>" +
313
+ "~|-|unknown|redacted|hidden|" +
314
+ "s2=(?<s2>[a-fA-F0-9]+)|" +
315
+ "h3=(?<h3>[a-fA-F0-9]+)|" +
316
+ "plus=(?<plus>[A-Z0-9+]+)|" +
317
+ "w3w=(?<w3w>[a-z]+\\.[a-z]+\\.[a-z]+)|" +
318
+ "bldg=(?<bldg>[\\w-]+)(?:\\.floor=(?<floor>[\\w-]+))?(?:\\.room=(?<room>[\\w-]+))?(?:\\.zone=(?<zone>[\\w-]+))?|" +
319
+ "(?<lat>-?\\d+(?:\\.\\d+)?),(?<lon>-?\\d+(?:\\.\\d+)?)(?:,(?<alt>-?\\d+(?:\\.\\d+)?)m?)?|" +
320
+ "(?<generic>[^@/?#]+)" +
321
+ ")" +
322
+ "(?:/A:(?<actor>[^/@]+))?" +
323
+ "@T:(?<calendar>[a-z]{3,4})" +
324
+ "(?:\\.(?:m-?\\d+|c\\d+|y\\d+|d\\d{1,2}|h\\d{1,2}|s\\d+(?:\\.\\d+)?))*" +
325
+ "(?:![^;?#]+)?" +
326
+ "(?:;(?<extensions>[^?#]+))?" +
327
+ "(?:\\?[^#]+)?" +
328
+ "(?:#.+)?$",
293
329
  );
294
330
 
295
331
  private static readonly REGEX_TIME = new RegExp(
296
- '^T:(?<calendar>[a-z]{3,4})\\.' +
297
- '(?:(?<unix>s\\d+(?:\\.\\d+)?)|m(?<millennium>-?\\d+)(?:\\.c(?<century>\\d+)(?:\\.y(?<year>\\d+)(?:\\.M(?<month>\\d{1,2})(?:\\.d(?<day>\\d{1,2})(?:\\.h(?<hour>\\d{1,2})(?:\\.n(?<minute>\\d{1,2})(?:\\.s(?<second>\\d{1,2}(?:\\.\\d+)?))?)?)?)?)?)?)?)' +
298
- '(?:!(?<signature>[^;?#]+))?$',
332
+ "^T:(?<calendar>[a-z]{3,4})" +
333
+ "(?:\\.(?:m-?\\d+|c\\d+|y\\d+|d\\d{1,2}|h\\d{1,2}|s\\d+(?:\\.\\d+)?))*" +
334
+ "(?:![^;?#]+)?$",
299
335
  );
300
336
 
301
337
  // --- CORE METHODS ---
302
338
 
339
+ /**
340
+ * SANITIZER: Normalises a raw TPS input string before validation.
341
+ *
342
+ * Pure string-based — no parsing into components, no regex beyond simple
343
+ * character checks, no re-serialisation via buildTimePart / toURI.
344
+ *
345
+ * Token ranks (descending): m(8) c(7) y(6) m(5) d(4) h(3) m(2) s(1) m(0)
346
+ */
347
+ static sanitizeTimeInput(input: string): string {
348
+ // ── 1. Whitespace ────────────────────────────────────────────────────────
349
+ let s = input.trim().replace(/\s+/g, "");
350
+ if (!s) return s;
351
+
352
+ // ── 1.5 Convert legacy "/T:" separators to the new canonical "@T:".
353
+ // The input may contain "/T:" from older versions; we normalise early so
354
+ // subsequent logic can assume only the '@' form.
355
+ if (s.includes("/T:")) {
356
+ s = s.replace(/\/T:/g, "@T:");
357
+ }
358
+
359
+ // ── 2. Scheme casing ─────────────────────────────────────────────────────
360
+ if (s.slice(0, 6).toLowerCase() === "tps://") {
361
+ s = "tps://" + s.slice(6);
362
+ }
363
+
364
+ // ── 3. T: prefix casing (time-only strings) ──────────────────────────────
365
+ if (!s.startsWith("tps://") && s.slice(0, 2).toLowerCase() === "t:") {
366
+ s = "T:" + s.slice(2);
367
+ }
368
+
369
+ // ── 4. Locate T: section ─────────────────────────────────────────────────
370
+ let tStart = -1;
371
+ if (s.startsWith("T:")) {
372
+ tStart = 0;
373
+ } else {
374
+ const atT = s.indexOf("@T:");
375
+ if (atT !== -1) tStart = atT + 1;
376
+ }
377
+ if (tStart === -1) return s; // no T: section — return as-is
378
+
379
+ const beforeT = s.slice(0, tStart); // URI prefix or empty
380
+ const timeAndRest = s.slice(tStart); // T:cal.tok... [!sig][;ext]
381
+
382
+ // Isolate token section from any trailing suffix (!sig / ;ext / ?q / #f)
383
+ const suffixIdx = timeAndRest.search(/[!;?#]/);
384
+ const timeSuffix = suffixIdx !== -1 ? timeAndRest.slice(suffixIdx) : "";
385
+ const timePart =
386
+ suffixIdx !== -1 ? timeAndRest.slice(0, suffixIdx) : timeAndRest;
387
+ // timePart = "T:greg.m3.c1.y26.m01.d07.h13.m20.s45"
388
+
389
+ // Split off calendar code
390
+ const afterColon = timePart.slice(timePart.indexOf(":") + 1); // "greg.m3.c1..."
391
+ const firstDot = afterColon.indexOf(".");
392
+ const cal = (
393
+ firstDot !== -1 ? afterColon.slice(0, firstDot) : afterColon
394
+ ).toLowerCase();
395
+ const tokenStr = firstDot !== -1 ? afterColon.slice(firstDot + 1) : "";
396
+
397
+ // If no calendar code was provided at all (e.g. "T:"), bail out early
398
+ // rather than inventing a default calendar. The string will remain
399
+ // unparsable so validation can report it as invalid.
400
+ if (!cal) {
401
+ return s;
402
+ }
403
+
404
+ // No tokens at all — fill every slot with 0 and return
405
+ // Use tps as the default calendar if none was specified
406
+ const resolvedCal = cal || DefaultCalendars.TPS;
407
+ if (!tokenStr) {
408
+ return `${beforeT}T:${resolvedCal}.m0.c0.y0.m0.d0.h0.m0.s0.m0${timeSuffix}`;
409
+ }
410
+
411
+ // ── 5. Tokenise ──────────────────────────────────────────────────────────
412
+ // Each raw token: first char = letter prefix, remainder = numeric value
413
+ type Tok = { p: string; v: string };
414
+ const tokens: Tok[] = tokenStr
415
+ .split(".")
416
+ .filter((t) => t.length >= 2 && /^[a-z]/.test(t))
417
+ .map((t) => ({ p: t[0], v: t.slice(1) }));
418
+
419
+ // ── 6. Detect order from non-m tokens (c=7, y=6, d=4, h=3, s=1) ─────────
420
+ const nonMRank: Record<string, number> = { c: 7, y: 6, d: 4, h: 3, s: 1 };
421
+ const nonMSeq = tokens
422
+ .filter((t) => t.p !== "m" && nonMRank[t.p] !== undefined)
423
+ .map((t) => nonMRank[t.p]);
424
+
425
+ let isAsc = false;
426
+ if (nonMSeq.length >= 2) {
427
+ // ascending when every consecutive rank-diff is positive
428
+ isAsc = nonMSeq.every((r, i) => i === 0 || r > nonMSeq[i - 1]);
429
+ }
430
+
431
+ // ── 7. Reverse tokens if ascending ───────────────────────────────────────
432
+ if (isAsc) tokens.reverse();
433
+
434
+ // ── 8. Disambiguate 'm' tokens by DESC position ──────────────────────────
435
+ // DESC slot order for m tokens: rank 8 (millennium), 5 (month), 2 (minute), 0 (ms)
436
+ const mDescRanks = [8, 5, 2, 0];
437
+ const byRank = new Map<number, string>();
438
+ let mIdx = 0;
439
+
440
+ for (const tok of tokens) {
441
+ if (tok.p === "m") {
442
+ if (mIdx < mDescRanks.length) byRank.set(mDescRanks[mIdx++], tok.v);
443
+ } else {
444
+ const r = nonMRank[tok.p];
445
+ if (r !== undefined) byRank.set(r, tok.v);
446
+ }
447
+ }
448
+
449
+ // ── 9. Build complete DESC token string, filling gaps with '0' ───────────
450
+ // Full DESC slot sequence: m(8) c(7) y(6) m(5) d(4) h(3) m(2) s(1) m(0)
451
+ const descSlots: Array<[string, number]> = [
452
+ ["m", 8],
453
+ ["c", 7],
454
+ ["y", 6],
455
+ ["m", 5],
456
+ ["d", 4],
457
+ ["h", 3],
458
+ ["m", 2],
459
+ ["s", 1],
460
+ ["m", 0],
461
+ ];
462
+
463
+ const finalTokenStr = descSlots
464
+ .map(([p, r]) => p + (byRank.get(r) ?? "0"))
465
+ .join(".");
466
+
467
+ return `${beforeT}T:${resolvedCal}.${finalTokenStr}${timeSuffix}`;
468
+ }
469
+
303
470
  static validate(input: string): boolean {
304
- if (input.startsWith('tps://')) return this.REGEX_URI.test(input);
305
- return this.REGEX_TIME.test(input);
471
+ const sanitized = this.sanitizeTimeInput(input);
472
+ if (sanitized.startsWith("tps://")) {
473
+ return this.REGEX_URI.test(sanitized);
474
+ }
475
+ return this.REGEX_TIME.test(sanitized);
306
476
  }
307
477
 
308
478
  static parse(input: string): TPSComponents | null {
309
- if (input.startsWith('tps://')) {
479
+ // Always sanitize first so we operate on the canonical form. This also
480
+ // rewrites any legacy "/T:" separators to "@T:" so the regex below can
481
+ // remain strict.
482
+ input = this.sanitizeTimeInput(input);
483
+
484
+ // quick fail via regex to rule out obviously bad strings
485
+ if (input.startsWith("tps://")) {
310
486
  const match = this.REGEX_URI.exec(input);
311
487
  if (!match || !match.groups) return null;
312
- return this._mapGroupsToComponents(match.groups);
488
+ const comp: any = this._mapGroupsToComponents(match.groups);
489
+ // extract the raw time portion and parse it separately
490
+ const atIdx = input.indexOf("@T:");
491
+ let timeStr = "";
492
+ let signature: string | undefined;
493
+ if (atIdx !== -1) {
494
+ timeStr = input.slice(atIdx + 1); // include the leading 'T:'
495
+ // if there's a signature, capture it first
496
+ const sigMatch = timeStr.match(/!(?<sig>[^;?#]+)/);
497
+ if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
498
+ signature = sigMatch.groups.sig;
499
+ }
500
+ // cut off signature, extensions, query, or fragment
501
+ timeStr = timeStr.split(/[!;?#]/)[0];
502
+ }
503
+ if (timeStr) {
504
+ const parsed = this.parseTimeString(timeStr);
505
+ if (!parsed) return null;
506
+ Object.assign(comp, parsed.components);
507
+ comp.order = parsed.order;
508
+ }
509
+ if (signature) {
510
+ comp.signature = signature;
511
+ }
512
+ return comp as TPSComponents;
313
513
  }
514
+ // time-only string
314
515
  const match = this.REGEX_TIME.exec(input);
315
516
  if (!match || !match.groups) return null;
316
- return this._mapGroupsToComponents(match.groups);
517
+ // isolate signature if present
518
+ let timeOnly = input;
519
+ let signature: string | undefined;
520
+ const sigMatch = input.match(/!(?<sig>[^;?#]+)/);
521
+ if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
522
+ signature = sigMatch.groups.sig;
523
+ timeOnly = input.split(/[!;?#]/)[0];
524
+ }
525
+ const parsed = this.parseTimeString(timeOnly);
526
+ if (!parsed) return null;
527
+ const comp = parsed.components as TPSComponents;
528
+ if (signature) comp.signature = signature;
529
+ comp.order = parsed.order;
530
+ return comp;
317
531
  }
318
532
 
319
533
  /**
@@ -323,14 +537,16 @@ export class TPS {
323
537
  */
324
538
  static toURI(comp: TPSComponents): string {
325
539
  // 1. Build Space Part (L: anchor)
326
- let spacePart = 'L:-'; // Default: unknown
540
+ let spacePart = "L:-"; // Default: unknown
327
541
 
328
- if (comp.isHiddenLocation) {
329
- spacePart = 'L:~';
542
+ if (comp.spaceAnchor) {
543
+ spacePart = comp.spaceAnchor;
544
+ } else if (comp.isHiddenLocation) {
545
+ spacePart = "L:~";
330
546
  } else if (comp.isRedactedLocation) {
331
- spacePart = 'L:redacted';
547
+ spacePart = "L:redacted";
332
548
  } else if (comp.isUnknownLocation) {
333
- spacePart = 'L:-';
549
+ spacePart = "L:-";
334
550
  } else if (comp.s2Cell) {
335
551
  spacePart = `L:s2=${comp.s2Cell}`;
336
552
  } else if (comp.h3Cell) {
@@ -352,85 +568,87 @@ export class TPS {
352
568
  }
353
569
 
354
570
  // 2. Build Actor Part (A: anchor) - optional
355
- let actorPart = '';
571
+ let actorPart = "";
356
572
  if (comp.actor) {
357
573
  actorPart = `/A:${comp.actor}`;
358
574
  }
359
575
 
360
- // 3. Build Time Part
361
- let timePart = `T:${comp.calendar}`;
362
-
363
- if (comp.calendar === 'unix' && comp.unixSeconds !== undefined) {
364
- timePart += `.s${comp.unixSeconds}`;
365
- } else {
366
- if (comp.millennium !== undefined) timePart += `.m${comp.millennium}`;
367
- if (comp.century !== undefined) timePart += `.c${comp.century}`;
368
- if (comp.year !== undefined) timePart += `.y${comp.year}`;
369
- if (comp.month !== undefined) timePart += `.M${this.pad(comp.month)}`;
370
- if (comp.day !== undefined) timePart += `.d${this.pad(comp.day)}`;
371
- if (comp.hour !== undefined) timePart += `.h${this.pad(comp.hour)}`;
372
- if (comp.minute !== undefined) timePart += `.n${this.pad(comp.minute)}`;
373
- if (comp.second !== undefined) timePart += `.s${this.pad(comp.second)}`;
374
- }
375
-
376
- // 4. Add Signature (!) - optional
377
- if (comp.signature) {
378
- timePart += `!${comp.signature}`;
379
- }
576
+ // 3. Build Time Part (handles order & signature)
577
+ const timePart = this.buildTimePart(comp);
380
578
 
381
579
  // 5. Build Extensions
382
- let extPart = '';
580
+ let extPart = "";
383
581
  if (comp.extensions && Object.keys(comp.extensions).length > 0) {
384
582
  const extStrings = Object.entries(comp.extensions).map(
385
583
  ([k, v]) => `${k}=${v}`,
386
584
  );
387
- extPart = `;${extStrings.join('.')}`;
585
+ extPart = `;${extStrings.join(".")}`;
388
586
  }
389
587
 
390
- return `tps://${spacePart}${actorPart}/${timePart}${extPart}`;
588
+ // timePart already begins with 'T:'. The new canonical separator is '@'
589
+ // instead of '/', so we interpolate it accordingly. Actor anchor (if
590
+ // present) still uses a leading slash.
591
+ return `tps://${spacePart}${actorPart}@${timePart}${extPart}`;
391
592
  }
392
593
 
393
594
  /**
394
595
  * CONVERTER: Creates a TPS Time Object string from a JavaScript Date.
395
596
  * Supports plugin drivers for non-Gregorian calendars.
396
597
  * @param date - The JS Date object (defaults to Now).
397
- * @param calendar - The target calendar driver (default 'greg').
398
- * @returns Canonical string (e.g., "T:greg.m3.c1.y26...").
598
+ * @param calendar - The target calendar driver (default `"tps"`).
599
+ * @param opts - Optional parameters; for built-in calendars the only
600
+ * supported key is `order` which may be `'ascending'` or `'descending'`.
601
+ * @returns Canonical string (e.g., "T:tps.m3.c1.y26...").
399
602
  */
400
603
  static fromDate(
401
604
  date: Date = new Date(),
402
- calendar: CalendarCode = 'greg',
605
+ calendar: string = DefaultCalendars.TPS,
606
+ opts?: { order?: TimeOrder },
403
607
  ): string {
404
- // Check for registered driver first
405
- const driver = this.drivers.get(calendar);
608
+ const normalizedCalendar = calendar.toLowerCase();
609
+ const driver = this.drivers.get(normalizedCalendar);
406
610
  if (driver) {
407
- return driver.fromDate(date);
611
+ // when caller requested an explicit order we can bypass the driver's
612
+ // `fromDate` helper and instead generate components ourselves so that
613
+ // order is honoured even if the driver doesn't know about it. This
614
+ // keeps behaviour identical to the old built-in implementation.
615
+ if (opts?.order) {
616
+ const comp = driver.getComponentsFromDate(date) as TPSComponents;
617
+ comp.calendar = normalizedCalendar;
618
+ comp.order = opts.order;
619
+ return this.buildTimePart(comp);
620
+ }
621
+ return driver.getFromDate(date);
408
622
  }
409
623
 
410
- // Built-in handlers
411
- if (calendar === 'unix') {
624
+ // Fallback for old built-in calendars (shouldn't happen once drivers are
625
+ // registered, but kept for backwards compatibility).
626
+ const comp: TPSComponents = { calendar: normalizedCalendar } as any;
627
+
628
+ if (normalizedCalendar === DefaultCalendars.UNIX) {
412
629
  const s = (date.getTime() / 1000).toFixed(3);
413
- return `T:unix.s${s}`;
630
+ comp.unixSeconds = parseFloat(s);
631
+ if (opts?.order) comp.order = opts.order;
632
+ return this.buildTimePart(comp);
414
633
  }
415
634
 
416
- if (calendar === 'greg') {
635
+ if (normalizedCalendar === DefaultCalendars.GREG) {
417
636
  const fullYear = date.getUTCFullYear();
418
- const m = Math.floor(fullYear / 1000) + 1;
419
- const c = Math.floor((fullYear % 1000) / 100) + 1;
420
- const y = fullYear % 100;
421
- const M = date.getUTCMonth() + 1;
422
- const d = date.getUTCDate();
423
- const h = date.getUTCHours();
424
- const n = date.getUTCMinutes();
425
- const s = date.getUTCSeconds();
426
-
427
- return `T:greg.m${m}.c${c}.y${y}.M${this.pad(M)}.d${this.pad(
428
- d,
429
- )}.h${this.pad(h)}.n${this.pad(n)}.s${this.pad(s)}`;
637
+ comp.millennium = Math.floor(fullYear / 1000) + 1;
638
+ comp.century = Math.floor((fullYear % 1000) / 100) + 1;
639
+ comp.year = fullYear % 100;
640
+ comp.month = date.getUTCMonth() + 1;
641
+ comp.day = date.getUTCDate();
642
+ comp.hour = date.getUTCHours();
643
+ comp.minute = date.getUTCMinutes();
644
+ comp.second = date.getUTCSeconds();
645
+ comp.millisecond = date.getUTCMilliseconds();
646
+ if (opts?.order) comp.order = opts.order;
647
+ return this.buildTimePart(comp);
430
648
  }
431
649
 
432
650
  throw new Error(
433
- `Calendar driver '${calendar}' not implemented. Register a driver.`,
651
+ `Calendar driver '${normalizedCalendar}' not implemented. Register a driver.`,
434
652
  );
435
653
  }
436
654
 
@@ -441,7 +659,7 @@ export class TPS {
441
659
  * @param targetCalendar - The target calendar code (e.g., 'hij').
442
660
  * @returns A TPS string in the target calendar, or null if invalid.
443
661
  */
444
- static to(targetCalendar: CalendarCode, tpsString: string): string | null {
662
+ static to(targetCalendar: string, tpsString: string): string | null {
445
663
  // 1. Parse to components and convert to Gregorian Date
446
664
  const gregDate = this.toDate(tpsString);
447
665
  if (!gregDate) return null;
@@ -457,45 +675,25 @@ export class TPS {
457
675
  * @returns JS Date object or `null` if invalid.
458
676
  */
459
677
  static toDate(tpsString: string): Date | null {
460
- const p = this.parse(tpsString);
461
- if (!p) return null;
678
+ const parsed = this.parse(tpsString);
679
+ if (!parsed) return null;
462
680
 
463
- // Check for registered driver first
464
- const driver = this.drivers.get(p.calendar);
465
- if (driver) {
466
- return driver.toGregorian(p);
467
- }
681
+ const cal = parsed.calendar || DefaultCalendars.TPS;
468
682
 
469
- // Built-in handlers
470
- if (p.calendar === 'unix' && p.unixSeconds !== undefined) {
471
- return new Date(p.unixSeconds * 1000);
683
+ const driver = this.drivers.get(cal);
684
+ if (!driver) {
685
+ console.error(`Calendar driver '${cal}' not registered.`);
686
+ return null;
472
687
  }
473
688
 
474
- if (p.calendar === 'greg') {
475
- const m = p.millennium || 0;
476
- const c = p.century || 1;
477
- const y = p.year || 0;
478
- const fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
479
-
480
- return new Date(
481
- Date.UTC(
482
- fullYear,
483
- (p.month || 1) - 1,
484
- p.day || 1,
485
- p.hour || 0,
486
- p.minute || 0,
487
- Math.floor(p.second || 0),
488
- ),
489
- );
490
- }
491
- return null;
689
+ return driver.getDateFromComponents(parsed);
492
690
  }
493
691
 
494
692
  // --- DRIVER CONVENIENCE METHODS ---
495
693
 
496
694
  /**
497
695
  * Parse a calendar-specific date string into TPS components.
498
- * Requires the driver to implement the optional `parseDate` method.
696
+ * Requires the driver to implement `parseDate`.
499
697
  *
500
698
  * @param calendar - The calendar code (e.g., 'hij')
501
699
  * @param dateString - Date string in calendar-native format (e.g., '1447-07-21')
@@ -508,11 +706,11 @@ export class TPS {
508
706
  * // { calendar: 'hij', year: 1447, month: 7, day: 21 }
509
707
  *
510
708
  * const uri = TPS.toURI({ ...components, latitude: 31.95, longitude: 35.91 });
511
- * // "tps://31.95,35.91@T:hij.y1447.M07.d21"
709
+ * // "tps://31.95,35.91@T:hij.y1447.m07.d21"
512
710
  * ```
513
711
  */
514
712
  static parseCalendarDate(
515
- calendar: CalendarCode,
713
+ calendar: string,
516
714
  dateString: string,
517
715
  format?: string,
518
716
  ): Partial<TPSComponents> | null {
@@ -522,11 +720,7 @@ export class TPS {
522
720
  `Calendar driver '${calendar}' not found. Register a driver first.`,
523
721
  );
524
722
  }
525
- if (!driver.parseDate) {
526
- throw new Error(
527
- `Driver '${calendar}' does not implement parseDate(). Use fromGregorian() instead.`,
528
- );
529
- }
723
+ // parseDate is guaranteed by the interface, so we can call it directly.
530
724
  return driver.parseDate(dateString, format);
531
725
  }
532
726
 
@@ -543,19 +737,19 @@ export class TPS {
543
737
  * ```ts
544
738
  * // With coordinates
545
739
  * TPS.fromCalendarDate('hij', '1447-07-21', { latitude: 31.95, longitude: 35.91 });
546
- * // "tps://31.95,35.91@T:hij.y1447.M07.d21"
740
+ * // "tps://31.95,35.91@T:hij.y1447.m07.d21"
547
741
  *
548
742
  * // With privacy flag
549
743
  * TPS.fromCalendarDate('hij', '1447-07-21', { isHiddenLocation: true });
550
- * // "tps://hidden@T:hij.y1447.M07.d21"
744
+ * // "tps://hidden@T:hij.y1447.m07.d21"
551
745
  *
552
746
  * // Without location
553
747
  * TPS.fromCalendarDate('hij', '1447-07-21');
554
- * // "tps://unknown@T:hij.y1447.M07.d21"
748
+ * // "tps://unknown@T:hij.y1447.m07.d21"
555
749
  * ```
556
750
  */
557
751
  static fromCalendarDate(
558
- calendar: CalendarCode,
752
+ calendar: string,
559
753
  dateString: string,
560
754
  location?: {
561
755
  latitude?: number;
@@ -573,7 +767,7 @@ export class TPS {
573
767
 
574
768
  // Merge with location
575
769
  const fullComponents: TPSComponents = {
576
- calendar,
770
+ calendar: calendar,
577
771
  ...components,
578
772
  ...location,
579
773
  } as TPSComponents;
@@ -583,7 +777,7 @@ export class TPS {
583
777
 
584
778
  /**
585
779
  * Format TPS components to a calendar-specific date string.
586
- * Requires the driver to implement the optional `format` method.
780
+ * Requires the driver to implement `format`.
587
781
  *
588
782
  * @param calendar - The calendar code
589
783
  * @param components - TPS components to format
@@ -592,13 +786,13 @@ export class TPS {
592
786
  *
593
787
  * @example
594
788
  * ```ts
595
- * const tps = TPS.parse('tps://unknown@T:hij.y1447.M07.d21');
789
+ * const tps = TPS.parse('tps://unknown@T:hij.y1447.m07.d21');
596
790
  * const formatted = TPS.formatCalendarDate('hij', tps);
597
791
  * // "1447-07-21"
598
792
  * ```
599
793
  */
600
794
  static formatCalendarDate(
601
- calendar: CalendarCode,
795
+ calendar: string,
602
796
  components: Partial<TPSComponents>,
603
797
  format?: string,
604
798
  ): string {
@@ -606,33 +800,234 @@ export class TPS {
606
800
  if (!driver) {
607
801
  throw new Error(`Calendar driver '${calendar}' not found.`);
608
802
  }
609
- if (!driver.format) {
610
- throw new Error(`Driver '${calendar}' does not implement format().`);
611
- }
803
+ // format is guaranteed by the interface, so we can call it directly.
612
804
  return driver.format(components, format);
613
805
  }
614
806
 
615
807
  // --- INTERNAL HELPERS ---
616
808
 
809
+ /**
810
+ * Generate the canonical `T:` time string for a set of components. The
811
+ * `order` field (or `comp.order`) controls whether tokens are emitted in
812
+ * ascending or descending hierarchy; if undefined the default
813
+ * `'descending'` orientation is used.
814
+ *
815
+ * Drivers may ignore this helper and produce their own time strings if they
816
+ * implement custom ordering logic.
817
+ */
818
+ public static buildTimePart(comp: TPSComponents): string {
819
+ const calendar = (comp.calendar || "").toLowerCase();
820
+ if (!/^[a-z]{3,4}$/.test(calendar)) {
821
+ throw new Error(
822
+ `Invalid calendar code '${comp.calendar}'. Calendar code width must be 3–4 lowercase letters.`,
823
+ );
824
+ }
825
+
826
+ let time = `T:${calendar}`;
827
+ if (calendar === DefaultCalendars.UNIX) {
828
+ if (comp.unixSeconds !== undefined) {
829
+ time += `.s${comp.unixSeconds}`;
830
+ }
831
+ return time;
832
+ }
833
+
834
+ // sequence of [prefix, value, rank]
835
+ // All four of millennium / month / minute / millisecond share the prefix 'm'.
836
+ // Position within the ordered sequence disambiguates them during parsing.
837
+ const tokens: Array<[string, number | undefined, number]> = [
838
+ ["m", comp.millennium, 8], // m-token rank 8 → millennium
839
+ ["c", comp.century, 7],
840
+ ["y", comp.year, 6],
841
+ ["m", comp.month, 5], // m-token rank 5 → month
842
+ ["d", comp.day, 4],
843
+ ["h", comp.hour, 3],
844
+ ["m", comp.minute, 2], // m-token rank 2 → minute
845
+ ["s", comp.second, 1],
846
+ ["m", comp.millisecond, 0], // m-token rank 0 → millisecond
847
+ ];
848
+
849
+ const order: TimeOrder = comp.order || TimeOrder.DESC;
850
+ if (order === TimeOrder.ASC) tokens.reverse();
851
+
852
+ for (const [pref, val] of tokens) {
853
+ if (val !== undefined) {
854
+ // seconds may be fractional
855
+ time += `.${pref}${val}`;
856
+ }
857
+ }
858
+
859
+ if (comp.signature) {
860
+ time += `!${comp.signature}`;
861
+ }
862
+
863
+ return time;
864
+ }
865
+
866
+ /**
867
+ * Parse the *time* portion of a TPS string (optionally beginning with
868
+ * `T:`) into components and determine the component ordering. This helper
869
+ * accepts tokens in **any** sequence and will return an `order` value of
870
+ * `'ascending'` or `'descending'`.
871
+ *
872
+ * The caller is responsible for stripping off a leading signature or other
873
+ * trailer characters; this method will drop anything after `!`, `;`, `?` or
874
+ * `#`.
875
+ *
876
+ * ### `m`-token disambiguation
877
+ * All four of millennium (rank 8), month (rank 5), minute (rank 2) and
878
+ * millisecond (rank 0) share the single-character prefix `m`. They are told
879
+ * apart by their **position relative to the neighbouring tokens**. The
880
+ * algorithm is:
881
+ *
882
+ * 1. Pre-scan the non-`m` tokens (c, y, d, h, s) whose ranks are fixed to
883
+ * determine whether the string is ascending or descending.
884
+ * 2. While iterating, track `lastAssignedRank` – the rank of the most
885
+ * recently processed token (m or non-m).
886
+ * 3. When an `m` token is encountered, derive its rank from `lastAssignedRank`
887
+ * and the detected order:
888
+ * - **DESC** null → 8 (mill) | rank > 5 → 5 (month) | rank > 2 → 2 (min) | else → 0 (ms)
889
+ * - **ASC** null → 0 (ms) | rank < 2 → 2 (min) | rank < 5 → 5 (month) | else → 8 (mill)
890
+ *
891
+ * @param input - Time fragment (e.g. `"T:greg.m3.c1.y26"` or `"greg.m0.s25.m30"`)
892
+ */
893
+ static parseTimeString(
894
+ input: string,
895
+ ): { components: Partial<TPSComponents>; order: TimeOrder } | null {
896
+ let s = input.trim();
897
+ // strip off anything after signature or extensions/query/fragment
898
+ s = s.split(/[!;?#]/)[0];
899
+ if (s.startsWith("T:")) s = s.slice(2);
900
+ const parts = s.split(".");
901
+ if (parts.length === 0) return null;
902
+ const calendar = parts[0];
903
+ const comp: Partial<TPSComponents> = { calendar };
904
+
905
+ // Fixed-rank prefixes (unambiguous regardless of position)
906
+ const fixedRankMap: Record<string, number> = {
907
+ c: 7,
908
+ y: 6,
909
+ d: 4,
910
+ h: 3,
911
+ s: 1,
912
+ };
913
+
914
+ // ── Step 1: pre-scan non-m tokens to estimate order ─────────────────────
915
+ // This is only needed to handle the first 'm' token when lastAssignedRank
916
+ // is still null (nothing has been seen yet).
917
+ let initialOrder: TimeOrder = TimeOrder.DESC;
918
+ if (calendar !== DefaultCalendars.UNIX) {
919
+ const nonMRanks: number[] = [];
920
+ for (let i = 1; i < parts.length; i++) {
921
+ const pr = parts[i]?.charAt(0);
922
+ if (pr && pr in fixedRankMap) nonMRanks.push(fixedRankMap[pr]);
923
+ }
924
+ if (nonMRanks.length >= 2) {
925
+ const isAsc = nonMRanks.every((v, i, a) => i === 0 || a[i - 1] <= v);
926
+ if (isAsc) initialOrder = TimeOrder.ASC;
927
+ }
928
+ }
929
+
930
+ // ── Step 2: resolve the semantic rank of an 'm' token ───────────────────
931
+ const assignMRank = (lastRank: number | null, ord: TimeOrder): number => {
932
+ if (ord === TimeOrder.DESC) {
933
+ if (lastRank === null) return 8; // first token → millennium
934
+ if (lastRank > 5) return 5; // after century / year → month
935
+ if (lastRank > 2) return 2; // after day / hour → minute
936
+ return 0; // after second → millisecond
937
+ } else {
938
+ if (lastRank === null) return 0; // first token → millisecond
939
+ if (lastRank < 2) return 2; // after millisecond / second → minute
940
+ if (lastRank < 5) return 5; // after minute / hour / day → month
941
+ return 8; // after month / year / cent → millennium
942
+ }
943
+ };
944
+
945
+ // ── Step 3: iterate and build components ────────────────────────────────
946
+ const ranks: number[] = [];
947
+ let lastAssignedRank: number | null = null;
948
+
949
+ for (let i = 1; i < parts.length; i++) {
950
+ const token = parts[i];
951
+ if (!token) continue;
952
+ const prefix = token.charAt(0);
953
+ const value = token.slice(1);
954
+
955
+ // UNIX calendar: single 's' token carries the full unix timestamp
956
+ if (calendar === DefaultCalendars.UNIX && prefix === "s") {
957
+ comp.unixSeconds = parseFloat(value);
958
+ ranks.push(9);
959
+ continue;
960
+ }
961
+
962
+ if (prefix === "m") {
963
+ const rank = assignMRank(lastAssignedRank, initialOrder);
964
+ switch (rank) {
965
+ case 8:
966
+ comp.millennium = parseInt(value, 10);
967
+ break;
968
+ case 5:
969
+ comp.month = parseInt(value, 10);
970
+ break;
971
+ case 2:
972
+ comp.minute = parseInt(value, 10);
973
+ break;
974
+ case 0:
975
+ comp.millisecond = parseInt(value, 10);
976
+ break;
977
+ }
978
+ ranks.push(rank);
979
+ lastAssignedRank = rank;
980
+ } else {
981
+ switch (prefix) {
982
+ case "c":
983
+ comp.century = parseInt(value, 10);
984
+ ranks.push(7);
985
+ lastAssignedRank = 7;
986
+ break;
987
+ case "y":
988
+ comp.year = parseInt(value, 10);
989
+ ranks.push(6);
990
+ lastAssignedRank = 6;
991
+ break;
992
+ case "d":
993
+ comp.day = parseInt(value, 10);
994
+ ranks.push(4);
995
+ lastAssignedRank = 4;
996
+ break;
997
+ case "h":
998
+ comp.hour = parseInt(value, 10);
999
+ ranks.push(3);
1000
+ lastAssignedRank = 3;
1001
+ break;
1002
+ case "s":
1003
+ comp.second = parseFloat(value);
1004
+ ranks.push(1);
1005
+ lastAssignedRank = 1;
1006
+ break;
1007
+ default:
1008
+ // unknown prefix – ignore
1009
+ break;
1010
+ }
1011
+ }
1012
+ }
1013
+
1014
+ // ── Step 4: confirm order from the complete rank sequence ────────────────
1015
+ let order: TimeOrder = TimeOrder.DESC;
1016
+ if (ranks.length > 1) {
1017
+ const isAsc = ranks.every((v, i, a) => i === 0 || a[i - 1] <= v);
1018
+ const isDesc = ranks.every((v, i, a) => i === 0 || a[i - 1] >= v);
1019
+ if (isAsc && !isDesc) order = TimeOrder.ASC;
1020
+ // mixed / single direction → defaults to DESC
1021
+ }
1022
+
1023
+ return { components: comp, order };
1024
+ }
1025
+
617
1026
  private static _mapGroupsToComponents(
618
1027
  g: Record<string, string>,
619
1028
  ): TPSComponents {
620
1029
  const components: any = {};
621
- components.calendar = g.calendar as CalendarCode;
622
-
623
- // Time Mapping
624
- if (components.calendar === 'unix' && g.unix) {
625
- components.unixSeconds = parseFloat(g.unix.substring(1));
626
- } else {
627
- if (g.millennium) components.millennium = parseInt(g.millennium, 10);
628
- if (g.century) components.century = parseInt(g.century, 10);
629
- if (g.year) components.year = parseInt(g.year, 10);
630
- if (g.month) components.month = parseInt(g.month, 10);
631
- if (g.day) components.day = parseInt(g.day, 10);
632
- if (g.hour) components.hour = parseInt(g.hour, 10);
633
- if (g.minute) components.minute = parseInt(g.minute, 10);
634
- if (g.second) components.second = parseFloat(g.second);
635
- }
1030
+ components.calendar = g.calendar as string;
636
1031
 
637
1032
  // Signature Mapping
638
1033
  if (g.signature) {
@@ -647,11 +1042,11 @@ export class TPS {
647
1042
  // Space Mapping
648
1043
  if (g.space) {
649
1044
  // Privacy markers
650
- if (g.space === 'unknown' || g.space === '-') {
1045
+ if (g.space === "unknown" || g.space === "-") {
651
1046
  components.isUnknownLocation = true;
652
- } else if (g.space === 'redacted') {
1047
+ } else if (g.space === "redacted") {
653
1048
  components.isRedactedLocation = true;
654
- } else if (g.space === 'hidden' || g.space === '~') {
1049
+ } else if (g.space === "hidden" || g.space === "~") {
655
1050
  components.isHiddenLocation = true;
656
1051
  }
657
1052
  // Geospatial cells
@@ -671,6 +1066,10 @@ export class TPS {
671
1066
  if (g.room) components.room = g.room;
672
1067
  if (g.zone) components.zone = g.zone;
673
1068
  }
1069
+ // Generic pre-@ anchor (adm/node/net/planet/etc)
1070
+ else if (g.generic) {
1071
+ components.spaceAnchor = g.generic;
1072
+ }
674
1073
  // GPS coordinates
675
1074
  else {
676
1075
  if (g.lat) components.latitude = parseFloat(g.lat);
@@ -682,9 +1081,9 @@ export class TPS {
682
1081
  // Extensions Mapping
683
1082
  if (g.extensions) {
684
1083
  const extObj: any = {};
685
- const parts = g.extensions.split('.');
1084
+ const parts = g.extensions.split(".");
686
1085
  parts.forEach((p: string) => {
687
- const eqIdx = p.indexOf('=');
1086
+ const eqIdx = p.indexOf("=");
688
1087
  if (eqIdx > 0) {
689
1088
  const key = p.substring(0, eqIdx);
690
1089
  const val = p.substring(eqIdx + 1);
@@ -701,13 +1100,14 @@ export class TPS {
701
1100
 
702
1101
  return components as TPSComponents;
703
1102
  }
704
-
705
- private static pad(n: number): string {
706
- const s = n.toString();
707
- return s.length < 2 ? '0' + s : s;
708
- }
709
1103
  }
710
1104
 
1105
+ // register built-in drivers and set default
1106
+ // (tps and gregorian provide canonical conversions before unix)
1107
+ TPS.registerDriver(new TpsDriver());
1108
+ TPS.registerDriver(new GregorianDriver());
1109
+ TPS.registerDriver(new UnixDriver());
1110
+
711
1111
  // --- TPS-UID v1 Types ---
712
1112
 
713
1113
  /**
@@ -715,7 +1115,7 @@ export class TPS {
715
1115
  */
716
1116
  export type TPSUID7RBDecodeResult = {
717
1117
  /** Version identifier */
718
- version: 'tpsuid7rb';
1118
+ version: "tpsuid7rb";
719
1119
  /** Epoch milliseconds (UTC) */
720
1120
  epochMs: number;
721
1121
  /** Whether the TPS payload was compressed */
@@ -756,7 +1156,7 @@ export type TPSUID7RBEncodeOptions = {
756
1156
  *
757
1157
  * @example
758
1158
  * ```ts
759
- * const tps = 'tps://31.95,35.91@T:greg.m3.c1.y26.M01.d09';
1159
+ * const tps = 'tps://31.95,35.91@T:greg.m3.c1.y26.m01.d09';
760
1160
  *
761
1161
  * // Encode to binary
762
1162
  * const bytes = TPSUID7RB.encodeBinary(tps);
@@ -776,7 +1176,7 @@ export class TPSUID7RB {
776
1176
  /** Version 1 */
777
1177
  private static readonly VER = 0x01;
778
1178
  /** String prefix for base64url encoded form */
779
- private static readonly PREFIX = 'tpsuid7rb_';
1179
+ private static readonly PREFIX = "tpsuid7rb_";
780
1180
  /** Regex for validating base64url encoded form */
781
1181
  public static readonly REGEX = /^tpsuid7rb_[A-Za-z0-9_-]+$/;
782
1182
 
@@ -792,15 +1192,18 @@ export class TPSUID7RB {
792
1192
  * @param opts - Encoding options (compress, epochMs override)
793
1193
  * @returns Binary TPS-UID as Uint8Array
794
1194
  */
795
- static encodeBinary(tps: string, opts?: TPSUID7RBEncodeOptions): Uint8Array {
796
- const compress = opts?.compress ?? false;
797
- const epochMs = opts?.epochMs ?? this.epochMsFromTPSString(tps);
1195
+ static encodeBinary(
1196
+ tps: string,
1197
+ opts: TPSUID7RBEncodeOptions = {},
1198
+ ): Uint8Array {
1199
+ const compress = opts.compress ?? false;
1200
+ const epochMs = opts.epochMs ?? this.epochMsFromTPSString(tps);
798
1201
 
799
1202
  if (!Number.isInteger(epochMs) || epochMs < 0) {
800
- throw new Error('epochMs must be a non-negative integer');
1203
+ throw new Error("epochMs must be a non-negative integer");
801
1204
  }
802
1205
  if (epochMs > 0xffffffffffff) {
803
- throw new Error('epochMs exceeds 48-bit range');
1206
+ throw new Error("epochMs exceeds 48-bit range");
804
1207
  }
805
1208
 
806
1209
  const flags = compress ? 0x01 : 0x00;
@@ -866,7 +1269,7 @@ export class TPSUID7RB {
866
1269
  static decodeBinary(bytes: Uint8Array): TPSUID7RBDecodeResult {
867
1270
  // Header min size: 4+1+1+6+4 + 1 (at least 1 byte varint) = 17
868
1271
  if (bytes.length < 17) {
869
- throw new Error('TPSUID7RB: too short');
1272
+ throw new Error("TPSUID7RB: too short");
870
1273
  }
871
1274
 
872
1275
  // MAGIC
@@ -876,7 +1279,7 @@ export class TPSUID7RB {
876
1279
  bytes[2] !== 0x55 ||
877
1280
  bytes[3] !== 0x37
878
1281
  ) {
879
- throw new Error('TPSUID7RB: bad magic');
1282
+ throw new Error("TPSUID7RB: bad magic");
880
1283
  }
881
1284
 
882
1285
  // VERSION
@@ -905,7 +1308,7 @@ export class TPSUID7RB {
905
1308
  offset += bytesRead;
906
1309
 
907
1310
  if (offset + tpsLen > bytes.length) {
908
- throw new Error('TPSUID7RB: length overflow');
1311
+ throw new Error("TPSUID7RB: length overflow");
909
1312
  }
910
1313
 
911
1314
  // TPS payload
@@ -913,7 +1316,7 @@ export class TPSUID7RB {
913
1316
  const tpsUtf8 = compressed ? this.inflateRaw(payload) : payload;
914
1317
  const tps = new TextDecoder().decode(tpsUtf8);
915
1318
 
916
- return { version: 'tpsuid7rb', epochMs, compressed, nonce, tps };
1319
+ return { version: "tpsuid7rb", epochMs, compressed, nonce, tps };
917
1320
  }
918
1321
 
919
1322
  /**
@@ -925,7 +1328,7 @@ export class TPSUID7RB {
925
1328
  * @returns Base64url encoded TPS-UID with prefix
926
1329
  */
927
1330
  static encodeBinaryB64(tps: string, opts?: TPSUID7RBEncodeOptions): string {
928
- const bytes = this.encodeBinary(tps, opts);
1331
+ const bytes = this.encodeBinary(tps, opts ?? {});
929
1332
  return `${this.PREFIX}${this.base64UrlEncode(bytes)}`;
930
1333
  }
931
1334
 
@@ -938,7 +1341,7 @@ export class TPSUID7RB {
938
1341
  static decodeBinaryB64(id: string): TPSUID7RBDecodeResult {
939
1342
  const s = id.trim();
940
1343
  if (!s.startsWith(this.PREFIX)) {
941
- throw new Error('TPSUID7RB: missing prefix');
1344
+ throw new Error("TPSUID7RB: missing prefix");
942
1345
  }
943
1346
  const b64 = s.slice(this.PREFIX.length);
944
1347
  const bytes = this.base64UrlDecode(b64);
@@ -967,9 +1370,23 @@ export class TPSUID7RB {
967
1370
  longitude?: number;
968
1371
  altitude?: number;
969
1372
  compress?: boolean;
1373
+ order?: TimeOrder;
970
1374
  }): string {
971
1375
  const now = new Date();
972
- const tps = this.generateTPSString(now, opts);
1376
+ const time = TPS.fromDate(now, DefaultCalendars.TPS, {
1377
+ order: opts?.order,
1378
+ });
1379
+ let space = "unknown";
1380
+
1381
+ if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
1382
+ space = `${opts.latitude},${opts.longitude}`;
1383
+ if (opts.altitude !== undefined) {
1384
+ space += `,${opts.altitude}m`;
1385
+ }
1386
+ }
1387
+
1388
+ const tps = `tps://${space}@${time}`;
1389
+
973
1390
  return this.encodeBinaryB64(tps, {
974
1391
  compress: opts?.compress,
975
1392
  epochMs: now.getTime(),
@@ -983,27 +1400,36 @@ export class TPSUID7RB {
983
1400
  /**
984
1401
  * Generate a TPS string from a Date and optional location.
985
1402
  */
1403
+ // NOTE: this helper is primarily used by `generate()`; drivers and
1404
+ // callers should prefer `TPS.fromDate()` when order or calendars matter.
986
1405
  private static generateTPSString(
987
1406
  date: Date,
988
- opts?: { latitude?: number; longitude?: number; altitude?: number },
1407
+ opts?: {
1408
+ latitude?: number;
1409
+ longitude?: number;
1410
+ altitude?: number;
1411
+ order?: TimeOrder;
1412
+ },
989
1413
  ): string {
990
1414
  const fullYear = date.getUTCFullYear();
991
- const m = Math.floor(fullYear / 1000) + 1;
992
- const c = Math.floor((fullYear % 1000) / 100) + 1;
993
- const y = fullYear % 100;
994
- const M = date.getUTCMonth() + 1;
995
- const d = date.getUTCDate();
996
- const h = date.getUTCHours();
997
- const n = date.getUTCMinutes();
998
- const s = date.getUTCSeconds();
999
-
1000
- const pad = (num: number) => num.toString().padStart(2, '0');
1001
-
1002
- const timePart = `T:greg.m${m}.c${c}.y${y}.M${pad(M)}.d${pad(d)}.h${pad(
1003
- h,
1004
- )}.n${pad(n)}.s${pad(s)}`;
1005
-
1006
- let spacePart = 'unknown';
1415
+ const comp: TPSComponents = {
1416
+ calendar: DefaultCalendars.TPS,
1417
+ millennium: Math.floor(fullYear / 1000) + 1,
1418
+ century: Math.floor((fullYear % 1000) / 100) + 1,
1419
+ year: fullYear % 100,
1420
+ month: date.getUTCMonth() + 1,
1421
+ day: date.getUTCDate(),
1422
+ hour: date.getUTCHours(),
1423
+ minute: date.getUTCMinutes(),
1424
+ second: date.getUTCSeconds(),
1425
+ millisecond: date.getUTCMilliseconds(),
1426
+ };
1427
+ if (opts?.order) comp.order = opts.order;
1428
+
1429
+ // note: this method belongs to TPSUID7RB, but buildTimePart lives on TPS
1430
+ const timePart = TPS.buildTimePart(comp);
1431
+
1432
+ let spacePart = "unknown";
1007
1433
  if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
1008
1434
  spacePart = `${opts.latitude},${opts.longitude}`;
1009
1435
  if (opts.altitude !== undefined) {
@@ -1019,63 +1445,15 @@ export class TPSUID7RB {
1019
1445
  * Supports both URI format (tps://...) and time-only format (T:greg...)
1020
1446
  */
1021
1447
  static epochMsFromTPSString(tps: string): number {
1022
- let time: string;
1023
-
1024
- if (tps.includes('@')) {
1025
- // URI format: tps://...@T:greg...
1026
- const at = tps.indexOf('@');
1027
- time = tps.slice(at + 1).trim();
1028
- } else if (tps.startsWith('T:')) {
1029
- // Time-only format
1030
- time = tps;
1031
- } else {
1032
- throw new Error('TPS: unrecognized format');
1033
- }
1034
-
1035
- if (!time.startsWith('T:greg.')) {
1036
- throw new Error('TPS: only T:greg.* parsing is supported');
1037
- }
1038
-
1039
- // Extract m (millennium), c (century), y (year)
1040
- const mMatch = time.match(/\.m(-?\d+)/);
1041
- const cMatch = time.match(/\.c(\d+)/);
1042
- const yMatch = time.match(/\.y(\d{1,4})/);
1043
- const MMatch = time.match(/\.M(\d{1,2})/);
1044
- const dMatch = time.match(/\.d(\d{1,2})/);
1045
- const hMatch = time.match(/\.h(\d{1,2})/);
1046
- const nMatch = time.match(/\.n(\d{1,2})/);
1047
- const sMatch = time.match(/\.s(\d{1,2})/);
1048
-
1049
- // Calculate full year from millennium, century, year
1050
- let fullYear: number;
1051
- if (mMatch && cMatch && yMatch) {
1052
- const millennium = parseInt(mMatch[1], 10);
1053
- const century = parseInt(cMatch[1], 10);
1054
- const year = parseInt(yMatch[1], 10);
1055
- fullYear = (millennium - 1) * 1000 + (century - 1) * 100 + year;
1056
- } else if (yMatch) {
1057
- // Fallback: interpret y as 2-digit year
1058
- let year = parseInt(yMatch[1], 10);
1059
- if (year < 100) {
1060
- year = year <= 69 ? 2000 + year : 1900 + year;
1061
- }
1062
- fullYear = year;
1063
- } else {
1064
- throw new Error('TPS: missing year component');
1065
- }
1066
-
1067
- const month = MMatch ? parseInt(MMatch[1], 10) : 1;
1068
- const day = dMatch ? parseInt(dMatch[1], 10) : 1;
1069
- const hour = hMatch ? parseInt(hMatch[1], 10) : 0;
1070
- const minute = nMatch ? parseInt(nMatch[1], 10) : 0;
1071
- const second = sMatch ? parseInt(sMatch[1], 10) : 0;
1072
-
1073
- const epoch = Date.UTC(fullYear, month - 1, day, hour, minute, second);
1074
- if (!Number.isFinite(epoch)) {
1075
- throw new Error('TPS: failed to compute epochMs');
1076
- }
1077
-
1078
- return epoch;
1448
+ const date = TPS.toDate(tps);
1449
+ if (date) return date.getTime();
1450
+
1451
+ // If parse fails due to unsupported/extended extension payloads,
1452
+ // strip extensions/query/fragment and retry. Epoch only depends on time.
1453
+ const stripped = tps.replace(/;[^?#]*/, "").replace(/[?#].*$/, "");
1454
+ const retryDate = TPS.toDate(stripped);
1455
+ if (!retryDate) throw new Error("TPS: unable to parse date for epoch");
1456
+ return retryDate.getTime();
1079
1457
  }
1080
1458
 
1081
1459
  // ---------------------------
@@ -1108,7 +1486,7 @@ export class TPSUID7RB {
1108
1486
 
1109
1487
  const n = Number(v);
1110
1488
  if (!Number.isSafeInteger(n)) {
1111
- throw new Error('TPSUID7RB: u48 not safe integer');
1489
+ throw new Error("TPSUID7RB: u48 not safe integer");
1112
1490
  }
1113
1491
  return n;
1114
1492
  }
@@ -1116,7 +1494,7 @@ export class TPSUID7RB {
1116
1494
  /** Encode unsigned integer as LEB128 varint */
1117
1495
  private static uvarintEncode(n: number): Uint8Array {
1118
1496
  if (!Number.isInteger(n) || n < 0) {
1119
- throw new Error('uvarint must be non-negative int');
1497
+ throw new Error("uvarint must be non-negative int");
1120
1498
  }
1121
1499
  const out: number[] = [];
1122
1500
  let x = n >>> 0;
@@ -1138,12 +1516,12 @@ export class TPSUID7RB {
1138
1516
  let i = 0;
1139
1517
  while (true) {
1140
1518
  if (offset + i >= bytes.length) {
1141
- throw new Error('uvarint overflow');
1519
+ throw new Error("uvarint overflow");
1142
1520
  }
1143
1521
  const b = bytes[offset + i];
1144
1522
  if (b < 0x80) {
1145
1523
  if (i > 9 || (i === 9 && b > 1)) {
1146
- throw new Error('uvarint too large');
1524
+ throw new Error("uvarint too large");
1147
1525
  }
1148
1526
  x |= b << s;
1149
1527
  return { value: x >>> 0, bytesRead: i + 1 };
@@ -1152,7 +1530,7 @@ export class TPSUID7RB {
1152
1530
  s += 7;
1153
1531
  i++;
1154
1532
  if (i > 10) {
1155
- throw new Error('uvarint too long');
1533
+ throw new Error("uvarint too long");
1156
1534
  }
1157
1535
  }
1158
1536
  }
@@ -1164,35 +1542,35 @@ export class TPSUID7RB {
1164
1542
  /** Encode bytes to base64url (no padding) */
1165
1543
  private static base64UrlEncode(bytes: Uint8Array): string {
1166
1544
  // Node.js environment
1167
- if (typeof Buffer !== 'undefined') {
1545
+ if (typeof Buffer !== "undefined") {
1168
1546
  return Buffer.from(bytes)
1169
- .toString('base64')
1170
- .replace(/\+/g, '-')
1171
- .replace(/\//g, '_')
1172
- .replace(/=+$/g, '');
1547
+ .toString("base64")
1548
+ .replace(/\+/g, "-")
1549
+ .replace(/\//g, "_")
1550
+ .replace(/=+$/g, "");
1173
1551
  }
1174
1552
  // Browser environment
1175
- let binary = '';
1553
+ let binary = "";
1176
1554
  for (let i = 0; i < bytes.length; i++) {
1177
1555
  binary += String.fromCharCode(bytes[i]);
1178
1556
  }
1179
1557
  return btoa(binary)
1180
- .replace(/\+/g, '-')
1181
- .replace(/\//g, '_')
1182
- .replace(/=+$/g, '');
1558
+ .replace(/\+/g, "-")
1559
+ .replace(/\//g, "_")
1560
+ .replace(/=+$/g, "");
1183
1561
  }
1184
1562
 
1185
1563
  /** Decode base64url to bytes */
1186
1564
  private static base64UrlDecode(b64url: string): Uint8Array {
1187
1565
  // Add padding
1188
1566
  const padLen = (4 - (b64url.length % 4)) % 4;
1189
- const b64 = (b64url + '='.repeat(padLen))
1190
- .replace(/-/g, '+')
1191
- .replace(/_/g, '/');
1567
+ const b64 = (b64url + "=".repeat(padLen))
1568
+ .replace(/-/g, "+")
1569
+ .replace(/_/g, "/");
1192
1570
 
1193
1571
  // Node.js environment
1194
- if (typeof Buffer !== 'undefined') {
1195
- return new Uint8Array(Buffer.from(b64, 'base64'));
1572
+ if (typeof Buffer !== "undefined") {
1573
+ return new Uint8Array(Buffer.from(b64, "base64"));
1196
1574
  }
1197
1575
  // Browser environment
1198
1576
  const binary = atob(b64);
@@ -1210,33 +1588,33 @@ export class TPSUID7RB {
1210
1588
  /** Compress using zlib deflate raw */
1211
1589
  private static deflateRaw(data: Uint8Array): Uint8Array {
1212
1590
  // Node.js environment
1213
- if (typeof require !== 'undefined') {
1591
+ if (typeof require !== "undefined") {
1214
1592
  try {
1215
1593
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1216
- const zlib = require('zlib');
1594
+ const zlib = require("zlib");
1217
1595
  return new Uint8Array(zlib.deflateRawSync(Buffer.from(data)));
1218
1596
  } catch {
1219
- throw new Error('TPSUID7RB: compression not available');
1597
+ throw new Error("TPSUID7RB: compression not available");
1220
1598
  }
1221
1599
  }
1222
1600
  // Browser: would need pako or similar library
1223
- throw new Error('TPSUID7RB: compression not available in browser');
1601
+ throw new Error("TPSUID7RB: compression not available in browser");
1224
1602
  }
1225
1603
 
1226
1604
  /** Decompress using zlib inflate raw */
1227
1605
  private static inflateRaw(data: Uint8Array): Uint8Array {
1228
1606
  // Node.js environment
1229
- if (typeof require !== 'undefined') {
1607
+ if (typeof require !== "undefined") {
1230
1608
  try {
1231
1609
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1232
- const zlib = require('zlib');
1610
+ const zlib = require("zlib");
1233
1611
  return new Uint8Array(zlib.inflateRawSync(Buffer.from(data)));
1234
1612
  } catch {
1235
- throw new Error('TPSUID7RB: decompression failed');
1613
+ throw new Error("TPSUID7RB: decompression failed");
1236
1614
  }
1237
1615
  }
1238
1616
  // Browser: would need pako or similar library
1239
- throw new Error('TPSUID7RB: decompression not available in browser');
1617
+ throw new Error("TPSUID7RB: decompression not available in browser");
1240
1618
  }
1241
1619
 
1242
1620
  // ---------------------------
@@ -1271,7 +1649,7 @@ export class TPSUID7RB {
1271
1649
 
1272
1650
  // Validate epoch
1273
1651
  if (!Number.isInteger(epochMs) || epochMs < 0 || epochMs > 0xffffffffffff) {
1274
- throw new Error('epochMs must be a valid 48-bit non-negative integer');
1652
+ throw new Error("epochMs must be a valid 48-bit non-negative integer");
1275
1653
  }
1276
1654
 
1277
1655
  // Flags: Bit 0 = compress, Bit 1 = sealed
@@ -1327,7 +1705,7 @@ export class TPSUID7RB {
1327
1705
  sealedBytes: Uint8Array,
1328
1706
  publicKey: string | Buffer | Uint8Array,
1329
1707
  ): TPSUID7RBDecodeResult {
1330
- if (sealedBytes.length < 18) throw new Error('TPSUID7RB: too short');
1708
+ if (sealedBytes.length < 18) throw new Error("TPSUID7RB: too short");
1331
1709
 
1332
1710
  // Check Magic
1333
1711
  if (
@@ -1336,13 +1714,13 @@ export class TPSUID7RB {
1336
1714
  sealedBytes[2] !== 0x55 ||
1337
1715
  sealedBytes[3] !== 0x37
1338
1716
  ) {
1339
- throw new Error('TPSUID7RB: bad magic');
1717
+ throw new Error("TPSUID7RB: bad magic");
1340
1718
  }
1341
1719
 
1342
1720
  // Check Flags for Sealed Bit (bit 1)
1343
1721
  const flags = sealedBytes[5];
1344
1722
  if ((flags & 0x02) === 0) {
1345
- throw new Error('TPSUID7RB: not a sealed UID');
1723
+ throw new Error("TPSUID7RB: not a sealed UID");
1346
1724
  }
1347
1725
 
1348
1726
  // 1. Parse the structure to find where content ends
@@ -1357,7 +1735,7 @@ export class TPSUID7RB {
1357
1735
  const payloadEnd = offset + tpsLen;
1358
1736
 
1359
1737
  if (payloadEnd > sealedBytes.length) {
1360
- throw new Error('TPSUID7RB: length overflow (truncated)');
1738
+ throw new Error("TPSUID7RB: length overflow (truncated)");
1361
1739
  }
1362
1740
 
1363
1741
  // The Content to verify matches exactly [0 ... payloadEnd]
@@ -1365,7 +1743,7 @@ export class TPSUID7RB {
1365
1743
 
1366
1744
  // After content: SealType (1 byte) + Signature
1367
1745
  if (sealedBytes.length <= payloadEnd + 1) {
1368
- throw new Error('TPSUID7RB: missing signature data');
1746
+ throw new Error("TPSUID7RB: missing signature data");
1369
1747
  }
1370
1748
 
1371
1749
  const sealType = sealedBytes[payloadEnd];
@@ -1385,7 +1763,7 @@ export class TPSUID7RB {
1385
1763
  // Verify
1386
1764
  const isValid = this.verifyEd25519(content, signature, publicKey);
1387
1765
  if (!isValid) {
1388
- throw new Error('TPSUID7RB: signature verification failed');
1766
+ throw new Error("TPSUID7RB: signature verification failed");
1389
1767
  }
1390
1768
 
1391
1769
  // Decode (reuse standard logic, but ignoring the extra bytes at end is fine?)
@@ -1403,10 +1781,10 @@ export class TPSUID7RB {
1403
1781
  data: Uint8Array,
1404
1782
  privateKey: string | Buffer | Uint8Array,
1405
1783
  ): Uint8Array {
1406
- if (typeof require !== 'undefined') {
1784
+ if (typeof require !== "undefined") {
1407
1785
  try {
1408
1786
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1409
- const crypto = require('crypto');
1787
+ const crypto = require("crypto");
1410
1788
  // Node's crypto.sign uses PEM or KeyObject, but for raw Ed25519 keys we might need 'crypto.sign(null, data, key)'
1411
1789
  // or ensure key is properly formatted.
1412
1790
  // For simplicity in Node 20+, crypto.sign(null, data, privateKey) works if key is KeyObject.
@@ -1419,8 +1797,8 @@ export class TPSUID7RB {
1419
1797
  // Let's assume standard Ed25519 standard implementation pattern logic:
1420
1798
  keyObj = crypto.createPrivateKey({
1421
1799
  key: Buffer.from(privateKey),
1422
- format: 'der', // or 'pem' - strict.
1423
- type: 'pkcs8',
1800
+ format: "der", // or 'pem' - strict.
1801
+ type: "pkcs8",
1424
1802
  });
1425
1803
  // Actually, simpler: construct key object from raw bytes if possible?
1426
1804
  // Node's crypto is strict. Let's try the simplest:
@@ -1434,11 +1812,11 @@ export class TPSUID7RB {
1434
1812
  // Handling RAW Ed25519 keys in Node requires specific 'crypto.createPrivateKey' with 'raw' format (Node 11.6+).
1435
1813
 
1436
1814
  const key =
1437
- typeof privateKey === 'string' && !privateKey.includes('PRIVATE KEY')
1815
+ typeof privateKey === "string" && !privateKey.includes("PRIVATE KEY")
1438
1816
  ? crypto.createPrivateKey({
1439
- key: Buffer.from(privateKey, 'hex'),
1440
- format: 'pem',
1441
- type: 'pkcs8',
1817
+ key: Buffer.from(privateKey, "hex"),
1818
+ format: "pem",
1819
+ type: "pkcs8",
1442
1820
  }) // Fallback guess
1443
1821
  : privateKey;
1444
1822
 
@@ -1447,10 +1825,10 @@ export class TPSUID7RB {
1447
1825
  return new Uint8Array(crypto.sign(null, data, key));
1448
1826
  } catch (e) {
1449
1827
  // If standard crypto fails (e.g. key format issue), throw
1450
- throw new Error('TPSUID7RB: signing failed (check key format)');
1828
+ throw new Error("TPSUID7RB: signing failed (check key format)");
1451
1829
  }
1452
1830
  }
1453
- throw new Error('TPSUID7RB: signing not available in browser');
1831
+ throw new Error("TPSUID7RB: signing not available in browser");
1454
1832
  }
1455
1833
 
1456
1834
  private static verifyEd25519(
@@ -1458,16 +1836,16 @@ export class TPSUID7RB {
1458
1836
  signature: Uint8Array,
1459
1837
  publicKey: string | Buffer | Uint8Array,
1460
1838
  ): boolean {
1461
- if (typeof require !== 'undefined') {
1839
+ if (typeof require !== "undefined") {
1462
1840
  try {
1463
1841
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1464
- const crypto = require('crypto');
1842
+ const crypto = require("crypto");
1465
1843
  return crypto.verify(null, data, publicKey, signature);
1466
1844
  } catch {
1467
1845
  return false;
1468
1846
  }
1469
1847
  }
1470
- throw new Error('TPSUID7RB: verification not available in browser');
1848
+ throw new Error("TPSUID7RB: verification not available in browser");
1471
1849
  }
1472
1850
 
1473
1851
  // ---------------------------
@@ -1477,21 +1855,276 @@ export class TPSUID7RB {
1477
1855
  /** Generate cryptographically secure random bytes */
1478
1856
  private static randomBytes(length: number): Uint8Array {
1479
1857
  // Node.js environment
1480
- if (typeof require !== 'undefined') {
1858
+ if (typeof require !== "undefined") {
1481
1859
  try {
1482
1860
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1483
- const crypto = require('crypto');
1861
+ const crypto = require("crypto");
1484
1862
  return new Uint8Array(crypto.randomBytes(length));
1485
1863
  } catch {
1486
1864
  // Fallback to crypto.getRandomValues
1487
1865
  }
1488
1866
  }
1489
1867
  // Browser or fallback
1490
- if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
1868
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
1491
1869
  const bytes = new Uint8Array(length);
1492
1870
  crypto.getRandomValues(bytes);
1493
1871
  return bytes;
1494
1872
  }
1495
- throw new Error('TPSUID7RB: no crypto available');
1873
+ throw new Error("TPSUID7RB: no crypto available");
1874
+ }
1875
+ }
1876
+
1877
+ /**
1878
+ * `TpsDate` is a Date-like wrapper with native TPS conversion helpers.
1879
+ *
1880
+ * It mirrors common JavaScript `Date` construction patterns:
1881
+ * - `new TpsDate()`
1882
+ * - `new TpsDate(ms)`
1883
+ * - `new TpsDate(isoString)`
1884
+ * - `new TpsDate(tpsString)`
1885
+ * - `new TpsDate(year, monthIndex, day?, hour?, minute?, second?, ms?)`
1886
+ */
1887
+ export class TpsDate {
1888
+ private readonly internal: Date;
1889
+
1890
+ private getTpsComponents(): TPSComponents {
1891
+ const parsed = TPS.parse(this.toTPS(DefaultCalendars.TPS));
1892
+ if (!parsed) {
1893
+ throw new Error("TpsDate: failed to derive TPS components");
1894
+ }
1895
+ return parsed;
1896
+ }
1897
+
1898
+ private getTpsFullYear(): number {
1899
+ const comp = this.getTpsComponents();
1900
+ return (comp.millennium - 1) * 1000 + (comp.century - 1) * 100 + comp.year;
1901
+ }
1902
+
1903
+ constructor();
1904
+ constructor(value: string | number | Date | TpsDate);
1905
+ constructor(
1906
+ year: number,
1907
+ monthIndex: number,
1908
+ day?: number,
1909
+ hours?: number,
1910
+ minutes?: number,
1911
+ seconds?: number,
1912
+ ms?: number,
1913
+ );
1914
+ constructor(
1915
+ ...args:
1916
+ | []
1917
+ | [string | number | Date | TpsDate]
1918
+ | [number, number, number?, number?, number?, number?, number?]
1919
+ ) {
1920
+ if (args.length === 0) {
1921
+ this.internal = new Date();
1922
+ return;
1923
+ }
1924
+
1925
+ if (args.length === 1) {
1926
+ const value = args[0];
1927
+ if (value instanceof TpsDate) {
1928
+ this.internal = new Date(value.getTime());
1929
+ return;
1930
+ }
1931
+ if (value instanceof Date) {
1932
+ this.internal = new Date(value.getTime());
1933
+ return;
1934
+ }
1935
+ if (typeof value === "string" && TpsDate.looksLikeTPS(value)) {
1936
+ const parsed = TPS.toDate(value);
1937
+ if (!parsed) {
1938
+ throw new RangeError(`Invalid TPS date string: ${value}`);
1939
+ }
1940
+ this.internal = parsed;
1941
+ return;
1942
+ }
1943
+
1944
+ this.internal = new Date(value);
1945
+ return;
1946
+ }
1947
+
1948
+ const [year, monthIndex, day, hours, minutes, seconds, ms] = args;
1949
+ this.internal = new Date(
1950
+ year,
1951
+ monthIndex,
1952
+ day ?? 1,
1953
+ hours ?? 0,
1954
+ minutes ?? 0,
1955
+ seconds ?? 0,
1956
+ ms ?? 0,
1957
+ );
1958
+ }
1959
+
1960
+ private static looksLikeTPS(input: string): boolean {
1961
+ const s = input.trim();
1962
+ return s.startsWith("tps://") || s.startsWith("T:") || s.startsWith("t:");
1963
+ }
1964
+
1965
+ static now(): number {
1966
+ return Date.now();
1967
+ }
1968
+
1969
+ static parse(input: string): number {
1970
+ if (this.looksLikeTPS(input)) {
1971
+ const d = TPS.toDate(input);
1972
+ return d ? d.getTime() : Number.NaN;
1973
+ }
1974
+ return Date.parse(input);
1975
+ }
1976
+
1977
+ static UTC(
1978
+ year: number,
1979
+ monthIndex: number,
1980
+ day?: number,
1981
+ hours?: number,
1982
+ minutes?: number,
1983
+ seconds?: number,
1984
+ ms?: number,
1985
+ ): number {
1986
+ return Date.UTC(
1987
+ year,
1988
+ monthIndex,
1989
+ day ?? 1,
1990
+ hours ?? 0,
1991
+ minutes ?? 0,
1992
+ seconds ?? 0,
1993
+ ms ?? 0,
1994
+ );
1995
+ }
1996
+
1997
+ static fromTPS(tps: string): TpsDate {
1998
+ return new TpsDate(tps);
1999
+ }
2000
+
2001
+ toGregorianDate(): Date {
2002
+ return new Date(this.internal.getTime());
2003
+ }
2004
+
2005
+ toDate(): Date {
2006
+ return this.toGregorianDate();
2007
+ }
2008
+
2009
+ toTPS(
2010
+ calendar: string = DefaultCalendars.TPS,
2011
+ opts?: { order?: TimeOrder },
2012
+ ): string {
2013
+ return TPS.fromDate(this.internal, calendar, opts);
2014
+ }
2015
+
2016
+ toTPSURI(
2017
+ calendar: string = DefaultCalendars.TPS,
2018
+ opts?: {
2019
+ order?: TimeOrder;
2020
+ latitude?: number;
2021
+ longitude?: number;
2022
+ altitude?: number;
2023
+ isUnknownLocation?: boolean;
2024
+ isHiddenLocation?: boolean;
2025
+ isRedactedLocation?: boolean;
2026
+ },
2027
+ ): string {
2028
+ const time = this.toTPS(calendar, { order: opts?.order });
2029
+ const comp = TPS.parse(time) as TPSComponents;
2030
+
2031
+ if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
2032
+ comp.latitude = opts.latitude;
2033
+ comp.longitude = opts.longitude;
2034
+ if (opts.altitude !== undefined) comp.altitude = opts.altitude;
2035
+ } else if (opts?.isHiddenLocation) {
2036
+ comp.isHiddenLocation = true;
2037
+ } else if (opts?.isRedactedLocation) {
2038
+ comp.isRedactedLocation = true;
2039
+ } else {
2040
+ comp.isUnknownLocation = true;
2041
+ }
2042
+
2043
+ return TPS.toURI(comp);
2044
+ }
2045
+
2046
+ getTime(): number {
2047
+ return this.internal.getTime();
2048
+ }
2049
+
2050
+ valueOf(): number {
2051
+ return this.internal.valueOf();
2052
+ }
2053
+
2054
+ toString(): string {
2055
+ return this.toTPS(DefaultCalendars.TPS);
2056
+ }
2057
+
2058
+ toISOString(): string {
2059
+ return this.internal.toISOString();
2060
+ }
2061
+
2062
+ toUTCString(): string {
2063
+ return this.internal.toUTCString();
2064
+ }
2065
+
2066
+ toJSON(): string | null {
2067
+ return this.internal.toJSON();
2068
+ }
2069
+
2070
+ getFullYear(): number {
2071
+ return this.getTpsFullYear();
2072
+ }
2073
+
2074
+ getUTCFullYear(): number {
2075
+ return this.getTpsFullYear();
2076
+ }
2077
+
2078
+ getMonth(): number {
2079
+ return this.getTpsComponents().month - 1;
2080
+ }
2081
+
2082
+ getUTCMonth(): number {
2083
+ return this.getTpsComponents().month - 1;
2084
+ }
2085
+
2086
+ getDate(): number {
2087
+ return this.getTpsComponents().day;
2088
+ }
2089
+
2090
+ getUTCDate(): number {
2091
+ return this.getTpsComponents().day;
2092
+ }
2093
+
2094
+ getHours(): number {
2095
+ return this.getTpsComponents().hour;
2096
+ }
2097
+
2098
+ getUTCHours(): number {
2099
+ return this.getTpsComponents().hour;
2100
+ }
2101
+
2102
+ getMinutes(): number {
2103
+ return this.getTpsComponents().minute;
2104
+ }
2105
+
2106
+ getUTCMinutes(): number {
2107
+ return this.getTpsComponents().minute;
2108
+ }
2109
+
2110
+ getSeconds(): number {
2111
+ return this.getTpsComponents().second;
2112
+ }
2113
+
2114
+ getUTCSeconds(): number {
2115
+ return this.getTpsComponents().second;
2116
+ }
2117
+
2118
+ getMilliseconds(): number {
2119
+ return this.getTpsComponents().millisecond;
2120
+ }
2121
+
2122
+ getUTCMilliseconds(): number {
2123
+ return this.getTpsComponents().millisecond;
2124
+ }
2125
+
2126
+ [Symbol.toPrimitive](hint: string): string | number {
2127
+ if (hint === "number") return this.valueOf();
2128
+ return this.toString();
1496
2129
  }
1497
2130
  }