Skip to content

Commit 501008e

Browse files
committed
fix: re-use the action to export pdf to email
1 parent 1047632 commit 501008e

7 files changed

Lines changed: 123 additions & 228 deletions

File tree

.github/workflows/export.yml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ on:
1212
required: false
1313
type: boolean
1414
default: false
15+
sub_ids:
16+
description: "Optional comma-separated lecture sub_ids to filter (default: all summarized lectures)"
17+
required: false
18+
type: string
19+
default: ""
1520

1621
permissions:
1722
contents: read
@@ -80,9 +85,14 @@ jobs:
8085
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
8186
RECEIVER_EMAIL: ${{ secrets.RECEIVER_EMAIL }}
8287
EXPORT_COURSE_ID: ${{ inputs.course_id }}
88+
EXPORT_SUB_IDS: ${{ inputs.sub_ids }}
8389
run: |
90+
PDF_FLAG=""
8491
if [ "${{ inputs.export_pdf }}" = "true" ]; then
85-
python scripts/export_course.py --course-id "$EXPORT_COURSE_ID" --pdf
92+
PDF_FLAG="--pdf"
93+
fi
94+
if [ -n "$EXPORT_SUB_IDS" ]; then
95+
python scripts/export_course.py --course-id "$EXPORT_COURSE_ID" --sub-ids "$EXPORT_SUB_IDS" $PDF_FLAG
8696
else
87-
python scripts/export_course.py --course-id "$EXPORT_COURSE_ID"
97+
python scripts/export_course.py --course-id "$EXPORT_COURSE_ID" $PDF_FLAG
8898
fi

docs/frontend-readme.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,17 @@ https://<你的用户名>.github.io/<仓库名>/
2626
| **Expiration** | 建议 90 天(过期后可随时重新生成) |
2727
| **Repository access** | 选择 **Only select repositories** → 选中你 fork 的仓库 |
2828
| **Permissions → Repository permissions → Contents** | 选择 **Read and write** |
29+
| **Permissions → Repository permissions → Actions** | 选择 **Read and write** |
2930

3031
其余权限全部保持 **No access**。点击 **Generate token** 并复制保存。
3132

32-
> **为什么需要 Contents: Read and Write?**
33-
> - **Read**:从 `data` 分支拉取加密数据库
34-
> - **Write**:将你在网页编辑器中的修改推送回仓库
33+
> **为什么需要这两组权限?**
34+
> - **Contents: Read and Write**
35+
> - **Read**:从 `data` 分支拉取加密数据库
36+
> - **Write**:将你在网页编辑器中的修改推送回仓库
37+
> - **Actions: Read and Write**
38+
> - **Write**:触发 `Export Course Summaries` workflow(前端「导出」按钮使用)
39+
> - 没有这个权限,导出按钮会报 `403/404`
3540
3641
### 3. 首次配置
3742

@@ -55,7 +60,7 @@ https://<你的用户名>.github.io/<仓库名>/
5560
- **节次列表** — 单课程视图,显示状态标签(就绪 / 总结中 / 等待中 / 失败)
5661
- **摘要阅读** — 完整 Markdown 渲染,支持 LaTeX 公式(KaTeX)
5762
- **编辑** — 点击编辑按钮修改任意摘要,保存后自动推送到 GitHub
58-
- **课次摘要导出 PDF** — 在课程课次页点击「导出」,可勾选或全选后下载 PDF
63+
- **课次摘要导出 PDF** — 在课程课次页点击「导出」并勾选课次后,前端会通过 GitHub API 触发 `Export Course Summaries` workflow(基于 WeasyPrint),PDF 完成后会发送到 `RECEIVER_EMAIL` 邮箱(约 1-3 分钟)。需要 PAT 具备 **Actions: Write** 权限。
5964
- **搜索** — 全文搜索所有摘要内容
6065
- **移动端友好** — 底部标签栏导航,触控优化,响应式布局
6166

frontend/css/export.css

Lines changed: 0 additions & 103 deletions
This file was deleted.

frontend/index.html

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,6 @@
1313

1414
<!-- Custom CSS -->
1515
<link rel="stylesheet" href="css/app.css">
16-
<!-- Export-only styles (loaded via <link> so html2canvas inherits them
17-
reliably when cloning the document for PDF capture). -->
18-
<link rel="stylesheet" href="css/export.css">
1916
</head>
2017
<body class="bg-gray-50 min-h-[100dvh]" x-data="app()" x-cloak>
2118

@@ -519,6 +516,11 @@ <h3 class="font-semibold text-gray-800">导出课程摘要</h3>
519516
</p>
520517
</div>
521518

