1// Syllabus scanning: upload a syllabus, have the server's scanSyllabus.php parse it into 2// events/tasks, let the user fix anything up inline, and then add it all to their calendar. 3// 4// The syllabus cards are the entry points, so there's no pick-a-file screen in between: clicking one 5// opens the file picker, and #syllabusScanModal only appears once there are results to review. 6// 7// WHICH CLASS A SYLLABUS BELONGS TO: a scan started from a class's detail view, or from the tasks 8// rail filtered to a class, knows before the file is even picked. The add menu's Syllabus option and 9// the New Course form's import card don't, and for those it's worked out AFTER the scan: what the 10// document calls its course is matched against the classes on the calendar (matchSyllabusClass.php), 11// and failing that, at a school where the student can't add a real class anyway, one is built out of 12// the meeting times the scan found. Both land in the review's class picker, which is where a wrong 13// answer gets corrected. See syllabusScanResolveClassForScan. 14 15// THE CLASS PICKER 16// The class everything will be filed under, in the modal's header: the same select the New Task view 17// uses for "Class or Event", minus the events. It belongs to the review timeline, so it's shown and 18// hidden with it, and it can legitimately sit unselected â a scan that matched nothing opens on 19// "Select class" and the Import button refuses until one is picked. 20function syllabusScanRefreshClassSelect(){ 21 let classEvent = window.syllabusScanClassEventID ? getEventByID(window.syllabusScanClassEventID) : null 22 $('#syllabusScanClassSelectInput') 23 .attr('data-tag-eventid', classEvent ? window.syllabusScanClassEventID : -1) 24 .html(classEvent ? syllabusScanClassOptionInnerHTML(classEvent) : 'Select class') 25 syllabusScanHideClassDropdown() 26 // scans run in parallel, so two reviews can be identical from the outside â the file name is the 27 // only thing saying which syllabus you're looking at 28 $('#syllabusScanFileName').text(window.syllabusScanPresentedScan ? window.syllabusScanPresentedScan.fileName : '') 29} 30 31// the inline display is cleared rather than set, so the select goes back to whatever the stylesheet 32// says rather than being pinned to "block" 33function syllabusScanShowClassSelect(){ 34 $('#syllabusScanClassSelect').css('display', '') 35} 36 37function syllabusScanHideClassSelect(){ 38 $('#syllabusScanClassSelect').hide() 39 syllabusScanHideClassDropdown() 40} 41 42// every class on the schedule, for the picker â all of them, whatever semester they're in, newest 43// first. Deliberately unfiltered by semester: which term the CALENDAR is showing has nothing to do 44// with which class a syllabus is for, and a scan is filed from wherever the student happens to be 45// looking. Newest first rather than alphabetical because a class saved recently is the one a 46// syllabus arriving now is likely for, which puts the term being registered for at the top without 47// the picker having to know anything about terms. 48// 49// This is the picker only. Which classes a syllabus is MATCHED against is a separate, narrower 50// question that matchSyllabusClass.php answers off the calendar itself (the current terms' classes), 51// so what it can name is always something in here. 52function syllabusScanScheduleClasses(){ 53 let events = (window.getUsersSchedulesAndEvents() || {}).events || {} 54 return Object.keys(events).map(id => events[id]).filter(event => 55 event && event.type == 'class' && event.title 56 ).sort((a, b) => Number(b.eventID) - Number(a.eventID)) 57} 58 59// the class's colour then its name, built the same way populateCreateTaskTagDropdown builds a task's 60// tag so the two pickers can't drift apart visually 61function syllabusScanClassOptionInnerHTML(classEvent){ 62 let color = generateSaturatedDarkModeConsciousColor(mapClassicColorToThemeColor(classEvent.color)) 63 return `<span class="createTaskTagColor" style="background-color: ${color};"></span>` 64 + `<span class="classDetailLabel">${escapeHTML(classEvent.title)}</span>` 65} 66 67function syllabusScanClassDropdownIsOpen(){ 68 return $('#syllabusScanClassDropdown').is(':visible') 69} 70 71function syllabusScanHideClassDropdown(){ 72 $('#syllabusScanClassDropdown').hide() 73 $('#syllabusScanClassSelectIcon').removeClass('iconPointsUp') 74} 75 76// rebuilt on every open, so a class added since the review opened is in there. The "Add Class" row is 77// the way out for a syllabus whose class isn't on the calendar at all. 78function syllabusScanShowClassDropdown(){ 79 let classes = syllabusScanScheduleClasses() 80 let rows = classes.map(classEvent => 81 `<div class="dropdown-option createTaskTagDropdownOption syllabusScanClassOption creationSettingTextInput creationSettingTextInputSmall${classEvent.eventID == window.syllabusScanClassEventID ? ' syllabusScanClassOptionSelected' : ''}" data-id="${classEvent.eventID}">` 82 + syllabusScanClassOptionInnerHTML(classEvent) 83 + `</div>` 84 ).join('') 85 86 rows += `<div class="dropdown-option createTaskTagDropdownOption creationSettingTextInput creationSettingTextInputSmall addClassOrEventInTaskViewDropdown" id="syllabusScanAddClassOption">` 87 + `<span class='taskViewAddClassOrEventPlus'>+</span><span class='taskViewAdClassOrEventText'>Add Class</span>` 88 + `</div>` 89 90 $('#syllabusScanClassDropdown').html(rows).show() 91 $('#syllabusScanClassSelectIcon').addClass('iconPointsUp') 92} 93 94$(document).on('click', '#syllabusScanClassSelectContainer', function(event){ 95 event.stopPropagation() 96 if(syllabusScanClassDropdownIsOpen()){syllabusScanHideClassDropdown(); return} 97 syllabusScanShowClassDropdown() 98}) 99 100// picking a class re-points the whole import: the linkage the added events and tasks get, the 101// duplicate check, and the file we attach all read syllabusScanClassEventID. 102$(document).on('click', '.syllabusScanClassOption', function(event){ 103 event.stopPropagation() 104 window.syllabusScanClassEventID = parseInt($(this).attr('data-id')) 105 // and the record behind the modal, so it doesn't disagree with the select above it 106 if(window.syllabusScanPresentedScan){ 107 let classEvent = getEventByID(window.syllabusScanClassEventID) 108 window.syllabusScanPresentedScan.classEventID = window.syllabusScanClassEventID 109 window.syllabusScanPresentedScan.className = (classEvent && classEvent.title) || '' 110 } 111 syllabusScanRefreshClassSelect() 112 // this is the fix for the needsClass refusal, so the red on the picker and the line under the 113 // button both go the moment a class is picked 114 syllabusScanRefreshError() 115}) 116 117// Add Class: the class isn't on the calendar, so the user is off to make it in the left rail, which 118// this modal is covering. The scan goes back to being a card in the corner and they press Review on 119// it once the class exists. Rows already edited don't survive (the review is rebuilt from the scan's
120// response), but the import can't run without a class, so this is pressed before anyone settles in. 121$(document).on('click', '#syllabusScanAddClassOption', function(event){ 122 event.stopPropagation() 123 let scan = window.syllabusScanPresentedScan 124 syllabusScanHideClassDropdown() 125 if(scan){syllabusScanParkPresentedScan(scan)} 126 hideBaseModal('#syllabusScanModal') 127 // the same left-rail destination the New Task view's Add Class goes to: class search at a school 128 // with classes, the New Course form at one without 129 performActionForVerticalNavItemClick('verticalNavCoursesButton') 130 syllabusScanFocusAddClassDestination() 131}) 132 133// Add Class is pressed with a class already in mind â the one the syllabus is for â so whichever 134// form it lands on should be ready to be typed into. 135// 136// Only the search half needs doing here: the New Course form already focuses its Name field on the 137// way in (addEventView.js), while openClassSearch only summons the rail. Guarded on the same 138// condition performActionForVerticalNavItemClick branches on, so this focuses the field that 139// actually came up. 140// 141// Deferred a frame because the rail is mid-open when this runs â focus() on an element that isn't 142// laid out yet is dropped, and in the pwa the sheet is still animating in. 143function syllabusScanFocusAddClassDestination(){ 144 if(!(window.semestersWeHaveClassesFor || []).length){return} 145 setTimeout(function(){ 146 let input = $('#classSearchInput') 147 if(input.is(':visible')){input.focus()} 148 }, 0) 149} 150 151// Puts the review's scan back in the queue as a card without discarding it. Every way out of the 152// editor that isn't a finished add comes through here now â Add Class, and simply closing it â so 153// leaving the review costs nothing and the only thing that throws a scan away is its card's â. 154// 155// The rows go onto the record as the user left them, ahead of the reset that clears them, and 156// renderSyllabusScanResults prefers them over rebuilding from the response. Deliberately NOT in 157// SYLLABUS_SCAN_STORED_FIELDS: this is the page's working copy, and a refresh is still a refresh. 158// 159// Already back in the queue means a park has already run for this scan, so this is a second dismissal 160// signal for the same close (the pwa's background tap raises both, see syllabusScanParkOnDismiss). 161// It has to bail BEFORE the line below: by now the reset has emptied window.syllabusScanItems, and 162// writing that over parkedItems would throw away the very rows the first park saved. 163function syllabusScanParkPresentedScan(scan){ 164 if(syllabusScanByID(scan.id)){return} 165 scan.parkedItems = window.syllabusScanItems 166 syllabusScanResetModal() 167 window.syllabusScanScans.push(scan) 168 syllabusScanSetScanState(scan, 'done') 169} 170 171// anywhere else closes it, including elsewhere in the modal 172$(document).on('click', function(){syllabusScanHideClassDropdown()}) 173 174// THE SCAN QUEUE 175// Scans run in the background and in parallel, so every scan that's in flight, or landed and waiting 176// to be looked at, is a record in this list â the class it files into, the term dates it was uploaded 177// with, its request, and its response once it arrives. One record, one toast card, and the card's 178// data-scan-id is what ties a click back to its scan. 179// 180// What stays global is the MODAL. window.syllabusScanItems and the class/mode/term globals describe 181// whichever scan is currently in the modal, and syllabusScanPresentScan is the one thing that writes 182// them â that handover is what lets any number of scans be in flight while one is being edited. 183window.syllabusScanScans = [] 184 185// which scan the stack is showing. Only ever one card on screen; the rest are reachable through the 186// pager in the card's top corner. 187window.syllabusScanNotifIndex = 0 188 189// how long the card is faded down for while its contents are exchanged; matches the CSS on 190// .syllabusScanNotifSwapping 191const SYLLABUS_SCAN_NOTIF_SWAP_MS = 110 192window.syllabusScanNextScanID = 1 193 194// the scan whose results the modal is showing, or null. Its context has been copied onto the globals. 195window.syllabusScanPresentedScan = null 196 197function syllabusScanByID(scanID){ 198 return window.syllabusScanScans.filter(scan => scan.id == scanID)[0] || null 199} 200 201function syllabusScanRunningScans(){ 202 return window.syllabusScanScans.filter(scan => scan.state == 'scanning') 203} 204 205// drops a scan and the card standing for it: reviewed, dismissed, or cancelled 206function syllabusScanRemoveScan(scan){ 207 window.syllabusScanScans = window.syllabusScanScans.filter(other => other !== scan) 208 syllabusScanPaintNotifications() 209} 210 211// WHAT SURVIVES THE PAGE 212// A scan runs on the server whether or not this page is still here to watch it (see WHAT A SCAN IS, 213// SERVER-SIDE), so all a refresh loses is our end of the conversation: which scans were running and 214// what they were for. That's what's written down here and read back on the way in. 215// 216// The QUEUE is what's remembered, and only the queue. A scan handed to the modal leaves it on the 217// way in (syllabusScanShowPresentedScan), so an open review is still lost with the page â which is 218// what beforeunload warns about. Terminal cards aren't kept either: an error card's Try again is a 219// fresh upload anyway, and an empty one has nothing behind it. 220const SYLLABUS_SCAN_STORE_KEY = 'syllabusScansInFlight' 221 222// what a stored scan is: its two ids, the context the pick was made in, and when it started. The 223// response is left out on purpose â it's the biggest thing on a record, it's already on the server 224// under the scan id, and one poll has it back. So is anything belonging to this page's attempt (the 225// request, the poll timer). 226// 227// startedAt is kept because the wheel is drawn from elapsed time and nothing else (see THE BAR UNDER 228// A RUNNING SCAN): a scan that has been going a minute comes back a minute along rather than at empty. 229const SYLLABUS_SCAN_STORED_FIELDS = ['id', 'scanID', 'classEventID', 'classUnknownMode', 'unknownClass', 'className', 'fileName', 'termStart', 'termEnd', 'startedAt'] 230 231// called from the paint, which every change to the queue already goes through, so what's written
232// down can't drift from what's on screen. (A scan being given its scanID changes nothing visible and 233// calls this itself.) 234function syllabusScanRememberScans(){ 235 if(typeof store === 'undefined'){return} 236 237 let worthKeeping = window.syllabusScanScans.filter(scan => 238 scan.scanID && (scan.state == 'scanning' || scan.state == 'done')) 239 240 store.set(SYLLABUS_SCAN_STORE_KEY, worthKeeping.map(function(scan){ 241 let record = {} 242 SYLLABUS_SCAN_STORED_FIELDS.forEach(field => {record[field] = scan[field]}) 243 return record 244 })) 245} 246 247// and the other half: the scans this page has inherited, back in the queue as cards and polling. 248// Each is put back in the scanning state whatever it was written down as, because the question is 249// what it is NOW, and the first poll answers it immediately for one that finished while we were gone. 250function syllabusScanRestoreScans(){ 251 if(typeof store === 'undefined'){return} 252 253 let saved = store.get(SYLLABUS_SCAN_STORE_KEY) 254 if(!saved || !saved.length){return} 255 256 saved.forEach(function(record){ 257 if(!record || !record.scanID){return} 258 259 let scan = {"state": 'scanning', "request": null, "poll": null, "response": null, "count": 0, "restored": true} 260 SYLLABUS_SCAN_STORED_FIELDS.forEach(field => {scan[field] = record[field]}) 261 262 // a record written before startedAt was kept has nothing to measure from, and the wheel is 263 // arithmetic on that number â without it the fill is NaN and the ring never draws at all. 264 // Starting the curve over is the honest fallback: it's still filling towards an answer. 265 if(!scan.startedAt){scan.startedAt = Date.now()} 266 // where the elapsed time says it should be, so the first paint is already at the right fill 267 // rather than at empty until the timer's first tick 268 scan.progress = syllabusScanProgressFor(scan) 269 270 // scan ids are handed out per page load, so every restored one has to be reserved before a 271 // fresh scan can take it â two scans sharing an id are one card between them 272 window.syllabusScanNextScanID = Math.max(window.syllabusScanNextScanID, scan.id + 1) 273 window.syllabusScanScans.push(scan) 274 syllabusScanPollScan(scan, 0) 275 }) 276 277 syllabusScanPaintNotifications() 278 // the queue didn't come through window.syllabusScanBeginScan, so nothing has started the tick 279 syllabusScanStartProgressTimer() 280} 281 282// on the way in, once the stack these paint into exists 283$(function(){syllabusScanRestoreScans()}) 284 285// GUARDS 286// Parallel scans are fine in general â independent uploads landing in independent classes. Two cases 287// aren't, and both are refused before a scan starts. 288 289// the same file twice at once is the same scan twice: two identical toasts, and a second upload the 290// server answers out of the very cache row the first one is still writing. Gated on the name, which 291// is all we know about a file without reading it. 292function syllabusScanFileIsAlreadyScanning(fileName){ 293 return syllabusScanRunningScans().some(scan => scan.fileName == fileName) 294} 295 296// One syllabus in flight per class. Two scans racing to the same class would both clear the server's 297// already-linked check (neither has added anything yet) and then both write onto it, while 298// coursicleUsersSyllabi has one row per class. A class that doesn't exist yet is exempt: nothing to 299// collide with. 300function syllabusScanClassIsAlreadyScanning(classEventID){ 301 if(!classEventID){return false} 302 return syllabusScanRunningScans().some(scan => scan.classEventID == classEventID) 303} 304 305// starts a scan for a class, whoever asked for it: everything downstream (the upload, the class 306// context we send the model, the linkage the added events/tasks get) keys off the class event id. 307// This opens the file picker; the scan that follows runs without a modal at all. #syllabusScanModal 308// only appears once there are results. 309window.startSyllabusScanForClass = function(classEventID){ 310 if(!classEventID){return} 311 classEventID = parseInt(classEventID) 312 if(syllabusScanClassIsAlreadyScanning(classEventID)){ 313 alert("We're already scanning a syllabus for this class. Give that one a moment to finish.") 314 return 315 } 316 syllabusScanPickFileFor({"classEventID": classEventID, "classUnknownMode": false, "unknownClass": null}) 317} 318
319// starts a scan with no class named: reached from the add menu's Syllabus option and from the New 320// Course form's import card, where the user has a file and nothing else. Which class it belongs to is 321// worked out once the scan lands â see syllabusScanResolveClassForScan. 322// 323// The absent classEventID is also what makes the server hand back the scanned file's hash, for 324// linkSyllabusFile.php to attach to a class once there IS one. Every scan comes back with the 325// "lecture" section of scanSyllabus.php's schema; this is the only flow that reads it. 326window.startSyllabusScanWithoutClass = function(){ 327 syllabusScanPickFileFor({ 328 "classEventID": null, 329 "classUnknownMode": true, 330 // reserve one color now, in case a class gets built out of this syllabus 331 "unknownClass": {"color": getNextColor(), "lecture": null, "file": null, "createdClassEventID": null} 332 }) 333} 334 335// arms the file picker for a scan that doesn't exist yet â there's nothing to make a record of until 336// a file has been chosen. The context waits here rather than on the globals, which belong to whatever 337// scan is in the modal. 338function syllabusScanPickFileFor(pick){ 339 // last line of defense for the rule that a scan only ever happens on the user's own schedule 340 // (canUseSyllabusOnCurrentSchedule in eventDetailView.js). Every way in already hides itself in 341 // that case, so this is for the ways in we haven't thought of â a stale detail view left open 342 // across a schedule switch, say. Silent: there's no offer on screen to explain a refusal. 343 // 344 // requireLoadedSchedule: the offers may take "the schedule hasn't loaded yet" for a yes, since the 345 // one about to load is the user's own, but a scan has to go somewhere and there is nowhere to put 346 // it until it does. That window is the sub-second before loginless returns on a first-ever load. 347 if(!window.canUseSyllabusOnCurrentSchedule({requireLoadedSchedule: true})){return} 348 window.syllabusScanPendingPick = pick 349 // clearing the input matters for re-picking: choosing the same file twice in a row fires no 350 // change event unless the value is reset between attempts 351 $('#syllabusScanFileInput').val('') 352 $('#syllabusScanFileInput').click() 353} 354 355// opened from the class detail view, which is showing the class in question 356$(document).on('click', '.syllabusButtonIN', function(){ 357 window.startSyllabusScanForClass($('#infoColumn').attr('data-id')) 358}) 359 360// the New Course form's import card. Neither it nor the add menu's Syllabus option (schedule.php) 361// names a class, so both go straight to the file picker and let the syllabus answer the question 362// afterwards. On the web the New Course form stays put in the left rail while the scan runs; in the 363// pwa its sheet comes down once a file is chosen (see syllabusScanBeginScan). 364$(document).on('click', '#createClassFromSyllabusBtn', function(){ 365 window.startSyllabusScanWithoutClass() 366}) 367 368// empties the modal so a scan can be put into it. The MODAL only â the toasts, and the scans behind 369// them, are deliberately untouched, since one of them is usually what's about to go in. 370function syllabusScanResetModal(){ 371 window.syllabusScanPresentedScan = null 372 window.syllabusScanItems = [] 373 syllabusScanCollapseAllRows() 374 window.syllabusScanInvalidHighlight = false 375 hideSyllabusScanError() 376 $('#syllabusScanReviewStep').hide() 377 syllabusScanHideClassSelect() 378 $('#syllabusScanAddBtn').removeClass('syllabusScanAdding') 379 $('#syllabusScanAddBtnText').text('Add to Calendar') 380} 381 382// a scan that can't go on with the modal already open on it: drop the state and take the modal away 383// with it 384function syllabusScanCloseAfterFailure(){ 385 syllabusScanResetModal() 386 hideBaseModal('#syllabusScanModal') 387} 388 389// one scan's whole record. The class name and term dates are read here, while the pick's context is 390// current, because the toast and the review both need them long after another scan may have taken 391// the modal. 392function syllabusScanCreateScan(pick, file){ 393 let classEvent = pick.classEventID ? getEventByID(pick.classEventID) : null 394 let occurrence = (classEvent && classEvent.occurrenceData) || {} 395 396 // the term the model resolves relative dates against, and the default span for recurring items 397 // whose first/last occurrence the syllabus doesn't state. A class that already exists supplies 398 // its own; a scan that doesn't know its class yet falls back to the registration semester. 399 let termFrom = pick.classUnknownMode 400 ? [window.registrationSemesterStartDate, window.registrationSemesterEndDate] 401 : [occurrence.starts, occurrence.ends] 402 403 return { 404 "id": window.syllabusScanNextScanID++, 405 "state": 'scanning', 406 "classEventID": pick.classEventID, 407 "classUnknownMode": pick.classUnknownMode, 408 "unknownClass": pick.unknownClass, 409 "className": (classEvent && classEvent.title) || '', 410 "fileName": file.name, 411 "termStart": termFrom[0] ? syllabusScanUnixToDateString(termFrom[0]) : null, 412 "termEnd": termFrom[1] ? syllabusScanUnixToDateString(termFrom[1]) : null, 413 "request": null, 414 // the server's name for this scan: the upload answers with it and every poll asks by it (see 415 // WHAT A SCAN IS, SERVER-SIDE). It's also the only part of a scan that survives the page. 416 "scanID": null, 417 "poll": null, 418 // came back from storage rather than from a file the user just picked, which changes what a 419 // landing scan does. Cleared as soon as a poll finds it still running. 420 "restored": false, 421 "response": null, 422 "count": 0,
423 // what the bar is drawn from: when this started, and how far it has been filled 424 "startedAt": Date.now(), 425 "progress": 0 426 } 427} 428 429// same FormData upload pattern as uploadResource in resources.js. The class name and term range ride 430// along as context for the LLM (it uses them to resolve years and relative dates); classEventID 431// additionally lets the server reject a second syllabus for a class that already has one linked. 432function syllabusScanUploadFormData(scan, file){ 433 const formData = new FormData() 434 formData.append('uuid', store.get('uuid')) 435 formData.append('file', file) 436 formData.append('fileName', file.name) 437 // a scan that doesn't know its class sends no classEventID, and that absence is what makes the 438 // server hand back the stored file's details for linkSyllabusFile.php to attach later 439 if(!scan.classUnknownMode){ 440 formData.append('classEventID', scan.classEventID) 441 if(scan.className){formData.append('className', scan.className)} 442 } 443 if(scan.termStart){formData.append('termStart', scan.termStart)} 444 if(scan.termEnd){formData.append('termEnd', scan.termEnd)} 445 return formData 446} 447 448// as soon as they pick a file, upload it and start the scan 449$(document).on('change', '#syllabusScanFileInput', function(){ 450 // in the pwa the add menu is still standing behind the picker when the Syllabus option is what 451 // armed it (see the dismiss handler in schedule.js) â a file having been chosen is what finally 452 // puts it away. A no-op for every other way into the picker, where nothing is open behind it. 453 if(window.closeAddMenu){window.closeAddMenu()} 454 let pick = window.syllabusScanPendingPick 455 window.syllabusScanPendingPick = null 456 window.syllabusScanBeginScan(pick, this.files[0]) 457}) 458 459// Everything a scan is, from a file and the context it belongs to. Held apart from the picker's 460// change handler because the picker isn't the only way a file arrives: the tasks rail takes one 461// dropped on it (see the drop zone in tasks.js), and a dropped file has to travel exactly the path a 462// chosen one does â same guards, same card, same request â or the two ways in drift. 463window.syllabusScanBeginScan = function(pick, file){ 464 if(!file || !pick){return} 465 466 // the class was checked when the picker was armed; the file can only be checked now 467 if(syllabusScanFileIsAlreadyScanning(file.name)){ 468 alert("We're already scanning that file. Give it a moment to finish.") 469 return 470 } 471 472 let scan = syllabusScanCreateScan(pick, file) 473 window.syllabusScanScans.push(scan) 474 window.syllabusScanNotifIndex = window.syllabusScanScans.length - 1 475 syllabusScanPaintNotifications() 476 syllabusScanStartProgressTimer() 477 478 // No modal while the scan runs: the toast IS the loading state, and the user keeps their calendar 479 // the entire time (see THE SCAN TOASTS). In the pwa the sheet the scan was started from â the New 480 // Course form, or the class detail view â comes down here rather than when the picker is armed, so 481 // backing out of the file picker leaves it standing. 482 if(window.isPWA && (scan.classUnknownMode || $('#infoColumn').closest('.slidableModal').hasClass('slideable-modal-visible'))){hideSlidableModal()} 483 484 scan.request = $.ajax({ 485 url: '/shared/resources/scanSyllabus.php', 486 type: 'POST', 487 dataType: 'json', 488 data: syllabusScanUploadFormData(scan, file), 489 processData: false, 490 contentType: false, 491 success(response){ 492 // the checks the server can answer on the spot â a class that isn't theirs, one that 493 // already has a syllabus, a file too big or of a type we can't read â still come back 494 // here, since they happen before it has a scan to hand over 495 if(response.error === true){ 496 // the one refusal with a remedy of its own. scanSyllabus.php names it and sends the 497 // ceiling with it, so this end never keeps a copy of the number to fall out of step. 498 scan.tooBig = response.reason == 'File too big' 499 scan.maxUploadMB = response.maxUploadMB 500 syllabusScanReportFailure(scan, response.explanation) 501 return 502 } 503 // a syllabus the server had already read comes back finished right here, with nothing to 504 // wait for and no id to wait on (see the CACHE CHECK in scanSyllabus.php). Same envelope 505 // a poll would have brought, so it lands the same way. 506 if(response.status == 'done'){ 507 syllabusScanApplyResult(scan, response) 508 return 509 } 510 // otherwise the file is up and the parse is under way in a process that no longer needs 511 // us. All this request gives us is the name of it. 512 scan.scanID = response.scanID 513 syllabusScanRememberScans() 514 syllabusScanPollScan(scan, SYLLABUS_SCAN_POLL_MS) 515 }, 516 error(jqXHR, textStatus){ 517 // an abort is the toast's cancel tearing the request down; the scan and its card have 518 // already gone with it, so there's nothing to report 519 if(textStatus == 'abort'){return} 520 // the upload itself failing is the one transport failure that really is a failed scan: 521 // there's no id, so there's nothing to go back and ask about. Once there IS an id, a 522 // request that doesn't come back is just a poll to try again (see syllabusScanPollScan). 523 syllabusScanReportFailure(scan, "We couldn't scan your syllabus. Please try again.") 524 }, 525 complete(){ scan.request = null } 526 }) 527} 528 529// WHAT A SCAN IS, SERVER-SIDE 530// The upload doesn't return the results and doesn't hold a connection open waiting for them. It 531// answers with an id the moment the file is in, and the parse carries on in a php process that 532// nothing is connected to (see the note at the top of scanSyllabus.php). From there the scan is a 533// thing on the server this page asks after every few seconds, which is what lets it outlive the page
534// it started on: all we hold is an id, small enough for localStorage (see WHAT SURVIVES THE PAGE). 535// 536// Same endpoint either way: a request carrying a scanID is a poll, one carrying a file is an upload. 537// The exception is a file the server has already read, which comes back finished on the upload with 538// nothing to poll for. Both endings arrive as the same envelope and go through syllabusScanApplyResult. 539 540// how often we ask after a running scan. A scan takes the better part of a minute and a poll is one 541// indexed lookup on the scan's own row, so this is cheap enough to leave running for the duration. 542const SYLLABUS_SCAN_POLL_MS = 3000 543 544// asks after a scan once, and either lands it, fails it, or arranges to ask again. delay separates 545// the first ask from the rest: a restored scan asks immediately (it may well have finished while the 546// page was away), where one we just started waits out an interval first. 547function syllabusScanPollScan(scan, delay){ 548 scan.poll = setTimeout(function(){ 549 scan.poll = null 550 551 // the card may have been dismissed while the timer was out, which is the whole of cancelling a 552 // scan on this end: we stop asking, and the server finishes into the cache regardless 553 if(!syllabusScanByID(scan.id) || scan.state != 'scanning'){return} 554 555 scan.request = $.ajax({ 556 url: '/shared/resources/scanSyllabus.php', 557 type: 'POST', 558 dataType: 'json', 559 data: {"uuid": store.get('uuid'), "scanID": scan.scanID}, 560 success(response){ 561 // a failure carries the same explanation it would have arrived with had the user 562 // still been connected; the server keeps it on the scan's row (see failSyllabusScan) 563 if(response.error === true){ 564 syllabusScanReportFailure(scan, response.explanation) 565 return 566 } 567 if(response.status != 'done'){ 568 // still going, and the user is here waiting on it, so it's no longer a scan that 569 // landed in their absence whatever it was when the page loaded 570 scan.restored = false 571 syllabusScanPollScan(scan, SYLLABUS_SCAN_POLL_MS) 572 return 573 } 574 syllabusScanApplyResult(scan, response) 575 }, 576 error(jqXHR, textStatus){ 577 if(textStatus == 'abort'){return} 578 // a poll that doesn't come back says nothing about the scan behind it, which is 579 // running somewhere this page isn't. So this is a poll to try again, not a scan to 580 // give up on â losing the connection is what the whole arrangement exists to survive. 581 syllabusScanPollScan(scan, SYLLABUS_SCAN_POLL_MS) 582 }, 583 complete(){ scan.request = null } 584 }) 585 }, delay) 586} 587 588// a scan has landed: the results go on its record, and either the review opens on it or its card 589// reports what came back and waits to be asked for one. 590function syllabusScanApplyResult(scan, response){ 591 scan.response = response 592 scan.count = syllabusScanResultItemCount(scan) 593 594 // A scan that waits has nothing left in flight â the class matching and the duplicate check don't 595 // run until someone opens its review â so this is the point it has finished at. Every scan that 596 // reads is a success: a syllabus that never says when the class meets now gets its class picked in 597 // the review like any other. 598 if(syllabusScanReviewMustWait(scan)){ 599 syllabusScanSetScanState(scan, 'done') 600 return 601 } 602 603 // Straight into the modal, and the card stays on its wheel for the whole of it. The parse landing 604 // is not the end of the work on this path: the review still has a class to settle 605 // (matchSyllabusClass.php) and a duplicate check to make (dedupeSyllabusItems.php) before there 606 // are rows to open on, and a card reading "12 tasks found. Review" across that stretch would be 607 // offering a review that isn't built yet. The wheel resolves when the build does â into the modal, 608 // which retires the card, or into the empty or error card the build ended on. 609 // 610 // Filled last, and given its fill to play: the build goes first and the ring stays on its elapsed 611 // time curve across it, so the wheel is still turning while the two round trips run. Filling on 612 // the parse instead put a full wheel on screen and then made the user wait behind it, which a 613 // cached re-upload shows at its worst â the parse lands in two seconds and the whole of the visible 614 // wait is the build. A long scan is already near the end of the curve and never notices either way. 615 // the modal is claimed now rather than when the build gets to it: presentScan does it 616 // synchronously, and that claim is what makes a second scan landing inside this moment wait its 617 // turn instead of building over the top of this one. Released the usual way if this presentation 618 // is cancelled. 619 window.syllabusScanPresentingScanID = scan.id 620 syllabusScanPresentScan(scan, function(onShow){ 621 syllabusScanFillRing(scan, function(){ 622 // something with more right to the modal may have taken it meanwhile â a Review pressed on
623 // another card is a person deciding, and outranks a wheel finishing. This scan has landed and 624 // has nothing left in flight, so it waits on its own card exactly as one that landed while the 625 // modal was busy does; without this it sits on a full wheel with nothing left to resolve it. 626 if(window.syllabusScanPresentingScanID != scan.id){ 627 syllabusScanSetScanState(scan, 'done') 628 return 629 } 630 onShow() 631 }) 632 }) 633} 634 635// Whether a landed scan waits on its card's own Review rather than taking the screen. Usually it 636// doesn't: the wait is over, so what came back is what the user wants next and shouldn't need a 637// click. Three things defer it: 638function syllabusScanReviewMustWait(scan){ 639 // the modal already showing another scan (that keeps the screen, and it isn't only the opening 640 // that waits â building the results writes window.syllabusScanItems, which is the state the open 641 // review is being edited from); 642 if(syllabusScanModalIsOpen()){return true} 643 644 // another scan already having its review built, which the check above can't see because the modal 645 // isn't up yet, and building a second on top of it loses one of the two outright (see 646 // syllabusScanPresentingScanID). Only this automatic path asks â a toast's Review is a person 647 // deciding, so a claim left standing by a build that never finished can't lock them out; 648 if(window.syllabusScanPresentingScanID){return true} 649 650 // and this scan having finished while the page was away, where taking the screen on arrival would 651 // be the app interrupting rather than answering. (A scan restored while still RUNNING doesn't 652 // reach here in that state: the poll that found it running clears the flag.) 653 if(scan.restored){return true} 654 655 return false 656} 657 658// THE SCAN TOASTS
659// A scan routinely takes the better part of a minute (see the 300s timeout scanSyllabus.php hands 660// callAnthropicStructuredMessages), so it doesn't get a modal at all. It runs in the background and a 661// card in the bottom-right corner reports it â what's being scanned while the request is out, then 662// the outcome. The modal's only job is the review; cancelling is a card's dismiss. 663// 664// One card per scan in window.syllabusScanScans, each with its own actions. The card reads its scan 665// out of the queue by the data-scan-id it carries, so nothing has to parse its own markup back. 666// Styles are under #syllabusScanNotifStack in all.css (with the dark and themed variants re-pointing 667// that rule's --ssn* variables); the stack itself is in modals.php. 668 669const SYLLABUS_SCAN_NOTIF_ICON_CHECK = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"><path d="m4 12.5 5.5 5.5L20 6.5"/></svg>` 670const SYLLABUS_SCAN_NOTIF_ICON_ALERT = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v9"/><path d="M12 19h.01"/></svg>` 671// A scan that read the file and found nothing in it isn't a failure, so it doesn't get the failure's 672// exclamation â a question mark says "nothing here to import" without saying anything went wrong. 673const SYLLABUS_SCAN_NOTIF_ICON_QUESTION = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9.2 8.6a3 3 0 1 1 4 2.9c-.8.3-1.2 1-1.2 1.8v1.1"/><path d="M12 19h.01"/></svg>` 674 675// The progress wheel, drawn in the same 18px slot the outcome glyph lands in â the ring IS the 676// progress, and when the scan finishes it becomes the check or the alert in place. One mark that 677// fills and then resolves, rather than a bar along the bottom that vanishes and a glyph that appears 678// somewhere else. 679// 680// Rotated -90 so it starts at twelve o'clock. The dasharray is the full circumference and 681// syllabusScanPaintProgress writes the dashoffset; see SYLLABUS_SCAN_RING_CIRCUMFERENCE. 682// Sized in the stylesheet to the height of the two lines beside it, so the mark and the copy are one 683// block rather than a small dot floating against a taller column. The geometry here only has to be 684// self-consistent â the svg scales to whatever CSS gives it. 685// Only a backstop: the fill says when it has finished, and this is for the cases where it never 686// starts â a ring already full, a card repainted out from under it, a browser told to skip motion. 687const SYLLABUS_SCAN_RING_FILL_GIVE_UP_MS = 800 688 689const SYLLABUS_SCAN_RING_RADIUS = 16 690const SYLLABUS_SCAN_RING_CIRCUMFERENCE = 2 * Math.PI * SYLLABUS_SCAN_RING_RADIUS 691 692// the arc the ring hasn't drawn yet: a full circle at 0 and none of it at 1. Both the drawing and the 693// tick that moves it read it here, so a card rebuilt mid-scan comes back at the fill it had rather 694// than at empty until the next tick. 695function syllabusScanRingDashoffset(scan){ 696 return (SYLLABUS_SCAN_RING_CIRCUMFERENCE * (1 - (scan.progress || 0))).toFixed(2) 697} 698 699function syllabusScanNotifRingHTML(scan){ 700 return `<svg class="syllabusScanNotifRing" viewBox="0 0 36 36" aria-hidden="true"> 701 <circle class="syllabusScanNotifRingTrack" cx="18" cy="18" r="${SYLLABUS_SCAN_RING_RADIUS}"/> 702 <circle class="syllabusScanNotifRingFill" cx="18" cy="18" r="${SYLLABUS_SCAN_RING_RADIUS}" 703 stroke-dasharray="${SYLLABUS_SCAN_RING_CIRCUMFERENCE.toFixed(2)}" 704 stroke-dashoffset="${syllabusScanRingDashoffset(scan)}" 705 transform="rotate(-90 18 18)"/> 706 </svg>` 707} 708 709// the toast's dismiss is the only way to stop a running scan, and stopping it throws the upload away 710const SYLLABUS_SCAN_CANCEL_CONFIRM = "Stop scanning your syllabus? It won't be added to your calendar." 711 712// THE BAR UNDER A RUNNING SCAN 713// The server reports nothing mid-scan, so the fill is a function of elapsed time and nothing else: 714// 90% at the expected time, then halving what's left every ~27s. It never reaches the end on its own, 715// so a full bar always means the answer actually landed â though only just, out in the tail: the 716// request's own timeout is 300s, by which point the curve is at 99.9% and reads as full to the eye. 717// 718// It runs a little past the parse on the path that opens the review itself, since the two round trips 719// that build one are part of the same wait (see syllabusScanApplyResult). The curve doesn't care â 720// it's the same asymptote, and those two are seconds against the scan's minute. 721 722// HOW LONG A SCAN TAKES. The one number here worth tuning; everything else follows from it. 723const SYLLABUS_SCAN_EXPECTED_MS = 90000 724 725const SYLLABUS_SCAN_TICK_MS = 200 726// lets the fill's transition land before the card turns over 727 728// over ln(10), which is what puts the curve at 90% at the expected time 729function syllabusScanProgressFor(scan){ 730 return 1 - Math.exp(-(Date.now() - scan.startedAt) / (SYLLABUS_SCAN_EXPECTED_MS / 2.303)) 731} 732 733// The one attribute, rather than a repaint: syllabusScanPaintNotifications rebuilds the card's whole
734// contents, and doing that five times a second would restart its entrance and fight the cursor. 735function syllabusScanPaintProgress(){ 736 // every card on screen, not just the front one: the deck shows the whole queue at once, and a 737 // running scan behind a finished one had its wheel frozen 738 $('#syllabusScanNotifStack .syllabusScanNotifCard').each(function(){ 739 let scan = syllabusScanByID($(this).attr('data-scan-id')) 740 if(!scan || scan.state != 'scanning'){return} 741 $(this).find('.syllabusScanNotifRingFill').attr('stroke-dashoffset', syllabusScanRingDashoffset(scan)) 742 }) 743} 744 745// Runs the wheel to full, as an animation rather than a jump. A transition needs a start the browser 746// has actually painted, and a scan that lands in its first second or two has never had one written: 747// the tick that draws the curve only runs five times a second. So the value it's at goes on, gets 748// committed by a forced reflow, and only then does the full one â the same dance a replayed shake does. 749function syllabusScanFillRing(scan, onFull){ 750 let ring = $(`#syllabusScanNotifStack .syllabusScanNotifCard[data-scan-id="${scan.id}"] .syllabusScanNotifRingFill`) 751 if(ring.length){ 752 ring.attr('stroke-dashoffset', syllabusScanRingDashoffset(scan)) 753 void ring.get(0).getBoundingClientRect() 754 } 755 scan.progress = 1 756 syllabusScanPaintProgress() 757 758 if(!ring.length){ 759 onFull() 760 return 761 } 762 763 // the fill reports its own end rather than this end knowing how long the stylesheet gives it 764 let settle = function(event){ 765 if(event && (event.originalEvent || event).propertyName != 'stroke-dashoffset'){return} 766 clearTimeout(timer) 767 ring.off('transitionend', settle) 768 onFull() 769 } 770 ring.on('transitionend', settle) 771 let timer = setTimeout(settle, SYLLABUS_SCAN_RING_FILL_GIVE_UP_MS) 772} 773 774// One timer however many scans are running, started by the first and stopping itself after the last. 775// Taking the max keeps it from ever going backwards, which also protects the 1 the answer puts there. 776function syllabusScanStartProgressTimer(){ 777 if(window.syllabusScanProgressTimer){return} 778 window.syllabusScanProgressTimer = setInterval(function(){ 779 let scans = syllabusScanRunningScans() 780 if(!scans.length){ 781 clearInterval(window.syllabusScanProgressTimer) 782 window.syllabusScanProgressTimer = null 783 return 784 } 785 scans.forEach(function(scan){ 786 scan.progress = Math.max(scan.progress || 0, syllabusScanProgressFor(scan)) 787 }) 788 syllabusScanPaintProgress() 789 }, SYLLABUS_SCAN_TICK_MS) 790} 791 792// What the success handler used to do inline, now that the bar is given a moment to land first. 793// Guarded on the scan still being there, since the card can be dismissed inside that moment. 794// 795// Every scan that reads is a success now. A syllabus that never says when the class meets used to be 796// a dead end â the old build-a-class flow refused to create a TBA class and had nowhere else to put 797// the events and tasks, so it took the warning colours and opened on an explanation. There is 798// somewhere else now: the review's class picker, with its Add Class row. Nothing about the document 799 800// The words for each state, in one place so the cards can't drift on what they say. Two slots and no 801// more: the title is the outcome, and the detail is which scan this IS â with several scans in the 802// queue the class and the file are the only things telling them apart. A null action leaves the foot 803// off the card, for a state with nothing to offer (an empty scan has no review to open, and a retry 804// would read the same document the same way). 805// The class the scan is for. Plain text: the colour it identifies with is carried by the wheel beside 806// it, and doing both made one short line say the same thing twice. 807function syllabusScanClassNameHTML(scan){ 808 if(!scan.className){return ''} 809 return `<span class="syllabusScanNotifClass">${escapeHTML(scan.className)}</span>` 810} 811 812// Every state names the class, so a card says which syllabus it's about however it ended up. A scan 813// that named no class (an upload the matching couldn't place) drops that clause rather than leaving a 814// gap where a name should be. 815// Two lines, and they do different jobs. The top names the syllabus and never changes shape â it's
816// how you tell one card from another when several are landing at once. The bottom carries whatever 817// there is to say right now: the file while it runs, then the outcome and the one thing to do about 818// it, as a link in the sentence rather than a button under it. A button made every finished card a 819// row taller for a two-word action. 820// The file name as a failure line can carry it. A failed card has to say WHICH syllabus failed â 821// several can be in the stack at once and the title only names the class, which two uploads can 822// share â but the detail is one line that never wraps, so a long name run in whole would push Try 823// again off the end and take the card's only remedy with it. The line is split instead of the name 824// being cut: the sentence gives up its tail to the ellipsis and the link keeps its full width, so 825// the name fits itself to the card. A scan with no name falls back to the word, which is the line 826// exactly as it read before. 827function syllabusScanFailNameText(scan){ 828 return scan.fileName || 'syllabus' 829} 830 831function syllabusScanNotifWords(scan){ 832 let klass = syllabusScanClassNameHTML(scan) 833 let link = label => `<span class="syllabusScanNotifAction" data-action="primary" role="button" tabindex="0">${label}</span>` 834 let lead = html => `<span class="syllabusScanNotifDetailLead">${html}</span>` 835 let count = scan.count == 1 ? '1 task' : scan.count + ' tasks' 836 837 // The same line in every state, and nothing in it but what the card is about. The state is said by 838 // the mark on the left â the wheel while it runs, the outcome when it lands â and the line below 839 // says the rest, so a verb up here would only be a third voice repeating them. 840 let title = klass ? `${klass} Syllabus` : 'Syllabus' 841 842 if(scan.state == 'scanning'){ 843 return {"titleHTML": title, "detailHTML": lead(escapeHTML(scan.fileName))} 844 } 845 846 let detail = { 847 "done": `${lead(count + ' found. ')}${link('Review')}`, 848 // Deliberately not "Try again": the document read fine and had nothing in it, so re-running 849 // the same file returns the same nothing. The remedy that actually works is a different file, 850 // so the link says that. A failed scan keeps "Try again" â there the same file really might 851 // succeed. 852 "empty": `${lead('No tasks found. ')}${link('Pick another file')}` 853 // Named, because the failure is the one state whose detail line stops showing the file. While it 854 // runs the file name IS the detail, so a card that failed and went to a bare "Couldn't scan 855 // syllabus" dropped the only thing telling two uploads of the same class apart. 856 }[scan.state] || (scan.tooBig 857 // the only failure whose remedy is a different file; everything else really might work the 858 // second time, so those keep Try again. The title above already says "Syllabus". 859 ? `${lead(`Too large${scan.maxUploadMB ? ` (${scan.maxUploadMB}mb max)` : ''}. `)}${link('Pick another file')}` 860 : `${lead(`Couldn't scan ${escapeHTML(syllabusScanFailNameText(scan))}. `)}${link('Try again')}`) 861 862 return {"titleHTML": title, "detailHTML": detail} 863} 864 865// a card's contents without its wrapper: a card already on screen gets these swapped in place, so 866// its entrance isn't replayed every time a scan beside it changes 867function syllabusScanNotifCardInnerHTML(scan){ 868 let words = syllabusScanNotifWords(scan) 869 870 // One mark, two jobs: it's the progress wheel while the scan runs and the outcome the moment it 871 // lands, in the same place and at the same size. The card doesn't rearrange when it finishes â 872 // the ring just resolves into a check or an alert. 873 let glyph = `<span class="syllabusScanNotifGlyph">${ 874 scan.state == 'scanning' ? syllabusScanNotifRingHTML(scan) 875 : scan.state == 'done' ? SYLLABUS_SCAN_NOTIF_ICON_CHECK 876 : scan.state == 'empty' ? SYLLABUS_SCAN_NOTIF_ICON_QUESTION 877 : SYLLABUS_SCAN_NOTIF_ICON_ALERT}</span>` 878 879 // Chevrons in the corner, only when there's more than one scan to move between. Each is lit only 880 // when it leads somewhere, wearing the classes the schedule's own nav arrows use, so where you 881 // are in the run reads without a count: both lit is the middle, a dim one is an end. 882 let total = window.syllabusScanScans.length 883 let index = window.syllabusScanNotifIndex 884 // Filled carets rather than the chevrons the schedule's week nav uses: at 8px a stroked chevron is 885 // mostly the gap inside it, and the pair reads as four thin lines instead of one control. 886 let arrow = (dir, at) => `<i class="fa-solid fa-caret-${dir} syllabusScanNotifPage ${at ? 'scheduleNavBtnInactive' : 'scheduleNavBtnActive'}"` 887 + ` data-action="${dir == 'left' ? 'prev' : 'next'}" role="button" tabindex="0"` 888 + ` aria-label="${dir == 'left' ? 'Previous' : 'Next'} import" aria-disabled="${at ? 'true' : 'false'}"></i>` 889 // Only worth drawing when there's somewhere to go: one syllabus in the stack needs no way to move 890 // between syllabi. Positioned into the card's top-right corner by the stylesheet now that there's 891 // no kicker row to ride along. 892 let pager = total > 1 893 ? `<span class="syllabusScanNotifPager">${arrow('left', index == 0)}${arrow('right', index == total - 1)}</span>` 894 : '' 895 896 // No kicker: the headline already says what this is ("Scanning your CHEM 101 syllabus"), and a 897 // SYLLABUS IMPORT label above it was the card saying so twice. The pager goes to the top corner it 898 // used to sit in, and the dismiss centres against the taller head. 899 return `${pager} 900 <div class="syllabusScanNotifCardHead"> 901 ${glyph} 902 <div class="syllabusScanNotifCopy"> 903 <div class="syllabusScanNotifTitle">${words.titleHTML}</div> 904 <div class="syllabusScanNotifDetail">${words.detailHTML}</div>
905 </div> 906 <i class="fa-solid fa-xmark syllabusScanNotifX" data-action="dismiss" role="button" tabindex="0"></i> 907 </div>` 908} 909 910function syllabusScanNotifCardHTML(scan){ 911 return `<div class="syllabusScanNotifCard" data-scan-id="${scan.id}" data-state="${scan.state}">${syllabusScanNotifCardInnerHTML(scan)}</div>` 912} 913 914// moves a scan to a new state and redraws its card 915function syllabusScanSetScanState(scan, state){ 916 scan.state = state 917 syllabusScanPaintNotifications() 918} 919 920// Sits the stack over the calendar, centred in it, whatever the two rails are doing. Both edges are 921// measured rather than written down as widths: each rail is one width open and another shut, the 922// tasks rail's tab hangs off its left edge instead of sitting inside it, and all of those numbers 923// move with zoom. 924// 925// On the right it's the tab that gets measured, not the rail: it's the leftmost part of that 926// furniture and it stays on screen when the rail collapses. On the left the rail's own right edge is 927// the boundary, which reads as 0 when collapsed, so there's no open-or-shut case either side. 928function syllabusScanPositionStack(){ 929 let stack = document.getElementById('syllabusScanNotifStack') 930 if(!stack){return} 931 932 // The pwa and mobile web have no rails flanking a calendar, so the stylesheet's own corner is 933 // right. #toggleView is the tab bar that only exists there, the same thing the stack's other 934 // mobile rule keys on. Worth checking rather than trusting the measurements: #leftRail is in the 935 // markup on mobile too, at its full 300px until something collapses it. 936 if(document.getElementById('toggleView')){ 937 stack.style.left = '' 938 stack.style.right = '' 939 return 940 } 941 942 let leftRail = document.getElementById('leftRail') 943 let tab = document.getElementById('toggleRightRailTab') 944 945 let leftEdge = leftRail && leftRail.offsetParent ? leftRail.getBoundingClientRect().right : 0 946 let rightEdge = tab && tab.offsetParent ? tab.getBoundingClientRect().left : window.innerWidth 947 948 // the same gap either side, so it insets the card without moving what it's centred on 949 stack.style.left = Math.round(leftEdge + 20) + 'px' 950 stack.style.right = Math.round(window.innerWidth - rightEdge + 20) + 'px' 951} 952 953$(window).on('resize', syllabusScanPositionStack) 954 955// both rails animate their width, so watching them covers every toggle without needing to know what 956// did the toggling 957$(function(){ 958 if(typeof ResizeObserver != 'function'){return} 959 let watch = new ResizeObserver(syllabusScanPositionStack) 960 ;['leftRail', 'rightRail'].forEach(function(id){ 961 let rail = document.getElementById(id) 962 if(rail){watch.observe(rail)} 963 }) 964}) 965 966// One card at a time, wherever this runs: the pager steps between them, and on touch its carets are 967// sized for a finger rather than a cursor (all.css). A deck that fanned open lived here for a while; 968// it needed room it didn't own on every surface it met, and every screen it collided with became 969// another thing to dodge. 970 971// (re)draws the stack from window.syllabusScanScans 972function syllabusScanPaintNotifications(){ 973 syllabusScanPositionStack() 974 975 let stack = $('#syllabusScanNotifStack') 976 977 // the queue is about to be drawn, so it's also the moment worth writing it down: everything that 978 // changes a scan comes through here on its way to the screen (see WHAT SURVIVES THE PAGE) 979 syllabusScanRememberScans() 980 981 // whichever one is being shown, kept in range as scans are dismissed out from under it 982 let scans = window.syllabusScanScans 983 if(!scans.length){ 984 stack.empty() 985 return 986 } 987 window.syllabusScanNotifIndex = Math.min(Math.max(window.syllabusScanNotifIndex, 0), scans.length - 1) 988 let scan = scans[window.syllabusScanNotifIndex] 989 990 // The card on screen is reused for whichever scan is being shown, rather than torn down and 991 // rebuilt: paging is the same card taking on the next scan's contents, so it stays put instead of 992 // sliding in from off screen each time an arrow is pressed. 993 let card = stack.children('.syllabusScanNotifCard').first() 994 if(!card.length){ 995 card = $(syllabusScanNotifCardHTML(scan)) 996 stack.append(card) 997 // the entrance class a frame later than the card itself, or there's nothing for the 998 // transition to start from and it just appears 999 requestAnimationFrame(() => requestAnimationFrame(() => card.addClass('syllabusScanNotifIn'))) 1000 return 1001 } 1002 1003 let contents = syllabusScanNotifCardInnerHTML(scan) 1004 1005 // a scan reporting its own progress just redraws in place; only a move to a different scan is 1006 // worth covering, and then only for as long as the exchange takes 1007 if(card.attr('data-scan-id') == String(scan.id)){ 1008 card.attr('data-state', scan.state).html(contents) 1009 return 1010 } 1011 1012 card.addClass('syllabusScanNotifSwapping') 1013 setTimeout(function(){ 1014 card.attr('data-scan-id', scan.id).attr('data-state', scan.state).html(contents) 1015 card.removeClass('syllabusScanNotifSwapping') 1016 }, SYLLABUS_SCAN_NOTIF_SWAP_MS) 1017} 1018 1019// stops at the ends rather than wrapping, since the arrows show which ends they are 1020$(document).on('click', '.syllabusScanNotifPage', function(event){ 1021 event.stopPropagation() 1022 let step = $(this).attr('data-action') == 'next' ? 1 : -1 1023 let target = window.syllabusScanNotifIndex + step 1024 if(target < 0 || target > window.syllabusScanScans.length - 1){return} 1025 // the same soft tap the app's carets give (softVibrate there). Only after the bounds check, so a 1026 // dim arrow stays silent, and android only â iOS Safari and its pwa expose no Vibration API at 1027 // all (see _playCelebrationHaptics in tasks.js). 1028 if('vibrate' in navigator && typeof navigator.vibrate == 'function'){navigator.vibrate(8)} 1029 window.syllabusScanNotifIndex = target 1030 syllabusScanPaintNotifications() 1031}) 1032 1033// how many rows this scan's review will show: after the recurrence patterns are expanded (a "quiz 1034// every week" is one item from the model and twelve rows here), so the number on the card is the 1035// number the user then sees. Takes the scan rather than the globals â the modal may hold another. 1036function syllabusScanResultItemCount(scan){ 1037 let result = scan.response.result 1038 return (result.events || []).flatMap(item => syllabusScanNormalizeEvent(item, scan)).length 1039 + (result.tasks || []).flatMap(item => syllabusScanNormalizeTask(item, scan)).length 1040} 1041 1042// Is the results modal on screen? Two mechanisms, because the two paths tear down differently: the 1043// web modal wears webLikeModalShowing until hideBaseModal takes it off, while the sheet's visibility 1044// lives on the wrapper showBaseModal handed it to. baseModalShow can't answer this â 1045// hideSlidableModal only clears slideable-modal-visible and leaves that class behind on the pwa. 1046function syllabusScanModalIsOpen(){ 1047 if($('#syllabusScanModal').hasClass('webLikeModalShowing')){return true} 1048 return $('#syllabusScanModal').closest('.slidableModal').hasClass('slideable-modal-visible') 1049} 1050 1051// Is it the topmost modal, so a dismissal aimed at the backdrop was aimed at THIS? Same tw
1051o 1052// mechanisms as above, and for the same reason: the web modal carries its own z-index, while the pwa 1053// sheet's lives on the wrapper renderSlidableModal reparented it into, and the two are ranked by 1054// different helpers over different sets. slidableModal.js isn't on every page that loads this (see 1055// hideBaseModal's own typeof guard), so the pwa branch checks before it calls. 1056function syllabusScanModalIsTopmost(){ 1057 if($('#syllabusScanModal').hasClass('webLikeModalShowing')){ 1058 return parseInt($('#syllabusScanModal').css('z-index'), 10) == getZIndexOfHighestBaseModal() 1059 } 1060 if(typeof getZIndexOfHighestSlidableModal != 'function'){return false} 1061 let sheet = $('#syllabusScanModal').closest('.slidableModal') 1062 return sheet.length > 0 && parseInt(sheet.css('z-index'), 10) == getZIndexOfHighestSlidableModal() 1063} 1064 1065// The scan whose review is currently being BUILT, or null. Not the same as syllabusScanPresentedScan, 1066// which is set partway through the build and deliberately outlives its modal; this is set the moment 1067// a presentation starts and cleared however it ends, so it can answer "is one going on right now?". 1068// 1069// Building a review isn't instant â a scan with no class may have to create one (a sync), and every 1070// scan with a class pays a duplicate-check round trip â and for that whole stretch the modal is still 1071// hidden, so syllabusScanModalIsOpen() says no. Without this, a second scan landing in that window 1072// starts its own presentation over the top: syllabusScanResetModal wipes the first one's context 1073// mid-build, both render into the same globals, both leave the queue, and one of the two is simply 1074// gone. Several scans landing together is the normal shape of a reload, since every restored scan 1075// starts polling in the same tick (see syllabusScanRestoreScans). 1076window.syllabusScanPresentingScanID = null 1077 1078// Hands a landed scan over to the modal: its context becomes the modal's, its step gets built inside 1079// the still-hidden modal, and then it's shown and its card retired. Reached on its own the moment a 1080// scan lands, and from a toast's Review when the modal was busy at that moment. 1081// 1082// This is the ONLY thing that writes the modal's globals, which is what lets any number of scans be 1083// in flight while one of them is being edited. 1084// 1085// beforeShow is the automatic path's hook for the last moment before the modal appears â everything 1086// this scan was waiting on is done by then, which is where the card's wheel gets to finish (see 1087// syllabusScanApplyResult). It's handed the show as a callback rather than returning, since what it 1088// does is an animation. A toast's Review doesn't pass one: that card resolved
1088its wheel when the scan 1089// landed and has been sitting on a check ever since. 1090function syllabusScanPresentScan(scan, beforeShow){ 1091 // whatever was in the modal isn't this scan; clear it out before its context is replaced 1092 syllabusScanResetModal() 1093 1094 // this scan now owns the modal, from here until its review is either on screen or given up on 1095 window.syllabusScanPresentingScanID = scan.id 1096 1097 // a scan that knows its class goes straight in. One that doesn't has to settle that first, since 1098 // everything the review then does â which class the dedupe compares against, what the picker opens 1099 // on â depends on the answer. 1100 syllabusScanResolveClassForScan(scan, function(){ 1101 if(syllabusScanPresentationWasCancelled(scan)){return} 1102 syllabusScanApplyScanContext(scan) 1103 syllabusScanRefreshClassSelect() 1104 // the review is a dedupe round trip away, so the modal is only shown once the rows exist â 1105 // opening before that would land on an empty step 1106 renderSyllabusScanResults(scan, function(){ 1107 if(syllabusScanPresentationWasCancelled(scan)){return} 1108 let show = function(){ 1109 // the hook is a moment of its own, and the card can be dismissed inside it like any other 1110 if(syllabusScanPresentationWasCancelled(scan)){return} 1111 syllabusScanShowPresentedScan(scan) 1112 } 1113 if(beforeShow){beforeShow(show); return} 1114 show() 1115 }) 1116 }) 1117} 1118 1119// The card can be dismissed while the review is being built, and on the automatic path that dismiss 1120// is a cancel â the card is still on its wheel there, and the â asks to confirm as much (see 1121// syllabusScanApplyResult and SYLLABUS_SCAN_CANCEL_CONFIRM). The build is two round trips, so it 1122// can't be stopped where it stands; instead each step that follows one checks the scan is still in 1123// the queue, and a cancelled one goes no further. Without this the review opened anyway, seconds 1124// after the user said not to. 1125// 1126// A class built out of the syllabus inside that window goes with it: the dismiss couldn't take it, 1127// because it didn't exist yet (see syllabusScanRemoveClassBuiltForAbandonedScan). 1128function syllabusScanPresentationWasCancelled(scan){ 1129 if(syllabusScanByID(scan.id)){return false} 1130 1131 // the modal is handed back only if this build still holds it: a Review pressed on another card 1132 // during those round trips takes it over, and clearing the globals then would empty the build now 1133 // using them 1134 if(window.syllabusScanPresentingScanID == scan.id){ 1135 window.syllabusScanPresentingScanID = null 1136 syllabusScanResetModal() 1137 } 1138 syllabusScanRemoveClassBuiltForAbandonedScan(scan) 1139 return true 1140} 1141 1142// WHICH CLASS THIS SYLLABUS IS FOR, for the scans that didn't come with an answer. Runs once per 1143// scan â the answer is kept on the record, so re-opening a parked review doesn't ask again (and 1144// doesn't undo a class the user picked by hand in the meantime). Written as a callback because both 1145// halves of it are round trips: the matching is an endpoint, and building a class needs a sync. 1146// 1147// Three outcomes, in order of preference: 1148// 1. it matches a class already on the calendar -> that class, pre-selected 1149// 2. no match, and the student is at a school where they CAN'T add a real class for this term -> 1150// build one out of the syllabus, silently, and pre-select that 1151// 3. neither -> nothing selected, and the picker (with its Add Class row) is how they say 1152// 1153// Case 2 is deliberately limited to schools with no current-semester class data. Where the school HAS 1154// posted classes, a student whose syllabus didn't match should be picking the real class out of the 1155// dropdown or adding it from search, not leaving a custom duplicate on their calendar. 1156function syllabusScanResolveClassForScan(scan, onDone){ 1157 if(!scan.classUnknownMode || scan.classEventID){onDone(); return} 1158 1159 // the two things a class can be built or matched from, and the file to attach to whichever class 1160 // this ends up on, all kept on the record so the answer outlives the modal 1161 scan.unknownClass.lecture = scan.response.result.lecture || null 1162 scan.unknownClass.file = scan.response.syllabusFile || null 1163 1164 let lecture = scan.unknownClass.lecture 1165 1166 // what the syllabus calls its course, both ways it can, against the classes the student is taking 1167 // this term â which the endpoint reads off their calendar itself, so nothing but the two names 1168 // goes up. A class event carries both of its own names on the server side too (title is the class 1169 // code, subtitle the official course title), so the two vocabularies still meet. 1170 syllabusMatchFindClassOnServer( 1171 (lecture && lecture.className) || '', 1172 (lecture && lecture.courseTitle) || '', 1173 function(match){ 1174 // only a confident match fills the picker in. A "possible" one is treated as no match: this
1175 // pre-selects a destination the user may never look at, so the bar is "this is the class", not 1176 // "this is the likeliest of the classes". 1177 if(match.confidence == 'confident'){ 1178 let matchedEvent = getEventByID(match.eventID) 1179 if(matchedEvent){ 1180 scan.classEventID = match.eventID 1181 scan.className = matchedEvent.title || '' 1182 onDone() 1183 return 1184 } 1185 } 1186 1187 syllabusScanBuildClassForScanIfPossible(scan, onDone) 1188 }) 1189} 1190 1191// asks the server which class on the calendar a syllabus is for. The rules live in 1192// matchSyllabusClass.php â a course code is not free text and comparing two of them takes a parser, 1193// which every client would otherwise have had to carry its own copy of, thresholds and all. Nothing 1194// about it needs a server; it's shared, so it is one. 1195// 1196// The candidates aren't sent: the endpoint reads the student's own classes for the current terms out 1197// of the database, so what a syllabus can match doesn't depend on what this page happens to be 1198// holding in memory. They're a subset of the picker's list (which is every class on the schedule), so 1199// a match can't name a class the dropdown doesn't offer. Fails open to "no match", which is a real 1200// answer and not an error state: the review opens on an unselected picker, exactly as it does for a 1201// syllabus that resembles nothing the student is taking. 1202function syllabusMatchFindClassOnServer(syllabusClassName, syllabusCourseTitle, callback){ 1203 $.ajax({ 1204 url: '/shared/resources/matchSyllabusClass.php', 1205 type: 'POST', 1206 dataType: 'json', 1207 data: { 1208 uuid: store.get('uuid'), 1209 className: syllabusClassName, 1210 courseTitle: syllabusCourseTitle 1211 }, 1212 success(response){ 1213 callback(response && response.error === false ? response : {confidence: 'none', eventID: null}) 1214 }, 1215 error(){ callback({confidence: 'none', eventID: null}) } 1216 }) 1217} 1218 1219// "Meets MW 10:00â10:50 am · F 1:00â1:50 pm", or '' when the lecture extraction found no usable 1220// meeting times â which is what tells syllabusScanBuildClassForScanIfPossible there's no class to 1221// build here 1222function syllabusScanLectureSummary(lecture){ 1223 let meetings = (lecture && lecture.meetings) || [] 1224 let parts = [] 1225 meetings.forEach(meeting => { 1226 if(!meeting.startTime || !meeting.days || !meeting.days.length){return} 1227 parts.push(meeting.days.join('') + ' ' + syllabusScanTimeRangeLabel(meeting.startTime, meeting.endTime, ' ')) 1228 }) 1229 return parts.length ? 'Meets ' + parts.join(' · ') : '' 1230} 1231 1232// case 2 above: no class matched, so make one out of the syllabus â but only where that's the right 1233// answer, and only when the document gave us enough to make a real class with. 1234// 1235// "Enough" means stated meeting times. A class without them is a TBA class: it paints nothing on the 1236// calendar and tells the student nothing, so rather than leave one lying around under a name we 1237// guessed at, this does nothing and the review opens unselected. Same when the syllabus never names 1238// the course â an untitled class is worse than none. 1239function syllabusScanBuildClassForScanIfPossible(scan, onDone){ 1240 let lecture = scan.unknownClass.lecture 1241 let className = (lecture && (lecture.className || lecture.courseTitle)) || '' 1242 1243 if(window.schoolHasCurrentRegistrationSemesterCourses){onDone(); return} 1244 if(!className.trim() || !syllabusScanLectureSummary(lecture)){onDone(); return} 1245 1246 // the globals the creation reads (the reserved colour, the meeting times) belong to whichever scan 1247 // is in the modal, and this runs before the handover, so they're set for the length of the call 1248 syllabusScanApplyScanContext(scan) 1249 1250 syllabusScanCreateClassFromLecture(className.trim(), function(newClassEventID){ 1251 if(newClassEventID){ 1252 scan.classEventID = newClassEventID 1253 scan.className = className.trim() 1254 // remembered so an abandoned review can take the class back off the calendar with it: we 1255 // put it there without being asked, so it shouldn't outlive the import it was created for 1256 scan.unknownClass.createdClassEventID = newClassEventID 1257 } 1258 onDone() 1259 }) 1260} 1261 1262// the modal's copy of a scan's context. Everything downstream â the dedupe check, the editor's 1263// defaults, the add pass â reads these rather than the record, so this is the handover. 1264function syllabusScanApplyScanContext(scan){ 1265 window.syllabusScanPresentedScan = scan 1266 window.syllabusScanClassEventID = scan.classEventID 1267 window.syllabusScanClassUnknownMode = scan.classUnknownMode 1268 window.syllabusScanUnknownClass = scan.unknownClass 1269 window.syllabusScanTermEnd = scan.termEnd 1270} 1271 1272// the step is built: show it, and retire the card that was standing in for it. The scan lives in the 1273// modal from here, which is why it comes out of the queue. 1274function syllabusScanShowPresentedScan(scan){ 1275 // the build is over â this is the way it ends when there was a review to show 1276 window.syllabusScanPresentingScanID = null 1277 syllabusScanRemoveScan(scan) 1278 showBaseModal('#syllabusScanModal') 1279 // the step was built while the modal was hidden, which is exactly where the timeline's measuring 1280 // passes read zero, so they run again now that there's something on screen to measure 1281 syllabusScanMeasureTimeline() 1282 // and once more on the next frame: in the pwa showBaseModal hands the modal to 1283 // renderSlidableModal, which reparents it into the sheet, so the widths the pass above read were 1284 // the ones it had where it used to sit rather than the ones it has now 1285 requestAnimationFrame(syllabusScanMeasureTimeline) 1286} 1287
1288// A scan can end without ever reaching a review in two ways â it failed, or it worked and there was 1289// nothing in the document â and the two differ only in which state the card takes. The record stays 1290// put, so an action on the card (where there is one) has something to go back to. 1291// 1292// message only reaches the user down the alert branch, which is for a scan whose card is already 1293// gone. The card has no room for it (see syllabusScanNotifWords â two lines and no description), so 1294// a specific reason from the server is dropped in favour of the generic title. 1295function syllabusScanEndScanWithoutReview(scan, state, message){ 1296 // a build that turned out to have nothing to show ends here rather than at 1297 // syllabusScanShowPresentedScan, so its claim on the modal is released here too. Guarded on the id 1298 // because most callers never started a build at all. 1299 if(window.syllabusScanPresentingScanID == scan.id){window.syllabusScanPresentingScanID = null} 1300 1301 if(!syllabusScanByID(scan.id)){ 1302 alert(message) 1303 syllabusScanCloseAfterFailure() 1304 return 1305 } 1306 syllabusScanSetScanState(scan, state) 1307} 1308 1309// the scan couldn't be done: a server error, a transport failure, or a document we couldn't read. 1310// Its card goes red and offers Try again. 1311function syllabusScanReportFailure(scan, message){ 1312 syllabusScanEndScanWithoutReview(scan, 'error', message) 1313} 1314 1315// the scan was done and the document had nothing in it to put on a calendar. NOT a failure and not 1316// told as one: a syllabus that's all course description and grading policy is a perfectly readable 1317// document, and saying we couldn't read it sends the user off re-uploading it in other formats 1318// hunting a problem that was never there. It offers Pick another file rather than a retry, because 1319// the same document reads the same way however many times it's sent. 1320function syllabusScanReportEmpty(scan){ 1321 syllabusScanEndScanWithoutReview(scan, 'empty', "We read your syllabus, but it doesn't list any events or tasks to add.") 1322} 1323 1324// Try again / Pick another file: the file itself only ever existed inside the request, so there's 1325// nothing to resubmit â this drops the landed scan and puts the user back at the file picker. 1326// 1327// The class is read off the scan rather than its mode, because an empty scan started without one may 1328// since have built a class it should keep; only a scan that never resolved falls back to the no-class 1329// entry point. 1330function syllabusScanRetryScan(scan){ 1331 syllabusScanRemoveScan(scan) 1332 if(!scan.classEventID && scan.classUnknownMode){ 1333 window.startSyllabusScanWithoutClass() 1334 return 1335 } 1336 window.startSyllabusScanForClass(scan.classEventID) 1337} 1338 1339// One handler for every card, and for the WHOLE of every card: the card is the action. The word at 1340// the end of the second line (Review, Try again, Pick another file) is still the affordance â it says 1341// what pressing does, and it stays a link for the keyboard â but the target is the card, because a 1342// two-word link on a toast is a small thing to hit with a thumb and everything else on the card is 1343// about that one action anyway. 1344// 1345// Which scan it belongs to comes off the card, so the cards stay independent of each other. Only two 1346// things inside opt out, and each is a control that acts on something other than the scan's result: 1347// the â and the pager. The pager needs no test here â its own handler stops the event before this 1348// element is reached (see .syllabusScanNotifPage above) â but the â is read off the target below, 1349// which is also what makes a keyboard Enter on it land as a dismiss rather than as the card's action. 1350$(document).on('click', '#syllabusScanNotifStack .syllabusScanNotifCard', function(event){ 1351 event.stopPropagation() 1352 let scan = syllabusScanByID($(this).attr('data-scan-id')) 1353 if(!scan){return} 1354 1355 if($(event.target).closest('[data-action="dismiss"]').length){ 1356 // mid-scan this is a cancel, which on this end means we stop asking: the poll timer and any 1357 // request it has out both go, and the card with them. The scan itself carries on in the process 1358 // running it and finishes into the cache, so an identical re-upload comes back instantly. 1359 if(scan.state == 'scanning'){ 1360 if(!confirm(SYLLABUS_SCAN_CANCEL_CONFIRM)){return} 1361 if(scan.poll){clearTimeout(scan.poll); scan.poll = null} 1362 if(scan.request){scan.request.abort()} 1363 } 1364 // A landed card asks nothing. It's the only thing on screen standing for the scan, and 1365 // dismissing what you can see isn't a decision anyone needs talking out of â a running scan 1366 // is the exception above, since there's a request to call off. 1367 // 1368 // This is where a scan actually ends now that closing the review parks it, so a class this
1369 // flow built unasked goes here too. It can't live in syllabusScanRemoveScan: a scan also 1370 // leaves the queue on its way INTO the modal, and that one must not take the class with it. 1371 syllabusScanRemoveClassBuiltForAbandonedScan(scan) 1372 syllabusScanRemoveScan(scan) 1373 return 1374 } 1375 1376 // Nothing to open while it runs. This state carries no link at all (syllabusScanNotifWords gives 1377 // it the file name and no action), so it was unreachable while the link WAS the target â now that 1378 // the whole card is, it has to be said: the response presentScan would render hasn't arrived yet. 1379 // iOS says the same thing in the same place, SyllabusScanQueue.act's .scanning case. 1380 if(scan.state == 'scanning'){return} 1381 1382 // Both landings that lead nowhere go back to the picker. Empty has to be named here as well as 1383 // error: without it an empty scan fell through to presentScan and opened a review with nothing 1384 // in it. 1385 if(scan.state == 'error' || scan.state == 'empty'){ 1386 syllabusScanRetryScan(scan) 1387 return 1388 } 1389 syllabusScanPresentScan(scan) 1390}) 1391 1392$(document).on('keydown', '#syllabusScanNotifStack [data-action]', function(event){ 1393 if(event.key != 'Enter' && event.key != ' '){return} 1394 event.preventDefault() 1395 $(this).trigger('click') 1396}) 1397 1398// A scan in the queue isn't work at risk: the server is running it and this page has written its id 1399// down, so leaving mid-scan costs nothing and coming back picks it up (see WHAT SURVIVES THE PAGE). 1400// 1401// A REVIEW is, and it's the one thing left here. Closing the modal keeps a review's corrections now 1402// (they're parked onto the record), but that copy lives on the page â the stored fields don't carry 1403// it â so a page leave still loses every correction made in it. 1404// 1405// The check is by visibility rather than by syllabusScanPresentedScan, which outlives its modal â the 1406// successful-add path closes the modal without resetting it, and that scan is finished, not 1407// abandoned. This is a page leave, not a modal close, which parks rather than warning. 1408// 1409// The text is ours in name only; every current browser shows its own generic wording. It's here for 1410// the handful that still read the legacy returnValue, and because the return value is what turns the 1411// prompt on at all. preventDefault is the modern spelling of the same thing, so both are set. 1412const SYLLABUS_SCAN_LEAVE_CONFIRM = "You're in the middle of reviewing a syllabus. If you leave now, you'll lose the changes you've made to it." 1413 1414$(window).on('beforeunload', function(event){ 1415 if(!syllabusScanModalIsOpen()){return} 1416 event.preventDefault() 1417 event.originalEvent.returnValue = SYLLABUS_SCAN_LEAVE_CONFIRM 1418 return SYLLABUS_SCAN_LEAVE_CONFIRM 1419}) 1420 1421// DATES AND TIMES 1422// the primitives the rest of the file works in: a date is a local "YYYY-MM-DD", a time is either 1423// 24-hour "HH:MM" (what the item objects hold) or minutes since midnight (what occurrenceData does). 1424 1425// same weekday codes as daysOccurring / FREQUENCY_TYPES day handling elsewhere 1426const SYLLABUS_SCAN_DAY_CODES = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa'] 1427 1428// One time IS filled in on the user's behalf, and only one: a task the syllabus dated without a due 1429// time is due at the end of that day, which is what "due Friday" means everywhere else on a 1430// syllabus. It arrives as 11:59pm â visible on the row and editable like any other value, not a 1431// hidden default applied at save time â so the row imports instead of stopping the whole import to 1432// ask a question whose answer was never in doubt. 1433// 1434// Nothing else is guessed at. A timed event with no end is still held back as incomplete for the 1435// user to answer on the timeline (see syllabusScanItemComplete): an hour picked for them would read 1436// as something the syllabus said, and there's no equivalent convention to lean on. 1437 1438// what a task with no stated due time is filed at â the last minute of the day it's due 1439const SYLLABUS_SCAN_DEFAULT_TASK_DUE_TIME = '23:59' 1440 1441// unix seconds -> "YYYY-MM-DD" in the user's local timezone 1442function syllabusScanUnixToDateString(unix){ 1443 let date = new Date(Number(unix) * 1000) 1444 return date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0') 1445} 1446 1447// "YYYY-MM-DD" (+ minutes into the day) ->
1447 unix seconds in the user's local timezone. 1448// dayOffset lets us get the following midnight without worrying about DST. 1449function syllabusScanDateToUnix(dateString, minutes = 0, dayOffset = 0){ 1450 let [year, month, day] = dateString.split('-').map(Number) 1451 return new Date(year, month - 1, day + dayOffset, 0, minutes).getTime() / 1000 1452} 1453 1454// "YYYY-MM-DD" -> local Date (new Date("YYYY-MM-DD") would parse as UTC and can shift a day) 1455function syllabusScanParseDate(dateString){ 1456 let [year, month, day] = dateString.split('-').map(Number) 1457 return new Date(year, month - 1, day) 1458} 1459 1460// "YYYY-MM-DD" + a number of days -> "YYYY-MM-DD" (local time, so DST-safe via Date math) 1461function syllabusScanAddDays(dateString, days){ 1462 let [year, month, day] = dateString.split('-').map(Number) 1463 return syllabusScanUnixToDateString(new Date(year, month - 1, day + days).getTime() / 1000) 1464} 1465 1466// the first date on/after dateString that falls on the given weekday code, e.g. ("2026-01-20", "M") 1467function syllabusScanAdvanceToDay(dateString, dayCode){ 1468 let offset = (SYLLABUS_SCAN_DAY_CODES.indexOf(dayCode) - syllabusScanParseDate(dateString).getDay() + 7) % 7 1469 return syllabusScanAddDays(dateString, offset) 1470} 1471 1472// "HH:MM" -> minutes since midnight (what occurrenceData uses), null if blank 1473function syllabusScanTimeToMinutes(value){ 1474 if(!value){return null} 1475 let [hours, minutes] = value.split(':').map(Number) 1476 return hours * 60 + minutes 1477} 1478 1479// the inverse: minutes since midnight -> the "HH:MM" 24-hour form the items hold 1480function syllabusScanMinutesToTime(minutes){ 1481 return String(Math.floor(minutes / 60)).padStart(2, '0') + ':' + String(minutes % 60).padStart(2, '0') 1482} 1483 1484// "14:05" -> {time: "2:05", meridiem: "pm"}, split so a range can share one meridiem 1485function syllabusScanFormatTime(value){ 1486 let [hours, minutes] = value.split(':').map(Number) 1487 return { 1488 "time": (hours % 12 || 12) + ':' + String(minutes).padStart(2, '0'), 1489 "meridiem": hours >= 12 ? 'pm' : 'am' 1490 } 1491} 1492 1493// "2:00â3:15pm", or just the start when there's no end. The meridiem is only said twice when the two 1494// ends differ. gap is what sits between a time and its meridiem â '' in the compact row chips, ' ' 1495// in the lecture summary. 1496function syllabusScanTimeRangeLabel(startTime, endTime, gap){ 1497 let start = syllabusScanFormatTime(startTime) 1498 if(!endTime){return start.time + gap + start.meridiem} 1499 let end = syllabusScanFormatTime(endTime) 1500 return (start.meridiem == end.meridiem ? start.time : start.time + gap + start.meridiem) + 'â' + end.time + gap + end.meridiem 1501} 1502 1503// RECURRENCE HELPERS 1504// the model returns recurring items as a pattern (repeats/repeatDays/firstDate/lastDate, 1505// see scanSyllabus.php's schema) rather than a dated occurrence. These helpers turn that 1506// pattern into what the app can actually store: events become native repeating events 1507// (occurrenceData.repeats, one per weekday since a repeating general event has a single 1508// start date), and tasks get expanded into individual dated to-dos (tasks have no 1509// recurrence support). 1510 1511const SYLLABUS_SCAN_DAY_NAMES = {'Su': 'Sunday', 'M': 'Monday', 'Tu': 'Tuesday', 'W': 'Wednesday', 'Th': 'Thursday', 'F': 'Friday', 'Sa': 'Saturday'} 1512const SYLLABUS_SCAN_REPEAT_WORDS = {'daily': 'every day', 'weekly': 'weekly', 'biweekly': 'every other week', 'monthly': 'monthly', 'yearly': 'yearly'} 1513 1514// most weekly items a semester can hold; also the cap on how many to-dos one recurring 1515// task is allowed to expand into, so a bad date range can't flood the user's task list 1516const SYLLABUS_SCAN_MAX_OCCURRENCES = 20 1517 1518// where a recurring item's pattern starts/ends: what the syllabus said, else the term the scan was 1519// uploaded with. The scan is passed in rather than read off the globals, because these run for a 1520// scan that has only just landed as well as for the one currently in the modal. 1521function syllabusScanPatternRange(item, scan){ 1522 return { 1523 first: item.firstDate || scan.termStart || null, 1524 last: item.lastDate || scan.termEnd || null 1525 } 1526} 1527 1528// the weekdays a pattern falls on, or null for one that isn't keyed off weekdays at all 1529function syllabusScanRepeatWeekdays(item){ 1530 let weekly = item.repeats == 'weekly' || item.repeats == 'biweekly' 1531 return (weekly && item.repeatDays && item.repeatDays.length) ? item.repeatDays : null 1532} 1533 1534// "Repeats weekly on Mondays", for the review row label 1535function syllabusScanRepeatLabel(repeats, dayCode){ 1536 let label = 'Repeats ' + (SYLLABUS_SCAN_REPEAT_WORDS[repeats] || repeats) 1537 if(dayCode && SYLLABUS_SCAN_DAY_NAMES[dayCode]){label += ' on ' + SYLLABUS_SCAN_DAY_NAMES[dayCode] + 's'} 1538 return label 1539} 1540
1541// a recurring event -> one review row (and later one repeating event) per weekday it 1542// occurs on, each anchored to its first occurrence. If we can't pin down a start date 1543// (no repeatDays, no firstDate, no semester dates), the row renders with a blank date 1544// for the user to fill in, and the recurrence still applies from whatever they pick. 1545function syllabusScanNormalizeEvent(item, scan){ 1546 if(!item.repeats){return [item]} 1547 1548 let range = syllabusScanPatternRange(item, scan) 1549 1550 return (syllabusScanRepeatWeekdays(item) || [null]).map(dayCode => { 1551 let first = item.firstDate || item.date || null 1552 if(dayCode && (first || range.first)){first = syllabusScanAdvanceToDay(first || range.first, dayCode)} 1553 return Object.assign({}, item, { 1554 "date": first, 1555 "untilDate": range.last, 1556 "repeatLabel": syllabusScanRepeatLabel(item.repeats, dayCode) 1557 }) 1558 }) 1559} 1560 1561// a recurring task -> individual dated to-dos, since tasks have no recurrence support. 1562// Falls back to a single undated row when there's not enough info to place occurrences. 1563function syllabusScanNormalizeTask(item, scan){ 1564 if(!item.repeats){return [item]} 1565 1566 let range = syllabusScanPatternRange(item, scan) 1567 let weekdays = syllabusScanRepeatWeekdays(item) 1568 let start = item.firstDate || item.dueDate || (weekdays ? range.first : null) 1569 if(!start || !range.last){return [Object.assign({}, item, {"dueDate": item.firstDate || item.dueDate || null})]} 1570 1571 let dates = [] 1572 let days = weekdays || [null] 1573 days.forEach(dayCode => { 1574 let current = dayCode ? syllabusScanAdvanceToDay(start, dayCode) : start 1575 while(current <= range.last && dates.length < SYLLABUS_SCAN_MAX_OCCURRENCES * days.length){ 1576 dates.push(current) 1577 if(item.repeats == 'monthly'){ 1578 // step a month by anchoring to the same day-of-month 1579 let [year, month, day] = current.split('-').map(Number) 1580 current = syllabusScanUnixToDateString(new Date(year, month, day).getTime() / 1000) 1581 }else{ 1582 current = syllabusScanAddDays(current, item.repeats == 'daily' ? 1 : (item.repeats == 'biweekly' ? 14 : 7)) 1583 } 1584 } 1585 }) 1586 dates.sort() 1587 1588 return dates.map(date => Object.assign({}, item, {"dueDate": date})) 1589} 1590 1591// builds the editable review list from one scan's parsed events/tasks, into the modal. onReady is 1592// called once the list is actually on the page â a round trip later than this returns, so it's the 1593// only honest moment to show the modal.
1594function renderSyllabusScanResults(scan, onReady){ 1595 let result = scan.response.result 1596 let events = (result.events || []).flatMap(item => syllabusScanNormalizeEvent(item, scan)) 1597 let tasks = (result.tasks || []).flatMap(item => syllabusScanNormalizeTask(item, scan)) 1598 if(events.length + tasks.length == 0){ 1599 syllabusScanReportEmpty(scan) 1600 return 1601 } 1602 1603 let render = function(){ 1604 renderSyllabusScanRows(events, tasks) 1605 onReady() 1606 } 1607 1608 // A parked review comes back exactly as it was left. It skips the dedupe pass as well as the 1609 // rebuild: the user has already seen those rows and may have deliberately re-included one, and 1610 // unchecking it again behind their back would undo the very edit parking exists to keep. 1611 if(scan.parkedItems){ 1612 renderSyllabusScanRows(events, tasks, scan.parkedItems) 1613 onReady() 1614 return 1615 } 1616 1617 // with no class settled on there's nothing to send the duplicate check (it's scoped to a class the 1618 // caller owns, and answers nothing without one), so the review opens with every row included. It 1619 // isn't re-run when a class is then picked: the rows are already on screen by then, and quietly 1620 // unchecking some of them under the user is worse than the duplicate it would save. 1621 // 1622 // A class the scan just built is NOT exempt: the check compares against the student's whole 1623 // semester, so an assignment already synced from Canvas onto another class still counts. 1624 if(!scan.classEventID){ 1625 render() 1626 return 1627 } 1628 1629 // flag any items that look like something already on the user's calendar this semester so we can 1630 // pre-uncheck them. A separate, cheap call (dedupeSyllabusItems.php) rather than part of the scan, 1631 // so the scan's global cache isn't touched. Fails open: if it errors, every row is included as 1632 // before. The modal stays closed until it returns so it opens on the finished list. 1633 dedupeSyllabusScanItems(scan, events, tasks, render) 1634} 1635 1636// asks the server which normalized rows duplicate something already on the user's calendar, then 1637// tags those item objects with _isDuplicate / _dupMatch so the review rows start pre-unchecked. The 1638// combined order (events then tasks) defines the index we send and get back. 1639// 1640// What gets compared against is the server's business: it reads the semester's events and tasks off 1641// the calendar itself (see dedupeSyllabusItems.php), so all we send is the class and the rows. That 1642// keeps the check working on whatever is actually saved rather than on this tab's copy of the 1643// schedule â which matters most for the case it exists for, an LMS import that landed after load. 1644function dedupeSyllabusScanItems(scan, events, tasks, callback){ 1645 let allItems = events.concat(tasks) 1646 let payload = allItems.map((item, index) => ({ 1647 "index": index, 1648 "title": item.title || '', 1649 "date": item.date || item.dueDate || null 1650 })) 1651 1652 $.ajax({ 1653 url: '/shared/resources/dedupeSyllabusItems.php', 1654 type: 'POST', 1655 dataType: 'json', 1656 data: { 1657 uuid: store.get('uuid'),
1658 // the scan's own class, not the modal's â this runs before the scan has been handed over 1659 classEventID: scan.classEventID, 1660 // context for the model, as in the scan itself: which class these rows are landing on, so 1661 // an existing item on another class doesn't read as a duplicate of one of them 1662 className: scan.className, 1663 items: JSON.stringify(payload) 1664 }, 1665 success(response){ 1666 if(response && response.error === false && Array.isArray(response.duplicates)){ 1667 response.duplicates.forEach(dup => { 1668 let item = allItems[dup.index] 1669 if(item){ 1670 item._isDuplicate = true 1671 item._dupMatch = dup.matchedTitle || '' 1672 } 1673 }) 1674 } 1675 callback() 1676 }, 1677 // fail open: render normally with nothing flagged 1678 error(){ callback() } 1679 }) 1680} 1681 1682// TIMELINE REVIEW 1683// the review list is a chronological semester timeline: items sorted by date under sticky 1684// month headers, with a spine down the left whose dots mark each item, so the semester's pacing 1685// reads at a glance. Rows are compact and read-first; clicking one expands it in place into the 1686// edit inputs. All state lives on the item objects in window.syllabusScanItems (_included, _title, 1687// _date, _start, _end, _location, _until, _type), so the list can re-render â e.g. after a date edit 1688// moves an item to another month â without losing anything, and the add pass reads the same fields 1689// the inputs write. 1690 1691const SYLLABUS_SCAN_MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] 1692const SYLLABUS_SCAN_MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] 1693const SYLLABUS_SCAN_DAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] 1694 1695// "Tue 3" for the compact row's date cell 1696function syllabusScanDayLabel(item){ 1697 if(!item._date){return 'No date'} 1698 let date = syllabusScanParseDate(item._date) 1699 return SYLLABUS_SCAN_DAY_SHORT[date.getDay()] + ' ' + date.getDate() 1700} 1701 1702// "14:05" -> "2:05pm": the g:ia form the time dropdown reads and writes. The items themselves stay 1703// on 24-hour "HH:MM", so this and syllabusScanReadTimeInput are the only crossing points. 1704function syllabusScanTimeToPickerValue(value){ 1705 if(!value){return ''} 1706 let parts = syllabusScanFormatTime(value) 1707 return parts.time + parts.meridiem 1708} 1709 1710// the compact row's time chip: "2:00â3:15pm" for a timed event, "11:59pm" for a task's due time. 1711// A task with no time yet says so, the way a row with no date does â it's a gap the user has to 1712// close, not a value. An event with neither time is simply all-day, so it says nothing; one holding 1713// only an end is short a start, and names that gap the same way. 1714function syllabusScanTimeChip(item){ 1715 if(item._start){return syllabusScanTimeRangeLabel(item._start, item._end, '')} 1716 if(item._type == 'task'){return 'No time'} 1717 return item._end ? 'No start' : '' 1718} 1719 1720// the row's whole left-hand cell, "Thu 3 · 11:59pm", built here rather than inline so the render and 1721// the in-place refresh can't drift 1722function syllabusScanWhenLabel(item){ 1723 let time = syllabusScanTimeChip(item) 1724 return syllabusScanDayLabel(item) + (time ? ' · ' + time : '') 1725} 1726 1727// seeds the editable state the timeline works from 1728function syllabusScanInitRowState(item, type){ 1729 item._type = type 1730 // a likely duplicate starts excluded so we don't silently re-add it; the user can re-check it 1731 item._included = !item._isDuplicate 1732 item._title = item.title || '' 1733 item._date = (type == 'event' && item.repeats) ? (item.date || '') : (item.date || item.dueDate || '') 1734 // A time the syllabus never gave is left blank for the user to fill in rather than guessed at, 1735 // with the one exception above: a task with no due time is filed at the end of its day, on the 1736 // row from the start so it's a value the user can see and change. A timed event with no end is 1737 // still an incomplete row (see syllabusScanItemComplete). An end that isn't after its start is no 1738 // more usable than no end at all, so it's dropped and asked for the same way. 1739 // (An event with no start AND no end is a different thing â that's an all-day event, and 1740 // complete. One holding only an end is half a span, and asks for the start it's short of.) 1741 item._start = type == 'event' ? ((!item.allDay && item.startTime) || '') : (item.dueTime || SYLLABUS_SCAN_DEFAULT_TASK_DUE_TIME) 1742 item._end = (type == 'event' && !item.allDay && item.endTime) || '' 1743 let endMinutes = syllabusScanTimeToMinutes(item._end) 1744 if(type == 'event' && item._start && endMinutes != null && endMinutes <= syllabusScanTimeToMinutes(item._start)){ 1745 item._end = '' 1746 } 1747 item._location = item.location || '' 1748 item._until = item.untilDate || '' 1749 // the editable frequency, seeded from the scan's pattern; only events can repeat 1750 // (recurring tasks were already expanded into individual dated to-dos above) 1751 item._repeats = (type == 'event' && item.repeats) || null 1752 return item 1753} 1754 1755// builds the review timeline once duplicate flags (if any) are attached to the item objects 1756// `prebuilt` is a parked review's own rows, handed back instead of built from the response â see 1757// syllabusScanParkPresentedScan. Same array shape either way, so everything below is unchanged.
1758function renderSyllabusScanRows(events, tasks, prebuilt){ 1759 // the items array is the single source of truth for rendering, edits, and the final add. Its 1760 // order stays events-then-tasks â the order dedupeSyllabusScanItems indexed and the order the add 1761 // pass walks â while the timeline sorts a copy of the indices for display. 1762 window.syllabusScanItems = [] 1763 if(prebuilt){ 1764 window.syllabusScanItems = prebuilt 1765 }else{ 1766 events.forEach(item => window.syllabusScanItems.push(syllabusScanInitRowState(item, 'event'))) 1767 tasks.forEach(item => window.syllabusScanItems.push(syllabusScanInitRowState(item, 'task'))) 1768 } 1769 syllabusScanCollapseAllRows() 1770 window.syllabusScanInvalidHighlight = false 1771 // a different scan going into the same modal must not inherit the last one's complaint 1772 hideSyllabusScanError() 1773 1774 renderSyllabusScanTimeline() 1775 // clear the inline display rather than .show() (which would pin an inline "block"): in the pwa 1776 // sheet the step's CSS makes it a flex column so the timeline can fill the sheet 1777 $('#syllabusScanReviewStep').css('display', '') 1778 // the list is what the picker names a destination for, so it arrives with it 1779 syllabusScanShowClassSelect() 1780 // now that it's on screen, the measurements the render couldn't take 1781 syllabusScanMeasureTimeline() 1782} 1783 1784// The dates line up down the list rather than each row starting wherever its own label ends. Done 1785// by measurement rather than a guessed column width: the widest label wins and nothing is padded 1786// past it, so the titles sit as close as alignment allows. 1787function syllabusScanAlignWhenColumn(){ 1788 let cells = $('#syllabusScanResults .syllabusScanRowWhen') 1789 if(!cells.length){return} 1790 cells.css('width', '') 1791 let widest = 0 1792 cells.each(function(){widest = Math.max(widest, this.getBoundingClientRect().width)}) 1793 if(widest){cells.css('width', Math.ceil(widest) + 'px')} 1794} 1795 1796// Where the one spine starts and stops: the first DATED row's dot down to the last row's. Measured 1797// rather than guessed at, because a dot sits at its row's vertical centre and an open row is three 1798// times the height of a closed one. Between them it passes behind the month headers, which are 1799// opaque. 1800// 1801// The spine is the CHRONOLOGY's line, so "Missing details" is outside it: those rows show their 1802// dots with nothing joining them. A row with no date isn't anywhere on a timeline yet, and running 1803// the line up through the bucket read as the semester beginning above its own first month. So the 1804// line starts clean at the first month row's dot, with no lead-in above it. 1805function syllabusScanDrawSpines(){ 1806 let line = $('#syllabusScanResults .syllabusScanTimeline').get(0) 1807 if(!line){return} 1808 // The unfinished rows are always the leading run, so what's left is contiguous and the line 1809 // still has exactly one start and one end. Nothing dated at all (every row unfinished) leaves 1810 // no rows here and therefore no spine â just the column of dots, which is the whole of it. 1811 let rows = $(line).find('.syllabusScanRow').not('.syllabusScanRowNeedsInfo') 1812 if(!rows.length){ 1813 line.style.setProperty('--syllabusScanSpineTop', '0px') 1814 line.style.setProperty('--syllabusScanSpineEnd', '0px') 1815 return 1816 } 1817 let top = line.getBoundingClientRect().top 1818 let first = rows.first().get(0).getBoundingClientRect() 1819 let last = rows.last().get(0).getBoundingClientRect() 1820 let start = first.top + first.height / 2 - top 1821 let end = last.top + last.height / 2 - top 1822 line.style.setProperty('--syllabusScanSpineTop', Math.round(start) + 'px') 1823 line.style.setProperty('--syllabusScanSpineEnd', Math.round(Math.max(0, end - start)) + 'px') 1824} 1825 1826// Both measuring passes need the step on screen: renderSyllabusScanTimeline runs while the review 1827// step is still display:none (see the scan handler), where every getBoundingClientRect is zero and 1828// the spine would come out with no height at all. So they bail while hidden and are re-run the 1829// moment the step is revealed. 1830function syllabusScanMeasureTimeline(){ 1831 let results = $('#syllabusScanResults').get(0) 1832 if(!results || !results.getClientRects().length){return} 1833 syllabusScanAlignWhenColumn() 1834 syllabusScanDrawSpines() 1835} 1836 1837// (re)draws the whole timeline from window.syllabusScanItems. Cheap enough to run on any state 1838// change, which is what keeps the month groups and the add-button count honest after an edit; 1839// inclusion and expansion state survive because they live on the items and in 1840// window.syllabusScanExpandedIndexes, not in the DOM â the one exception being a row this render 1841// moves out of "Missing details", which closes as it goes (syllabusScanCollapseGraduatedRows).
1842function renderSyllabusScanTimeline(){ 1843 let items = window.syllabusScanItems 1844 1845 // chronological order for display only; ties keep original (events-then-tasks) order. 1846 // Items missing anything the add pass requires (see syllabusScanItemComplete) are split out 1847 // into their own section, whatever it is they're missing. 1848 let complete = [], needsInfo = [] 1849 items.forEach((item, index) => (syllabusScanItemComplete(item) ? complete : needsInfo).push(index)) 1850 complete.sort((a, b) => items[a]._date < items[b]._date ? -1 : (items[a]._date > items[b]._date ? 1 : a - b)) 1851 syllabusScanCollapseGraduatedRows(needsInfo) 1852 1853 // only worth labeling years on the month headers when the syllabus spans two of them (e.g. a 1854 // spring class scanned in December) 1855 let years = new Set(complete.map(index => items[index]._date.split('-')[0])) 1856 1857 let monthHeader = date => 1858 `<div class="syllabusScanMonthHeader">${SYLLABUS_SCAN_MONTH_NAMES[date.getMonth()] + (years.size > 1 ? ' ' + date.getFullYear() : '')}</div>` 1859 1860 // Incomplete items lead, not trail: they're the only rows needing something done before the 1861 // import will take them, and a section under a semester's worth of dates is one nobody scrolls 1862 // to. The header doesn't name what's missing because it varies â a name, a date, one end of an 1863 // event's span â and each row says its own in the cell the value would have been in. 1864 let html = needsInfo.length 1865 ? `<div class="syllabusScanMonthHeader">Missing details</div>` 1866 + needsInfo.map(renderSyllabusScanTimelineRow).join('') 1867 : '' 1868 let lastMonth = null 1869 1870 complete.forEach(index => { 1871 let date = syllabusScanParseDate(items[index]._date) 1872 let month = date.getFullYear() + '-' + date.getMonth() 1873 if(month != lastMonth){ 1874 // a calendar month with nothing in it at all still gets its header and a note, so an 1875 // empty November between October and December items reads as deliberately empty rather 1876 // than the list skipping ahead 1877 if(lastMonth !== null){ 1878 let parts = lastMonth.split('-') 1879 let gapMonth = new Date(Number(parts[0]), Number(parts[1]) + 1, 1) 1880 while(gapMonth < new Date(date.getFullYear(), date.getMonth(), 1)){ 1881 html += monthHeader(gapMonth) 1882 html += `<div class="syllabusScanEmptyMonth">No tasks</div>` 1883 gapMonth = new Date(gapMonth.getFullYear(), gapMonth.getMonth() + 1, 1) 1884 } 1885 } 1886 html += monthHeader(date) 1887 lastMonth = month 1888 } 1889 html += renderSyllabusScanTimelineRow(index) 1890 }) 1891 1892 // the expanded row's time dropdowns go down with the old markup and come back bound to the new 1893 // inputs. (The dots' coloring is pure CSS: --syllabusScanAccent on #syllabusScanModal in all.css.) 1894 syllabusScanDestroyTimePickers() 1895 $('#syllabusScanResults').html(`<div class="syllabusScanTimeline">${html}</div>`) 1896 syllabusScanInitTimePickers() 1897 syllabusScanSizeInputsToText() 1898 syllabusScanMeasureTimeline() 1899 // if a re-render finds nothing flagged anymore (rows were fixed or unchecked), the 1900 // add-attempt highlight mode retires itself 1901 if(window.syllabusScanInvalidHighlight && !$('.syllabusScanRowInvalid').length){ 1902 window.syllabusScanInvalidHighlight = false 1903 } 1904 1905 updateSyllabusScanAddCount() 1906} 1907 1908// badges under the title: the recurrence pattern, and the duplicate warning (kept visible even after 1909// re-including, so the user remembers why it started excluded). '' when a row has neither. 1910function syllabusScanRowBadgesHTML(item){ 1911 let badges = '' 1912 if(item._type == 'event' && item._repeats){ 1913 // a scan-detected pattern keeps its weekday phrasing ("Repeats weekly on Mondays"); 1914 // a frequency the user picked themselves gets the generic wording 1915 let label = (item._repeats == item.repeats && item.repeatLabel) ? item.repeatLabel : syllabusScanRepeatLabel(item._repeats) 1916 badges += `<span class="syllabusScanBadge syllabusScanRepeatBadge">${escapeHTML(label)}</span>` 1917 } 1918 if(item._isDuplicate){badges += `<span class="syllabusScanBadge syllabusScanDupBadge">Already in your calendar${item._dupMatch ? ` as "${escapeHTML(item._dupMatch)}"` : ''}</span>`} 1919 return badges ? `<div class="syllabusScanRowBadges">${badges}</div>` : '' 1920} 1921 1922// TIME FIELDS 1923// every clock field in the timeline is the app's own time dropdown â the jquery timepicker the 1924// create-task sidebar (#createTaskTimeBox) and the create-event form use â never the browser's 1925// native time control, so the list, the wording and the themed styling are the same everywhere. 1926// Its settings mirror the sidebar's: g:ia labels, minute steps (so 11:59pm is actually reachable), 1927// and a 12:00am-11:59pm range. An event's Ends field additionally starts its list at that row's 1928// start time, which with the 11:59pm ceiling is what stops an event running backwards or spilling 1929// over midnight. 1930const SYLLABUS_SCAN_TIMEPICKER_SETTINGS = { 1931 'timeFormat': 'g:ia', 1932 'step': 1, 1933 'minTime': '12:00am', 1934 'maxTime': '11:59pm', 1935 'selectOnBlur': true 1936} 1937 1938// (re)binds the dropdown to whatever edit block is on screen. Safe to call on every render: the 1939// plugin's methods no-op on inputs it hasn't touched, and each render tears the old pickers down 1940// first so their (1440-row) lists don't pile up on the body, where the plugin parks them. 1941function syllabusScanInitTimePickers(){ 1942 // every open row, not just one: the minTime a field gets is read off ITS OWN row's start, so 1943 // binding these per row is what stops one card's Ends list being floored by another card's start 1944 $('.syllabusScanRowExpanded').each(function(){ 1945 let row = $(this) 1946 let item = syllabusScanItemForRow(row) 1947 1948 row.find('.syllabusScanTimeInput').each(function(){ 1949 let input = $(this) 1950 // the Ends list can't offer anything at or before the start; with no start yet (an all-day 1951 // row) it falls back to the shared floor and the toggle keeps it disabled anyway 1952 let minTime = (input.hasClass('syllabusScanEnd') && item._start) 1953 ? syllabusScanTimeToPickerValue(item._start) 1954 : SYLLABUS_SCAN_TIMEPICKER_SETTINGS.minTime 1955 input.timepicker($.extend({}, SYLLABUS_SCAN_TIMEPICKER_SETTINGS, {'minTime': minTime})) 1956 }) 1957 }) 1958} 1959 1960// drops the pickers (and the lists they appended to the body) before the DOM under them goes away. 1961// One at a time: the plugin's remove reads the list off the first element of a set, so handing it 1962// both fields at once would orphan the second one's list on the body. 1963function syllabusScanDestroyTimePickers(){ 1964 $('#syllabusScanResults').find('.syllabusScanTimeInput').each(function(){ 1965 $(this).timepicker('remove') 1966 }) 1967} 1968 1969// an edit field's current value as the "HH:MM" the items store, or '' when it's empty or the 1970// dropdown can't make a time of what's in it. Reading through the plugin means whatever it 1971// accepts by typing ("10p", "1030") lands the same way picking from the list does. 1972function syllabusScanReadTimeInput(input){ 1973 if(!input.length){return ''} 1974 if(input.hasClass('ui-timepicker-input')){ 1975 let seconds = input.timepicker('getSecondsFromMidnight') 1976 return seconds == null ? '' : syllabusScanMinutesToTime(Math.floor(seconds / 60)) 1977 } 1978
1979 // no dropdown bound (the plugin didn't load): read the "2:05pm" text ourselves rather than 1980 // letting a display-format value through as though it were the "HH:MM" the items store 1981 let match = /^\s*(\d{1,2})(?::(\d{2}))?\s*([ap])\.?m?\.?\s*$/i.exec(input.val() || '') 1982 if(!match){return ''} 1983 let hours = Number(match[1]) % 12 + (match[3].toLowerCase() == 'p' ? 12 : 0) 1984 return syllabusScanMinutesToTime(hours * 60 + Number(match[2] || 0)) 1985} 1986 1987// The disclosure caret at the right edge of every row. Always drawn, never hover-revealed â it's the 1988// only thing saying the rows do anything, and on touch there's no hover to reveal it. 1989const SYLLABUS_SCAN_CARET_DOWN = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 9 7 7 7-7"/></svg>` 1990const SYLLABUS_SCAN_CARET_UP = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 15 7-7 7 7"/></svg>` 1991 1992// Exclude closes the card on a line of its own, under the notes, at the right edge. It used to ride 1993// the right end of the last line of fields, which put it above the notes once those arrived â and 1994// the one control that acts on the whole row shouldn't sit in the middle of the card. 1995// 1996// It says Exclude and not Delete because nothing is being deleted: none of this is on the calendar 1997// yet, and the flag it flips is _included. On an already-excluded row it offers Include. 1998function syllabusScanRowEditFooter(item){ 1999 return `<div class="syllabusScanRowLine syllabusScanRowFooterLine"> 2000 <span class="syllabusScanRowState${item._included ? ' syllabusScanRowDelete' : ' syllabusScanRowRestore'}" role="button" tabindex="0">${item._included ? 'Exclude' : 'Include'}</span></div>` 2001} 2002 2003// the row's date and time as one cell on the left of a closed row. The open row drops it: the same 2004// two values become the pills on the first line of the card below. 2005function syllabusScanRowWhenHTML(item, expanded){ 2006 if(expanded){return ''} 2007 return `<span class="syllabusScanRowWhen">${syllabusScanWhenLabel(item)}</span>` 2008} 2009 2010// "Thu, Sep 10" rather than the browser's 09/10/2026: a real date input does the picking, but it 2011// sits invisibly over a span carrying the friendly text, so the pill reads like the rest of the row 2012// instead of like a form control. The span is what's restyled; the input is only ever the mechanism. 2013function syllabusScanDateLabel(dateString){ 2014 if(!dateString){return 'Pick a date'} 2015 let date = syllabusScanParseDate(dateString) 2016 return SYLLABUS_SCAN_DAY_SHORT[date.getDay()] + ', ' + SYLLABUS_SCAN_MONTH_SHORT[date.getMonth()] + ' ' + date.getDate() 2017} 2018 2019// the recurrence end holds nothing but a date, the same as the row's own date pill a few places to 2020// its left, so both of its states keep the word in front of it 2021function syllabusScanUntilLabel(value){ 2022 return value ? 'Until ' + syllabusScanDateLabel(value) : 'Repeats until' 2023} 2024 2025function syllabusScanDatePillHTML(inputClass, value, label){ 2026 return `<span class="syllabusScanPill syllabusScanDatePill${value ? '' : ' syllabusScanDatePillEmpty'}"> 2027 <span class="syllabusScanDateLabel">${label}</span> 2028 <input type="date" class="${inputClass}" value="${value}"></span>` 2029} 2030 2031// The frequency dropdown, wrapped so it can be a pill like everything else on the line. 2032// 2033// A bare <select> can't: the border-radius and border it inherits from .syllabusScanPill compute 2034// fine and then go unpainted, because appearance:auto hands the whole box to the platform, which 2035// draws its own â squarer corners and a native stepper glyph in a row of 9px pills. Turning 2036// appearance off fixes the box and takes the dropdown indicator with it, so the pill supplies one. 2037// 2038// Same shape as the date pill above: a span carrying the chrome, with the real control inside it. 2039// The caret is pointer-events:none over a select stretched across the whole pill, so a click 2040// anywhere â the caret's corner included â still opens the list. 2041function syllabusScanRepeatsPillHTML(options){ 2042 return `<span class="syllabusScanPill syllabusScanRepeatsPill"> 2043 <select class="syllabusScanRepeats">${options}</select> 2044 <span class="syllabusScanRepeatsCaret">
2044${SYLLABUS_SCAN_CARET_DOWN}</span></span>` 2045} 2046 2047// The date and time as pills you tap to get the pickers â the same shape the closed row wears for 2048// its time, so opening a row reads as the value becoming tappable rather than becoming a form. 2049function syllabusScanRowWhenFieldsHTML(item){ 2050 // "Starts" on an event, where it's one end of a span and the field beside it says "Ends". A task 2051 // has no span â the single time on it is when the thing is due â so that one stays "Time". 2052 let startLabel = item._type == 'event' ? 'Starts' : 'Time' 2053 let times = `<input type="text" class="syllabusScanStart syllabusScanTimeInput syllabusScanPill" value="${syllabusScanTimeToPickerValue(item._start)}" placeholder="${startLabel}">` 2054 if(item._type == 'event'){ 2055 times += `<input type="text" class="syllabusScanEnd syllabusScanTimeInput syllabusScanPill" value="${syllabusScanTimeToPickerValue(item._end)}" placeholder="Ends">` 2056 } 2057 return `<div class="syllabusScanRowLine syllabusScanWhenFields"> 2058 ${syllabusScanDatePillHTML('syllabusScanDate', item._date, syllabusScanDateLabel(item._date))}${times}</div>` 2059} 2060 2061// What the syllabus said about this row beyond its name â grade weight, where it's submitted, the 2062// drop policy (scanSyllabus.php asks for 1-2 short sentences of exactly that). It rides along on 2063// every scanned row and lands in the task's or event's own notes field, but until now nothing in the 2064// review showed it, so a scan quietly imported a paragraph per row that you only saw afterwards. 2065// 2066// Read, not edit: it's here to tell you what's coming and to tell two same-named rows apart, and 2067// it's fully editable on the task itself once it's added. An editable field would also have to be a 2068// textarea, which is the one shape this card of single-line pills has no room for. Two lines, then 2069// it clips (CSS), with the whole of it in the tooltip. 2070function syllabusScanRowNotesHTML(item){ 2071 let notes = (item.notes || '').trim() 2072 if(!notes){return ''} 2073 return `<div class="syllabusScanRowNotes" title="${escapeHTML(notes)}">${escapeHTML(notes)}</div>` 2074} 2075 2076// The fields under an open row, in the order fields -> notes -> Exclude. Deliberately no Title 2077// field: the row's own title becomes the editable one in place (see renderSyllabusScanTimelineRow), 2078// so the name never appears twice. The fields keep a fixed place at the top of the card whether or 2079// not a row came with notes, so clicking through rows doesn't shift them around under the cursor, 2080// and Exclude closes it out below everything it acts on. 2081function syllabusScanRowEditHTML(item){ 2082 if(item._type == 'task'){ 2083 // a task is just a name and a due date/time: the name edits in the row, the rest is this line 2084 return `<div class="syllabusScanRowEdit">${syllabusScanRowWhenFieldsHTML(item)}${syllabusScanRowNotesHTML(item)}${syllabusScanRowEditFooter(item)}</div>` 2085 } 2086 2087 // all day mirrors the create-event switch: no clock times at all. It reads as on exactly when the 2088 // row has neither of them â an end with no start isn't an all-day event, it's half a span still 2089 // waiting on its start (see syllabusScanItemComplete), and drawing "All day" over a filled Ends 2090 // field would be flatly wrong. 2091 let allDay = !item._start && !item._end 2092 let options = Object.keys(FREQUENCY_TYPES).map(key => 2093 `<option value="${key}"${(item._repeats || 'doesNotRepeat') == key ? ' selected' : ''}>${FREQUENCY_TYPES[key]}</option>`).join('') 2094 let untilHtml = item._repeats ? syllabusScanDatePillHTML('syllabusScanUntil', item._until, syllabusScanUntilLabel(item._until)) : '' 2095 return ` 2096 <div class="syllabusScanRowEdit"> 2097 ${syllabusScanRowWhenFieldsHTML(item)} 2098 <div class="syllabusScanRowLine"> 2099 <input type="text" class="syllabusScanLocation syllabusScanPill syllabusScanPillWide" value="${escapeHTML(item._location)}" placeholder="Location"> 2100 <span class="syllabusScanAllDayToggle syllabusScanPill syllabusScanPillButton${allDay ? ' syllabusScanAllDayOn' : ''}" role="button" tabindex="0" data-allday="${allDay ? '1' : '0'}">${allDay ? 'All day' : 'Not all day'}</span> 2101 ${syllabusScanRepeatsPillHTML(options)}${untilHtml} 2102 </div> 2103 ${syllabusScanRowNotesHTML(item)} 2104 ${syllabusScanRowEditFooter(item)} 2105 </div>` 2106} 2107
2108// one compact row (plus its edit inputs when it's the expanded one) 2109function renderSyllabusScanTimelineRow(index){ 2110 let item = window.syllabusScanItems[index] 2111 let expanded = syllabusScanRowIsExpanded(index) 2112 let invalid = window.syllabusScanInvalidHighlight && syllabusScanItemIncomplete(item) 2113 // The red dot: a row that needs something before it can be added. Same test the outline and the 2114 // import gate use, so an excluded row is never flagged however incomplete it is â nothing is 2115 // being asked of it, and its dot goes grey with the rest of the row rather than staying red (the 2116 // red carries !important for the dark and theme overrides, so it would otherwise outrank 2117 // .syllabusScanRowExcluded). 2118 // 2119 // This one is LIVE: syllabusScanRefreshRowDot re-tests it on every in-place edit, so answering 2120 // what a row was missing turns its dot blue under the user's hand rather than at the next 2121 // render. syllabusScanRowNeedsInfo below is the other half of the pair and is deliberately NOT 2122 // live â see the comment on syllabusScanRefreshRowDot. 2123 let incomplete = syllabusScanItemIncomplete(item) 2124 // Where this render FILED the row: under "Missing details" rather than under a month. Not a 2125 // colour â it's what syllabusScanDrawSpines measures around, since the spine is the chronology's 2126 // line and undated rows aren't on it yet. Set once per render and left alone in between, so a 2127 // dot going blue mid-edit doesn't drag the spine's start up into the bucket before the row has 2128 // actually moved out of it. 2129 // 2130 // The bucket takes rows on completeness ALONE (see renderSyllabusScanTimeline), so this asks the 2131 // same question and not the dot's: an EXCLUDED row with no date is filed up there too, and while 2132 // nothing is being asked of it, it's still not anywhere on a timeline â reading it as a spine row 2133 // started the line inside the bucket, which is the one thing the spine is not allowed to do. 2134 let needsInfo = !syllabusScanItemComplete(item) 2135 2136 return ` 2137 <div class="syllabusScanRow${item._included ? '' : ' syllabusScanRowExcluded'}${expanded ? ' syllabusScanRowExpanded' : ''}${invalid ? ' syllabusScanRowInvalid' : ''}${incomplete ? ' syllabusScanRowIncomplete' : ''}${needsInfo ? ' syllabusScanRowNeedsInfo' : ''}" data-type="${item._type}" data-index="${index}"> 2138 <div class="syllabusScanRowMain"> 2139 ${syllabusScanRowWhenHTML(item, expanded)} 2140 ${expanded 2141 ? `<input type="text" class="syllabusScanTitle syllabusScanPill" value="${escapeHTML(item._title)}" placeholder="Untitled" size="1">` 2142 : `<span class="syllabusScanRowTitle">${escapeHTML(item._title) || 'Untitled'}</span>`} 2143 <span class="syllabusScanRowCaret" role="button" tabindex="0" aria-label="${expanded ? 'Close this item' : 'Edit this item'}">${expanded ? SYLLABUS_SCAN_CARET_UP : SYLLABUS_SCAN_CARET_DOWN}</span> 2144 </div> 2145 ${syllabusScanRowBadgesHTML(item)} 2146 ${expanded ? syllabusScanRowEditHTML(item) : ''} 2147 </div>` 2148} 2149 2150// the item a row is drawn from. Every handler below starts here, since a row's data-index is the 2151// only thing tying the DOM back to window.syllabusScanItems. 2152function syllabusScanItemForRow(row){ 2153 return window.syllabusScanItems[$(row).attr('data-index')] 2154} 2155 2156// EXPANDED ROWS 2157// Any number of rows can be open at once. Reviewing a syllabus is a comparing job â this assignment 2158// against that one, two rows the scan dated the same week â and a list that closes what you were 2159// reading the moment you open anything else makes you do it from memory. So the state is a set of 2160// indices rather than one, and everything that walks "the expanded row" walks all of them. 2161window.syllabusScanExpandedIndexes = new Set() 2162 2163// what the last render put under "Missing details", so the next one can tell a row that has since 2164// been answered from one that was always fine (see syllabusScanCollapseGraduatedRows) 2165window.syllabusScanNeedsInfoIndexes = new Set() 2166 2167function syllabusScanRowIsExpanded(index){ 2168 return window.syllabusScanExpandedIndexes.has(index) 2169} 2170 2171function syllabusScanCollapseAllRows(){ 2172 window.syllabusScanExpandedIndexes = new Set() 2173 window.syllabusScanNeedsInfoIndexes = new Set() 2174} 2175 2176// A row opened out of "Missing details" is a question being answered, and once it's answered the row 2177// is done: leaving it open would park a card in the middle of the dates while the user moves on to 2178// the next question, and the section they're working through would read as though it still wanted 2179// something from that row. So a row that was in the section last render and isn't in
2179it this one 2180// folds up as it lands in its month â the same render that moves it is the one that closes it. 2181// 2182// Only rows that graduated: everything else keeps whatever it was, because opening a second row to 2183// compare it against a first is the whole reason several can be open at once. 2184function syllabusScanCollapseGraduatedRows(needsInfo){ 2185 let stillNeedsInfo = new Set(needsInfo) 2186 window.syllabusScanExpandedIndexes.forEach(index => { 2187 if(window.syllabusScanNeedsInfoIndexes.has(index) && !stillNeedsInfo.has(index)){ 2188 window.syllabusScanExpandedIndexes.delete(index) 2189 } 2190 }) 2191 window.syllabusScanNeedsInfoIndexes = stillNeedsInfo 2192} 2193 2194// folds whatever's sitting in the open rows' inputs back into their items, so a re-render (or the 2195// add pass) never loses an edit whose change event hasn't fired yet 2196function syllabusScanCommitExpandedRows(){ 2197 $('.syllabusScanRowExpanded').each(function(){ 2198 syllabusScanCommitRow($(this)) 2199 }) 2200} 2201 2202// The all-day toggle follows the times rather than the other way round: a time typed into a row that 2203// rendered all-day flips it in place, so the card doesn't sit there saying "All day" over a start 2204// time until the next re-render (where all day is derived from the times) contradicts it. Only ever 2205// in that direction â emptying a field is not how you ask for an all-day row, and switching the 2206// toggle back on under the user would take the field they just cleared away with it. 2207function syllabusScanSyncAllDayToggle(row, item){ 2208 let toggle = row.find('.syllabusScanAllDayToggle') 2209 if(!toggle.length || !(item._start || item._end)){return} 2210 toggle.attr('data-allday', '0').removeClass('syllabusScanAllDayOn').text('Not all day') 2211} 2212 2213function syllabusScanCommitRow(row){ 2214 if(!row.length){return} 2215 let item = syllabusScanItemForRow(row) 2216 item._title = row.find('.syllabusScanTitle').val() 2217 item._date = row.find('.syllabusScanDate').val() 2218 if(item._type == 'event'){ 2219 // the frequency select's doesNotRepeat maps to the null the add pass expects 2220 let repeatsSelect = row.find('.syllabusScanRepeats') 2221 if(repeatsSelect.length){item._repeats = repeatsSelect.val() == 'doesNotRepeat' ? null : repeatsSelect.val()} 2222 // All day means no clock times at all, exactly like the create-event switch â but it's a 2223 // shortcut for emptying the two fields, not a lock on them, so the fields are what's read here 2224 // and the toggle never overrules them. A row the scan gave no start to renders all-day with 2225 // both fields live (only the toggle's own click disables them), so a time typed into one is 2226 // the user saying this isn't an all-day event; taking the toggle's word for it instead 2227 // dropped that edit on the spot and the next re-render â the press of Import that flags a row 2228 // still missing its date, say â put the blank field back. Turning all day ON clears both 2229 // fields first, so a row that really is all-day still reads as empty. 2230 item._start = syllabusScanReadTimeInput(row.find('.syllabusScanStart')) 2231 item._end = syllabusScanReadTimeInput(row.find('.syllabusScanEnd')) 2232 syllabusScanSyncAllDayToggle(row, item) 2233 item._location = row.find('.syllabusScanLocation').val() 2234 // the until input only exists while the row's frequency is a repeating one 2235 let until = row.find('.syllabusScanUntil') 2236 if(until.length){item._until = until.val()} 2237 }else{ 2238 item._start = syllabusScanReadTimeInput(row.find('.syllabusScanStart')) 2239 } 2240} 2241 2242// keeps a row's read-mode line in sync with its item without touching the edit inputs: the in-place 2243// path for edits that shouldn't tear down the DOM the user is typing into. The open row's when cell 2244// is left alone â those values ARE its date and time inputs, and rewriting them would yank the field 2245// out from under an open picker. 2246function syllabusScanRefreshRowReadMode(row, item){ 2247 row.find('.syllabusScanRowTitle').text(item._title.trim() || 'Untitled') 2248 if(!row.hasClass('syllabusScanRowExpanded')){ 2249 row.find('.syllabusScanRowWhen').html(syllabusScanWhenLabel(item)) 2250 } 2251 2252 // rebuild the badges outright: the duplicate badge is fixed but the repeat badge follows the 2253 // (editable) frequency 2254 row.find('.syllabusScanRowBadges').remove() 2255 let badges = syllabusScanRowBadgesHTML(item) 2256 if(badges){$(badges).insertAfter(row.find('.syllabusScanRowMain'))} 2257} 2258 2259// INVALID-ROW HIGHLIGHTING 2260// rows the add pass can't take already live under the timeline's "Missing details" section; rather 2261// than silently skipping them (which reads as items vanishing), trying to add while any are still
2262// included flags them â an outline on each and a scroll to the section. The mode lives on 2263// window.syllabusScanInvalidHighlight and retires itself once every flagged row is fixed or excluded. 2264 2265// the one bar every "is this row ready?" question keys off â the timeline's grouping, the add count 2266// and the add pass all ask this, so none of them can drift on what addable means. A row needs its 2267// name and its date. Beyond that: 2268// - a task needs nothing more: one with no due time is filed at 11:59pm, the last minute of the day 2269// it's due (see syllabusScanInitRowState), rather than held back to be asked about. 2270// - an event needs both ends of its span or neither. Neither is an all-day event, and isn't missing 2271// anything. One end can't be drawn: an event stored without an end paints with no height at all 2272// (that's the negative (endTime - startTime) block), and an end with no start is a span with one 2273// end â asking for the start it's short of beats both silently dropping the end the user typed 2274// and inventing a start to pair it with. 2275function syllabusScanItemComplete(item){ 2276 if(!item._title.trim() || !item._date){return false} 2277 if(item._type == 'task'){return true} 2278 return !!item._start == !!item._end 2279} 2280 2281// a row the add pass would have to skip: included, but not addable as it stands 2282function syllabusScanItemIncomplete(item){ 2283 return item._included && !syllabusScanItemComplete(item) 2284} 2285 2286// the add pass's gate: when any included row is incomplete, turn the highlight mode on, bring the 2287// flagged rows into view, and report true so the add bails 2288function syllabusScanHighlightInvalidCheckedItems(){ 2289 if(!window.syllabusScanItems.some(syllabusScanItemIncomplete)){return false} 2290 2291 window.syllabusScanInvalidHighlight = true 2292 // the re-render is what applies the outlines, and it has to happen before the message goes up: 2293 // showSyllabusScanError scrolls to and shakes .syllabusScanRowInvalid, which doesn't exist yet 2294 renderSyllabusScanTimeline() 2295 showSyllabusScanError('invalidRows') 2296 return true 2297} 2298 2299// IMPORT ERRORS 2300// Every way the import can refuse reports the same way: a line of red under the Import button, which 2301// slides down and grows the modal. They were alert()s, which is the wrong shape for all of them â 2302// an alert has to be dismissed before you can look at the rows it's describing, so by the time you 2303// can act on the message it's gone, and it says nothing about WHERE the problem is. 2304// 2305// So each error also points: the field or rows at fault get the red outline and are scrolled into 2306// view (the invalid-row pass above was already doing both; the class picker now does too). The 2307// message names what to do, the highlight says where, and neither blocks the other. 2308// 2309// The kind is remembered on window.syllabusScanErrorKind so syllabusScanRefreshError can retire the 2310// message the moment the thing it's complaining about is fixed â nothing here should outlive the 2311// problem and leave the user reading a stale accusation. 2312 2313// Built from the live rows rather than fixed, because "what's missing" is the whole content of this 2314// one: a row can lack its name, its date, or one end of an event's span, and the fix is different 2315// for each. Rows can be missing different things, in which case it names all of them. 2316// 2317// The branches below have to cover every way syllabusScanItemComplete can say no, and they do: a 2318// task can only fail on its name or its date, and an event on those plus one of the two half-spans. 2319// The fallback is there because they didn't always: a task with no due time used to be incomplete 2320// with no branch describing it, so `needs` came out empty, `needs[0]` was undefined, and the 2321// sentence shipped the literal word "undefined" to the user. Nothing is interpolated into the 2322// sentence now unless a branch actually produced it. 2323function syllabusScanInvalidRowsMessage(){ 2324 let flagged = window.syllabusScanItems.filter(syllabusScanItemIncomplete) 2325 if(!flagged.length){return ''} 2326 2327 let needs = [] 2328 if(flagged.some(item => !item._title.trim())){needs.push('a name')} 2329 if(flagged.some(item => !item._date)){needs.push('a date')} 2330 // every case syllabusScanItemComplete rejects needs a clause here, or the sentence names undefined 2331 if(flagged.some(item => item._type == 'task' && !item._start)){needs.push('a due time')} 2332 if(flagged.some(item => item._type == 'event' && item._start && !item._end)){needs.push('an end time')} 2333 if(flagged.some(item => item._type == 'event' && item._end && !item._start)){needs.push('a start time')} 2334 2335 let one = flagged.length == 1 2336 let tail = `Fill in the ${one ? 'row' : 'rows'} outlined in red, or exclude ${one ? 'it' : 'them'}.` 2337 2338 // nothing named the gap, so say only what's certainly true rather than leaving a hole where the 2339 // missing field's name should be 2340 if(!needs.length){return `${one ? 'One item still needs something' : flagged.length + ' items still need something'}. ${tail}`} 2341 2342 let what = needs.length > 1 ? needs.slice(0, -1).join(', ') + ' and ' + needs[needs.length - 1] : needs[0] 2343 return `${one ? 'One item is' : flagged.length + ' items are'} missing ${what}. ${tail}` 2344} 2345 2346function syllabusScanErrorMessage(kind){
2347 if(kind == 'invalidRows'){return syllabusScanInvalidRowsMessage()} 2348 if(kind == 'needsClass'){return 'Select which class these assignments are for.'} 2349 if(kind == 'nothingIncluded'){return 'Every item is excluded. Include at least one to import it to your calendar.'} 2350 if(kind == 'taskImportFailed'){return "We couldn't add your syllabus tasks. Please try again."} 2351 return '' 2352} 2353 2354// Whether the message still describes reality. Called after every edit that could have fixed things, 2355// so a message goes away as soon as it stops being true rather than sitting there until the next 2356// press of Import. taskImportFailed is the exception: it's about a request that already failed, so 2357// nothing the user does in the editor makes it untrue â only trying again does, and the add pass 2358// clears it on the way in. 2359function syllabusScanErrorStillApplies(kind){ 2360 if(kind == 'invalidRows'){return window.syllabusScanItems.some(syllabusScanItemIncomplete)} 2361 if(kind == 'needsClass'){return !window.syllabusScanClassEventID} 2362 if(kind == 'nothingIncluded'){return !window.syllabusScanItems.some(item => item._included && syllabusScanItemComplete(item))} 2363 return kind == 'taskImportFailed' 2364} 2365 2366// What each refusal is actually about, so the message, the scroll and the shake all point at the 2367// same thing. nothingIncluded and taskImportFailed have no field at fault â every row is struck 2368// through in the first, and the second is about a request rather than anything on screen â so both 2369// fall to the button, which is what couldn't do its job and what you'd press again. 2370function syllabusScanErrorTargets(kind){ 2371 if(kind == 'invalidRows'){return $('.syllabusScanRowInvalid')} 2372 if(kind == 'needsClass'){return $('#syllabusScanClassSelectContainer')} 2373 return $('#syllabusScanAddBtn') 2374} 2375 2376// Replaying the animation takes removing the class, forcing a reflow, and adding it back. Without 2377// the reflow the browser never sees the class as having changed, so pressing Import a second time on 2378// the same unfixed row would shake once and then sit still â exactly when the user needs telling 2379// again. 2380function syllabusScanShakeElements(elements){ 2381 elements.each(function(){ 2382 $(this).removeClass('syllabusScanShake') 2383 void this.offsetWidth 2384 $(this).addClass('syllabusScanShake') 2385 }) 2386} 2387 2388$(document).on('animationend', '.syllabusScanShake', function(){ 2389 $(this).removeClass('syllabusScanShake') 2390}) 2391 2392function showSyllabusScanError(kind){ 2393 let message = syllabusScanErrorMessage(kind) 2394 if(!message){return} 2395 window.syllabusScanErrorKind = kind 2396 $('#syllabusScanErrorText').text(message) 2397 $('#syllabusScanError').addClass('syllabusScanErrorShowing') 2398 // the picker is the only at-fault field outside the list, so it's the only one that has to be 2399 // marked here â the rows wear their outlines from the render 2400 $('#syllabusScanClassSelectContainer').toggleClass('syllabusScanFieldInvalid', kind == 'needsClass') 2401 2402 // bring it on screen, then move it. Scrolling to the first of several while shaking all of them 2403 // is deliberate: with four rows missing a date you want to be taken to the first AND told there 2404 // are others, not taken to the first and left thinking it's the only one. 2405 let targets = syllabusScanErrorTargets(kind) 2406 if(!targets.length){return} 2407 targets.get(0).scrollIntoView({"block": 'center'}) 2408 syllabusScanShakeElements(targets) 2409} 2410 2411function hideSyllabusScanError(){ 2412 window.syllabusScanErrorKind = null 2413 $('#syllabusScanError').removeClass('syllabusScanErrorShowing') 2414 $('#syllabusScanClassSelectContainer').removeClass('syllabusScanFieldInvalid') 2415 // deliberately not cleared: the text has to survive the slide back up, or the line empties and 2416 // collapses to nothing with no transition to watch 2417} 2418 2419// re-checks the showing message against the current state, and takes it away once it no longer 2420// holds. Also refreshes it in place, so "3 items are missing a date" counts down as they're fixed. 2421function syllabusScanRefreshError(){ 2422 let kind = window.syllabusScanErrorKind 2423 if(!kind){return} 2424 if(!syllabusScanErrorStillApplies(kind)){ 2425 hideSyllabusScanError() 2426 return 2427 } 2428 let message = syllabusScanErrorMessage(kind) 2429 if(message){$('#syllabusScanErrorText').text(message)} 2430} 2431 2432// keeps the highlight honest during in-place edits (the change-handler path deliberately never 2433// re-renders): each row's outline follows its current state, and once nothing is flagged the mode 2434// itself goes away
2435function syllabusScanRefreshInvalidHighlight(){ 2436 if(!window.syllabusScanInvalidHighlight){return} 2437 $('.syllabusScanRow').each(function(){ 2438 $(this).toggleClass('syllabusScanRowInvalid', syllabusScanItemIncomplete(syllabusScanItemForRow(this))) 2439 }) 2440 if(!$('.syllabusScanRowInvalid').length){ 2441 window.syllabusScanInvalidHighlight = false 2442 } 2443} 2444 2445// THE IMPORT'S SPINNER 2446// The button spins while the import runs, and for at least SYLLABUS_SCAN_MIN_SPIN either way. A 2447// cached, tasks-only import can come back in well under a tenth of a second, and a spinner that 2448// appears and vanishes inside one frame reads as a flicker, or as nothing having happened at all â 2449// the modal just disappears and you're left unsure whether it took. 2450// 2451// The floor is on the DISMISSAL, not on the request: the request goes out the moment the button is 2452// pressed, and only the closing of the modal waits for the clock to catch up. Delaying the request 2453// itself would spend the same second without doing anything with it, and would push every slow import 2454// out by that much on top of its own time. 2455const SYLLABUS_SCAN_MIN_SPIN = 1000 2456let syllabusScanSpinnerStartedAt = 0 2457 2458function syllabusScanAfterMinimumSpin(callback){ 2459 let elapsed = Date.now() - syllabusScanSpinnerStartedAt 2460 if(elapsed >= SYLLABUS_SCAN_MIN_SPIN){ 2461 callback() 2462 return 2463 } 2464 setTimeout(callback, SYLLABUS_SCAN_MIN_SPIN - elapsed) 2465} 2466 2467// the button carries a live count of what will actually be added â included rows the add pass will 2468// take, since one it would skip would overpromise 2469function updateSyllabusScanAddCount(){ 2470 // don't clobber the in-flight label if a sync is running 2471 if($('#syllabusScanAddBtn').hasClass('syllabusScanAdding')){return} 2472 let count = window.syllabusScanItems.filter(item => item._included && syllabusScanItemComplete(item)).length 2473 $('#syllabusScanAddBtnText').text(count ? `Import ${count} Task${count == 1 ? '' : 's'}` : 'Import Tasks') 2474} 2475 2476// THE DOT 2477// A row's dot answers the moment its data does: fill in the date (or the name, or the end time) a 2478// row was missing and it goes from red to the timeline's blue right there, under the hand that 2479// filled it in â no waiting for the next render. 2480// 2481// What does NOT happen at the same moment is the row MOVING. A row folding up and flying out of 2482// "Missing details" the instant the last field lands reads as the list snatching the work away 2483// mid-edit, so the regroup waits for the user's next step elsewhere (see 2484// syllabusScanCollapseGraduatedRows, and iOS's regroupCompletedRows, which is the same deal). The 2485// row therefore sits where it is, complete, with a blue dot â which is exactly what iOS draws: 2486// SyllabusScanDot reads item.isIncomplete live, while the grouping is read off the layout snapshot. 2487// 2488// Hence the two classes. This one is the row's LIVE validity and nothing else; the position-shaped 2489// one, syllabusScanRowNeedsInfo, is what the last render filed the row as, and it's what the spine 2490// measures around. Toggling only this one is what keeps the spine still while the dot changes 2491// colour: no row has moved, so the line has no reason to. 2492function syllabusScanRefreshRowDot(row, item){ 2493 row.toggleClass('syllabusScanRowIncomplete', syllabusScanItemIncomplete(item)) 2494} 2495 2496// after an in-place edit: the row's read-mode line, its dot, the button's count, and whether the row 2497// still wears the can't-add outline 2498function syllabusScanRefreshRowAfterEdit(row, item){ 2499 syllabusScanRefreshRowReadMode(row, item) 2500 syllabusScanRefreshRowDot(row, item) 2501 updateSyllabusScanAddCount() 2502 syllabusScanRefreshInvalidHighlight() 2503 // an edit can be the thing that answers a refused import, so the message re-checks itself here 2504 // rather than waiting to be disproved by the next press of the button 2505 syllabusScanRefreshError() 2506} 2507 2508// Clicking a closed row anywhere opens it in place into the edit inputs. Closing it again is the 2509// row's own top line â everything to the right of the title, the caret included. 2510// 2511// The two halves are deliberately asymmetric. Opening wants the biggest target it can get: a closed 2512// row has nothing else on it to hit, so the whole row is the button. Closing can't have the same 2513// deal, because an open row is a form and in a form a click is aimed at a field â treating the 2514// card's dead space as "close" (which it used to) meant a miss between two pills threw away the trip 2515// into the row, and a drag that started on the notes and ended on the background closed it 2516// mid-sentence. So the target is the line the caret already lives on, which stays put while the card 2517// below it changes shape, and is big enough to hit without aiming: the whole strip past the title. 2518// The title itself is exempt â it's an input, and clicking into it is how you edit the name. 2519// Where the press LANDED, not where it was released. A click event fires on the nearest common 2520// ancestor of the two, so selecting a title by dragging left to right and lifting past its right 2521// edge reports as a click on the row â and closed the row the selection was being made in. Judging 2522// the gesture by its start makes a drag out of the title a drag out of the title. 2523$(document).on('mousedown touchstart', function(event){ 2524 window.syllabusScanPressTarget = event.target 2525}) 2526 2527$(document).on('click', '.syllabusScanRow', function(event){ 2528 let index = Number($(this).attr('data-index')) 2529 // a click with no press behind it is a synthesised one (the caret's Enter/Space handler), which 2530 // has no drag to be confused by 2531 let target = $(window.syllabusScanPressTarget || event.target) 2532 let wasExpanded = syllabusScanRowIsExpanded(index) 2533 2534 if(wasExpanded){ 2535 if(!target.closest('.syllabusScanRowMain').length){return} 2536 if(target.closest('.syllabusScanTitle').length){return} 2537 }else if(target.closest('input, select, label, .syllabusScanRowState, .syllabusScanAllDayToggle').length){ 2538 // a closed row carries none of these, but the caret's keyboard handler routes a keypress 2539 // through here as a click, and a stray one shouldn't reopen the row it just closed 2540 return 2541 } 2542 2543 syllabusScanCommitExpandedRows() 2544 if(wasExpanded){ 2545 window.syllabusScanExpandedIndexes.delete(index) 2546 }else{ 2547 window.syllabusScanExpandedIndexes.add(index) 2548 }
2549 renderSyllabusScanTimeline() 2550 2551 // An opened card can be taller than the room left below it, and the list doesn't move on its own 2552 // â so a row near the bottom opens mostly out of sight. 'nearest' scrolls only when the row isn't 2553 // fully visible and only as far as it has to, leaving rows already in view exactly where they 2554 // were. Opening only: collapsing shrinks the content, so nothing goes out of view, and scrolling 2555 // then would slide the list under a cursor that's about to click again. 2556 if(!wasExpanded){ 2557 let row = document.querySelector(`.syllabusScanRow[data-index="${index}"]`) 2558 if(row){row.scrollIntoView({"block": 'nearest'})} 2559 } 2560}) 2561 2562// Chrome only opens the native date picker from the calendar glyph, and ours is invisible under the 2563// pill â so the pill asks for it directly. showPicker throws if it wasn't a real user gesture. 2564$(document).on('click', '.syllabusScanDatePill', function(event){ 2565 event.stopPropagation() 2566 let input = $(this).find('input[type="date"]').get(0) 2567 if(typeof input.showPicker == 'function'){ 2568 try{input.showPicker(); return}catch(error){} 2569 } 2570 input.focus() 2571}) 2572 2573$(document).on('change', '.syllabusScanDate, .syllabusScanUntil', function(){ 2574 let pill = $(this).closest('.syllabusScanDatePill') 2575 pill.find('.syllabusScanDateLabel').text($(this).hasClass('syllabusScanUntil') 2576 ? syllabusScanUntilLabel(this.value) 2577 : syllabusScanDateLabel(this.value)) 2578 pill.toggleClass('syllabusScanDatePillEmpty', !this.value) 2579}) 2580 2581// FIELDS SIZED TO THEIR OWN TEXT 2582// The title and the location are as wide as what's written in them and no wider, so the title's 2583// underline stops where the words do and the location doesn't sit there as an empty trough. Both 2584// are text you read at a glance far more often than you edit, and a box wider than its contents 2585// reads as an unfinished form. 2586// 2587// The title used to do this with the size attribute, which is a count of characters at the font's 2588// AVERAGE width â it overshoots on narrow text, undershoots on wide, and had a 10-character floor 2589// that gave a three-letter title an underline three times its length. No amount of tuning fixes 2590// that, because the unit isn't the one being measured. The location didn't do it at all: flex-grow 2591// handed it every pixel the line wasn't using. Both measure the actual string in the actual font now. 2592// 2593// The mirror is one span for the whole modal, off-screen, wearing the measured input's own computed 2594// font. white-space: pre keeps its trailing spaces, which is what lets the underline follow the 2595// caret out as they're typed. 2596function syllabusScanTextMirror(){ 2597 let mirror = document.getElementById('syllabusScanTextMirror') 2598 if(!mirror){ 2599 mirror = document.createElement('span') 2600 mirror.id = 'syllabusScanTextMirror' 2601 document.body.appendChild(mirror) 2602 } 2603 return mirror 2604} 2605 2606function syllabusScanSizeInputToText(input){ 2607 let style = window.getComputedStyle(input) 2608 let mirror = syllabusScanTextMirror() 2609 mirror.style.font = style.font 2610 mirror.style.letterSpacing = style.letterSpacing 2611 // an empty field shows its placeholder, so that's what it has to fit â it's the width's floor, 2612 // and the reason neither field needs a min-width to stay hittable while empty 2613 mirror.textContent = input.value || input.placeholder || '' 2614 2615 // The padding AND the border ride along under border-box, which is what these inputs are: 2616 // whatever isn't counted here comes straight back out of the content box, and the glyph on 2617 // the end wears it. The title has no border and only ever needed the padding â the location 2618 // pill wears the pills' own 1px a side, which is what left "Location" a pixel short of 2619 // itself and clipped its own last letter. 2620 // 2621 // +1 is the caret's own column, without which it lands on top of the last glyph at the end of 2622 // a full-width value. 2623 let inset = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + 2624 parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth) 2625 // the ceiling is the stylesheet's max-width â set the width past it and the input just clamps, 2626 // so the cap lives with the rest of the field's dimensions rather than being duplicated here 2627 input.style.width = (mirror.offsetWidth + (style.boxSizing == 'border-box' ? inset : 0) + 1) + 'px' 2628} 2629 2630// after a render: the inputs are new elements, and until they're in the document there's no computed 2631// font to measure them against 2632function syllabusScanSizeInputsToText(){ 2633 $('.syllabusScanTitle, .syllabusScanLocation').each(function(){ syllabusScanSizeInputToText(this) }) 2634} 2635 2636$(document).on('input', '.syllabusScanTitle, .syllabusScanLocation', function(){ 2637 syllabusScanSizeInputToText(this) 2638}) 2639 2640// A name is the one thing a row can be missing that's answered by typing rather than by picking, and 2641// the answer is complete as soon as there's a character in the box â so the dot follows the keystroke 2642// rather than the blur. Same as iOS, where the title field writes straight into the item and 2643// everything derived from it (the dot, the outline, the button's count) recomputes on the spot. 2644// Only the derived read-outs, deliberately: no read-mode refresh (the row is open, so the title IS 2645// this input) and no re-render, which would tear the field out from under the cursor. 2646$(document).on('input', '.syllabusScanTitle', function(){ 2647 let row = $(this).closest('.syllabusScanRow') 2648 let item = syllabusScanItemForRow(row) 2649 if(!item){return} 2650 item._title = $(this).val() 2651 syllabusScanRefreshRowDot(row, item) 2652 updateSyllabusScanAddCount()
2653 syllabusScanRefreshInvalidHighlight() 2654 syllabusScanRefreshError() 2655}) 2656 2657// Enter means done with this field. These are the card's two free-text inputs, and they're 2658// single-line, so there's nothing for the key to do except what everyone expects it to do â the row 2659// stays open, since Enter here is finishing a name rather than finishing with the row. 2660// 2661// Blurring is also what commits: the change event a blur fires is what the handler below reads, so 2662// there's nothing to save by hand. preventDefault because the inputs sit inside the page's form on 2663// some pages, where a bare Enter is a submit. 2664$(document).on('keydown', '.syllabusScanTitle, .syllabusScanLocation', function(event){ 2665 if(event.key != 'Enter'){return} 2666 event.preventDefault() 2667 this.blur() 2668}) 2669 2670// commit each edit as it lands, refreshing the row's read-mode line in place. Deliberately never a 2671// full re-render from here: date inputs fire change on every completed keystroke (the browser 2672// re-validates per segment), so rebuilding the list mid-edit would tear down the input under the 2673// user's cursor and eat the rest of their typing. A row whose date changed keeps its spot under its 2674// old month until it's collapsed â the click handler's re-render regroups it then. 2675$(document).on('change', '.syllabusScanRowExpanded input', function(){ 2676 let row = $(this).closest('.syllabusScanRow') 2677 syllabusScanCommitExpandedRows() 2678 syllabusScanRefreshRowAfterEdit(row, syllabusScanItemForRow(row)) 2679}) 2680 2681// the all-day toggle disables the clock-time inputs; an all-day event has no times, and the generic 2682// change handler above (registered first, so it has already run) committed the cleared state 2683$(document).on('click', '.syllabusScanAllDayToggle', function(event){ 2684 event.stopPropagation() 2685 let row = $(this).closest('.syllabusScanRow') 2686 let on = $(this).attr('data-allday') != '1' 2687 $(this).attr('data-allday', on ? '1' : '0').toggleClass('syllabusScanAllDayOn', on).text(on ? 'All day' : 'Not all day') 2688 row.find('.syllabusScanStart, .syllabusScanEnd').prop('disabled', on).val('') 2689 syllabusScanCommitExpandedRows() 2690 // through the full after-edit pass rather than just the read-mode line: an all-day event needs no 2691 // end time, so this toggle is one of the ways a row that was missing one stops missing it â its 2692 // dot, its outline and a refused import all have to hear about that as immediately as they would 2693 // from the field itself 2694 syllabusScanRefreshRowAfterEdit(row, syllabusScanItemForRow(row)) 2695}) 2696 2697// an event's Ends list always begins at that row's start time, so re-point it whenever the start 2698// moves (the generic change handler above has already committed the new value). An end the move has 2699// stranded â now at or before the new start â is cleared rather than nudged to a length we'd be 2700// inventing, so the row goes back to asking for one. 2701$(document).on('change', '.syllabusScanStart', function(){ 2702 let row = $(this).closest('.syllabusScanRow') 2703 let item = syllabusScanItemForRow(row) 2704 let endInput = row.find('.syllabusScanEnd') 2705 if(item._type != 'event' || !endInput.length){return} 2706 2707 let startMinutes = syllabusScanTimeToMinutes(item._start) 2708 if(startMinutes == null){return} 2709 2710 if(endInput.hasClass('ui-timepicker-input')){ 2711 endInput.timepicker('option', {'minTime': syllabusScanTimeToPickerValue(item._start)}) 2712 } 2713 2714 let endMinutes = syllabusScanTimeToMinutes(item._end) 2715 if(endMinutes == null || endMinutes > startMinutes){return} 2716 2717 item._end = '' 2718 endInput.val('') 2719 syllabusScanRefreshRowAfterEdit(row, item) 2720}) 2721 2722// THE TIME DROPDOWN AND THE KEYBOARD 2723// The plugin parks its list on the body at the coordinates the field had the moment it opened, and on 2724// a phone the field is rarely still there a moment later: the same focus that opens the list also 2725// raises the keyboard, which shrinks the viewport and scrolls the field into view inside 2726// #syllabusScanResults (max-height: 45vh, so it does scroll). That scroll used to close the list 2727// outright â which is why tapping a field low in the list gave you a keyboard and no dropdown, and 2728// why it sometimes never drew at all when the two raced. A field at the top of the list never scrolls, 2729// which is why it has always worked up there. 2730// 2731// So: follow the field instead of closing, and place the list against the viewport the keyboard 2732// actually left behind. 2733 2734// puts one open list where it belongs, given where its field is NOW and where the keyboard starts 2735function syllabusScanPlaceTimeList(input){ 2736 2737 let list = input.data('timepicker-list') 2738 if(!list || !list.is(':visible')){return} 2739 2740 let field = input.get(0).getBoundingClientRect()
2741 // visualViewport is the only thing that knows where the keyboard begins; window height doesn't 2742 // move for it on every Android/Chrome combination 2743 let view = window.visualViewport 2744 let viewBottom = (view ? view.offsetTop + view.height : $(window).height()) 2745 let height = list.outerHeight() 2746 2747 // Under the field when it fits above the keyboard, over the field when it doesn't â even when 2748 // that runs off the top of the screen. A list drawn under the keyboard can't be reached at all; 2749 // one clipped at the top can still be scrolled, so clipping is the better failure. 2750 let below = field.bottom + height <= viewBottom 2751 let top = below ? field.bottom : field.top - height 2752 2753 list.toggleClass('ui-timepicker-positioned-top', !below) 2754 list.offset({'top': top + $(window).scrollTop(), 'left': field.left + $(window).scrollLeft()}) 2755 2756} 2757 2758// at most one list is ever open, but writing it as a set costs nothing and can't go stale 2759function syllabusScanPlaceOpenTimeLists(){ 2760 $('#syllabusScanResults').find('.syllabusScanTimeInput').each(function(){ 2761 syllabusScanPlaceTimeList($(this)) 2762 }) 2763} 2764 2765// the field moving under a list that is already open: follow it, and only give up once the field has 2766// left the scroller entirely, where there is nothing left to anchor to 2767$(document).on('scroll', '#syllabusScanResults', function(){ 2768 let box = this.getBoundingClientRect() 2769 $(this).find('.syllabusScanTimeInput').each(function(){ 2770 let input = $(this) 2771 let list = input.data('timepicker-list') 2772 if(!list || !list.is(':visible')){return} 2773 let field = this.getBoundingClientRect() 2774 if(field.bottom < box.top || field.top > box.bottom){ 2775 input.timepicker('hide') 2776 return 2777 } 2778 syllabusScanPlaceTimeList(input) 2779 }) 2780}) 2781 2782// The keyboard does not arrive in one step: Android animates it in, so the viewport keeps changing for 2783// a few hundred ms after the list has already been placed. Following it is
2783what puts the list where 2784// the field IS rather than where it was when the plugin looked. 2785if(window.visualViewport){ 2786 window.visualViewport.addEventListener('resize', syllabusScanPlaceOpenTimeLists) 2787 window.visualViewport.addEventListener('scroll', syllabusScanPlaceOpenTimeLists) 2788} 2789 2790// and place it once on the way in, so it never paints at the plugin's own guess first 2791$(document).on('showTimepicker', '.syllabusScanTimeInput', function(){ 2792 syllabusScanPlaceTimeList($(this)) 2793}) 2794 2795// changing the frequency reshapes the edit block (a repeating event gains the "until" line), 2796// so rebuild it in place. A select commits in a single interaction â unlike the text/date 2797// inputs there's no in-progress typing to protect, so the rebuild is safe here. 2798$(document).on('change', '.syllabusScanRepeats', function(){ 2799 let row = $(this).closest('.syllabusScanRow') 2800 let item = syllabusScanItemForRow(row) 2801 syllabusScanCommitExpandedRows() 2802 // the first time a row becomes repeating, default its end to the term's, like the scan 2803 // does for the patterns it detects (blank still means "no end", like Ends On "None") 2804 if(item._repeats && !item._until){item._until = window.syllabusScanTermEnd || ''} 2805 // the rebuilt block carries fresh time inputs, so the dropdowns move with them 2806 syllabusScanDestroyTimePickers() 2807 row.find('.syllabusScanRowEdit').replaceWith(syllabusScanRowEditHTML(item)) 2808 syllabusScanInitTimePickers() 2809 // the card was rebuilt, so its Location is a new element that hasn't been measured yet 2810 syllabusScanSizeInputsToText() 2811 syllabusScanRefreshRowReadMode(row, item) 2812}) 2813 2814// Exclude / Include, the editor's one non-field control. Both fold THAT row back up, so you land 2815// back on the list looking at the row you just changed; the row's own strikethrough reports the 2816// state. Only that one â any other rows you had open stay open, since deciding about this row says 2817// nothing about the ones you were comparing it against. 2818// Pre-flagged duplicates arrive with _included false, so they render struck through from the start. 2819$(document).on('click', '.syllabusScanRowState', function(event){ 2820 event.stopPropagation() 2821 syllabusScanCommitExpandedRows() 2822 let row = $(this).closest('.syllabusScanRow') 2823 let item = syllabusScanItemForRow(row) 2824 item._included = !item._included 2825 window.syllabusScanExpandedIndexes.delete(Number(row.attr('data-index'))) 2826 renderSyllabusScanTimeline() 2827 // excluding the offending row answers "items are missing a date" just as filling it in does, and 2828 // including one answers "every item is excluded" 2829 syllabusScanRefreshError() 2830}) 2831 2832$(document).on('keydown', '.syllabusScanRowState, .syllabusScanRowCaret', function(event){ 2833 if(event.key != 'Enter' && event.key != ' '){return} 2834 event.preventDefault() 2835 // a keyboard press has no mousedown of its own, so the last real one is cleared rather than left 2836 // to stand in for it in the row handler's where-did-this-start check 2837 window.syllabusScanPressTarget = null 2838 $(this).trigger('click') 2839}) 2840 2841// Dismissing the timeline editor (background click, escape, or a swipe down on the pwa sheet) is a 2842// way out of the review, not a decision about the scan: it parks, so the card comes back with the 2843// user's rows on it and they can pick the review up where they left it. Nothing is asked, because 2844// nothing is lost. 2845// 2846// THE PARK ITSELF, reached from both routes below. It takes no view on which modal was dismissed or 2847// whether it was on top: each route settles that for itself, in the terms available to it. 2848// 2849// The two conditions here are what tell an abandoned review apart from a finished one. A review the 2850// user was still fixing up has its scan on window.syllabusScanPresentedScan and its step on screen; 2851// a finished add hides #syllabusScanReviewStep before it closes (see syncTasksThenClose) precisely 2852// so that this reads it as a deliberate exit, and a scan that died on its way in cleared the 2853// presented scan when it reset the modal. 2854function syllabusScanParkOnDismiss(){ 2855 if(!$('#syllabusScanReviewStep').is(':visible')){return} 2856 if(window.syllabusScanPresentedScan){ 2857 syllabusScanParkPresentedScan(window.syllabusScanPresentedScan) 2858 } 2859 window.syllabusScanClassUnknownMode = false 2860 window.syllabusScanUnknownClass = null 2861 syllabusScanPaintNotifications() 2862} 2863
2864// ROUTE ONE: the base modal's hide-override hook (checkBaseModalOverridesThenHide in baseModal.php), 2865// which is what a background click and escape come through. It fires for EVERY modal's dismissal, so 2866// this has to establish that the review is the one on screen and the one on top before it acts. 2867// 2868// Both of those are asked through the helpers rather than off webLikeModalShowing directly: that 2869// class is only hung on a modal showBaseModal drew the web way, so on the pwa â where this modal is 2870// handed to renderSlidableModal instead â reading it meant every dismissal left down here bailed on 2871// the first line, and the scan went nowhere. That was the bug: the card never came back. 2872// 2873// Returns nothing so the hide goes ahead â the park is the whole of the work. 2874// 2875// stripe.js initializes the array earlier in the load order, the || [] is for pages that load 2876// without it. 2877window.baseModalHideOverrides = window.baseModalHideOverrides || [] 2878window.baseModalHideOverrides.push(function(){ 2879 if(!syllabusScanModalIsOpen()){return} 2880 // only when the review is the topmost modal â a modal stacked above it should close normally 2881 if(!syllabusScanModalIsTopmost()){return} 2882 syllabusScanParkOnDismiss() 2883}) 2884 2885// Send the timeline back to the top on the way out, so the next review â a parked one picked back up, 2886// or a different scan's entirely â opens at its first row rather than at wherever the last one was 2887// left. Rebuilding the list doesn't do this on its own: renderSyllabusScanTimeline runs while the 2888// modal is hidden, and a scroller with no layout keeps the offset it had and restores it on show. 2889// 2890// Done on hide rather than on show because the modal is still on screen for both events below (the 2891// web modal is triggered before the show class comes off, the pwa sheet while it's animating down), 2892// which is what makes the reset take â and it's off screen or going by the time the eye could catch 2893// the jump. Both names are needed: the web modal and the pwa sheet announce their close differently. 2894$(document).on('syllabusScanModalBaseModalHidden syllabusScanModal.modalHidden', function(){ 2895 $('#syllabusScanResults').scrollTop(0) 2896 2897 // ROUTE TWO: the pwa's swipe down and its hardware back button call hideSlidableModal() directly 2898 // (slidableModal.js), so they never reach the hide-override hook above and route one never hears 2899 // about them. This trigger is the one thing every close does announce, and it carries the id of 2900 // the modal that closed, so being here at all is the whole of "it was this modal" â there's no 2901 // topmost question to ask either, since only the modal coming down raises it. 2902 // 2903 // A background tap raises BOTH routes: the hook runs first, then hideBaseModal falls through to 2904 // hideSlidableModal and this fires. The second park is a no-op because the scan is back in the 2905 // queue by then, which is what syllabusScanParkPresentedScan's opening line is for. 2906 // 2907 // Safe on the web modal too, for the same reason, so it isn't gated to the pwa. 2908 syllabusScanParkOnDismiss() 2909}) 2910 2911// a class this flow created on its own, for a review that's now being thrown away, goes with it: it 2912// was never asked for, so leaving it behind would mean backing out of an import silently costs you a 2913// class you never made. 2914// 2915// Two things stop it: the user having pointed the import at a DIFFERENT class since (they may well 2916// have kept ours deliberately), and the import having already run (syllabusScanAddItemsToClass 2917// clears the id, since by then the class holds real imported items and isn't ours to take away). 2918function syllabusScanRemoveClassBuiltForAbandonedScan(scan){ 2919 if(!scan || !scan.unknownClass || !scan.unknownClass.createdClassEventID){return} 2920 if(scan.classEventID != scan.unknownClass.createdClassEventID){return} 2921 2922 let createdClassEventID = scan.unknownClass.createdClassEventID 2923 scan.unknownClass.createdClassEventID = null 2924 scan.classEventID = null 2925 deleteEvent(createdClassEventID, getCurrentlySelectedScheduleID()) 2926} 2927 2928$(document).on('click', '#syllabusScanAddBtn', function(){ 2929 syllabusScanAddCheckedItems() 2930}) 2931 2932// the add pass, run by the editor's "Import N tasks" button 2933function syllabusScanAddCheckedItems(){ 2934 if($('#syllabusScanAddBtn').hasClass('syllabusScanAdding')){return} 2935
2936 // fold in any edit still sitting in the expanded row's inputs (its change event may not have 2937 // fired if a field was focused when the button was clicked) 2938 syllabusScanCommitExpandedRows() 2939 2940 // a fresh attempt answers whatever the last one said, including the one failure the editor can't 2941 // disprove on its own (taskImportFailed) 2942 hideSyllabusScanError() 2943 2944 // refuse to run while any included row is missing something the add pass needs; the highlight 2945 // pass outlines the offenders and scrolls them into view instead of skipping them silently 2946 if(syllabusScanHighlightInvalidCheckedItems()){return} 2947 2948 // everything lands ON a class, so there has to be one. Most reviews open with it already filled 2949 // in; this is what's left when neither the scan's own class nor the match supplied one, and the 2950 // picker is right above the button. 2951 if(!window.syllabusScanClassEventID){ 2952 showSyllabusScanError('needsClass') 2953 return 2954 } 2955 2956 syllabusScanAddItemsToClass() 2957} 2958 2959// creates the class event a scanned syllabus described: ONE event of type "class", with the 2960// lecture's meeting patterns as daysOccurring â several patterns, or several sessions on the same 2961// day, are just more time ranges on the relevant days, the same shape getdaysOccurringData builds 2962// for a hand-made class. 2963// 2964// Synced alone, first: the server assigns the real event id everything else will link to, so it has 2965// to exist before the items go up. onDone gets that id (null on failure). 2966function syllabusScanCreateClassFromLecture(className, onDone){ 2967 let schedulesAndEvents = window.getUsersSchedulesAndEvents() 2968 let scheduleID = getCurrentlySelectedScheduleID() 2969 let lecture = (window.syllabusScanUnknownClass && window.syllabusScanUnknownClass.lecture) || null 2970 2971 let daysOccurring = null 2972 let meetings = (lecture && lecture.meetings) || [] 2973 meetings.forEach(meeting => { 2974 let start = syllabusScanTimeToMinutes(meeting.startTime) 2975 if(start == null || !meeting.days){return} 2976 // a lecture without a stated end still needs height on the calendar; 50 minutes is 2977 // the standard college period 2978 let end = syllabusScanTimeToMinutes(meeting.endTime) 2979 if(end == null || end <= start){end = Math.min(start + 50, 1439)} 2980 meeting.days.forEach(day => { 2981 daysOccurring = daysOccurring || {"M": [], "Tu": [], "W": [], "Th": [], "F": [], "Sa": [], "Su": []} 2982 if(daysOccurring[day]){daysOccurring[day].push({"startTime": start, "endTime": end})} 2983 }) 2984 }) 2985 2986 // remember which ids existed before this sync, so the server's response can tell us the new one 2987 let priorEventIDs = new Set(Object.keys(schedulesAndEvents.events)) 2988 2989 // the same shape addCustomClass builds for a hand-made class 2990 schedulesAndEvents.events[0] = { 2991 "eventID": 0, 2992 "type": "class", 2993 "classID": null, 2994 "title": className, 2995 "subtitle": null, 2996 "professor": null, 2997 "location": (lecture && lecture.location) || null, 2998 "classSize": null, 2999 "description": null, 3000 "attributes": null, 3001 "crn": null, 3002 "credits": null, 3003 "color": window.syllabusScanUnknownClass.color, 3004 "notes": null, 3005 "school": retrieveSchoolParameter(), 3006 "semester": window.registrationSemester || null, 3007 "class": null, 3008 "occurrenceData": { 3009 "starts": Number(window.registrationSemesterStartDate) || null, 3010 "ends": Number(window.registrationSemesterEndDate) || null, 3011 "allDay": 0, 3012 "startTime": null, 3013 "endTime": null, 3014 "repeats": null, 3015 "gapBetweenRepeats": 0, 3016 "dayOfMonth": null, 3017 "monthOfYear": null, 3018 "daysOccurring": daysOccurring 3019 }, 3020 "reminders": getDefaultRemindersEnabled() ? getDefaultReminders().event : [], 3021 "tracking": null 3022 } 3023 schedulesAndEvents = pushEventIDToSchedule(schedulesAndEvents, 0, scheduleID) 3024 window.setUsersSchedulesAndEvents(schedulesAndEvents) 3025 3026 window.sendUsersEventsToServer(function(){ 3027 // the sync response replaced local state with the server's copy, real ids included; 3028 // the new class is the class-typed event that wasn't there before 3029 let events = window.getUsersSchedulesAndEvents().events 3030 let newClassEventID = null 3031 Object.keys(events).forEach(id => { 3032 if(priorEventIDs.has(id)){return} 3033 let event = events[id] 3034 if(event && event.type == "class" && event.title == className){newClassEventID = parseInt(id)} 3035 }) 3036 3037 onDone(newClassEventID) 3038 }) 3039} 3040 3041// ties the stored syllabus file to the class the import landed on, so its detail view gets the View 3042// action and unlinking finds the file row. 3043// 3044// Only an upload that named no class has a file to tie: the server attaches it to the class itself
3045// when there was one, and only hands the cache id back when there wasn't (see scanSyllabus.php's 3046// syllabusFile). Which class that turns out to be is settled much later than the upload â matched, 3047// built, or picked out of the dropdown â so this runs at import time, on whatever the answer was. 3048// 3049// The cache id is all we send: it's how the server finds the row it already wrote for this scan. 3050// Fire-and-forget â a miss here only costs the View link, and linkSyllabusFile.php logs its own. 3051function syllabusScanLinkFileToClass(classEventID){ 3052 let file = window.syllabusScanUnknownClass && window.syllabusScanUnknownClass.file 3053 if(!classEventID || !file || !file.syllabusCacheID){return} 3054 3055 $.ajax({ 3056 url: "/shared/loginless/backend/linkSyllabusFile.php", 3057 type: "POST", 3058 dataType: 'json', 3059 data: { 3060 uuid: store.get('uuid'), 3061 classEventID: classEventID, 3062 syllabusCacheID: file.syllabusCacheID 3063 } 3064 }) 3065} 3066 3067// the items half of the add pass: walks the item state (never the DOM, so it works with the modal 3068// hidden) and persists everything included against window.syllabusScanClassEventID 3069function syllabusScanAddItemsToClass(){ 3070 let schedulesAndEvents = window.getUsersSchedulesAndEvents() 3071 let scheduleID = getCurrentlySelectedScheduleID() 3072 let classEvent = getEventByID(window.syllabusScanClassEventID) 3073 let defaultReminders = getDefaultRemindersEnabled() ? getDefaultReminders() : {event: [], task: []} 3074 3075 // like a calendar import, new events get ids 0, -1, -2... and the server creates them 3076 // all in one sync (see setUsersEvents.php). Tasks are collected and added afterwards. 3077 let newEventID = 0 3078 let addedEventCount = 0 3079 let tasksToAdd = [] 3080 3081 // walked in their original events-then-tasks order, so the temp event ids and task order come out 3082 // the same every time 3083 window.syllabusScanItems.forEach(function(item){ 3084 // skip rows the user excluded â including duplicates we pre-excluded that they left off 3085 if(!item._included){return} 3086 3087 // the backstop for the gate upstream, asking the same question the timeline asked 3088 if(!syllabusScanItemComplete(item)){return} 3089 3090 let title = item._title.trim() 3091 let date = item._date 3092 let startTime = syllabusScanTimeToMinutes(item._start) 3093 3094 if(item._type == 'event'){ 3095 // shaped like addCustomEvent's occurrenceData: a one-off event ends at the next 3096 // midnight, a repeating one carries repeats + an "until" boundary (exclusive, so 3097 // until + 1 day makes the last occurrence inclusive â see isEventInViewport's 3098 // endsCheck). Monthly repeats anchor to the start date's day-of-month and yearly 3099 // ones to its month too, matching makeCustomEventOccurrenceData. 3100 let repeats = item._repeats 3101 let untilDate = repeats ? item._until : null 3102 3103 schedulesAndEvents.events[newEventID] = { 3104 "eventID": newEventID, 3105 "type": "general", 3106 "classID": null, 3107 "title": title, 3108 "subtitle": null, 3109 "professor": null, 3110 "location": item._location.trim() || null, 3111 "classSize": null, 3112 "description": null, 3113 "attributes": null, 3114 "crn": null, 3115 "credits": null, 3116 "color": classEvent['color'], // match the class the syllabus came from 3117 "notes": item.notes, 3118 "school": null, 3119 "semester": null, 3120 "class": null, 3121 "occurrenceData": { 3122 "starts": syllabusScanDateToUnix(date), 3123 "ends": repeats ? (untilDate ? syllabusScanDateToUnix(untilDate, 0, 1) : null) : syllabusScanDateToUnix(date, 0, 1), 3124 "allDay": startTime == null ? 1 : 0, 3125 "startTime": startTime, 3126 "endTime": syllabusScanTimeToMinutes(item._end), 3127 "repeats": repeats, 3128 "gapBetweenRepeats": null, 3129 "dayOfMonth": (repeats == "monthly" || repeats == "yearly") ? Number(date.split('-')[2]) : null, 3130 "monthOfYear": repeats == "yearly" ? Number(date.split('-')[1]) - 1 : null, // 0-based, like Date.getMonth in the create form 3131 "daysOccurring": null 3132 }, 3133 "reminders": defaultReminders.event, 3134 // link this event to the class its syllabus was scanned from, so the class detail 3135 // view can later unlink (delete) everything the syllabus added (see insertEvent / unlinkSyllabus.php) 3136 "syllabusClassEventID": window.syllabusScanClassEventID 3137 } 3138 schedulesAndEvents = pushEventIDToSchedule(schedulesAndEvents, newEventID, scheduleID) 3139 newEventID-- 3140 addedEventCount++ 3141 }else{ 3142 tasksToAdd.push({ 3143 "name": title, 3144 // A task with no due time is due at the end of the day it's due on. The row was seeded 3145 // with 11:59pm, so this is the backstop for one that arrived without a time anyway (a 3146 // parked review from before that default, or a time the user cleared) â the stored due 3147 // is 11:59pm either way, and no row is ever dropped for want of a time. 3148 "due": syllabusScanDateToUnix(date, startTime == null ? syllabusScanTimeToMinutes(SYLLABUS_SCAN_DEFAULT_TASK_DUE_TIME) : startTime), 3149 "notes": item.notes, 3150 "associatedEventID": window.syllabusScanClassEventID, 3151 // must be an array: the server's updateReminders count()s it, and a 3152 // non-array is a fatal on php 8 (this was hardcoded to 0 and 500'd) 3153 "reminders": defaultReminders.task, 3154 // same linkage as the events above, so the syllabus's tasks are unlinked/deleted together 3155 "syllabusClassEventID": window.syllabusScanClassEventID 3156 }) 3157 } 3158 }) 3159 3160 if(addedEventCount == 0 && tasksToAdd.length == 0){ 3161 // the invalid-row gate upstream guarantees every included row was addable, so the only way to 3162 // arrive here empty-handed is nothing included at all 3163 showSyllabusScanError('nothingIncluded') 3164 return 3165 } 3166 3167 $('#syllabusScanAddBtn').addClass('syllabusScanAdding') 3168 syllabusScanSpinnerStartedAt = Date.now() 3169 3170 // the class is now holding this import, so it stops being ours to take away if the modal is 3171 // dismissed afterwards (see syllabusScanRemoveClassBuiltForAbandonedScan) 3172 if(window.syllabusScanUnknownClass){window.syllabusScanUnknownClass.createdClassEventID = null} 3173 3174 // and the stored file gets tied to whichever class the import ended up on â matched, built or 3175 // picked. Does nothing unless this was an upload that named no class. 3176 syllabusScanLinkFileToClass(window.syllabusScanClassEventID) 3177 3178 let syncTasksThenClose = function(){ 3179 addSyllabusTasksInBulk(tasksToAdd, function(){ 3180 // re-pull events so the class detail view learns it now has a linked syllabus 3181 // (hasLinkedSyllabus), then re-render it so the upload button swaps to the "Syllabus added · 3182 // View · Remove" row. Needed especially in the tasks-only case, where no events sync ran. 3183 window.syncUsersEventsWithServer(function(){ 3184 // only re-render if the detail view is actually showing this class: a scan can also be 3185 // started from the tasks pane's empty state, and re-rendering then would throw the 3186 // class detail panel open over whatever the user was looking at 3187 if($('#infoColumn').is(':visible') && $('#infoColumn').attr('data-id') == window.syllabusScanClassEventID){
3188 renderClassDetailView(window.syllabusScanClassEventID, "class") 3189 } 3190 }) 3191 syllabusScanAfterMinimumSpin(function(){ 3192 // hiding the review step before the close is what tells the dismissal override this 3193 // was a deliberate exit rather than someone abandoning a scan they'd been fixing up 3194 $('#syllabusScanReviewStep').hide() 3195 syllabusScanHideClassSelect() 3196 hideBaseModal('#syllabusScanModal') 3197 }) 3198 }) 3199 } 3200 3201 if(addedEventCount > 0){ 3202 window.setUsersSchedulesAndEvents(schedulesAndEvents) 3203 window.sendUsersEventsToServer(syncTasksThenClose) 3204 }else{ 3205 syncTasksThenClose() 3206 } 3207} 3208 3209// adds every syllabus task in a single request via setUsersTasks.php's importing branch, rather than 3210// the normal sync, which only creates one id-0 task per request (a recurring task's ~20 to-dos would 3211// have meant ~20 sequential syncs). That branch ignores the ids we send and returns only 3212// {success:true}, so we re-pull tasks afterwards to land the new ones, with their real server ids, 3213// into local state. It records syllabusClassEventID itself, so unlinking still deletes these tasks. 3214function addSyllabusTasksInBulk(tasksToAdd, callback){ 3215 if(tasksToAdd.length == 0){ 3216 callback() 3217 return 3218 } 3219 3220 // Past-due rows arrive ticked off, as a first LMS import does (markTaskCompletedIfOld in 3221 // taskImporterFunctions.py): a syllabus scanned in October lists August's work too. Completed AT 3222 // the due date rather than now, so the streak doesn't open on a wall of overdue. No first-import 3223 // gate needed â a class only ever takes one syllabus. 3224 let now = Math.floor(Date.now() / 1000) 3225 3226 let tasksPayload = tasksToAdd.map(task => ({ 3227 "name": task.name, 3228 "due": task.due, 3229 "notes": task.notes, 3230 "completed": task.due && task.due < now ? task.due : null, 3231 "associatedEventID": task.associatedEventID, 3232 "reminders": task.reminders, 3233 "syllabusClassEventID": task.syllabusClassEventID 3234 })) 3235 3236 $.ajax({ 3237 url: "/shared/loginless/backend/setUsersTasks.php", 3238 type: "POST", 3239 dataType: 'json', 3240 data: { 3241 tasks: JSON.stringify({tasks: tasksPayload}), 3242 uuid: store.get('uuid'), 3243 timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, 3244 client: window.isPWA ? 'android' : 'web', 3245 importing: 1 3246 }, 3247 // re-pull tasks so the imported ones (with their real ids) are in local state, then close 3248 success(){ window.syncUsersTasksWithServer(callback) }, 3249 // re-enable the button so the user can retry from the editor (events, if any, 3250 // already synced) 3251 // a failure gets the same floor as a success: coming straight back with an error under a 3252 // button that never visibly did anything reads as the press not having registered 3253 error(){ 3254 syllabusScanAfterMinimumSpin(function(){ 3255 $('#syllabusScanAddBtn').removeClass('syllabusScanAdding') 3256 updateSyllabusScanAddCount() // restore the live "Add N to Calendar" label 3257 // after the button is back, so the message lands under one that can be pressed again 3258 showSyllabusScanError('taskImportFailed') 3259 }) 3260 } 3261 }) 3262} 3263 3264
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.