@nextera.one/tps-standard 0.5.0 → 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
@@ -2,31 +2,96 @@
2
2
  * TPS: Temporal Positioning System
3
3
  * The Universal Protocol for Space-Time Coordinates.
4
4
  * @packageDocumentation
5
- * @version 0.4.2
6
- * @license MIT
5
+ * @version 0.5.0
6
+ * @license Apache-2.0
7
7
  * @copyright 2026 TPS Standards Working Group
8
+ *
9
+ * v0.5.0 Changes:
10
+ * - Added Actor anchor (A:) for provenance tracking
11
+ * - Added Signature (!) for cryptographic verification
12
+ * - Added structural anchors (bldg, floor, room, zone)
13
+ * - Added geospatial cell systems (S2, H3, Plus Code, what3words)
8
14
  */
9
15
 
10
- 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
+ }
11
43
 
12
44
  export interface TPSComponents {
13
45
  // --- TEMPORAL ---
14
- calendar: CalendarCode;
15
- millennium?: number;
16
- century?: number;
17
- year?: number;
18
- month?: number;
19
- day?: number;
20
- hour?: number;
21
- minute?: number;
22
- 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.
23
64
  unixSeconds?: number;
24
65
 
25
- // --- SPATIAL ---
66
+ // --- SPATIAL: GPS Coordinates ---
26
67
  latitude?: number;
27
68
  longitude?: number;
28
69
  altitude?: number;
29
70
 
71
+ // --- SPATIAL: Geospatial Cells ---
72
+ /** Google S2 cell ID (hierarchical, prefix-searchable) */
73
+ s2Cell?: string;
74
+ /** Uber H3 cell ID (hexagonal grid) */
75
+ h3Cell?: string;
76
+ /** Open Location Code / Plus Code */
77
+ plusCode?: string;
78
+ /** what3words address (e.g. "filled.count.soap") */
79
+ what3words?: string;
80
+
81
+ // --- SPATIAL: Structural Anchors ---
82
+ /** Physical building identifier */
83
+ building?: string;
84
+ /** Vertical division (level) */
85
+ floor?: string;
86
+ /** Enclosed space identifier */
87
+ room?: string;
88
+ /** Logical area within building */
89
+ zone?: string;
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
+
94
+ // --- SPATIAL: Privacy Markers ---
30
95
  /** Technical missing data (e.g. server log without GPS) */
31
96
  isUnknownLocation?: boolean;
32
97
  /** Removed for legal/security reasons (e.g. GDPR) */
@@ -34,8 +99,16 @@ export interface TPSComponents {
34
99
  /** Masked by user preference (e.g. "Don't show my location") */
35
100
  isHiddenLocation?: boolean;
36
101
 
102
+ // --- PROVENANCE ---
103
+ /** Actor anchor - identifies observer/witness (e.g. "did:web:sensor.example.com", "node:gateway-01") */
104
+ actor?: string;
105
+ /** Verification hash appended to time (e.g. "sha256:8f3e2a...") */
106
+ signature?: string;
107
+
37
108
  // --- CONTEXT ---
38
109
  extensions?: Record<string, string>;
110
+
111
+ order?: TimeOrder;
39
112
  }
40
113
 
41
114
  // --- PLUGIN ARCHITECTURE ---
@@ -91,7 +164,7 @@ export interface TPSComponents {
91
164
  */
92
165
  export interface CalendarDriver {
93
166
  /** The calendar code this driver handles (e.g., 'hij', 'jul'). */
94
- readonly code: CalendarCode;
167
+ readonly code: string;
95
168
 
96
169
  /**
97
170
  * Human-readable name for this calendar (optional).
@@ -100,25 +173,25 @@ export interface CalendarDriver {
100
173
  readonly name?: string;
101
174
 
102
175
  /**
103
- * Converts a Gregorian Date to this calendar's components.
176
+ * Converts a Date to this calendar's components.
104
177
  * @param date - The Gregorian Date object.
105
178
  * @returns Partial TPS components for year, month, day, etc.
106
179
  */
107
- fromGregorian(date: Date): Partial<TPSComponents>;
180
+ getComponentsFromDate(date: Date): Partial<TPSComponents>;
108
181
 
109
182
  /**
110
- * Converts this calendar's components to a Gregorian Date.
183
+ * Converts this calendar's components to a Date.
111
184
  * @param components - Partial TPS components (year, month, day, etc.).
112
185
  * @returns A JavaScript Date object.
113
186
  */
114
- toGregorian(components: Partial<TPSComponents>): Date;
187
+ getDateFromComponents(components: Partial<TPSComponents>): Date;
115
188
 
116
189
  /**
117
190
  * Generates a TPS time string for this calendar from a Date.
118
191
  * @param date - The Gregorian Date object.
119
- * @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...").
120
193
  */
121
- fromDate(date: Date): string;
194
+ getFromDate(date: Date): string;
122
195
 
123
196
  // --- NEW ENHANCED METHODS (Optional) ---
124
197
 
@@ -139,7 +212,7 @@ export interface CalendarDriver {
139
212
  * driver.parseDate('1447-07-21 14:30:00'); // → { year: 1447, month: 7, day: 21, hour: 14, ... }
140
213
  * ```
141
214
  */
142
- parseDate?(input: string, format?: string): Partial<TPSComponents>;
215
+ parseDate(input: string, format?: string): Partial<TPSComponents>;
143
216
 
144
217
  /**
145
218
  * Format TPS components to a calendar-specific date string.
@@ -155,7 +228,7 @@ export interface CalendarDriver {
155
228
  * driver.format({ year: 1447, month: 7, day: 21 }, 'short'); // → '21/7/1447'
156
229
  * ```
157
230
  */
158
- format?(components: Partial<TPSComponents>, format?: string): string;
231
+ format(components: Partial<TPSComponents>, format?: string): string;
159
232
 
160
233
  /**
161
234
  * Validate a calendar-specific date string or components.
@@ -169,7 +242,7 @@ export interface CalendarDriver {
169
242
  * driver.validate({ year: 1447, month: 7, day: 31 }); // → false (Rajab has 30 days)
170
243
  * ```
171
244
  */
172
- validate?(input: string | Partial<TPSComponents>): boolean;
245
+ validate(input: string | Partial<TPSComponents>): boolean;
173
246
 
174
247
  /**
175
248
  * Get calendar metadata (month names, day names, etc.).
@@ -181,7 +254,7 @@ export interface CalendarDriver {
181
254
  * // → ['Muharram', 'Safar', 'Rabi I', ...]
182
255
  * ```
183
256
  */
184
- getMetadata?(): CalendarMetadata;
257
+ getMetadata(): CalendarMetadata;
185
258
  }
186
259
 
187
260
  /**
@@ -208,8 +281,7 @@ export interface CalendarMetadata {
208
281
 
209
282
  export class TPS {
210
283
  // --- PLUGIN REGISTRY ---
211
- private static readonly drivers: Map<CalendarCode, CalendarDriver> =
212
- new Map();
284
+ private static readonly drivers: Map<string, CalendarDriver> = new Map();
213
285
 
214
286
  /**
215
287
  * Registers a calendar driver plugin.
@@ -224,35 +296,238 @@ export class TPS {
224
296
  * @param code - The calendar code.
225
297
  * @returns The driver or undefined.
226
298
  */
227
- static getDriver(code: CalendarCode): CalendarDriver | undefined {
299
+ static getDriver(code: string): CalendarDriver | undefined {
228
300
  return this.drivers.get(code);
229
301
  }
230
302
 
231
303
  // --- REGEX ---
304
+ // Updated for v0.5.0: supports L: anchors, A: actor, ! signature, structural & geospatial anchors
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).
232
309
  private static readonly REGEX_URI = new RegExp(
233
- '^tps://(?<space>unknown|redacted|hidden|(?<lat>-?\\d+(?:\\.\\d+)?),(?<lon>-?\\d+(?:\\.\\d+)?)(?:,(?<alt>-?\\d+(?:\\.\\d+)?)m?)?)@T:(?<calendar>[a-z]{3,4})\\.(?:(?<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+)?))?)?)?)?)?)?)?)?(?:;(?<extensions>[a-z0-9\\.\\-\\_]+))?$',
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
+ "(?:#.+)?$",
234
329
  );
235
330
 
236
331
  private static readonly REGEX_TIME = new RegExp(
237
- '^T:(?<calendar>[a-z]{3,4})\\.(?:(?<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+)?))?)?)?)?)?)?)?)?$',
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
+ "(?:![^;?#]+)?$",
238
335
  );
239
336
 
240
337
  // --- CORE METHODS ---
241
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
+
242
470
  static validate(input: string): boolean {
243
- if (input.startsWith('tps://')) return this.REGEX_URI.test(input);
244
- 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);
245
476
  }
246
477
 
247
478
  static parse(input: string): TPSComponents | null {
248
- 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://")) {
249
486
  const match = this.REGEX_URI.exec(input);
250
487
  if (!match || !match.groups) return null;
251
- 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;
252
513
  }
514
+ // time-only string
253
515
  const match = this.REGEX_TIME.exec(input);
254
516
  if (!match || !match.groups) return null;
255
- 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;
256
531
  }
257
532
 
258
533
  /**
@@ -261,89 +536,119 @@ export class TPS {
261
536
  * @returns Full URI string (e.g. "tps://...").
262
537
  */
263
538
  static toURI(comp: TPSComponents): string {
264
- // 1. Build Space Part
265
- let spacePart = 'unknown'; // Default safe fallback
539
+ // 1. Build Space Part (L: anchor)
540
+ let spacePart = "L:-"; // Default: unknown
266
541
 
267
- if (comp.isHiddenLocation) {
268
- spacePart = 'hidden';
542
+ if (comp.spaceAnchor) {
543
+ spacePart = comp.spaceAnchor;
544
+ } else if (comp.isHiddenLocation) {
545
+ spacePart = "L:~";
269
546
  } else if (comp.isRedactedLocation) {
270
- spacePart = 'redacted';
547
+ spacePart = "L:redacted";
271
548
  } else if (comp.isUnknownLocation) {
272
- spacePart = 'unknown';
549
+ spacePart = "L:-";
550
+ } else if (comp.s2Cell) {
551
+ spacePart = `L:s2=${comp.s2Cell}`;
552
+ } else if (comp.h3Cell) {
553
+ spacePart = `L:h3=${comp.h3Cell}`;
554
+ } else if (comp.plusCode) {
555
+ spacePart = `L:plus=${comp.plusCode}`;
556
+ } else if (comp.what3words) {
557
+ spacePart = `L:w3w=${comp.what3words}`;
558
+ } else if (comp.building) {
559
+ spacePart = `L:bldg=${comp.building}`;
560
+ if (comp.floor) spacePart += `.floor=${comp.floor}`;
561
+ if (comp.room) spacePart += `.room=${comp.room}`;
562
+ if (comp.zone) spacePart += `.zone=${comp.zone}`;
273
563
  } else if (comp.latitude !== undefined && comp.longitude !== undefined) {
274
- spacePart = `${comp.latitude},${comp.longitude}`;
564
+ spacePart = `L:${comp.latitude},${comp.longitude}`;
275
565
  if (comp.altitude !== undefined) {
276
566
  spacePart += `,${comp.altitude}m`;
277
567
  }
278
568
  }
279
569
 
280
- // 2. Build Time Part
281
- let timePart = `T:${comp.calendar}`;
570
+ // 2. Build Actor Part (A: anchor) - optional
571
+ let actorPart = "";
572
+ if (comp.actor) {
573
+ actorPart = `/A:${comp.actor}`;
574
+ }
282
575
 
283
- if (comp.calendar === 'unix' && comp.unixSeconds !== undefined) {
284
- timePart += `.s${comp.unixSeconds}`;
285
- } else {
286
- if (comp.millennium !== undefined) timePart += `.m${comp.millennium}`;
287
- if (comp.century !== undefined) timePart += `.c${comp.century}`;
288
- if (comp.year !== undefined) timePart += `.y${comp.year}`;
289
- if (comp.month !== undefined) timePart += `.M${this.pad(comp.month)}`;
290
- if (comp.day !== undefined) timePart += `.d${this.pad(comp.day)}`;
291
- if (comp.hour !== undefined) timePart += `.h${this.pad(comp.hour)}`;
292
- if (comp.minute !== undefined) timePart += `.n${this.pad(comp.minute)}`;
293
- if (comp.second !== undefined) timePart += `.s${this.pad(comp.second)}`;
294
- }
295
-
296
- // 3. Build Extensions
297
- let extPart = '';
576
+ // 3. Build Time Part (handles order & signature)
577
+ const timePart = this.buildTimePart(comp);
578
+
579
+ // 5. Build Extensions
580
+ let extPart = "";
298
581
  if (comp.extensions && Object.keys(comp.extensions).length > 0) {
299
582
  const extStrings = Object.entries(comp.extensions).map(
300
- ([k, v]) => `${k}${v}`,
583
+ ([k, v]) => `${k}=${v}`,
301
584
  );
302
- extPart = `;${extStrings.join('.')}`;
585
+ extPart = `;${extStrings.join(".")}`;
303
586
  }
304
587
 
305
- return `tps://${spacePart}@${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}`;
306
592
  }
307
593
 
308
594
  /**
309
595
  * CONVERTER: Creates a TPS Time Object string from a JavaScript Date.
310
596
  * Supports plugin drivers for non-Gregorian calendars.
311
597
  * @param date - The JS Date object (defaults to Now).
312
- * @param calendar - The target calendar driver (default 'greg').
313
- * @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...").
314
602
  */
315
603
  static fromDate(
316
604
  date: Date = new Date(),
317
- calendar: CalendarCode = 'greg',
605
+ calendar: string = DefaultCalendars.TPS,
606
+ opts?: { order?: TimeOrder },
318
607
  ): string {
319
- // Check for registered driver first
320
- const driver = this.drivers.get(calendar);
608
+ const normalizedCalendar = calendar.toLowerCase();
609
+ const driver = this.drivers.get(normalizedCalendar);
321
610
  if (driver) {
322
- 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);
323
622
  }
324
623
 
325
- // Built-in handlers
326
- 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) {
327
629
  const s = (date.getTime() / 1000).toFixed(3);
328
- return `T:unix.s${s}`;
630
+ comp.unixSeconds = parseFloat(s);
631
+ if (opts?.order) comp.order = opts.order;
632
+ return this.buildTimePart(comp);
329
633
  }
330
634
 
331
- if (calendar === 'greg') {
635
+ if (normalizedCalendar === DefaultCalendars.GREG) {
332
636
  const fullYear = date.getUTCFullYear();
333
- const m = Math.floor(fullYear / 1000) + 1;
334
- const c = Math.floor((fullYear % 1000) / 100) + 1;
335
- const y = fullYear % 100;
336
- const M = date.getUTCMonth() + 1;
337
- const d = date.getUTCDate();
338
- const h = date.getUTCHours();
339
- const n = date.getUTCMinutes();
340
- const s = date.getUTCSeconds();
341
-
342
- return `T:greg.m${m}.c${c}.y${y}.M${this.pad(M)}.d${this.pad(d)}.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);
343
648
  }
344
649
 
345
650
  throw new Error(
346
- `Calendar driver '${calendar}' not implemented. Register a driver.`,
651
+ `Calendar driver '${normalizedCalendar}' not implemented. Register a driver.`,
347
652
  );
348
653
  }
349
654
 
@@ -354,7 +659,7 @@ export class TPS {
354
659
  * @param targetCalendar - The target calendar code (e.g., 'hij').
355
660
  * @returns A TPS string in the target calendar, or null if invalid.
356
661
  */
357
- static to(targetCalendar: CalendarCode, tpsString: string): string | null {
662
+ static to(targetCalendar: string, tpsString: string): string | null {
358
663
  // 1. Parse to components and convert to Gregorian Date
359
664
  const gregDate = this.toDate(tpsString);
360
665
  if (!gregDate) return null;
@@ -370,45 +675,25 @@ export class TPS {
370
675
  * @returns JS Date object or `null` if invalid.
371
676
  */
372
677
  static toDate(tpsString: string): Date | null {
373
- const p = this.parse(tpsString);
374
- if (!p) return null;
678
+ const parsed = this.parse(tpsString);
679
+ if (!parsed) return null;
375
680
 
376
- // Check for registered driver first
377
- const driver = this.drivers.get(p.calendar);
378
- if (driver) {
379
- return driver.toGregorian(p);
380
- }
681
+ const cal = parsed.calendar || DefaultCalendars.TPS;
381
682
 
382
- // Built-in handlers
383
- if (p.calendar === 'unix' && p.unixSeconds !== undefined) {
384
- 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;
385
687
  }
386
688
 
387
- if (p.calendar === 'greg') {
388
- const m = p.millennium || 0;
389
- const c = p.century || 1;
390
- const y = p.year || 0;
391
- const fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
392
-
393
- return new Date(
394
- Date.UTC(
395
- fullYear,
396
- (p.month || 1) - 1,
397
- p.day || 1,
398
- p.hour || 0,
399
- p.minute || 0,
400
- Math.floor(p.second || 0),
401
- ),
402
- );
403
- }
404
- return null;
689
+ return driver.getDateFromComponents(parsed);
405
690
  }
406
691
 
407
692
  // --- DRIVER CONVENIENCE METHODS ---
408
693
 
409
694
  /**
410
695
  * Parse a calendar-specific date string into TPS components.
411
- * Requires the driver to implement the optional `parseDate` method.
696
+ * Requires the driver to implement `parseDate`.
412
697
  *
413
698
  * @param calendar - The calendar code (e.g., 'hij')
414
699
  * @param dateString - Date string in calendar-native format (e.g., '1447-07-21')
@@ -421,11 +706,11 @@ export class TPS {
421
706
  * // { calendar: 'hij', year: 1447, month: 7, day: 21 }
422
707
  *
423
708
  * const uri = TPS.toURI({ ...components, latitude: 31.95, longitude: 35.91 });
424
- * // "tps://31.95,35.91@T:hij.y1447.M07.d21"
709
+ * // "tps://31.95,35.91@T:hij.y1447.m07.d21"
425
710
  * ```
426
711
  */
427
712
  static parseCalendarDate(
428
- calendar: CalendarCode,
713
+ calendar: string,
429
714
  dateString: string,
430
715
  format?: string,
431
716
  ): Partial<TPSComponents> | null {
@@ -435,11 +720,7 @@ export class TPS {
435
720
  `Calendar driver '${calendar}' not found. Register a driver first.`,
436
721
  );
437
722
  }
438
- if (!driver.parseDate) {
439
- throw new Error(
440
- `Driver '${calendar}' does not implement parseDate(). Use fromGregorian() instead.`,
441
- );
442
- }
723
+ // parseDate is guaranteed by the interface, so we can call it directly.
443
724
  return driver.parseDate(dateString, format);
444
725
  }
445
726
 
@@ -456,19 +737,19 @@ export class TPS {
456
737
  * ```ts
457
738
  * // With coordinates
458
739
  * TPS.fromCalendarDate('hij', '1447-07-21', { latitude: 31.95, longitude: 35.91 });
459
- * // "tps://31.95,35.91@T:hij.y1447.M07.d21"
740
+ * // "tps://31.95,35.91@T:hij.y1447.m07.d21"
460
741
  *
461
742
  * // With privacy flag
462
743
  * TPS.fromCalendarDate('hij', '1447-07-21', { isHiddenLocation: true });
463
- * // "tps://hidden@T:hij.y1447.M07.d21"
744
+ * // "tps://hidden@T:hij.y1447.m07.d21"
464
745
  *
465
746
  * // Without location
466
747
  * TPS.fromCalendarDate('hij', '1447-07-21');
467
- * // "tps://unknown@T:hij.y1447.M07.d21"
748
+ * // "tps://unknown@T:hij.y1447.m07.d21"
468
749
  * ```
469
750
  */
470
751
  static fromCalendarDate(
471
- calendar: CalendarCode,
752
+ calendar: string,
472
753
  dateString: string,
473
754
  location?: {
474
755
  latitude?: number;
@@ -486,7 +767,7 @@ export class TPS {
486
767
 
487
768
  // Merge with location
488
769
  const fullComponents: TPSComponents = {
489
- calendar,
770
+ calendar: calendar,
490
771
  ...components,
491
772
  ...location,
492
773
  } as TPSComponents;
@@ -496,7 +777,7 @@ export class TPS {
496
777
 
497
778
  /**
498
779
  * Format TPS components to a calendar-specific date string.
499
- * Requires the driver to implement the optional `format` method.
780
+ * Requires the driver to implement `format`.
500
781
  *
501
782
  * @param calendar - The calendar code
502
783
  * @param components - TPS components to format
@@ -505,13 +786,13 @@ export class TPS {
505
786
  *
506
787
  * @example
507
788
  * ```ts
508
- * const tps = TPS.parse('tps://unknown@T:hij.y1447.M07.d21');
789
+ * const tps = TPS.parse('tps://unknown@T:hij.y1447.m07.d21');
509
790
  * const formatted = TPS.formatCalendarDate('hij', tps);
510
791
  * // "1447-07-21"
511
792
  * ```
512
793
  */
513
794
  static formatCalendarDate(
514
- calendar: CalendarCode,
795
+ calendar: string,
515
796
  components: Partial<TPSComponents>,
516
797
  format?: string,
517
798
  ): string {
@@ -519,39 +800,277 @@ export class TPS {
519
800
  if (!driver) {
520
801
  throw new Error(`Calendar driver '${calendar}' not found.`);
521
802
  }
522
- if (!driver.format) {
523
- throw new Error(`Driver '${calendar}' does not implement format().`);
524
- }
803
+ // format is guaranteed by the interface, so we can call it directly.
525
804
  return driver.format(components, format);
526
805
  }
527
806
 
528
807
  // --- INTERNAL HELPERS ---
529
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
+
530
1026
  private static _mapGroupsToComponents(
531
1027
  g: Record<string, string>,
532
1028
  ): TPSComponents {
533
1029
  const components: any = {};
534
- components.calendar = g.calendar as CalendarCode;
1030
+ components.calendar = g.calendar as string;
535
1031
 
536
- // Time Mapping
537
- if (components.calendar === 'unix' && g.unix) {
538
- components.unixSeconds = parseFloat(g.unix.substring(1));
539
- } else {
540
- if (g.millennium) components.millennium = parseInt(g.millennium, 10);
541
- if (g.century) components.century = parseInt(g.century, 10);
542
- if (g.year) components.year = parseInt(g.year, 10);
543
- if (g.month) components.month = parseInt(g.month, 10);
544
- if (g.day) components.day = parseInt(g.day, 10);
545
- if (g.hour) components.hour = parseInt(g.hour, 10);
546
- if (g.minute) components.minute = parseInt(g.minute, 10);
547
- if (g.second) components.second = parseFloat(g.second);
1032
+ // Signature Mapping
1033
+ if (g.signature) {
1034
+ components.signature = g.signature;
1035
+ }
1036
+
1037
+ // Actor Mapping
1038
+ if (g.actor) {
1039
+ components.actor = g.actor;
548
1040
  }
549
1041
 
550
1042
  // Space Mapping
551
1043
  if (g.space) {
552
- if (g.space === 'unknown') components.isUnknownLocation = true;
553
- else if (g.space === 'redacted') components.isRedactedLocation = true;
554
- else if (g.space === 'hidden') components.isHiddenLocation = true;
1044
+ // Privacy markers
1045
+ if (g.space === "unknown" || g.space === "-") {
1046
+ components.isUnknownLocation = true;
1047
+ } else if (g.space === "redacted") {
1048
+ components.isRedactedLocation = true;
1049
+ } else if (g.space === "hidden" || g.space === "~") {
1050
+ components.isHiddenLocation = true;
1051
+ }
1052
+ // Geospatial cells
1053
+ else if (g.s2) {
1054
+ components.s2Cell = g.s2;
1055
+ } else if (g.h3) {
1056
+ components.h3Cell = g.h3;
1057
+ } else if (g.plus) {
1058
+ components.plusCode = g.plus;
1059
+ } else if (g.w3w) {
1060
+ components.what3words = g.w3w;
1061
+ }
1062
+ // Structural anchors
1063
+ else if (g.bldg) {
1064
+ components.building = g.bldg;
1065
+ if (g.floor) components.floor = g.floor;
1066
+ if (g.room) components.room = g.room;
1067
+ if (g.zone) components.zone = g.zone;
1068
+ }
1069
+ // Generic pre-@ anchor (adm/node/net/planet/etc)
1070
+ else if (g.generic) {
1071
+ components.spaceAnchor = g.generic;
1072
+ }
1073
+ // GPS coordinates
555
1074
  else {
556
1075
  if (g.lat) components.latitude = parseFloat(g.lat);
557
1076
  if (g.lon) components.longitude = parseFloat(g.lon);
@@ -562,24 +1081,33 @@ export class TPS {
562
1081
  // Extensions Mapping
563
1082
  if (g.extensions) {
564
1083
  const extObj: any = {};
565
- const parts = g.extensions.split('.');
1084
+ const parts = g.extensions.split(".");
566
1085
  parts.forEach((p: string) => {
567
- const key = p.charAt(0);
568
- const val = p.substring(1);
569
- if (key && val) extObj[key] = val;
1086
+ const eqIdx = p.indexOf("=");
1087
+ if (eqIdx > 0) {
1088
+ const key = p.substring(0, eqIdx);
1089
+ const val = p.substring(eqIdx + 1);
1090
+ if (key && val) extObj[key] = val;
1091
+ } else {
1092
+ // Legacy format: first char is key
1093
+ const key = p.charAt(0);
1094
+ const val = p.substring(1);
1095
+ if (key && val) extObj[key] = val;
1096
+ }
570
1097
  });
571
1098
  components.extensions = extObj;
572
1099
  }
573
1100
 
574
1101
  return components as TPSComponents;
575
1102
  }
576
-
577
- private static pad(n: number): string {
578
- const s = n.toString();
579
- return s.length < 2 ? '0' + s : s;
580
- }
581
1103
  }
582
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
+
583
1111
  // --- TPS-UID v1 Types ---
584
1112
 
585
1113
  /**
@@ -587,7 +1115,7 @@ export class TPS {
587
1115
  */
588
1116
  export type TPSUID7RBDecodeResult = {
589
1117
  /** Version identifier */
590
- version: 'tpsuid7rb';
1118
+ version: "tpsuid7rb";
591
1119
  /** Epoch milliseconds (UTC) */
592
1120
  epochMs: number;
593
1121
  /** Whether the TPS payload was compressed */
@@ -628,7 +1156,7 @@ export type TPSUID7RBEncodeOptions = {
628
1156
  *
629
1157
  * @example
630
1158
  * ```ts
631
- * 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';
632
1160
  *
633
1161
  * // Encode to binary
634
1162
  * const bytes = TPSUID7RB.encodeBinary(tps);
@@ -648,7 +1176,7 @@ export class TPSUID7RB {
648
1176
  /** Version 1 */
649
1177
  private static readonly VER = 0x01;
650
1178
  /** String prefix for base64url encoded form */
651
- private static readonly PREFIX = 'tpsuid7rb_';
1179
+ private static readonly PREFIX = "tpsuid7rb_";
652
1180
  /** Regex for validating base64url encoded form */
653
1181
  public static readonly REGEX = /^tpsuid7rb_[A-Za-z0-9_-]+$/;
654
1182
 
@@ -664,15 +1192,18 @@ export class TPSUID7RB {
664
1192
  * @param opts - Encoding options (compress, epochMs override)
665
1193
  * @returns Binary TPS-UID as Uint8Array
666
1194
  */
667
- static encodeBinary(tps: string, opts?: TPSUID7RBEncodeOptions): Uint8Array {
668
- const compress = opts?.compress ?? false;
669
- 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);
670
1201
 
671
1202
  if (!Number.isInteger(epochMs) || epochMs < 0) {
672
- throw new Error('epochMs must be a non-negative integer');
1203
+ throw new Error("epochMs must be a non-negative integer");
673
1204
  }
674
1205
  if (epochMs > 0xffffffffffff) {
675
- throw new Error('epochMs exceeds 48-bit range');
1206
+ throw new Error("epochMs exceeds 48-bit range");
676
1207
  }
677
1208
 
678
1209
  const flags = compress ? 0x01 : 0x00;
@@ -738,7 +1269,7 @@ export class TPSUID7RB {
738
1269
  static decodeBinary(bytes: Uint8Array): TPSUID7RBDecodeResult {
739
1270
  // Header min size: 4+1+1+6+4 + 1 (at least 1 byte varint) = 17
740
1271
  if (bytes.length < 17) {
741
- throw new Error('TPSUID7RB: too short');
1272
+ throw new Error("TPSUID7RB: too short");
742
1273
  }
743
1274
 
744
1275
  // MAGIC
@@ -748,7 +1279,7 @@ export class TPSUID7RB {
748
1279
  bytes[2] !== 0x55 ||
749
1280
  bytes[3] !== 0x37
750
1281
  ) {
751
- throw new Error('TPSUID7RB: bad magic');
1282
+ throw new Error("TPSUID7RB: bad magic");
752
1283
  }
753
1284
 
754
1285
  // VERSION
@@ -777,7 +1308,7 @@ export class TPSUID7RB {
777
1308
  offset += bytesRead;
778
1309
 
779
1310
  if (offset + tpsLen > bytes.length) {
780
- throw new Error('TPSUID7RB: length overflow');
1311
+ throw new Error("TPSUID7RB: length overflow");
781
1312
  }
782
1313
 
783
1314
  // TPS payload
@@ -785,7 +1316,7 @@ export class TPSUID7RB {
785
1316
  const tpsUtf8 = compressed ? this.inflateRaw(payload) : payload;
786
1317
  const tps = new TextDecoder().decode(tpsUtf8);
787
1318
 
788
- return { version: 'tpsuid7rb', epochMs, compressed, nonce, tps };
1319
+ return { version: "tpsuid7rb", epochMs, compressed, nonce, tps };
789
1320
  }
790
1321
 
791
1322
  /**
@@ -797,7 +1328,7 @@ export class TPSUID7RB {
797
1328
  * @returns Base64url encoded TPS-UID with prefix
798
1329
  */
799
1330
  static encodeBinaryB64(tps: string, opts?: TPSUID7RBEncodeOptions): string {
800
- const bytes = this.encodeBinary(tps, opts);
1331
+ const bytes = this.encodeBinary(tps, opts ?? {});
801
1332
  return `${this.PREFIX}${this.base64UrlEncode(bytes)}`;
802
1333
  }
803
1334
 
@@ -810,7 +1341,7 @@ export class TPSUID7RB {
810
1341
  static decodeBinaryB64(id: string): TPSUID7RBDecodeResult {
811
1342
  const s = id.trim();
812
1343
  if (!s.startsWith(this.PREFIX)) {
813
- throw new Error('TPSUID7RB: missing prefix');
1344
+ throw new Error("TPSUID7RB: missing prefix");
814
1345
  }
815
1346
  const b64 = s.slice(this.PREFIX.length);
816
1347
  const bytes = this.base64UrlDecode(b64);
@@ -839,9 +1370,23 @@ export class TPSUID7RB {
839
1370
  longitude?: number;
840
1371
  altitude?: number;
841
1372
  compress?: boolean;
1373
+ order?: TimeOrder;
842
1374
  }): string {
843
1375
  const now = new Date();
844
- 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
+
845
1390
  return this.encodeBinaryB64(tps, {
846
1391
  compress: opts?.compress,
847
1392
  epochMs: now.getTime(),
@@ -855,25 +1400,36 @@ export class TPSUID7RB {
855
1400
  /**
856
1401
  * Generate a TPS string from a Date and optional location.
857
1402
  */
1403
+ // NOTE: this helper is primarily used by `generate()`; drivers and
1404
+ // callers should prefer `TPS.fromDate()` when order or calendars matter.
858
1405
  private static generateTPSString(
859
1406
  date: Date,
860
- opts?: { latitude?: number; longitude?: number; altitude?: number },
1407
+ opts?: {
1408
+ latitude?: number;
1409
+ longitude?: number;
1410
+ altitude?: number;
1411
+ order?: TimeOrder;
1412
+ },
861
1413
  ): string {
862
1414
  const fullYear = date.getUTCFullYear();
863
- const m = Math.floor(fullYear / 1000) + 1;
864
- const c = Math.floor((fullYear % 1000) / 100) + 1;
865
- const y = fullYear % 100;
866
- const M = date.getUTCMonth() + 1;
867
- const d = date.getUTCDate();
868
- const h = date.getUTCHours();
869
- const n = date.getUTCMinutes();
870
- const s = date.getUTCSeconds();
871
-
872
- const pad = (num: number) => num.toString().padStart(2, '0');
873
-
874
- const timePart = `T:greg.m${m}.c${c}.y${y}.M${pad(M)}.d${pad(d)}.h${pad(h)}.n${pad(n)}.s${pad(s)}`;
875
-
876
- 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";
877
1433
  if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
878
1434
  spacePart = `${opts.latitude},${opts.longitude}`;
879
1435
  if (opts.altitude !== undefined) {
@@ -889,63 +1445,15 @@ export class TPSUID7RB {
889
1445
  * Supports both URI format (tps://...) and time-only format (T:greg...)
890
1446
  */
891
1447
  static epochMsFromTPSString(tps: string): number {
892
- let time: string;
893
-
894
- if (tps.includes('@')) {
895
- // URI format: tps://...@T:greg...
896
- const at = tps.indexOf('@');
897
- time = tps.slice(at + 1).trim();
898
- } else if (tps.startsWith('T:')) {
899
- // Time-only format
900
- time = tps;
901
- } else {
902
- throw new Error('TPS: unrecognized format');
903
- }
904
-
905
- if (!time.startsWith('T:greg.')) {
906
- throw new Error('TPS: only T:greg.* parsing is supported');
907
- }
908
-
909
- // Extract m (millennium), c (century), y (year)
910
- const mMatch = time.match(/\.m(-?\d+)/);
911
- const cMatch = time.match(/\.c(\d+)/);
912
- const yMatch = time.match(/\.y(\d{1,4})/);
913
- const MMatch = time.match(/\.M(\d{1,2})/);
914
- const dMatch = time.match(/\.d(\d{1,2})/);
915
- const hMatch = time.match(/\.h(\d{1,2})/);
916
- const nMatch = time.match(/\.n(\d{1,2})/);
917
- const sMatch = time.match(/\.s(\d{1,2})/);
918
-
919
- // Calculate full year from millennium, century, year
920
- let fullYear: number;
921
- if (mMatch && cMatch && yMatch) {
922
- const millennium = parseInt(mMatch[1], 10);
923
- const century = parseInt(cMatch[1], 10);
924
- const year = parseInt(yMatch[1], 10);
925
- fullYear = (millennium - 1) * 1000 + (century - 1) * 100 + year;
926
- } else if (yMatch) {
927
- // Fallback: interpret y as 2-digit year
928
- let year = parseInt(yMatch[1], 10);
929
- if (year < 100) {
930
- year = year <= 69 ? 2000 + year : 1900 + year;
931
- }
932
- fullYear = year;
933
- } else {
934
- throw new Error('TPS: missing year component');
935
- }
936
-
937
- const month = MMatch ? parseInt(MMatch[1], 10) : 1;
938
- const day = dMatch ? parseInt(dMatch[1], 10) : 1;
939
- const hour = hMatch ? parseInt(hMatch[1], 10) : 0;
940
- const minute = nMatch ? parseInt(nMatch[1], 10) : 0;
941
- const second = sMatch ? parseInt(sMatch[1], 10) : 0;
942
-
943
- const epoch = Date.UTC(fullYear, month - 1, day, hour, minute, second);
944
- if (!Number.isFinite(epoch)) {
945
- throw new Error('TPS: failed to compute epochMs');
946
- }
947
-
948
- 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();
949
1457
  }
950
1458
 
951
1459
  // ---------------------------
@@ -978,7 +1486,7 @@ export class TPSUID7RB {
978
1486
 
979
1487
  const n = Number(v);
980
1488
  if (!Number.isSafeInteger(n)) {
981
- throw new Error('TPSUID7RB: u48 not safe integer');
1489
+ throw new Error("TPSUID7RB: u48 not safe integer");
982
1490
  }
983
1491
  return n;
984
1492
  }
@@ -986,7 +1494,7 @@ export class TPSUID7RB {
986
1494
  /** Encode unsigned integer as LEB128 varint */
987
1495
  private static uvarintEncode(n: number): Uint8Array {
988
1496
  if (!Number.isInteger(n) || n < 0) {
989
- throw new Error('uvarint must be non-negative int');
1497
+ throw new Error("uvarint must be non-negative int");
990
1498
  }
991
1499
  const out: number[] = [];
992
1500
  let x = n >>> 0;
@@ -1008,12 +1516,12 @@ export class TPSUID7RB {
1008
1516
  let i = 0;
1009
1517
  while (true) {
1010
1518
  if (offset + i >= bytes.length) {
1011
- throw new Error('uvarint overflow');
1519
+ throw new Error("uvarint overflow");
1012
1520
  }
1013
1521
  const b = bytes[offset + i];
1014
1522
  if (b < 0x80) {
1015
1523
  if (i > 9 || (i === 9 && b > 1)) {
1016
- throw new Error('uvarint too large');
1524
+ throw new Error("uvarint too large");
1017
1525
  }
1018
1526
  x |= b << s;
1019
1527
  return { value: x >>> 0, bytesRead: i + 1 };
@@ -1022,7 +1530,7 @@ export class TPSUID7RB {
1022
1530
  s += 7;
1023
1531
  i++;
1024
1532
  if (i > 10) {
1025
- throw new Error('uvarint too long');
1533
+ throw new Error("uvarint too long");
1026
1534
  }
1027
1535
  }
1028
1536
  }
@@ -1034,35 +1542,35 @@ export class TPSUID7RB {
1034
1542
  /** Encode bytes to base64url (no padding) */
1035
1543
  private static base64UrlEncode(bytes: Uint8Array): string {
1036
1544
  // Node.js environment
1037
- if (typeof Buffer !== 'undefined') {
1545
+ if (typeof Buffer !== "undefined") {
1038
1546
  return Buffer.from(bytes)
1039
- .toString('base64')
1040
- .replace(/\+/g, '-')
1041
- .replace(/\//g, '_')
1042
- .replace(/=+$/g, '');
1547
+ .toString("base64")
1548
+ .replace(/\+/g, "-")
1549
+ .replace(/\//g, "_")
1550
+ .replace(/=+$/g, "");
1043
1551
  }
1044
1552
  // Browser environment
1045
- let binary = '';
1553
+ let binary = "";
1046
1554
  for (let i = 0; i < bytes.length; i++) {
1047
1555
  binary += String.fromCharCode(bytes[i]);
1048
1556
  }
1049
1557
  return btoa(binary)
1050
- .replace(/\+/g, '-')
1051
- .replace(/\//g, '_')
1052
- .replace(/=+$/g, '');
1558
+ .replace(/\+/g, "-")
1559
+ .replace(/\//g, "_")
1560
+ .replace(/=+$/g, "");
1053
1561
  }
1054
1562
 
1055
1563
  /** Decode base64url to bytes */
1056
1564
  private static base64UrlDecode(b64url: string): Uint8Array {
1057
1565
  // Add padding
1058
1566
  const padLen = (4 - (b64url.length % 4)) % 4;
1059
- const b64 = (b64url + '='.repeat(padLen))
1060
- .replace(/-/g, '+')
1061
- .replace(/_/g, '/');
1567
+ const b64 = (b64url + "=".repeat(padLen))
1568
+ .replace(/-/g, "+")
1569
+ .replace(/_/g, "/");
1062
1570
 
1063
1571
  // Node.js environment
1064
- if (typeof Buffer !== 'undefined') {
1065
- return new Uint8Array(Buffer.from(b64, 'base64'));
1572
+ if (typeof Buffer !== "undefined") {
1573
+ return new Uint8Array(Buffer.from(b64, "base64"));
1066
1574
  }
1067
1575
  // Browser environment
1068
1576
  const binary = atob(b64);
@@ -1080,33 +1588,33 @@ export class TPSUID7RB {
1080
1588
  /** Compress using zlib deflate raw */
1081
1589
  private static deflateRaw(data: Uint8Array): Uint8Array {
1082
1590
  // Node.js environment
1083
- if (typeof require !== 'undefined') {
1591
+ if (typeof require !== "undefined") {
1084
1592
  try {
1085
1593
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1086
- const zlib = require('zlib');
1594
+ const zlib = require("zlib");
1087
1595
  return new Uint8Array(zlib.deflateRawSync(Buffer.from(data)));
1088
1596
  } catch {
1089
- throw new Error('TPSUID7RB: compression not available');
1597
+ throw new Error("TPSUID7RB: compression not available");
1090
1598
  }
1091
1599
  }
1092
1600
  // Browser: would need pako or similar library
1093
- throw new Error('TPSUID7RB: compression not available in browser');
1601
+ throw new Error("TPSUID7RB: compression not available in browser");
1094
1602
  }
1095
1603
 
1096
1604
  /** Decompress using zlib inflate raw */
1097
1605
  private static inflateRaw(data: Uint8Array): Uint8Array {
1098
1606
  // Node.js environment
1099
- if (typeof require !== 'undefined') {
1607
+ if (typeof require !== "undefined") {
1100
1608
  try {
1101
1609
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1102
- const zlib = require('zlib');
1610
+ const zlib = require("zlib");
1103
1611
  return new Uint8Array(zlib.inflateRawSync(Buffer.from(data)));
1104
1612
  } catch {
1105
- throw new Error('TPSUID7RB: decompression failed');
1613
+ throw new Error("TPSUID7RB: decompression failed");
1106
1614
  }
1107
1615
  }
1108
1616
  // Browser: would need pako or similar library
1109
- throw new Error('TPSUID7RB: decompression not available in browser');
1617
+ throw new Error("TPSUID7RB: decompression not available in browser");
1110
1618
  }
1111
1619
 
1112
1620
  // ---------------------------
@@ -1141,7 +1649,7 @@ export class TPSUID7RB {
1141
1649
 
1142
1650
  // Validate epoch
1143
1651
  if (!Number.isInteger(epochMs) || epochMs < 0 || epochMs > 0xffffffffffff) {
1144
- 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");
1145
1653
  }
1146
1654
 
1147
1655
  // Flags: Bit 0 = compress, Bit 1 = sealed
@@ -1160,12 +1668,16 @@ export class TPSUID7RB {
1160
1668
  const content = new Uint8Array(contentLen);
1161
1669
  let offset = 0;
1162
1670
 
1163
- content.set(this.MAGIC, offset); offset += 4;
1671
+ content.set(this.MAGIC, offset);
1672
+ offset += 4;
1164
1673
  content[offset++] = this.VER;
1165
1674
  content[offset++] = flags;
1166
- content.set(this.writeU48(epochMs), offset); offset += 6;
1167
- content.set(nonceBuf, offset); offset += 4;
1168
- content.set(lenVar, offset); offset += lenVar.length;
1675
+ content.set(this.writeU48(epochMs), offset);
1676
+ offset += 6;
1677
+ content.set(nonceBuf, offset);
1678
+ offset += 4;
1679
+ content.set(lenVar, offset);
1680
+ offset += lenVar.length;
1169
1681
  content.set(payload, offset);
1170
1682
 
1171
1683
  // Sign the content
@@ -1193,7 +1705,7 @@ export class TPSUID7RB {
1193
1705
  sealedBytes: Uint8Array,
1194
1706
  publicKey: string | Buffer | Uint8Array,
1195
1707
  ): TPSUID7RBDecodeResult {
1196
- if (sealedBytes.length < 18) throw new Error('TPSUID7RB: too short');
1708
+ if (sealedBytes.length < 18) throw new Error("TPSUID7RB: too short");
1197
1709
 
1198
1710
  // Check Magic
1199
1711
  if (
@@ -1202,25 +1714,28 @@ export class TPSUID7RB {
1202
1714
  sealedBytes[2] !== 0x55 ||
1203
1715
  sealedBytes[3] !== 0x37
1204
1716
  ) {
1205
- throw new Error('TPSUID7RB: bad magic');
1717
+ throw new Error("TPSUID7RB: bad magic");
1206
1718
  }
1207
1719
 
1208
1720
  // Check Flags for Sealed Bit (bit 1)
1209
1721
  const flags = sealedBytes[5];
1210
1722
  if ((flags & 0x02) === 0) {
1211
- throw new Error('TPSUID7RB: not a sealed UID');
1723
+ throw new Error("TPSUID7RB: not a sealed UID");
1212
1724
  }
1213
1725
 
1214
1726
  // 1. Parse the structure to find where content ends
1215
1727
  // We need to parse LEN and Payload to find the split point
1216
1728
  let offset = 16; // Start of LEN
1217
1729
  // Decode LEN
1218
- const { value: tpsLen, bytesRead } = this.uvarintDecode(sealedBytes, offset);
1730
+ const { value: tpsLen, bytesRead } = this.uvarintDecode(
1731
+ sealedBytes,
1732
+ offset,
1733
+ );
1219
1734
  offset += bytesRead;
1220
1735
  const payloadEnd = offset + tpsLen;
1221
1736
 
1222
1737
  if (payloadEnd > sealedBytes.length) {
1223
- throw new Error('TPSUID7RB: length overflow (truncated)');
1738
+ throw new Error("TPSUID7RB: length overflow (truncated)");
1224
1739
  }
1225
1740
 
1226
1741
  // The Content to verify matches exactly [0 ... payloadEnd]
@@ -1228,23 +1743,27 @@ export class TPSUID7RB {
1228
1743
 
1229
1744
  // After content: SealType (1 byte) + Signature
1230
1745
  if (sealedBytes.length <= payloadEnd + 1) {
1231
- throw new Error('TPSUID7RB: missing signature data');
1746
+ throw new Error("TPSUID7RB: missing signature data");
1232
1747
  }
1233
1748
 
1234
1749
  const sealType = sealedBytes[payloadEnd];
1235
1750
  if (sealType !== 0x01) {
1236
- throw new Error(`TPSUID7RB: unsupported seal type 0x${sealType.toString(16)}`);
1751
+ throw new Error(
1752
+ `TPSUID7RB: unsupported seal type 0x${sealType.toString(16)}`,
1753
+ );
1237
1754
  }
1238
1755
 
1239
1756
  const signature = sealedBytes.slice(payloadEnd + 1);
1240
1757
  if (signature.length !== 64) {
1241
- throw new Error(`TPSUID7RB: invalid Ed25519 signature length ${signature.length}`);
1758
+ throw new Error(
1759
+ `TPSUID7RB: invalid Ed25519 signature length ${signature.length}`,
1760
+ );
1242
1761
  }
1243
1762
 
1244
1763
  // Verify
1245
1764
  const isValid = this.verifyEd25519(content, signature, publicKey);
1246
1765
  if (!isValid) {
1247
- throw new Error('TPSUID7RB: signature verification failed');
1766
+ throw new Error("TPSUID7RB: signature verification failed");
1248
1767
  }
1249
1768
 
1250
1769
  // Decode (reuse standard logic, but ignoring the extra bytes at end is fine?)
@@ -1262,28 +1781,28 @@ export class TPSUID7RB {
1262
1781
  data: Uint8Array,
1263
1782
  privateKey: string | Buffer | Uint8Array,
1264
1783
  ): Uint8Array {
1265
- if (typeof require !== 'undefined') {
1784
+ if (typeof require !== "undefined") {
1266
1785
  try {
1267
1786
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1268
- const crypto = require('crypto');
1787
+ const crypto = require("crypto");
1269
1788
  // Node's crypto.sign uses PEM or KeyObject, but for raw Ed25519 keys we might need 'crypto.sign(null, data, key)'
1270
1789
  // or ensure key is properly formatted.
1271
1790
  // For simplicity in Node 20+, crypto.sign(null, data, privateKey) works if key is KeyObject.
1272
1791
  // If raw bytes: establish KeyObject.
1273
-
1792
+
1274
1793
  let keyObj;
1275
1794
  if (Buffer.isBuffer(privateKey) || privateKey instanceof Uint8Array) {
1276
- // Assuming raw 64-byte private key (or 32-byte seed properly expanded by crypto)
1277
- // Node < 16 is tricky with raw keys.
1278
- // Let's assume standard Ed25519 standard implementation pattern logic:
1279
- keyObj = crypto.createPrivateKey({
1280
- key: Buffer.from(privateKey),
1281
- format: 'der', // or 'pem' - strict.
1282
- type: 'pkcs8'
1283
- });
1284
- // Actually, simpler: construct key object from raw bytes if possible?
1285
- // Node's crypto is strict. Let's try the simplest:
1286
- // If hex string provided, convert to buffer.
1795
+ // Assuming raw 64-byte private key (or 32-byte seed properly expanded by crypto)
1796
+ // Node < 16 is tricky with raw keys.
1797
+ // Let's assume standard Ed25519 standard implementation pattern logic:
1798
+ keyObj = crypto.createPrivateKey({
1799
+ key: Buffer.from(privateKey),
1800
+ format: "der", // or 'pem' - strict.
1801
+ type: "pkcs8",
1802
+ });
1803
+ // Actually, simpler: construct key object from raw bytes if possible?
1804
+ // Node's crypto is strict. Let's try the simplest:
1805
+ // If hex string provided, convert to buffer.
1287
1806
  }
1288
1807
 
1289
1808
  // Simpler fallback: If user passed a PEM string, great.
@@ -1292,20 +1811,24 @@ export class TPSUID7RB {
1292
1811
  // and assume the user provides a VALID key object or compatible format (PEM/DER).
1293
1812
  // Handling RAW Ed25519 keys in Node requires specific 'crypto.createPrivateKey' with 'raw' format (Node 11.6+).
1294
1813
 
1295
- const key = typeof privateKey === 'string' && !privateKey.includes('PRIVATE KEY')
1296
- ? crypto.createPrivateKey({ key: Buffer.from(privateKey, 'hex'), format: 'pem', type: 'pkcs8' }) // Fallback guess
1297
- : privateKey;
1814
+ const key =
1815
+ typeof privateKey === "string" && !privateKey.includes("PRIVATE KEY")
1816
+ ? crypto.createPrivateKey({
1817
+ key: Buffer.from(privateKey, "hex"),
1818
+ format: "pem",
1819
+ type: "pkcs8",
1820
+ }) // Fallback guess
1821
+ : privateKey;
1298
1822
 
1299
1823
  // Note: Raw Ed25519 key support in Node.js 'crypto' acts via 'generateKeyPair' or KeyObject.
1300
1824
  // Direct raw signing is via crypto.sign(null, data, key).
1301
1825
  return new Uint8Array(crypto.sign(null, data, key));
1302
-
1303
1826
  } catch (e) {
1304
1827
  // If standard crypto fails (e.g. key format issue), throw
1305
- throw new Error('TPSUID7RB: signing failed (check key format)');
1828
+ throw new Error("TPSUID7RB: signing failed (check key format)");
1306
1829
  }
1307
1830
  }
1308
- throw new Error('TPSUID7RB: signing not available in browser');
1831
+ throw new Error("TPSUID7RB: signing not available in browser");
1309
1832
  }
1310
1833
 
1311
1834
  private static verifyEd25519(
@@ -1313,16 +1836,16 @@ export class TPSUID7RB {
1313
1836
  signature: Uint8Array,
1314
1837
  publicKey: string | Buffer | Uint8Array,
1315
1838
  ): boolean {
1316
- if (typeof require !== 'undefined') {
1839
+ if (typeof require !== "undefined") {
1317
1840
  try {
1318
1841
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1319
- const crypto = require('crypto');
1842
+ const crypto = require("crypto");
1320
1843
  return crypto.verify(null, data, publicKey, signature);
1321
1844
  } catch {
1322
1845
  return false;
1323
1846
  }
1324
1847
  }
1325
- throw new Error('TPSUID7RB: verification not available in browser');
1848
+ throw new Error("TPSUID7RB: verification not available in browser");
1326
1849
  }
1327
1850
 
1328
1851
  // ---------------------------
@@ -1332,22 +1855,276 @@ export class TPSUID7RB {
1332
1855
  /** Generate cryptographically secure random bytes */
1333
1856
  private static randomBytes(length: number): Uint8Array {
1334
1857
  // Node.js environment
1335
- if (typeof require !== 'undefined') {
1858
+ if (typeof require !== "undefined") {
1336
1859
  try {
1337
1860
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1338
- const crypto = require('crypto');
1861
+ const crypto = require("crypto");
1339
1862
  return new Uint8Array(crypto.randomBytes(length));
1340
1863
  } catch {
1341
1864
  // Fallback to crypto.getRandomValues
1342
1865
  }
1343
1866
  }
1344
1867
  // Browser or fallback
1345
- if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
1868
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
1346
1869
  const bytes = new Uint8Array(length);
1347
1870
  crypto.getRandomValues(bytes);
1348
1871
  return bytes;
1349
1872
  }
1350
- throw new Error('TPSUID7RB: no crypto available');
1873
+ throw new Error("TPSUID7RB: no crypto available");
1351
1874
  }
1352
1875
  }
1353
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();
2129
+ }
2130
+ }