@x-tech-laboratories/x-libs 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +89 -13
  2. package/dist/backups.d.ts +31 -0
  3. package/dist/backups.js +43 -0
  4. package/dist/dates.d.ts +9 -0
  5. package/dist/dates.js +60 -0
  6. package/dist/durations.d.ts +9 -1
  7. package/dist/durations.js +19 -5
  8. package/dist/electron/app.d.ts +13 -0
  9. package/dist/electron/app.js +27 -0
  10. package/dist/electron/bridge.d.ts +32 -0
  11. package/dist/electron/bridge.js +53 -0
  12. package/dist/electron/events.d.ts +21 -0
  13. package/dist/electron/events.js +25 -0
  14. package/dist/electron/fetch.d.ts +25 -0
  15. package/dist/electron/fetch.js +67 -0
  16. package/dist/electron/window.d.ts +96 -0
  17. package/dist/electron/window.js +128 -0
  18. package/dist/log.d.ts +30 -0
  19. package/dist/log.js +79 -0
  20. package/dist/server/backup_files.d.ts +3 -0
  21. package/dist/server/backup_files.js +25 -0
  22. package/dist/web/drag.d.ts +3 -0
  23. package/dist/web/drag.js +13 -0
  24. package/dist/web/format.d.ts +1 -0
  25. package/dist/web/format.js +5 -0
  26. package/dist/web/menus.d.ts +4 -0
  27. package/dist/web/menus.js +28 -1
  28. package/dist/web/prefs.d.ts +12 -0
  29. package/dist/web/prefs.js +62 -0
  30. package/dist/web/{preact → react}/badges.d.ts +9 -9
  31. package/dist/web/{preact → react}/badges.js +6 -6
  32. package/dist/web/react/controls.d.ts +72 -0
  33. package/dist/web/react/controls.js +88 -0
  34. package/dist/web/{preact → react}/dialog.d.ts +10 -10
  35. package/dist/web/{preact → react}/dialog.js +14 -9
  36. package/dist/web/react/layout.d.ts +100 -0
  37. package/dist/web/react/layout.js +80 -0
  38. package/dist/web/react/menus.d.ts +31 -0
  39. package/dist/web/react/menus.js +159 -0
  40. package/dist/web/react/prefs.d.ts +2 -0
  41. package/dist/web/react/prefs.js +11 -0
  42. package/dist/web/react/reveal.d.ts +9 -0
  43. package/dist/web/react/reveal.js +30 -0
  44. package/dist/web/react/shell.d.ts +41 -0
  45. package/dist/web/react/shell.js +90 -0
  46. package/dist/web/react/shortcuts.d.ts +23 -0
  47. package/dist/web/react/shortcuts.js +95 -0
  48. package/dist/web/{preact → react}/split.d.ts +4 -4
  49. package/dist/web/{preact → react}/split.js +9 -10
  50. package/dist/web/{preact → react}/states.d.ts +7 -7
  51. package/dist/web/{preact → react}/states.js +6 -5
  52. package/dist/web/react/table.d.ts +69 -0
  53. package/dist/web/react/table.js +278 -0
  54. package/dist/web/{preact → react}/time.d.ts +2 -1
  55. package/dist/web/react/time.js +19 -0
  56. package/dist/web/react/toasts.d.ts +29 -0
  57. package/dist/web/react/toasts.js +72 -0
  58. package/dist/web/react/tree.d.ts +28 -0
  59. package/dist/web/react/tree.js +183 -0
  60. package/dist/web/react/watch.d.ts +3 -0
  61. package/dist/web/react/watch.js +5 -0
  62. package/dist/web/selection.d.ts +25 -0
  63. package/dist/web/selection.js +106 -0
  64. package/dist/web/tables.d.ts +19 -1
  65. package/dist/web/tables.js +78 -8
  66. package/dist/web/theme.d.ts +6 -1
  67. package/dist/web/theme.js +31 -21
  68. package/dist/web/tree.d.ts +37 -0
  69. package/dist/web/tree.js +128 -0
  70. package/dist/web/virtual.d.ts +7 -0
  71. package/dist/web/virtual.js +10 -0
  72. package/dist/web/watch.d.ts +6 -0
  73. package/dist/web/watch.js +17 -0
  74. package/package.json +15 -4
  75. package/dist/web/preact/controls.d.ts +0 -37
  76. package/dist/web/preact/controls.js +0 -43
  77. package/dist/web/preact/layout.d.ts +0 -80
  78. package/dist/web/preact/layout.js +0 -71
  79. package/dist/web/preact/menus.d.ts +0 -19
  80. package/dist/web/preact/menus.js +0 -70
  81. package/dist/web/preact/shell.d.ts +0 -27
  82. package/dist/web/preact/shell.js +0 -63
  83. package/dist/web/preact/table.d.ts +0 -23
  84. package/dist/web/preact/table.js +0 -20
  85. package/dist/web/preact/time.js +0 -9
  86. package/dist/web/preact/toasts.d.ts +0 -15
  87. package/dist/web/preact/toasts.js +0 -34
