codex-work-receipt 0.3.0 → 0.5.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.
@@ -2,6 +2,8 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
+ import { rowDate } from "../lib/time.mjs";
6
+
5
7
  function walkJsonlFiles(directory, accumulator = []) {
6
8
  if (!fs.existsSync(directory)) return accumulator;
7
9
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
@@ -26,26 +28,90 @@ function readJsonl(filePath) {
26
28
  return rows;
27
29
  }
28
30
 
29
- export function loadCodexSessions(mode) {
31
+ function sessionFromFile(file) {
32
+ const rows = readJsonl(file.filePath);
33
+ const meta = rows.find((row) => row.type === "session_meta")?.payload || {};
34
+ const timestamps = rows.map(rowDate).filter(Boolean).sort((left, right) => left - right);
35
+ const fallbackDate = new Date(file.modifiedAt);
36
+ return {
37
+ rows,
38
+ filePath: file.filePath,
39
+ modifiedAt: file.modifiedAt,
40
+ sessionId: meta.session_id || meta.id || path.basename(file.filePath, ".jsonl"),
41
+ startAt: timestamps[0] || fallbackDate,
42
+ endAt: timestamps.at(-1) || fallbackDate,
43
+ };
44
+ }
45
+
46
+ function codexSessionFiles() {
30
47
  const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
31
48
  const sessionsDirectory = path.join(codexHome, "sessions");
32
49
  const files = walkJsonlFiles(sessionsDirectory)
33
50
  .map((filePath) => ({ filePath, modifiedAt: fs.statSync(filePath).mtimeMs }))
34
51
  .sort((left, right) => right.modifiedAt - left.modifiedAt);
35
-
36
52
  if (!files.length) throw new Error(`没有在 ${sessionsDirectory} 找到 Codex 会话记录`);
53
+ return files;
54
+ }
55
+
56
+ function calendarCandidates(files, startDate) {
57
+ if (!startDate) return files;
58
+ const approximateStart = Date.parse(`${startDate}T00:00:00.000Z`) - 48 * 60 * 60 * 1000;
59
+ return files.filter((file) => file.modifiedAt >= approximateStart);
60
+ }
61
+
62
+ export function loadCodexSessions(range) {
63
+ const files = codexSessionFiles();
64
+ let candidates = files;
37
65
 
38
- const selected = mode === "latest"
39
- ? files.slice(0, 1)
40
- : files.filter((file) => file.modifiedAt >= Date.now() - 72 * 60 * 60 * 1000);
66
+ if (range.scope === "latest") {
67
+ candidates = files.slice(0, 40);
68
+ } else if (range.scope === "session" && range.sessionId) {
69
+ const filenameMatches = files.filter((file) => path.basename(file.filePath).includes(range.sessionId));
70
+ candidates = filenameMatches.length ? filenameMatches : files;
71
+ } else {
72
+ candidates = calendarCandidates(files, range.startDate);
73
+ }
74
+
75
+ const sessions = candidates
76
+ .map(sessionFromFile)
77
+ .sort((left, right) => right.endAt - left.endAt);
78
+
79
+ if (range.scope === "latest") return sessions.slice(0, 1);
80
+ if (range.scope === "session") {
81
+ const selected = sessions.find((session) => session.sessionId === range.sessionId);
82
+ if (!selected) throw new Error(`没有找到指定的 Codex 会话:${range.sessionId}`);
83
+ return [selected];
84
+ }
85
+ return sessions;
86
+ }
87
+
88
+ export function listRecentCodexSessions(limit = 10) {
89
+ const sessions = codexSessionFiles()
90
+ .slice(0, Math.max(40, limit * 3))
91
+ .map(sessionFromFile)
92
+ .filter((session) => session.rows.length)
93
+ .sort((left, right) => right.endAt - left.endAt)
94
+ .slice(0, limit);
41
95
 
42
- return selected.map((file) => {
43
- const rows = readJsonl(file.filePath);
44
- const meta = rows.find((row) => row.type === "session_meta")?.payload || {};
96
+ return sessions.map((session) => {
97
+ let completedTurns = 0;
98
+ let toolCalls = 0;
99
+ let model = null;
100
+ for (const row of session.rows) {
101
+ if (row.type === "turn_context" && row.payload?.model) model = row.payload.model;
102
+ if (row.type === "event_msg" && row.payload?.type === "task_complete") completedTurns += 1;
103
+ if (
104
+ row.type === "response_item" &&
105
+ (row.payload?.type === "custom_tool_call" || row.payload?.type === "function_call")
106
+ ) toolCalls += 1;
107
+ }
45
108
  return {
46
- rows,
47
- sessionId: meta.session_id || meta.id || path.basename(file.filePath, ".jsonl"),
109
+ sessionId: session.sessionId,
110
+ startAt: session.startAt,
111
+ endAt: session.endAt,
112
+ completedTurns,
113
+ toolCalls,
114
+ model,
48
115
  };
49
116
  });
50
117
  }
51
-
@@ -1,6 +1,16 @@
1
+ import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+
1
4
  import { formatDate, formatDuration, formatNumber, formatTime } from "../lib/time.mjs";
2
5
  import { buildCompensation, DEFAULT_LOCALE, getReceiptCopy } from "../core/presentation.mjs";
3
6
 
7
+ const require = createRequire(import.meta.url);
8
+ const DOM_TO_IMAGE_SOURCE = fs.readFileSync(require.resolve("dom-to-image-more"), "utf8");
9
+
10
+ function inlineScript(value) {
11
+ return String(value).replaceAll("</script", "<\\/script");
12
+ }
13
+
4
14
  function escapeHtml(value) {
5
15
  return String(value)
6
16
  .replaceAll("&", "&amp;")
@@ -20,6 +30,12 @@ function formatCount(value, units, locale) {
20
30
  return `${formatNumber(amount, locale)} ${unit}`;
21
31
  }
22
32
 
33
+ function formatDateKey(value, locale) {
34
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || ""));
35
+ if (!match) return String(value || "");
36
+ return locale === "en" ? `${match[2]}/${match[3]}/${match[1]}` : `${match[1]}/${match[2]}/${match[3]}`;
37
+ }
38
+
23
39
  export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = null }) {
24
40
  const locale = record.locale || DEFAULT_LOCALE;
25
41
  const copy = getReceiptCopy(locale);
@@ -27,8 +43,18 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
27
43
  const endAt = new Date(record.period.end_at);
28
44
  const timezone = record.period.timezone;
29
45
  const displayDate = formatDate(endAt, timezone, locale);
30
- const businessHours = `${formatTime(startAt, timezone, locale)}—${formatTime(endAt, timezone, locale)}`;
46
+ const rangeStartDate = record.period.range_start_date || formatDateKey(record.period.start_at.slice(0, 10), "zh-CN").replaceAll("/", "-");
47
+ const rangeEndDate = record.period.range_end_date || formatDateKey(record.period.end_at.slice(0, 10), "zh-CN").replaceAll("/", "-");
48
+ const isCalendarScope = new Set(["today", "last-7-days", "this-week"]).has(record.source.scope);
49
+ const spansMultipleDates = rangeStartDate !== rangeEndDate;
31
50
  const scopeLabel = copy.scope[record.source.scope] || copy.scope.latest;
51
+ const dateRange = spansMultipleDates
52
+ ? `${formatDateKey(rangeStartDate, locale)}—${formatDateKey(rangeEndDate, locale)}`
53
+ : formatDateKey(rangeEndDate, locale);
54
+ const businessPeriod = isCalendarScope
55
+ ? `${dateRange} · ${scopeLabel}`
56
+ : `${formatTime(startAt, timezone, locale)}—${formatTime(endAt, timezone, locale)}`;
57
+ const businessPeriodLabel = isCalendarScope ? copy.meta.period : copy.meta.hours;
32
58
  const modelLabel = record.stats.models.length ? record.stats.models.join(" / ") : copy.modelMissing;
33
59
  const receiptNumber = `${record.id.slice(4, 12).toUpperCase()}-${String(record.stats.completed_turns).padStart(3, "0")}`;
34
60
  const compensation = record.presentation.compensation || buildCompensation(record.source.scope, 0, locale);
@@ -36,6 +62,13 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
36
62
  minimumFractionDigits: 1,
37
63
  maximumFractionDigits: 1,
38
64
  }).format(record.stats.average_first_token_ms / 1000);
