@nextera.one/tps-standard 0.5.33 → 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 (105) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +133 -56
  3. package/dist/date.d.ts +54 -0
  4. package/dist/date.js +174 -0
  5. package/dist/date.js.map +1 -0
  6. package/dist/driver-manager.d.ts +34 -0
  7. package/dist/driver-manager.js +53 -0
  8. package/dist/driver-manager.js.map +1 -0
  9. package/dist/drivers/chinese.d.ts +25 -0
  10. package/dist/drivers/chinese.js +485 -0
  11. package/dist/drivers/chinese.js.map +1 -0
  12. package/dist/drivers/gregorian.d.ts +3 -5
  13. package/dist/drivers/gregorian.js +26 -19
  14. package/dist/drivers/gregorian.js.map +1 -1
  15. package/dist/drivers/hijri.d.ts +1 -16
  16. package/dist/drivers/hijri.js +9 -102
  17. package/dist/drivers/hijri.js.map +1 -1
  18. package/dist/drivers/holocene.d.ts +6 -3
  19. package/dist/drivers/holocene.js +7 -20
  20. package/dist/drivers/holocene.js.map +1 -1
  21. package/dist/drivers/julian.d.ts +3 -10
  22. package/dist/drivers/julian.js +11 -71
  23. package/dist/drivers/julian.js.map +1 -1
  24. package/dist/drivers/persian.d.ts +1 -6
  25. package/dist/drivers/persian.js +17 -92
  26. package/dist/drivers/persian.js.map +1 -1
  27. package/dist/drivers/tps.d.ts +11 -28
  28. package/dist/drivers/tps.js +8 -58
  29. package/dist/drivers/tps.js.map +1 -1
  30. package/dist/drivers/unix.d.ts +5 -6
  31. package/dist/drivers/unix.js +10 -32
  32. package/dist/drivers/unix.js.map +1 -1
  33. package/dist/esm/date.js +170 -0
  34. package/dist/esm/date.js.map +1 -0
  35. package/dist/esm/driver-manager.js +49 -0
  36. package/dist/esm/driver-manager.js.map +1 -0
  37. package/dist/esm/drivers/chinese.js +481 -0
  38. package/dist/esm/drivers/chinese.js.map +1 -0
  39. package/dist/esm/drivers/gregorian.js +160 -0
  40. package/dist/esm/drivers/gregorian.js.map +1 -0
  41. package/dist/esm/drivers/hijri.js +184 -0
  42. package/dist/esm/drivers/hijri.js.map +1 -0
  43. package/dist/esm/drivers/holocene.js +115 -0
  44. package/dist/esm/drivers/holocene.js.map +1 -0
  45. package/dist/esm/drivers/julian.js +161 -0
  46. package/dist/esm/drivers/julian.js.map +1 -0
  47. package/dist/esm/drivers/persian.js +190 -0
  48. package/dist/esm/drivers/persian.js.map +1 -0
  49. package/dist/esm/drivers/tps.js +181 -0
  50. package/dist/esm/drivers/tps.js.map +1 -0
  51. package/dist/esm/drivers/unix.js +50 -0
  52. package/dist/esm/drivers/unix.js.map +1 -0
  53. package/dist/esm/index.js +873 -0
  54. package/dist/esm/index.js.map +1 -0
  55. package/dist/esm/types.js +28 -0
  56. package/dist/esm/types.js.map +1 -0
  57. package/dist/esm/uid.js +221 -0
  58. package/dist/esm/uid.js.map +1 -0
  59. package/dist/esm/utils/calendar.js +126 -0
  60. package/dist/esm/utils/calendar.js.map +1 -0
  61. package/dist/esm/utils/env.js +76 -0
  62. package/dist/esm/utils/env.js.map +1 -0
  63. package/dist/esm/utils/timezone.js +168 -0
  64. package/dist/esm/utils/timezone.js.map +1 -0
  65. package/dist/esm/utils/tps-string.js +160 -0
  66. package/dist/esm/utils/tps-string.js.map +1 -0
  67. package/dist/index.d.ts +84 -466
  68. package/dist/index.js +430 -1095
  69. package/dist/index.js.map +1 -1
  70. package/dist/types.d.ts +103 -0
  71. package/dist/types.js +31 -0
  72. package/dist/types.js.map +1 -0
  73. package/dist/uid.d.ts +48 -0
  74. package/dist/uid.js +225 -0
  75. package/dist/uid.js.map +1 -0
  76. package/dist/utils/calendar.d.ts +55 -0
  77. package/dist/utils/calendar.js +136 -0
  78. package/dist/utils/calendar.js.map +1 -0
  79. package/dist/utils/env.d.ts +12 -0
  80. package/dist/utils/env.js +79 -0
  81. package/dist/utils/env.js.map +1 -0
  82. package/dist/utils/timezone.d.ts +32 -0
  83. package/dist/utils/timezone.js +173 -0
  84. package/dist/utils/timezone.js.map +1 -0
  85. package/dist/utils/tps-string.d.ts +12 -0
  86. package/dist/utils/tps-string.js +164 -0
  87. package/dist/utils/tps-string.js.map +1 -0
  88. package/package.json +20 -5
  89. package/src/date.ts +243 -0
  90. package/src/driver-manager.ts +54 -0
  91. package/src/drivers/chinese.ts +542 -0
  92. package/src/drivers/gregorian.ts +29 -27
  93. package/src/drivers/hijri.ts +13 -113
  94. package/src/drivers/holocene.ts +11 -12
  95. package/src/drivers/julian.ts +18 -72
  96. package/src/drivers/persian.ts +25 -92
  97. package/src/drivers/tps.ts +16 -55
  98. package/src/drivers/unix.ts +12 -33
  99. package/src/index.ts +384 -1556
  100. package/src/types.ts +131 -0
  101. package/src/uid.ts +308 -0
  102. package/src/utils/calendar.ts +161 -0
  103. package/src/utils/env.ts +88 -0
  104. package/src/utils/timezone.ts +182 -0
  105. package/src/utils/tps-string.ts +166 -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.2
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,278 +30,36 @@ 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";
25
-
26
- // Calendar codes are plain strings to allow arbitrary user-defined
27
- // calendars. The library still exports constants for the built-in values but
28
- // callers may also supply their own codes.
29
- export const DefaultCalendars = {
30
- TPS: "tps",
31
- GREG: "greg",
32
- HIJ: "hij",
33
- PER: "per",
34
- JUL: "jul",
35
- HOLO: "holo",
36
- UNIX: "unix",
37
- } as const;
38
-
39
- /**
40
- * Specifies the direction of the time-component hierarchy when serializing or
41
- * deserializing a TPS string. The default is `'descending'` (millennium → … →
42
- * second), but `'ascending'` produces the reverse order.
43
- */
44
- export enum TimeOrder {
45
- DESC = "desc",
46
- ASC = "asc",
47
- }
48
-
49
- export interface TPSComponents {
50
- // --- TEMPORAL ---
51
- calendar: string;
52
- // --- REQUIRED TEMPORAL FIELDS ---
53
- // All of the traditional Gregorian components are now mandatory. This
54
- // reflects the fact that a valid TPS time object must contain a complete
55
- // timestamp when using the canonical calendar formats.
56
- millennium: number;
57
- century: number;
58
- year: number;
59
- month: number;
60
- day: number;
61
- hour: number;
62
- minute: number;
63
- second: number;
64
- /** Sub-second precision (0–999). Encoded as the last `m` token. */
65
- millisecond: number;
66
- // --- OPTIONAL UNIX BACKUP ---
67
- // `unixSeconds` remains optional to support the Unix driver and other
68
- // cases where a simple epoch value is preferred.
69
- unixSeconds?: number;
70
-
71
- // --- SPATIAL: GPS Coordinates ---
72
- latitude?: number;
73
- longitude?: number;
74
- altitude?: number;
75
-
76
- // --- SPATIAL: Geospatial Cells ---
77
- /** Google S2 cell ID (hierarchical, prefix-searchable) */
78
- s2Cell?: string;
79
- /** Uber H3 cell ID (hexagonal grid) */
80
- h3Cell?: string;
81
- /** Open Location Code / Plus Code */
82
- plusCode?: string;
83
- /** what3words address (e.g. "filled.count.soap") */
84
- what3words?: string;
85
-
86
- // --- SPATIAL: Structural Anchors ---
87
- /** Physical building identifier */
88
- building?: string;
89
- /** Vertical division (level) */
90
- floor?: string;
91
- /** Enclosed space identifier */
92
- room?: string;
93
- /** Logical area within building */
94
- zone?: string;
95
-
96
- /** Raw pre-@ space anchor (e.g. adm:city:SA:riyadh, node:api-1, net:ip4:203.0.113.10) */
97
- spaceAnchor?: string;
98
-
99
- // --- SPATIAL: Privacy Markers ---
100
- /** Technical missing data (e.g. server log without GPS) */
101
- isUnknownLocation?: boolean;
102
- /** Removed for legal/security reasons (e.g. GDPR) */
103
- isRedactedLocation?: boolean;
104
- /** Masked by user preference (e.g. "Don't show my location") */
105
- isHiddenLocation?: boolean;
106
-
107
- // --- PROVENANCE ---
108
- /** Actor anchor - identifies observer/witness (e.g. "did:web:sensor.example.com", "node:gateway-01") */
109
- actor?: string;
110
- /** Verification hash appended to time (e.g. "sha256:8f3e2a...") */
111
- signature?: string;
112
-
113
- // --- CONTEXT ---
114
- extensions?: Record<string, string>;
115
-
116
- order?: TimeOrder;
117
- }
118
-
119
- // --- PLUGIN ARCHITECTURE ---
120
-
121
- /**
122
- * Interface for Calendar Driver plugins.
123
- * Implementations provide conversion logic to/from Gregorian and support for
124
- * external calendar libraries.
125
- *
126
- * @example Using a driver to parse a Hijri date string
127
- * ```ts
128
- * const driver = TPS.getDriver('hij');
129
- * if (driver?.parseDate) {
130
- * const components = driver.parseDate('1447-07-21');
131
- * const gregDate = driver.toGregorian(components);
132
- * const tpsString = TPS.fromDate(gregDate, 'hij');
133
- * }
134
- * ```
135
- *
136
- * @example Wrapping an external library (moment-hijri)
137
- * ```ts
138
- * import moment from 'moment-hijri';
139
- *
140
- * class HijriDriver implements CalendarDriver {
141
- * readonly code = 'hij';
142
- *
143
- * parseDate(input: string, format?: string): Partial<TPSComponents> {
144
- * const m = moment(input, format || 'iYYYY-iMM-iDD');
145
- * return {
146
- * calendar: 'hij',
147
- * year: m.iYear(),
148
- * month: m.iMonth() + 1,
149
- * day: m.iDate()
150
- * };
151
- * }
152
- *
153
- * fromGregorian(date: Date): Partial<TPSComponents> {
154
- * const m = moment(date);
155
- * return {
156
- * calendar: 'hij',
157
- * year: m.iYear(),
158
- * month: m.iMonth() + 1,
159
- * day: m.iDate(),
160
- * hour: m.hour(),
161
- * minute: m.minute(),
162
- * second: m.second()
163
- * };
164
- * }
165
- *
166
- * // ... other methods
167
- * }
168
- * ```
169
- */
170
- export interface CalendarDriver {
171
- /** The calendar code this driver handles (e.g., 'hij', 'jul'). */
172
- readonly code: string;
173
-
174
- /**
175
- * Human-readable name for this calendar (optional).
176
- * @example "Hijri (Islamic)"
177
- */
178
- readonly name?: string;
179
-
180
- /**
181
- * Converts a Date to this calendar's components.
182
- * @param date - The Gregorian Date object.
183
- * @returns Partial TPS components for year, month, day, etc.
184
- */
185
- getComponentsFromDate(date: Date): Partial<TPSComponents>;
186
-
187
- /**
188
- * Converts this calendar's components to a Date.
189
- * @param components - Partial TPS components (year, month, day, etc.).
190
- * @returns A JavaScript Date object.
191
- */
192
- getDateFromComponents(components: Partial<TPSComponents>): Date;
193
-
194
- /**
195
- * Generates a TPS time string for this calendar from a Date.
196
- * @param date - The Gregorian Date object.
197
- * @returns A TPS time string (e.g., "T:hij.y1447.m07.d21...").
198
- */
199
- getFromDate(date: Date): string;
200
-
201
- // --- NEW ENHANCED METHODS (Optional) ---
202
-
203
- /**
204
- * Parse a calendar-specific date string into TPS components.
205
- * This allows drivers to handle native date formats from external libraries.
206
- *
207
- * @param input - Date string in calendar-native format (e.g., '1447-07-21' for Hijri)
208
- * @param format - Optional format string (driver-specific, e.g., 'iYYYY-iMM-iDD')
209
- * @returns Partial TPS components
210
- *
211
- * @example
212
- * ```ts
213
- * // Hijri driver
214
- * driver.parseDate('1447-07-21'); // → { year: 1447, month: 7, day: 21, calendar: 'hij' }
215
- *
216
- * // With time
217
- * driver.parseDate('1447-07-21 14:30:00'); // → { year: 1447, month: 7, day: 21, hour: 14, ... }
218
- * ```
219
- */
220
- parseDate(input: string, format?: string): Partial<TPSComponents>;
221
-
222
- /**
223
- * Format TPS components to a calendar-specific date string.
224
- * Inverse of parseDate().
225
- *
226
- * @param components - TPS components to format
227
- * @param format - Optional format string (driver-specific)
228
- * @returns Formatted date string in calendar-native format
229
- *
230
- * @example
231
- * ```ts
232
- * driver.format({ year: 1447, month: 7, day: 21 }); // → '1447-07-21'
233
- * driver.format({ year: 1447, month: 7, day: 21 }, 'short'); // → '21/7/1447'
234
- * ```
235
- */
236
- format(components: Partial<TPSComponents>, format?: string): string;
237
-
238
- /**
239
- * Validate a calendar-specific date string or components.
240
- *
241
- * @param input - Date string or components to validate
242
- * @returns true if valid for this calendar
243
- *
244
- * @example
245
- * ```ts
246
- * driver.validate('1447-13-01'); // → false (month 13 invalid)
247
- * driver.validate({ year: 1447, month: 7, day: 31 }); // → false (Rajab has 30 days)
248
- * ```
249
- */
250
- validate(input: string | Partial<TPSComponents>): boolean;
251
-
252
- /**
253
- * Get calendar metadata (month names, day names, etc.).
254
- * Useful for UI rendering.
255
- *
256
- * @example
257
- * ```ts
258
- * driver.getMetadata().monthNames
259
- * // → ['Muharram', 'Safar', 'Rabi I', ...]
260
- * ```
261
- */
262
- getMetadata(): CalendarMetadata;
263
- }
264
-
265
- /**
266
- * Metadata about a calendar system.
267
- */
268
- export interface CalendarMetadata {
269
- /** Human-readable calendar name */
270
- name: string;
271
- /** Month names in order (1-12 or 1-13) */
272
- monthNames?: string[];
273
- /** Short month names */
274
- monthNamesShort?: string[];
275
- /** Day of week names (Sunday=0 or locale-specific) */
276
- dayNames?: string[];
277
- /** Short day names */
278
- dayNamesShort?: string[];
279
- /** Whether this calendar is lunar-based */
280
- isLunar?: boolean;
281
- /** Number of months per year */
282
- monthsPerYear?: number;
283
- /** Epoch year (for reference) */
284
- epochYear?: number;
285
- }
33
+ import { ChineseDriver } from "./drivers/chinese";
34
+
35
+ export * from "./types";
36
+ export * from "./uid";
37
+ export * from "./date";
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";
42
+ import { buildTimePart, parseTimeString } from "./utils/tps-string";
43
+ import { localToUtc } from "./utils/timezone";
44
+
45
+ import {
46
+ CalendarDriver,
47
+ TPSComponents,
48
+ TimeOrder,
49
+ DefaultCalendars,
50
+ } from "./types";
286
51
 