package/README.md CHANGED
@@ -6,7 +6,15 @@ Code the X-apps share, published as one npm package, `@x-tech-laboratories/x-lib
6
6
 
7
7
  | Import | What it holds |
8
8
  | ---------------------------------------- | ----------------------------------------------------------------------------- |
9
- | `durations` | `parse_duration` for settings such as `"2h30m"`, `format_duration`, `format_age`. |
9
+ | `durations` | `parse_duration` for settings such as `"2h30m"`, `format_duration` in months down to seconds, `format_age`, and `format_clock` for a playing time such as `"3:07"`. |
10
+ | `log` | Loggers by scope and level from a setting such as `"warn,play:debug"`; a malformed setting falls back instead of silencing logging, and a setting read through a function applies to loggers made before it arrived. |
11
+ | `dates` | Local dates as `"2026-06-10"` or `"2026-06-10T09:30"`: reading, writing, adding days and months, overdue, and Yesterday/Today/Tomorrow labels. |
12
+ | `backups` | Named backups of an app's data in any store, newest first, with daily automatic ones kept to a set number. |
13
+ | `electron/bridge` | `define_bridge`: one method table for the preload, the main process, and a web page's calls to its server; `with_cloneable_arguments` flattens what structured clone refuses, such as a Proxy. |
14
+ | `electron/events` | `define_events`: messages the main process pushes to its pages, such as progress, on one channel and subscribed to by name. |
15
+ | `electron/app` | `hold_single_instance`, `bring_to_front`, and `first_argument` for a link handed over on the command line. |
16
+ | `electron/window` | `create_window`: links and other sites open in the browser, the background follows the system theme, and the size and place are remembered, through the app's write guard if it has one; timers can keep running while hidden. |
17
+ | `electron/fetch` | `create_main_fetch` and `create_page_fetch`: the page's requests sent from the main process past cross-origin rules, to an allow-list of hosts, with headers such as `Origin` pinned. |
10
18
  | `agent/commands` | `run_command` and `stream_command`: one shell command, in its own process group. |
11
19
  | `agent/long_runs` | `LongRuns`: commands that outlive the agent, their output and exit code kept on disk. |
12
20
  | `agent/keys` | Reads and makes OpenSSH ed25519 agent keys; signs and checks the login challenge. |
@@ -17,13 +25,21 @@ Code the X-apps share, published as one npm package, `@x-tech-laboratories/x-lib
17
25
  | `server/agents/connection`, `agent`, `local`, `route` | One connected agent, the `Agent` interface, `LocalAgent`, and the Fastify route. |
18
26
  | `server/passwords` | scrypt hashes and the `name:hash` accounts file. |
19
27
  | `server/hash_password` | The `hash-password` command. |
20
- | `web/dom`, `web/format`, `web/router`, `web/theme` | The page's element builder, formatters, hash router and theme choice. |
21
- | `web/states`, `web/tables`, `web/menus`, `web/split` | `LoginNeeded`; table sorting; menu items and on-screen placement; pane sizing. |
22
- | `web/preact/shell` | `Shell`: top bar, sidebar, theme button, toasts and dialogs; `use_route`. |
23
- | `web/preact/layout`, `badges`, `states` | Panels, facts, tiles, cards, tabs, crumbs, steppers, settings rows; badges and chips; `use_resource` and its loading states. |
24
- | `web/preact/time` | `Age`: a time as how long ago, with the full stamp on hover. |
25
- | `web/preact/controls`, `table` | Segmented choices, button groups, switches, search boxes, selects; sortable tables that turn into cards on phones, and a pager. |
26
- | `web/preact/toasts`, `dialog`, `menus`, `split` | Toasts with a link or Undo; modal, confirm and text dialogs openable from anywhere; context menus and popovers; resize handles and splits. |
28
+ | `server/backup_files` | `file_backup_store`: the `backups` store as files in a folder on disk. |
29
+ | `web/dom`, `web/format`, `web/router`, `web/theme` | The page's element builder, formatters (including `compact_count`, such as `"1.4M"`), hash router and theme choice, kept in this browser or the app's settings. |
30
+ | `web/states`, `web/tables`, `web/menus`, `web/split` | `LoginNeeded`; sorting by several columns, trees and column order; menu items, placement and keys; pane sizing. |
31
+ | `web/tree` | Rows with `parent_id` and `position`: nesting, subtrees, tree order, paths, and where a dropped or keyed row lands. |
32
+ | `web/watch`, `web/react/watch` | `create_watch`: a change counter a store bumps; `use_watch` redraws only the components that read that store. |
33
+ | `web/prefs`, `web/react/prefs` | Remembered view settings that read as their default when stored corrupt, stale or of the wrong shape; `use_pref` keeps one as component state. |
34
+ | `web/selection`, `web/virtual`, `web/drag` | Picking rows with plain, Ctrl and Shift clicks from a fixed anchor, or as lists of ids with the anchor kept by id, the row an arrow or page key moves to; which rows of a long list a viewport needs; a badge such as "3 tasks" as the drag image. |
35
+ | `web/react/shell` | `Shell`: top bar, section links or the app's own sidebar, resizable, theme button, toasts, dialogs and menus, with or without logins, its phone menu held by the Shell or the app; `use_route`. |
36
+ | `web/react/layout`, `badges`, `states` | Panels, facts, tiles, cards, tabs, crumbs, steppers, settings rows, `RowActions` and `moved` for ordered lists, `SelectionBar` for picked rows; badges and chips; `use_resource` and its loading states. |
37
+ | `web/react/time` | `Age`: a time as how long ago, with the full stamp on hover; `use_now`, the time read again each minute. |
38
+ | `web/react/reveal` | `use_reveal`: a long list shown a page at a time, the next page added as its end scrolls into view. |
39
+ | `web/react/controls`, `table` | Segmented choices, button groups, switches, search boxes that can take focus and follow a value cleared from outside, selects, `CommitInput`, `DateTimeField`, `ColorPicker`, `TreeSelect`; `DataTable` on TanStack Table, sorting by several columns, with columns to move, resize, hide and filter, tree rows, groups under headings, rows the app drags and styles, rows that come sorted from a database, rows worked by keyboard, and cards on phones; a pager. |
40
+ | `web/react/shortcuts` | `use_shortcuts` for keys such as `"mod+z"`, quiet while typing or while a dialog or popover is open; `describe_keys` for help text. |
41
+ | `web/react/tree` | `Tree`: nested rows that fold, walk with the arrow keys, reorder by dragging or with Alt and the arrow keys, show several picked, and take drops from outside. |
42
+ | `web/react/toasts`, `dialog`, `menus`, `split` | Toasts with a link or Undo that say how they closed, and `undoable` actions that commit or undo exactly once; modal, confirm and text dialogs openable from anywhere; context menus and popovers worked by keyboard, openable from anywhere; resize handles and splits. |
27
43
 
