@nextera.one/tps-standard 0.5.34 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +133 -56
  3. package/dist/driver-manager.d.ts +34 -0
  4. package/dist/driver-manager.js +53 -0
  5. package/dist/driver-manager.js.map +1 -0
  6. package/dist/drivers/chinese.d.ts +25 -0
  7. package/dist/drivers/chinese.js +485 -0
  8. package/dist/drivers/chinese.js.map +1 -0
  9. package/dist/esm/date.js +170 -0
  10. package/dist/esm/date.js.map +1 -0
  11. package/dist/esm/driver-manager.js +49 -0
  12. package/dist/esm/driver-manager.js.map +1 -0
  13. package/dist/esm/drivers/chinese.js +481 -0
  14. package/dist/esm/drivers/chinese.js.map +1 -0
  15. package/dist/esm/drivers/gregorian.js +160 -0
  16. package/dist/esm/drivers/gregorian.js.map +1 -0
  17. package/dist/esm/drivers/hijri.js +184 -0
  18. package/dist/esm/drivers/hijri.js.map +1 -0
  19. package/dist/esm/drivers/holocene.js +115 -0
  20. package/dist/esm/drivers/holocene.js.map +1 -0
  21. package/dist/esm/drivers/julian.js +161 -0
  22. package/dist/esm/drivers/julian.js.map +1 -0
  23. package/dist/esm/drivers/persian.js +190 -0
  24. package/dist/esm/drivers/persian.js.map +1 -0
  25. package/dist/esm/drivers/tps.js +181 -0
  26. package/dist/esm/drivers/tps.js.map +1 -0
  27. package/dist/esm/drivers/unix.js +50 -0
  28. package/dist/esm/drivers/unix.js.map +1 -0
  29. package/dist/esm/index.js +873 -0
  30. package/dist/esm/index.js.map +1 -0
  31. package/dist/esm/types.js +28 -0
  32. package/dist/esm/types.js.map +1 -0
  33. package/dist/esm/uid.js +221 -0
  34. package/dist/esm/uid.js.map +1 -0
  35. package/dist/esm/utils/calendar.js +126 -0
  36. package/dist/esm/utils/calendar.js.map +1 -0
  37. package/dist/esm/utils/env.js +76 -0
  38. package/dist/esm/utils/env.js.map +1 -0
  39. package/dist/esm/utils/timezone.js +168 -0
  40. package/dist/esm/utils/timezone.js.map +1 -0
  41. package/dist/esm/utils/tps-string.js +160 -0
  42. package/dist/esm/utils/tps-string.js.map +1 -0
  43. package/dist/index.d.ts +91 -2
  44. package/dist/index.js +412 -132
  45. package/dist/index.js.map +1 -1
  46. package/dist/types.d.ts +19 -1
  47. package/dist/types.js +1 -0
  48. package/dist/types.js.map +1 -1
  49. package/dist/uid.js +1 -1
  50. package/dist/uid.js.map +1 -1
  51. package/dist/utils/timezone.d.ts +32 -0
  52. package/dist/utils/timezone.js +173 -0
  53. package/dist/utils/timezone.js.map +1 -0
  54. package/package.json +20 -5
  55. package/src/driver-manager.ts +54 -0
  56. package/src/drivers/chinese.ts +542 -0
  57. package/src/index.ts +379 -123
  58. package/src/types.ts +26 -2
  59. package/src/uid.ts +2 -2
  60. package/src/utils/timezone.ts +182 -0
package/src/index.ts CHANGED
@@ -2,10 +2,18 @@
2
2
  * TPS: Temporal Positioning System
3
3
  * The Universal Protocol for Space-Time Coordinates.
4
4
  * @packageDocumentation
5
- * @version 0.5.34
5
+ * @version 0.6.0
6
6
  * @license Apache-2.0
7
7
  * @copyright 2026 TPS Standards Working Group
8
8
  *
9
+ * v0.5.35 Changes:
10
+ * - Added TPS.now(), TPS.diff(), TPS.add() convenience methods
11
+ * - Added Chinese Lunisolar (chin) calendar driver
12
+ * - Added DriverManager (driver registry separated from TPS class)
13
+ * - Added timezone utility (src/utils/timezone.ts) with IANA + offset support
14
+ * - TPS.toDate() now respects ;tz= extensions when present
15
+ * - ESM dual-mode exports + browser IIFE bundle
16
+ *
9
17
  * v0.5.0 Changes:
10
18
  * - Added Actor anchor (A:) for provenance tracking
11
19
  * - Added Signature (!) for cryptographic verification
