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