28
44
  ```ts
29
45
  import { AgentHub } from "@x-tech-laboratories/x-libs/server/agents/hub";
@@ -38,13 +54,73 @@ which an app imports once:
38
54
  ```ts
39
55
  import "@x-tech-laboratories/halon/halon-tokens.css";
40
56
  import "@x-tech-laboratories/halon/halon.css";
41
- import { DataTable } from "@x-tech-laboratories/x-libs/web/preact/table";
42
- import { toast } from "@x-tech-laboratories/x-libs/web/preact/toasts";
57
+ import { DataTable } from "@x-tech-laboratories/x-libs/web/react/table";
58
+ import { toast } from "@x-tech-laboratories/x-libs/web/react/toasts";
43
59
  ```
44
60
 
45
- The widgets are Preact components, so a page renders with Preact; `Shell` holds the toasts and dialogs that `toast` and
46
- `confirm_dialog` open from anywhere. A request whose session has lapsed throws `LoginNeeded` from `web/states`, and
47
- `use_resource` passes it on as an unhandled rejection, which the page's login listens for.
61
+ The widgets are React components, so a page renders with React through `createRoot` from `react-dom/client`; `Shell`
62
+ holds the toasts, dialogs and menus that `toast`, `confirm_dialog` and `open_context_menu` open from anywhere. `DataTable`
63
+ is drawn on `@tanstack/react-table` with Halon's markup; its sorting, column order, widths, hidden columns and open rows
64
+ are props with change callbacks, so the app keeps them in its own settings, and a column's filter is the app's own
65
+ content in a popover X-Libs opens from the column's funnel. A request whose session has lapsed
66
+ throws `LoginNeeded` from `web/states`, and `use_resource` passes it on as an unhandled rejection, which the page's
67
+ login listens for.
68
+
69
+ ## Shortcuts and trees
70
+
71
+ ```tsx
72
+ import { use_shortcuts } from "@x-tech-laboratories/x-libs/web/react/shortcuts";
73
+ import { Tree } from "@x-tech-laboratories/x-libs/web/react/tree";
74
+
75
+ use_shortcuts([
76
+ { keys: "mod+z", run: undo },
77
+ { keys: "escape", run: clear_picks },
78
+ ]);
79
+
80
+ <Tree items={tags} label="Tags" render_item={(tag) => tag.name} collapsed={collapsed} on_collapsed_change={set_collapsed}
81
+ on_move={(tag, { parent_id, position }) => move_tag(tag.id, parent_id, position)} />
82
+ ```
83
+
84
+ A shortcut stays quiet in text fields unless it has `when: "always"`, and while a dialog or popover is open unless it
85
+ has `over_overlays: true`. The tree builds Halon's `.tree` markup; a row dropped onto its top or bottom quarter lands
86
+ beside it, and onto its middle inside it.
87
+
88
+ ## Electron apps
89
+
90
+ The bridge and window helpers describe the few parts of Electron they use, so the app passes Electron's own objects in
91
+ and X-Libs does not depend on Electron.
92
+
93
+ ```ts
94
+ // shared
95
+ export const notes_bridge = define_bridge<NotesApi>("x_notes", ["list_notes", "add_note"]);
96
+ // preload
97
+ notes_bridge.expose(contextBridge, ipcRenderer);
98
+ // main
99
+ notes_bridge.handle(ipcMain, { list_notes, add_note });
100
+ create_window(BrowserWindow, {
101
+ title: "X-Notes", file: join(__dirname, "../renderer/index.html"), dev_url: process.env.ELECTRON_RENDERER_URL,
102
+ preload: join(__dirname, "../preload/index.mjs"), default_size: { width: 1100, height: 700 },
103
+ state_file: join(app.getPath("userData"), "window.json"), native_theme: nativeTheme, shell,
104
+ });
105
+ // web page
106
+ window.x_notes = notes_bridge.web_rpc((method, args) => post_json(`api/${method}`, args));
107
+ ```
108
+
109
+ A page that must reach another site, such as an API that sends no CORS headers, sends its requests through the main
110
+ process with `electron/fetch`. The main process allows only the hosts it lists; the page gets a `fetch` to hand to a
111
+ library.
112
+
113
+ ```ts
114
+ // shared
115
+ export const media_bridge = define_bridge<{ http_fetch: FetchMethod }>("x_media", ["http_fetch"]);
116
+ // main
117
+ media_bridge.handle(ipcMain, { http_fetch: create_main_fetch({ hosts: ["api.example.com", "*.cdn.example.com"] }) });
118
+ // web page
119
+ const page_fetch = create_page_fetch((request) => window.x_media.http_fetch(request), { pinned_headers: { Origin: "https://example.com" } });
120
+ ```
121
+
122
+ `create_window` keeps the app's own pages in the window: a link or a navigation to another site opens in the browser,
123
+ and only web and mail links leave the app.
48
124
 
49
125
  ## The app and its tasks
50
126
 
@@ -0,0 +1,31 @@
1
+ export interface Backup {
2
+ name: string;
3
+ taken_at: string;
4
+ is_automatic: boolean;
5
+ size_bytes: number;
6
+ }
7
+ export interface BackupNaming {
8
+ pattern: RegExp;
9
+ name_for: (taken_at: Date, is_automatic: boolean) => string;
10
+ describe: (name: string, size_bytes: number) => Backup;
11
+ }
12
+ export declare function backup_naming(prefix: string, extension: string): BackupNaming;
13
+ export interface BackupStore {
14
+ list: () => Promise<{
15
+ name: string;
16
+ size_bytes: number;
17
+ }[]> | {
18
+ name: string;
19
+ size_bytes: number;
20
+ }[];
21
+ take: (name: string) => Promise<number> | number;
22
+ remove: (name: string) => Promise<void> | void;
23
+ }
24
+ export interface Backups {
25
+ naming: BackupNaming;
26
+ list: () => Promise<Backup[]>;
27
+ create: (is_automatic?: boolean) => Promise<Backup>;
28
+ remove: (name: string) => Promise<void>;
29
+ keep_automatic: (to_keep: number, interval_in_milliseconds?: number, now?: Date) => Promise<void>;
30
+ }
31
+ export declare function backups(naming: BackupNaming, store: BackupStore): Backups;
@@ -0,0 +1,43 @@
1
+ // Named backups of an app's data: the name says when each was taken and whether it was automatic, and daily ones keep a set number.
2
+ const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3
+ // Names such as tasks-2026-06-10T09-30-00Z-auto.db for the prefix "tasks" and the extension ".db".
4
+ export function backup_naming(prefix, extension) {
5
+ const pattern = new RegExp(`^${escaped(prefix)}-(\\d{4}-\\d{2}-\\d{2})T(\\d{2})-(\\d{2})-(\\d{2})Z(-auto)?${escaped(extension)}$`);
6
+ return {
7
+ pattern,
8
+ name_for: (taken_at, is_automatic) => `${prefix}-${taken_at.toISOString().slice(0, 19).replaceAll(":", "-")}Z${is_automatic ? "-auto" : ""}${extension}`,
9
+ describe: (name, size_bytes) => {
10
+ const match = pattern.exec(name);
11
+ if (!match)
12
+ throw new Error(`not a backup name: ${name}`);
13
+ const [, date, hours, minutes, seconds, automatic] = match;
14
+ return { name, taken_at: `${date}T${hours}:${minutes}:${seconds}Z`, is_automatic: automatic !== undefined, size_bytes };
15
+ },
16
+ };
17
+ }
18
+ const day_in_milliseconds = 24 * 60 * 60 * 1000;
19
+ export function backups(naming, store) {
20
+ const list = async () => (await store.list())
21
+ .filter((file) => naming.pattern.test(file.name))
22
+ .map((file) => naming.describe(file.name, file.size_bytes))
23
+ .sort((first, second) => second.taken_at.localeCompare(first.taken_at));
24
+ const remove = async (name) => {
25
+ if (!naming.pattern.test(name))
26
+ throw new Error(`not a backup name: ${name}`);
27
+ await store.remove(name);
28
+ };
29
+ const create = async (is_automatic = false, now = new Date()) => {
30
+ const name = naming.name_for(now, is_automatic);
31
+ const existing = (await list()).find((backup) => backup.name === name);
32
+ return existing ?? naming.describe(name, await store.take(name));
33
+ };
34
+ const keep_automatic = async (to_keep, interval_in_milliseconds = day_in_milliseconds, now = new Date()) => {
35
+ const newest = (await list()).find((backup) => backup.is_automatic);
36
+ if (!newest || now.getTime() - Date.parse(newest.taken_at) >= interval_in_milliseconds)
37
+ await create(true, now);
38
+ const automatic = (await list()).filter((backup) => backup.is_automatic);
39
+ for (const backup of automatic.slice(Math.max(0, to_keep)))
40
+ await remove(backup.name);
41
+ };
42
+ return { naming, list, create: (is_automatic) => create(is_automatic), remove, keep_automatic };
43
+ }
@@ -0,0 +1,9 @@
1
+ export declare function has_time(local_date: string): boolean;
2
+ export declare function parse_local(local_date: string): Date;
3
+ export declare function format_local(date: Date, with_time: boolean): string;
4
+ export declare function start_of_day(date: Date): Date;
5
+ export declare function days_between(first: Date, second: Date): number;
6
+ export declare function add_days(date: Date, days: number): Date;
7
+ export declare function add_months(date: Date, months: number): Date;
8
+ export declare function is_overdue(local_date: string, now?: Date): boolean;
9
+ export declare function relative_day_label(local_date: string, now?: Date, locale?: string): string;
package/dist/dates.js ADDED
@@ -0,0 +1,60 @@
1
+ // Local calendar dates written as "YYYY-MM-DD" or "YYYY-MM-DDTHH:MM", read and written in the device's own time zone.
2
+ const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
3
+ // ── Text and dates ───────────────────────────────────────────────────────────
4
+ export function has_time(local_date) {
5
+ return local_date.includes("T");
6
+ }
7
+ // A date without a time stands for midnight at the start of its day.
8
+ export function parse_local(local_date) {
9
+ const [day = "", time = "00:00"] = local_date.split("T");
10
+ const [year = 0, month = 1, day_of_month = 1] = day.split("-").map(Number);
11
+ const [hours = 0, minutes = 0] = time.split(":").map(Number);
12
+ return new Date(year, month - 1, day_of_month, hours, minutes);
13
+ }
14
+ export function format_local(date, with_time) {
15
+ const pad = (number) => String(number).padStart(2, "0");
16
+ const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
17
+ return with_time ? `${day}T${pad(date.getHours())}:${pad(date.getMinutes())}` : day;
18
+ }
19
+ // ── Arithmetic ───────────────────────────────────────────────────────────────
20
+ export function start_of_day(date) {
21
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
22
+ }
23
+ // Whole calendar days from the first date to the second, so a clock change in between still counts as one day.
24
+ export function days_between(first, second) {
25
+ return Math.round((start_of_day(second).getTime() - start_of_day(first).getTime()) / DAY_IN_MILLISECONDS);
26
+ }
27
+ export function add_days(date, days) {
28
+ const moved = new Date(date);
29
+ moved.setDate(moved.getDate() + days);
30
+ return moved;
31
+ }
32
+ // A day past the end of the target month falls back to its last day, so Jan 31 plus a month is Feb 28.
33
+ export function add_months(date, months) {
34
+ const last_day = new Date(date.getFullYear(), date.getMonth() + months + 1, 0).getDate();
35
+ return new Date(date.getFullYear(), date.getMonth() + months, Math.min(date.getDate(), last_day), date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds());
36
+ }
37
+ // ── Labels ───────────────────────────────────────────────────────────────────
38
+ // A date without a time is overdue only once its day has passed.
39
+ export function is_overdue(local_date, now = new Date()) {
40
+ const date = parse_local(local_date);
41
+ return has_time(local_date) ? date < now : date < start_of_day(now);
42
+ }
43
+ // Yesterday, Today or Tomorrow in the reader's language, else a short date with the year only when it is not this year, then the time if there is one.
44
+ export function relative_day_label(local_date, now = new Date(), locale) {
45
+ const date = parse_local(local_date);
46
+ const day_offset = days_between(now, date);
47
+ const day_label = Math.abs(day_offset) <= 1
48
+ ? capitalised(new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(day_offset, "day"), locale)
49
+ : date.toLocaleDateString(locale, {
50
+ month: "short",
51
+ day: "numeric",
52
+ year: date.getFullYear() === now.getFullYear() ? undefined : "numeric",
53
+ });
54
+ if (!has_time(local_date))
55
+ return day_label;
56
+ return `${day_label} ${date.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" })}`;
57
+ }
58
+ function capitalised(text, locale) {
59
+ return text.charAt(0).toLocaleUpperCase(locale) + text.slice(1);
60
+ }
@@ -1,3 +1,11 @@
1
+ declare const unit_names: readonly [readonly ["months", 2592000], readonly ["weeks", 604800], readonly ["days", 86400], readonly ["hours", 3600], readonly ["minutes", 60], readonly ["seconds", 1]];
2
+ export type DurationUnit = (typeof unit_names)[number][0];
3
+ export interface DurationWording {
4
+ separator?: string;
5
+ largest_unit?: DurationUnit;
6
+ }
1
7
  export declare function parse_duration(value: unknown, key: string): number;