@@ -22,12 +30,17 @@ import { PersianDriver } from "./drivers/persian";
22
30
  import { HijriDriver } from "./drivers/hijri";
23
31
  import { JulianDriver } from "./drivers/julian";
24
32
  import { HoloceneDriver } from "./drivers/holocene";
33
+ import { ChineseDriver } from "./drivers/chinese";
25
34
 
26
35
  export * from "./types";
27
36
  export * from "./uid";
28
37
  export * from "./date";
29
38
  export { Env } from "./utils/env";
39
+ export { DriverManager } from "./driver-manager";
40
+ export { utcToLocal, localToUtc, getOffsetString } from "./utils/timezone";
41
+ import { DriverManager } from "./driver-manager";
30
42
  import { buildTimePart, parseTimeString } from "./utils/tps-string";
43
+ import { localToUtc } from "./utils/timezone";
31
44
 
32
45
  import {
33
46
  CalendarDriver,
@@ -38,14 +51,15 @@ import {
38
51
 
39
52
  export class TPS {
40
53
  // --- PLUGIN REGISTRY ---
41
- private static readonly drivers: Map<string, CalendarDriver> = new Map();
54
+ /** Shared DriverManager instance use TPS.driverManager for direct access. */
55
+ static readonly driverManager = new DriverManager();
42
56
 
43
57
  /**
44
58
  * Registers a calendar driver plugin.
45
59
  * @param driver - The driver instance to register.
46
60
  */
47
61
  static registerDriver(driver: CalendarDriver): void {
48
- this.drivers.set(driver.code, driver);
62
+ this.driverManager.register(driver);
49
63
  }
50
64
 
51
65
  /**
@@ -54,41 +68,44 @@ export class TPS {
54
68
  * @returns The driver or undefined.
55
69
  */
56
70
  static getDriver(code: string): CalendarDriver | undefined {
57
- return this.drivers.get(code);
71
+ return this.driverManager.get(code);
58
72
  }
59
73
 
60
- // --- REGEX ---
61
- // Updated for v0.5.0: supports L: anchors, A: actor, ! signature, structural & geospatial anchors
62
- // Tokens may appear in any order; actual semantic parsing happens in
63
- // `parseTimeString()` so these patterns are intentionally permissive.
64
- // regex simply ensures prefix, space, calendar, and token characters;
65
- // token order is not enforced (parseTimeString handles semantics).
74
+ // --- REGEX (v0.6.0) ---
75
+ // The URI and time regexes are intentionally permissive in the location &
76
+ // extension sections detailed semantic parsing happens in
77
+ // _mapGroupsToComponents() and the layer parsers below.
78
+ //
79
+ // Structure:
80
+ // tps://[location]/A:[actor]@T:[cal].[tokens];[ext];...#C:[ctx];...
81
+ //
82
+ // The `;` separator is used consistently:
83
+ // - between location layers (before @T:)
84
+ // - between extensions (after T: tokens, before #)
85
+ // - between context key=val pairs (after #C:)
66
86
  private static readonly REGEX_URI = new RegExp(
67
87
  "^tps://" +
68
- // Location part (preserve named captures for space subfields)
69
- "(?:L:)?(?<space>" +
70
- "~|-|unknown|redacted|hidden|" +
71
- "s2=(?<s2>[a-fA-F0-9]+)|" +
72
- "h3=(?<h3>[a-fA-F0-9]+)|" +
73
- "plus=(?<plus>[A-Z0-9+]+)|" +
74
- "w3w=(?<w3w>[a-z]+\\.[a-z]+\\.[a-z]+)|" +
75
- "bldg=(?<bldg>[\\w-]+)(?:\\.floor=(?<floor>[\\w-]+))?(?:\\.room=(?<room>[\\w-]+))?(?:\\.zone=(?<zone>[\\w-]+))?|" +
76
- "(?<lat>-?\\d+(?:\\.\\d+)?),(?<lon>-?\\d+(?:\\.\\d+)?)(?:,(?<alt>-?\\d+(?:\\.\\d+)?)m?)?|" +
77
- "(?<generic>[^@/?#]+)" +
78
- ")" +
79
- "(?:/A:(?<actor>[^/@]+))?" +
88
+ // Location: everything up to optional /A: actor and then @T:
89
+ "(?<location>[^@]+?)" +
90
+ // Optional actor overlay
91
+ "(?:/A:(?<actor>[^@]+))?" +
92
+ // Time section
80
93
  "@T:(?<calendar>[a-z]{3,4})" +
81
- "(?:\\.(?:m-?\\d+|c\\d+|y\\d+|d\\d{1,2}|h\\d{1,2}|s\\d+(?:\\.\\d+)?))*" +
82
- "(?:![^;?#]+)?" +
83
- "(?:;(?<extensions>[^?#]+))?" +
84
- "(?:\\?[^#]+)?" +
85
- "(?:#.+)?$",
94
+ "(?<tokens>(?:\\.[a-z]-?[\\d.]+)*)" +
95
+ // Optional signature
96
+ "(?:!(?<signature>[^;#]+))?" +
97
+ // Optional extensions (;KEY:val;key=val;...)
98
+ "(?:;(?<extensions>[^#]+))?" +
99
+ // Optional context fragment (#C:key=val;...)
100
+ "(?:#C:(?<context>.+))?$",
86
101
  );
87
102
 
88
103
  private static readonly REGEX_TIME = new RegExp(
89
104
  "^T:(?<calendar>[a-z]{3,4})" +
90
- "(?:\\.(?:m-?\\d+|c\\d+|y\\d+|d\\d{1,2}|h\\d{1,2}|s\\d+(?:\\.\\d+)?))*" +
91
- "(?:![^;?#]+)?$",
105
+ "(?<tokens>(?:\\.[a-z]-?[\\d.]+)*)" +
106
+ "(?:!(?<signature>[^;#]+))?" +
107
+ "(?:;(?<extensions>[^#]+))?" +
108
+ "(?:#C:(?<context>.+))?$",
92
109
  );
93
110
 
94
111
  // --- CORE METHODS ---
@@ -106,6 +123,22 @@ export class TPS {
106
123
  let s = input.trim().replace(/\s+/g, "");
107
124
  if (!s) return s;
108
125
 
126
+ // ── 1.2 Compact scheme normalization (v0.6.0) ──────────────────────────
127
+ // TPS:... → tps://... (generic compact)
128
+ // NIP4:x → tps://net:ip4:x (IPv4 shorthand)
129
+ // NIP6:x → tps://net:ip6:x (IPv6 shorthand)
130
+ // NODE:x → tps://node:x (logical node shorthand)
131
+ if (/^TPS:/i.test(s) && !s.toLowerCase().startsWith("tps://")) {
132
+ // TPS:L:... or TPS:lat,lon... → tps://...
133
+ s = "tps://" + s.slice(4); // strip 'TPS:'
134
+ } else if (/^NIP4:/i.test(s)) {
135
+ s = "tps://net:ip4:" + s.slice(5);
136
+ } else if (/^NIP6:/i.test(s)) {
137
+ s = "tps://net:ip6:" + s.slice(5);
138
+ } else if (/^NODE:/i.test(s)) {
139
+ s = "tps://node:" + s.slice(5);
140
+ }
141
+
109
142
  // ── 1.5 Convert legacy "/T:" separators to the new canonical "@T:".
110
143
  // The input may contain "/T:" from older versions; we normalise early so
111
144
  // subsequent logic can assume only the '@' form.
@@ -276,12 +309,33 @@ export class TPS {
276
309
  if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
277
310
  signature = sigMatch.groups.sig;
278
311
  timeOnly = input.split(/[!;?#]/)[0];
312
+ } else {
313
+ // Strip extension/query/fragment suffix so parseTimeString sees only tokens
314
+ timeOnly = input.split(/[;?#]/)[0];
279
315
  }
280
316
  const parsed = parseTimeString(timeOnly);
281
317
  if (!parsed) return null;
282
318
  const comp = parsed.components as TPSComponents;
283
319
  if (signature) comp.signature = signature;
284
320
  comp.order = parsed.order;
321
+
322
+ // Route through the same group mapper used by REGEX_URI for consistency
323
+ // (handles extensions ;KEY:val and context #C:key=val)
324
+ const syntheticGroups: Record<string, string> = {
325
+ calendar: match.groups.calendar ?? "",
326
+ signature: match.groups.signature ?? "",
327
+ extensions: match.groups.extensions ?? "",
328
+ context: match.groups.context ?? "",
329
+ location: "", // no location in time-only string
330
+ actor: "",
331
+ };
332
+ const mappedComp = this._mapGroupsToComponents(syntheticGroups);
333
+ // Merge temporal components from parseTimeString with mapped metadata
334
+ Object.assign(comp, {
335
+ signature: mappedComp.signature || comp.signature,
336
+ extensions: mappedComp.extensions || comp.extensions,
337
+ context: mappedComp.context,
338
+ });
285
339
  return comp;
286
340
  }
287
341
 
@@ -291,59 +345,87 @@ export class TPS {
291
345
  * @returns Full URI string (e.g. "tps://...").
292
346
  */
293
347
  static toURI(comp: TPSComponents): string {
294
- // 1. Build Space Part (L: anchor)
295
- let spacePart = "L:-"; // Default: unknown
348
+ // ── 1. Location layers (v0.6.0) ──────────────────────────────────────────
349
+ // Build an ordered list of location layer strings, then join with ";"
350
+ const layers: string[] = [];
296
351
 
297
- if (comp.spaceAnchor) {
298
- spacePart = comp.spaceAnchor;
299
- } else if (comp.isHiddenLocation) {
300
- spacePart = "L:~";
352
+ // Privacy shorthand takes priority
353
+ if (comp.isHiddenLocation) {
354
+ layers.push("L:~");
301
355
  } else if (comp.isRedactedLocation) {
302
- spacePart = "L:redacted";
356
+ layers.push("L:redacted");
303
357
  } else if (comp.isUnknownLocation) {
304
- spacePart = "L:-";
358
+ layers.push("L:-");
359
+ } else if (comp.spaceAnchor) {
360
+ // Generic / legacy anchor (adm:, planet:, etc.)
361
+ layers.push(comp.spaceAnchor);
362
+ } else if (comp.ipv4) {
363
+ layers.push(`net:ip4:${comp.ipv4}`);
364
+ } else if (comp.ipv6) {
365
+ layers.push(`net:ip6:${comp.ipv6}`);
366
+ } else if (comp.nodeName) {
367
+ layers.push(`node:${comp.nodeName}`);
305
368
  } else if (comp.s2Cell) {
306
- spacePart = `L:s2=${comp.s2Cell}`;
369
+ layers.push(`S2:${comp.s2Cell}`);
307
370
  } else if (comp.h3Cell) {
308
- spacePart = `L:h3=${comp.h3Cell}`;
309
- } else if (comp.plusCode) {
310
- spacePart = `L:plus=${comp.plusCode}`;
371
+ layers.push(`H3:${comp.h3Cell}`);
311
372
  } else if (comp.what3words) {
312
- spacePart = `L:w3w=${comp.what3words}`;
373
+ layers.push(`3W:${comp.what3words}`);
374
+ } else if (comp.plusCode) {
375
+ layers.push(`plus:${comp.plusCode}`);
313
376
  } else if (comp.building) {
314
- spacePart = `L:bldg=${comp.building}`;
315
- if (comp.floor) spacePart += `.floor=${comp.floor}`;
316
- if (comp.room) spacePart += `.room=${comp.room}`;
317
- if (comp.zone) spacePart += `.zone=${comp.zone}`;
377
+ layers.push(`bldg:${comp.building}`);
378
+ if (comp.floor) layers.push(`floor:${comp.floor}`);
379
+ if (comp.room) layers.push(`room:${comp.room}`);
380
+ if (comp.door) layers.push(`door:${comp.door}`);
381
+ if (comp.zone) layers.push(`zone:${comp.zone}`);
318
382
  } else if (comp.latitude !== undefined && comp.longitude !== undefined) {
319
- spacePart = `L:${comp.latitude},${comp.longitude}`;
320
- if (comp.altitude !== undefined) {
321
- spacePart += `,${comp.altitude}m`;
322
- }
383
+ let gps = `L:${comp.latitude},${comp.longitude}`;
384
+ if (comp.altitude !== undefined) gps += `,${comp.altitude}m`;
385
+ layers.push(gps);
386
+ } else {
387
+ layers.push("L:-"); // unknown fallback
323
388
  }
324
389
 
325
- // 2. Build Actor Part (A: anchor) - optional
326
- let actorPart = "";
327
- if (comp.actor) {
328
- actorPart = `/A:${comp.actor}`;
390
+ // Place layer (P:) appended after primary location
391
+ if (
392
+ comp.placeCountryCode || comp.placeCountryName ||
393
+ comp.placeCityCode || comp.placeCityName
394
+ ) {
395
+ const pParts: string[] = [];
396
+ if (comp.placeCountryCode) pParts.push(`cc=${comp.placeCountryCode}`);
397
+ if (comp.placeCountryName) pParts.push(`cn=${comp.placeCountryName}`);
398
+ if (comp.placeCityCode) pParts.push(`ci=${comp.placeCityCode}`);
399
+ if (comp.placeCityName) pParts.push(`ct=${comp.placeCityName}`);
400
+ layers.push(`P:${pParts.join(",")}`);
329
401
  }
330
402
 
331
- // 3. Build Time Part (handles order & signature)
403
+ const locationStr = layers.join(";");
404
+
405
+ // ── 2. Actor (/A:...) ─────────────────────────────────────────────────────
406
+ const actorPart = comp.actor ? `/A:${comp.actor}` : "";
407
+
408
+ // ── 3. Time (mandatory 9 tokens) ─────────────────────────────────────────
332
409
  const timePart = buildTimePart(comp);
333
410
 
334
- // 5. Build Extensions
411
+ // ── 4. Extensions (;KEY:val;...) ─────────────────────────────────────────
335
412
  let extPart = "";
336
413
  if (comp.extensions && Object.keys(comp.extensions).length > 0) {
337
- const extStrings = Object.entries(comp.extensions).map(
338
- ([k, v]) => `${k}=${v}`,
339
- );
340
- extPart = `;${extStrings.join(".")}`;
414
+ const extStrings = Object.entries(comp.extensions).map(([k, v]) => {
415
+ // Emit as KEY:val (preferred v0.6.0 style)
416
+ return `${k.toUpperCase()}:${v}`;
417
+ });
418
+ extPart = `;${extStrings.join(";")}`;
341
419
  }
342
420
 
343
- // timePart already begins with 'T:'. The new canonical separator is '@'
344
- // instead of '/', so we interpolate it accordingly. Actor anchor (if
345
- // present) still uses a leading slash.
346
- return `tps://${spacePart}${actorPart}@${timePart}${extPart}`;
421
+ // ── 5. Context (#C:key=val;...) ──────────────────────────────────────────
422
+ let contextPart = "";
423
+ if (comp.context && Object.keys(comp.context).length > 0) {
424
+ const ctxStrings = Object.entries(comp.context).map(([k, v]) => `${k}=${v}`);
425
+ contextPart = `#C:${ctxStrings.join(";")}`;
426
+ }
427
+
428
+ return `tps://${locationStr}${actorPart}@${timePart}${extPart}${contextPart}`;
347
429
  }
348
430
 
349
431
  /**
@@ -361,7 +443,7 @@ export class TPS {
361
443
  opts?: { order?: TimeOrder },
362
444
  ): string {
363
445
  const normalizedCalendar = calendar.toLowerCase();
364
- const driver = this.drivers.get(normalizedCalendar);
446
+ const driver = this.driverManager.get(normalizedCalendar);
365
447
  if (driver) {
366
448
  // when caller requested an explicit order we can bypass the driver's
367
449
  // `fromDate` helper and instead generate components ourselves so that
@@ -435,13 +517,24 @@ export class TPS {
435
517
 
436
518
  const cal = parsed.calendar || DefaultCalendars.TPS;
437
519
 
438
- const driver = this.drivers.get(cal);
520
+ const driver = this.driverManager.get(cal);
439
521
  if (!driver) {
440
522
  console.error(`Calendar driver '${cal}' not registered.`);
441
523
  return null;
442
524
  }
443
525
 
444
- return driver.getDateFromComponents(parsed);
526
+ const date = driver.getDateFromComponents(parsed);
527
+
528
+ // If the URI has a ;tz= extension, the calendar date was expressed in local
529
+ // time. Convert from local → UTC using the timezone utility.
530
+ const tz = parsed.extensions?.["tz"];
531
+ if (tz && date) {
532
+ const localMs = date.getTime();
533
+ const utcMs = localToUtc(localMs, tz);
534
+ return new Date(utcMs);
535
+ }
536
+
537
+ return date;
445
538
  }
446
539
 
447
540
  // --- DRIVER CONVENIENCE METHODS ---
@@ -469,7 +562,7 @@ export class TPS {
469
562
  dateString: string,
470
563
  format?: string,
471
564
  ): Partial<TPSComponents> | null {
472
- const driver = this.drivers.get(calendar);
565
+ const driver = this.driverManager.get(calendar);
473
566
  if (!driver) {
474
567
  throw new Error(
475
568
  `Calendar driver '${calendar}' not found. Register a driver first.`,
@@ -551,7 +644,7 @@ export class TPS {
551
644
  components: Partial<TPSComponents>,
552
645
  format?: string,
553
646
  ): string {
554
- const driver = this.drivers.get(calendar);
647
+ const driver = this.driverManager.get(calendar);
555
648
  if (!driver) {
556
649
  throw new Error(`Calendar driver '${calendar}' not found.`);
557
650
  }
@@ -559,6 +652,98 @@ export class TPS {
559
652
  return driver.format(components, format);
560
653
  }
561
654
 
655
+ // --- CONVENIENCE METHODS ---
656
+
657
+ /**
658
+ * Returns a TPS time string for the current moment.
659
+ * Shorthand for `TPS.fromDate(new Date(), calendar, opts)`.
660
+ *
661
+ * @param calendar - Calendar code. Defaults to 'greg'.
662
+ * @param opts - Optional `order` (ASC/DESC) parameter.
663
+ * @returns TPS time string.
664
+ *
665
+ * @example
666
+ * ```ts
667
+ * TPS.now(); // "T:greg.m3.c1.y26.m3.d4.h06.m30.s00.m0"
668
+ * TPS.now('hij'); // "T:hij.y1447.m09.d05.h06.m30.s00"
669
+ * ```
670
+ */
671
+ static now(
672
+ calendar: string = DefaultCalendars.GREG,
673
+ opts?: { order?: TimeOrder },
674
+ ): string {
675
+ return this.fromDate(new Date(), calendar, opts) as string;
676
+ }
677
+
678
+ /**
679
+ * Returns the difference in milliseconds between two TPS strings.
680
+ * The result is `t2 - t1`; negative if t1 is after t2.
681
+ *
682
+ * @param t1 - First TPS string (subtracted from t2).
683
+ * @param t2 - Second TPS string.
684
+ * @returns Milliseconds between the two moments, or NaN on parse failure.
685
+ *
686
+ * @example
687
+ * ```ts
688
+ * const ms = TPS.diff('T:greg.m3.c1.y26.m1.d1.h0.m0.s0.m0',
689
+ * 'T:greg.m3.c1.y26.m1.d2.h0.m0.s0.m0');
690
+ * // 86_400_000 (one day)
691
+ * ```
692
+ */
693
+ static diff(t1: string, t2: string): number {
694
+ const d1 = this.toDate(t1);
695
+ const d2 = this.toDate(t2);
696
+ if (!d1 || !d2) return NaN;
697
+ return d2.getTime() - d1.getTime();
698
+ }
699
+
700
+ /**
701
+ * Returns a new TPS string shifted by the given duration.
702
+ * The result is in the same calendar as the original string.
703
+ *
704
+ * @param tpsStr - Source TPS string.
705
+ * @param duration - Object with optional `days`, `hours`, `minutes`, `seconds`, `milliseconds`.
706
+ * @returns Shifted TPS string, or null if the input is invalid.
707
+ *
708
+ * @example
709
+ * ```ts
710
+ * const t = 'T:greg.m3.c1.y26.m1.d9.h14.m30.s25.m0';
711
+ * TPS.add(t, { days: 7 }); // one week later
712
+ * TPS.add(t, { hours: -2 }); // two hours earlier
713
+ * ```
714
+ */
715
+ static add(
716
+ tpsStr: string,
717
+ duration: {
718
+ days?: number;
719
+ hours?: number;
720
+ minutes?: number;
721
+ seconds?: number;
722
+ milliseconds?: number;
723
+ },
724
+ ): string | null {
725
+ const date = this.toDate(tpsStr);
726
+ if (!date) return null;
727
+
728
+ const parsed = this.parse(tpsStr);
729
+ const calendar = parsed?.calendar ?? DefaultCalendars.GREG;
730
+ const order = parsed?.order;
731
+
732
+ const deltaMs =
733
+ (duration.days ?? 0) * 86_400_000 +
734
+ (duration.hours ?? 0) * 3_600_000 +
735
+ (duration.minutes ?? 0) * 60_000 +
736
+ (duration.seconds ?? 0) * 1_000 +
737
+ (duration.milliseconds ?? 0);
738
+
739
+ const shifted = new Date(date.getTime() + deltaMs);
740
+ return this.fromDate(
741
+ shifted,
742
+ calendar,
743
+ order ? { order } : undefined,
744
+ ) as string;
745
+ }
746
+
562
747
  // --- INTERNAL HELPERS ---
563
748
 
564
749
  private static _mapGroupsToComponents(
@@ -567,77 +752,147 @@ export class TPS {
567
752
  const components: any = {};
568
753
  components.calendar = g.calendar as string;
569
754
 
570
- // Signature Mapping
755
+ // ── Signature ────────────────────────────────────────────────────────────
571
756
  if (g.signature) {
572
757
  components.signature = g.signature;
573
758
  }
574
759
 
575
- // Actor Mapping
760
+ // ── Actor (/A:...) ────────────────────────────────────────────────────────
576
761
  if (g.actor) {
577
- components.actor = g.actor;
762
+ components.actor = g.actor.trim();
578
763
  }
579
764
 
580
- // Space Mapping
581
- if (g.space) {
582
- // Privacy markers
583
- if (g.space === "unknown" || g.space === "-") {
584
- components.isUnknownLocation = true;
585
- } else if (g.space === "redacted") {
586
- components.isRedactedLocation = true;
587
- } else if (g.space === "hidden" || g.space === "~") {
588
- components.isHiddenLocation = true;
589
- }
590
- // Geospatial cells
591
- else if (g.s2) {
592
- components.s2Cell = g.s2;
593
- } else if (g.h3) {
594
- components.h3Cell = g.h3;
595
- } else if (g.plus) {
596
- components.plusCode = g.plus;
597
- } else if (g.w3w) {
598
- components.what3words = g.w3w;
599
- }
600
- // Structural anchors
601
- else if (g.bldg) {
602
- components.building = g.bldg;
603
- if (g.floor) components.floor = g.floor;
604
- if (g.room) components.room = g.room;
605
- if (g.zone) components.zone = g.zone;
606
- }
607
- // Generic pre-@ anchor (adm/node/net/planet/etc)
608
- else if (g.generic) {
609
- components.spaceAnchor = g.generic;
610
- }
611
- // GPS coordinates
612
- else {
613
- if (g.lat) components.latitude = parseFloat(g.lat);
614
- if (g.lon) components.longitude = parseFloat(g.lon);
615
- if (g.alt) components.altitude = parseFloat(g.alt);
616
- }
765
+ // ── Location layers (v0.6.0: multi-layer, ;-separated) ───────────────────
766
+ if (g.location) {
767
+ this._parseLocationLayers(g.location, components);
617
768
  }
618
769
 
619
- // Extensions Mapping
770
+ // ── Extensions (;KEY:val or ;key=val after T: tokens) ────────────────────
620
771
  if (g.extensions) {
621
- const extObj: any = {};
622
- const parts = g.extensions.split(".");
623
- parts.forEach((p: string) => {
624
- const eqIdx = p.indexOf("=");
772
+ const extObj: Record<string, string> = {};
773
+ g.extensions.split(";").forEach((part: string) => {
774
+ part = part.trim();
775
+ if (!part) return;
776
+ const colonIdx = part.indexOf(":");
777
+ const eqIdx = part.indexOf("=");
778
+ if (colonIdx > 0 && (eqIdx < 0 || colonIdx < eqIdx)) {
779
+ // KEY:val form (e.g. TZ:+03:00)
780
+ const key = part.substring(0, colonIdx).toLowerCase();
781
+ const val = part.substring(colonIdx + 1);
782
+ if (key && val !== undefined) extObj[key] = val;
783
+ } else if (eqIdx > 0) {
784
+ // key=val form (e.g. tz=+03:00)
785
+ const key = part.substring(0, eqIdx).toLowerCase();
786
+ const val = part.substring(eqIdx + 1);
787
+ if (key && val !== undefined) extObj[key] = val;
788
+ }
789
+ });
790
+ if (Object.keys(extObj).length > 0) components.extensions = extObj;
791
+ }
792
+
793
+ // ── Context (#C:key=val;key=val) ─────────────────────────────────────────
794
+ if (g.context) {
795
+ const ctx: Record<string, string> = {};
796
+ g.context.split(";").forEach((part: string) => {
797
+ part = part.trim();
798
+ if (!part) return;
799
+ const eqIdx = part.indexOf("=");
625
800
  if (eqIdx > 0) {
626
- const key = p.substring(0, eqIdx);
627
- const val = p.substring(eqIdx + 1);
628
- if (key && val) extObj[key] = val;
629
- } else {
630
- // Legacy format: first char is key
631
- const key = p.charAt(0);
632
- const val = p.substring(1);
633
- if (key && val) extObj[key] = val;
801
+ ctx[part.substring(0, eqIdx)] = part.substring(eqIdx + 1);
634
802
  }
635
803
  });
636
- components.extensions = extObj;
804
+ if (Object.keys(ctx).length > 0) components.context = ctx;
637
805
  }
638
806
 
639
807
  return components as TPSComponents;
640
808
  }
809
+
810
+ /**
811
+ * Parses a multi-layer location string (before @T:) into component fields.
812
+ * Layers are `;`-separated. Each layer is identified by its prefix token.
813
+ *
814
+ * Supported layers:
815
+ * L:lat,lon[,altm] — GPS
816
+ * L:~|L:-|L:redacted — Privacy markers
817
+ * P:cc=JO,ci=AMM,... — Place (country/city codes and names)
818
+ * S2:token — S2 cell
819
+ * H3:token — H3 cell
820
+ * 3W:word.word.word — What3Words
821
+ * plus:token — Plus Code
822
+ * net:ip4:x.x.x.x — IPv4
823
+ * net:ip6:x::x — IPv6
824
+ * node:name — Logical node/host
825
+ * bldg:name — Building
826
+ * floor:x — Floor
827
+ * room:x — Room
828
+ * door:x — Door
829
+ * zone:x — Zone
830
+ */
831
+ private static _parseLocationLayers(location: string, components: any): void {
832
+ const layers = location.trim().split(";");
833
+
834
+ for (const layer of layers) {
835
+ const l = layer.trim();
836
+ if (!l) continue;
837
+
838
+ // Privacy shorthand
839
+ if (l === "L:~" || l === "L:hidden") { components.isHiddenLocation = true; continue; }
840
+ if (l === "L:-" || l === "L:unknown") { components.isUnknownLocation = true; continue; }
841
+ if (l === "L:redacted") { components.isRedactedLocation = true; continue; }
842
+
843
+ // P: Place layer — P:cc=JO,ci=AMM,cn=Jordan,ct=Amman
844
+ if (l.startsWith("P:")) {
845
+ l.slice(2).split(",").forEach((pair: string) => {
846
+ const eq = pair.indexOf("=");
847
+ if (eq < 1) return;
848
+ const k = pair.substring(0, eq).toLowerCase();
849
+ const v = pair.substring(eq + 1);
850
+ if (k === "cc") components.placeCountryCode = v;
851
+ else if (k === "cn") components.placeCountryName = v;
852
+ else if (k === "ci") components.placeCityCode = v;
853
+ else if (k === "ct") components.placeCityName = v;
854
+ });
855
+ continue;
856
+ }
857
+
858
+ // GPS coordinates (L:lat,lon[,alt])
859
+ if (l.startsWith("L:")) {
860
+ const coords = l.slice(2);
861
+ const m = coords.match(/^(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)(?:,(-?\d+(?:\.\d+)?)m?)?$/);
862
+ if (m) {
863
+ components.latitude = parseFloat(m[1]);
864
+ components.longitude = parseFloat(m[2]);
865
+ if (m[3]) components.altitude = parseFloat(m[3]);
866
+ }
867
+ continue;
868
+ }
869
+
870
+ // Geospatial cells
871
+ if (/^S2:/i.test(l)) { components.s2Cell = l.slice(3); continue; }
872
+ if (/^H3:/i.test(l)) { components.h3Cell = l.slice(3); continue; }
873
+ if (/^3W:/i.test(l)) { components.what3words = l.slice(3); continue; }
874
+ if (/^plus:/i.test(l)) { components.plusCode = l.slice(5); continue; }
875
+
876
+ // Network
877
+ if (/^net:ip4:/i.test(l)) { components.ipv4 = l.slice(8); continue; }
878
+ if (/^net:ip6:/i.test(l)) { components.ipv6 = l.slice(8); continue; }
879
+ if (/^node:/i.test(l)) { components.nodeName = l.slice(5); continue; }
880
+
881
+ // Structural
882
+ if (/^bldg:/i.test(l)) { components.building = l.slice(5); continue; }
883
+ if (/^floor:/i.test(l)) { components.floor = l.slice(6); continue; }
884
+ if (/^room:/i.test(l)) { components.room = l.slice(5); continue; }
885
+ if (/^door:/i.test(l)) { components.door = l.slice(5); continue; }
886
+ if (/^zone:/i.test(l)) { components.zone = l.slice(5); continue; }
887
+
888
+ // Fallback: generic space anchor (adm:, planet:, legacy strings)
889
+ if (l) {
890
+ components.spaceAnchor = components.spaceAnchor
891
+ ? components.spaceAnchor + ";" + l
892
+ : l;
893
+ }
894
+ }
895
+ }
641
896
  }
642
897
 
643
898
  // register built-in drivers and set default
@@ -649,6 +904,7 @@ TPS.registerDriver(new PersianDriver());
649
904
  TPS.registerDriver(new HijriDriver());
650
905
  TPS.registerDriver(new JulianDriver());
651
906
  TPS.registerDriver(new HoloceneDriver());
907
+ TPS.registerDriver(new ChineseDriver());
652
908
 
653
909
  /**
654
910
  * `TpsDate` is a Date-like wrapper with native TPS conversion helpers.