PageSourceSearch

https://www.coursicle.com/all/js/schedule/syllabusScan.js?new93

js coursicle.com collected 2026-09-24 08:48:25 UTC 173,160 bytes, 3,264 lines download raw bytes

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.&nbsp;')}${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.&nbsp;')}${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)` : ''}.&nbsp;`)}${link('Pick another file')}`
860		: `${lead(`Couldn't scan ${escapeHTML(syllabusScanFailNameText(scan))}.&nbsp;`)}${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 ? ' &middot; ' + 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.