2
- export declare function format_duration(seconds: number): string;
8
+ export declare function format_duration(seconds: number, wording?: DurationWording): string;
3
9
  export declare function format_age(seconds: number): string;
10
+ export declare function format_clock(milliseconds: number | null | undefined, empty?: string): string;
11
+ export {};
package/dist/durations.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Reads durations such as "2h30m" from settings and writes them back in words, for the server and the agent alike.
2
2
  import parse from "parse-duration";
3
- const unit_names = [["weeks", 604800], ["days", 86400], ["hours", 3600], ["minutes", 60], ["seconds", 1]];
3
+ // A month counts as 30 days.
4
+ const unit_names = [["months", 2592000], ["weeks", 604800], ["days", 86400], ["hours", 3600], ["minutes", 60], ["seconds", 1]];
4
5
  const words = new Intl.DurationFormat("en", { style: "long" });
5
6
  // A duration in whole seconds; `key` names the setting in the error.
6
7
  export function parse_duration(value, key) {
@@ -14,13 +15,14 @@ export function parse_duration(value, key) {
14
15
  }
15
16
  return Math.floor(seconds);
16
17
  }
17
- // The two largest whole units, such as "1 day, 2 hours".
18
- export function format_duration(seconds) {
18
+ // The two largest whole units, such as "1 day, 2 hours" or, with a space between, "1 week 2 days".
19
+ export function format_duration(seconds, wording = {}) {
19
20
  if (seconds < 1)
20
21
  return "0 seconds";
22
+ const first_unit = unit_names.findIndex(([name]) => name === (wording.largest_unit ?? "months"));
21
23
  const parts = {};
22
24
  let remaining = Math.floor(seconds);
23
- for (const [name, size] of unit_names) {
25
+ for (const [name, size] of unit_names.slice(first_unit)) {
24
26
  if (Object.keys(parts).length === 2)
25
27
  break;
26
28
  if (remaining < size)
@@ -28,8 +30,20 @@ export function format_duration(seconds) {
28
30
  parts[name] = Math.floor(remaining / size);
29
31
  remaining %= size;
30
32
  }
31
- return words.format(parts);
33
+ if (wording.separator === undefined)
34
+ return words.format(parts);
35
+ return Object.entries(parts).map(([name, count]) => words.format({ [name]: count })).join(wording.separator);
32
36
  }
33
37
  export function format_age(seconds) {
34
38
  return seconds < 0 ? "never" : `${format_duration(seconds)} ago`;
35
39
  }
40
+ // A playing time as a clock, such as "3:07", or "1:02:03" from an hour.
41
+ export function format_clock(milliseconds, empty = "0:00") {
42
+ if (milliseconds === null || milliseconds === undefined || !Number.isFinite(milliseconds))
43
+ return empty;
44
+ const total = Math.max(0, Math.round(milliseconds / 1000));
45
+ const hours = Math.floor(total / 3600);
46
+ const minutes = Math.floor((total % 3600) / 60);
47
+ const seconds = String(total % 60).padStart(2, "0");
48
+ return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
49
+ }
@@ -0,0 +1,13 @@
1
+ export interface AppLike {
2
+ requestSingleInstanceLock(): boolean;
3
+ quit(): void;
4
+ on(event: "second-instance", listener: (event: unknown, argv: string[]) => void): unknown;
5
+ }
6
+ export interface FocusableWindowLike {
7
+ isMinimized(): boolean;
8
+ restore(): void;
9
+ focus(): void;
10
+ }
11
+ export declare function hold_single_instance(app: AppLike, on_second_launch: (argv: string[]) => void): boolean;
12
+ export declare function bring_to_front(window: FocusableWindowLike): void;
13
+ export declare function first_argument<Value>(argv: readonly string[], parse: (argument: string) => Value | null): Value | null;
@@ -0,0 +1,27 @@
1
+ // The app's place on the desktop: one copy running at a time, its window brought forward, and links handed over on the command line.
2
+ // ── One copy ─────────────────────────────────────────────────────────────────
3
+ // Keeps one copy running: a second launch hands its command line to the first and quits; returns false in the copy that must quit.
4
+ export function hold_single_instance(app, on_second_launch) {
5
+ if (!app.requestSingleInstanceLock()) {
6
+ app.quit();
7
+ return false;
8
+ }
9
+ app.on("second-instance", (_event, argv) => on_second_launch(argv));
10
+ return true;
11
+ }
12
+ // Brings a window forward, restoring it first when minimised.
13
+ export function bring_to_front(window) {
14
+ if (window.isMinimized())
15
+ window.restore();
16
+ window.focus();
17
+ }
18
+ // ── Command line ─────────────────────────────────────────────────────────────
19
+ // The first command-line argument a parser accepts, such as a task link, or null.
20
+ export function first_argument(argv, parse) {
21
+ for (const argument of argv) {
22
+ const value = parse(argument);
23
+ if (value !== null)
24
+ return value;
25
+ }
26
+ return null;
27
+ }
@@ -0,0 +1,32 @@
1
+ export interface IpcMainLike {
2
+ handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
3
+ removeHandler(channel: string): void;
4
+ }
5
+ export interface IpcRendererLike {
6
+ invoke(channel: string, ...args: unknown[]): Promise<unknown>;
7
+ }
8
+ export interface ContextBridgeLike {
9
+ exposeInMainWorld(api_key: string, api: unknown): void;
10
+ }
11
+ type AnyMethod = (...args: never[]) => unknown;
12
+ export type Client<Methods extends Record<keyof Methods, AnyMethod>> = {
13
+ [Name in keyof Methods]: (...args: Parameters<Methods[Name]>) => Promise<Awaited<ReturnType<Methods[Name]>>>;
14
+ };
15
+ export type Implementation<Methods extends Record<keyof Methods, AnyMethod>> = {
16
+ [Name in keyof Methods]: (...args: Parameters<Methods[Name]>) => Awaited<ReturnType<Methods[Name]>> | Promise<Awaited<ReturnType<Methods[Name]>>>;
17
+ };
18
+ export type CallRemote = (method: string, args: unknown[]) => Promise<unknown>;
19
+ export interface Bridge<Methods extends Record<keyof Methods, AnyMethod>> {
20
+ api_name: string;
21
+ method_names: readonly (keyof Methods & string)[];
22
+ channel: (method: keyof Methods & string) => string;
23
+ expose: (context_bridge: ContextBridgeLike, ipc_renderer: IpcRendererLike) => void;
24
+ ipc_client: (ipc_renderer: IpcRendererLike) => Client<Methods>;
25
+ handle: (ipc_main: IpcMainLike, implementation: Implementation<Methods>) => () => void;
26
+ web_rpc: (call_remote: CallRemote) => Client<Methods>;
27
+ dispatch: (implementation: Implementation<Methods>, method: string, args: unknown[]) => Promise<unknown>;
28
+ }
29
+ export declare function define_bridge<Methods extends Record<keyof Methods, AnyMethod>>(api_name: string, method_names: readonly (keyof Methods & string)[]): Bridge<Methods>;
30
+ export declare function to_cloneable(value: unknown): unknown;
31
+ export declare function with_cloneable_arguments<Methods extends Record<keyof Methods, AnyMethod>>(client: Client<Methods>): Client<Methods>;
32
+ export {};
@@ -0,0 +1,53 @@
1
+ // One table of method names serves the Electron preload, the main process and a web page's calls to its server alike.
2
+ export function define_bridge(api_name, method_names) {
3
+ const channel = (method) => `${api_name}:${method}`;
4
+ const is_method = (method) => method_names.includes(method);
5
+ function client_through(call) {
6
+ return Object.fromEntries(method_names.map((method) => [method, (...args) => call(method, args)]));
7
+ }
8
+ async function dispatch(implementation, method, args) {
9
+ if (!is_method(method))
10
+ throw new Error(`${api_name} has no method ${method}`);
11
+ const answer = implementation[method];
12
+ return answer.apply(implementation, args);
13
+ }
14
+ return {
15
+ api_name,
16
+ method_names,
17
+ channel,
18
+ dispatch,
19
+ ipc_client: (ipc_renderer) => client_through((method, args) => ipc_renderer.invoke(channel(method), ...args)),
20
+ expose: (context_bridge, ipc_renderer) => {
21
+ context_bridge.exposeInMainWorld(api_name, client_through((method, args) => ipc_renderer.invoke(channel(method), ...args)));
22
+ },
23
+ handle: (ipc_main, implementation) => {
24
+ for (const method of method_names) {
25
+ ipc_main.handle(channel(method), (_event, ...args) => dispatch(implementation, method, args));
26
+ }
27
+ return () => { for (const method of method_names)
28
+ ipc_main.removeHandler(channel(method)); };
29
+ },
30
+ web_rpc: client_through,
31
+ };
32
+ }
33
+ // ── Cloning ──────────────────────────────────────────────────────────────────
34
+ // A value that survives structured clone passes as it is, and anything else, such as a Proxy, crosses as plain JSON.
35
+ export function to_cloneable(value) {
36
+ if (value === null || typeof value !== "object")
37
+ return value;
38
+ try {
39
+ structuredClone(value);
40
+ return value;
41
+ }
42
+ catch {
43
+ return JSON.parse(JSON.stringify(value));
44
+ }
45
+ }
46
+ // The same client with every argument made cloneable first, since Electron clones arguments before the preload sees them.
47
+ export function with_cloneable_arguments(client) {
48
+ const wrapped = {};
49
+ for (const [name, method] of Object.entries(client)) {
50
+ wrapped[name] = (...args) => method(...args.map(to_cloneable));
51
+ }
52
+ return wrapped;
53
+ }
@@ -0,0 +1,21 @@
1
+ import type { ContextBridgeLike } from "./bridge.ts";
2
+ export interface IpcRendererEventsLike {
3
+ on(channel: string, listener: (event: unknown, message: unknown) => void): unknown;
4
+ removeListener(channel: string, listener: (event: unknown, message: unknown) => void): unknown;
5
+ }
6
+ export interface WindowLike {
7
+ webContents: {
8
+ send(channel: string, message: unknown): void;
9
+ };
10
+ }
11
+ export interface EventsClient<Events extends Record<string, unknown>> {
12
+ on<Name extends keyof Events & string>(name: Name, handler: (payload: Events[Name]) => void): () => void;
13
+ }
14
+ export interface EventsBridge<Events extends Record<string, unknown>> {
15
+ api_name: string;
16
+ channel: string;
17
+ ipc_client: (ipc_renderer: IpcRendererEventsLike) => EventsClient<Events>;
18
+ expose: (context_bridge: ContextBridgeLike, ipc_renderer: IpcRendererEventsLike) => void;
19
+ emit: <Name extends keyof Events & string>(windows: Iterable<WindowLike>, name: Name, payload: Events[Name]) => void;
20
+ }
21
+ export declare function define_events<Events extends Record<string, unknown>>(api_name: string): EventsBridge<Events>;
@@ -0,0 +1,25 @@
1
+ // Messages the main process pushes to its pages, such as progress, on one channel and subscribed to by name.
2
+ export function define_events(api_name) {
3
+ const channel = `${api_name}:event`;
4
+ const ipc_client = (ipc_renderer) => ({
5
+ on: (name, handler) => {
6
+ const listener = (_event, message) => {
7
+ const { name: sent_name, payload } = message;
8
+ if (sent_name === name)
9
+ handler(payload);
10
+ };
11
+ ipc_renderer.on(channel, listener);
12
+ return () => void ipc_renderer.removeListener(channel, listener);
13
+ },
14
+ });
15
+ return {
16
+ api_name,
17
+ channel,
18
+ ipc_client,
19
+ expose: (context_bridge, ipc_renderer) => context_bridge.exposeInMainWorld(api_name, ipc_client(ipc_renderer)),
20
+ emit: (windows, name, payload) => {
21
+ for (const window of windows)
22
+ window.webContents.send(channel, { name, payload });
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,25 @@
1
+ export type HeaderList = [string, string][];
2
+ export interface FetchRequest {
3
+ url: string;
4
+ method: string;
5
+ headers: HeaderList;
6
+ body?: Uint8Array<ArrayBuffer>;
7
+ }
8
+ export interface FetchAnswer {
9
+ status: number;
10
+ status_text: string;
11
+ headers: HeaderList;
12
+ body: Uint8Array<ArrayBuffer> | null;
13
+ }
14
+ export type FetchMethod = (request: FetchRequest) => FetchAnswer;
15
+ export interface FetchPolicy {
16
+ hosts: readonly string[];
17
+ allow_http?: boolean;
18
+ }
19
+ export declare function is_allowed_url(url: string, policy: FetchPolicy): boolean;
20
+ export declare function create_main_fetch(policy: FetchPolicy, send?: typeof fetch): (request: FetchRequest) => Promise<FetchAnswer>;
21
+ export interface PageFetchOptions {
22
+ pinned_headers?: Record<string, string>;
23
+ }
24
+ export declare function to_response(answer: FetchAnswer): Response;
25
+ export declare function create_page_fetch(send: (request: FetchRequest) => Promise<FetchAnswer>, options?: PageFetchOptions): (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
@@ -0,0 +1,67 @@
1
+ // Fetching from Electron's main process for a page whose own requests the browser's cross-origin rules would block.
2
+ const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
3
+ export function is_allowed_url(url, policy) {
4
+ let parsed;
5
+ try {
6
+ parsed = new URL(url);
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ if (parsed.protocol !== "https:" && !(policy.allow_http && parsed.protocol === "http:"))
12
+ return false;
13
+ return policy.hosts.some((host) => host.startsWith("*.") ? parsed.hostname.endsWith(host.slice(1)) : parsed.host === host || parsed.hostname === host);
14
+ }
15
+ // The body arrives already unpacked, so the headers describing the packed body are dropped.
16
+ const DROPPED_ANSWER_HEADERS = new Set(["content-encoding", "content-length"]);
17
+ // The main process's half: sends the page's request with Node's fetch, refusing any URL outside the policy.
18
+ export function create_main_fetch(policy, send = fetch) {
19
+ return async (request) => {
20
+ if (!is_allowed_url(request.url, policy))
21
+ throw new Error(`fetching ${request.url} is not allowed`);
22
+ const response = await send(request.url, { method: request.method, headers: request.headers, body: request.body });
23
+ const headers = [];
24
+ response.headers.forEach((value, name) => {
25
+ if (!DROPPED_ANSWER_HEADERS.has(name))
26
+ headers.push([name, value]);
27
+ });
28
+ const has_body = !NULL_BODY_STATUSES.has(response.status) && request.method !== "HEAD";
29
+ return {
30
+ status: response.status,
31
+ status_text: response.statusText,
32
+ headers,
33
+ body: has_body ? new Uint8Array(await response.arrayBuffer()) : null,
34
+ };
35
+ };
36
+ }
37
+ function abort_error() {
38
+ return new DOMException("The request was aborted", "AbortError");
39
+ }
40
+ // Rejects once the signal aborts, though the main process still finishes the request.
41
+ function until_aborted(promise, signal) {
42
+ if (!signal)
43
+ return promise;
44
+ if (signal.aborted)
45
+ return Promise.reject(abort_error());
46
+ return new Promise((resolve, reject) => {
47
+ signal.addEventListener("abort", () => reject(abort_error()), { once: true });
48
+ promise.then(resolve, reject);
49
+ });
50
+ }
51
+ export function to_response(answer) {
52
+ const body = NULL_BODY_STATUSES.has(answer.status) ? null : answer.body;
53
+ return new Response(body, { status: answer.status, statusText: answer.status_text, headers: answer.headers });
54
+ }
55
+ // The page's half: a fetch that sends each request through the bridge method, for libraries that take a fetch.
56
+ export function create_page_fetch(send, options = {}) {
57
+ return async (input, init) => {
58
+ const request = new Request(input, init);
59
+ // A plain Headers keeps names a Request would drop, such as Origin and Referer.
60
+ const headers = new Headers(request.headers);
61
+ for (const [name, value] of Object.entries(options.pinned_headers ?? {}))
62
+ headers.set(name, value);
63
+ const body = request.method === "GET" || request.method === "HEAD" ? undefined : new Uint8Array(await request.arrayBuffer());
64
+ const answer = await until_aborted(send({ url: request.url, method: request.method, headers: [...headers.entries()], body }), init?.signal ?? request.signal);
65
+ return to_response(answer);
66
+ };
67
+ }