287
52
  export class TPS {
288
53
  // --- PLUGIN REGISTRY ---
289
- 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();
290
56
 
291
57
  /**
292
58
  * Registers a calendar driver plugin.
293
59
  * @param driver - The driver instance to register.
294
60
  */
295
61
  static registerDriver(driver: CalendarDriver): void {
296
- this.drivers.set(driver.code, driver);
62
+ this.driverManager.register(driver);
297
63
  }
298
64
 
299
65
  /**
@@ -302,41 +68,44 @@ export class TPS {
302
68
  * @returns The driver or undefined.
303
69
  */
304
70
  static getDriver(code: string): CalendarDriver | undefined {
305
- return this.drivers.get(code);
306
- }
307
-
308
- // --- REGEX ---
309
- // Updated for v0.5.0: supports L: anchors, A: actor, ! signature, structural & geospatial anchors
310
- // Tokens may appear in any order; actual semantic parsing happens in
311
- // `parseTimeString()` so these patterns are intentionally permissive.
312
- // regex simply ensures prefix, space, calendar, and token characters;
313
- // token order is not enforced (parseTimeString handles semantics).
71
+ return this.driverManager.get(code);
72
+ }
73
+
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:)
314
86
  private static readonly REGEX_URI = new RegExp(
315
87
  "^tps://" +
316
- // Location part (preserve named captures for space subfields)
317
- "(?:L:)?(?<space>" +
318
- "~|-|unknown|redacted|hidden|" +
319
- "s2=(?<s2>[a-fA-F0-9]+)|" +
320
- "h3=(?<h3>[a-fA-F0-9]+)|" +
321
- "plus=(?<plus>[A-Z0-9+]+)|" +
322
- "w3w=(?<w3w>[a-z]+\\.[a-z]+\\.[a-z]+)|" +
323
- "bldg=(?<bldg>[\\w-]+)(?:\\.floor=(?<floor>[\\w-]+))?(?:\\.room=(?<room>[\\w-]+))?(?:\\.zone=(?<zone>[\\w-]+))?|" +
324
- "(?<lat>-?\\d+(?:\\.\\d+)?),(?<lon>-?\\d+(?:\\.\\d+)?)(?:,(?<alt>-?\\d+(?:\\.\\d+)?)m?)?|" +
325
- "(?<generic>[^@/?#]+)" +
326
- ")" +
327
- "(?:/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
328
93
  "@T:(?<calendar>[a-z]{3,4})" +
329
- "(?:\\.(?:m-?\\d+|c\\d+|y\\d+|d\\d{1,2}|h\\d{1,2}|s\\d+(?:\\.\\d+)?))*" +
330
- "(?:![^;?#]+)?" +
331
- "(?:;(?<extensions>[^?#]+))?" +
332
- "(?:\\?[^#]+)?" +
333
- "(?:#.+)?$",
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>.+))?$",
334
101
  );
335
102
 
336
103
  private static readonly REGEX_TIME = new RegExp(
337
104
  "^T:(?<calendar>[a-z]{3,4})" +
338
- "(?:\\.(?:m-?\\d+|c\\d+|y\\d+|d\\d{1,2}|h\\d{1,2}|s\\d+(?:\\.\\d+)?))*" +
339
- "(?:![^;?#]+)?$",
105
+ "(?<tokens>(?:\\.[a-z]-?[\\d.]+)*)" +
106
+ "(?:!(?<signature>[^;#]+))?" +
107
+ "(?:;(?<extensions>[^#]+))?" +
108
+ "(?:#C:(?<context>.+))?$",
340
109
  );
341
110
 
342
111
  // --- CORE METHODS ---