519+
<div class="px-4 py-2 bg-blue-50 border-t border-blue-100 text-xs text-blue-800 leading-relaxed">
520+
点击导出后,将通过 GitHub Actions 后台运行 WeasyPrint 生成 PDF,<br>
521+
并发送到你配置的 RECEIVER_EMAIL。预计耗时 1-3 分钟。
522+
</div>
523+
522524
<div class="px-4 py-3 border-t border-gray-100 flex justify-end gap-2">
523525
<button @click="closeExportDialog()"
524526
:disabled="exportingPdf"
@@ -528,7 +530,7 @@ <h3 class="font-semibold text-gray-800">导出课程摘要</h3>
528530
<button @click="exportSelectedToPdf()"
529531
:disabled="exportingPdf"
530532
class="px-4 py-2 text-sm rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50"
531-
x-text="exportingPdf ? '导出中…' : '导出 PDF'">
533+
x-text="exportingPdf ? '触发中…' : '触发后台导出'">
532534
</button>
533535
</div>
534536
</div>
@@ -549,8 +551,6 @@ <h3 class="font-semibold text-gray-800">导出课程摘要</h3>
549551
<script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
550552
<!-- DOMPurify (provides window.DOMPurify) -->
551553
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
552-
<!-- html2pdf.js (provides window.html2pdf) -->
553-
<script src="https://cdn.jsdelivr.net/npm/html2pdf.js@0.10.1/dist/html2pdf.bundle.min.js"></script>
554554
<!-- KaTeX + auto-render -->
555555
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.js"></script>
556556
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/contrib/auto-render.min.js"></script>

frontend/js/app.js

Lines changed: 25 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -113,32 +113,6 @@ function _highlightSnippet(text, query, radius) {
113113
return snip.replace(re, "<mark>$1</mark>");
114114
}
115115

