@@ -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, "&" )
119- . replace ( / < / g, "<" )
120- . replace ( / > / g, ">" )
121- . replace ( / " / g, """ )
122- . replace ( / ' / g, "'" ) ;
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 ── */
143117document . 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