@@ -631,49 +631,143 @@ async function fillTranscribeInfo() {
631631}
632632
633633// ── Page: Align ────────────────────────────────────────────────────────────────
634+
635+ // Align page state
636+ const AlignState = { rows : [ ] , slideOptions : [ ] , courseId : '' } ;
637+
634638function buildAlign ( ) {
635639 return `
636640 ${ sectionTitle ( 'Align Transcripts to Slides' , '' ) }
637641 <div id="align-info-card"></div>
638642 ${ mkCard ( `
639- <div class="card-title">Auto-discover (whole course)</div>
640- <div class="card-sub">Pairs all unaligned captions with matching slide files.</div>
641- <div class="row">
643+ <div class="card-title" style="display:flex;align-items:center;gap:8px">
644+ ${ I . link } Video ↔ Slide Matching
645+ </div>
646+ <div class="card-sub">
647+ Match each video to its lecture slides. Auto-suggested matches are pre-filled.
648+ Click <strong>+</strong> to add multiple slide files, or <strong>×</strong> to exclude a video.
649+ </div>
650+ <div class="row" style="margin-top:8px">
642651 <div class="col expand">
643652 <span class="label">Course</span>
644653 <select id="align-course" class="select-ctrl">${ courseOptions ( ) } </select>
645654 </div>
646- <div class="col expand">
647- <span class="label">Output directory (blank = auto)</span>
648- <input id="align-outdir" class="input-text" type="text" placeholder="blank = [course]/alignment/">
649- </div>
655+ <button class="btn-primary" id="align-scan-btn">${ I . search || '🔍' } Scan videos & slides</button>
650656 </div>
651- <div class="row" style="margin-top:8px">
652- <button class="btn-primary" id="align-auto-btn">${ I . play } Run auto-align</button>
653- </div>
654- ` ) }
655- ${ mkCard ( `
656- <div class="card-title">Manual (specific files)</div>
657- <div class="card-sub">Align one caption JSON to one or more slide files.</div>
658- <div class="field">
659- <label class="label">Caption JSON path</label>
660- <input id="align-caption" class="input-text" type="text">
661- </div>
662- <div class="field">
663- <label class="label">Slide file(s) (space-separated for multi-part)</label>
664- <input id="align-slides" class="input-text" type="text">
665- </div>
666- <div class="row end" style="margin-top:8px">
667- <div class="col expand">
668- <label class="label">Output directory (blank = auto)</label>
669- <input id="align-manual-out" class="input-text" type="text">
670- </div>
671- <button class="btn-primary" id="align-manual-btn">${ I . play } Align</button>
657+ <div id="align-match-status" style="font-size:11px;color:var(--c-white-45);margin:4px 0 6px 0"></div>
658+ <div id="align-match-rows"></div>
659+ <div class="row" style="margin-top:12px;gap:12px">
660+ <button class="btn-primary" id="align-run-btn">${ I . play } Align with mapping</button>
661+ <button class="btn-outline" id="align-auto-btn">Auto-align (skip matching)</button>
672662 </div>
673663 ` ) }
674664 ` ;
675665}
676666
667+ function _alignSlideOptionsHtml ( selected = '(none)' ) {
668+ let html = '<option value="(none)">(none — auto-detect)</option>' ;
669+ for ( const s of AlignState . slideOptions ) {
670+ const sel = s . rel === selected ? ' selected' : '' ;
671+ html += `<option value="${ esc ( s . rel ) } "${ sel } >${ esc ( s . rel ) } </option>` ;
672+ }
673+ return html ;
674+ }
675+
676+ function _alignAutoSuggest ( capStem ) {
677+ // Simple heuristic: week number → lecture number, or token overlap
678+ const capLower = capStem . toLowerCase ( ) . replace ( / [ - _ ] / g, ' ' ) ;
679+ const weekMatch = capLower . match ( / w e e k \s * ( \d + ) / ) ;
680+ const lecMatch = capLower . match ( / l e c (?: t u r e ) ? \s * ( \d + ) / ) ;
681+ const capNum = weekMatch ? weekMatch [ 1 ] : ( lecMatch ? lecMatch [ 1 ] : null ) ;
682+
683+ let bestScore = 0 , bestRel = '' ;
684+ for ( const s of AlignState . slideOptions ) {
685+ const sl = s . name . toLowerCase ( ) . replace ( / [ - _ ] / g, ' ' ) ;
686+ // Lecture number match
687+ const slNum = sl . match ( / l (?: e c t u r e ) ? \s * ( \d + ) / i) ;
688+ if ( capNum && slNum && capNum === slNum [ 1 ] ) return s . rel ;
689+ // Token overlap
690+ const capTokens = new Set ( capLower . split ( / \s + / ) ) ;
691+ const slTokens = new Set ( sl . split ( / \s + / ) ) ;
692+ const inter = [ ...capTokens ] . filter ( t => slTokens . has ( t ) ) . length ;
693+ const union = new Set ( [ ...capTokens , ...slTokens ] ) . size ;
694+ const score = union ? inter / union : 0 ;
695+ if ( score > bestScore ) { bestScore = score ; bestRel = s . rel ; }
696+ }
697+ return bestScore > 0.05 ? bestRel : '(none)' ;
698+ }
699+
700+ function _alignRebuildRows ( ) {
701+ const container = document . getElementById ( 'align-match-rows' ) ;
702+ if ( ! container ) return ;
703+
704+ if ( ! AlignState . rows . length ) { container . innerHTML = '' ; return ; }
705+
706+ let html = `<div style="display:flex;gap:6px;padding:2px 0;opacity:0.4;font-size:10px;font-weight:600">
707+ <span style="width:28px"></span><span style="width:14px"></span>
708+ <span style="width:220px">Video</span><span style="width:16px"></span>
709+ <span style="flex:1">Lecture slides</span></div>` ;
710+
711+ AlignState . rows . forEach ( ( row , ri ) => {
712+ const statusIcon = row . aligned
713+ ? '<span style="color:var(--c-success)" title="Already aligned">●</span>'
714+ : '<span style="opacity:0.3" title="Not yet aligned">○</span>' ;
715+
716+ let slidesHtml = '' ;
717+ row . slides . forEach ( ( sel , si ) => {
718+ const actionBtn = si === 0
719+ ? `<button class="icon-btn" title="Add slide file" data-action="add" data-row="${ ri } ">+</button>`
720+ : `<button class="icon-btn" title="Remove" data-action="remove-slide" data-row="${ ri } " data-slide="${ si } " style="color:var(--c-error)">−</button>` ;
721+ slidesHtml += `<div style="display:flex;align-items:center;gap:2px;margin-bottom:2px">
722+ <select class="select-ctrl" style="flex:1;font-size:11px" data-row="${ ri } " data-slide="${ si } ">${ _alignSlideOptionsHtml ( sel ) } </select>
723+ ${ actionBtn } </div>` ;
724+ } ) ;
725+
726+ html += `<div style="display:flex;align-items:flex-start;gap:6px;padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.06)">
727+ <button class="icon-btn" title="Remove video" data-action="remove" data-row="${ ri } " style="color:var(--c-error);font-size:14px">×</button>
728+ <span style="width:14px;padding-top:4px">${ statusIcon } </span>
729+ <span style="width:220px;padding-top:4px;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${ esc ( row . title ) } ">${ esc ( row . title ) } </span>
730+ <span style="padding-top:4px;opacity:0.3">→</span>
731+ <div style="flex:1">${ slidesHtml } </div>
732+ </div>` ;
733+ } ) ;
734+
735+ container . innerHTML = html ;
736+
737+ // Update status
738+ const nMatched = AlignState . rows . filter ( r => r . slides . some ( s => s !== '(none)' ) ) . length ;
739+ const statusEl = document . getElementById ( 'align-match-status' ) ;
740+ if ( statusEl ) statusEl . textContent = `${ AlignState . rows . length } video(s), ${ AlignState . slideOptions . length } slide file(s). ${ nMatched } matched.` ;
741+
742+ // Bind events via delegation
743+ container . onclick = ( e ) => {
744+ const btn = e . target . closest ( '[data-action]' ) ;
745+ if ( ! btn ) return ;
746+ const action = btn . dataset . action ;
747+ const ri = parseInt ( btn . dataset . row ) ;
748+ if ( action === 'remove' ) {
749+ AlignState . rows . splice ( ri , 1 ) ;
750+ _alignRebuildRows ( ) ;
751+ } else if ( action === 'add' ) {
752+ AlignState . rows [ ri ] . slides . push ( '(none)' ) ;
753+ _alignRebuildRows ( ) ;
754+ } else if ( action === 'remove-slide' ) {
755+ const si = parseInt ( btn . dataset . slide ) ;
756+ if ( AlignState . rows [ ri ] . slides . length > 1 ) {
757+ AlignState . rows [ ri ] . slides . splice ( si , 1 ) ;
758+ _alignRebuildRows ( ) ;
759+ }
760+ }
761+ } ;
762+ container . onchange = ( e ) => {
763+ const sel = e . target . closest ( 'select[data-row]' ) ;
764+ if ( ! sel ) return ;
765+ const ri = parseInt ( sel . dataset . row ) ;
766+ const si = parseInt ( sel . dataset . slide ) ;
767+ AlignState . rows [ ri ] . slides [ si ] = sel . value ;
768+ } ;
769+ }
770+
677771async function fillAlignInfo ( ) {
678772 const [ model , ctx ] = await Promise . all ( [
679773 window . api . getConstant ( 'align' , 'EMBED_MODEL' ) ,
@@ -682,8 +776,7 @@ async function fillAlignInfo() {
682776 const el = document . getElementById ( 'align-info-card' ) ;
683777 if ( el ) {
684778 el . innerHTML = mkCard ( `<div class="info-row">${ I . info }
685- <span>Embed model: <strong>${ esc ( model ) } </strong> Context: ±<strong>${ esc ( ctx ) } s</strong>
686- — Three-strategy matching: lecture number → token overlap → content embedding.</span></div>` ) ;
779+ <span>Embed: <strong>${ esc ( model ) } </strong> Context: ±<strong>${ esc ( ctx ) } s</strong></span></div>` ) ;
687780 }
688781}
689782
@@ -1356,26 +1449,64 @@ async function attachPageHandlers() {
13561449 // ── Align ─────────────────────────────────────────────────────────────────────
13571450 if ( pg === 4 ) {
13581451 fillAlignInfo ( ) ;
1452+
1453+ // Scan button — populate the matching rows
1454+ document . getElementById ( 'align-scan-btn' ) ?. addEventListener ( 'click' , async ( ) => {
1455+ const cid = document . getElementById ( 'align-course' ) ?. value ;
1456+ if ( ! cid ) { snack ( 'Select a course first.' , false ) ; return ; }
1457+ AlignState . courseId = cid ;
1458+
1459+ const data = await window . api . alignScan ( cid ) ;
1460+ AlignState . slideOptions = data . slides ;
1461+
1462+ AlignState . rows = data . captions . map ( cap => {
1463+ // Determine title
1464+ const title = data . titles [ cap . stem ] || cap . stem ;
1465+ // Pick initial slides: existing mapping > auto-suggest
1466+ let initSlides ;
1467+ if ( data . mapping [ cap . stem ] && data . mapping [ cap . stem ] . length ) {
1468+ initSlides = data . mapping [ cap . stem ] ;
1469+ } else {
1470+ const suggested = _alignAutoSuggest ( cap . stem ) ;
1471+ initSlides = suggested !== '(none)' ? [ suggested ] : [ '(none)' ] ;
1472+ }
1473+ return { stem : cap . stem , title, aligned : cap . aligned , slides : initSlides } ;
1474+ } ) ;
1475+
1476+ _alignRebuildRows ( ) ;
1477+ snack ( `Found ${ data . captions . length } video(s), ${ data . slides . length } slide file(s).` ) ;
1478+ } ) ;
1479+
1480+ // Align with mapping button
1481+ document . getElementById ( 'align-run-btn' ) ?. addEventListener ( 'click' , async ( ) => {
1482+ const cid = AlignState . courseId || document . getElementById ( 'align-course' ) ?. value ;
1483+ if ( ! cid ) { snack ( 'Select a course first.' , false ) ; return ; }
1484+ if ( ! AlignState . rows . length ) { snack ( 'Click "Scan" first.' , false ) ; return ; }
1485+
1486+ // Build mapping from current state
1487+ const mapping = { } ;
1488+ for ( const row of AlignState . rows ) {
1489+ const vals = row . slides . filter ( s => s && s !== '(none)' ) ;
1490+ if ( vals . length ) mapping [ row . stem ] = vals ;
1491+ }
1492+
1493+ // Save mapping
1494+ const mapFile = await window . api . alignSaveMapping ( cid , mapping ) ;
1495+
1496+ const python = await window . api . getPythonPath ( ) ;
1497+ const paths = await window . api . getScriptsPaths ( ) ;
1498+ const cmd = [ python , paths . align , '--course' , cid , '--mapping' , mapFile ] ;
1499+ runCmd ( cmd , 'align --course ' + cid + ' --mapping …' ) ;
1500+ } ) ;
1501+
1502+ // Auto-align (skip matching) button
13591503 document . getElementById ( 'align-auto-btn' ) ?. addEventListener ( 'click' , async ( ) => {
1360- const cid = document . getElementById ( 'align-course' ) ?. value ;
1504+ const cid = document . getElementById ( 'align-course' ) ?. value ;
13611505 if ( ! cid ) { snack ( 'Select a course first.' , false ) ; return ; }
13621506 const python = await window . api . getPythonPath ( ) ;
13631507 const paths = await window . api . getScriptsPaths ( ) ;
1364- const outDir = document . getElementById ( 'align-outdir' ) ?. value . trim ( ) ;
13651508 const cmd = [ python , paths . align , '--course' , cid ] ;
1366- if ( outDir ) cmd . push ( '--out' , outDir ) ;
1367- runCmd ( cmd , 'semantic_alignment.py --course ' + cid ) ;
1368- } ) ;
1369- document . getElementById ( 'align-manual-btn' ) ?. addEventListener ( 'click' , async ( ) => {
1370- const caption = document . getElementById ( 'align-caption' ) ?. value . trim ( ) ;
1371- const slides = document . getElementById ( 'align-slides' ) ?. value . trim ( ) ;
1372- if ( ! caption || ! slides ) { snack ( 'Caption and slide path(s) are required.' , false ) ; return ; }
1373- const python = await window . api . getPythonPath ( ) ;
1374- const paths = await window . api . getScriptsPaths ( ) ;
1375- const outDir = document . getElementById ( 'align-manual-out' ) ?. value . trim ( ) ;
1376- const cmd = [ python , paths . align , '--caption' , caption , '--slides' , ...slides . split ( / \s + / ) ] ;
1377- if ( outDir ) cmd . push ( '--out' , outDir ) ;
1378- runCmd ( cmd , 'semantic_alignment.py --caption …' ) ;
1509+ runCmd ( cmd , 'align --course ' + cid ) ;
13791510 } ) ;
13801511 return ;
13811512 }
0 commit comments