116-
function _escapeHtml(s) {
117-
return String(s || "")
118-
.replace(/&/g, "&amp;")
119-
.replace(/</g, "&lt;")
120-
.replace(/>/g, "&gt;")
121-
.replace(/"/g, "&quot;")
122-
.replace(/'/g, "&#39;");
123-
}
124-
125-
function _getLectureDateString(dateText, processedAt) {
126-
if (dateText) return String(dateText);
127-
if (!processedAt) return "";
128-
const d = new Date(processedAt);
129-
if (Number.isNaN(d.getTime())) return String(processedAt);
130-
return d.toISOString().slice(0, 10);
131-
}
132-
133-
// A4 width in CSS px at 96 DPI: 210 * 96 / 25.4 ≈ 794. Drives both the
134-
// mount width and html2canvas windowWidth so layout in the capture iframe
135-
// matches what the user sees. Export styles live in frontend/css/export.css
136-
// (loaded via <link> so html2canvas inherits them when cloning the document).
137-
const _EXPORT_CANVAS_WIDTH = 794;
138-
// Stable id used by exportSelectedToPdf's onclone hook to find the mount in
139-
// the cloned document and restore its opacity for capture.
140-
const _EXPORT_MOUNT_ID = "ics-pdf-export-mount";
141-
142116
/* ── Alpine app ── */
143117
document.addEventListener("alpine:init", () => {
144118
Alpine.data("app", () => ({
@@ -271,102 +245,43 @@ document.addEventListener("alpine:init", () => {
271245
selectedExportCount() {
272246
return this.getExportableLectures().filter((lec) => this.exportSelection[lec.sub_id]).length;
273247
},
274-
_buildExportHtml(lectures) {
275-
const courseTitle = this.currentCourse?.title || "课程";
276-
const teacher = this.currentCourse?.teacher || "";
277-
const sections = lectures.map((lec) => {
278-
const dateText = _getLectureDateString(lec.date, lec.processed_at);
279-
const title = _escapeHtml(lec.sub_title || "Untitled");
280-
const subtitle = dateText ? `<small>(${_escapeHtml(dateText)})</small>` : "";
281-
const titleLine = subtitle ? `${title} ${subtitle}` : title;
282-
return `
283-
<h2>${titleLine}</h2>
284-
${ICS.render.renderMarkdown(lec.summary || "")}
285-
<hr>
286-
`;
287-
}).join("");
288-
// Mirrors scripts/export_course.py:_build_html — H1 + teacher line + hr,
289-
// then per-lecture H2 with date in <small>, markdown body, hr. Styling
290-
// comes from frontend/css/export.css (scoped under .ics-export-root).
291-
return `
292-
<div class="ics-export-root">
293-
<h1>${_escapeHtml(courseTitle)}</h1>
294-
<p>${teacher ? `任课教师:${_escapeHtml(teacher)}` : ""}</p>
295-
<hr>
296-
${sections}
297-
</div>
298-
`;
299-
},
300248
async exportSelectedToPdf() {
249+
// Triggers .github/workflows/export.yml via workflow_dispatch. The
250+
// workflow runs scripts/export_course.py (WeasyPrint) and emails the
251+
// PDF to RECEIVER_EMAIL — same output and same code path as a manual
252+
// run from the Actions UI. We dropped the in-browser html2pdf.js
253+
// approach because the screenshot-based pipeline produced blank PDFs
254+
// unreliably; routing through Actions reuses the working tech stack.
301255
if (this.exportingPdf) return;
302-
if (!window.html2pdf) {
303-
this._toast("PDF library failed to load", "error");
304-
return;
305-
}
306-
const selected = this.getExportableLectures().filter((lec) => this.exportSelection[lec.sub_id]);
256+
const selected = this.getExportableLectures().filter(
257+
(lec) => this.exportSelection[lec.sub_id]
258+
);
307259
if (!selected.length) {
308260
this._toast("Please select at least one lecture", "error");
309261
return;
310262
}
263+
const creds = _loadCreds();
264+
if (!creds?.token) {
265+
this._toast("Not authenticated", "error");
266+
return;
267+
}
311268
this.exportingPdf = true;
312-
let mount = null;
313269
try {
314-
// Mount on-screen at top-left so html2canvas measures real layout
315-
// (off-screen mounts have produced blank captures in past attempts),
316-
// but hide visually with opacity:0. The onclone hook below restores
317-
// opacity inside the capture clone so html2canvas paints content.
318-
mount = document.createElement("div");
319-
mount.id = _EXPORT_MOUNT_ID;
320-
mount.style.position = "fixed";
321-
mount.style.left = "0";
322-
mount.style.top = "0";
323-
mount.style.width = _EXPORT_CANVAS_WIDTH + "px";
324-
mount.style.opacity = "0";
325-
mount.style.pointerEvents = "none";
326-
mount.innerHTML = this._buildExportHtml(selected);
327-
document.body.appendChild(mount);
328-
const exportNode = mount.querySelector(".ics-export-root");
329-
if (!exportNode) throw new Error("Failed to build export content");
330-
ICS.render.activateKaTeX(exportNode);
331-
332-
// Let the browser lay out (and paint) the just-injected DOM before
333-
// html2canvas measures it. Two rAFs guarantee at least one paint.
334-
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
335-
336-
const fileBase = (this.currentCourse?.title?.trim() || "course_summaries")
337-
.replace(/[\\/:*?"<>|]+/g, "_");
338-
await window.html2pdf()
339-
.set({
340-
margin: [12, 10, 12, 10],
341-
filename: fileBase + "_summaries.pdf",
342-
image: { type: "jpeg", quality: 0.98 },
343-
html2canvas: {
344-
scale: 2,
345-
useCORS: true,
346-
windowWidth: _EXPORT_CANVAS_WIDTH,
347-
// The on-screen mount carries opacity:0 to stay invisible to
348-
// the user; restore it inside the cloned capture document so
349-
// the rendered canvas isn't transparent.
350-
onclone: (doc) => {
351-
const m = doc.getElementById(_EXPORT_MOUNT_ID);
352-
if (m) m.style.opacity = "1";
353-
},
354-
},
355-
jsPDF: { unit: "mm", format: "a4", orientation: "portrait" },
356-
// Default ["css", "legacy"] handles long markdown sections
357-
// reliably. "css"-only mode combined with break-after: avoid on
358-
// every heading produced multi-page blank PDFs (the algorithm
359-
// pushed each section forward when it hit a page boundary).
360-
pagebreak: { mode: ["css", "legacy"] },
361-
})
362-
.from(exportNode)
363-
.save();
270+
const subIds = selected.map((lec) => String(lec.sub_id)).join(",");
271+
// Workflow files live on the default branch (main). Surfaced as a
272+
// hardcoded "main" for now; expose as a setting if users rename it.
273+
await ICS.github.triggerExportWorkflow(
274+
this.repoOwner, this.repoName, "main", creds.token,
275+
this.currentCourse.course_id, true, subIds
276+
);
364277
this.exportDialogOpen = false;
365-
this._toast("PDF exported", "success");
278+
this._toast(
279+
"已触发后台导出,PDF 将在 1-3 分钟内发送到 RECEIVER_EMAIL",
280+
"success"
281+
);
366282
} catch (e) {
367283
this._toast(e?.message || "Export failed", "error");
368284
} finally {
369-
if (mount && mount.parentNode) mount.parentNode.removeChild(mount);
370285
this.exportingPdf = false;
371286
}
372287
},

0 commit comments

Comments
 (0)