@@ -113,6 +113,23 @@ 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+
116133/* ── Alpine app ── */
117134document . addEventListener ( "alpine:init" , ( ) => {
118135 Alpine . data ( "app" , ( ) => ( {
@@ -127,6 +144,7 @@ document.addEventListener("alpine:init", () => {
127144 setup : { token : "" , stuid : "" , uispsw : "" , dashscope : "" , smtp : "" } ,
128145 setupError : "" , setupTesting : false ,
129146 settingsForm : { } , showSecrets : { } ,
147+ exportDialogOpen : false , exportSelection : { } , exportingPdf : false ,
130148 iterations : 10000 , repoOwner : "" , repoName : "" , dataBranch : "data" ,
131149 _history : [ ] ,
132150
@@ -205,6 +223,7 @@ document.addEventListener("alpine:init", () => {
205223 else if ( view === "detail" && params . subId ) { this . currentLecture = ICS . db . getLecture ( params . subId ) ; this . showTranscript = false ; }
206224 else if ( view === "edit" ) { this . editText = this . currentLecture ?. summary || "" ; this . editPreview = false ; }
207225 this . view = view ;
226+ if ( view !== "lectures" ) this . exportDialogOpen = false ;
208227 } ,
209228 goBack ( ) {
210229 const p = this . _history . pop ( ) ;
@@ -217,6 +236,119 @@ document.addEventListener("alpine:init", () => {
217236 startEdit ( ) { this . navigate ( "edit" ) ; } ,
218237 cancelEdit ( ) { this . goBack ( ) ; } ,
219238
239+ getExportableLectures ( ) {
240+ return ( this . lectures || [ ] ) . filter ( ( lec ) => lec . summary && lec . summary . trim ( ) ) ;
241+ } ,
242+ openExportDialog ( ) {
243+ const list = this . getExportableLectures ( ) ;
244+ if ( ! list . length ) { this . _toast ( "No summarized lectures to export" , "error" ) ; return ; }
245+ this . exportSelection = { } ;
246+ list . forEach ( ( lec ) => { this . exportSelection [ lec . sub_id ] = true ; } ) ;
247+ this . exportDialogOpen = true ;
248+ } ,
249+ closeExportDialog ( ) {
250+ if ( this . exportingPdf ) return ;
251+ this . exportDialogOpen = false ;
252+ } ,
253+ isLectureSelected ( subId ) { return ! ! this . exportSelection [ subId ] ; } ,
254+ toggleLectureSelection ( subId , checked ) { this . exportSelection [ subId ] = ! ! checked ; } ,
255+ setExportAll ( checked ) {
256+ this . getExportableLectures ( ) . forEach ( ( lec ) => { this . exportSelection [ lec . sub_id ] = ! ! checked ; } ) ;
257+ } ,
258+ isExportAllSelected ( ) {
259+ const list = this . getExportableLectures ( ) ;
260+ return list . length > 0 && list . every ( ( lec ) => this . exportSelection [ lec . sub_id ] ) ;
261+ } ,
262+ selectedExportCount ( ) {
263+ return this . getExportableLectures ( ) . filter ( ( lec ) => this . exportSelection [ lec . sub_id ] ) . length ;
264+ } ,
265+ _buildExportHtml ( lectures ) {
266+ const courseTitle = this . currentCourse ?. title || "课程" ;
267+ const teacher = this . currentCourse ?. teacher || "" ;
268+ const sections = lectures . map ( ( lec ) => {
269+ const dateText = _getLectureDateString ( lec . date , lec . processed_at ) ;
270+ const title = _escapeHtml ( lec . sub_title || "Untitled" ) ;
271+ const subtitle = dateText ? `<small>(${ _escapeHtml ( dateText ) } )</small>` : "" ;
272+ const titleLine = subtitle ? `${ title } ${ subtitle } ` : title ;
273+ return `
274+ <section>
275+ <h2>${ titleLine } </h2>
276+ ${ ICS . render . renderMarkdown ( lec . summary || "" ) }
277+ </section>
278+ <hr>
279+ ` ;
280+ } ) . join ( "" ) ;
281+ return `
282+ <!DOCTYPE html>
283+ <html lang="zh-CN">
284+ <head>
285+ <meta charset="UTF-8">
286+ <style>
287+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif; color: #111827; }
288+ h1 { font-size: 24px; margin: 0; }
289+ .meta { color: #6b7280; margin: 8px 0 16px; font-size: 14px; }
290+ section { page-break-inside: avoid; }
291+ h2 { font-size: 18px; margin: 14px 0 10px; }
292+ h2 small { font-size: 12px; font-weight: 400; color: #6b7280; margin-left: 6px; }
293+ hr { border: none; border-top: 1px solid #e5e7eb; margin: 12px 0; }
294+ p { line-height: 1.7; }
295+ pre { white-space: pre-wrap; overflow-wrap: break-word; word-wrap: break-word; }
296+ img { max-width: 100%; }
297+ </style>
298+ </head>
299+ <body>
300+ <h1>${ _escapeHtml ( courseTitle ) } </h1>
301+ <p class="meta">${ teacher ? `任课教师:${ _escapeHtml ( teacher ) } ` : "" } </p>
302+ <hr>
303+ ${ sections }
304+ </body>
305+ </html>
306+ ` ;
307+ } ,
308+ async exportSelectedToPdf ( ) {
309+ if ( this . exportingPdf ) return ;
310+ if ( ! window . html2pdf ) {
311+ this . _toast ( "PDF library failed to load" , "error" ) ;
312+ return ;
313+ }
314+ const selected = this . getExportableLectures ( ) . filter ( ( lec ) => this . exportSelection [ lec . sub_id ] ) ;
315+ if ( ! selected . length ) {
316+ this . _toast ( "Please select at least one lecture" , "error" ) ;
317+ return ;
318+ }
319+ this . exportingPdf = true ;
320+ let mount = null ;
321+ try {
322+ mount = document . createElement ( "div" ) ;
323+ mount . style . position = "fixed" ;
324+ mount . style . left = "-10000px" ;
325+ mount . style . top = "0" ;
326+ mount . style . width = "794px" ;
327+ mount . innerHTML = this . _buildExportHtml ( selected ) ;
328+ document . body . appendChild ( mount ) ;
329+ const fileBase = ( this . currentCourse ?. title ?. trim ( ) || "course_summaries" )
330+ . replace ( / [ \\ / : * ? " < > | ] + / g, "_" ) ;
331+ await window . html2pdf ( )
332+ . set ( {
333+ margin : [ 12 , 10 , 12 , 10 ] ,
334+ filename : fileBase + "_summaries.pdf" ,
335+ image : { type : "jpeg" , quality : 0.98 } ,
336+ html2canvas : { scale : 2 , useCORS : true } ,
337+ jsPDF : { unit : "mm" , format : "a4" , orientation : "portrait" } ,
338+ pagebreak : { mode : [ "css" , "legacy" ] } ,
339+ } )
340+ . from ( mount . firstElementChild || mount )
341+ . save ( ) ;
342+ this . exportDialogOpen = false ;
343+ this . _toast ( "PDF exported" , "success" ) ;
344+ } catch ( e ) {
345+ this . _toast ( e ?. message || "Export failed" , "error" ) ;
346+ } finally {
347+ if ( mount && mount . parentNode ) mount . parentNode . removeChild ( mount ) ;
348+ this . exportingPdf = false ;
349+ }
350+ } ,
351+
220352 async saveEdit ( ) {
221353 if ( this . saving ) return ;
222354 this . saving = true ;
0 commit comments