@nextera.one/tps-standard 0.5.34 → 0.7.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
@@ -0,0 +1,873 @@
1
+ /**
2
+ * TPS: Temporal Positioning System
3
+ * The Universal Protocol for Space-Time Coordinates.
4
+ * @packageDocumentation
5
+ * @version 0.6.0
6
+ * @license Apache-2.0
7
+ * @copyright 2026 TPS Standards Working Group
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
+ *
17
+ * v0.5.0 Changes:
18
+ * - Added Actor anchor (A:) for provenance tracking
19
+ * - Added Signature (!) for cryptographic verification
20
+ * - Added structural anchors (bldg, floor, room, zone)
21
+ * - Added geospatial cell systems (S2, H3, Plus Code, what3words)
22
+ */
23
+ // built-in drivers are registered automatically; importing them here
24
+ // ensures they are included when the library bundler/tree-shaker runs.
25
+ import { GregorianDriver } from "./drivers/gregorian";
26
+ import { UnixDriver } from "./drivers/unix";
27
+ import { TpsDriver } from "./drivers/tps";
28
+ import { PersianDriver } from "./drivers/persian";
29
+ import { HijriDriver } from "./drivers/hijri";
30
+ import { JulianDriver } from "./drivers/julian";
31
+ import { HoloceneDriver } from "./drivers/holocene";
32
+ import { ChineseDriver } from "./drivers/chinese";
33
+ export * from "./types";
34
+ export * from "./uid";
35
+ export * from "./date";
36
+ export { Env } from "./utils/env";
37
+ export { DriverManager } from "./driver-manager";
38
+ export { utcToLocal, localToUtc, getOffsetString } from "./utils/timezone";
39
+ import { DriverManager } from "./driver-manager";
40
+ import { buildTimePart, parseTimeString } from "./utils/tps-string";
41
+ import { localToUtc } from "./utils/timezone";
42
+ import { DefaultCalendars, } from "./types";
43
+ export class TPS {
44
+ /**
45
+ * Registers a calendar driver plugin.
46
+ * @param driver - The driver instance to register.
47
+ */
48
+ static registerDriver(driver) {
49
+ this.driverManager.register(driver);
50
+ }
51
+ /**
52
+ * Gets a registered calendar driver.
53
+ * @param code - The calendar code.
54
+ * @returns The driver or undefined.
55
+ */
56
+ static getDriver(code) {
57
+ return this.driverManager.get(code);
58
+ }
59
+ // --- CORE METHODS ---
60
+ /**
61
+ * SANITIZER: Normalises a raw TPS input string before validation.
62
+ *
63
+ * Pure string-based — no parsing into components, no regex beyond simple
64
+ * character checks, no re-serialisation via buildTimePart / toURI.
65
+ *
66
+ * Token ranks (descending): m(8) c(7) y(6) m(5) d(4) h(3) m(2) s(1) m(0)
67
+ */
68
+ static sanitizeTimeInput(input) {
69
+ // ── 1. Whitespace ────────────────────────────────────────────────────────
70
+ let s = input.trim().replace(/\s+/g, "");
71
+ if (!s)
72
+ return s;
73
+ // ── 1.2 Compact scheme normalization (v0.6.0) ──────────────────────────
74
+ // TPS:... → tps://... (generic compact)
75
+ // NIP4:x → tps://net:ip4:x (IPv4 shorthand)
76
+ // NIP6:x → tps://net:ip6:x (IPv6 shorthand)
77
+ // NODE:x → tps://node:x (logical node shorthand)
78
+ if (/^TPS:/i.test(s) && !s.toLowerCase().startsWith("tps://")) {
79
+ // TPS:L:... or TPS:lat,lon... → tps://...
80
+ s = "tps://" + s.slice(4); // strip 'TPS:'
81
+ }
82
+ else if (/^NIP4:/i.test(s)) {
83
+ s = "tps://net:ip4:" + s.slice(5);
84
+ }
85
+ else if (/^NIP6:/i.test(s)) {
86
+ s = "tps://net:ip6:" + s.slice(5);
87
+ }
88
+ else if (/^NODE:/i.test(s)) {
89
+ s = "tps://node:" + s.slice(5);
90
+ }
91
+ // ── 1.5 Convert legacy "/T:" separators to the new canonical "@T:".
92
+ // The input may contain "/T:" from older versions; we normalise early so
93
+ // subsequent logic can assume only the '@' form.
94
+ if (s.includes("/T:")) {
95
+ s = s.replace(/\/T:/g, "@T:");
96
+ }
97
+ // ── 2. Scheme casing ─────────────────────────────────────────────────────
98
+ if (s.slice(0, 6).toLowerCase() === "tps://") {
99
+ s = "tps://" + s.slice(6);
100
+ }
101
+ // ── 3. T: prefix casing (time-only strings) ──────────────────────────────
102
+ if (!s.startsWith("tps://") && s.slice(0, 2).toLowerCase() === "t:") {
103
+ s = "T:" + s.slice(2);
104
+ }
105
+ // ── 4. Locate T: section ─────────────────────────────────────────────────
106
+ let tStart = -1;
107
+ if (s.startsWith("T:")) {
108
+ tStart = 0;
109
+ }
110
+ else {
111
+ const atT = s.indexOf("@T:");
112
+ if (atT !== -1)
113
+ tStart = atT + 1;
114
+ }
115
+ if (tStart === -1)
116
+ return s; // no T: section — return as-is
117
+ const beforeT = s.slice(0, tStart); // URI prefix or empty
118
+ const timeAndRest = s.slice(tStart); // T:cal.tok... [!sig][;ext]
119
+ // Isolate token section from any trailing suffix (!sig / ;ext / ?q / #f)
120
+ const suffixIdx = timeAndRest.search(/[!;?#]/);
121
+ const timeSuffix = suffixIdx !== -1 ? timeAndRest.slice(suffixIdx) : "";
122
+ const timePart = suffixIdx !== -1 ? timeAndRest.slice(0, suffixIdx) : timeAndRest;
123
+ // timePart = "T:greg.m3.c1.y26.m01.d07.h13.m20.s45"
124
+ // Split off calendar code
125
+ const afterColon = timePart.slice(timePart.indexOf(":") + 1); // "greg.m3.c1..."
126
+ const firstDot = afterColon.indexOf(".");
127
+ const cal = (firstDot !== -1 ? afterColon.slice(0, firstDot) : afterColon).toLowerCase();
128
+ const tokenStr = firstDot !== -1 ? afterColon.slice(firstDot + 1) : "";
129
+ // If no calendar code was provided at all (e.g. "T:"), bail out early
130
+ // rather than inventing a default calendar. The string will remain
131
+ // unparsable so validation can report it as invalid.
132
+ if (!cal) {
133
+ return s;
134
+ }
135
+ // No tokens at all — fill every slot with 0 and return
136
+ if (!tokenStr) {
137
+ return `${beforeT}T:${cal}.m0.c0.y0.m0.d0.h0.m0.s0.m0${timeSuffix}`;
138
+ }
139
+ const tokens = tokenStr
140
+ .split(".")
141
+ .filter((t) => t.length >= 2 && /^[a-z]/.test(t))
142
+ .map((t) => ({ p: t[0], v: t.slice(1) }));
143
+ // ── 6. Detect order from non-m tokens (c=7, y=6, d=4, h=3, s=1) ─────────
144
+ const nonMRank = { c: 7, y: 6, d: 4, h: 3, s: 1 };
145
+ const nonMSeq = tokens
146
+ .filter((t) => t.p !== "m" && nonMRank[t.p] !== undefined)
147
+ .map((t) => nonMRank[t.p]);
148
+ let isAsc = false;
149
+ if (nonMSeq.length >= 2) {
150
+ // ascending when every consecutive rank-diff is positive
151
+ isAsc = nonMSeq.every((r, i) => i === 0 || r > nonMSeq[i - 1]);
152
+ }
153
+ // ── 7. Reverse tokens if ascending ───────────────────────────────────────
154
+ if (isAsc)
155
+ tokens.reverse();
156
+ // ── 8. Disambiguate 'm' tokens by DESC position ──────────────────────────
157
+ // DESC slot order for m tokens: rank 8 (millennium), 5 (month), 2 (minute), 0 (ms)
158
+ const mDescRanks = [8, 5, 2, 0];
159
+ const byRank = new Map();
160
+ let mIdx = 0;
161
+ for (const tok of tokens) {
162
+ if (tok.p === "m") {
163
+ if (mIdx < mDescRanks.length)
164
+ byRank.set(mDescRanks[mIdx++], tok.v);
165
+ }
166
+ else {
167
+ const r = nonMRank[tok.p];
168
+ if (r !== undefined)
169
+ byRank.set(r, tok.v);
170
+ }
171
+ }
172
+ // ── 9. Build complete DESC token string, filling gaps with '0' ───────────
173
+ // Full DESC slot sequence: m(8) c(7) y(6) m(5) d(4) h(3) m(2) s(1) m(0)
174
+ const descSlots = [
175
+ ["m", 8],
176
+ ["c", 7],
177
+ ["y", 6],
178
+ ["m", 5],
179
+ ["d", 4],
180
+ ["h", 3],
181
+ ["m", 2],
182
+ ["s", 1],
183
+ ["m", 0],
184
+ ];
185
+ const finalTokenStr = descSlots
186
+ .map(([p, r]) => p + (byRank.get(r) ?? "0"))
187
+ .join(".");
188
+ return `${beforeT}T:${cal}.${finalTokenStr}${timeSuffix}`;
189
+ }
190
+ static validate(input) {
191
+ const sanitized = this.sanitizeTimeInput(input);
192
+ if (sanitized.startsWith("tps://")) {
193
+ return this.REGEX_URI.test(sanitized);
194
+ }
195
+ return this.REGEX_TIME.test(sanitized);
196
+ }
197
+ static parse(input) {
198
+ // Always sanitize first so we operate on the canonical form. This also
199
+ // rewrites any legacy "/T:" separators to "@T:" so the regex below can
200
+ // remain strict.
201
+ input = this.sanitizeTimeInput(input);
202
+ // quick fail via regex to rule out obviously bad strings
203
+ if (input.startsWith("tps://")) {
204
+ const match = this.REGEX_URI.exec(input);
205
+ if (!match || !match.groups)
206
+ return null;
207
+ const comp = this._mapGroupsToComponents(match.groups);
208
+ // extract the raw time portion and parse it separately
209
+ const atIdx = input.indexOf("@T:");
210
+ let timeStr = "";
211
+ let signature;
212
+ if (atIdx !== -1) {
213
+ timeStr = input.slice(atIdx + 1); // include the leading 'T:'
214
+ // if there's a signature, capture it first
215
+ const sigMatch = timeStr.match(/!(?<sig>[^;?#]+)/);
216
+ if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
217
+ signature = sigMatch.groups.sig;
218
+ }
219
+ // cut off signature, extensions, query, or fragment
220
+ timeStr = timeStr.split(/[!;?#]/)[0];
221
+ }
222
+ if (timeStr) {
223
+ const parsed = parseTimeString(timeStr);
224
+ if (!parsed)
225
+ return null;
226
+ Object.assign(comp, parsed.components);
227
+ comp.order = parsed.order;
228
+ }
229
+ if (signature) {
230
+ comp.signature = signature;
231
+ }
232
+ return comp;
233
+ }
234
+ // time-only string
235
+ const match = this.REGEX_TIME.exec(input);
236
+ if (!match || !match.groups)
237
+ return null;
238
+ // isolate signature if present
239
+ let timeOnly = input;
240
+ let signature;
241
+ const sigMatch = input.match(/!(?<sig>[^;?#]+)/);
242
+ if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
243
+ signature = sigMatch.groups.sig;
244
+ timeOnly = input.split(/[!;?#]/)[0];
245
+ }
246
+ else {
247
+ // Strip extension/query/fragment suffix so parseTimeString sees only tokens
248
+ timeOnly = input.split(/[;?#]/)[0];
249
+ }
250
+ const parsed = parseTimeString(timeOnly);
251
+ if (!parsed)
252
+ return null;
253
+ const comp = parsed.components;
254
+ if (signature)
255
+ comp.signature = signature;
256
+ comp.order = parsed.order;
257
+ // Route through the same group mapper used by REGEX_URI for consistency
258
+ // (handles extensions ;KEY:val and context #C:key=val)
259
+ const syntheticGroups = {
260
+ calendar: match.groups.calendar ?? "",
261
+ signature: match.groups.signature ?? "",
262
+ extensions: match.groups.extensions ?? "",
263
+ context: match.groups.context ?? "",
264
+ location: "", // no location in time-only string
265
+ actor: "",
266
+ };
267
+ const mappedComp = this._mapGroupsToComponents(syntheticGroups);
268
+ // Merge temporal components from parseTimeString with mapped metadata
269
+ Object.assign(comp, {
270
+ signature: mappedComp.signature || comp.signature,
271
+ extensions: mappedComp.extensions || comp.extensions,
272
+ context: mappedComp.context,
273
+ });
274
+ return comp;
275
+ }
276
+ /**
277
+ * SERIALIZER: Converts a components object into a full TPS URI.
278
+ * @param comp - The TPS components.
279
+ * @returns Full URI string (e.g. "tps://...").
280
+ */
281
+ static toURI(comp) {
282
+ // ── 1. Location layers (v0.6.0) ──────────────────────────────────────────
283
+ // Build an ordered list of location layer strings, then join with ";"
284
+ const layers = [];
285
+ // Privacy shorthand takes priority
286
+ if (comp.isHiddenLocation) {
287
+ layers.push("L:~");
288
+ }
289
+ else if (comp.isRedactedLocation) {
290
+ layers.push("L:redacted");
291
+ }
292
+ else if (comp.isUnknownLocation) {
293
+ layers.push("L:-");
294
+ }
295
+ else if (comp.spaceAnchor) {
296
+ // Generic / legacy anchor (adm:, planet:, etc.)
297
+ layers.push(comp.spaceAnchor);
298
+ }
299
+ else if (comp.ipv4) {
300
+ layers.push(`net:ip4:${comp.ipv4}`);
301
+ }
302
+ else if (comp.ipv6) {
303
+ layers.push(`net:ip6:${comp.ipv6}`);
304
+ }
305
+ else if (comp.nodeName) {
306
+ layers.push(`node:${comp.nodeName}`);
307
+ }
308
+ else if (comp.s2Cell) {
309
+ layers.push(`S2:${comp.s2Cell}`);
310
+ }
311
+ else if (comp.h3Cell) {
312
+ layers.push(`H3:${comp.h3Cell}`);
313
+ }
314
+ else if (comp.what3words) {
315
+ layers.push(`3W:${comp.what3words}`);
316
+ }
317
+ else if (comp.plusCode) {
318
+ layers.push(`plus:${comp.plusCode}`);
319
+ }
320
+ else if (comp.building) {
321
+ layers.push(`bldg:${comp.building}`);
322
+ if (comp.floor)
323
+ layers.push(`floor:${comp.floor}`);
324
+ if (comp.room)
325
+ layers.push(`room:${comp.room}`);
326
+ if (comp.door)
327
+ layers.push(`door:${comp.door}`);
328
+ if (comp.zone)
329
+ layers.push(`zone:${comp.zone}`);
330
+ }
331
+ else if (comp.latitude !== undefined && comp.longitude !== undefined) {
332
+ let gps = `L:${comp.latitude},${comp.longitude}`;
333
+ if (comp.altitude !== undefined)
334
+ gps += `,${comp.altitude}m`;
335
+ layers.push(gps);
336
+ }
337
+ else {
338
+ layers.push("L:-"); // unknown fallback
339
+ }
340
+ // Place layer (P:) — appended after primary location
341
+ if (comp.placeCountryCode || comp.placeCountryName ||
342
+ comp.placeCityCode || comp.placeCityName) {
343
+ const pParts = [];
344
+ if (comp.placeCountryCode)
345
+ pParts.push(`cc=${comp.placeCountryCode}`);
346
+ if (comp.placeCountryName)
347
+ pParts.push(`cn=${comp.placeCountryName}`);
348
+ if (comp.placeCityCode)
349
+ pParts.push(`ci=${comp.placeCityCode}`);
350
+ if (comp.placeCityName)
351
+ pParts.push(`ct=${comp.placeCityName}`);
352
+ layers.push(`P:${pParts.join(",")}`);
353
+ }
354
+ const locationStr = layers.join(";");
355
+ // ── 2. Actor (/A:...) ─────────────────────────────────────────────────────
356
+ const actorPart = comp.actor ? `/A:${comp.actor}` : "";
357
+ // ── 3. Time (mandatory 9 tokens) ─────────────────────────────────────────
358
+ const timePart = buildTimePart(comp);
359
+ // ── 4. Extensions (;KEY:val;...) ─────────────────────────────────────────
360
+ let extPart = "";
361
+ if (comp.extensions && Object.keys(comp.extensions).length > 0) {
362
+ const extStrings = Object.entries(comp.extensions).map(([k, v]) => {
363
+ // Emit as KEY:val (preferred v0.6.0 style)
364
+ return `${k.toUpperCase()}:${v}`;
365
+ });
366
+ extPart = `;${extStrings.join(";")}`;
367
+ }
368
+ // ── 5. Context (#C:key=val;...) ──────────────────────────────────────────
369
+ let contextPart = "";
370
+ if (comp.context && Object.keys(comp.context).length > 0) {
371
+ const ctxStrings = Object.entries(comp.context).map(([k, v]) => `${k}=${v}`);
372
+ contextPart = `#C:${ctxStrings.join(";")}`;
373
+ }
374
+ return `tps://${locationStr}${actorPart}@${timePart}${extPart}${contextPart}`;
375
+ }
376
+ /**
377
+ * CONVERTER: Creates a TPS Time Object string from a JavaScript Date.
378
+ * Supports plugin drivers for non-Gregorian calendars.
379
+ * @param date - The JS Date object (defaults to Now).
380
+ * @param calendar - The target calendar driver (default `"tps"`).
381
+ * @param opts - Optional parameters; for built-in calendars the only
382
+ * supported key is `order` which may be `'ascending'` or `'descending'`.
383
+ * @returns Canonical string (e.g., "T:tps.m3.c1.y26...").
384
+ */
385
+ static fromDate(date = new Date(), calendar = DefaultCalendars.TPS, opts) {
386
+ const normalizedCalendar = calendar.toLowerCase();
387
+ const driver = this.driverManager.get(normalizedCalendar);
388
+ if (driver) {
389
+ // when caller requested an explicit order we can bypass the driver's
390
+ // `fromDate` helper and instead generate components ourselves so that
391
+ // order is honoured even if the driver doesn't know about it. This
392
+ // keeps behaviour identical to the old built-in implementation.
393
+ if (opts?.order) {
394
+ const comp = driver.getComponentsFromDate(date);
395
+ comp.calendar = normalizedCalendar;
396
+ comp.order = opts.order;
397
+ return buildTimePart(comp);
398
+ }
399
+ return driver.getFromDate(date);
400
+ }
401
+ // Fallback for old built-in calendars (shouldn't happen once drivers are
402
+ // registered, but kept for backwards compatibility).
403
+ const comp = { calendar: normalizedCalendar };
404
+ if (normalizedCalendar === DefaultCalendars.UNIX) {
405
+ const s = (date.getTime() / 1000).toFixed(3);
406
+ comp.unixSeconds = parseFloat(s);
407
+ if (opts?.order)
408
+ comp.order = opts.order;
409
+ return buildTimePart(comp);
410
+ }
411
+ if (normalizedCalendar === DefaultCalendars.GREG) {
412
+ const fullYear = date.getUTCFullYear();
413
+ comp.millennium = Math.floor(fullYear / 1000) + 1;
414
+ comp.century = Math.floor((fullYear % 1000) / 100) + 1;
415
+ comp.year = fullYear % 100;
416
+ comp.month = date.getUTCMonth() + 1;
417
+ comp.day = date.getUTCDate();
418
+ comp.hour = date.getUTCHours();
419
+ comp.minute = date.getUTCMinutes();
420
+ comp.second = date.getUTCSeconds();
421
+ comp.millisecond = date.getUTCMilliseconds();
422
+ if (opts?.order)
423
+ comp.order = opts.order;
424
+ return buildTimePart(comp);
425
+ }
426
+ throw new Error(`Calendar driver '${normalizedCalendar}' not implemented. Register a driver.`);
427
+ }
428
+ /**
429
+ * CONVERTER: Converts a TPS string to a Date in a target calendar format.
430
+ * Uses plugin drivers for cross-calendar conversion.
431
+ * @param tpsString - The source TPS string (any calendar).
432
+ * @param targetCalendar - The target calendar code (e.g., 'hij').
433
+ * @returns A TPS string in the target calendar, or null if invalid.
434
+ */
435
+ static to(targetCalendar, tpsString) {
436
+ // 1. Parse to components and convert to Gregorian Date
437
+ const gregDate = this.toDate(tpsString);
438
+ if (!gregDate)
439
+ return null;
440
+ // 2. Convert Gregorian to target calendar using driver
441
+ return this.fromDate(gregDate, targetCalendar);
442
+ }
443
+ /**
444
+ * CONVERTER: Reconstructs a JavaScript Date object from a TPS string.
445
+ * Supports plugin drivers for non-Gregorian calendars.
446
+ * @param tpsString - The TPS string.
447
+ * @returns JS Date object or `null` if invalid.
448
+ */
449
+ static toDate(tpsString) {
450
+ const parsed = this.parse(tpsString);
451
+ if (!parsed)
452
+ return null;
453
+ const cal = parsed.calendar || DefaultCalendars.TPS;
454
+ const driver = this.driverManager.get(cal);
455
+ if (!driver) {
456
+ console.error(`Calendar driver '${cal}' not registered.`);
457
+ return null;
458
+ }
459
+ const date = driver.getDateFromComponents(parsed);
460
+ // If the URI has a ;tz= extension, the calendar date was expressed in local
461
+ // time. Convert from local → UTC using the timezone utility.
462
+ const tz = parsed.extensions?.["tz"];
463
+ if (tz && date) {
464
+ const localMs = date.getTime();
465
+ const utcMs = localToUtc(localMs, tz);
466
+ return new Date(utcMs);
467
+ }
468
+ return date;
469
+ }
470
+ // --- DRIVER CONVENIENCE METHODS ---
471
+ /**
472
+ * Parse a calendar-specific date string into TPS components.
473
+ * Requires the driver to implement `parseDate`.
474
+ *
475
+ * @param calendar - The calendar code (e.g., 'hij')
476
+ * @param dateString - Date string in calendar-native format (e.g., '1447-07-21')
477
+ * @param format - Optional format string (driver-specific)
478
+ * @returns TPS components or null if parsing fails
479
+ *
480
+ * @example
481
+ * ```ts
482
+ * const components = TPS.parseCalendarDate('hij', '1447-07-21');
483
+ * // { calendar: 'hij', year: 1447, month: 7, day: 21 }
484
+ *
485
+ * const uri = TPS.toURI({ ...components, latitude: 31.95, longitude: 35.91 });
486
+ * // "tps://31.95,35.91@T:hij.y1447.m07.d21"
487
+ * ```
488
+ */
489
+ static parseCalendarDate(calendar, dateString, format) {
490
+ const driver = this.driverManager.get(calendar);
491
+ if (!driver) {
492
+ throw new Error(`Calendar driver '${calendar}' not found. Register a driver first.`);
493
+ }
494
+ // parseDate is guaranteed by the interface, so we can call it directly.
495
+ return driver.parseDate(dateString, format);
496
+ }
497
+ /**
498
+ * Convert a calendar-specific date string directly to a TPS URI.
499
+ * This is a convenience method that combines parseDate + toURI.
500
+ *
501
+ * @param calendar - The calendar code (e.g., 'hij')
502
+ * @param dateString - Date string in calendar-native format
503
+ * @param location - Optional location (lat/lon/alt or privacy flag)
504
+ * @returns Full TPS URI string
505
+ *
506
+ * @example
507
+ * ```ts
508
+ * // With coordinates
509
+ * TPS.fromCalendarDate('hij', '1447-07-21', { latitude: 31.95, longitude: 35.91 });
510
+ * // "tps://31.95,35.91@T:hij.y1447.m07.d21"
511
+ *
512
+ * // With privacy flag
513
+ * TPS.fromCalendarDate('hij', '1447-07-21', { isHiddenLocation: true });
514
+ * // "tps://hidden@T:hij.y1447.m07.d21"
515
+ *
516
+ * // Without location
517
+ * TPS.fromCalendarDate('hij', '1447-07-21');
518
+ * // "tps://unknown@T:hij.y1447.m07.d21"
519
+ * ```
520
+ */
521
+ static fromCalendarDate(calendar, dateString, location) {
522
+ const components = this.parseCalendarDate(calendar, dateString);
523
+ if (!components) {
524
+ throw new Error(`Failed to parse date string: ${dateString}`);
525
+ }
526
+ // Merge with location
527
+ const fullComponents = {
528
+ calendar: calendar,
529
+ ...components,
530
+ ...location,
531
+ };
532
+ return this.toURI(fullComponents);
533
+ }
534
+ /**
535
+ * Format TPS components to a calendar-specific date string.
536
+ * Requires the driver to implement `format`.
537
+ *
538
+ * @param calendar - The calendar code
539
+ * @param components - TPS components to format
540
+ * @param format - Optional format string (driver-specific)
541
+ * @returns Formatted date string in calendar-native format
542
+ *
543
+ * @example
544
+ * ```ts
545
+ * const tps = TPS.parse('tps://unknown@T:hij.y1447.m07.d21');
546
+ * const formatted = TPS.formatCalendarDate('hij', tps);
547
+ * // "1447-07-21"
548
+ * ```
549
+ */
550
+ static formatCalendarDate(calendar, components, format) {
551
+ const driver = this.driverManager.get(calendar);
552
+ if (!driver) {
553
+ throw new Error(`Calendar driver '${calendar}' not found.`);
554
+ }
555
+ // format is guaranteed by the interface, so we can call it directly.
556
+ return driver.format(components, format);
557
+ }
558
+ // --- CONVENIENCE METHODS ---
559
+ /**
560
+ * Returns a TPS time string for the current moment.
561
+ * Shorthand for `TPS.fromDate(new Date(), calendar, opts)`.
562
+ *
563
+ * @param calendar - Calendar code. Defaults to 'greg'.
564
+ * @param opts - Optional `order` (ASC/DESC) parameter.
565
+ * @returns TPS time string.
566
+ *
567
+ * @example
568
+ * ```ts
569
+ * TPS.now(); // "T:greg.m3.c1.y26.m3.d4.h06.m30.s00.m0"
570
+ * TPS.now('hij'); // "T:hij.y1447.m09.d05.h06.m30.s00"
571
+ * ```
572
+ */
573
+ static now(calendar = DefaultCalendars.GREG, opts) {
574
+ return this.fromDate(new Date(), calendar, opts);
575
+ }
576
+ /**
577
+ * Returns the difference in milliseconds between two TPS strings.
578
+ * The result is `t2 - t1`; negative if t1 is after t2.
579
+ *
580
+ * @param t1 - First TPS string (subtracted from t2).
581
+ * @param t2 - Second TPS string.
582
+ * @returns Milliseconds between the two moments, or NaN on parse failure.
583
+ *
584
+ * @example
585
+ * ```ts
586
+ * const ms = TPS.diff('T:greg.m3.c1.y26.m1.d1.h0.m0.s0.m0',
587
+ * 'T:greg.m3.c1.y26.m1.d2.h0.m0.s0.m0');
588
+ * // 86_400_000 (one day)
589
+ * ```
590
+ */
591
+ static diff(t1, t2) {
592
+ const d1 = this.toDate(t1);
593
+ const d2 = this.toDate(t2);
594
+ if (!d1 || !d2)
595
+ return NaN;
596
+ return d2.getTime() - d1.getTime();
597
+ }
598
+ /**
599
+ * Returns a new TPS string shifted by the given duration.
600
+ * The result is in the same calendar as the original string.
601
+ *
602
+ * @param tpsStr - Source TPS string.
603
+ * @param duration - Object with optional `days`, `hours`, `minutes`, `seconds`, `milliseconds`.
604
+ * @returns Shifted TPS string, or null if the input is invalid.
605
+ *
606
+ * @example
607
+ * ```ts
608
+ * const t = 'T:greg.m3.c1.y26.m1.d9.h14.m30.s25.m0';
609
+ * TPS.add(t, { days: 7 }); // one week later
610
+ * TPS.add(t, { hours: -2 }); // two hours earlier
611
+ * ```
612
+ */
613
+ static add(tpsStr, duration) {
614
+ const date = this.toDate(tpsStr);
615
+ if (!date)
616
+ return null;
617
+ const parsed = this.parse(tpsStr);
618
+ const calendar = parsed?.calendar ?? DefaultCalendars.GREG;
619
+ const order = parsed?.order;
620
+ const deltaMs = (duration.days ?? 0) * 86400000 +
621
+ (duration.hours ?? 0) * 3600000 +
622
+ (duration.minutes ?? 0) * 60000 +
623
+ (duration.seconds ?? 0) * 1000 +
624
+ (duration.milliseconds ?? 0);
625
+ const shifted = new Date(date.getTime() + deltaMs);
626
+ return this.fromDate(shifted, calendar, order ? { order } : undefined);
627
+ }
628
+ // --- INTERNAL HELPERS ---
629
+ static _mapGroupsToComponents(g) {
630
+ const components = {};
631
+ components.calendar = g.calendar;
632
+ // ── Signature ────────────────────────────────────────────────────────────
633
+ if (g.signature) {
634
+ components.signature = g.signature;
635
+ }
636
+ // ── Actor (/A:...) ────────────────────────────────────────────────────────
637
+ if (g.actor) {
638
+ components.actor = g.actor.trim();
639
+ }
640
+ // ── Location layers (v0.6.0: multi-layer, ;-separated) ───────────────────
641
+ if (g.location) {
642
+ this._parseLocationLayers(g.location, components);
643
+ }
644
+ // ── Extensions (;KEY:val or ;key=val after T: tokens) ────────────────────
645
+ if (g.extensions) {
646
+ const extObj = {};
647
+ g.extensions.split(";").forEach((part) => {
648
+ part = part.trim();
649
+ if (!part)
650
+ return;
651
+ const colonIdx = part.indexOf(":");
652
+ const eqIdx = part.indexOf("=");
653
+ if (colonIdx > 0 && (eqIdx < 0 || colonIdx < eqIdx)) {
654
+ // KEY:val form (e.g. TZ:+03:00)
655
+ const key = part.substring(0, colonIdx).toLowerCase();
656
+ const val = part.substring(colonIdx + 1);
657
+ if (key && val !== undefined)
658
+ extObj[key] = val;
659
+ }
660
+ else if (eqIdx > 0) {
661
+ // key=val form (e.g. tz=+03:00)
662
+ const key = part.substring(0, eqIdx).toLowerCase();
663
+ const val = part.substring(eqIdx + 1);
664
+ if (key && val !== undefined)
665
+ extObj[key] = val;
666
+ }
667
+ });
668
+ if (Object.keys(extObj).length > 0)
669
+ components.extensions = extObj;
670
+ }
671
+ // ── Context (#C:key=val;key=val) ─────────────────────────────────────────
672
+ if (g.context) {
673
+ const ctx = {};
674
+ g.context.split(";").forEach((part) => {
675
+ part = part.trim();
676
+ if (!part)
677
+ return;
678
+ const eqIdx = part.indexOf("=");
679
+ if (eqIdx > 0) {
680
+ ctx[part.substring(0, eqIdx)] = part.substring(eqIdx + 1);
681
+ }
682
+ });
683
+ if (Object.keys(ctx).length > 0)
684
+ components.context = ctx;
685
+ }
686
+ return components;
687
+ }
688
+ /**
689
+ * Parses a multi-layer location string (before @T:) into component fields.
690
+ * Layers are `;`-separated. Each layer is identified by its prefix token.
691
+ *
692
+ * Supported layers:
693
+ * L:lat,lon[,altm] — GPS
694
+ * L:~|L:-|L:redacted — Privacy markers
695
+ * P:cc=JO,ci=AMM,... — Place (country/city codes and names)
696
+ * S2:token — S2 cell
697
+ * H3:token — H3 cell
698
+ * 3W:word.word.word — What3Words
699
+ * plus:token — Plus Code
700
+ * net:ip4:x.x.x.x — IPv4
701
+ * net:ip6:x::x — IPv6
702
+ * node:name — Logical node/host
703
+ * bldg:name — Building
704
+ * floor:x — Floor
705
+ * room:x — Room
706
+ * door:x — Door
707
+ * zone:x — Zone
708
+ */
709
+ static _parseLocationLayers(location, components) {
710
+ const layers = location.trim().split(";");
711
+ for (const layer of layers) {
712
+ const l = layer.trim();
713
+ if (!l)
714
+ continue;
715
+ // Privacy shorthand
716
+ if (l === "L:~" || l === "L:hidden") {
717
+ components.isHiddenLocation = true;
718
+ continue;
719
+ }
720
+ if (l === "L:-" || l === "L:unknown") {
721
+ components.isUnknownLocation = true;
722
+ continue;
723
+ }
724
+ if (l === "L:redacted") {
725
+ components.isRedactedLocation = true;
726
+ continue;
727
+ }
728
+ // P: Place layer — P:cc=JO,ci=AMM,cn=Jordan,ct=Amman
729
+ if (l.startsWith("P:")) {
730
+ l.slice(2).split(",").forEach((pair) => {
731
+ const eq = pair.indexOf("=");
732
+ if (eq < 1)
733
+ return;
734
+ const k = pair.substring(0, eq).toLowerCase();
735
+ const v = pair.substring(eq + 1);
736
+ if (k === "cc")
737
+ components.placeCountryCode = v;
738
+ else if (k === "cn")
739
+ components.placeCountryName = v;
740
+ else if (k === "ci")
741
+ components.placeCityCode = v;
742
+ else if (k === "ct")
743
+ components.placeCityName = v;
744
+ });
745
+ continue;
746
+ }
747
+ // GPS coordinates (L:lat,lon[,alt])
748
+ if (l.startsWith("L:")) {
749
+ const coords = l.slice(2);
750
+ const m = coords.match(/^(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)(?:,(-?\d+(?:\.\d+)?)m?)?$/);
751
+ if (m) {
752
+ components.latitude = parseFloat(m[1]);
753
+ components.longitude = parseFloat(m[2]);
754
+ if (m[3])
755
+ components.altitude = parseFloat(m[3]);
756
+ }
757
+ continue;
758
+ }
759
+ // Geospatial cells
760
+ if (/^S2:/i.test(l)) {
761
+ components.s2Cell = l.slice(3);
762
+ continue;
763
+ }
764
+ if (/^H3:/i.test(l)) {
765
+ components.h3Cell = l.slice(3);
766
+ continue;
767
+ }
768
+ if (/^3W:/i.test(l)) {
769
+ components.what3words = l.slice(3);
770
+ continue;
771
+ }
772
+ if (/^plus:/i.test(l)) {
773
+ components.plusCode = l.slice(5);
774
+ continue;
775
+ }
776
+ // Network
777
+ if (/^net:ip4:/i.test(l)) {
778
+ components.ipv4 = l.slice(8);
779
+ continue;
780
+ }
781
+ if (/^net:ip6:/i.test(l)) {
782
+ components.ipv6 = l.slice(8);
783
+ continue;
784
+ }
785
+ if (/^node:/i.test(l)) {
786
+ components.nodeName = l.slice(5);
787
+ continue;
788
+ }
789
+ // Structural
790
+ if (/^bldg:/i.test(l)) {
791
+ components.building = l.slice(5);
792
+ continue;
793
+ }
794
+ if (/^floor:/i.test(l)) {
795
+ components.floor = l.slice(6);
796
+ continue;
797
+ }
798
+ if (/^room:/i.test(l)) {
799
+ components.room = l.slice(5);
800
+ continue;
801
+ }
802
+ if (/^door:/i.test(l)) {
803
+ components.door = l.slice(5);
804
+ continue;
805
+ }
806
+ if (/^zone:/i.test(l)) {
807
+ components.zone = l.slice(5);
808
+ continue;
809
+ }
810
+ // Fallback: generic space anchor (adm:, planet:, legacy strings)
811
+ if (l) {
812
+ components.spaceAnchor = components.spaceAnchor
813
+ ? components.spaceAnchor + ";" + l
814
+ : l;
815
+ }
816
+ }
817
+ }
818
+ }
819
+ // --- PLUGIN REGISTRY ---
820
+ /** Shared DriverManager instance — use TPS.driverManager for direct access. */
821
+ TPS.driverManager = new DriverManager();
822
+ // --- REGEX (v0.6.0) ---
823
+ // The URI and time regexes are intentionally permissive in the location &
824
+ // extension sections — detailed semantic parsing happens in
825
+ // _mapGroupsToComponents() and the layer parsers below.
826
+ //
827
+ // Structure:
828
+ // tps://[location]/A:[actor]@T:[cal].[tokens];[ext];...#C:[ctx];...
829
+ //
830
+ // The `;` separator is used consistently:
831
+ // - between location layers (before @T:)
832
+ // - between extensions (after T: tokens, before #)
833
+ // - between context key=val pairs (after #C:)
834
+ TPS.REGEX_URI = new RegExp("^tps://" +
835
+ // Location: everything up to optional /A: actor and then @T:
836
+ "(?<location>[^@]+?)" +
837
+ // Optional actor overlay
838
+ "(?:/A:(?<actor>[^@]+))?" +
839
+ // Time section
840
+ "@T:(?<calendar>[a-z]{3,4})" +
841
+ "(?<tokens>(?:\\.[a-z]-?[\\d.]+)*)" +
842
+ // Optional signature
843
+ "(?:!(?<signature>[^;#]+))?" +
844
+ // Optional extensions (;KEY:val;key=val;...)
845
+ "(?:;(?<extensions>[^#]+))?" +
846
+ // Optional context fragment (#C:key=val;...)
847
+ "(?:#C:(?<context>.+))?$");
848
+ TPS.REGEX_TIME = new RegExp("^T:(?<calendar>[a-z]{3,4})" +
849
+ "(?<tokens>(?:\\.[a-z]-?[\\d.]+)*)" +
850
+ "(?:!(?<signature>[^;#]+))?" +
851
+ "(?:;(?<extensions>[^#]+))?" +
852
+ "(?:#C:(?<context>.+))?$");
853
+ // register built-in drivers and set default
854
+ // (tps and gregorian provide canonical conversions before unix)
855
+ TPS.registerDriver(new TpsDriver());
856
+ TPS.registerDriver(new GregorianDriver());
857
+ TPS.registerDriver(new UnixDriver());
858
+ TPS.registerDriver(new PersianDriver());
859
+ TPS.registerDriver(new HijriDriver());
860
+ TPS.registerDriver(new JulianDriver());
861
+ TPS.registerDriver(new HoloceneDriver());
862
+ TPS.registerDriver(new ChineseDriver());
863
+ /**
864
+ * `TpsDate` is a Date-like wrapper with native TPS conversion helpers.
865
+ *
866
+ * It mirrors common JavaScript `Date` construction patterns:
867
+ * - `new TpsDate()`
868
+ * - `new TpsDate(ms)`
869
+ * - `new TpsDate(isoString)`
870
+ * - `new TpsDate(tpsString)`
871
+ * - `new TpsDate(year, monthIndex, day?, hour?, minute?, second?, ms?)`
872
+ */
873
+ //# sourceMappingURL=index.js.map