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.
- package/CHANGELOG.md +17 -0
- package/README.en.md +28 -111
- package/README.md +29 -189
- package/docs/cli.en.md +137 -0
- package/docs/cli.md +137 -0
- package/docs/codex-skill.en.md +45 -0
- package/docs/codex-skill.md +45 -0
- package/docs/data-schema.en.md +35 -0
- package/docs/data-schema.md +19 -9
- package/docs/images/social-preview-source.svg +84 -0
- package/docs/images/social-preview.png +0 -0
- package/docs/mobile-import.en.md +19 -0
- package/docs/mobile-import.md +13 -9
- package/docs/privacy.en.md +30 -0
- package/docs/privacy.md +26 -8
- package/package.json +2 -1
- package/skills/ai-work-receipt/SKILL.md +5 -2
- package/src/cli.mjs +23 -4
- package/src/core/args.mjs +35 -4
- package/src/core/metrics.mjs +37 -25
- package/src/core/presentation.mjs +36 -13
- package/src/core/qr-payload.mjs +10 -2
- package/src/core/range.mjs +118 -0
- package/src/core/receipt-record.mjs +10 -3
- package/src/core/selector.mjs +71 -0
- package/src/parsers/codex.mjs +77 -11
- package/src/renderers/html.mjs +191 -40
- package/templates/README.md +3 -4
package/src/parsers/codex.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
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("&", "&")
|
|
@@ -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
|
|
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: #
|
|
67
|
-
--desk: #
|
|
68
|
-
--desk-soft: #
|
|
69
|
-
--muted: #
|
|
70
|
-
--line:
|
|
71
|
-
--accent:
|
|
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:
|
|
74
|
-
--mark-fg:
|
|
106
|
+
--mark-bg: #ffffff;
|
|
107
|
+
--mark-fg: #171713;
|
|
75
108
|
}
|
|
76
109
|
html[data-theme="diner"] {
|
|
77
|
-
--ink: #
|
|
78
|
-
--paper: #
|
|
79
|
-
--desk: #
|
|
80
|
-
--desk-soft: #
|
|
81
|
-
--muted: #
|
|
82
|
-
--line:
|
|
83
|
-
--accent:
|
|
84
|
-
--shadow: rgba(
|
|
85
|
-
--mark-bg: #
|
|
86
|
-
--mark-fg: #
|
|
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: #
|
|
90
|
-
--paper: #
|
|
91
|
-
--desk: #
|
|
92
|
-
--desk-soft: #
|
|
93
|
-
--muted: #
|
|
94
|
-
--line:
|
|
95
|
-
--accent:
|
|
96
|
-
--shadow: rgba(
|
|
97
|
-
--mark-bg: #
|
|
98
|
-
--mark-fg: #
|
|
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
|
-
<
|
|
318
|
-
<
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
<
|
|
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(
|
|
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
|
-
|
|
433
|
+
</article>
|
|
359
434
|
|
|
360
|
-
|
|
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
|
-
|
|
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>`;
|