@@ -354,6 +123,22 @@ export class TPS {
354
123
  let s = input.trim().replace(/\s+/g, "");
355
124
  if (!s) return s;
356
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
+
357
142
  // ── 1.5 Convert legacy "/T:" separators to the new canonical "@T:".
358
143
  // The input may contain "/T:" from older versions; we normalise early so
359
144
  // subsequent logic can assume only the '@' form.
@@ -504,7 +289,7 @@ export class TPS {
504
289
  timeStr = timeStr.split(/[!;?#]/)[0];
505
290
  }
506
291
  if (timeStr) {
507
- const parsed = this.parseTimeString(timeStr);
292
+ const parsed = parseTimeString(timeStr);
508
293
  if (!parsed) return null;
509
294
  Object.assign(comp, parsed.components);
510
295
  comp.order = parsed.order;
@@ -524,12 +309,33 @@ export class TPS {
524
309
  if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
525
310
  signature = sigMatch.groups.sig;
526
311
  timeOnly = input.split(/[!;?#]/)[0];
312
+ } else {
313
+ // Strip extension/query/fragment suffix so parseTimeString sees only tokens
314
+ timeOnly = input.split(/[;?#]/)[0];
527
315
  }
528
- const parsed = this.parseTimeString(timeOnly);
316
+ const parsed = parseTimeString(timeOnly);
529
317
  if (!parsed) return null;
530
318
  const comp = parsed.components as TPSComponents;
531
319
  if (signature) comp.signature = signature;
532
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
+ });
533
339
  return comp;
534
340
  }
535
341
 
@@ -539,59 +345,87 @@ export class TPS {
539
345
  * @returns Full URI string (e.g. "tps://...").
540
346
  */
541
347
  static toURI(comp: TPSComponents): string {
542
- // 1. Build Space Part (L: anchor)
543
- 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[] = [];
544
351
 
545
- if (comp.spaceAnchor) {
546
- spacePart = comp.spaceAnchor;
547
- } else if (comp.isHiddenLocation) {
548
- spacePart = "L:~";
352
+ // Privacy shorthand takes priority
353
+ if (comp.isHiddenLocation) {
354
+ layers.push("L:~");
549
355
  } else if (comp.isRedactedLocation) {
550
- spacePart = "L:redacted";
356
+ layers.push("L:redacted");
551
357
  } else if (comp.isUnknownLocation) {
552
- 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}`);
553
368
  } else if (comp.s2Cell) {
554
- spacePart = `L:s2=${comp.s2Cell}`;
369
+ layers.push(`S2:${comp.s2Cell}`);
555
370
  } else if (comp.h3Cell) {
556
- spacePart = `L:h3=${comp.h3Cell}`;
557
- } else if (comp.plusCode) {
558
- spacePart = `L:plus=${comp.plusCode}`;
371
+ layers.push(`H3:${comp.h3Cell}`);
559
372
  } else if (comp.what3words) {
560
- spacePart = `L:w3w=${comp.what3words}`;
373
+ layers.push(`3W:${comp.what3words}`);
374
+ } else if (comp.plusCode) {
375
+ layers.push(`plus:${comp.plusCode}`);
561
376
  } else if (comp.building) {
562
- spacePart = `L:bldg=${comp.building}`;
563
- if (comp.floor) spacePart += `.floor=${comp.floor}`;
564
- if (comp.room) spacePart += `.room=${comp.room}`;
565
- 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}`);
566
382
  } else if (comp.latitude !== undefined && comp.longitude !== undefined) {
567
- spacePart = `L:${comp.latitude},${comp.longitude}`;
568
- if (comp.altitude !== undefined) {
569
- spacePart += `,${comp.altitude}m`;
570
- }
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
571
388
  }
572
389
 
573
- // 2. Build Actor Part (A: anchor) - optional
574
- let actorPart = "";
575
- if (comp.actor) {
576
- 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(",")}`);
577
401
  }
578
402
 
579
- // 3. Build Time Part (handles order & signature)
580
- const timePart = this.buildTimePart(comp);
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) ─────────────────────────────────────────
409
+ const timePart = buildTimePart(comp);
581
410
 
582
- // 5. Build Extensions
411
+ // ── 4. Extensions (;KEY:val;...) ─────────────────────────────────────────
583
412
  let extPart = "";
584
413
  if (comp.extensions && Object.keys(comp.extensions).length > 0) {
585
- const extStrings = Object.entries(comp.extensions).map(
586
- ([k, v]) => `${k}=${v}`,
587
- );
588
- 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(";")}`;
419
+ }
420
+
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(";")}`;
589
426
  }
590
427
 
591
- // timePart already begins with 'T:'. The new canonical separator is '@'
592
- // instead of '/', so we interpolate it accordingly. Actor anchor (if
593
- // present) still uses a leading slash.
594
- return `tps://${spacePart}${actorPart}@${timePart}${extPart}`;
428
+ return `tps://${locationStr}${actorPart}@${timePart}${extPart}${contextPart}`;
595
429
  }
596
430
 
597
431
  /**
@@ -609,7 +443,7 @@ export class TPS {
609
443
  opts?: { order?: TimeOrder },
610
444
  ): string {
611
445
  const normalizedCalendar = calendar.toLowerCase();
612
- const driver = this.drivers.get(normalizedCalendar);
446
+ const driver = this.driverManager.get(normalizedCalendar);
613
447
  if (driver) {
614
448
  // when caller requested an explicit order we can bypass the driver's
615
449
  // `fromDate` helper and instead generate components ourselves so that
@@ -619,7 +453,7 @@ export class TPS {
619
453
  const comp = driver.getComponentsFromDate(date) as TPSComponents;
620
454
  comp.calendar = normalizedCalendar;
621
455
  comp.order = opts.order;
622
- return this.buildTimePart(comp);
456
+ return buildTimePart(comp);
623
457
  }
624
458
  return driver.getFromDate(date);
625
459
  }
@@ -632,7 +466,7 @@ export class TPS {
632
466
  const s = (date.getTime() / 1000).toFixed(3);
633
467
  comp.unixSeconds = parseFloat(s);
634
468
  if (opts?.order) comp.order = opts.order;
635
- return this.buildTimePart(comp);
469
+ return buildTimePart(comp);
636
470
  }
637
471
 
638
472
  if (normalizedCalendar === DefaultCalendars.GREG) {
@@ -647,7 +481,7 @@ export class TPS {
647
481
  comp.second = date.getUTCSeconds();
648
482
  comp.millisecond = date.getUTCMilliseconds();
649
483
  if (opts?.order) comp.order = opts.order;
650
- return this.buildTimePart(comp);
484
+ return buildTimePart(comp);
651
485
  }
652
486
 
653
487
  throw new Error(
@@ -683,13 +517,24 @@ export class TPS {
683
517
 
684
518
  const cal = parsed.calendar || DefaultCalendars.TPS;
685
519
 
686
- const driver = this.drivers.get(cal);
520
+ const driver = this.driverManager.get(cal);
687
521
  if (!driver) {
688
522
  console.error(`Calendar driver '${cal}' not registered.`);
689
523
  return null;
690
524
  }
691
525
 
692
- 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;
693
538
  }
694
539
 
695
540
  // --- DRIVER CONVENIENCE METHODS ---
@@ -717,7 +562,7 @@ export class TPS {
717
562
  dateString: string,
718
563
  format?: string,
719
564
  ): Partial<TPSComponents> | null {
720
- const driver = this.drivers.get(calendar);
565
+ const driver = this.driverManager.get(calendar);
721
566
  if (!driver) {
722
567
  throw new Error(
723
568
  `Calendar driver '${calendar}' not found. Register a driver first.`,
@@ -799,7 +644,7 @@ export class TPS {
799
644
  components: Partial<TPSComponents>,
800
645
  format?: string,
801
646
  ): string {
802
- const driver = this.drivers.get(calendar);
647
+ const driver = this.driverManager.get(calendar);
803
648
  if (!driver) {
804
649
  throw new Error(`Calendar driver '${calendar}' not found.`);
805
650
  }
@@ -807,1033 +652,260 @@ export class TPS {
807
652
  return driver.format(components, format);
808
653
  }
809
654
 
810
- // --- INTERNAL HELPERS ---
655
+ // --- CONVENIENCE METHODS ---
811
656
 
812
657
  /**
813
- * Generate the canonical `T:` time string for a set of components. The
814
- * `order` field (or `comp.order`) controls whether tokens are emitted in
815
- * ascending or descending hierarchy; if undefined the default
816
- * `'descending'` orientation is used.
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.
817
664
  *
818
- * Drivers may ignore this helper and produce their own time strings if they
819
- * implement custom ordering logic.
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
+ * ```
820
670
  */
821
- public static buildTimePart(comp: TPSComponents): string {
822
- const calendar = (comp.calendar || "").toLowerCase();
823
- if (!/^[a-z]{3,4}$/.test(calendar)) {
824
- throw new Error(
825
- `Invalid calendar code '${comp.calendar}'. Calendar code width must be 3–4 lowercase letters.`,
826
- );
827
- }
828
-
829
- let time = `T:${calendar}`;
830
- if (calendar === DefaultCalendars.UNIX) {
831
- if (comp.unixSeconds !== undefined) {
832
- time += `.s${comp.unixSeconds}`;
833
- }
834
- return time;
835
- }
836
-
837
- // sequence of [prefix, value, rank]
838
- // All four of millennium / month / minute / millisecond share the prefix 'm'.
839
- // Position within the ordered sequence disambiguates them during parsing.
840
- const tokens: Array<[string, number | undefined, number]> = [
841
- ["m", comp.millennium, 8], // m-token rank 8 → millennium
842
- ["c", comp.century, 7],
843
- ["y", comp.year, 6],
844
- ["m", comp.month, 5], // m-token rank 5 → month
845
- ["d", comp.day, 4],
846
- ["h", comp.hour, 3],
847
- ["m", comp.minute, 2], // m-token rank 2 → minute
848
- ["s", comp.second, 1],
849
- ["m", comp.millisecond, 0], // m-token rank 0 → millisecond
850
- ];
851
-
852
- const order: TimeOrder = comp.order || TimeOrder.DESC;
853
- if (order === TimeOrder.ASC) tokens.reverse();
854
-
855
- for (const [pref, val] of tokens) {
856
- if (val !== undefined) {
857
- // seconds may be fractional
858
- time += `.${pref}${val}`;
859
- }
860
- }
861
-
862
- if (comp.signature) {
863
- time += `!${comp.signature}`;
864
- }
865
-
866
- return time;
671
+ static now(
672
+ calendar: string = DefaultCalendars.GREG,
673
+ opts?: { order?: TimeOrder },
674
+ ): string {
675
+ return this.fromDate(new Date(), calendar, opts) as string;
867
676
  }
868
677
 
869
678
  /**
870
- * Parse the *time* portion of a TPS string (optionally beginning with
871
- * `T:`) into components and determine the component ordering. This helper
872
- * accepts tokens in **any** sequence and will return an `order` value of
873
- * `'ascending'` or `'descending'`.
679
+ * Returns the difference in milliseconds between two TPS strings.
680
+ * The result is `t2 - t1`; negative if t1 is after t2.
874
681
  *
875
- * The caller is responsible for stripping off a leading signature or other
876
- * trailer characters; this method will drop anything after `!`, `;`, `?` or
877
- * `#`.
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.
878
685
  *
879
- * ### `m`-token disambiguation
880
- * All four of millennium (rank 8), month (rank 5), minute (rank 2) and
881
- * millisecond (rank 0) share the single-character prefix `m`. They are told
882
- * apart by their **position relative to the neighbouring tokens**. The
883
- * algorithm is:
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.
884
703
  *
885
- * 1. Pre-scan the non-`m` tokens (c, y, d, h, s) whose ranks are fixed to
886
- * determine whether the string is ascending or descending.
887
- * 2. While iterating, track `lastAssignedRank` the rank of the most
888
- * recently processed token (m or non-m).
889
- * 3. When an `m` token is encountered, derive its rank from `lastAssignedRank`
890
- * and the detected order:
891
- * - **DESC** null → 8 (mill) | rank > 5 → 5 (month) | rank > 2 → 2 (min) | else → 0 (ms)
892
- * - **ASC** null → 0 (ms) | rank < 2 → 2 (min) | rank < 5 → 5 (month) | else → 8 (mill)
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.
893
707
  *
894
- * @param input - Time fragment (e.g. `"T:greg.m3.c1.y26"` or `"greg.m0.s25.m30"`)
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
+ * ```
895
714
  */
896
- static parseTimeString(
897
- input: string,
898
- ): { components: Partial<TPSComponents>; order: TimeOrder } | null {
899
- let s = input.trim();
900
- // strip off anything after signature or extensions/query/fragment
901
- s = s.split(/[!;?#]/)[0];
902
- if (s.startsWith("T:")) s = s.slice(2);
903
- const parts = s.split(".");
904
- if (parts.length === 0) return null;
905
- const calendar = parts[0];
906
- const comp: Partial<TPSComponents> = { calendar };
907
-
908
- // Fixed-rank prefixes (unambiguous regardless of position)
909
- const fixedRankMap: Record<string, number> = {
910
- c: 7,
911
- y: 6,
912
- d: 4,
913
- h: 3,
914
- s: 1,
915
- };
916
-
917
- // ── Step 1: pre-scan non-m tokens to estimate order ─────────────────────
918
- // This is only needed to handle the first 'm' token when lastAssignedRank
919
- // is still null (nothing has been seen yet).
920
- let initialOrder: TimeOrder = TimeOrder.DESC;
921
- if (calendar !== DefaultCalendars.UNIX) {
922
- const nonMRanks: number[] = [];
923
- for (let i = 1; i < parts.length; i++) {
924
- const pr = parts[i]?.charAt(0);
925
- if (pr && pr in fixedRankMap) nonMRanks.push(fixedRankMap[pr]);
926
- }
927
- if (nonMRanks.length >= 2) {
928
- const isAsc = nonMRanks.every((v, i, a) => i === 0 || a[i - 1] <= v);
929
- if (isAsc) initialOrder = TimeOrder.ASC;
930
- }
931
- }
932
-
933
- // ── Step 2: resolve the semantic rank of an 'm' token ───────────────────
934
- const assignMRank = (lastRank: number | null, ord: TimeOrder): number => {
935
- if (ord === TimeOrder.DESC) {
936
- if (lastRank === null) return 8; // first token → millennium
937
- if (lastRank > 5) return 5; // after century / year → month
938
- if (lastRank > 2) return 2; // after day / hour → minute
939
- return 0; // after second → millisecond
940
- } else {
941
- if (lastRank === null) return 0; // first token → millisecond
942
- if (lastRank < 2) return 2; // after millisecond / second → minute
943
- if (lastRank < 5) return 5; // after minute / hour / day → month
944
- return 8; // after month / year / cent → millennium
945
- }
946
- };
947
-
948
- // ── Step 3: iterate and build components ────────────────────────────────
949
- const ranks: number[] = [];
950
- let lastAssignedRank: number | null = null;
951
-
952
- for (let i = 1; i < parts.length; i++) {
953
- const token = parts[i];
954
- if (!token) continue;
955
- const prefix = token.charAt(0);
956
- const value = token.slice(1);
957
-
958
- // UNIX calendar: single 's' token carries the full unix timestamp
959
- if (calendar === DefaultCalendars.UNIX && prefix === "s") {
960
- comp.unixSeconds = parseFloat(value);
961
- ranks.push(9);
962
- continue;
963
- }
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;
964
727
 
965
- if (prefix === "m") {
966
- const rank = assignMRank(lastAssignedRank, initialOrder);
967
- switch (rank) {
968
- case 8:
969
- comp.millennium = parseInt(value, 10);
970
- break;
971
- case 5:
972
- comp.month = parseInt(value, 10);
973
- break;
974
- case 2:
975
- comp.minute = parseInt(value, 10);
976
- break;
977
- case 0:
978
- comp.millisecond = parseInt(value, 10);
979
- break;
980
- }
981
- ranks.push(rank);
982
- lastAssignedRank = rank;
983
- } else {
984
- switch (prefix) {
985
- case "c":
986
- comp.century = parseInt(value, 10);
987
- ranks.push(7);
988
- lastAssignedRank = 7;
989
- break;
990
- case "y":
991
- comp.year = parseInt(value, 10);
992
- ranks.push(6);
993
- lastAssignedRank = 6;
994
- break;
995
- case "d":
996
- comp.day = parseInt(value, 10);
997
- ranks.push(4);
998
- lastAssignedRank = 4;
999
- break;
1000
- case "h":
1001
- comp.hour = parseInt(value, 10);
1002
- ranks.push(3);
1003
- lastAssignedRank = 3;
1004
- break;
1005
- case "s":
1006
- comp.second = parseFloat(value);
1007
- ranks.push(1);
1008
- lastAssignedRank = 1;
1009
- break;
1010
- default:
1011
- // unknown prefix – ignore
1012
- break;
1013
- }
1014
- }
1015
- }
728
+ const parsed = this.parse(tpsStr);
729
+ const calendar = parsed?.calendar ?? DefaultCalendars.GREG;
730
+ const order = parsed?.order;
1016
731
 
1017
- // ── Step 4: confirm order from the complete rank sequence ────────────────
1018
- let order: TimeOrder = TimeOrder.DESC;
1019
- if (ranks.length > 1) {
1020
- const isAsc = ranks.every((v, i, a) => i === 0 || a[i - 1] <= v);
1021
- const isDesc = ranks.every((v, i, a) => i === 0 || a[i - 1] >= v);
1022
- if (isAsc && !isDesc) order = TimeOrder.ASC;
1023
- // mixed / single direction → defaults to DESC
1024
- }
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);
1025
738
 
1026
- return { components: comp, order };
739
+ const shifted = new Date(date.getTime() + deltaMs);
740
+ return this.fromDate(
741
+ shifted,
742
+ calendar,
743
+ order ? { order } : undefined,
744
+ ) as string;
1027
745
  }
1028
746
 
747
+ // --- INTERNAL HELPERS ---
748
+
1029
749
  private static _mapGroupsToComponents(
1030
750
  g: Record<string, string>,
1031
751
  ): TPSComponents {
1032
752
  const components: any = {};
1033
753
  components.calendar = g.calendar as string;
1034
754
 
1035
- // Signature Mapping
755
+ // ── Signature ────────────────────────────────────────────────────────────
1036
756
  if (g.signature) {
1037
757
  components.signature = g.signature;
1038
758
  }
1039
759
 
1040
- // Actor Mapping
760
+ // ── Actor (/A:...) ────────────────────────────────────────────────────────
1041
761
  if (g.actor) {
1042
- components.actor = g.actor;
762
+ components.actor = g.actor.trim();
1043
763
  }
1044
764
 
1045
- // Space Mapping
1046
- if (g.space) {
1047
- // Privacy markers
1048
- if (g.space === "unknown" || g.space === "-") {
1049
- components.isUnknownLocation = true;
1050
- } else if (g.space === "redacted") {
1051
- components.isRedactedLocation = true;
1052
- } else if (g.space === "hidden" || g.space === "~") {
1053
- components.isHiddenLocation = true;
1054
- }
1055
- // Geospatial cells
1056
- else if (g.s2) {
1057
- components.s2Cell = g.s2;
1058
- } else if (g.h3) {
1059
- components.h3Cell = g.h3;
1060
- } else if (g.plus) {
1061
- components.plusCode = g.plus;
1062
- } else if (g.w3w) {
1063
- components.what3words = g.w3w;
1064
- }
1065
- // Structural anchors
1066
- else if (g.bldg) {
1067
- components.building = g.bldg;
1068
- if (g.floor) components.floor = g.floor;
1069
- if (g.room) components.room = g.room;
1070
- if (g.zone) components.zone = g.zone;
1071
- }
1072
- // Generic pre-@ anchor (adm/node/net/planet/etc)
1073
- else if (g.generic) {
1074
- components.spaceAnchor = g.generic;
1075
- }
1076
- // GPS coordinates
1077
- else {
1078
- if (g.lat) components.latitude = parseFloat(g.lat);
1079
- if (g.lon) components.longitude = parseFloat(g.lon);
1080
- if (g.alt) components.altitude = parseFloat(g.alt);
1081
- }
765
+ // ── Location layers (v0.6.0: multi-layer, ;-separated) ───────────────────
766
+ if (g.location) {
767
+ this._parseLocationLayers(g.location, components);
1082
768
  }
1083
769
 
1084
- // Extensions Mapping
770
+ // ── Extensions (;KEY:val or ;key=val after T: tokens) ────────────────────
1085
771
  if (g.extensions) {
1086
- const extObj: any = {};
1087
- const parts = g.extensions.split(".");
1088
- parts.forEach((p: string) => {
1089
- const eqIdx = p.indexOf("=");
1090
- if (eqIdx > 0) {
1091
- const key = p.substring(0, eqIdx);
1092
- const val = p.substring(eqIdx + 1);
1093
- if (key && val) extObj[key] = val;
1094
- } else {
1095
- // Legacy format: first char is key
1096
- const key = p.charAt(0);
1097
- const val = p.substring(1);
1098
- if (key && val) extObj[key] = val;
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;
1099
788
  }
1100
789
  });
1101
- components.extensions = extObj;
1102
- }
1103
-
1104
- return components as TPSComponents;
1105
- }
1106
- }
1107
-
1108
- // register built-in drivers and set default
1109
- // (tps and gregorian provide canonical conversions before unix)
1110
- TPS.registerDriver(new TpsDriver());
1111
- TPS.registerDriver(new GregorianDriver());
1112
- TPS.registerDriver(new UnixDriver());
1113
- TPS.registerDriver(new PersianDriver());
1114
- TPS.registerDriver(new HijriDriver());
1115
- TPS.registerDriver(new JulianDriver());
1116
- TPS.registerDriver(new HoloceneDriver());
1117
-
1118
- // --- TPS-UID v1 Types ---
1119
-
1120
- /**
1121
- * Decoded result from TPSUID7RB binary format.
1122
- */
1123
- export type TPSUID7RBDecodeResult = {
1124
- /** Version identifier */
1125
- version: "tpsuid7rb";
1126
- /** Epoch milliseconds (UTC) */
1127
- epochMs: number;
1128
- /** Whether the TPS payload was compressed */
1129
- compressed: boolean;
1130
- /** 32-bit nonce for collision prevention */
1131
- nonce: number;
1132
- /** The original TPS string (exact reconstruction) */
1133
- tps: string;
1134
- };
1135
-
1136
- /**
1137
- * Encoding options for TPSUID7RB.
1138
- */
1139
- export type TPSUID7RBEncodeOptions = {
1140
- /** Enable zlib compression of TPS payload */
1141
- compress?: boolean;
1142
- /** Override epoch milliseconds (default: parsed from TPS) */
1143
- epochMs?: number;
1144
- };
1145
-
1146
- /**
1147
- * TPS-UID v1 — Temporal Positioning System Identifier (Binary Reversible)
1148
- *
1149
- * A time-first, reversible identifier that binds an event to a TPS coordinate.
1150
- * Unlike UUIDs, TPS-UID identifies events in spacetime and allows exact
1151
- * reconstruction of the original TPS string.
1152
- *
1153
- * Binary Schema (all integers big-endian):
1154
- * ```
1155
- * MAGIC 4 bytes "TPU7"
1156
- * VER 1 byte 0x01
1157
- * FLAGS 1 byte bit0 = compression flag
1158
- * TIME 6 bytes epoch_ms (48-bit unsigned)
1159
- * NONCE 4 bytes 32-bit random
1160
- * LEN varint length of TPS payload
1161
- * TPS bytes UTF-8 TPS string (raw or zlib-compressed)
1162
- * ```
1163
- *
1164
- * @example
1165
- * ```ts
1166
- * const tps = 'tps://31.95,35.91@T:greg.m3.c1.y26.m01.d09';
1167
- *
1168
- * // Encode to binary
1169
- * const bytes = TPSUID7RB.encodeBinary(tps);
1170
- *
1171
- * // Encode to base64url string
1172
- * const id = TPSUID7RB.encodeBinaryB64(tps);
1173
- * // → "tpsuid7rb_AFRQV..."
1174
- *
1175
- * // Decode back to original TPS
1176
- * const decoded = TPSUID7RB.decodeBinaryB64(id);
1177
- * console.log(decoded.tps); // exact original TPS
1178
- * ```
1179
- */
1180
- export class TPSUID7RB {
1181
- /** Magic bytes: "TPU7" */
1182
- private static readonly MAGIC = new Uint8Array([0x54, 0x50, 0x55, 0x37]);
1183
- /** Version 1 */
1184
- private static readonly VER = 0x01;
1185
- /** String prefix for base64url encoded form */
1186
- private static readonly PREFIX = "tpsuid7rb_";
1187
- /** Regex for validating base64url encoded form */
1188
- public static readonly REGEX = /^tpsuid7rb_[A-Za-z0-9_-]+$/;
1189
-
1190
- // ---------------------------
1191
- // Public API
1192
- // ---------------------------
1193
-
1194
- /**
1195
- * Encode TPS string to binary bytes (Uint8Array).
1196
- * This is the canonical form for hashing, signing, and storage.
1197
- *
1198
- * @param tps - The TPS string to encode
1199
- * @param opts - Encoding options (compress, epochMs override)
1200
- * @returns Binary TPS-UID as Uint8Array
1201
- */
1202
- static encodeBinary(
1203
- tps: string,
1204
- opts: TPSUID7RBEncodeOptions = {},
1205
- ): Uint8Array {
1206
- const compress = opts.compress ?? false;
1207
- const epochMs = opts.epochMs ?? this.epochMsFromTPSString(tps);
1208
-
1209
- if (!Number.isInteger(epochMs) || epochMs < 0) {
1210
- throw new Error("epochMs must be a non-negative integer");
1211
- }
1212
- if (epochMs > 0xffffffffffff) {
1213
- throw new Error("epochMs exceeds 48-bit range");
1214
- }
1215
-
1216
- const flags = compress ? 0x01 : 0x00;
1217
-
1218
- // Generate 32-bit nonce
1219
- const nonceBuf = this.randomBytes(4);
1220
- const nonce =
1221
- ((nonceBuf[0] << 24) >>> 0) +
1222
- ((nonceBuf[1] << 16) >>> 0) +
1223
- ((nonceBuf[2] << 8) >>> 0) +
1224
- nonceBuf[3];
1225
-
1226
- // Encode TPS to UTF-8
1227
- const tpsUtf8 = new TextEncoder().encode(tps);
1228
-
1229
- // Optionally compress
1230
- const payload = compress ? this.deflateRaw(tpsUtf8) : tpsUtf8;
1231
-
1232
- // Encode length as varint
1233
- const lenVar = this.uvarintEncode(payload.length);
1234
-
1235
- // Construct binary structure
1236
- const out = new Uint8Array(
1237
- 4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length,
1238
- );
1239
- let offset = 0;
1240
-
1241
- // MAGIC
1242
- out.set(this.MAGIC, offset);
1243
- offset += 4;
1244
-
1245
- // VER
1246
- out[offset++] = this.VER;
1247
-
1248
- // FLAGS
1249
- out[offset++] = flags;
1250
-
1251
- // TIME (48-bit big-endian)
1252
- const timeBytes = this.writeU48(epochMs);
1253
- out.set(timeBytes, offset);
1254
- offset += 6;
1255
-
1256
- // NONCE (32-bit big-endian)
1257
- out.set(nonceBuf, offset);
1258
- offset += 4;
1259
-
1260
- // LEN (varint)
1261
- out.set(lenVar, offset);
1262
- offset += lenVar.length;
1263
-
1264
- // TPS payload
1265
- out.set(payload, offset);
1266
-
1267
- return out;
1268
- }
1269
-
1270
- /**
1271
- * Decode binary bytes back to original TPS string.
1272
- *
1273
- * @param bytes - Binary TPS-UID
1274
- * @returns Decoded result with original TPS string
1275
- */
1276
- static decodeBinary(bytes: Uint8Array): TPSUID7RBDecodeResult {
1277
- // Header min size: 4+1+1+6+4 + 1 (at least 1 byte varint) = 17
1278
- if (bytes.length < 17) {
1279
- throw new Error("TPSUID7RB: too short");
1280
- }
1281
-
1282
- // MAGIC
1283
- if (
1284
- bytes[0] !== 0x54 ||
1285
- bytes[1] !== 0x50 ||
1286
- bytes[2] !== 0x55 ||
1287
- bytes[3] !== 0x37
1288
- ) {
1289
- throw new Error("TPSUID7RB: bad magic");
1290
- }
1291
-
1292
- // VERSION
1293
- const ver = bytes[4];
1294
- if (ver !== this.VER) {
1295
- throw new Error(`TPSUID7RB: unsupported version ${ver}`);
790
+ if (Object.keys(extObj).length > 0) components.extensions = extObj;
1296
791
  }
1297
792
 
1298
- // FLAGS
1299
- const flags = bytes[5];
1300
- const compressed = (flags & 0x01) === 0x01;
1301
-
1302
- // TIME (48-bit big-endian)
1303
- const epochMs = this.readU48(bytes, 6);
1304
-
1305
- // NONCE (32-bit big-endian)
1306
- const nonce =
1307
- ((bytes[12] << 24) >>> 0) +
1308
- ((bytes[13] << 16) >>> 0) +
1309
- ((bytes[14] << 8) >>> 0) +
1310
- bytes[15];
1311
-
1312
- // LEN (varint at offset 16)
1313
- let offset = 16;
1314
- const { value: tpsLen, bytesRead } = this.uvarintDecode(bytes, offset);
1315
- offset += bytesRead;
1316
-
1317
- if (offset + tpsLen > bytes.length) {
1318
- throw new Error("TPSUID7RB: length overflow");
1319
- }
1320
-
1321
- // TPS payload
1322
- const payload = bytes.slice(offset, offset + tpsLen);
1323
- const tpsUtf8 = compressed ? this.inflateRaw(payload) : payload;
1324
- const tps = new TextDecoder().decode(tpsUtf8);
1325
-
1326
- return { version: "tpsuid7rb", epochMs, compressed, nonce, tps };
1327
- }
1328
-
1329
- /**
1330
- * Encode TPS to base64url string with prefix.
1331
- * This is the transport/storage form.
1332
- *
1333
- * @param tps - The TPS string to encode
1334
- * @param opts - Encoding options
1335
- * @returns Base64url encoded TPS-UID with prefix
1336
- */
1337
- static encodeBinaryB64(tps: string, opts?: TPSUID7RBEncodeOptions): string {
1338
- const bytes = this.encodeBinary(tps, opts ?? {});
1339
- return `${this.PREFIX}${this.base64UrlEncode(bytes)}`;
1340
- }
1341
-
1342
- /**
1343
- * Decode base64url string back to original TPS string.
1344
- *
1345
- * @param id - Base64url encoded TPS-UID with prefix
1346
- * @returns Decoded result with original TPS string
1347
- */
1348
- static decodeBinaryB64(id: string): TPSUID7RBDecodeResult {
1349
- const s = id.trim();
1350
- if (!s.startsWith(this.PREFIX)) {
1351
- throw new Error("TPSUID7RB: missing prefix");
1352
- }
1353
- const b64 = s.slice(this.PREFIX.length);
1354
- const bytes = this.base64UrlDecode(b64);
1355
- return this.decodeBinary(bytes);
1356
- }
1357
-
1358
- /**
1359
- * Validate base64url encoded TPS-UID format.
1360
- * Note: This validates shape only; binary decode is authoritative.
1361
- *
1362
- * @param id - String to validate
1363
- * @returns true if format is valid
1364
- */
1365
- static validateBinaryB64(id: string): boolean {
1366
- return this.REGEX.test(id.trim());
1367
- }
1368
-
1369
- /**
1370
- * Generate a TPS-UID from the current time and optional location.
1371
- *
1372
- * @param opts - Generation options
1373
- * @returns Base64url encoded TPS-UID
1374
- */
1375
- static generate(opts?: {
1376
- latitude?: number;
1377
- longitude?: number;
1378
- altitude?: number;
1379
- compress?: boolean;
1380
- order?: TimeOrder;
1381
- }): string {
1382
- const now = new Date();
1383
- const time = TPS.fromDate(now, DefaultCalendars.TPS, {
1384
- order: opts?.order,
1385
- });
1386
- let space = "unknown";
1387
-
1388
- if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
1389
- space = `${opts.latitude},${opts.longitude}`;
1390
- if (opts.altitude !== undefined) {
1391
- space += `,${opts.altitude}m`;
1392
- }
1393
- }
1394
-
1395
- const tps = `tps://${space}@${time}`;
1396
-
1397
- return this.encodeBinaryB64(tps, {
1398
- compress: opts?.compress,
1399
- epochMs: now.getTime(),
1400
- });
1401
- }
1402
-
1403
- // ---------------------------
1404
- // TPS String Helpers
1405
- // ---------------------------
1406
-
1407
- /**
1408
- * Generate a TPS string from a Date and optional location.
1409
- */
1410
-
1411
- /**
1412
- * Parse epoch milliseconds from a TPS string.
1413
- * Supports both URI format (tps://...) and time-only format (T:greg...)
1414
- */
1415
- static epochMsFromTPSString(tps: string): number {
1416
- const date = TPS.toDate(tps);
1417
- if (date) return date.getTime();
1418
-
1419
- // If parse fails due to unsupported/extended extension payloads,
1420
- // strip extensions/query/fragment and retry. Epoch only depends on time.
1421
- const stripped = tps.replace(/;[^?#]*/, "").replace(/[?#].*$/, "");
1422
- const retryDate = TPS.toDate(stripped);
1423
- if (!retryDate) throw new Error("TPS: unable to parse date for epoch");
1424
- return retryDate.getTime();
1425
- }
1426
-
1427
- // ---------------------------
1428
- // Binary Helpers
1429
- // ---------------------------
1430
-
1431
- /** Write 48-bit unsigned integer (big-endian) */
1432
- private static writeU48(epochMs: number): Uint8Array {
1433
- const b = new Uint8Array(6);
1434
- // Use BigInt for proper 48-bit handling
1435
- const v = BigInt(epochMs);
1436
- b[0] = Number((v >> 40n) & 0xffn);
1437
- b[1] = Number((v >> 32n) & 0xffn);
1438
- b[2] = Number((v >> 24n) & 0xffn);
1439
- b[3] = Number((v >> 16n) & 0xffn);
1440
- b[4] = Number((v >> 8n) & 0xffn);
1441
- b[5] = Number(v & 0xffn);
1442
- return b;
1443
- }
1444
-
1445
- /** Read 48-bit unsigned integer (big-endian) */
1446
- private static readU48(bytes: Uint8Array, offset: number): number {
1447
- const v =
1448
- (BigInt(bytes[offset]) << 40n) +
1449
- (BigInt(bytes[offset + 1]) << 32n) +
1450
- (BigInt(bytes[offset + 2]) << 24n) +
1451
- (BigInt(bytes[offset + 3]) << 16n) +
1452
- (BigInt(bytes[offset + 4]) << 8n) +
1453
- BigInt(bytes[offset + 5]);
1454
-
1455
- const n = Number(v);
1456
- if (!Number.isSafeInteger(n)) {
1457
- throw new Error("TPSUID7RB: u48 not safe integer");
1458
- }
1459
- return n;
1460
- }
1461
-
1462
- /** Encode unsigned integer as LEB128 varint */
1463
- private static uvarintEncode(n: number): Uint8Array {
1464
- if (!Number.isInteger(n) || n < 0) {
1465
- throw new Error("uvarint must be non-negative int");
1466
- }
1467
- const out: number[] = [];
1468
- let x = n >>> 0;
1469
- while (x >= 0x80) {
1470
- out.push((x & 0x7f) | 0x80);
1471
- x >>>= 7;
1472
- }
1473
- out.push(x);
1474
- return new Uint8Array(out);
1475
- }
1476
-
1477
- /** Decode LEB128 varint */
1478
- private static uvarintDecode(
1479
- bytes: Uint8Array,
1480
- offset: number,
1481
- ): { value: number; bytesRead: number } {
1482
- let x = 0;
1483
- let s = 0;
1484
- let i = 0;
1485
- while (true) {
1486
- if (offset + i >= bytes.length) {
1487
- throw new Error("uvarint overflow");
1488
- }
1489
- const b = bytes[offset + i];
1490
- if (b < 0x80) {
1491
- if (i > 9 || (i === 9 && b > 1)) {
1492
- throw new Error("uvarint too large");
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("=");
800
+ if (eqIdx > 0) {
801
+ ctx[part.substring(0, eqIdx)] = part.substring(eqIdx + 1);
1493
802
  }
1494
- x |= b << s;
1495
- return { value: x >>> 0, bytesRead: i + 1 };
1496
- }
1497
- x |= (b & 0x7f) << s;
1498
- s += 7;
1499
- i++;
1500
- if (i > 10) {
1501
- throw new Error("uvarint too long");
1502
- }
1503
- }
1504
- }
1505
-
1506
- // ---------------------------
1507
- // Base64url Helpers
1508
- // ---------------------------
1509
-
1510
- /** Encode bytes to base64url (no padding) */
1511
- private static base64UrlEncode(bytes: Uint8Array): string {
1512
- // Node.js environment
1513
- if (typeof Buffer !== "undefined") {
1514
- return Buffer.from(bytes)
1515
- .toString("base64")
1516
- .replace(/\+/g, "-")
1517
- .replace(/\//g, "_")
1518
- .replace(/=+$/g, "");
1519
- }
1520
- // Browser environment
1521
- let binary = "";
1522
- for (let i = 0; i < bytes.length; i++) {
1523
- binary += String.fromCharCode(bytes[i]);
1524
- }
1525
- return btoa(binary)
1526
- .replace(/\+/g, "-")
1527
- .replace(/\//g, "_")
1528
- .replace(/=+$/g, "");
1529
- }
1530
-
1531
- /** Decode base64url to bytes */
1532
- private static base64UrlDecode(b64url: string): Uint8Array {
1533
- // Add padding
1534
- const padLen = (4 - (b64url.length % 4)) % 4;
1535
- const b64 = (b64url + "=".repeat(padLen))
1536
- .replace(/-/g, "+")
1537
- .replace(/_/g, "/");
1538
-
1539
- // Node.js environment
1540
- if (typeof Buffer !== "undefined") {
1541
- return new Uint8Array(Buffer.from(b64, "base64"));
1542
- }
1543
- // Browser environment
1544
- const binary = atob(b64);
1545
- const bytes = new Uint8Array(binary.length);
1546
- for (let i = 0; i < binary.length; i++) {
1547
- bytes[i] = binary.charCodeAt(i);
1548
- }
1549
- return bytes;
1550
- }
1551
-
1552
- // ---------------------------
1553
- // Compression Helpers
1554
- // ---------------------------
1555
-
1556
- /** Compress using zlib deflate raw */
1557
- private static deflateRaw(data: Uint8Array): Uint8Array {
1558
- // Node.js environment
1559
- if (typeof require !== "undefined") {
1560
- try {
1561
- // eslint-disable-next-line @typescript-eslint/no-require-imports
1562
- const zlib = require("zlib");
1563
- return new Uint8Array(zlib.deflateRawSync(Buffer.from(data)));
1564
- } catch {
1565
- throw new Error("TPSUID7RB: compression not available");
1566
- }
1567
- }
1568
- // Browser: would need pako or similar library
1569
- throw new Error("TPSUID7RB: compression not available in browser");
1570
- }
1571
-
1572
- /** Decompress using zlib inflate raw */
1573
- private static inflateRaw(data: Uint8Array): Uint8Array {
1574
- // Node.js environment
1575
- if (typeof require !== "undefined") {
1576
- try {
1577
- // eslint-disable-next-line @typescript-eslint/no-require-imports
1578
- const zlib = require("zlib");
1579
- return new Uint8Array(zlib.inflateRawSync(Buffer.from(data)));
1580
- } catch {
1581
- throw new Error("TPSUID7RB: decompression failed");
1582
- }
1583
- }
1584
- // Browser: would need pako or similar library
1585
- throw new Error("TPSUID7RB: decompression not available in browser");
1586
- }
1587
-
1588
- // ---------------------------
1589
- // Cryptographic Sealing (Ed25519)
1590
- // ---------------------------
1591
-
1592
- /**
1593
- * Seal (sign) a TPS string to create a cryptographically verifiable TPS-UID.
1594
- * This appends an Ed25519 signature to the binary form.
1595
- *
1596
- * @param tps - The TPS string to seal
1597
- * @param privateKey - Ed25519 private key (hex or buffer)
1598
- * @param opts - Encoding options
1599
- * @returns Sealed binary TPS-UID
1600
- */
1601
- static seal(
1602
- tps: string,
1603
- privateKey: string | Buffer | Uint8Array,
1604
- opts?: TPSUID7RBEncodeOptions,
1605
- ): Uint8Array {
1606
- // 1. Create standard binary (unsealed first)
1607
- // We force the SEAL flag (bit 1) to be 0 initially for the "content to sign"
1608
- // But wait, we want the signature to cover the header too.
1609
- // Strategy: Construct the full binary with SEAL flag OFF, sign it, then set SEAL flag ON and append sig.
1610
- // Actually, the standard way is:
1611
- // Content = MAGIC + VER + FLAGS(with seal bit set) + TIME + NONCE + LEN + PAYLOAD
1612
- // Signature = Sign(Content)
1613
- // Final = Content + SEAL_TYPE + SIGNATURE
1614
-
1615
- const compress = opts?.compress ?? false;
1616
- const epochMs = opts?.epochMs ?? this.epochMsFromTPSString(tps);
1617
-
1618
- // Validate epoch
1619
- if (!Number.isInteger(epochMs) || epochMs < 0 || epochMs > 0xffffffffffff) {
1620
- throw new Error("epochMs must be a valid 48-bit non-negative integer");
803
+ });
804
+ if (Object.keys(ctx).length > 0) components.context = ctx;
1621
805
  }
1622
806
 
1623
- // Flags: Bit 0 = compress, Bit 1 = sealed
1624
- const flags = (compress ? 0x01 : 0x00) | 0x02; // Set SEAL bit
1625
-
1626
- // Generate Nonce
1627
- const nonceBuf = this.randomBytes(4);
1628
-
1629
- // Encode Payload
1630
- const tpsUtf8 = new TextEncoder().encode(tps);
1631
- const payload = compress ? this.deflateRaw(tpsUtf8) : tpsUtf8;
1632
- const lenVar = this.uvarintEncode(payload.length);
1633
-
1634
- // Construct Content (Header + Payload)
1635
- const contentLen = 4 + 1 + 1 + 6 + 4 + lenVar.length + payload.length;
1636
- const content = new Uint8Array(contentLen);
1637
- let offset = 0;
1638
-
1639
- content.set(this.MAGIC, offset);
1640
- offset += 4;
1641
- content[offset++] = this.VER;
1642
- content[offset++] = flags;
1643
- content.set(this.writeU48(epochMs), offset);
1644
- offset += 6;
1645
- content.set(nonceBuf, offset);
1646
- offset += 4;
1647
- content.set(lenVar, offset);
1648
- offset += lenVar.length;
1649
- content.set(payload, offset);
1650
-
1651
- // Sign the content
1652
- const signature = this.signEd25519(content, privateKey);
1653
- const sealType = 0x01; // Ed25519
1654
-
1655
- // Final Output: Content + SealType (1) + Signature (64)
1656
- const final = new Uint8Array(contentLen + 1 + signature.length);
1657
- final.set(content, 0);
1658
- final.set([sealType], contentLen);
1659
- final.set(signature, contentLen + 1);
1660
-
1661
- return final;
807
+ return components as TPSComponents;
1662
808
  }
1663
809
 
1664
810
  /**
1665
- * Verify a sealed TPS-UID and decode it.
1666
- * Throws if signature is invalid or not sealed.
1667
- *
1668
- * @param sealedBytes - The binary sealed TPS-UID
1669
- * @param publicKey - Ed25519 public key (hex or buffer) to verify against
1670
- * @returns Decoded result
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
1671
830
  */
1672
- static verifyAndDecode(
1673
- sealedBytes: Uint8Array,
1674
- publicKey: string | Buffer | Uint8Array,
1675
- ): TPSUID7RBDecodeResult {
1676
- if (sealedBytes.length < 18) throw new Error("TPSUID7RB: too short");
1677
-
1678
- // Check Magic
1679
- if (
1680
- sealedBytes[0] !== 0x54 ||
1681
- sealedBytes[1] !== 0x50 ||
1682
- sealedBytes[2] !== 0x55 ||
1683
- sealedBytes[3] !== 0x37
1684
- ) {
1685
- throw new Error("TPSUID7RB: bad magic");
1686
- }
1687
-
1688
- // Check Flags for Sealed Bit (bit 1)
1689
- const flags = sealedBytes[5];
1690
- if ((flags & 0x02) === 0) {
1691
- throw new Error("TPSUID7RB: not a sealed UID");
1692
- }
1693
-
1694
- // 1. Parse the structure to find where content ends
1695
- // We need to parse LEN and Payload to find the split point
1696
- let offset = 16; // Start of LEN
1697
- // Decode LEN
1698
- const { value: tpsLen, bytesRead } = this.uvarintDecode(
1699
- sealedBytes,
1700
- offset,
1701
- );
1702
- offset += bytesRead;
1703
- const payloadEnd = offset + tpsLen;
1704
-
1705
- if (payloadEnd > sealedBytes.length) {
1706
- throw new Error("TPSUID7RB: length overflow (truncated)");
1707
- }
1708
-
1709
- // The Content to verify matches exactly [0 ... payloadEnd]
1710
- const content = sealedBytes.slice(0, payloadEnd);
1711
-
1712
- // After content: SealType (1 byte) + Signature
1713
- if (sealedBytes.length <= payloadEnd + 1) {
1714
- throw new Error("TPSUID7RB: missing signature data");
1715
- }
1716
-
1717
- const sealType = sealedBytes[payloadEnd];
1718
- if (sealType !== 0x01) {
1719
- throw new Error(
1720
- `TPSUID7RB: unsupported seal type 0x${sealType.toString(16)}`,
1721
- );
1722
- }
1723
-
1724
- const signature = sealedBytes.slice(payloadEnd + 1);
1725
- if (signature.length !== 64) {
1726
- throw new Error(
1727
- `TPSUID7RB: invalid Ed25519 signature length ${signature.length}`,
1728
- );
1729
- }
1730
-
1731
- // Verify
1732
- const isValid = this.verifyEd25519(content, signature, publicKey);
1733
- if (!isValid) {
1734
- throw new Error("TPSUID7RB: signature verification failed");
1735
- }
1736
-
1737
- // Decode (reuse standard logic, but ignoring the extra bytes at end is fine?)
1738
- // Actually standard logic doesn't expect trailing bytes unless we tell it to.
1739
- // But since we verified, we can just slice the content and decode that as a strict binary
1740
- // EXCEPT standard decodeBinary checks strict length.
1741
- // So we manually decode the components here to be safe and efficient.
1742
-
1743
- return this.decodeBinary(content); // Reuse strict decoder on the content part
1744
- }
1745
-
1746
- // --- Crypto Implementation (Ed25519) ---
1747
-
1748
- private static signEd25519(
1749
- data: Uint8Array,
1750
- privateKey: string | Buffer | Uint8Array,
1751
- ): Uint8Array {
1752
- if (typeof require !== "undefined") {
1753
- try {
1754
- // eslint-disable-next-line @typescript-eslint/no-require-imports
1755
- const crypto = require("crypto");
1756
-
1757
- let key: any;
1758
- if (typeof privateKey === "string") {
1759
- if (privateKey.includes("PRIVATE KEY")) {
1760
- // PEM format — use directly
1761
- key = privateKey;
1762
- } else {
1763
- // Hex-encoded DER/PKCS8
1764
- key = crypto.createPrivateKey({
1765
- key: Buffer.from(privateKey, "hex"),
1766
- format: "der",
1767
- type: "pkcs8",
1768
- });
1769
- }
1770
- } else if (
1771
- typeof privateKey === "object" &&
1772
- privateKey !== null &&
1773
- "asymmetricKeyType" in privateKey
1774
- ) {
1775
- // Node.js KeyObject (e.g. from crypto.generateKeyPairSync)
1776
- key = privateKey;
1777
- } else {
1778
- // Buffer or Uint8Array — assume DER/PKCS8 encoded
1779
- key = crypto.createPrivateKey({
1780
- key: Buffer.from(privateKey as Uint8Array),
1781
- format: "der",
1782
- type: "pkcs8",
1783
- });
1784
- }
1785
-
1786
- return new Uint8Array(crypto.sign(null, data, key));
1787
- } catch (e) {
1788
- throw new Error("TPSUID7RB: signing failed (check key format)");
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;
1789
856
  }
1790
- }
1791
- throw new Error("TPSUID7RB: signing not available in browser");
1792
- }
1793
857
 
1794
- private static verifyEd25519(
1795
- data: Uint8Array,
1796
- signature: Uint8Array,
1797
- publicKey: string | Buffer | Uint8Array,
1798
- ): boolean {
1799
- if (typeof require !== "undefined") {
1800
- try {
1801
- // eslint-disable-next-line @typescript-eslint/no-require-imports
1802
- const crypto = require("crypto");
1803
- return crypto.verify(null, data, publicKey, signature);
1804
- } catch {
1805
- return false;
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;
1806
868
  }
1807
- }
1808
- throw new Error("TPSUID7RB: verification not available in browser");
1809
- }
1810
869
 
1811
- // ---------------------------
1812
- // Random Bytes
1813
- // ---------------------------
1814
-
1815
- /** Generate cryptographically secure random bytes */
1816
- private static randomBytes(length: number): Uint8Array {
1817
- // Node.js environment
1818
- if (typeof require !== "undefined") {
1819
- try {
1820
- // eslint-disable-next-line @typescript-eslint/no-require-imports
1821
- const crypto = require("crypto");
1822
- return new Uint8Array(crypto.randomBytes(length));
1823
- } catch {
1824
- // Fallback to crypto.getRandomValues
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;
1825
893
  }
1826
894
  }
1827
- // Browser or fallback
1828
- if (typeof crypto !== "undefined" && crypto.getRandomValues) {
1829
- const bytes = new Uint8Array(length);
1830
- crypto.getRandomValues(bytes);
1831
- return bytes;
1832
- }
1833
- throw new Error("TPSUID7RB: no crypto available");
1834
895
  }
1835
896
  }
1836
897
 
898
+ // register built-in drivers and set default
899
+ // (tps and gregorian provide canonical conversions before unix)
900
+ TPS.registerDriver(new TpsDriver());
901
+ TPS.registerDriver(new GregorianDriver());
902
+ TPS.registerDriver(new UnixDriver());
903
+ TPS.registerDriver(new PersianDriver());
904
+ TPS.registerDriver(new HijriDriver());
905
+ TPS.registerDriver(new JulianDriver());
906
+ TPS.registerDriver(new HoloceneDriver());
907
+ TPS.registerDriver(new ChineseDriver());
908
+
1837
909
  /**
1838
910
  * `TpsDate` is a Date-like wrapper with native TPS conversion helpers.
1839
911
  *
@@ -1844,247 +916,3 @@ export class TPSUID7RB {
1844
916
  * - `new TpsDate(tpsString)`
1845
917
  * - `new TpsDate(year, monthIndex, day?, hour?, minute?, second?, ms?)`
1846
918
  */
1847
- export class TpsDate {
1848
- private readonly internal: Date;
1849
-
1850
- private getTpsComponents(): TPSComponents {
1851
- const parsed = TPS.parse(this.toTPS(DefaultCalendars.TPS));
1852
- if (!parsed) {
1853
- throw new Error("TpsDate: failed to derive TPS components");
1854
- }
1855
- return parsed;
1856
- }
1857
-
1858
- private getTpsFullYear(): number {
1859
- const comp = this.getTpsComponents();
1860
- return (comp.millennium - 1) * 1000 + (comp.century - 1) * 100 + comp.year;
1861
- }
1862
-
1863
- constructor();
1864
- constructor(value: string | number | Date | TpsDate);
1865
- constructor(
1866
- year: number,
1867
- monthIndex: number,
1868
- day?: number,
1869
- hours?: number,
1870
- minutes?: number,
1871
- seconds?: number,
1872
- ms?: number,
1873
- );
1874
- constructor(
1875
- ...args:
1876
- | []
1877
- | [string | number | Date | TpsDate]
1878
- | [number, number, number?, number?, number?, number?, number?]
1879
- ) {
1880
- if (args.length === 0) {
1881
- this.internal = new Date();
1882
- return;
1883
- }
1884
-
1885
- if (args.length === 1) {
1886
- const value = args[0];
1887
- if (value instanceof TpsDate) {
1888
- this.internal = new Date(value.getTime());
1889
- return;
1890
- }
1891
- if (value instanceof Date) {
1892
- this.internal = new Date(value.getTime());
1893
- return;
1894
- }
1895
- if (typeof value === "string" && TpsDate.looksLikeTPS(value)) {
1896
- const parsed = TPS.toDate(value);
1897
- if (!parsed) {
1898
- throw new RangeError(`Invalid TPS date string: ${value}`);
1899
- }
1900
- this.internal = parsed;
1901
- return;
1902
- }
1903
-
1904
- this.internal = new Date(value);
1905
- return;
1906
- }
1907
-
1908
- const [year, monthIndex, day, hours, minutes, seconds, ms] = args;
1909
- this.internal = new Date(
1910
- year,
1911
- monthIndex,
1912
- day ?? 1,
1913
- hours ?? 0,
1914
- minutes ?? 0,
1915
- seconds ?? 0,
1916
- ms ?? 0,
1917
- );
1918
- }
1919
-
1920
- private static looksLikeTPS(input: string): boolean {
1921
- const s = input.trim();
1922
- return s.startsWith("tps://") || s.startsWith("T:") || s.startsWith("t:");
1923
- }
1924
-
1925
- static now(): number {
1926
- return Date.now();
1927
- }
1928
-
1929
- static parse(input: string): number {
1930
- if (this.looksLikeTPS(input)) {
1931
- const d = TPS.toDate(input);
1932
- return d ? d.getTime() : Number.NaN;
1933
- }
1934
- return Date.parse(input);
1935
- }
1936
-
1937
- static UTC(
1938
- year: number,
1939
- monthIndex: number,
1940
- day?: number,
1941
- hours?: number,
1942
- minutes?: number,
1943
- seconds?: number,
1944
- ms?: number,
1945
- ): number {
1946
- return Date.UTC(
1947
- year,
1948
- monthIndex,
1949
- day ?? 1,
1950
- hours ?? 0,
1951
- minutes ?? 0,
1952
- seconds ?? 0,
1953
- ms ?? 0,
1954
- );
1955
- }
1956
-
1957
- static fromTPS(tps: string): TpsDate {
1958
- return new TpsDate(tps);
1959
- }
1960
-
1961
- toGregorianDate(): Date {
1962
- return new Date(this.internal.getTime());
1963
- }
1964
-
1965
- toDate(): Date {
1966
- return this.toGregorianDate();
1967
- }
1968
-
1969
- toTPS(
1970
- calendar: string = DefaultCalendars.TPS,
1971
- opts?: { order?: TimeOrder },
1972
- ): string {
1973
- return TPS.fromDate(this.internal, calendar, opts);
1974
- }
1975
-
1976
- toTPSURI(
1977
- calendar: string = DefaultCalendars.TPS,
1978
- opts?: {
1979
- order?: TimeOrder;
1980
- latitude?: number;
1981
- longitude?: number;
1982
- altitude?: number;
1983
- isUnknownLocation?: boolean;
1984
- isHiddenLocation?: boolean;
1985
- isRedactedLocation?: boolean;
1986
- },
1987
- ): string {
1988
- const time = this.toTPS(calendar, { order: opts?.order });
1989
- const comp = TPS.parse(time) as TPSComponents;
1990
-
1991
- if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
1992
- comp.latitude = opts.latitude;
1993
- comp.longitude = opts.longitude;
1994
- if (opts.altitude !== undefined) comp.altitude = opts.altitude;
1995
- } else if (opts?.isHiddenLocation) {
1996
- comp.isHiddenLocation = true;
1997
- } else if (opts?.isRedactedLocation) {
1998
- comp.isRedactedLocation = true;
1999
- } else {
2000
- comp.isUnknownLocation = true;
2001
- }
2002
-
2003
- return TPS.toURI(comp);
2004
- }
2005
-
2006
- getTime(): number {
2007
- return this.internal.getTime();
2008
- }
2009
-
2010
- valueOf(): number {
2011
- return this.internal.valueOf();
2012
- }
2013
-
2014
- toString(): string {
2015
- return this.toTPS(DefaultCalendars.TPS);
2016
- }
2017
-
2018
- toISOString(): string {
2019
- return this.internal.toISOString();
2020
- }
2021
-
2022
- toUTCString(): string {
2023
- return this.internal.toUTCString();
2024
- }
2025
-
2026
- toJSON(): string | null {
2027
- return this.internal.toJSON();
2028
- }
2029
-
2030
- getFullYear(): number {
2031
- return this.getTpsFullYear();
2032
- }
2033
-
2034
- getUTCFullYear(): number {
2035
- return this.getTpsFullYear();
2036
- }
2037
-
2038
- getMonth(): number {
2039
- return this.getTpsComponents().month - 1;
2040
- }
2041
-
2042
- getUTCMonth(): number {
2043
- return this.getTpsComponents().month - 1;
2044
- }
2045
-
2046
- getDate(): number {
2047
- return this.getTpsComponents().day;
2048
- }
2049
-
2050
- getUTCDate(): number {
2051
- return this.getTpsComponents().day;
2052
- }
2053
-
2054
- getHours(): number {
2055
- return this.getTpsComponents().hour;
2056
- }
2057
-
2058
- getUTCHours(): number {
2059
- return this.getTpsComponents().hour;
2060
- }
2061
-
2062
- getMinutes(): number {
2063
- return this.getTpsComponents().minute;
2064
- }
2065
-
2066
- getUTCMinutes(): number {
2067
- return this.getTpsComponents().minute;
2068
- }
2069
-
2070
- getSeconds(): number {
2071
- return this.getTpsComponents().second;
2072
- }
2073
-
2074
- getUTCSeconds(): number {
2075
- return this.getTpsComponents().second;
2076
- }
2077
-
2078
- getMilliseconds(): number {
2079
- return this.getTpsComponents().millisecond;
2080
- }
2081
-
2082
- getUTCMilliseconds(): number {
2083
- return this.getTpsComponents().millisecond;
2084
- }
2085
-
2086
- [Symbol.toPrimitive](hint: string): string | number {
2087
- if (hint === "number") return this.valueOf();
2088
- return this.toString();
2089
- }
2090
- }