65
+ const exportConfig = JSON.stringify({
66
+ idleLabel: copy.exportImage,
67
+ busyLabel: copy.exportingImage,
68
+ successLabel: copy.exportSuccess,
69
+ errorLabel: copy.exportError,
70
+ fileBase: `codex-work-receipt-${record.source.scope}-${spansMultipleDates ? `${rangeStartDate}-to-${rangeEndDate}` : `${rangeEndDate}-${record.id.slice(4, 12)}`}`,
71
+ }).replaceAll("<", "\\u003c");
39
72
  const rows = [
40
73
  receiptRow(copy.rows.scope, scopeLabel),
41
74
  receiptRow(copy.rows.sessions, formatCount(record.stats.session_count, copy.units.sessions, locale)),
@@ -63,39 +96,39 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
63
96
  :root {
64
97
  color-scheme: light;
65
98
  --ink: #171713;
66
- --paper: #fffef4;
67
- --desk: #d9d7cb;
68
- --desk-soft: #cfcdc1;
69
- --muted: #68675f;
70
- --line: rgba(23, 23, 19, .42);
71
- --accent: rgba(23, 23, 19, .055);
99
+ --paper: #ffffff;
100
+ --desk: #eeeae3;
101
+ --desk-soft: #f6f3ee;
102
+ --muted: #6d6c65;
103
+ --line: #aaa8a0;
104
+ --accent: #f3f2ed;
72
105
  --shadow: rgba(31, 30, 24, .18);
73
- --mark-bg: transparent;
74
- --mark-fg: var(--ink);
106
+ --mark-bg: #ffffff;
107
+ --mark-fg: #171713;
75
108
  }
76
109
  html[data-theme="diner"] {
77
- --ink: #7f1e18;
78
- --paper: #fff8e8;
79
- --desk: #d8a58e;
80
- --desk-soft: #f0d4bd;
81
- --muted: #9a4b3e;
82
- --line: rgba(127, 30, 24, .42);
83
- --accent: rgba(198, 54, 38, .08);
84
- --shadow: rgba(89, 34, 24, .2);
85
- --mark-bg: #bd2f23;
86
- --mark-fg: #fff8e8;
110
+ --ink: #a7475d;
111
+ --paper: #f7dde3;
112
+ --desk: #f2ece8;
113
+ --desk-soft: #faf6f3;
114
+ --muted: #b46b7b;
115
+ --line: #d29aa7;
116
+ --accent: #f0cdd6;
117
+ --shadow: rgba(116, 58, 72, .18);
118
+ --mark-bg: #f7dde3;
119
+ --mark-fg: #a7475d;
87
120
  }
88
121
  html[data-theme="payroll"] {
89
- --ink: #183d67;
90
- --paper: #f4f9ff;
91
- --desk: #aebdcb;
92
- --desk-soft: #dbe5ee;
93
- --muted: #53718f;
94
- --line: rgba(24, 61, 103, .38);
95
- --accent: rgba(40, 104, 166, .075);
96
- --shadow: rgba(28, 52, 79, .18);
97
- --mark-bg: #183d67;
98
- --mark-fg: #f4f9ff;
122
+ --ink: #ffe077;
123
+ --paper: #66742f;
124
+ --desk: #edebe3;
125
+ --desk-soft: #f7f5ef;
126
+ --muted: #e8cf74;
127
+ --line: #9aa35d;
128
+ --accent: #5c682b;
129
+ --shadow: rgba(48, 57, 19, .24);
130
+ --mark-bg: #66742f;
131
+ --mark-fg: #ffe077;
99
132
  }
100
133
  * { box-sizing: border-box; }
101
134
  body {
@@ -113,11 +146,17 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
113
146
  margin: 0 auto;
114
147
  padding: 30px 18px 54px;
115
148
  }
149
+ .toolbar {
150
+ display: grid;
151
+ justify-items: center;
152
+ gap: 12px;
153
+ margin-bottom: 22px;
154
+ }
116
155
  .theme-switcher {
117
156
  display: flex;
157
+ flex-wrap: wrap;
118
158
  justify-content: center;
119
159
  gap: 8px;
120
- margin-bottom: 22px;
121
160
  }
122
161
  .theme-button {
123
162
  appearance: none;
@@ -134,6 +173,35 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
134
173
  background: var(--ink);
135
174
  color: var(--paper);
136
175
  }
176
+ .export-actions {
177
+ display: grid;
178
+ justify-items: center;
179
+ gap: 6px;
180
+ }
181
+ .export-button {
182
+ appearance: none;
183
+ min-width: 168px;
184
+ border: 1px solid var(--ink);
185
+ border-radius: 999px;
186
+ background: var(--ink);
187
+ color: var(--paper);
188
+ cursor: pointer;
189
+ font: inherit;
190
+ font-size: 13px;
191
+ font-weight: 700;
192
+ padding: 10px 18px;
193
+ transition: opacity .15s ease, transform .15s ease;
194
+ }
195
+ .export-button:hover { transform: translateY(-1px); }
196
+ .export-button:disabled { cursor: wait; opacity: .62; transform: none; }
197
+ .export-status {
198
+ min-height: 16px;
199
+ color: var(--muted);
200
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
201
+ font-size: 11px;
202
+ }
203
+ .export-status[data-state="error"] { color: #b33a2e; }
204
+ .export-sheet { padding: 10px 0; }
137
205
  .paper {
138
206
  position: relative;
139
207
  background:
@@ -314,13 +382,20 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
314
382
  </head>
315
383
  <body>
316
384
  <main class="page">
317
- <nav class="theme-switcher" aria-label="${escapeHtml(copy.themeAria)}">
318
- <button class="theme-button" type="button" data-theme-value="classic">${escapeHtml(copy.themes.classic)}</button>
319
- <button class="theme-button" type="button" data-theme-value="diner">${escapeHtml(copy.themes.diner)}</button>
320
- <button class="theme-button" type="button" data-theme-value="payroll">${escapeHtml(copy.themes.payroll)}</button>
321
- </nav>
385
+ <div class="toolbar">
386
+ <nav class="theme-switcher" aria-label="${escapeHtml(copy.themeAria)}">
387
+ <button class="theme-button" type="button" data-theme-value="classic">${escapeHtml(copy.themes.classic)}</button>
388
+ <button class="theme-button" type="button" data-theme-value="diner">${escapeHtml(copy.themes.diner)}</button>
389
+ <button class="theme-button" type="button" data-theme-value="payroll">${escapeHtml(copy.themes.payroll)}</button>
390
+ </nav>
391
+ <div class="export-actions">
392
+ <button class="export-button" id="save-receipt-image" type="button">${escapeHtml(copy.exportImage)}</button>
393
+ <span class="export-status" id="export-status" role="status" aria-live="polite"></span>
394
+ </div>
395
+ </div>
322
396
 
323
- <article class="paper receipt" aria-label="${escapeHtml(copy.receiptAria)}">
397
+ <div class="export-sheet" id="receipt-export">
398
+ <article class="paper receipt" aria-label="${escapeHtml(copy.receiptAria)}">
324
399
  <header class="brand">
325
400
  <div class="brand-mark">C</div>
326
401
  <h1>${escapeHtml(copy.receiptTitle)}</h1>
@@ -329,7 +404,7 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
329
404
 
330
405
  <section class="meta">
331
406
  <div>${escapeHtml(copy.meta.date)}: ${escapeHtml(displayDate)}</div>
332
- <div>${escapeHtml(copy.meta.hours)}: ${escapeHtml(businessHours)}</div>
407
+ <div>${escapeHtml(businessPeriodLabel)}: ${escapeHtml(businessPeriod)}</div>
333
408
  <div>${escapeHtml(copy.meta.number)}: ${escapeHtml(receiptNumber)}</div>
334
409
  <div>${escapeHtml(copy.meta.timezone)}: ${escapeHtml(timezone)}</div>
335
410
  </section>
@@ -355,9 +430,9 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
355
430
  <div>MODEL · ${escapeHtml(modelLabel)}</div>
356
431
  <div>${escapeHtml(copy.footerThanks)}</div>
357
432
  </footer>
358
- </article>
433
+ </article>
359
434
 
360
- <section class="paper transfer-stub" aria-label="${escapeHtml(copy.transferAria)}">
435
+ <section class="paper transfer-stub" aria-label="${escapeHtml(copy.transferAria)}">
361
436
  <header class="transfer-heading">
362
437
  <h2>${escapeHtml(copy.transferTitle)}</h2>
363
438
  <p>${escapeHtml(copy.transferDescription)}</p>
@@ -375,11 +450,14 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
375
450
  </div>
376
451
  </div>
377
452
  <p class="transfer-note">${escapeHtml(copy.transferNote)}</p>
378
- </section>
453
+ </section>
454
+ </div>
379
455
 
380
456
  <p class="privacy">${escapeHtml(copy.privacy)}</p>
381
457
  </main>
458
+ <script>${inlineScript(DOM_TO_IMAGE_SOURCE)}</script>
382
459
  <script>
460
+ const exportConfig = ${exportConfig};
383
461
  const themes = new Set(["classic", "diner", "payroll"]);
384
462
  const buttons = [...document.querySelectorAll("[data-theme-value]")];
385
463
  function applyTheme(theme) {
@@ -392,6 +470,79 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
392
470
  let savedTheme = null;
393
471
  try { savedTheme = localStorage.getItem("codex-work-receipt-theme"); } catch {}
394
472
  applyTheme(savedTheme || document.documentElement.dataset.theme);
473
+
474
+ const exportButton = document.getElementById("save-receipt-image");
475
+ const exportStatus = document.getElementById("export-status");
476
+ const exportNode = document.getElementById("receipt-export");
477
+
478
+ function setExportStatus(message, state = "") {
479
+ exportStatus.textContent = message;
480
+ exportStatus.dataset.state = state;
481
+ }
482
+
483
+ function waitForImages(node) {
484
+ return Promise.all([...node.querySelectorAll("img")].map((image) => {
485
+ if (image.complete && image.naturalWidth > 0) return Promise.resolve();
486
+ return new Promise((resolve, reject) => {
487
+ image.addEventListener("load", resolve, { once: true });
488
+ image.addEventListener("error", reject, { once: true });
489
+ });
490
+ }));
491
+ }
492
+
493
+ function downloadImage(dataUrl, filename) {
494
+ const link = document.createElement("a");
495
+ link.href = dataUrl;
496
+ link.download = filename;
497
+ link.rel = "noopener";
498
+ document.body.appendChild(link);
499
+ if (typeof link.download === "string") link.click();
500
+ else window.open(dataUrl, "_blank", "noopener");
501
+ link.remove();
502
+ }
503
+
504
+ exportButton.addEventListener("click", async () => {
505
+ if (exportButton.disabled) return;
506
+ exportButton.disabled = true;
507
+ exportButton.textContent = exportConfig.busyLabel;
508
+ setExportStatus("");
509
+
510
+ try {
511
+ if (document.fonts?.ready) await document.fonts.ready;
512
+ await waitForImages(exportNode);
513
+ const width = Math.ceil(exportNode.scrollWidth);
514
+ const height = Math.ceil(exportNode.scrollHeight);
515
+ const scale = Math.max(2, Math.min(3, 1500 / Math.max(1, width)));
516
+ const paperColor = getComputedStyle(document.documentElement).getPropertyValue("--paper").trim() || "#ffffff";
517
+ const dataUrl = await domtoimage.toPng(exportNode, {
518
+ width,
519
+ height,
520
+ scale,
521
+ pixelRatio: 1,
522
+ bgcolor: paperColor,
523
+ cacheBust: false,
524
+ style: { background: paperColor },
525
+ onclone(clone) {
526
+ clone.style.background = paperColor;
527
+ clone.querySelectorAll(".paper").forEach((paper) => {
528
+ paper.style.boxShadow = "none";
529
+ });
530
+ },
531
+ logger: {},
532
+ });
533
+ const theme = themes.has(document.documentElement.dataset.theme)
534
+ ? document.documentElement.dataset.theme
535
+ : "classic";
536
+ downloadImage(dataUrl, exportConfig.fileBase + "-" + theme + ".png");
537
+ setExportStatus(exportConfig.successLabel, "success");
538
+ } catch (error) {
539
+ console.error(error);
540
+ setExportStatus(exportConfig.errorLabel, "error");
541
+ } finally {
542
+ exportButton.disabled = false;
543
+ exportButton.textContent = exportConfig.idleLabel;
544
+ }
545
+ });
395
546
  </script>
396
547
  </body>
397
548
  </html>`;
@@ -2,9 +2,8 @@
2
2
 
3
3
  首批内置主题:
4
4
 
5
- - `classic`:经典热敏小票
6
- - `diner`:红白餐馆结账单
7
- - `payroll`:蓝色办公室工资条
5
+ - `classic`:经典白票
6
+ - `diner`:复古粉票
7
+ - `payroll`:夜班绿票
8
8
 
9
9
  主题只改变 CSS 变量和少量装饰,不改变小票数据。
10
-