gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit1ed5457e1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre1ed5457e/plugins/time/time.zig

38.8 KB

  1. // hl:time plugin — native shared library (libtime.so, dlopen'd by the runtime)
  2. //
  3. // The clock (D22):
  4. // hl_time_now() → epoch milliseconds, UTC wall clock (Number)
  5. // hl_time_timestamp(ms?) → ISO-8601 UTC "YYYY-MM-DDTHH:MM:SS.mmmZ" (String)
  6. // hl_time_monotonic() → nanoseconds off a monotonic counter (Number)
  7. //
  8. // …and TIME AS AN EVENT SOURCE (mission 254):
  9. // hl_time_timer(secs, repeat) → { id, events } — `events` is a LOOP SOURCE
  10. // whose wake fd IS a timerfd, so the interpreter
  11. // loop's epoll (mission 256) blocks on the
  12. // kernel timer itself instead of polling
  13. // hl_time_timer_stop(id) → disarm, close, retire
  14. // hl_time_sleep(secs) → BLOCKING nanosleep (the creator's ruling: a
  15. // basic tool for tests and debugging)
  16. //
  17. // …and TIME ZONES (ticket #5), over the system's TZif files — see the last
  18. // section: hl_time_offset / _local / _fromLocal / _localTimestamp / _isZone.
  19. //
  20. // The clock calls allocate nothing and hold no state; `timestamp` renders into a
  21. // module-level buffer: the runtime dupes returned strings into its own tracker
  22. // the instant the call returns (plugin_loader.zig hlToValue .hl_string) and a
  23. // plugin call never yields, so the buffer cannot be observed stale or torn.
  24. //
  25. // Number is f64. Epoch ms (~1.8e12) is exact well past the year 200000, and
  26. // monotonic ns stays exact for the first ~104 days of counter uptime (2^53 ns);
  27. // beyond that the granularity coarsens above 1 ns, which is irrelevant for the
  28. // difference-measurement the call exists for.
  29. const std = @import("std");
  30. const linux = std.os.linux;
  31. const api = @import("plugin_api");
  32. const HlValue = api.HlValue;
  33. const HlObject = api.HlObject;
  34. const HlField = api.HlField;
  35. const HlIterator = api.HlIterator;
  36. const HlString = api.HlString;
  37. var gpa = std.heap.DebugAllocator(.{ .stack_trace_frames = 0 }){};
  38. const allocator = gpa.allocator();
  39. fn hlStr(s: []const u8) HlString {
  40. return .{ .ptr = s.ptr, .len = s.len };
  41. }
  42. fn nowMs() i64 {
  43. var ts: linux.timespec = undefined;
  44. _ = linux.clock_gettime(linux.CLOCK.REALTIME, &ts);
  45. return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000);
  46. }
  47. export fn hl_time_now(_: u32, _: [*]const HlValue) callconv(.c) HlValue {
  48. return api.makeNumber(@floatFromInt(nowMs()));
  49. }
  50. export fn hl_time_monotonic(_: u32, _: [*]const HlValue) callconv(.c) HlValue {
  51. var ts: linux.timespec = undefined;
  52. _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
  53. const ns: i64 = @as(i64, ts.sec) * 1_000_000_000 + @as(i64, ts.nsec);
  54. return api.makeNumber(@floatFromInt(ns));
  55. }
  56. // "YYYY-MM-DDTHH:MM:SS.mmmZ" is 24 bytes; the year field widens for absurd
  57. // inputs, so give it room rather than truncating.
  58. var iso_buf: [40]u8 = undefined;
  59. export fn hl_time_timestamp(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  60. // Argument is optional: absent, null, or non-numeric all mean "now". The
  61. // .hl wrapper declares `timestamp(ms)`, so a no-arg call arrives as one
  62. // hl_null — the same thing.
  63. var ms: i64 = if (argc > 0 and argv[0].type == .hl_number)
  64. @intFromFloat(argv[0].data.number)
  65. else
  66. nowMs();
  67. // Pre-epoch instants: floor the division so the millisecond part stays in
  68. // [0, 999] and the second walks backwards (std.time.epoch is u64-only).
  69. var secs: i64 = @divFloor(ms, 1000);
  70. var millis: i64 = ms - secs * 1000;
  71. if (secs < 0) {
  72. // Dates before 1970 have no u64 epoch representation; rather than
  73. // render garbage, clamp to the epoch itself and say so via the value.
  74. secs = 0;
  75. millis = 0;
  76. ms = 0;
  77. }
  78. const es = std.time.epoch.EpochSeconds{ .secs = @intCast(secs) };
  79. const yd = es.getEpochDay().calculateYearDay();
  80. const md = yd.calculateMonthDay();
  81. const ds = es.getDaySeconds();
  82. const out = std.fmt.bufPrint(&iso_buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.{d:0>3}Z", .{
  83. yd.year,
  84. md.month.numeric(),
  85. @as(u32, md.day_index) + 1,
  86. ds.getHoursIntoDay(),
  87. ds.getMinutesIntoHour(),
  88. ds.getSecondsIntoMinute(),
  89. @as(u32, @intCast(millis)),
  90. }) catch return api.makeError("hl:time timestamp() could not format the instant");
  91. return api.makeString(out);
  92. }
  93. // ═══════════════════════════════════════════════════════════════════════════
  94. // TIME AS AN EVENT SOURCE (mission 254)
  95. // ═══════════════════════════════════════════════════════════════════════════
  96. //
  97. // `every(n)` / `after(n)` / `until(t)` are ONE primitive: a timerfd registered
  98. // with the interpreter's event loop as an ordinary source, exactly the shape
  99. // `spawn()` hands back from hl:proc. Two properties are the whole reason it is a
  100. // timerfd rather than a thread or a deadline the loop checks:
  101. //
  102. // THE WAKE FD IS THE TIMER ITSELF. Mission 256 taught the loop to block in one
  103. // `epoll_wait` over its sources' `HlIterator.wake_fd`s. A timerfd IS such an
  104. // fd, so a timer costs one kernel wakeup at the instant it is due — not a 1ms
  105. // poll that could never resolve a 1ms interval in the first place. An idle
  106. // process with no timers is untouched (`tools/idle-cpu-gate.sh`).
  107. //
  108. // THE KERNEL OWNS THE SCHEDULE, so `every` cannot drift. `timerfd_settime` is
  109. // given an ABSOLUTE deadline on CLOCK_MONOTONIC plus an interval, and the
  110. // kernel re-arms from the previous EXPIRY, never from when anyone read the fd.
  111. // The handler's own runtime is therefore not added to the next round — which
  112. // is exactly the defect a `sleep(interval)` loop has.
  113. //
  114. // WHY `tryNext` READS THE CLOCK AND NOT THE FD. `loop_wait.Waiter` DRAINS every
  115. // fd it was woken by (`read(fd, &sink, 8)`) before the loop's poll round — that
  116. // is the level-triggered race argument in loop_wait.zig's header, and it means
  117. // the timerfd's expiration COUNT is already gone by the time this poll runs. So
  118. // the count is not the signal; the deadline is. Both sides read the same
  119. // CLOCK_MONOTONIC and the kernel expires at-or-after the absolute deadline it
  120. // was handed, so `now >= deadline` is guaranteed true when the bell rings. A
  121. // dropped or duplicated wake is harmless in both directions: a duplicate polls
  122. // to `null`, and a missed one is picked up by the loop's 250ms safety timeout.
  123. // RAW SYSCALLS, NO LIBC. This .so has never linked libc (`timestamp` renders
  124. // with std.fmt and the clock reads go through `linux.clock_gettime`), and the
  125. // timer half keeps it that way — `std.os.linux` has timerfd and nanosleep, so
  126. // nothing here needs `build.zig` to gain a `link_libc` it did not have.
  127. fn timerfdCreate() i32 {
  128. const rc = linux.timerfd_create(.MONOTONIC, .{ .NONBLOCK = true, .CLOEXEC = true });
  129. const fd: isize = @bitCast(rc);
  130. return if (fd < 0) -1 else @intCast(fd);
  131. }
  132. fn timerfdSetAbs(fd: i32, spec: *const linux.itimerspec) bool {
  133. const rc = linux.timerfd_settime(fd, .{ .ABSTIME = true }, spec, null);
  134. return @as(isize, @bitCast(rc)) == 0;
  135. }
  136. fn timerfdDisarm(fd: i32) void {
  137. const zero = linux.itimerspec{
  138. .it_interval = .{ .sec = 0, .nsec = 0 },
  139. .it_value = .{ .sec = 0, .nsec = 0 },
  140. };
  141. _ = linux.timerfd_settime(fd, .{}, &zero, null);
  142. }
  143. fn monoNs() i64 {
  144. var ts: linux.timespec = undefined;
  145. _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
  146. return @as(i64, ts.sec) * 1_000_000_000 + @as(i64, ts.nsec);
  147. }
  148. /// THE FLOOR, and it is the SAME NUMBER IN BOTH REALMS (mission 254).
  149. ///
  150. /// The creator confirmed fractional seconds — "a millisecond is just 0.001 then
  151. /// thats okay" — and server-side that is not the limit: a timerfd carries
  152. /// nanoseconds and the loop blocks on it. THE BROWSER IS THE LIMIT, and it was
  153. /// MEASURED rather than quoted (tests/browser/tests/66-timers.mjs prints the
  154. /// number it measured on every run). HTML's timer nesting rule clamps a chained
  155. /// `setTimeout(…, 0)` to 4ms from the fifth nesting level on, and a `setInterval`
  156. /// asked for 1ms delivers ~4ms periods.
  157. ///
  158. /// So `every(0.001)` would mean two different things in the two realms, and the
  159. /// SAME `on t.tick()` handler is meant to run in both. The brief's choice was
  160. /// "document the divergence with the measured number, or clamp both realms to
  161. /// the same honest minimum" — CLAMPED, because a silent target divergence is
  162. /// already a named defect class in this tree and because a number the author
  163. /// writes should mean one thing everywhere. Anything below the floor is raised
  164. /// to it, in both realms (`server.js` carries the same constant), and
  165. /// `plugins/time/server.hl` says so where an author will read it.
  166. const FLOOR_NS: i64 = 4_000_000;
  167. /// ONE ARMED TIMER. Never freed: the struct is ~48 bytes, it is referenced by an
  168. /// iterator the runtime owns, and freeing it would need the loop's poll round and
  169. /// the GC sweep to agree on an order they have no reason to. `stop()` closes the
  170. /// fd (which is the only scarce resource) and marks it dead; the poll then answers
  171. /// `null` forever, which is what a retired source must do.
  172. const Timer = struct {
  173. fd: i32 = -1,
  174. /// 0 = ONE-SHOT. `after()` and `until()` are `every()` that fires once.
  175. interval_ns: i64 = 0,
  176. /// The next fire's ABSOLUTE CLOCK_MONOTONIC instant — the same value the
  177. /// kernel holds, which is what makes `now >= deadline` exact rather than
  178. /// approximate.
  179. deadline_ns: i64 = 0,
  180. count: f64 = 0,
  181. stopped: bool = false,
  182. };
  183. /// id → Timer, 1-based; `0` is "no timer" so a default-initialised member is
  184. /// never mistaken for one. Ids are never reused: `stop()` leaves the slot dead
  185. /// rather than freeing it, so a stale id can only ever answer "already stopped".
  186. var timers = std.ArrayListUnmanaged(*Timer).empty;
  187. fn timerById(id: f64) ?*Timer {
  188. if (id < 1) return null;
  189. const idx: usize = @intFromFloat(id - 1);
  190. if (idx >= timers.items.len) return null;
  191. return timers.items[idx];
  192. }
  193. fn timerTryNext(ctx: ?*anyopaque) callconv(.c) HlValue {
  194. const t: *Timer = @ptrCast(@alignCast(ctx orelse return api.makeNull()));
  195. if (t.stopped) return api.makeNull();
  196. const now = monoNs();
  197. if (now < t.deadline_ns) return api.makeNull();
  198. t.count += 1;
  199. if (t.interval_ns == 0) {
  200. // A ONE-SHOT IS SPENT. The .hl half retires the source and closes the fd
  201. // (mission 249: a source that will never speak again must not keep the
  202. // program alive); this flag makes the polls between here and there
  203. // answer null rather than fire a second time.
  204. t.stopped = true;
  205. } else {
  206. // SCHEDULED FROM THE ORIGIN, and CAUGHT UP BY SKIPPING. Advancing by one
  207. // interval keeps the phase the first fire established, so the handler's
  208. // own runtime never accumulates. If the loop was busy for longer than an
  209. // interval the missed rounds are DROPPED rather than delivered as a
  210. // burst — a periodic timer that owes you a backlog is a stampede, and
  211. // the kernel's own timerfd overrun count is discarded for the same
  212. // reason (see the header: the Waiter has already drained it).
  213. t.deadline_ns += t.interval_ns;
  214. while (t.deadline_ns <= now) t.deadline_ns += t.interval_ns;
  215. }
  216. const fields = allocator.alloc(HlField, 2) catch return api.makeNull();
  217. fields[0] = .{ .key = hlStr("count"), .value = api.makeNumber(t.count) };
  218. fields[1] = .{ .key = hlStr("at"), .value = api.makeNumber(@floatFromInt(nowMs())) };
  219. const obj = allocator.create(HlObject) catch return api.makeNull();
  220. obj.* = .{ .fields = fields.ptr, .field_count = 2, .deinit_fn = null };
  221. return api.makeObject(obj);
  222. }
  223. fn timerDeinit(ctx: ?*anyopaque) callconv(.c) void {
  224. const t: *Timer = @ptrCast(@alignCast(ctx orelse return));
  225. t.stopped = true;
  226. }
  227. /// hl_time_timer(seconds, repeat) → { id, events }
  228. export fn hl_time_timer(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  229. if (argc < 1 or argv[0].type != .hl_number) {
  230. return api.makeError("hl:time timer: pass the interval in SECONDS (fractions allowed)");
  231. }
  232. const secs = argv[0].data.number;
  233. if (!(secs == secs) or secs < 0) { // NaN or negative
  234. return api.makeError("hl:time timer: the interval must be a positive number of seconds");
  235. }
  236. const repeat = argc >= 2 and argv[1].type == .hl_bool and argv[1].data.boolean;
  237. var ns: i64 = @intFromFloat(secs * 1_000_000_000.0);
  238. if (ns < FLOOR_NS) ns = FLOOR_NS;
  239. const fd = timerfdCreate();
  240. if (fd < 0) return api.makeError("hl:time timer: timerfd_create failed");
  241. const t = allocator.create(Timer) catch return api.makeError("hl:time: out of memory");
  242. t.* = .{ .fd = fd, .interval_ns = if (repeat) ns else 0, .deadline_ns = monoNs() + ns };
  243. // ABSOLUTE, so the kernel and this plugin hold the SAME instant — the whole
  244. // exactness argument in the header. The interval is the kernel's own re-arm,
  245. // which is what makes `every` drift-free rather than nearly drift-free.
  246. const spec = linux.itimerspec{
  247. .it_interval = .{
  248. .sec = if (repeat) @intCast(@divTrunc(ns, 1_000_000_000)) else 0,
  249. .nsec = if (repeat) @intCast(@mod(ns, 1_000_000_000)) else 0,
  250. },
  251. .it_value = .{
  252. .sec = @intCast(@divTrunc(t.deadline_ns, 1_000_000_000)),
  253. .nsec = @intCast(@mod(t.deadline_ns, 1_000_000_000)),
  254. },
  255. };
  256. if (!timerfdSetAbs(fd, &spec)) {
  257. _ = linux.close(fd);
  258. return api.makeError("hl:time timer: timerfd_settime failed");
  259. }
  260. timers.append(allocator, t) catch return api.makeError("hl:time: out of memory");
  261. const id: f64 = @floatFromInt(timers.items.len);
  262. const iter = allocator.create(HlIterator) catch return api.makeError("hl:time: out of memory");
  263. iter.* = .{
  264. .context = @ptrCast(t),
  265. .next_fn = &timerTryNext,
  266. .deinit_fn = &timerDeinit,
  267. .try_next_fn = &timerTryNext,
  268. .wake_fd = fd,
  269. };
  270. const fields = allocator.alloc(HlField, 2) catch return api.makeNull();
  271. fields[0] = .{ .key = hlStr("id"), .value = api.makeNumber(id) };
  272. fields[1] = .{ .key = hlStr("events"), .value = api.makeIterator(iter) };
  273. const obj = allocator.create(HlObject) catch return api.makeNull();
  274. obj.* = .{ .fields = fields.ptr, .field_count = 2, .deinit_fn = null };
  275. return api.makeObject(obj);
  276. }
  277. /// hl_time_timer_stop(id) — disarm and close. TRUE when this call was the one
  278. /// that stopped it. The caller retires the event source FIRST (Timer.hl), so the
  279. /// loop has already dropped this fd from its epoll set by the time it is closed.
  280. export fn hl_time_timer_stop(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  281. if (argc < 1 or argv[0].type != .hl_number) {
  282. return api.makeError("hl:time timer stop: pass the id timer() returned");
  283. }
  284. const t = timerById(argv[0].data.number) orelse return api.makeBool(false);
  285. const was_open = t.fd >= 0;
  286. t.stopped = true;
  287. if (t.fd >= 0) {
  288. timerfdDisarm(t.fd);
  289. _ = linux.close(t.fd);
  290. t.fd = -1;
  291. }
  292. return api.makeBool(was_open);
  293. }
  294. /// hl_time_sleep(seconds) — BLOCKING, and that is the creator's ruling, not an
  295. /// omission: *"sleep you need mostly for testing or debugging stuff, its what i
  296. /// consider basic tools"*. It stops this thread, which inside a request handler
  297. /// means the loop is stopped too; `after()` is the thing to use there, and
  298. /// server.hl says so where an author will read it.
  299. export fn hl_time_sleep(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  300. if (argc < 1 or argv[0].type != .hl_number) {
  301. return api.makeError("hl:time sleep: pass the duration in SECONDS (fractions allowed)");
  302. }
  303. const secs = argv[0].data.number;
  304. if (!(secs == secs) or secs <= 0) return api.makeBool(false);
  305. const ns: i64 = @intFromFloat(secs * 1_000_000_000.0);
  306. // EINTR leaves the remainder; a signal must not silently shorten the sleep.
  307. var req = linux.timespec{ .sec = @intCast(@divTrunc(ns, 1_000_000_000)), .nsec = @intCast(@mod(ns, 1_000_000_000)) };
  308. var rem = linux.timespec{ .sec = 0, .nsec = 0 };
  309. while (@as(isize, @bitCast(linux.nanosleep(&req, &rem))) != 0) {
  310. if (rem.sec == 0 and rem.nsec == 0) break;
  311. req = rem;
  312. rem = .{ .sec = 0, .nsec = 0 };
  313. }
  314. return api.makeBool(true);
  315. }
  316. // ═══════════════════════════════════════════════════════════════════════════
  317. // TIME ZONES (ticket #5)
  318. // ═══════════════════════════════════════════════════════════════════════════
  319. //
  320. // The creator's ruling (ident.worldapi.org#14): "hybriel needs time zone
  321. // support". Five calls, one primitive:
  322. //
  323. // hl_time_offset(ms, zone) → seconds EAST of UTC at that instant
  324. // hl_time_local(ms, zone) → { year, month, day, hour, minute, second,
  325. // millisecond, weekday, offset }
  326. // hl_time_fromLocal(fields, zone) → epoch ms of that wall-clock reading
  327. // hl_time_localTimestamp(ms, zone) → "YYYY-MM-DDTHH:MM:SS.mmm+HH:MM"
  328. // hl_time_isZone(name) → Boolean
  329. //
  330. // THE DATABASE IS THE SYSTEM'S: /usr/share/zoneinfo, the TZif files (RFC 8536)
  331. // every Linux ships, read with raw syscalls like the rest of this .so — no libc,
  332. // no new dependency. A zone is parsed once and cached for the life of the
  333. // process. The browser twin (server.js) asks Intl instead, and the two agree
  334. // because everything EXCEPT the offset is computed by the same arithmetic on
  335. // both sides: `offset()` is the only question either realm puts to its
  336. // database, and `local`, `fromLocal` and `localTimestamp` are built on it.
  337. //
  338. // OFFSET LOOKUP, per RFC 8536: before the first transition, time type 0; between
  339. // transitions, the type the last one selected; after the last, the footer's
  340. // POSIX TZ rule (a "slim" TZif carries nothing else for the future), or the
  341. // last type when there is no footer.
  342. //
  343. // Same threading assumption as `timestamp` above: a plugin call never yields and
  344. // the runtime copies a returned value before the next call, so the results are
  345. // rendered into module-level buffers and the zone cache has no lock.
  346. const ZONEINFO = "/usr/share/zoneinfo/";
  347. const MAX_MS: f64 = 8.64e15; // the range an ECMAScript Date (and Intl) accepts
  348. const TzType = struct { utoff: i32, isdst: bool };
  349. /// A POSIX TZ string's rule date: Jn (1..365, Feb 29 never counted), n (0..365,
  350. /// Feb 29 counted) or Mm.w.d (weekday d of week w of month m; w=5 is "last").
  351. const RuleDate = struct {
  352. kind: enum { julian1, julian0, mwd } = .mwd,
  353. n: i32 = 0,
  354. m: i32 = 0,
  355. w: i32 = 0,
  356. d: i32 = 0,
  357. /// local time of day the change happens, seconds (may be negative or > 24h)
  358. time: i32 = 7200,
  359. };
  360. const PosixRule = struct {
  361. std_off: i32 = 0, // seconds east of UTC
  362. dst_off: i32 = 0,
  363. has_dst: bool = false,
  364. start: RuleDate = .{},
  365. end: RuleDate = .{},
  366. };
  367. const Zone = struct {
  368. trans: []i64,
  369. idx: []u8,
  370. types: []TzType,
  371. rule: ?PosixRule,
  372. };
  373. var zones = std.StringHashMapUnmanaged(?*Zone).empty;
  374. fn floorDiv(a: i64, b: i64) i64 {
  375. return @divFloor(a, b);
  376. }
  377. /// Days since 1970-01-01 of a proleptic Gregorian date (H. Hinnant's
  378. /// days_from_civil). `m` is 1..12; `d` may be any integer — it simply adds.
  379. fn daysFromCivil(y0: i64, m: i64, d: i64) i64 {
  380. const y = if (m <= 2) y0 - 1 else y0;
  381. const era = floorDiv(y, 400);
  382. const yoe = y - era * 400;
  383. const mp = @mod(m + 9, 12);
  384. const doy = @divFloor(153 * mp + 2, 5);
  385. const doe = yoe * 365 + @divFloor(yoe, 4) - @divFloor(yoe, 100) + doy;
  386. return era * 146097 + doe - 719468 + (d - 1);
  387. }
  388. const Civil = struct { y: i64, m: i64, d: i64 };
  389. fn civilFromDays(z0: i64) Civil {
  390. const z = z0 + 719468;
  391. const era = floorDiv(z, 146097);
  392. const doe = z - era * 146097;
  393. const yoe = @divFloor(doe - @divFloor(doe, 1460) + @divFloor(doe, 36524) - @divFloor(doe, 146096), 365);
  394. const doy = doe - (365 * yoe + @divFloor(yoe, 4) - @divFloor(yoe, 100));
  395. const mp = @divFloor(5 * doy + 2, 153);
  396. const d = doy - @divFloor(153 * mp + 2, 5) + 1;
  397. const m = if (mp < 10) mp + 3 else mp - 9;
  398. return .{ .y = if (m <= 2) yoe + era * 400 + 1 else yoe + era * 400, .m = m, .d = d };
  399. }
  400. fn isLeap(y: i64) bool {
  401. return @mod(y, 4) == 0 and (@mod(y, 100) != 0 or @mod(y, 400) == 0);
  402. }
  403. /// Seconds since the epoch at which rule date `r` happens in year `y`, in the
  404. /// local time whose offset is `off` (the offset in force just before it).
  405. fn ruleInstant(r: RuleDate, y: i64, off: i32) i64 {
  406. const jan1 = daysFromCivil(y, 1, 1);
  407. const day: i64 = switch (r.kind) {
  408. .julian1 => blk: {
  409. var n: i64 = r.n - 1;
  410. if (isLeap(y) and r.n >= 60) n += 1;
  411. break :blk jan1 + n;
  412. },
  413. .julian0 => jan1 + r.n,
  414. .mwd => blk: {
  415. const first = daysFromCivil(y, r.m, 1);
  416. // 1970-01-01 was a Thursday; POSIX d is 0 = Sunday
  417. const wd_first = @mod(first + 4, 7);
  418. var day = first + @mod(r.d - wd_first, 7) + (r.w - 1) * 7;
  419. if (r.w == 5) {
  420. const next = if (r.m == 12) daysFromCivil(y + 1, 1, 1) else daysFromCivil(y, r.m + 1, 1);
  421. while (day >= next) day -= 7;
  422. }
  423. break :blk day;
  424. },
  425. };
  426. return day * 86400 + r.time - off;
  427. }
  428. fn ruleOffset(r: PosixRule, t: i64) i32 {
  429. if (!r.has_dst) return r.std_off;
  430. const y = civilFromDays(floorDiv(t + r.std_off, 86400)).y;
  431. const start = ruleInstant(r.start, y, r.std_off);
  432. const end = ruleInstant(r.end, y, r.dst_off);
  433. const in_dst = if (start < end) (t >= start and t < end) else !(t >= end and t < start);
  434. return if (in_dst) r.dst_off else r.std_off;
  435. }
  436. fn zoneOffset(z: *const Zone, t: i64) i32 {
  437. if (z.trans.len == 0) {
  438. if (z.rule) |r| return ruleOffset(r, t);
  439. return z.types[0].utoff;
  440. }
  441. if (t < z.trans[0]) return z.types[0].utoff;
  442. if (t >= z.trans[z.trans.len - 1]) {
  443. if (z.rule) |r| return ruleOffset(r, t);
  444. return z.types[z.idx[z.idx.len - 1]].utoff;
  445. }
  446. var lo: usize = 0;
  447. var hi: usize = z.trans.len - 1; // trans[lo] <= t < trans[hi]
  448. while (hi - lo > 1) {
  449. const mid = lo + (hi - lo) / 2;
  450. if (z.trans[mid] <= t) lo = mid else hi = mid;
  451. }
  452. return z.types[z.idx[lo]].utoff;
  453. }
  454. // ── the POSIX TZ footer ─────────────────────────────────────────────────────
  455. const Cursor = struct {
  456. s: []const u8,
  457. i: usize = 0,
  458. fn peek(c: *Cursor) u8 {
  459. return if (c.i < c.s.len) c.s[c.i] else 0;
  460. }
  461. fn num(c: *Cursor) ?i32 {
  462. var v: i32 = 0;
  463. const from = c.i;
  464. while (c.i < c.s.len and c.s[c.i] >= '0' and c.s[c.i] <= '9') : (c.i += 1) {
  465. v = v * 10 + @as(i32, c.s[c.i] - '0');
  466. }
  467. return if (c.i == from) null else v;
  468. }
  469. };
  470. fn parseName(c: *Cursor) bool {
  471. if (c.peek() == '<') {
  472. while (c.i < c.s.len and c.s[c.i] != '>') c.i += 1;
  473. if (c.peek() != '>') return false;
  474. c.i += 1;
  475. return true;
  476. }
  477. const from = c.i;
  478. while (c.i < c.s.len and std.ascii.isAlphabetic(c.s[c.i])) c.i += 1;
  479. return c.i - from >= 3;
  480. }
  481. /// [+-]hh[:mm[:ss]] → seconds, sign as written
  482. fn parseHms(c: *Cursor) ?i32 {
  483. var sign: i32 = 1;
  484. if (c.peek() == '+') c.i += 1 else if (c.peek() == '-') {
  485. sign = -1;
  486. c.i += 1;
  487. }
  488. var v = (c.num() orelse return null) * 3600;
  489. if (c.peek() == ':') {
  490. c.i += 1;
  491. v += (c.num() orelse return null) * 60;
  492. if (c.peek() == ':') {
  493. c.i += 1;
  494. v += c.num() orelse return null;
  495. }
  496. }
  497. return sign * v;
  498. }
  499. fn parseRuleDate(c: *Cursor) ?RuleDate {
  500. var r = RuleDate{};
  501. if (c.peek() == 'J') {
  502. c.i += 1;
  503. r.kind = .julian1;
  504. r.n = c.num() orelse return null;
  505. } else if (c.peek() == 'M') {
  506. c.i += 1;
  507. r.kind = .mwd;
  508. r.m = c.num() orelse return null;
  509. if (c.peek() != '.') return null;
  510. c.i += 1;
  511. r.w = c.num() orelse return null;
  512. if (c.peek() != '.') return null;
  513. c.i += 1;
  514. r.d = c.num() orelse return null;
  515. if (r.m < 1 or r.m > 12 or r.w < 1 or r.w > 5 or r.d > 6) return null;
  516. } else {
  517. r.kind = .julian0;
  518. r.n = c.num() orelse return null;
  519. }
  520. if (c.peek() == '/') {
  521. c.i += 1;
  522. r.time = parseHms(c) orelse return null;
  523. }
  524. return r;
  525. }
  526. fn parsePosix(s: []const u8) ?PosixRule {
  527. var c = Cursor{ .s = s };
  528. var r = PosixRule{};
  529. if (!parseName(&c)) return null;
  530. // POSIX writes the offset WEST of UTC ("CET-1"); this plugin speaks east
  531. r.std_off = -(parseHms(&c) orelse return null);
  532. if (c.i == s.len) return r;
  533. if (!parseName(&c)) return null;
  534. r.has_dst = true;
  535. r.dst_off = r.std_off + 3600;
  536. if (c.peek() != ',' and c.i < s.len) r.dst_off = -(parseHms(&c) orelse return null);
  537. if (c.peek() != ',') return null; // a DST name without rules: no reliable answer
  538. c.i += 1;
  539. r.start = parseRuleDate(&c) orelse return null;
  540. if (c.peek() != ',') return null;
  541. c.i += 1;
  542. r.end = parseRuleDate(&c) orelse return null;
  543. return r;
  544. }
  545. // ── TZif ────────────────────────────────────────────────────────────────────
  546. fn be32(b: []const u8) i32 {
  547. return std.mem.readInt(i32, b[0..4], .big);
  548. }
  549. fn be64(b: []const u8) i64 {
  550. return std.mem.readInt(i64, b[0..8], .big);
  551. }
  552. /// A name the zone database may answer for: the shape of an IANA name, and
  553. /// never a path out of the directory or one of its non-zone entries.
  554. fn plausibleName(name: []const u8) bool {
  555. if (name.len == 0 or name.len > 64) return false;
  556. if (!std.ascii.isAlphabetic(name[0])) return false;
  557. for (name) |ch| {
  558. if (!(std.ascii.isAlphanumeric(ch) or ch == '/' or ch == '_' or ch == '-' or ch == '+')) return false;
  559. }
  560. if (std.mem.indexOf(u8, name, "//") != null or name[name.len - 1] == '/') return false;
  561. // the directory's own furniture: alternative trees, and entries Intl rejects
  562. const not_zones = [_][]const u8{ "posix/", "right/" };
  563. for (not_zones) |p| if (std.mem.startsWith(u8, name, p)) return false;
  564. const not_names = [_][]const u8{ "posixrules", "localtime", "Factory" };
  565. for (not_names) |n| if (std.mem.eql(u8, name, n)) return false;
  566. return true;
  567. }
  568. fn readZoneFile(name: []const u8) ?[]u8 {
  569. var path_buf: [128]u8 = undefined;
  570. const path = std.fmt.bufPrintZ(&path_buf, "{s}{s}", .{ ZONEINFO, name }) catch return null;
  571. const rc = linux.open(path, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }, 0);
  572. const sfd: isize = @bitCast(rc);
  573. if (sfd < 0) return null;
  574. const fd: i32 = @intCast(sfd);
  575. defer _ = linux.close(fd);
  576. var out: std.ArrayList(u8) = .empty;
  577. var buf: [8192]u8 = undefined;
  578. while (true) {
  579. const n: isize = @bitCast(linux.read(fd, &buf, buf.len));
  580. if (n < 0) {
  581. out.deinit(allocator);
  582. return null; // EISDIR for "Europe", among others
  583. }
  584. if (n == 0) break;
  585. out.appendSlice(allocator, buf[0..@intCast(n)]) catch {
  586. out.deinit(allocator);
  587. return null;
  588. };
  589. if (out.items.len > 1 << 20) break; // no zone is this large
  590. }
  591. return out.toOwnedSlice(allocator) catch null;
  592. }
  593. const Counts = struct { isut: usize, isstd: usize, leap: usize, time: usize, typ: usize, chr: usize };
  594. fn header(b: []const u8) ?Counts {
  595. if (b.len < 44 or !std.mem.eql(u8, b[0..4], "TZif")) return null;
  596. const n = struct {
  597. fn at(x: []const u8, o: usize) usize {
  598. const v = be32(x[o..]);
  599. return if (v < 0) 0 else @intCast(v);
  600. }
  601. };
  602. return .{
  603. .isut = n.at(b, 20),
  604. .isstd = n.at(b, 24),
  605. .leap = n.at(b, 28),
  606. .time = n.at(b, 32),
  607. .typ = n.at(b, 36),
  608. .chr = n.at(b, 40),
  609. };
  610. }
  611. fn parseTzif(b: []const u8) ?*Zone {
  612. const h1 = header(b) orelse return null;
  613. const version = b[4];
  614. var p: usize = 44;
  615. var tsize: usize = 4;
  616. var h = h1;
  617. if (version >= '2') {
  618. // skip the 32-bit block; the 64-bit one follows with its own header
  619. p += h1.time * 4 + h1.time + h1.typ * 6 + h1.chr + h1.leap * 8 + h1.isstd + h1.isut;
  620. h = header(b[p..]) orelse return null;
  621. p += 44;
  622. tsize = 8;
  623. }
  624. const need = h.time * tsize + h.time + h.typ * 6 + h.chr + h.leap * (tsize + 4) + h.isstd + h.isut;
  625. if (h.typ == 0 or b.len < p + need) return null;
  626. const z = allocator.create(Zone) catch return null;
  627. z.trans = allocator.alloc(i64, h.time) catch return null;
  628. z.idx = allocator.alloc(u8, h.time) catch return null;
  629. z.types = allocator.alloc(TzType, h.typ) catch return null;
  630. for (0..h.time) |i| {
  631. z.trans[i] = if (tsize == 8) be64(b[p + i * 8 ..]) else be32(b[p + i * 4 ..]);
  632. }
  633. p += h.time * tsize;
  634. for (0..h.time) |i| {
  635. z.idx[i] = b[p + i];
  636. if (z.idx[i] >= h.typ) return null;
  637. }
  638. p += h.time;
  639. for (0..h.typ) |i| {
  640. const e = b[p + i * 6 ..];
  641. z.types[i] = .{ .utoff = be32(e), .isdst = e[4] != 0 };
  642. }
  643. p += h.typ * 6 + h.chr + h.leap * (tsize + 4) + h.isstd + h.isut;
  644. z.rule = null;
  645. if (version >= '2' and p < b.len and b[p] == '\n') {
  646. const rest = b[p + 1 ..];
  647. if (std.mem.indexOfScalar(u8, rest, '\n')) |end| {
  648. if (end > 0) z.rule = parsePosix(rest[0..end]);
  649. }
  650. }
  651. return z;
  652. }
  653. /// The cached zone, or null when the database has no such zone.
  654. fn zoneByName(name: []const u8) ?*Zone {
  655. if (zones.get(name)) |cached| return cached;
  656. const z: ?*Zone = if (!plausibleName(name)) null else blk: {
  657. const bytes = readZoneFile(name) orelse break :blk null;
  658. defer allocator.free(bytes);
  659. break :blk parseTzif(bytes);
  660. };
  661. // unknown names are cached too, so a typo in a loop costs one open()
  662. if (zones.count() < 4096) {
  663. const key = allocator.dupe(u8, name) catch return z;
  664. zones.put(allocator, key, z) catch allocator.free(key);
  665. }
  666. return z;
  667. }
  668. // ── the calls ───────────────────────────────────────────────────────────────
  669. var err_buf: [160]u8 = undefined;
  670. fn unknownZone(name: []const u8) HlValue {
  671. const shown = if (name.len > 64) name[0..64] else name;
  672. const msg = std.fmt.bufPrint(&err_buf, "hl:time: unknown time zone '{s}'", .{shown}) catch "hl:time: unknown time zone";
  673. return api.makeError(msg);
  674. }
  675. const ZoneArg = union(enum) { zone: *Zone, err: HlValue };
  676. fn zoneArg(argc: u32, argv: [*]const HlValue, at: u32) ZoneArg {
  677. if (argc <= at or argv[at].type != .hl_string) {
  678. return .{ .err = api.makeError("hl:time: pass the time zone as an IANA name, e.g. 'Europe/Vienna'") };
  679. }
  680. const s = argv[at].data.string;
  681. const name = s.ptr[0..s.len];
  682. return if (zoneByName(name)) |z| .{ .zone = z } else .{ .err = unknownZone(name) };
  683. }
  684. /// The instant argument: a finite epoch-ms Number within Date's range, floored
  685. /// to a whole millisecond.
  686. fn instantArg(argc: u32, argv: [*]const HlValue) ?i64 {
  687. if (argc < 1 or argv[0].type != .hl_number) return null;
  688. const v = argv[0].data.number;
  689. if (!(v == v) or v > MAX_MS or v < -MAX_MS) return null;
  690. return @intFromFloat(@floor(v));
  691. }
  692. const BAD_INSTANT = "hl:time: pass the instant in epoch milliseconds (what now() returns)";
  693. fn offsetAtMs(z: *const Zone, ms: i64) i32 {
  694. return zoneOffset(z, floorDiv(ms, 1000));
  695. }
  696. /// hl_time_offset(ms, zone) → seconds east of UTC in force at that instant
  697. export fn hl_time_offset(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  698. const ms = instantArg(argc, argv) orelse return api.makeError(BAD_INSTANT);
  699. const z = switch (zoneArg(argc, argv, 1)) {
  700. .zone => |z| z,
  701. .err => |e| return e,
  702. };
  703. return api.makeNumber(@floatFromInt(offsetAtMs(z, ms)));
  704. }
  705. const FIELD_NAMES = [_][]const u8{ "year", "month", "day", "hour", "minute", "second", "millisecond", "weekday", "offset" };
  706. var local_fields: [FIELD_NAMES.len]HlField = undefined;
  707. var local_obj: HlObject = undefined;
  708. /// hl_time_local(ms, zone) → the wall-clock reading in that zone at that instant
  709. export fn hl_time_local(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  710. const ms = instantArg(argc, argv) orelse return api.makeError(BAD_INSTANT);
  711. const z = switch (zoneArg(argc, argv, 1)) {
  712. .zone => |z| z,
  713. .err => |e| return e,
  714. };
  715. const off = offsetAtMs(z, ms);
  716. const local = ms + @as(i64, off) * 1000;
  717. const days = floorDiv(local, 86_400_000);
  718. const in_day = local - days * 86_400_000;
  719. const c = civilFromDays(days);
  720. const values = [_]i64{
  721. c.y, c.m, c.d,
  722. @divFloor(in_day, 3_600_000), @mod(@divFloor(in_day, 60_000), 60), @mod(@divFloor(in_day, 1000), 60),
  723. @mod(in_day, 1000), @mod(days + 3, 7) + 1, // ISO: 1 = Monday … 7 = Sunday
  724. off,
  725. };
  726. for (FIELD_NAMES, values, 0..) |k, v, i| {
  727. local_fields[i] = .{ .key = hlStr(k), .value = api.makeNumber(@floatFromInt(v)) };
  728. }
  729. local_obj = .{ .fields = &local_fields, .field_count = local_fields.len, .deinit_fn = null };
  730. return api.makeObject(&local_obj);
  731. }
  732. fn fieldNumber(obj: *const HlObject, key: []const u8, default: f64) ?f64 {
  733. for (obj.fields[0..obj.field_count]) |f| {
  734. if (!std.mem.eql(u8, f.key.ptr[0..f.key.len], key)) continue;
  735. return switch (f.value.type) {
  736. .hl_null => default,
  737. .hl_number => if (f.value.data.number == f.value.data.number and
  738. @abs(f.value.data.number) < 1e12) @floor(f.value.data.number) else null,
  739. else => null,
  740. };
  741. }
  742. return default;
  743. }
  744. /// The wall-clock reading as if it were UTC, in ms. Out-of-range fields carry
  745. /// over the way Date.UTC's do: month 13 is January of the next year, minute 90
  746. /// is an hour and a half.
  747. fn wallMs(y: f64, mo: f64, d: f64, h: f64, mi: f64, s: f64, milli: f64) i64 {
  748. const m0: i64 = @as(i64, @intFromFloat(mo)) - 1;
  749. const yy: i64 = @as(i64, @intFromFloat(y)) + floorDiv(m0, 12);
  750. const days = daysFromCivil(yy, @mod(m0, 12) + 1, @intFromFloat(d));
  751. return days * 86_400_000 + @as(i64, @intFromFloat(h)) * 3_600_000 +
  752. @as(i64, @intFromFloat(mi)) * 60_000 + @as(i64, @intFromFloat(s)) * 1000 + @as(i64, @intFromFloat(milli));
  753. }
  754. /// THE INVERSE, and the one place a zone can answer twice or not at all.
  755. /// A reading inside a FOLD (the hour a fall-back repeats) is the EARLIER
  756. /// instant; a reading inside a GAP (the hour a spring-forward skips) is moved
  757. /// forward by the gap's length — 02:30 on Vienna's spring-forward night is
  758. /// 03:30 CEST. That is Temporal's "compatible" disambiguation, and server.js
  759. /// runs the same steps.
  760. fn fromWall(z: *const Zone, wall: i64) i64 {
  761. const before = offsetAtMs(z, wall - 86_400_000);
  762. const after = offsetAtMs(z, wall + 86_400_000);
  763. const a = wall - @as(i64, before) * 1000;
  764. const b = wall - @as(i64, after) * 1000;
  765. const a_ok = offsetAtMs(z, a) == before;
  766. const b_ok = offsetAtMs(z, b) == after;
  767. if (a_ok and b_ok) return @min(a, b);
  768. if (a_ok) return a;
  769. if (b_ok) return b;
  770. return a; // the gap: read with the offset in force before it
  771. }
  772. /// hl_time_fromLocal(fields, zone) → epoch ms
  773. export fn hl_time_fromLocal(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  774. if (argc < 1 or argv[0].type != .hl_object) {
  775. return api.makeError("hl:time fromLocal: pass the fields as { year, month, day, hour, minute, second, millisecond }");
  776. }
  777. const z = switch (zoneArg(argc, argv, 1)) {
  778. .zone => |z| z,
  779. .err => |e| return e,
  780. };
  781. const o = argv[0].data.object;
  782. const bad = api.makeError("hl:time fromLocal: every field must be a Number (year is required)");
  783. const y = fieldNumber(o, "year", std.math.nan(f64)) orelse return bad;
  784. if (!(y == y)) return bad;
  785. const mo = fieldNumber(o, "month", 1) orelse return bad;
  786. const d = fieldNumber(o, "day", 1) orelse return bad;
  787. const h = fieldNumber(o, "hour", 0) orelse return bad;
  788. const mi = fieldNumber(o, "minute", 0) orelse return bad;
  789. const s = fieldNumber(o, "second", 0) orelse return bad;
  790. const milli = fieldNumber(o, "millisecond", 0) orelse return bad;
  791. const wall = wallMs(y, mo, d, h, mi, s, milli);
  792. if (@abs(wall) > 8_640_000_000_000_000 + 86_400_000) return api.makeError(BAD_INSTANT);
  793. const ms = fromWall(z, wall);
  794. if (@abs(ms) > 8_640_000_000_000_000) return api.makeError(BAD_INSTANT);
  795. return api.makeNumber(@floatFromInt(ms));
  796. }
  797. var local_iso_buf: [48]u8 = undefined;
  798. /// hl_time_localTimestamp(ms, zone) → ISO-8601 with the zone's offset
  799. export fn hl_time_localTimestamp(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  800. const ms = instantArg(argc, argv) orelse return api.makeError(BAD_INSTANT);
  801. const z = switch (zoneArg(argc, argv, 1)) {
  802. .zone => |z| z,
  803. .err => |e| return e,
  804. };
  805. const off = offsetAtMs(z, ms);
  806. const local = ms + @as(i64, off) * 1000;
  807. const days = floorDiv(local, 86_400_000);
  808. const in_day: u64 = @intCast(local - days * 86_400_000);
  809. const c = civilFromDays(days);
  810. var w: std.Io.Writer = .fixed(&local_iso_buf);
  811. // 0000..9999 as four digits; beyond, ISO's expanded form (Date#toISOString's)
  812. if (c.y >= 0 and c.y <= 9999) {
  813. w.print("{d:0>4}", .{@as(u64, @intCast(c.y))}) catch unreachable;
  814. } else {
  815. w.print("{c}{d:0>6}", .{ @as(u8, if (c.y < 0) '-' else '+'), @abs(c.y) }) catch unreachable;
  816. }
  817. const abs_off: u64 = @abs(off);
  818. w.print("-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.{d:0>3}{c}{d:0>2}:{d:0>2}", .{
  819. @as(u64, @intCast(c.m)), @as(u64, @intCast(c.d)),
  820. in_day / 3_600_000, in_day / 60_000 % 60,
  821. in_day / 1000 % 60, in_day % 1000,
  822. @as(u8, if (off < 0) '-' else '+'), abs_off / 3600,
  823. abs_off / 60 % 60,
  824. }) catch unreachable;
  825. // an offset with seconds (local mean time before a zone's first rule)
  826. if (abs_off % 60 != 0) w.print(":{d:0>2}", .{abs_off % 60}) catch unreachable;
  827. return api.makeString(w.buffered());
  828. }
  829. /// hl_time_isZone(name) → true when the system's zone database knows the name
  830. export fn hl_time_isZone(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {
  831. if (argc < 1 or argv[0].type != .hl_string) return api.makeBool(false);
  832. const s = argv[0].data.string;
  833. return api.makeBool(zoneByName(s.ptr[0..s.len]) != null);
  834. }

Branches

Latest commits

  • 1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre
  • fa1f9dfatracker#29 (mission 063): unwatched check muted grey outline + check (accent only on hover), watched stays solid — no code regression, the accent outline read as ticked; gate checks real checks visibly (computed style + screenshot pixel) on /my/unwatched, show, movie, /my/movies; gate 266, tests/realdata-063.mjs, README + STATUSmre
  • d8b12d67tracker#27 (mission 060): short ids for movies, series and persons — old 702 kept (data/old-short-ids.json), new random [a-z0-9]{5} unique across both, claimed at creation, background backfill (resumes), shown under poster/photo, /<shortId> → 301; gate 259, tests/realdata-060*, README + STATUSmre
  • f14db671tracker#22-#25 (mission 058): episode air dates, season check = all episodes watched, movie watched check (+ /my/movies count), /genres/<genre> pages (movies + series, newest first, paginated); gate 238, tests/realdata-058.mjs, README + STATUSmre
  • 1704ec45tracker#17 (mission 057): season caret down/up, skeleton rows while a season loads, sessionless showSeasonEpisodes face (no page re-mount), client-only close; gate 214, tests/realdata-057.mjs, README + STATUSmre
  • f2fe3e36mission 056: README + STATUS (merge, fixes, Hybriel 8590df63, real-data check), tests/realdata-056.mjs, tools/check-public-slugs.hlmre
  • f40c250emission 056: re-vendor hybriel master 8590df63 (#121, #122); an adult title's page is Not found for non-followers; gate: leave the page before stopping the servermre
  • 2b7fdd6cmission 056: signed-out header one row on phones ("Log in", nowrap), backfill skips adult titles' posters, gate checksmre
  • 2c53d5efMerge branch 't16-person' (tracker#16 person pages) into main; filmography shows only public titles (054 adult flag), gate race fix (backfill start line)mre
  • c171227emission 054: hide adult/unknown titles from the public lists and the search; in-app adult-flag backfill (TMDB details + poster per title, resumes), gate + real-data proofmre
  • 139fafd8tracker#16: short bio (4 lines, click = all), real-data check script, README + STATUSmre
  • 93be9476tracker#16: person pages /person/<slug> with the filmography fetched from TMDB on the first visit (step by step), gatemre
  • 47a3cae6STATUS: mission 053 merge commit idsmre
  • dcc5eecaMerge branch 't14-search'mre
  • 03edc783Merge branch 't15-tvmaze'mre
  • 71b46345tracker#15: numbering check by date or title, placeholder titles in other languages, docs + real-data proofmre
  • 6bb2daf1tracker#13: homepage (tiles, intro, latest movies/shows), /shows, /movies/page/N, /my/movies; lists cached in memorymre
  • b8bd1157tracker#14: README + STATUS (search, real-data numbers, gate, merge notes)mre
  • 65c694a8tracker#14: search — header magnifier, /search/<text> (in-memory word-prefix index over titles + people), Fetch from web (TMDB search/multi, ours left out), Add = import via syncShow; gate +25 checks, real-data scriptmre
  • 34f2c15btracker#15: TVmaze merge in the sync (gaps only: new episodes/seasons, empty titles/air dates; numbering check), fake TVmaze episodes + gatemre