@nextera.one/tps-standard 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -467
- package/dist/drivers/gregorian.d.ts +18 -0
- package/dist/drivers/gregorian.js +157 -0
- package/dist/drivers/gregorian.js.map +1 -0
- package/dist/drivers/hijri.d.ts +42 -0
- package/dist/drivers/hijri.js +281 -0
- package/dist/drivers/hijri.js.map +1 -0
- package/dist/drivers/holocene.d.ts +25 -0
- package/dist/drivers/holocene.js +132 -0
- package/dist/drivers/holocene.js.map +1 -0
- package/dist/drivers/julian.d.ts +33 -0
- package/dist/drivers/julian.js +225 -0
- package/dist/drivers/julian.js.map +1 -0
- package/dist/drivers/persian.d.ts +33 -0
- package/dist/drivers/persian.js +269 -0
- package/dist/drivers/persian.js.map +1 -0
- package/dist/drivers/tps.d.ts +55 -0
- package/dist/drivers/tps.js +235 -0
- package/dist/drivers/tps.js.map +1 -0
- package/dist/drivers/unix.d.ts +16 -0
- package/dist/drivers/unix.js +76 -0
- package/dist/drivers/unix.js.map +1 -0
- package/dist/index.d.ts +174 -41
- package/dist/index.js +803 -321
- package/dist/index.js.map +1 -0
- package/package.json +9 -3
- package/src/drivers/gregorian.ts +191 -0
- package/src/drivers/hijri.ts +322 -0
- package/src/drivers/holocene.ts +152 -0
- package/src/drivers/julian.ts +255 -0
- package/src/drivers/persian.ts +298 -0
- package/src/drivers/tps.ts +270 -0
- package/src/drivers/unix.ts +79 -0
- package/src/index.ts +959 -366
- package/dist/src/index.js +0 -681
- package/dist/test/src/index.js +0 -963
- package/dist/test/test/persian-calendar.test.js +0 -488
- package/dist/test/test/tps-uid.test.js +0 -295
- package/dist/test/tps-uid.test.js +0 -240
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* TPS: Temporal Positioning System
|
|
4
4
|
* The Universal Protocol for Space-Time Coordinates.
|
|
5
5
|
* @packageDocumentation
|
|
6
|
-
* @version 0.5.
|
|
6
|
+
* @version 0.5.3
|
|
7
7
|
* @license Apache-2.0
|
|
8
8
|
* @copyright 2026 TPS Standards Working Group
|
|
9
9
|
*
|
|
@@ -14,7 +14,38 @@
|
|
|
14
14
|
* - Added geospatial cell systems (S2, H3, Plus Code, what3words)
|
|
15
15
|
*/
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.TPSUID7RB = exports.TPS = void 0;
|
|
17
|
+
exports.TpsDate = exports.TPSUID7RB = exports.TPS = exports.TimeOrder = exports.DefaultCalendars = void 0;
|
|
18
|
+
// built-in drivers are registered automatically; importing them here
|
|
19
|
+
// ensures they are included when the library bundler/tree-shaker runs.
|
|
20
|
+
const gregorian_1 = require("./drivers/gregorian");
|
|
21
|
+
const unix_1 = require("./drivers/unix");
|
|
22
|
+
const tps_1 = require("./drivers/tps");
|
|
23
|
+
const persian_1 = require("./drivers/persian");
|
|
24
|
+
const hijri_1 = require("./drivers/hijri");
|
|
25
|
+
const julian_1 = require("./drivers/julian");
|
|
26
|
+
const holocene_1 = require("./drivers/holocene");
|
|
27
|
+
// Calendar codes are plain strings to allow arbitrary user-defined
|
|
28
|
+
// calendars. The library still exports constants for the built-in values but
|
|
29
|
+
// callers may also supply their own codes.
|
|
30
|
+
exports.DefaultCalendars = {
|
|
31
|
+
TPS: "tps",
|
|
32
|
+
GREG: "greg",
|
|
33
|
+
HIJ: "hij",
|
|
34
|
+
PER: "per",
|
|
35
|
+
JUL: "jul",
|
|
36
|
+
HOLO: "holo",
|
|
37
|
+
UNIX: "unix",
|
|
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
|
+
var TimeOrder;
|
|
45
|
+
(function (TimeOrder) {
|
|
46
|
+
TimeOrder["DESC"] = "desc";
|
|
47
|
+
TimeOrder["ASC"] = "asc";
|
|
48
|
+
})(TimeOrder || (exports.TimeOrder = TimeOrder = {}));
|
|
18
49
|
class TPS {
|
|
19
50
|
/**
|
|
20
51
|
* Registers a calendar driver plugin.
|
|
@@ -32,22 +63,182 @@ class TPS {
|
|
|
32
63
|
return this.drivers.get(code);
|
|
33
64
|
}
|
|
34
65
|
// --- CORE METHODS ---
|
|
66
|
+
/**
|
|
67
|
+
* SANITIZER: Normalises a raw TPS input string before validation.
|
|
68
|
+
*
|
|
69
|
+
* Pure string-based — no parsing into components, no regex beyond simple
|
|
70
|
+
* character checks, no re-serialisation via buildTimePart / toURI.
|
|
71
|
+
*
|
|
72
|
+
* Token ranks (descending): m(8) c(7) y(6) m(5) d(4) h(3) m(2) s(1) m(0)
|
|
73
|
+
*/
|
|
74
|
+
static sanitizeTimeInput(input) {
|
|
75
|
+
// ── 1. Whitespace ────────────────────────────────────────────────────────
|
|
76
|
+
let s = input.trim().replace(/\s+/g, "");
|
|
77
|
+
if (!s)
|
|
78
|
+
return s;
|
|
79
|
+
// ── 1.5 Convert legacy "/T:" separators to the new canonical "@T:".
|
|
80
|
+
// The input may contain "/T:" from older versions; we normalise early so
|
|
81
|
+
// subsequent logic can assume only the '@' form.
|
|
82
|
+
if (s.includes("/T:")) {
|
|
83
|
+
s = s.replace(/\/T:/g, "@T:");
|
|
84
|
+
}
|
|
85
|
+
// ── 2. Scheme casing ─────────────────────────────────────────────────────
|
|
86
|
+
if (s.slice(0, 6).toLowerCase() === "tps://") {
|
|
87
|
+
s = "tps://" + s.slice(6);
|
|
88
|
+
}
|
|
89
|
+
// ── 3. T: prefix casing (time-only strings) ──────────────────────────────
|
|
90
|
+
if (!s.startsWith("tps://") && s.slice(0, 2).toLowerCase() === "t:") {
|
|
91
|
+
s = "T:" + s.slice(2);
|
|
92
|
+
}
|
|
93
|
+
// ── 4. Locate T: section ─────────────────────────────────────────────────
|
|
94
|
+
let tStart = -1;
|
|
95
|
+
if (s.startsWith("T:")) {
|
|
96
|
+
tStart = 0;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const atT = s.indexOf("@T:");
|
|
100
|
+
if (atT !== -1)
|
|
101
|
+
tStart = atT + 1;
|
|
102
|
+
}
|
|
103
|
+
if (tStart === -1)
|
|
104
|
+
return s; // no T: section — return as-is
|
|
105
|
+
const beforeT = s.slice(0, tStart); // URI prefix or empty
|
|
106
|
+
const timeAndRest = s.slice(tStart); // T:cal.tok... [!sig][;ext]
|
|
107
|
+
// Isolate token section from any trailing suffix (!sig / ;ext / ?q / #f)
|
|
108
|
+
const suffixIdx = timeAndRest.search(/[!;?#]/);
|
|
109
|
+
const timeSuffix = suffixIdx !== -1 ? timeAndRest.slice(suffixIdx) : "";
|
|
110
|
+
const timePart = suffixIdx !== -1 ? timeAndRest.slice(0, suffixIdx) : timeAndRest;
|
|
111
|
+
// timePart = "T:greg.m3.c1.y26.m01.d07.h13.m20.s45"
|
|
112
|
+
// Split off calendar code
|
|
113
|
+
const afterColon = timePart.slice(timePart.indexOf(":") + 1); // "greg.m3.c1..."
|
|
114
|
+
const firstDot = afterColon.indexOf(".");
|
|
115
|
+
const cal = (firstDot !== -1 ? afterColon.slice(0, firstDot) : afterColon).toLowerCase();
|
|
116
|
+
const tokenStr = firstDot !== -1 ? afterColon.slice(firstDot + 1) : "";
|
|
117
|
+
// If no calendar code was provided at all (e.g. "T:"), bail out early
|
|
118
|
+
// rather than inventing a default calendar. The string will remain
|
|
119
|
+
// unparsable so validation can report it as invalid.
|
|
120
|
+
if (!cal) {
|
|
121
|
+
return s;
|
|
122
|
+
}
|
|
123
|
+
// No tokens at all — fill every slot with 0 and return
|
|
124
|
+
if (!tokenStr) {
|
|
125
|
+
return `${beforeT}T:${cal}.m0.c0.y0.m0.d0.h0.m0.s0.m0${timeSuffix}`;
|
|
126
|
+
}
|
|
127
|
+
const tokens = tokenStr
|
|
128
|
+
.split(".")
|
|
129
|
+
.filter((t) => t.length >= 2 && /^[a-z]/.test(t))
|
|
130
|
+
.map((t) => ({ p: t[0], v: t.slice(1) }));
|
|
131
|
+
// ── 6. Detect order from non-m tokens (c=7, y=6, d=4, h=3, s=1) ─────────
|
|
132
|
+
const nonMRank = { c: 7, y: 6, d: 4, h: 3, s: 1 };
|
|
133
|
+
const nonMSeq = tokens
|
|
134
|
+
.filter((t) => t.p !== "m" && nonMRank[t.p] !== undefined)
|
|
135
|
+
.map((t) => nonMRank[t.p]);
|
|
136
|
+
let isAsc = false;
|
|
137
|
+
if (nonMSeq.length >= 2) {
|
|
138
|
+
// ascending when every consecutive rank-diff is positive
|
|
139
|
+
isAsc = nonMSeq.every((r, i) => i === 0 || r > nonMSeq[i - 1]);
|
|
140
|
+
}
|
|
141
|
+
// ── 7. Reverse tokens if ascending ───────────────────────────────────────
|
|
142
|
+
if (isAsc)
|
|
143
|
+
tokens.reverse();
|
|
144
|
+
// ── 8. Disambiguate 'm' tokens by DESC position ──────────────────────────
|
|
145
|
+
// DESC slot order for m tokens: rank 8 (millennium), 5 (month), 2 (minute), 0 (ms)
|
|
146
|
+
const mDescRanks = [8, 5, 2, 0];
|
|
147
|
+
const byRank = new Map();
|
|
148
|
+
let mIdx = 0;
|
|
149
|
+
for (const tok of tokens) {
|
|
150
|
+
if (tok.p === "m") {
|
|
151
|
+
if (mIdx < mDescRanks.length)
|
|
152
|
+
byRank.set(mDescRanks[mIdx++], tok.v);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
const r = nonMRank[tok.p];
|
|
156
|
+
if (r !== undefined)
|
|
157
|
+
byRank.set(r, tok.v);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// ── 9. Build complete DESC token string, filling gaps with '0' ───────────
|
|
161
|
+
// Full DESC slot sequence: m(8) c(7) y(6) m(5) d(4) h(3) m(2) s(1) m(0)
|
|
162
|
+
const descSlots = [
|
|
163
|
+
["m", 8],
|
|
164
|
+
["c", 7],
|
|
165
|
+
["y", 6],
|
|
166
|
+
["m", 5],
|
|
167
|
+
["d", 4],
|
|
168
|
+
["h", 3],
|
|
169
|
+
["m", 2],
|
|
170
|
+
["s", 1],
|
|
171
|
+
["m", 0],
|
|
172
|
+
];
|
|
173
|
+
const finalTokenStr = descSlots
|
|
174
|
+
.map(([p, r]) => p + (byRank.get(r) ?? "0"))
|
|
175
|
+
.join(".");
|
|
176
|
+
return `${beforeT}T:${cal}.${finalTokenStr}${timeSuffix}`;
|
|
177
|
+
}
|
|
35
178
|
static validate(input) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
179
|
+
const sanitized = this.sanitizeTimeInput(input);
|
|
180
|
+
if (sanitized.startsWith("tps://")) {
|
|
181
|
+
return this.REGEX_URI.test(sanitized);
|
|
182
|
+
}
|
|
183
|
+
return this.REGEX_TIME.test(sanitized);
|
|
39
184
|
}
|
|
40
185
|
static parse(input) {
|
|
41
|
-
|
|
186
|
+
// Always sanitize first so we operate on the canonical form. This also
|
|
187
|
+
// rewrites any legacy "/T:" separators to "@T:" so the regex below can
|
|
188
|
+
// remain strict.
|
|
189
|
+
input = this.sanitizeTimeInput(input);
|
|
190
|
+
// quick fail via regex to rule out obviously bad strings
|
|
191
|
+
if (input.startsWith("tps://")) {
|
|
42
192
|
const match = this.REGEX_URI.exec(input);
|
|
43
193
|
if (!match || !match.groups)
|
|
44
194
|
return null;
|
|
45
|
-
|
|
195
|
+
const comp = this._mapGroupsToComponents(match.groups);
|
|
196
|
+
// extract the raw time portion and parse it separately
|
|
197
|
+
const atIdx = input.indexOf("@T:");
|
|
198
|
+
let timeStr = "";
|
|
199
|
+
let signature;
|
|
200
|
+
if (atIdx !== -1) {
|
|
201
|
+
timeStr = input.slice(atIdx + 1); // include the leading 'T:'
|
|
202
|
+
// if there's a signature, capture it first
|
|
203
|
+
const sigMatch = timeStr.match(/!(?<sig>[^;?#]+)/);
|
|
204
|
+
if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
|
|
205
|
+
signature = sigMatch.groups.sig;
|
|
206
|
+
}
|
|
207
|
+
// cut off signature, extensions, query, or fragment
|
|
208
|
+
timeStr = timeStr.split(/[!;?#]/)[0];
|
|
209
|
+
}
|
|
210
|
+
if (timeStr) {
|
|
211
|
+
const parsed = this.parseTimeString(timeStr);
|
|
212
|
+
if (!parsed)
|
|
213
|
+
return null;
|
|
214
|
+
Object.assign(comp, parsed.components);
|
|
215
|
+
comp.order = parsed.order;
|
|
216
|
+
}
|
|
217
|
+
if (signature) {
|
|
218
|
+
comp.signature = signature;
|
|
219
|
+
}
|
|
220
|
+
return comp;
|
|
46
221
|
}
|
|
222
|
+
// time-only string
|
|
47
223
|
const match = this.REGEX_TIME.exec(input);
|
|
48
224
|
if (!match || !match.groups)
|
|
49
225
|
return null;
|
|
50
|
-
|
|
226
|
+
// isolate signature if present
|
|
227
|
+
let timeOnly = input;
|
|
228
|
+
let signature;
|
|
229
|
+
const sigMatch = input.match(/!(?<sig>[^;?#]+)/);
|
|
230
|
+
if (sigMatch && sigMatch.groups && sigMatch.groups.sig) {
|
|
231
|
+
signature = sigMatch.groups.sig;
|
|
232
|
+
timeOnly = input.split(/[!;?#]/)[0];
|
|
233
|
+
}
|
|
234
|
+
const parsed = this.parseTimeString(timeOnly);
|
|
235
|
+
if (!parsed)
|
|
236
|
+
return null;
|
|
237
|
+
const comp = parsed.components;
|
|
238
|
+
if (signature)
|
|
239
|
+
comp.signature = signature;
|
|
240
|
+
comp.order = parsed.order;
|
|
241
|
+
return comp;
|
|
51
242
|
}
|
|
52
243
|
/**
|
|
53
244
|
* SERIALIZER: Converts a components object into a full TPS URI.
|
|
@@ -56,15 +247,18 @@ class TPS {
|
|
|
56
247
|
*/
|
|
57
248
|
static toURI(comp) {
|
|
58
249
|
// 1. Build Space Part (L: anchor)
|
|
59
|
-
let spacePart =
|
|
60
|
-
if (comp.
|
|
61
|
-
spacePart =
|
|
250
|
+
let spacePart = "L:-"; // Default: unknown
|
|
251
|
+
if (comp.spaceAnchor) {
|
|
252
|
+
spacePart = comp.spaceAnchor;
|
|
253
|
+
}
|
|
254
|
+
else if (comp.isHiddenLocation) {
|
|
255
|
+
spacePart = "L:~";
|
|
62
256
|
}
|
|
63
257
|
else if (comp.isRedactedLocation) {
|
|
64
|
-
spacePart =
|
|
258
|
+
spacePart = "L:redacted";
|
|
65
259
|
}
|
|
66
260
|
else if (comp.isUnknownLocation) {
|
|
67
|
-
spacePart =
|
|
261
|
+
spacePart = "L:-";
|
|
68
262
|
}
|
|
69
263
|
else if (comp.s2Cell) {
|
|
70
264
|
spacePart = `L:s2=${comp.s2Cell}`;
|
|
@@ -94,76 +288,74 @@ class TPS {
|
|
|
94
288
|
}
|
|
95
289
|
}
|
|
96
290
|
// 2. Build Actor Part (A: anchor) - optional
|
|
97
|
-
let actorPart =
|
|
291
|
+
let actorPart = "";
|
|
98
292
|
if (comp.actor) {
|
|
99
293
|
actorPart = `/A:${comp.actor}`;
|
|
100
294
|
}
|
|
101
|
-
// 3. Build Time Part
|
|
102
|
-
|
|
103
|
-
if (comp.calendar === 'unix' && comp.unixSeconds !== undefined) {
|
|
104
|
-
timePart += `.s${comp.unixSeconds}`;
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
if (comp.millennium !== undefined)
|
|
108
|
-
timePart += `.m${comp.millennium}`;
|
|
109
|
-
if (comp.century !== undefined)
|
|
110
|
-
timePart += `.c${comp.century}`;
|
|
111
|
-
if (comp.year !== undefined)
|
|
112
|
-
timePart += `.y${comp.year}`;
|
|
113
|
-
if (comp.month !== undefined)
|
|
114
|
-
timePart += `.M${this.pad(comp.month)}`;
|
|
115
|
-
if (comp.day !== undefined)
|
|
116
|
-
timePart += `.d${this.pad(comp.day)}`;
|
|
117
|
-
if (comp.hour !== undefined)
|
|
118
|
-
timePart += `.h${this.pad(comp.hour)}`;
|
|
119
|
-
if (comp.minute !== undefined)
|
|
120
|
-
timePart += `.n${this.pad(comp.minute)}`;
|
|
121
|
-
if (comp.second !== undefined)
|
|
122
|
-
timePart += `.s${this.pad(comp.second)}`;
|
|
123
|
-
}
|
|
124
|
-
// 4. Add Signature (!) - optional
|
|
125
|
-
if (comp.signature) {
|
|
126
|
-
timePart += `!${comp.signature}`;
|
|
127
|
-
}
|
|
295
|
+
// 3. Build Time Part (handles order & signature)
|
|
296
|
+
const timePart = this.buildTimePart(comp);
|
|
128
297
|
// 5. Build Extensions
|
|
129
|
-
let extPart =
|
|
298
|
+
let extPart = "";
|
|
130
299
|
if (comp.extensions && Object.keys(comp.extensions).length > 0) {
|
|
131
300
|
const extStrings = Object.entries(comp.extensions).map(([k, v]) => `${k}=${v}`);
|
|
132
|
-
extPart = `;${extStrings.join(
|
|
301
|
+
extPart = `;${extStrings.join(".")}`;
|
|
133
302
|
}
|
|
134
|
-
|
|
303
|
+
// timePart already begins with 'T:'. The new canonical separator is '@'
|
|
304
|
+
// instead of '/', so we interpolate it accordingly. Actor anchor (if
|
|
305
|
+
// present) still uses a leading slash.
|
|
306
|
+
return `tps://${spacePart}${actorPart}@${timePart}${extPart}`;
|
|
135
307
|
}
|
|
136
308
|
/**
|
|
137
309
|
* CONVERTER: Creates a TPS Time Object string from a JavaScript Date.
|
|
138
310
|
* Supports plugin drivers for non-Gregorian calendars.
|
|
139
311
|
* @param date - The JS Date object (defaults to Now).
|
|
140
|
-
* @param calendar - The target calendar driver (default
|
|
141
|
-
* @
|
|
312
|
+
* @param calendar - The target calendar driver (default `"tps"`).
|
|
313
|
+
* @param opts - Optional parameters; for built-in calendars the only
|
|
314
|
+
* supported key is `order` which may be `'ascending'` or `'descending'`.
|
|
315
|
+
* @returns Canonical string (e.g., "T:tps.m3.c1.y26...").
|
|
142
316
|
*/
|
|
143
|
-
static fromDate(date = new Date(), calendar =
|
|
144
|
-
|
|
145
|
-
const driver = this.drivers.get(
|
|
317
|
+
static fromDate(date = new Date(), calendar = exports.DefaultCalendars.TPS, opts) {
|
|
318
|
+
const normalizedCalendar = calendar.toLowerCase();
|
|
319
|
+
const driver = this.drivers.get(normalizedCalendar);
|
|
146
320
|
if (driver) {
|
|
147
|
-
|
|
321
|
+
// when caller requested an explicit order we can bypass the driver's
|
|
322
|
+
// `fromDate` helper and instead generate components ourselves so that
|
|
323
|
+
// order is honoured even if the driver doesn't know about it. This
|
|
324
|
+
// keeps behaviour identical to the old built-in implementation.
|
|
325
|
+
if (opts?.order) {
|
|
326
|
+
const comp = driver.getComponentsFromDate(date);
|
|
327
|
+
comp.calendar = normalizedCalendar;
|
|
328
|
+
comp.order = opts.order;
|
|
329
|
+
return this.buildTimePart(comp);
|
|
330
|
+
}
|
|
331
|
+
return driver.getFromDate(date);
|
|
148
332
|
}
|
|
149
|
-
//
|
|
150
|
-
|
|
333
|
+
// Fallback for old built-in calendars (shouldn't happen once drivers are
|
|
334
|
+
// registered, but kept for backwards compatibility).
|
|
335
|
+
const comp = { calendar: normalizedCalendar };
|
|
336
|
+
if (normalizedCalendar === exports.DefaultCalendars.UNIX) {
|
|
151
337
|
const s = (date.getTime() / 1000).toFixed(3);
|
|
152
|
-
|
|
338
|
+
comp.unixSeconds = parseFloat(s);
|
|
339
|
+
if (opts?.order)
|
|
340
|
+
comp.order = opts.order;
|
|
341
|
+
return this.buildTimePart(comp);
|
|
153
342
|
}
|
|
154
|
-
if (
|
|
343
|
+
if (normalizedCalendar === exports.DefaultCalendars.GREG) {
|
|
155
344
|
const fullYear = date.getUTCFullYear();
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
345
|
+
comp.millennium = Math.floor(fullYear / 1000) + 1;
|
|
346
|
+
comp.century = Math.floor((fullYear % 1000) / 100) + 1;
|
|
347
|
+
comp.year = fullYear % 100;
|
|
348
|
+
comp.month = date.getUTCMonth() + 1;
|
|
349
|
+
comp.day = date.getUTCDate();
|
|
350
|
+
comp.hour = date.getUTCHours();
|
|
351
|
+
comp.minute = date.getUTCMinutes();
|
|
352
|
+
comp.second = date.getUTCSeconds();
|
|
353
|
+
comp.millisecond = date.getUTCMilliseconds();
|
|
354
|
+
if (opts?.order)
|
|
355
|
+
comp.order = opts.order;
|
|
356
|
+
return this.buildTimePart(comp);
|
|
357
|
+
}
|
|
358
|
+
throw new Error(`Calendar driver '${normalizedCalendar}' not implemented. Register a driver.`);
|
|
167
359
|
}
|
|
168
360
|
/**
|
|
169
361
|
* CONVERTER: Converts a TPS string to a Date in a target calendar format.
|
|
@@ -187,31 +379,21 @@ class TPS {
|
|
|
187
379
|
* @returns JS Date object or `null` if invalid.
|
|
188
380
|
*/
|
|
189
381
|
static toDate(tpsString) {
|
|
190
|
-
const
|
|
191
|
-
if (!
|
|
382
|
+
const parsed = this.parse(tpsString);
|
|
383
|
+
if (!parsed)
|
|
384
|
+
return null;
|
|
385
|
+
const cal = parsed.calendar || exports.DefaultCalendars.TPS;
|
|
386
|
+
const driver = this.drivers.get(cal);
|
|
387
|
+
if (!driver) {
|
|
388
|
+
console.error(`Calendar driver '${cal}' not registered.`);
|
|
192
389
|
return null;
|
|
193
|
-
// Check for registered driver first
|
|
194
|
-
const driver = this.drivers.get(p.calendar);
|
|
195
|
-
if (driver) {
|
|
196
|
-
return driver.toGregorian(p);
|
|
197
|
-
}
|
|
198
|
-
// Built-in handlers
|
|
199
|
-
if (p.calendar === 'unix' && p.unixSeconds !== undefined) {
|
|
200
|
-
return new Date(p.unixSeconds * 1000);
|
|
201
|
-
}
|
|
202
|
-
if (p.calendar === 'greg') {
|
|
203
|
-
const m = p.millennium || 0;
|
|
204
|
-
const c = p.century || 1;
|
|
205
|
-
const y = p.year || 0;
|
|
206
|
-
const fullYear = (m - 1) * 1000 + (c - 1) * 100 + y;
|
|
207
|
-
return new Date(Date.UTC(fullYear, (p.month || 1) - 1, p.day || 1, p.hour || 0, p.minute || 0, Math.floor(p.second || 0)));
|
|
208
390
|
}
|
|
209
|
-
return
|
|
391
|
+
return driver.getDateFromComponents(parsed);
|
|
210
392
|
}
|
|
211
393
|
// --- DRIVER CONVENIENCE METHODS ---
|
|
212
394
|
/**
|
|
213
395
|
* Parse a calendar-specific date string into TPS components.
|
|
214
|
-
* Requires the driver to implement
|
|
396
|
+
* Requires the driver to implement `parseDate`.
|
|
215
397
|
*
|
|
216
398
|
* @param calendar - The calendar code (e.g., 'hij')
|
|
217
399
|
* @param dateString - Date string in calendar-native format (e.g., '1447-07-21')
|
|
@@ -224,7 +406,7 @@ class TPS {
|
|
|
224
406
|
* // { calendar: 'hij', year: 1447, month: 7, day: 21 }
|
|
225
407
|
*
|
|
226
408
|
* const uri = TPS.toURI({ ...components, latitude: 31.95, longitude: 35.91 });
|
|
227
|
-
* // "tps://31.95,35.91@T:hij.y1447.
|
|
409
|
+
* // "tps://31.95,35.91@T:hij.y1447.m07.d21"
|
|
228
410
|
* ```
|
|
229
411
|
*/
|
|
230
412
|
static parseCalendarDate(calendar, dateString, format) {
|
|
@@ -232,9 +414,7 @@ class TPS {
|
|
|
232
414
|
if (!driver) {
|
|
233
415
|
throw new Error(`Calendar driver '${calendar}' not found. Register a driver first.`);
|
|
234
416
|
}
|
|
235
|
-
|
|
236
|
-
throw new Error(`Driver '${calendar}' does not implement parseDate(). Use fromGregorian() instead.`);
|
|
237
|
-
}
|
|
417
|
+
// parseDate is guaranteed by the interface, so we can call it directly.
|
|
238
418
|
return driver.parseDate(dateString, format);
|
|
239
419
|
}
|
|
240
420
|
/**
|
|
@@ -250,15 +430,15 @@ class TPS {
|
|
|
250
430
|
* ```ts
|
|
251
431
|
* // With coordinates
|
|
252
432
|
* TPS.fromCalendarDate('hij', '1447-07-21', { latitude: 31.95, longitude: 35.91 });
|
|
253
|
-
* // "tps://31.95,35.91@T:hij.y1447.
|
|
433
|
+
* // "tps://31.95,35.91@T:hij.y1447.m07.d21"
|
|
254
434
|
*
|
|
255
435
|
* // With privacy flag
|
|
256
436
|
* TPS.fromCalendarDate('hij', '1447-07-21', { isHiddenLocation: true });
|
|
257
|
-
* // "tps://hidden@T:hij.y1447.
|
|
437
|
+
* // "tps://hidden@T:hij.y1447.m07.d21"
|
|
258
438
|
*
|
|
259
439
|
* // Without location
|
|
260
440
|
* TPS.fromCalendarDate('hij', '1447-07-21');
|
|
261
|
-
* // "tps://unknown@T:hij.y1447.
|
|
441
|
+
* // "tps://unknown@T:hij.y1447.m07.d21"
|
|
262
442
|
* ```
|
|
263
443
|
*/
|
|
264
444
|
static fromCalendarDate(calendar, dateString, location) {
|
|
@@ -268,7 +448,7 @@ class TPS {
|
|
|
268
448
|
}
|
|
269
449
|
// Merge with location
|
|
270
450
|
const fullComponents = {
|
|
271
|
-
calendar,
|
|
451
|
+
calendar: calendar,
|
|
272
452
|
...components,
|
|
273
453
|
...location,
|
|
274
454
|
};
|
|
@@ -276,7 +456,7 @@ class TPS {
|
|
|
276
456
|
}
|
|
277
457
|
/**
|
|
278
458
|
* Format TPS components to a calendar-specific date string.
|
|
279
|
-
* Requires the driver to implement
|
|
459
|
+
* Requires the driver to implement `format`.
|
|
280
460
|
*
|
|
281
461
|
* @param calendar - The calendar code
|
|
282
462
|
* @param components - TPS components to format
|
|
@@ -285,7 +465,7 @@ class TPS {
|
|
|
285
465
|
*
|
|
286
466
|
* @example
|
|
287
467
|
* ```ts
|
|
288
|
-
* const tps = TPS.parse('tps://unknown@T:hij.y1447.
|
|
468
|
+
* const tps = TPS.parse('tps://unknown@T:hij.y1447.m07.d21');
|
|
289
469
|
* const formatted = TPS.formatCalendarDate('hij', tps);
|
|
290
470
|
* // "1447-07-21"
|
|
291
471
|
* ```
|
|
@@ -295,37 +475,224 @@ class TPS {
|
|
|
295
475
|
if (!driver) {
|
|
296
476
|
throw new Error(`Calendar driver '${calendar}' not found.`);
|
|
297
477
|
}
|
|
298
|
-
|
|
299
|
-
throw new Error(`Driver '${calendar}' does not implement format().`);
|
|
300
|
-
}
|
|
478
|
+
// format is guaranteed by the interface, so we can call it directly.
|
|
301
479
|
return driver.format(components, format);
|
|
302
480
|
}
|
|
303
481
|
// --- INTERNAL HELPERS ---
|
|
482
|
+
/**
|
|
483
|
+
* Generate the canonical `T:` time string for a set of components. The
|
|
484
|
+
* `order` field (or `comp.order`) controls whether tokens are emitted in
|
|
485
|
+
* ascending or descending hierarchy; if undefined the default
|
|
486
|
+
* `'descending'` orientation is used.
|
|
487
|
+
*
|
|
488
|
+
* Drivers may ignore this helper and produce their own time strings if they
|
|
489
|
+
* implement custom ordering logic.
|
|
490
|
+
*/
|
|
491
|
+
static buildTimePart(comp) {
|
|
492
|
+
const calendar = (comp.calendar || "").toLowerCase();
|
|
493
|
+
if (!/^[a-z]{3,4}$/.test(calendar)) {
|
|
494
|
+
throw new Error(`Invalid calendar code '${comp.calendar}'. Calendar code width must be 3–4 lowercase letters.`);
|
|
495
|
+
}
|
|
496
|
+
let time = `T:${calendar}`;
|
|
497
|
+
if (calendar === exports.DefaultCalendars.UNIX) {
|
|
498
|
+
if (comp.unixSeconds !== undefined) {
|
|
499
|
+
time += `.s${comp.unixSeconds}`;
|
|
500
|
+
}
|
|
501
|
+
return time;
|
|
502
|
+
}
|
|
503
|
+
// sequence of [prefix, value, rank]
|
|
504
|
+
// All four of millennium / month / minute / millisecond share the prefix 'm'.
|
|
505
|
+
// Position within the ordered sequence disambiguates them during parsing.
|
|
506
|
+
const tokens = [
|
|
507
|
+
["m", comp.millennium, 8], // m-token rank 8 → millennium
|
|
508
|
+
["c", comp.century, 7],
|
|
509
|
+
["y", comp.year, 6],
|
|
510
|
+
["m", comp.month, 5], // m-token rank 5 → month
|
|
511
|
+
["d", comp.day, 4],
|
|
512
|
+
["h", comp.hour, 3],
|
|
513
|
+
["m", comp.minute, 2], // m-token rank 2 → minute
|
|
514
|
+
["s", comp.second, 1],
|
|
515
|
+
["m", comp.millisecond, 0], // m-token rank 0 → millisecond
|
|
516
|
+
];
|
|
517
|
+
const order = comp.order || TimeOrder.DESC;
|
|
518
|
+
if (order === TimeOrder.ASC)
|
|
519
|
+
tokens.reverse();
|
|
520
|
+
for (const [pref, val] of tokens) {
|
|
521
|
+
if (val !== undefined) {
|
|
522
|
+
// seconds may be fractional
|
|
523
|
+
time += `.${pref}${val}`;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (comp.signature) {
|
|
527
|
+
time += `!${comp.signature}`;
|
|
528
|
+
}
|
|
529
|
+
return time;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Parse the *time* portion of a TPS string (optionally beginning with
|
|
533
|
+
* `T:`) into components and determine the component ordering. This helper
|
|
534
|
+
* accepts tokens in **any** sequence and will return an `order` value of
|
|
535
|
+
* `'ascending'` or `'descending'`.
|
|
536
|
+
*
|
|
537
|
+
* The caller is responsible for stripping off a leading signature or other
|
|
538
|
+
* trailer characters; this method will drop anything after `!`, `;`, `?` or
|
|
539
|
+
* `#`.
|
|
540
|
+
*
|
|
541
|
+
* ### `m`-token disambiguation
|
|
542
|
+
* All four of millennium (rank 8), month (rank 5), minute (rank 2) and
|
|
543
|
+
* millisecond (rank 0) share the single-character prefix `m`. They are told
|
|
544
|
+
* apart by their **position relative to the neighbouring tokens**. The
|
|
545
|
+
* algorithm is:
|
|
546
|
+
*
|
|
547
|
+
* 1. Pre-scan the non-`m` tokens (c, y, d, h, s) whose ranks are fixed to
|
|
548
|
+
* determine whether the string is ascending or descending.
|
|
549
|
+
* 2. While iterating, track `lastAssignedRank` – the rank of the most
|
|
550
|
+
* recently processed token (m or non-m).
|
|
551
|
+
* 3. When an `m` token is encountered, derive its rank from `lastAssignedRank`
|
|
552
|
+
* and the detected order:
|
|
553
|
+
* - **DESC** null → 8 (mill) | rank > 5 → 5 (month) | rank > 2 → 2 (min) | else → 0 (ms)
|
|
554
|
+
* - **ASC** null → 0 (ms) | rank < 2 → 2 (min) | rank < 5 → 5 (month) | else → 8 (mill)
|
|
555
|
+
*
|
|
556
|
+
* @param input - Time fragment (e.g. `"T:greg.m3.c1.y26"` or `"greg.m0.s25.m30"`)
|
|
557
|
+
*/
|
|
558
|
+
static parseTimeString(input) {
|
|
559
|
+
let s = input.trim();
|
|
560
|
+
// strip off anything after signature or extensions/query/fragment
|
|
561
|
+
s = s.split(/[!;?#]/)[0];
|
|
562
|
+
if (s.startsWith("T:"))
|
|
563
|
+
s = s.slice(2);
|
|
564
|
+
const parts = s.split(".");
|
|
565
|
+
if (parts.length === 0)
|
|
566
|
+
return null;
|
|
567
|
+
const calendar = parts[0];
|
|
568
|
+
const comp = { calendar };
|
|
569
|
+
// Fixed-rank prefixes (unambiguous regardless of position)
|
|
570
|
+
const fixedRankMap = {
|
|
571
|
+
c: 7,
|
|
572
|
+
y: 6,
|
|
573
|
+
d: 4,
|
|
574
|
+
h: 3,
|
|
575
|
+
s: 1,
|
|
576
|
+
};
|
|
577
|
+
// ── Step 1: pre-scan non-m tokens to estimate order ─────────────────────
|
|
578
|
+
// This is only needed to handle the first 'm' token when lastAssignedRank
|
|
579
|
+
// is still null (nothing has been seen yet).
|
|
580
|
+
let initialOrder = TimeOrder.DESC;
|
|
581
|
+
if (calendar !== exports.DefaultCalendars.UNIX) {
|
|
582
|
+
const nonMRanks = [];
|
|
583
|
+
for (let i = 1; i < parts.length; i++) {
|
|
584
|
+
const pr = parts[i]?.charAt(0);
|
|
585
|
+
if (pr && pr in fixedRankMap)
|
|
586
|
+
nonMRanks.push(fixedRankMap[pr]);
|
|
587
|
+
}
|
|
588
|
+
if (nonMRanks.length >= 2) {
|
|
589
|
+
const isAsc = nonMRanks.every((v, i, a) => i === 0 || a[i - 1] <= v);
|
|
590
|
+
if (isAsc)
|
|
591
|
+
initialOrder = TimeOrder.ASC;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// ── Step 2: resolve the semantic rank of an 'm' token ───────────────────
|
|
595
|
+
const assignMRank = (lastRank, ord) => {
|
|
596
|
+
if (ord === TimeOrder.DESC) {
|
|
597
|
+
if (lastRank === null)
|
|
598
|
+
return 8; // first token → millennium
|
|
599
|
+
if (lastRank > 5)
|
|
600
|
+
return 5; // after century / year → month
|
|
601
|
+
if (lastRank > 2)
|
|
602
|
+
return 2; // after day / hour → minute
|
|
603
|
+
return 0; // after second → millisecond
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
if (lastRank === null)
|
|
607
|
+
return 0; // first token → millisecond
|
|
608
|
+
if (lastRank < 2)
|
|
609
|
+
return 2; // after millisecond / second → minute
|
|
610
|
+
if (lastRank < 5)
|
|
611
|
+
return 5; // after minute / hour / day → month
|
|
612
|
+
return 8; // after month / year / cent → millennium
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
// ── Step 3: iterate and build components ────────────────────────────────
|
|
616
|
+
const ranks = [];
|
|
617
|
+
let lastAssignedRank = null;
|
|
618
|
+
for (let i = 1; i < parts.length; i++) {
|
|
619
|
+
const token = parts[i];
|
|
620
|
+
if (!token)
|
|
621
|
+
continue;
|
|
622
|
+
const prefix = token.charAt(0);
|
|
623
|
+
const value = token.slice(1);
|
|
624
|
+
// UNIX calendar: single 's' token carries the full unix timestamp
|
|
625
|
+
if (calendar === exports.DefaultCalendars.UNIX && prefix === "s") {
|
|
626
|
+
comp.unixSeconds = parseFloat(value);
|
|
627
|
+
ranks.push(9);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (prefix === "m") {
|
|
631
|
+
const rank = assignMRank(lastAssignedRank, initialOrder);
|
|
632
|
+
switch (rank) {
|
|
633
|
+
case 8:
|
|
634
|
+
comp.millennium = parseInt(value, 10);
|
|
635
|
+
break;
|
|
636
|
+
case 5:
|
|
637
|
+
comp.month = parseInt(value, 10);
|
|
638
|
+
break;
|
|
639
|
+
case 2:
|
|
640
|
+
comp.minute = parseInt(value, 10);
|
|
641
|
+
break;
|
|
642
|
+
case 0:
|
|
643
|
+
comp.millisecond = parseInt(value, 10);
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
ranks.push(rank);
|
|
647
|
+
lastAssignedRank = rank;
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
switch (prefix) {
|
|
651
|
+
case "c":
|
|
652
|
+
comp.century = parseInt(value, 10);
|
|
653
|
+
ranks.push(7);
|
|
654
|
+
lastAssignedRank = 7;
|
|
655
|
+
break;
|
|
656
|
+
case "y":
|
|
657
|
+
comp.year = parseInt(value, 10);
|
|
658
|
+
ranks.push(6);
|
|
659
|
+
lastAssignedRank = 6;
|
|
660
|
+
break;
|
|
661
|
+
case "d":
|
|
662
|
+
comp.day = parseInt(value, 10);
|
|
663
|
+
ranks.push(4);
|
|
664
|
+
lastAssignedRank = 4;
|
|
665
|
+
break;
|
|
666
|
+
case "h":
|
|
667
|
+
comp.hour = parseInt(value, 10);
|
|
668
|
+
ranks.push(3);
|
|
669
|
+
lastAssignedRank = 3;
|
|
670
|
+
break;
|
|
671
|
+
case "s":
|
|
672
|
+
comp.second = parseFloat(value);
|
|
673
|
+
ranks.push(1);
|
|
674
|
+
lastAssignedRank = 1;
|
|
675
|
+
break;
|
|
676
|
+
default:
|
|
677
|
+
// unknown prefix – ignore
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// ── Step 4: confirm order from the complete rank sequence ────────────────
|
|
683
|
+
let order = TimeOrder.DESC;
|
|
684
|
+
if (ranks.length > 1) {
|
|
685
|
+
const isAsc = ranks.every((v, i, a) => i === 0 || a[i - 1] <= v);
|
|
686
|
+
const isDesc = ranks.every((v, i, a) => i === 0 || a[i - 1] >= v);
|
|
687
|
+
if (isAsc && !isDesc)
|
|
688
|
+
order = TimeOrder.ASC;
|
|
689
|
+
// mixed / single direction → defaults to DESC
|
|
690
|
+
}
|
|
691
|
+
return { components: comp, order };
|
|
692
|
+
}
|
|
304
693
|
static _mapGroupsToComponents(g) {
|
|
305
694
|
const components = {};
|
|
306
695
|
components.calendar = g.calendar;
|
|
307
|
-
// Time Mapping
|
|
308
|
-
if (components.calendar === 'unix' && g.unix) {
|
|
309
|
-
components.unixSeconds = parseFloat(g.unix.substring(1));
|
|
310
|
-
}
|
|
311
|
-
else {
|
|
312
|
-
if (g.millennium)
|
|
313
|
-
components.millennium = parseInt(g.millennium, 10);
|
|
314
|
-
if (g.century)
|
|
315
|
-
components.century = parseInt(g.century, 10);
|
|
316
|
-
if (g.year)
|
|
317
|
-
components.year = parseInt(g.year, 10);
|
|
318
|
-
if (g.month)
|
|
319
|
-
components.month = parseInt(g.month, 10);
|
|
320
|
-
if (g.day)
|
|
321
|
-
components.day = parseInt(g.day, 10);
|
|
322
|
-
if (g.hour)
|
|
323
|
-
components.hour = parseInt(g.hour, 10);
|
|
324
|
-
if (g.minute)
|
|
325
|
-
components.minute = parseInt(g.minute, 10);
|
|
326
|
-
if (g.second)
|
|
327
|
-
components.second = parseFloat(g.second);
|
|
328
|
-
}
|
|
329
696
|
// Signature Mapping
|
|
330
697
|
if (g.signature) {
|
|
331
698
|
components.signature = g.signature;
|
|
@@ -337,13 +704,13 @@ class TPS {
|
|
|
337
704
|
// Space Mapping
|
|
338
705
|
if (g.space) {
|
|
339
706
|
// Privacy markers
|
|
340
|
-
if (g.space ===
|
|
707
|
+
if (g.space === "unknown" || g.space === "-") {
|
|
341
708
|
components.isUnknownLocation = true;
|
|
342
709
|
}
|
|
343
|
-
else if (g.space ===
|
|
710
|
+
else if (g.space === "redacted") {
|
|
344
711
|
components.isRedactedLocation = true;
|
|
345
712
|
}
|
|
346
|
-
else if (g.space ===
|
|
713
|
+
else if (g.space === "hidden" || g.space === "~") {
|
|
347
714
|
components.isHiddenLocation = true;
|
|
348
715
|
}
|
|
349
716
|
// Geospatial cells
|
|
@@ -369,6 +736,10 @@ class TPS {
|
|
|
369
736
|
if (g.zone)
|
|
370
737
|
components.zone = g.zone;
|
|
371
738
|
}
|
|
739
|
+
// Generic pre-@ anchor (adm/node/net/planet/etc)
|
|
740
|
+
else if (g.generic) {
|
|
741
|
+
components.spaceAnchor = g.generic;
|
|
742
|
+
}
|
|
372
743
|
// GPS coordinates
|
|
373
744
|
else {
|
|
374
745
|
if (g.lat)
|
|
@@ -382,9 +753,9 @@ class TPS {
|
|
|
382
753
|
// Extensions Mapping
|
|
383
754
|
if (g.extensions) {
|
|
384
755
|
const extObj = {};
|
|
385
|
-
const parts = g.extensions.split(
|
|
756
|
+
const parts = g.extensions.split(".");
|
|
386
757
|
parts.forEach((p) => {
|
|
387
|
-
const eqIdx = p.indexOf(
|
|
758
|
+
const eqIdx = p.indexOf("=");
|
|
388
759
|
if (eqIdx > 0) {
|
|
389
760
|
const key = p.substring(0, eqIdx);
|
|
390
761
|
const val = p.substring(eqIdx + 1);
|
|
@@ -403,45 +774,47 @@ class TPS {
|
|
|
403
774
|
}
|
|
404
775
|
return components;
|
|
405
776
|
}
|
|
406
|
-
static pad(n) {
|
|
407
|
-
const s = n.toString();
|
|
408
|
-
return s.length < 2 ? '0' + s : s;
|
|
409
|
-
}
|
|
410
777
|
}
|
|
411
778
|
exports.TPS = TPS;
|
|
412
779
|
// --- PLUGIN REGISTRY ---
|
|
413
780
|
TPS.drivers = new Map();
|
|
414
781
|
// --- REGEX ---
|
|
415
782
|
// Updated for v0.5.0: supports L: anchors, A: actor, ! signature, structural & geospatial anchors
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
783
|
+
// Tokens may appear in any order; actual semantic parsing happens in
|
|
784
|
+
// `parseTimeString()` so these patterns are intentionally permissive.
|
|
785
|
+
// regex simply ensures prefix, space, calendar, and token characters;
|
|
786
|
+
// token order is not enforced (parseTimeString handles semantics).
|
|
787
|
+
TPS.REGEX_URI = new RegExp("^tps://" +
|
|
788
|
+
// Location part (preserve named captures for space subfields)
|
|
789
|
+
"(?:L:)?(?<space>" +
|
|
790
|
+
"~|-|unknown|redacted|hidden|" +
|
|
791
|
+
"s2=(?<s2>[a-fA-F0-9]+)|" +
|
|
792
|
+
"h3=(?<h3>[a-fA-F0-9]+)|" +
|
|
793
|
+
"plus=(?<plus>[A-Z0-9+]+)|" +
|
|
794
|
+
"w3w=(?<w3w>[a-z]+\\.[a-z]+\\.[a-z]+)|" +
|
|
795
|
+
"bldg=(?<bldg>[\\w-]+)(?:\\.floor=(?<floor>[\\w-]+))?(?:\\.room=(?<room>[\\w-]+))?(?:\\.zone=(?<zone>[\\w-]+))?|" +
|
|
796
|
+
"(?<lat>-?\\d+(?:\\.\\d+)?),(?<lon>-?\\d+(?:\\.\\d+)?)(?:,(?<alt>-?\\d+(?:\\.\\d+)?)m?)?|" +
|
|
797
|
+
"(?<generic>[^@/?#]+)" +
|
|
798
|
+
")" +
|
|
799
|
+
"(?:/A:(?<actor>[^/@]+))?" +
|
|
800
|
+
"@T:(?<calendar>[a-z]{3,4})" +
|
|
801
|
+
"(?:\\.(?:m-?\\d+|c\\d+|y\\d+|d\\d{1,2}|h\\d{1,2}|s\\d+(?:\\.\\d+)?))*" +
|
|
802
|
+
"(?:![^;?#]+)?" +
|
|
803
|
+
"(?:;(?<extensions>[^?#]+))?" +
|
|
804
|
+
"(?:\\?[^#]+)?" +
|
|
805
|
+
"(?:#.+)?$");
|
|
806
|
+
TPS.REGEX_TIME = new RegExp("^T:(?<calendar>[a-z]{3,4})" +
|
|
807
|
+
"(?:\\.(?:m-?\\d+|c\\d+|y\\d+|d\\d{1,2}|h\\d{1,2}|s\\d+(?:\\.\\d+)?))*" +
|
|
808
|
+
"(?:![^;?#]+)?$");
|
|
809
|
+
// register built-in drivers and set default
|
|
810
|
+
// (tps and gregorian provide canonical conversions before unix)
|
|
811
|
+
TPS.registerDriver(new tps_1.TpsDriver());
|
|
812
|
+
TPS.registerDriver(new gregorian_1.GregorianDriver());
|
|
813
|
+
TPS.registerDriver(new unix_1.UnixDriver());
|
|
814
|
+
TPS.registerDriver(new persian_1.PersianDriver());
|
|
815
|
+
TPS.registerDriver(new hijri_1.HijriDriver());
|
|
816
|
+
TPS.registerDriver(new julian_1.JulianDriver());
|
|
817
|
+
TPS.registerDriver(new holocene_1.HoloceneDriver());
|
|
445
818
|
/**
|
|
446
819
|
* TPS-UID v1 — Temporal Positioning System Identifier (Binary Reversible)
|
|
447
820
|
*
|
|
@@ -462,7 +835,7 @@ TPS.REGEX_TIME = new RegExp('^T:(?<calendar>[a-z]{3,4})\\.' +
|
|
|
462
835
|
*
|
|
463
836
|
* @example
|
|
464
837
|
* ```ts
|
|
465
|
-
* const tps = 'tps://31.95,35.91@T:greg.m3.c1.y26.
|
|
838
|
+
* const tps = 'tps://31.95,35.91@T:greg.m3.c1.y26.m01.d09';
|
|
466
839
|
*
|
|
467
840
|
* // Encode to binary
|
|
468
841
|
* const bytes = TPSUID7RB.encodeBinary(tps);
|
|
@@ -488,14 +861,14 @@ class TPSUID7RB {
|
|
|
488
861
|
* @param opts - Encoding options (compress, epochMs override)
|
|
489
862
|
* @returns Binary TPS-UID as Uint8Array
|
|
490
863
|
*/
|
|
491
|
-
static encodeBinary(tps, opts) {
|
|
492
|
-
const compress = opts
|
|
493
|
-
const epochMs = opts
|
|
864
|
+
static encodeBinary(tps, opts = {}) {
|
|
865
|
+
const compress = opts.compress ?? false;
|
|
866
|
+
const epochMs = opts.epochMs ?? this.epochMsFromTPSString(tps);
|
|
494
867
|
if (!Number.isInteger(epochMs) || epochMs < 0) {
|
|
495
|
-
throw new Error(
|
|
868
|
+
throw new Error("epochMs must be a non-negative integer");
|
|
496
869
|
}
|
|
497
870
|
if (epochMs > 0xffffffffffff) {
|
|
498
|
-
throw new Error(
|
|
871
|
+
throw new Error("epochMs exceeds 48-bit range");
|
|
499
872
|
}
|
|
500
873
|
const flags = compress ? 0x01 : 0x00;
|
|
501
874
|
// Generate 32-bit nonce
|
|
@@ -543,14 +916,14 @@ class TPSUID7RB {
|
|
|
543
916
|
static decodeBinary(bytes) {
|
|
544
917
|
// Header min size: 4+1+1+6+4 + 1 (at least 1 byte varint) = 17
|
|
545
918
|
if (bytes.length < 17) {
|
|
546
|
-
throw new Error(
|
|
919
|
+
throw new Error("TPSUID7RB: too short");
|
|
547
920
|
}
|
|
548
921
|
// MAGIC
|
|
549
922
|
if (bytes[0] !== 0x54 ||
|
|
550
923
|
bytes[1] !== 0x50 ||
|
|
551
924
|
bytes[2] !== 0x55 ||
|
|
552
925
|
bytes[3] !== 0x37) {
|
|
553
|
-
throw new Error(
|
|
926
|
+
throw new Error("TPSUID7RB: bad magic");
|
|
554
927
|
}
|
|
555
928
|
// VERSION
|
|
556
929
|
const ver = bytes[4];
|
|
@@ -572,13 +945,13 @@ class TPSUID7RB {
|
|
|
572
945
|
const { value: tpsLen, bytesRead } = this.uvarintDecode(bytes, offset);
|
|
573
946
|
offset += bytesRead;
|
|
574
947
|
if (offset + tpsLen > bytes.length) {
|
|
575
|
-
throw new Error(
|
|
948
|
+
throw new Error("TPSUID7RB: length overflow");
|
|
576
949
|
}
|
|
577
950
|
// TPS payload
|
|
578
951
|
const payload = bytes.slice(offset, offset + tpsLen);
|
|
579
952
|
const tpsUtf8 = compressed ? this.inflateRaw(payload) : payload;
|
|
580
953
|
const tps = new TextDecoder().decode(tpsUtf8);
|
|
581
|
-
return { version:
|
|
954
|
+
return { version: "tpsuid7rb", epochMs, compressed, nonce, tps };
|
|
582
955
|
}
|
|
583
956
|
/**
|
|
584
957
|
* Encode TPS to base64url string with prefix.
|
|
@@ -589,7 +962,7 @@ class TPSUID7RB {
|
|
|
589
962
|
* @returns Base64url encoded TPS-UID with prefix
|
|
590
963
|
*/
|
|
591
964
|
static encodeBinaryB64(tps, opts) {
|
|
592
|
-
const bytes = this.encodeBinary(tps, opts);
|
|
965
|
+
const bytes = this.encodeBinary(tps, opts ?? {});
|
|
593
966
|
return `${this.PREFIX}${this.base64UrlEncode(bytes)}`;
|
|
594
967
|
}
|
|
595
968
|
/**
|
|
@@ -601,7 +974,7 @@ class TPSUID7RB {
|
|
|
601
974
|
static decodeBinaryB64(id) {
|
|
602
975
|
const s = id.trim();
|
|
603
976
|
if (!s.startsWith(this.PREFIX)) {
|
|
604
|
-
throw new Error(
|
|
977
|
+
throw new Error("TPSUID7RB: missing prefix");
|
|
605
978
|
}
|
|
606
979
|
const b64 = s.slice(this.PREFIX.length);
|
|
607
980
|
const bytes = this.base64UrlDecode(b64);
|
|
@@ -625,7 +998,17 @@ class TPSUID7RB {
|
|
|
625
998
|
*/
|
|
626
999
|
static generate(opts) {
|
|
627
1000
|
const now = new Date();
|
|
628
|
-
const
|
|
1001
|
+
const time = TPS.fromDate(now, exports.DefaultCalendars.TPS, {
|
|
1002
|
+
order: opts?.order,
|
|
1003
|
+
});
|
|
1004
|
+
let space = "unknown";
|
|
1005
|
+
if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
|
|
1006
|
+
space = `${opts.latitude},${opts.longitude}`;
|
|
1007
|
+
if (opts.altitude !== undefined) {
|
|
1008
|
+
space += `,${opts.altitude}m`;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
const tps = `tps://${space}@${time}`;
|
|
629
1012
|
return this.encodeBinaryB64(tps, {
|
|
630
1013
|
compress: opts?.compress,
|
|
631
1014
|
epochMs: now.getTime(),
|
|
@@ -637,86 +1020,21 @@ class TPSUID7RB {
|
|
|
637
1020
|
/**
|
|
638
1021
|
* Generate a TPS string from a Date and optional location.
|
|
639
1022
|
*/
|
|
640
|
-
static generateTPSString(date, opts) {
|
|
641
|
-
const fullYear = date.getUTCFullYear();
|
|
642
|
-
const m = Math.floor(fullYear / 1000) + 1;
|
|
643
|
-
const c = Math.floor((fullYear % 1000) / 100) + 1;
|
|
644
|
-
const y = fullYear % 100;
|
|
645
|
-
const M = date.getUTCMonth() + 1;
|
|
646
|
-
const d = date.getUTCDate();
|
|
647
|
-
const h = date.getUTCHours();
|
|
648
|
-
const n = date.getUTCMinutes();
|
|
649
|
-
const s = date.getUTCSeconds();
|
|
650
|
-
const pad = (num) => num.toString().padStart(2, '0');
|
|
651
|
-
const timePart = `T:greg.m${m}.c${c}.y${y}.M${pad(M)}.d${pad(d)}.h${pad(h)}.n${pad(n)}.s${pad(s)}`;
|
|
652
|
-
let spacePart = 'unknown';
|
|
653
|
-
if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
|
|
654
|
-
spacePart = `${opts.latitude},${opts.longitude}`;
|
|
655
|
-
if (opts.altitude !== undefined) {
|
|
656
|
-
spacePart += `,${opts.altitude}m`;
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
return `tps://${spacePart}@${timePart}`;
|
|
660
|
-
}
|
|
661
1023
|
/**
|
|
662
1024
|
* Parse epoch milliseconds from a TPS string.
|
|
663
1025
|
* Supports both URI format (tps://...) and time-only format (T:greg...)
|
|
664
1026
|
*/
|
|
665
1027
|
static epochMsFromTPSString(tps) {
|
|
666
|
-
|
|
667
|
-
if (
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
else {
|
|
677
|
-
throw new Error('TPS: unrecognized format');
|
|
678
|
-
}
|
|
679
|
-
if (!time.startsWith('T:greg.')) {
|
|
680
|
-
throw new Error('TPS: only T:greg.* parsing is supported');
|
|
681
|
-
}
|
|
682
|
-
// Extract m (millennium), c (century), y (year)
|
|
683
|
-
const mMatch = time.match(/\.m(-?\d+)/);
|
|
684
|
-
const cMatch = time.match(/\.c(\d+)/);
|
|
685
|
-
const yMatch = time.match(/\.y(\d{1,4})/);
|
|
686
|
-
const MMatch = time.match(/\.M(\d{1,2})/);
|
|
687
|
-
const dMatch = time.match(/\.d(\d{1,2})/);
|
|
688
|
-
const hMatch = time.match(/\.h(\d{1,2})/);
|
|
689
|
-
const nMatch = time.match(/\.n(\d{1,2})/);
|
|
690
|
-
const sMatch = time.match(/\.s(\d{1,2})/);
|
|
691
|
-
// Calculate full year from millennium, century, year
|
|
692
|
-
let fullYear;
|
|
693
|
-
if (mMatch && cMatch && yMatch) {
|
|
694
|
-
const millennium = parseInt(mMatch[1], 10);
|
|
695
|
-
const century = parseInt(cMatch[1], 10);
|
|
696
|
-
const year = parseInt(yMatch[1], 10);
|
|
697
|
-
fullYear = (millennium - 1) * 1000 + (century - 1) * 100 + year;
|
|
698
|
-
}
|
|
699
|
-
else if (yMatch) {
|
|
700
|
-
// Fallback: interpret y as 2-digit year
|
|
701
|
-
let year = parseInt(yMatch[1], 10);
|
|
702
|
-
if (year < 100) {
|
|
703
|
-
year = year <= 69 ? 2000 + year : 1900 + year;
|
|
704
|
-
}
|
|
705
|
-
fullYear = year;
|
|
706
|
-
}
|
|
707
|
-
else {
|
|
708
|
-
throw new Error('TPS: missing year component');
|
|
709
|
-
}
|
|
710
|
-
const month = MMatch ? parseInt(MMatch[1], 10) : 1;
|
|
711
|
-
const day = dMatch ? parseInt(dMatch[1], 10) : 1;
|
|
712
|
-
const hour = hMatch ? parseInt(hMatch[1], 10) : 0;
|
|
713
|
-
const minute = nMatch ? parseInt(nMatch[1], 10) : 0;
|
|
714
|
-
const second = sMatch ? parseInt(sMatch[1], 10) : 0;
|
|
715
|
-
const epoch = Date.UTC(fullYear, month - 1, day, hour, minute, second);
|
|
716
|
-
if (!Number.isFinite(epoch)) {
|
|
717
|
-
throw new Error('TPS: failed to compute epochMs');
|
|
718
|
-
}
|
|
719
|
-
return epoch;
|
|
1028
|
+
const date = TPS.toDate(tps);
|
|
1029
|
+
if (date)
|
|
1030
|
+
return date.getTime();
|
|
1031
|
+
// If parse fails due to unsupported/extended extension payloads,
|
|
1032
|
+
// strip extensions/query/fragment and retry. Epoch only depends on time.
|
|
1033
|
+
const stripped = tps.replace(/;[^?#]*/, "").replace(/[?#].*$/, "");
|
|
1034
|
+
const retryDate = TPS.toDate(stripped);
|
|
1035
|
+
if (!retryDate)
|
|
1036
|
+
throw new Error("TPS: unable to parse date for epoch");
|
|
1037
|
+
return retryDate.getTime();
|
|
720
1038
|
}
|
|
721
1039
|
// ---------------------------
|
|
722
1040
|
// Binary Helpers
|
|
@@ -744,14 +1062,14 @@ class TPSUID7RB {
|
|
|
744
1062
|
BigInt(bytes[offset + 5]);
|
|
745
1063
|
const n = Number(v);
|
|
746
1064
|
if (!Number.isSafeInteger(n)) {
|
|
747
|
-
throw new Error(
|
|
1065
|
+
throw new Error("TPSUID7RB: u48 not safe integer");
|
|
748
1066
|
}
|
|
749
1067
|
return n;
|
|
750
1068
|
}
|
|
751
1069
|
/** Encode unsigned integer as LEB128 varint */
|
|
752
1070
|
static uvarintEncode(n) {
|
|
753
1071
|
if (!Number.isInteger(n) || n < 0) {
|
|
754
|
-
throw new Error(
|
|
1072
|
+
throw new Error("uvarint must be non-negative int");
|
|
755
1073
|
}
|
|
756
1074
|
const out = [];
|
|
757
1075
|
let x = n >>> 0;
|
|
@@ -769,12 +1087,12 @@ class TPSUID7RB {
|
|
|
769
1087
|
let i = 0;
|
|
770
1088
|
while (true) {
|
|
771
1089
|
if (offset + i >= bytes.length) {
|
|
772
|
-
throw new Error(
|
|
1090
|
+
throw new Error("uvarint overflow");
|
|
773
1091
|
}
|
|
774
1092
|
const b = bytes[offset + i];
|
|
775
1093
|
if (b < 0x80) {
|
|
776
1094
|
if (i > 9 || (i === 9 && b > 1)) {
|
|
777
|
-
throw new Error(
|
|
1095
|
+
throw new Error("uvarint too large");
|
|
778
1096
|
}
|
|
779
1097
|
x |= b << s;
|
|
780
1098
|
return { value: x >>> 0, bytesRead: i + 1 };
|
|
@@ -783,7 +1101,7 @@ class TPSUID7RB {
|
|
|
783
1101
|
s += 7;
|
|
784
1102
|
i++;
|
|
785
1103
|
if (i > 10) {
|
|
786
|
-
throw new Error(
|
|
1104
|
+
throw new Error("uvarint too long");
|
|
787
1105
|
}
|
|
788
1106
|
}
|
|
789
1107
|
}
|
|
@@ -793,33 +1111,33 @@ class TPSUID7RB {
|
|
|
793
1111
|
/** Encode bytes to base64url (no padding) */
|
|
794
1112
|
static base64UrlEncode(bytes) {
|
|
795
1113
|
// Node.js environment
|
|
796
|
-
if (typeof Buffer !==
|
|
1114
|
+
if (typeof Buffer !== "undefined") {
|
|
797
1115
|
return Buffer.from(bytes)
|
|
798
|
-
.toString(
|
|
799
|
-
.replace(/\+/g,
|
|
800
|
-
.replace(/\//g,
|
|
801
|
-
.replace(/=+$/g,
|
|
1116
|
+
.toString("base64")
|
|
1117
|
+
.replace(/\+/g, "-")
|
|
1118
|
+
.replace(/\//g, "_")
|
|
1119
|
+
.replace(/=+$/g, "");
|
|
802
1120
|
}
|
|
803
1121
|
// Browser environment
|
|
804
|
-
let binary =
|
|
1122
|
+
let binary = "";
|
|
805
1123
|
for (let i = 0; i < bytes.length; i++) {
|
|
806
1124
|
binary += String.fromCharCode(bytes[i]);
|
|
807
1125
|
}
|
|
808
1126
|
return btoa(binary)
|
|
809
|
-
.replace(/\+/g,
|
|
810
|
-
.replace(/\//g,
|
|
811
|
-
.replace(/=+$/g,
|
|
1127
|
+
.replace(/\+/g, "-")
|
|
1128
|
+
.replace(/\//g, "_")
|
|
1129
|
+
.replace(/=+$/g, "");
|
|
812
1130
|
}
|
|
813
1131
|
/** Decode base64url to bytes */
|
|
814
1132
|
static base64UrlDecode(b64url) {
|
|
815
1133
|
// Add padding
|
|
816
1134
|
const padLen = (4 - (b64url.length % 4)) % 4;
|
|
817
|
-
const b64 = (b64url +
|
|
818
|
-
.replace(/-/g,
|
|
819
|
-
.replace(/_/g,
|
|
1135
|
+
const b64 = (b64url + "=".repeat(padLen))
|
|
1136
|
+
.replace(/-/g, "+")
|
|
1137
|
+
.replace(/_/g, "/");
|
|
820
1138
|
// Node.js environment
|
|
821
|
-
if (typeof Buffer !==
|
|
822
|
-
return new Uint8Array(Buffer.from(b64,
|
|
1139
|
+
if (typeof Buffer !== "undefined") {
|
|
1140
|
+
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
823
1141
|
}
|
|
824
1142
|
// Browser environment
|
|
825
1143
|
const binary = atob(b64);
|
|
@@ -835,34 +1153,34 @@ class TPSUID7RB {
|
|
|
835
1153
|
/** Compress using zlib deflate raw */
|
|
836
1154
|
static deflateRaw(data) {
|
|
837
1155
|
// Node.js environment
|
|
838
|
-
if (typeof require !==
|
|
1156
|
+
if (typeof require !== "undefined") {
|
|
839
1157
|
try {
|
|
840
1158
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
841
|
-
const zlib = require(
|
|
1159
|
+
const zlib = require("zlib");
|
|
842
1160
|
return new Uint8Array(zlib.deflateRawSync(Buffer.from(data)));
|
|
843
1161
|
}
|
|
844
1162
|
catch {
|
|
845
|
-
throw new Error(
|
|
1163
|
+
throw new Error("TPSUID7RB: compression not available");
|
|
846
1164
|
}
|
|
847
1165
|
}
|
|
848
1166
|
// Browser: would need pako or similar library
|
|
849
|
-
throw new Error(
|
|
1167
|
+
throw new Error("TPSUID7RB: compression not available in browser");
|
|
850
1168
|
}
|
|
851
1169
|
/** Decompress using zlib inflate raw */
|
|
852
1170
|
static inflateRaw(data) {
|
|
853
1171
|
// Node.js environment
|
|
854
|
-
if (typeof require !==
|
|
1172
|
+
if (typeof require !== "undefined") {
|
|
855
1173
|
try {
|
|
856
1174
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
857
|
-
const zlib = require(
|
|
1175
|
+
const zlib = require("zlib");
|
|
858
1176
|
return new Uint8Array(zlib.inflateRawSync(Buffer.from(data)));
|
|
859
1177
|
}
|
|
860
1178
|
catch {
|
|
861
|
-
throw new Error(
|
|
1179
|
+
throw new Error("TPSUID7RB: decompression failed");
|
|
862
1180
|
}
|
|
863
1181
|
}
|
|
864
1182
|
// Browser: would need pako or similar library
|
|
865
|
-
throw new Error(
|
|
1183
|
+
throw new Error("TPSUID7RB: decompression not available in browser");
|
|
866
1184
|
}
|
|
867
1185
|
// ---------------------------
|
|
868
1186
|
// Cryptographic Sealing (Ed25519)
|
|
@@ -889,7 +1207,7 @@ class TPSUID7RB {
|
|
|
889
1207
|
const epochMs = opts?.epochMs ?? this.epochMsFromTPSString(tps);
|
|
890
1208
|
// Validate epoch
|
|
891
1209
|
if (!Number.isInteger(epochMs) || epochMs < 0 || epochMs > 0xffffffffffff) {
|
|
892
|
-
throw new Error(
|
|
1210
|
+
throw new Error("epochMs must be a valid 48-bit non-negative integer");
|
|
893
1211
|
}
|
|
894
1212
|
// Flags: Bit 0 = compress, Bit 1 = sealed
|
|
895
1213
|
const flags = (compress ? 0x01 : 0x00) | 0x02; // Set SEAL bit
|
|
@@ -934,18 +1252,18 @@ class TPSUID7RB {
|
|
|
934
1252
|
*/
|
|
935
1253
|
static verifyAndDecode(sealedBytes, publicKey) {
|
|
936
1254
|
if (sealedBytes.length < 18)
|
|
937
|
-
throw new Error(
|
|
1255
|
+
throw new Error("TPSUID7RB: too short");
|
|
938
1256
|
// Check Magic
|
|
939
1257
|
if (sealedBytes[0] !== 0x54 ||
|
|
940
1258
|
sealedBytes[1] !== 0x50 ||
|
|
941
1259
|
sealedBytes[2] !== 0x55 ||
|
|
942
1260
|
sealedBytes[3] !== 0x37) {
|
|
943
|
-
throw new Error(
|
|
1261
|
+
throw new Error("TPSUID7RB: bad magic");
|
|
944
1262
|
}
|
|
945
1263
|
// Check Flags for Sealed Bit (bit 1)
|
|
946
1264
|
const flags = sealedBytes[5];
|
|
947
1265
|
if ((flags & 0x02) === 0) {
|
|
948
|
-
throw new Error(
|
|
1266
|
+
throw new Error("TPSUID7RB: not a sealed UID");
|
|
949
1267
|
}
|
|
950
1268
|
// 1. Parse the structure to find where content ends
|
|
951
1269
|
// We need to parse LEN and Payload to find the split point
|
|
@@ -955,13 +1273,13 @@ class TPSUID7RB {
|
|
|
955
1273
|
offset += bytesRead;
|
|
956
1274
|
const payloadEnd = offset + tpsLen;
|
|
957
1275
|
if (payloadEnd > sealedBytes.length) {
|
|
958
|
-
throw new Error(
|
|
1276
|
+
throw new Error("TPSUID7RB: length overflow (truncated)");
|
|
959
1277
|
}
|
|
960
1278
|
// The Content to verify matches exactly [0 ... payloadEnd]
|
|
961
1279
|
const content = sealedBytes.slice(0, payloadEnd);
|
|
962
1280
|
// After content: SealType (1 byte) + Signature
|
|
963
1281
|
if (sealedBytes.length <= payloadEnd + 1) {
|
|
964
|
-
throw new Error(
|
|
1282
|
+
throw new Error("TPSUID7RB: missing signature data");
|
|
965
1283
|
}
|
|
966
1284
|
const sealType = sealedBytes[payloadEnd];
|
|
967
1285
|
if (sealType !== 0x01) {
|
|
@@ -974,7 +1292,7 @@ class TPSUID7RB {
|
|
|
974
1292
|
// Verify
|
|
975
1293
|
const isValid = this.verifyEd25519(content, signature, publicKey);
|
|
976
1294
|
if (!isValid) {
|
|
977
|
-
throw new Error(
|
|
1295
|
+
throw new Error("TPSUID7RB: signature verification failed");
|
|
978
1296
|
}
|
|
979
1297
|
// Decode (reuse standard logic, but ignoring the extra bytes at end is fine?)
|
|
980
1298
|
// Actually standard logic doesn't expect trailing bytes unless we tell it to.
|
|
@@ -985,63 +1303,59 @@ class TPSUID7RB {
|
|
|
985
1303
|
}
|
|
986
1304
|
// --- Crypto Implementation (Ed25519) ---
|
|
987
1305
|
static signEd25519(data, privateKey) {
|
|
988
|
-
if (typeof require !==
|
|
1306
|
+
if (typeof require !== "undefined") {
|
|
989
1307
|
try {
|
|
990
1308
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
991
|
-
const crypto = require(
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1309
|
+
const crypto = require("crypto");
|
|
1310
|
+
let key;
|
|
1311
|
+
if (typeof privateKey === "string") {
|
|
1312
|
+
if (privateKey.includes("PRIVATE KEY")) {
|
|
1313
|
+
// PEM format — use directly
|
|
1314
|
+
key = privateKey;
|
|
1315
|
+
}
|
|
1316
|
+
else {
|
|
1317
|
+
// Hex-encoded DER/PKCS8
|
|
1318
|
+
key = crypto.createPrivateKey({
|
|
1319
|
+
key: Buffer.from(privateKey, "hex"),
|
|
1320
|
+
format: "der",
|
|
1321
|
+
type: "pkcs8",
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
else if (typeof privateKey === "object" &&
|
|
1326
|
+
privateKey !== null &&
|
|
1327
|
+
"asymmetricKeyType" in privateKey) {
|
|
1328
|
+
// Node.js KeyObject (e.g. from crypto.generateKeyPairSync)
|
|
1329
|
+
key = privateKey;
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
// Buffer or Uint8Array — assume DER/PKCS8 encoded
|
|
1333
|
+
key = crypto.createPrivateKey({
|
|
1002
1334
|
key: Buffer.from(privateKey),
|
|
1003
|
-
format:
|
|
1004
|
-
type:
|
|
1335
|
+
format: "der",
|
|
1336
|
+
type: "pkcs8",
|
|
1005
1337
|
});
|
|
1006
|
-
// Actually, simpler: construct key object from raw bytes if possible?
|
|
1007
|
-
// Node's crypto is strict. Let's try the simplest:
|
|
1008
|
-
// If hex string provided, convert to buffer.
|
|
1009
1338
|
}
|
|
1010
|
-
// Simpler fallback: If user passed a PEM string, great.
|
|
1011
|
-
// If they passed raw bytes, we might need 'ed25519' key type.
|
|
1012
|
-
// For this implementation, let's target Node's high-level sign/verify
|
|
1013
|
-
// and assume the user provides a VALID key object or compatible format (PEM/DER).
|
|
1014
|
-
// Handling RAW Ed25519 keys in Node requires specific 'crypto.createPrivateKey' with 'raw' format (Node 11.6+).
|
|
1015
|
-
const key = typeof privateKey === 'string' && !privateKey.includes('PRIVATE KEY')
|
|
1016
|
-
? crypto.createPrivateKey({
|
|
1017
|
-
key: Buffer.from(privateKey, 'hex'),
|
|
1018
|
-
format: 'pem',
|
|
1019
|
-
type: 'pkcs8',
|
|
1020
|
-
}) // Fallback guess
|
|
1021
|
-
: privateKey;
|
|
1022
|
-
// Note: Raw Ed25519 key support in Node.js 'crypto' acts via 'generateKeyPair' or KeyObject.
|
|
1023
|
-
// Direct raw signing is via crypto.sign(null, data, key).
|
|
1024
1339
|
return new Uint8Array(crypto.sign(null, data, key));
|
|
1025
1340
|
}
|
|
1026
1341
|
catch (e) {
|
|
1027
|
-
|
|
1028
|
-
throw new Error('TPSUID7RB: signing failed (check key format)');
|
|
1342
|
+
throw new Error("TPSUID7RB: signing failed (check key format)");
|
|
1029
1343
|
}
|
|
1030
1344
|
}
|
|
1031
|
-
throw new Error(
|
|
1345
|
+
throw new Error("TPSUID7RB: signing not available in browser");
|
|
1032
1346
|
}
|
|
1033
1347
|
static verifyEd25519(data, signature, publicKey) {
|
|
1034
|
-
if (typeof require !==
|
|
1348
|
+
if (typeof require !== "undefined") {
|
|
1035
1349
|
try {
|
|
1036
1350
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1037
|
-
const crypto = require(
|
|
1351
|
+
const crypto = require("crypto");
|
|
1038
1352
|
return crypto.verify(null, data, publicKey, signature);
|
|
1039
1353
|
}
|
|
1040
1354
|
catch {
|
|
1041
1355
|
return false;
|
|
1042
1356
|
}
|
|
1043
1357
|
}
|
|
1044
|
-
throw new Error(
|
|
1358
|
+
throw new Error("TPSUID7RB: verification not available in browser");
|
|
1045
1359
|
}
|
|
1046
1360
|
// ---------------------------
|
|
1047
1361
|
// Random Bytes
|
|
@@ -1049,10 +1363,10 @@ class TPSUID7RB {
|
|
|
1049
1363
|
/** Generate cryptographically secure random bytes */
|
|
1050
1364
|
static randomBytes(length) {
|
|
1051
1365
|
// Node.js environment
|
|
1052
|
-
if (typeof require !==
|
|
1366
|
+
if (typeof require !== "undefined") {
|
|
1053
1367
|
try {
|
|
1054
1368
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1055
|
-
const crypto = require(
|
|
1369
|
+
const crypto = require("crypto");
|
|
1056
1370
|
return new Uint8Array(crypto.randomBytes(length));
|
|
1057
1371
|
}
|
|
1058
1372
|
catch {
|
|
@@ -1060,12 +1374,12 @@ class TPSUID7RB {
|
|
|
1060
1374
|
}
|
|
1061
1375
|
}
|
|
1062
1376
|
// Browser or fallback
|
|
1063
|
-
if (typeof crypto !==
|
|
1377
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
1064
1378
|
const bytes = new Uint8Array(length);
|
|
1065
1379
|
crypto.getRandomValues(bytes);
|
|
1066
1380
|
return bytes;
|
|
1067
1381
|
}
|
|
1068
|
-
throw new Error(
|
|
1382
|
+
throw new Error("TPSUID7RB: no crypto available");
|
|
1069
1383
|
}
|
|
1070
1384
|
}
|
|
1071
1385
|
exports.TPSUID7RB = TPSUID7RB;
|
|
@@ -1074,6 +1388,174 @@ TPSUID7RB.MAGIC = new Uint8Array([0x54, 0x50, 0x55, 0x37]);
|
|
|
1074
1388
|
/** Version 1 */
|
|
1075
1389
|
TPSUID7RB.VER = 0x01;
|
|
1076
1390
|
/** String prefix for base64url encoded form */
|
|
1077
|
-
TPSUID7RB.PREFIX =
|
|
1391
|
+
TPSUID7RB.PREFIX = "tpsuid7rb_";
|
|
1078
1392
|
/** Regex for validating base64url encoded form */
|
|
1079
1393
|
TPSUID7RB.REGEX = /^tpsuid7rb_[A-Za-z0-9_-]+$/;
|
|
1394
|
+
/**
|
|
1395
|
+
* `TpsDate` is a Date-like wrapper with native TPS conversion helpers.
|
|
1396
|
+
*
|
|
1397
|
+
* It mirrors common JavaScript `Date` construction patterns:
|
|
1398
|
+
* - `new TpsDate()`
|
|
1399
|
+
* - `new TpsDate(ms)`
|
|
1400
|
+
* - `new TpsDate(isoString)`
|
|
1401
|
+
* - `new TpsDate(tpsString)`
|
|
1402
|
+
* - `new TpsDate(year, monthIndex, day?, hour?, minute?, second?, ms?)`
|
|
1403
|
+
*/
|
|
1404
|
+
class TpsDate {
|
|
1405
|
+
getTpsComponents() {
|
|
1406
|
+
const parsed = TPS.parse(this.toTPS(exports.DefaultCalendars.TPS));
|
|
1407
|
+
if (!parsed) {
|
|
1408
|
+
throw new Error("TpsDate: failed to derive TPS components");
|
|
1409
|
+
}
|
|
1410
|
+
return parsed;
|
|
1411
|
+
}
|
|
1412
|
+
getTpsFullYear() {
|
|
1413
|
+
const comp = this.getTpsComponents();
|
|
1414
|
+
return (comp.millennium - 1) * 1000 + (comp.century - 1) * 100 + comp.year;
|
|
1415
|
+
}
|
|
1416
|
+
constructor(...args) {
|
|
1417
|
+
if (args.length === 0) {
|
|
1418
|
+
this.internal = new Date();
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
if (args.length === 1) {
|
|
1422
|
+
const value = args[0];
|
|
1423
|
+
if (value instanceof TpsDate) {
|
|
1424
|
+
this.internal = new Date(value.getTime());
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (value instanceof Date) {
|
|
1428
|
+
this.internal = new Date(value.getTime());
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
if (typeof value === "string" && TpsDate.looksLikeTPS(value)) {
|
|
1432
|
+
const parsed = TPS.toDate(value);
|
|
1433
|
+
if (!parsed) {
|
|
1434
|
+
throw new RangeError(`Invalid TPS date string: ${value}`);
|
|
1435
|
+
}
|
|
1436
|
+
this.internal = parsed;
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
this.internal = new Date(value);
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
const [year, monthIndex, day, hours, minutes, seconds, ms] = args;
|
|
1443
|
+
this.internal = new Date(year, monthIndex, day ?? 1, hours ?? 0, minutes ?? 0, seconds ?? 0, ms ?? 0);
|
|
1444
|
+
}
|
|
1445
|
+
static looksLikeTPS(input) {
|
|
1446
|
+
const s = input.trim();
|
|
1447
|
+
return s.startsWith("tps://") || s.startsWith("T:") || s.startsWith("t:");
|
|
1448
|
+
}
|
|
1449
|
+
static now() {
|
|
1450
|
+
return Date.now();
|
|
1451
|
+
}
|
|
1452
|
+
static parse(input) {
|
|
1453
|
+
if (this.looksLikeTPS(input)) {
|
|
1454
|
+
const d = TPS.toDate(input);
|
|
1455
|
+
return d ? d.getTime() : Number.NaN;
|
|
1456
|
+
}
|
|
1457
|
+
return Date.parse(input);
|
|
1458
|
+
}
|
|
1459
|
+
static UTC(year, monthIndex, day, hours, minutes, seconds, ms) {
|
|
1460
|
+
return Date.UTC(year, monthIndex, day ?? 1, hours ?? 0, minutes ?? 0, seconds ?? 0, ms ?? 0);
|
|
1461
|
+
}
|
|
1462
|
+
static fromTPS(tps) {
|
|
1463
|
+
return new TpsDate(tps);
|
|
1464
|
+
}
|
|
1465
|
+
toGregorianDate() {
|
|
1466
|
+
return new Date(this.internal.getTime());
|
|
1467
|
+
}
|
|
1468
|
+
toDate() {
|
|
1469
|
+
return this.toGregorianDate();
|
|
1470
|
+
}
|
|
1471
|
+
toTPS(calendar = exports.DefaultCalendars.TPS, opts) {
|
|
1472
|
+
return TPS.fromDate(this.internal, calendar, opts);
|
|
1473
|
+
}
|
|
1474
|
+
toTPSURI(calendar = exports.DefaultCalendars.TPS, opts) {
|
|
1475
|
+
const time = this.toTPS(calendar, { order: opts?.order });
|
|
1476
|
+
const comp = TPS.parse(time);
|
|
1477
|
+
if (opts?.latitude !== undefined && opts?.longitude !== undefined) {
|
|
1478
|
+
comp.latitude = opts.latitude;
|
|
1479
|
+
comp.longitude = opts.longitude;
|
|
1480
|
+
if (opts.altitude !== undefined)
|
|
1481
|
+
comp.altitude = opts.altitude;
|
|
1482
|
+
}
|
|
1483
|
+
else if (opts?.isHiddenLocation) {
|
|
1484
|
+
comp.isHiddenLocation = true;
|
|
1485
|
+
}
|
|
1486
|
+
else if (opts?.isRedactedLocation) {
|
|
1487
|
+
comp.isRedactedLocation = true;
|
|
1488
|
+
}
|
|
1489
|
+
else {
|
|
1490
|
+
comp.isUnknownLocation = true;
|
|
1491
|
+
}
|
|
1492
|
+
return TPS.toURI(comp);
|
|
1493
|
+
}
|
|
1494
|
+
getTime() {
|
|
1495
|
+
return this.internal.getTime();
|
|
1496
|
+
}
|
|
1497
|
+
valueOf() {
|
|
1498
|
+
return this.internal.valueOf();
|
|
1499
|
+
}
|
|
1500
|
+
toString() {
|
|
1501
|
+
return this.toTPS(exports.DefaultCalendars.TPS);
|
|
1502
|
+
}
|
|
1503
|
+
toISOString() {
|
|
1504
|
+
return this.internal.toISOString();
|
|
1505
|
+
}
|
|
1506
|
+
toUTCString() {
|
|
1507
|
+
return this.internal.toUTCString();
|
|
1508
|
+
}
|
|
1509
|
+
toJSON() {
|
|
1510
|
+
return this.internal.toJSON();
|
|
1511
|
+
}
|
|
1512
|
+
getFullYear() {
|
|
1513
|
+
return this.getTpsFullYear();
|
|
1514
|
+
}
|
|
1515
|
+
getUTCFullYear() {
|
|
1516
|
+
return this.getTpsFullYear();
|
|
1517
|
+
}
|
|
1518
|
+
getMonth() {
|
|
1519
|
+
return this.getTpsComponents().month - 1;
|
|
1520
|
+
}
|
|
1521
|
+
getUTCMonth() {
|
|
1522
|
+
return this.getTpsComponents().month - 1;
|
|
1523
|
+
}
|
|
1524
|
+
getDate() {
|
|
1525
|
+
return this.getTpsComponents().day;
|
|
1526
|
+
}
|
|
1527
|
+
getUTCDate() {
|
|
1528
|
+
return this.getTpsComponents().day;
|
|
1529
|
+
}
|
|
1530
|
+
getHours() {
|
|
1531
|
+
return this.getTpsComponents().hour;
|
|
1532
|
+
}
|
|
1533
|
+
getUTCHours() {
|
|
1534
|
+
return this.getTpsComponents().hour;
|
|
1535
|
+
}
|
|
1536
|
+
getMinutes() {
|
|
1537
|
+
return this.getTpsComponents().minute;
|
|
1538
|
+
}
|
|
1539
|
+
getUTCMinutes() {
|
|
1540
|
+
return this.getTpsComponents().minute;
|
|
1541
|
+
}
|
|
1542
|
+
getSeconds() {
|
|
1543
|
+
return this.getTpsComponents().second;
|
|
1544
|
+
}
|
|
1545
|
+
getUTCSeconds() {
|
|
1546
|
+
return this.getTpsComponents().second;
|
|
1547
|
+
}
|
|
1548
|
+
getMilliseconds() {
|
|
1549
|
+
return this.getTpsComponents().millisecond;
|
|
1550
|
+
}
|
|
1551
|
+
getUTCMilliseconds() {
|
|
1552
|
+
return this.getTpsComponents().millisecond;
|
|
1553
|
+
}
|
|
1554
|
+
[Symbol.toPrimitive](hint) {
|
|
1555
|
+
if (hint === "number")
|
|
1556
|
+
return this.valueOf();
|
|
1557
|
+
return this.toString();
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
exports.TpsDate = TpsDate;
|
|
1561
|
+
//# sourceMappingURL=index.js.map
|