codex-work-receipt 0.4.0 → 0.6.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.
- package/CHANGELOG.md +19 -0
- package/README.en.md +6 -4
- package/README.md +6 -4
- package/docs/cli.en.md +7 -3
- package/docs/cli.md +7 -3
- package/docs/mobile-import.en.md +6 -4
- package/docs/mobile-import.md +6 -4
- package/docs/privacy.en.md +2 -0
- package/docs/privacy.md +2 -0
- package/package.json +2 -1
- package/src/cli.mjs +13 -10
- package/src/core/canonical.mjs +27 -0
- package/src/core/fact-buckets.mjs +205 -0
- package/src/core/fact-identity.mjs +39 -0
- package/src/core/metrics.mjs +14 -20
- package/src/core/presentation.mjs +14 -6
- package/src/core/qr-payload.mjs +208 -19
- package/src/core/range.mjs +24 -5
- package/src/core/receipt-record.mjs +37 -11
- package/src/core/source-revision.mjs +17 -0
- package/src/parsers/codex.mjs +27 -7
- package/src/renderers/html.mjs +193 -47
- package/templates/README.md +3 -4
package/src/renderers/html.mjs
CHANGED
|
@@ -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("&", "&")
|
|
@@ -26,7 +36,7 @@ function formatDateKey(value, locale) {
|
|
|
26
36
|
return locale === "en" ? `${match[2]}/${match[3]}/${match[1]}` : `${match[1]}/${match[2]}/${match[3]}`;
|
|
27
37
|
}
|
|
28
38
|
|
|
29
|
-
export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = null }) {
|
|
39
|
+
export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null, miniProgramCodeDataUrl = null }) {
|
|
30
40
|
const locale = record.locale || DEFAULT_LOCALE;
|
|
31
41
|
const copy = getReceiptCopy(locale);
|
|
32
42
|
const startAt = new Date(record.period.start_at);
|
|
@@ -35,12 +45,16 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
35
45
|
const displayDate = formatDate(endAt, timezone, locale);
|
|
36
46
|
const rangeStartDate = record.period.range_start_date || formatDateKey(record.period.start_at.slice(0, 10), "zh-CN").replaceAll("/", "-");
|
|
37
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);
|
|
38
49
|
const spansMultipleDates = rangeStartDate !== rangeEndDate;
|
|
39
|
-
const
|
|
50
|
+
const scopeLabel = copy.scope[record.source.scope] || copy.scope.latest;
|
|
51
|
+
const dateRange = spansMultipleDates
|
|
40
52
|
? `${formatDateKey(rangeStartDate, locale)}—${formatDateKey(rangeEndDate, locale)}`
|
|
53
|
+
: formatDateKey(rangeEndDate, locale);
|
|
54
|
+
const businessPeriod = isCalendarScope
|
|
55
|
+
? `${dateRange} · ${scopeLabel}`
|
|
41
56
|
: `${formatTime(startAt, timezone, locale)}—${formatTime(endAt, timezone, locale)}`;
|
|
42
|
-
const businessPeriodLabel =
|
|
43
|
-
const scopeLabel = copy.scope[record.source.scope] || copy.scope.latest;
|
|
57
|
+
const businessPeriodLabel = isCalendarScope ? copy.meta.period : copy.meta.hours;
|
|
44
58
|
const modelLabel = record.stats.models.length ? record.stats.models.join(" / ") : copy.modelMissing;
|
|
45
59
|
const receiptNumber = `${record.id.slice(4, 12).toUpperCase()}-${String(record.stats.completed_turns).padStart(3, "0")}`;
|
|
46
60
|
const compensation = record.presentation.compensation || buildCompensation(record.source.scope, 0, locale);
|
|
@@ -48,6 +62,13 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
48
62
|
minimumFractionDigits: 1,
|
|
49
63
|
maximumFractionDigits: 1,
|
|
50
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");
|
|
51
72
|
const rows = [
|
|
52
73
|
receiptRow(copy.rows.scope, scopeLabel),
|
|
53
74
|
receiptRow(copy.rows.sessions, formatCount(record.stats.session_count, copy.units.sessions, locale)),
|
|
@@ -64,6 +85,17 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
64
85
|
const miniProgramVisual = miniProgramCodeDataUrl
|
|
65
86
|
? `<img src="${miniProgramCodeDataUrl}" alt="${escapeHtml(copy.miniProgramAlt)}">`
|
|
66
87
|
: `<div class="mini-placeholder" role="img" aria-label="${escapeHtml(copy.placeholderAria)}"><span>${escapeHtml(copy.placeholderLabel)}</span><strong>${escapeHtml(copy.placeholderValue)}</strong></div>`;
|
|
88
|
+
const qrUrls = Array.isArray(dataQrDataUrls) && dataQrDataUrls.length
|
|
89
|
+
? dataQrDataUrls
|
|
90
|
+
: dataQrDataUrl
|
|
91
|
+
? [dataQrDataUrl]
|
|
92
|
+
: [];
|
|
93
|
+
const dataQrItems = qrUrls.map((url, index) => `
|
|
94
|
+
<div class="qr-item">
|
|
95
|
+
<div class="qr-frame"><img src="${url}" alt="${escapeHtml(copy.dataQrAlt)} ${index + 1}/${qrUrls.length}"></div>
|
|
96
|
+
<strong>${escapeHtml(copy.importData)}${qrUrls.length > 1 ? ` ${index + 1}/${qrUrls.length}` : ""}</strong>
|
|
97
|
+
<span>${escapeHtml(copy.importDataHint)}</span>
|
|
98
|
+
</div>`).join("");
|
|
67
99
|
|
|
68
100
|
return `<!doctype html>
|
|
69
101
|
<html lang="${escapeHtml(copy.htmlLang)}" data-theme="${escapeHtml(record.presentation.default_theme)}">
|
|
@@ -75,39 +107,39 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
75
107
|
:root {
|
|
76
108
|
color-scheme: light;
|
|
77
109
|
--ink: #171713;
|
|
78
|
-
--paper: #
|
|
79
|
-
--desk: #
|
|
80
|
-
--desk-soft: #
|
|
81
|
-
--muted: #
|
|
82
|
-
--line:
|
|
83
|
-
--accent:
|
|
110
|
+
--paper: #ffffff;
|
|
111
|
+
--desk: #eeeae3;
|
|
112
|
+
--desk-soft: #f6f3ee;
|
|
113
|
+
--muted: #6d6c65;
|
|
114
|
+
--line: #aaa8a0;
|
|
115
|
+
--accent: #f3f2ed;
|
|
84
116
|
--shadow: rgba(31, 30, 24, .18);
|
|
85
|
-
--mark-bg:
|
|
86
|
-
--mark-fg:
|
|
117
|
+
--mark-bg: #ffffff;
|
|
118
|
+
--mark-fg: #171713;
|
|
87
119
|
}
|
|
88
120
|
html[data-theme="diner"] {
|
|
89
|
-
--ink: #
|
|
90
|
-
--paper: #
|
|
91
|
-
--desk: #
|
|
92
|
-
--desk-soft: #
|
|
93
|
-
--muted: #
|
|
94
|
-
--line:
|
|
95
|
-
--accent:
|
|
96
|
-
--shadow: rgba(
|
|
97
|
-
--mark-bg: #
|
|
98
|
-
--mark-fg: #
|
|
121
|
+
--ink: #a7475d;
|
|
122
|
+
--paper: #f7dde3;
|
|
123
|
+
--desk: #f2ece8;
|
|
124
|
+
--desk-soft: #faf6f3;
|
|
125
|
+
--muted: #b46b7b;
|
|
126
|
+
--line: #d29aa7;
|
|
127
|
+
--accent: #f0cdd6;
|
|
128
|
+
--shadow: rgba(116, 58, 72, .18);
|
|
129
|
+
--mark-bg: #f7dde3;
|
|
130
|
+
--mark-fg: #a7475d;
|
|
99
131
|
}
|
|
100
132
|
html[data-theme="payroll"] {
|
|
101
|
-
--ink: #
|
|
102
|
-
--paper: #
|
|
103
|
-
--desk: #
|
|
104
|
-
--desk-soft: #
|
|
105
|
-
--muted: #
|
|
106
|
-
--line:
|
|
107
|
-
--accent:
|
|
108
|
-
--shadow: rgba(
|
|
109
|
-
--mark-bg: #
|
|
110
|
-
--mark-fg: #
|
|
133
|
+
--ink: #ffe077;
|
|
134
|
+
--paper: #66742f;
|
|
135
|
+
--desk: #edebe3;
|
|
136
|
+
--desk-soft: #f7f5ef;
|
|
137
|
+
--muted: #e8cf74;
|
|
138
|
+
--line: #9aa35d;
|
|
139
|
+
--accent: #5c682b;
|
|
140
|
+
--shadow: rgba(48, 57, 19, .24);
|
|
141
|
+
--mark-bg: #66742f;
|
|
142
|
+
--mark-fg: #ffe077;
|
|
111
143
|
}
|
|
112
144
|
* { box-sizing: border-box; }
|
|
113
145
|
body {
|
|
@@ -125,11 +157,17 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
125
157
|
margin: 0 auto;
|
|
126
158
|
padding: 30px 18px 54px;
|
|
127
159
|
}
|
|
160
|
+
.toolbar {
|
|
161
|
+
display: grid;
|
|
162
|
+
justify-items: center;
|
|
163
|
+
gap: 12px;
|
|
164
|
+
margin-bottom: 22px;
|
|
165
|
+
}
|
|
128
166
|
.theme-switcher {
|
|
129
167
|
display: flex;
|
|
168
|
+
flex-wrap: wrap;
|
|
130
169
|
justify-content: center;
|
|
131
170
|
gap: 8px;
|
|
132
|
-
margin-bottom: 22px;
|
|
133
171
|
}
|
|
134
172
|
.theme-button {
|
|
135
173
|
appearance: none;
|
|
@@ -146,6 +184,35 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
146
184
|
background: var(--ink);
|
|
147
185
|
color: var(--paper);
|
|
148
186
|
}
|
|
187
|
+
.export-actions {
|
|
188
|
+
display: grid;
|
|
189
|
+
justify-items: center;
|
|
190
|
+
gap: 6px;
|
|
191
|
+
}
|
|
192
|
+
.export-button {
|
|
193
|
+
appearance: none;
|
|
194
|
+
min-width: 168px;
|
|
195
|
+
border: 1px solid var(--ink);
|
|
196
|
+
border-radius: 999px;
|
|
197
|
+
background: var(--ink);
|
|
198
|
+
color: var(--paper);
|
|
199
|
+
cursor: pointer;
|
|
200
|
+
font: inherit;
|
|
201
|
+
font-size: 13px;
|
|
202
|
+
font-weight: 700;
|
|
203
|
+
padding: 10px 18px;
|
|
204
|
+
transition: opacity .15s ease, transform .15s ease;
|
|
205
|
+
}
|
|
206
|
+
.export-button:hover { transform: translateY(-1px); }
|
|
207
|
+
.export-button:disabled { cursor: wait; opacity: .62; transform: none; }
|
|
208
|
+
.export-status {
|
|
209
|
+
min-height: 16px;
|
|
210
|
+
color: var(--muted);
|
|
211
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
|
212
|
+
font-size: 11px;
|
|
213
|
+
}
|
|
214
|
+
.export-status[data-state="error"] { color: #b33a2e; }
|
|
215
|
+
.export-sheet { padding: 10px 0; }
|
|
149
216
|
.paper {
|
|
150
217
|
position: relative;
|
|
151
218
|
background:
|
|
@@ -326,13 +393,20 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
326
393
|
</head>
|
|
327
394
|
<body>
|
|
328
395
|
<main class="page">
|
|
329
|
-
<
|
|
330
|
-
<
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
396
|
+
<div class="toolbar">
|
|
397
|
+
<nav class="theme-switcher" aria-label="${escapeHtml(copy.themeAria)}">
|
|
398
|
+
<button class="theme-button" type="button" data-theme-value="classic">${escapeHtml(copy.themes.classic)}</button>
|
|
399
|
+
<button class="theme-button" type="button" data-theme-value="diner">${escapeHtml(copy.themes.diner)}</button>
|
|
400
|
+
<button class="theme-button" type="button" data-theme-value="payroll">${escapeHtml(copy.themes.payroll)}</button>
|
|
401
|
+
</nav>
|
|
402
|
+
<div class="export-actions">
|
|
403
|
+
<button class="export-button" id="save-receipt-image" type="button">${escapeHtml(copy.exportImage)}</button>
|
|
404
|
+
<span class="export-status" id="export-status" role="status" aria-live="polite"></span>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
334
407
|
|
|
335
|
-
<
|
|
408
|
+
<div class="export-sheet" id="receipt-export">
|
|
409
|
+
<article class="paper receipt" aria-label="${escapeHtml(copy.receiptAria)}">
|
|
336
410
|
<header class="brand">
|
|
337
411
|
<div class="brand-mark">C</div>
|
|
338
412
|
<h1>${escapeHtml(copy.receiptTitle)}</h1>
|
|
@@ -367,9 +441,9 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
367
441
|
<div>MODEL · ${escapeHtml(modelLabel)}</div>
|
|
368
442
|
<div>${escapeHtml(copy.footerThanks)}</div>
|
|
369
443
|
</footer>
|
|
370
|
-
|
|
444
|
+
</article>
|
|
371
445
|
|
|
372
|
-
|
|
446
|
+
<section class="paper transfer-stub" aria-label="${escapeHtml(copy.transferAria)}">
|
|
373
447
|
<header class="transfer-heading">
|
|
374
448
|
<h2>${escapeHtml(copy.transferTitle)}</h2>
|
|
375
449
|
<p>${escapeHtml(copy.transferDescription)}</p>
|
|
@@ -380,18 +454,17 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
380
454
|
<strong>${escapeHtml(copy.openMiniProgram)}</strong>
|
|
381
455
|
<span>${escapeHtml(copy.openMiniProgramHint)}</span>
|
|
382
456
|
</div>
|
|
383
|
-
|
|
384
|
-
<div class="qr-frame"><img src="${dataQrDataUrl}" alt="${escapeHtml(copy.dataQrAlt)}"></div>
|
|
385
|
-
<strong>${escapeHtml(copy.importData)}</strong>
|
|
386
|
-
<span>${escapeHtml(copy.importDataHint)}</span>
|
|
387
|
-
</div>
|
|
457
|
+
${dataQrItems}
|
|
388
458
|
</div>
|
|
389
459
|
<p class="transfer-note">${escapeHtml(copy.transferNote)}</p>
|
|
390
|
-
|
|
460
|
+
</section>
|
|
461
|
+
</div>
|
|
391
462
|
|
|
392
463
|
<p class="privacy">${escapeHtml(copy.privacy)}</p>
|
|
393
464
|
</main>
|
|
465
|
+
<script>${inlineScript(DOM_TO_IMAGE_SOURCE)}</script>
|
|
394
466
|
<script>
|
|
467
|
+
const exportConfig = ${exportConfig};
|
|
395
468
|
const themes = new Set(["classic", "diner", "payroll"]);
|
|
396
469
|
const buttons = [...document.querySelectorAll("[data-theme-value]")];
|
|
397
470
|
function applyTheme(theme) {
|
|
@@ -404,6 +477,79 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
|
|
|
404
477
|
let savedTheme = null;
|
|
405
478
|
try { savedTheme = localStorage.getItem("codex-work-receipt-theme"); } catch {}
|
|
406
479
|
applyTheme(savedTheme || document.documentElement.dataset.theme);
|
|
480
|
+
|
|
481
|
+
const exportButton = document.getElementById("save-receipt-image");
|
|
482
|
+
const exportStatus = document.getElementById("export-status");
|
|
483
|
+
const exportNode = document.getElementById("receipt-export");
|
|
484
|
+
|
|
485
|
+
function setExportStatus(message, state = "") {
|
|
486
|
+
exportStatus.textContent = message;
|
|
487
|
+
exportStatus.dataset.state = state;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function waitForImages(node) {
|
|
491
|
+
return Promise.all([...node.querySelectorAll("img")].map((image) => {
|
|
492
|
+
if (image.complete && image.naturalWidth > 0) return Promise.resolve();
|
|
493
|
+
return new Promise((resolve, reject) => {
|
|
494
|
+
image.addEventListener("load", resolve, { once: true });
|
|
495
|
+
image.addEventListener("error", reject, { once: true });
|
|
496
|
+
});
|
|
497
|
+
}));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function downloadImage(dataUrl, filename) {
|
|
501
|
+
const link = document.createElement("a");
|
|
502
|
+
link.href = dataUrl;
|
|
503
|
+
link.download = filename;
|
|
504
|
+
link.rel = "noopener";
|
|
505
|
+
document.body.appendChild(link);
|
|
506
|
+
if (typeof link.download === "string") link.click();
|
|
507
|
+
else window.open(dataUrl, "_blank", "noopener");
|
|
508
|
+
link.remove();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
exportButton.addEventListener("click", async () => {
|
|
512
|
+
if (exportButton.disabled) return;
|
|
513
|
+
exportButton.disabled = true;
|
|
514
|
+
exportButton.textContent = exportConfig.busyLabel;
|
|
515
|
+
setExportStatus("");
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
if (document.fonts?.ready) await document.fonts.ready;
|
|
519
|
+
await waitForImages(exportNode);
|
|
520
|
+
const width = Math.ceil(exportNode.scrollWidth);
|
|
521
|
+
const height = Math.ceil(exportNode.scrollHeight);
|
|
522
|
+
const scale = Math.max(2, Math.min(3, 1500 / Math.max(1, width)));
|
|
523
|
+
const paperColor = getComputedStyle(document.documentElement).getPropertyValue("--paper").trim() || "#ffffff";
|
|
524
|
+
const dataUrl = await domtoimage.toPng(exportNode, {
|
|
525
|
+
width,
|
|
526
|
+
height,
|
|
527
|
+
scale,
|
|
528
|
+
pixelRatio: 1,
|
|
529
|
+
bgcolor: paperColor,
|
|
530
|
+
cacheBust: false,
|
|
531
|
+
style: { background: paperColor },
|
|
532
|
+
onclone(clone) {
|
|
533
|
+
clone.style.background = paperColor;
|
|
534
|
+
clone.querySelectorAll(".paper").forEach((paper) => {
|
|
535
|
+
paper.style.boxShadow = "none";
|
|
536
|
+
});
|
|
537
|
+
},
|
|
538
|
+
logger: {},
|
|
539
|
+
});
|
|
540
|
+
const theme = themes.has(document.documentElement.dataset.theme)
|
|
541
|
+
? document.documentElement.dataset.theme
|
|
542
|
+
: "classic";
|
|
543
|
+
downloadImage(dataUrl, exportConfig.fileBase + "-" + theme + ".png");
|
|
544
|
+
setExportStatus(exportConfig.successLabel, "success");
|
|
545
|
+
} catch (error) {
|
|
546
|
+
console.error(error);
|
|
547
|
+
setExportStatus(exportConfig.errorLabel, "error");
|
|
548
|
+
} finally {
|
|
549
|
+
exportButton.disabled = false;
|
|
550
|
+
exportButton.textContent = exportConfig.idleLabel;
|
|
551
|
+
}
|
|
552
|
+
});
|
|
407
553
|
</script>
|
|
408
554
|
</body>
|
|
409
555
|
</html>`;
|