1/** 2 * new/js/features/export-backup.js 3 * 4 * Backup/restore, CSV exports, factory reset, and hosted workspace auth gate. 5 * 6 * Source: online/index.html 7 * startDownload / downloadCSV lines ~10191 8 * downloadFullBackup line ~10200 9 * exportRosterCSV line ~10220 10 * exportAttendanceCSV line ~10235 11 * exportCallLogCSV line ~10252 12 * factoryReset line ~10283 13 * importSingleCourseAsNew line ~10310 14 * importMultipleCourses line ~10340 15 * triggerRestore line ~10390 16 * downloadAllCoursesBackup line ~10440 17 */ 18 19import { 20 CourseManager, 21 getCourseExportData, 22 writeCourseExportData, 23 getCourseStorageKeys, 24 withOnlineOnlyHydration, 25 onlineOnlyState, 26} from '../services/storage-service.js'; 27import { fetchCloudProfile } from '../services/profile-service.js'; 28import { 29 getStoredCloudSession, 30 ensureCloudSession, 31 redirectHostedAuthToLogin, 32 isCloudAccountApproved, 33} from '../../../shared/cloud-auth.js'; 34import { GLOBAL_KEYS } from '../../../shared/constants.js'; 35import { showAlert, showConfirm } from '../ui/modal.js'; 36 37// --------------------------------------------------------------------------- 38// Data access â injected at boot via setExportDataAccess() 39// --------------------------------------------------------------------------- 40 41let _getStudentData = null; // () => students[] 42let _getConfig = null; // () => appConfig 43let _getLessonArchive = null; // () => lessonArchive 44let _getSyncService = null; // () => _syncService 45let _getAttendanceTotals = null; // (student) => {P, L, A, empty} 46 47export function setExportDataAccess({ 48 getStudentData, 49 getConfig, 50 getLessonArchive, 51 getSyncService, 52 getAttendanceTotals, 53}) { 54 _getStudentData = getStudentData; 55 _getConfig = getConfig; 56 _getLessonArchive = getLessonArchive; 57 _getSyncService = getSyncService; 58 _getAttendanceTotals = getAttendanceTotals; 59} 60 61// --------------------------------------------------------------------------- 62// Internal download helpers 63// --------------------------------------------------------------------------- 64 65function startDownload(blob, filename) { 66 const objectUrl = URL.createObjectURL(blob); 67 const anchor = document.createElement('a'); 68 anchor.href = objectUrl; 69 anchor.download = filename; 70 anchor.style.display = 'none'; 71 document.body.appendChild(anchor); 72 anchor.click(); 73 window.setTimeout(() => { 74 anchor.remove(); 75 URL.revokeObjectURL(objectUrl); 76 }, 0); 77} 78 79function downloadCSV(content, filename) { 80 const blob = new Blob([content], { type: 'text/csv' }); 81 startDownload(blob, filename); 82} 83 84// --------------------------------------------------------------------------- 85// Hosted workspace auth gate 86// --------------------------------------------------------------------------- 87 88/** 89 * Verify the user has a valid hosted session and approved account. 90 * Redirects to login and returns false if any check fails. 91 * @param {object} [options] 92 * @param {boolean} [options.forceProfile] - Force-refresh the cloud profile 93 * @returns {Promise<boolean>} 94 */ 95export async function ensureHostedWorkspaceReady(options = {}) { 96 const storedSession = getStoredCloudSession().session; 97 if (!storedSession?.user?.id) { 98 redirectHostedAuthToLogin({ forcePage: true }); 99 return false; 100 } 101 102 try { 103 const session = await ensureCloudSession(); 104 if (!session?.user?.id) { 105 redirectHostedAuthToLogin({ 106 email: storedSession.user?.email || '', 107 forcePage: true, 108 }); 109 return false; 110 } 111 112 const profile = await fetchCloudProfile(!!options.forceProfile); 113 if (isCloudAccountApproved(profile)) return true; 114 115 redirectHostedAuthToLogin({ 116 email: session.user?.email || storedSession.user?.email || '', 117 forcePage: true, 118 }); 119 return false; 120 } catch (error) { 121 if (error?.requiresApproval) { 122 redirectHostedAuthToLogin({ 123 email: storedSession.user?.email || '', 124 forcePage: true, 125 }); 126 return false;
127 } 128 throw error; 129 } 130} 131 132// --------------------------------------------------------------------------- 133// JSON backup / restore 134// --------------------------------------------------------------------------- 135 136export async function downloadFullBackup() { 137 const courseId = CourseManager.getActiveCourseId(); 138 if (!courseId) { 139 await showAlert('Open a course first, then export its backup.'); 140 return; 141 } 142 const courseName = CourseManager.getCourseName(courseId) || 'course'; 143 const sanitizedName = courseName.replace(/[^a-z0-9]/gi, '_'); 144 const backupData = getCourseExportData(courseId); 145 if (!backupData) { 146 await showAlert('There is no course data available to export yet.'); 147 return; 148 } 149 const blob = new Blob([JSON.stringify(backupData, null, 2)], { type: 'application/json' }); 150 startDownload(blob, `${sanitizedName}_Backup.json`); 151} 152 153export async function downloadAllCoursesBackup() { 154 const allCourses = CourseManager.getAllCourses(); 155 if (!allCourses.length) { 156 await showAlert('Create or import a course first, then export the full backup.'); 157 return; 158 } 159 const allData = { 160 exportDate: new Date().toISOString(), 161 exportType: 'ALL_COURSES', 162 lastActiveCourseId: CourseManager.getActiveCourseId(), 163 registry: allCourses.map((course) => ({ 164 courseId: course.id, 165 courseName: course.name, 166 created: course.created, 167 updatedAt: course.updatedAt, 168 lastAccessed: course.lastAccessed, 169 })), 170 courses: [], 171 }; 172 173 allCourses.forEach((course) => { 174 const courseData = getCourseExportData(course.id); 175 if (courseData) allData.courses.push(courseData); 176 }); 177 178 const blob = new Blob([JSON.stringify(allData, null, 2)], { type: 'application/json' }); 179 startDownload(blob, `ClassAction_AllCourses_${new Date().toISOString().split('T')[0]}.json`); 180 await showAlert(`Exported ${allCourses.length} course(s) successfully.`); 181} 182 183export async function importSingleCourseAsNew(data) { 184 if (!(await ensureHostedWorkspaceReady({ forceProfile: true }))) return; 185 186 const courseName = data.courseName || (data.config && data.config.subTitle) || 'Imported Course'; 187 if (!(await showConfirm(`Import course: "${courseName}"?\n\nThis will create a new course with this data.`))) return; 188 189 const newCourseId = CourseManager.createCourse(courseName, { 190 id: data.courseId, 191 created: data.created,
192 updatedAt: data.updatedAt, 193 lastAccessed: data.lastAccessed || Date.now(), 194 }); 195 writeCourseExportData(newCourseId, data); 196 197 const sync = _getSyncService?.(); 198 if (sync) { 199 try { 200 await sync.flushCloudCourseSave(newCourseId); 201 } catch (error) { 202 await showAlert(`Could not save the imported course online.\n\n${error.message}`); 203 return; 204 } 205 } 206 207 CourseManager.setActiveCourse(newCourseId); 208 await showAlert(`Successfully imported "${courseName}"!`); 209 location.reload(); 210} 211 212export async function importMultipleCourses(data) { 213 if (!(await ensureHostedWorkspaceReady({ forceProfile: true }))) return; 214 215 const count = data.courses ? data.courses.length : 0; 216 if (!(await showConfirm(`Import ${count} course(s)?\n\nThis will add all courses from the backup file.`))) return; 217 218 let imported = 0; 219 const importedCourseIds = []; 220 const courseIdMap = {}; 221 222 data.courses.forEach((courseData) => { 223 const oldCourseId = courseData.courseId; 224 const newCourseId = CourseManager.createCourse( 225 courseData.courseName || (courseData.config && courseData.config.subTitle) || 'Imported Course', 226 { 227 id: courseData.courseId, 228 created: courseData.created,
229 updatedAt: courseData.updatedAt, 230 lastAccessed: courseData.lastAccessed || Date.now(), 231 }, 232 ); 233 writeCourseExportData(newCourseId, courseData); 234 importedCourseIds.push(newCourseId); 235 if (oldCourseId) courseIdMap[oldCourseId] = newCourseId; 236 imported += 1; 237 }); 238 239 const sync = _getSyncService?.(); 240 if (sync) { 241 try { 242 await Promise.all(importedCourseIds.map((id) => sync.flushCloudCourseSave(id))); 243 } catch (error) { 244 await showAlert(`Could not finish saving the imported courses online.\n\n${error.message}`); 245 return; 246 } 247 } 248 249 if (imported > 0) { 250 const preferredCourseId = courseIdMap[data.lastActiveCourseId]; 251 if (preferredCourseId) { 252 CourseManager.setActiveCourse(preferredCourseId); 253 } else if (data.courses[0]) { 254 const fallbackId = courseIdMap[data.courses[0].courseId] || importedCourseIds[0]; 255 if (fallbackId) CourseManager.setActiveCourse(fallbackId); 256 } 257 } 258 259 await showAlert(`Successfully imported ${imported} course(s)!`); 260 location.reload(); 261} 262 263/** 264 * Validate that parsed backup JSON has the expected shape before importing. 265 * Returns an error string if invalid, or null if OK. 266 */ 267function validateBackupData(data) { 268 if (!data || typeof data !== 'object' || Array.isArray(data)) { 269 return 'Backup file does not contain a valid JSON object.'; 270 } 271 272 if (data.exportType === 'ALL_COURSES') { 273 if (!Array.isArray(data.courses)) { 274 return 'Multi-course backup is missing the "courses" array.'; 275 } 276 for (let i = 0; i < data.courses.length; i++) { 277 const c = data.courses[i]; 278 if (!c || typeof c !== 'object' || Array.isArray(c)) { 279 return `Course entry ${i + 1} is not a valid object.`; 280 } 281 } 282 return null; 283 } 284 285 // Single course: must have at least some recognizable course data 286 const hasStudents = Array.isArray(data.students); 287 const hasArchive = data.archive && typeof data.archive === 'object'; 288 const hasConfig = data.config && typeof data.config === 'object'; 289 const hasCourseName = typeof data.courseName === 'string'; 290 if (!hasStudents && !hasArchive && !hasConfig && !hasCourseName) { 291 return 'This file does not appear to be a Class Action backup. Expected students, archive, config, or courseName.'; 292 } 293 294 return null; 295} 296 297export function triggerRestore() { 298 const backupInput = document.getElementById('backupInput'); 299 const file = backupInput?.files?.[0]; 300 if (!file) return; 301 302 const reader = new FileReader(); 303 reader.onload = async (event) => { 304 try { 305 let data;
306 try { 307 data = JSON.parse(event.target.result); 308 } catch (_) { 309 await showAlert('Invalid backup file â could not be read. Please check the file and try again.'); 310 return; 311 } 312 313 const validationError = validateBackupData(data); 314 if (validationError) { 315 await showAlert(`Invalid backup file:\n\n${validationError}`); 316 return; 317 } 318 319 if (!(await ensureHostedWorkspaceReady({ forceProfile: true }))) return; 320 321 if (data.exportType === 'ALL_COURSES' && data.courses) { 322 await importMultipleCourses(data); 323 return; 324 } 325 326 const currentCourseId = CourseManager.getActiveCourseId(); 327 if (!currentCourseId) { 328 await importSingleCourseAsNew(data); 329 return; 330 } 331 332 const currentCourseName = CourseManager.getCourseName(currentCourseId) || 'this course'; 333 const backupCourseName = data.courseName || 'Unknown Course'; 334 let confirmMsg = `Restore backup into current course "${currentCourseName}"?\n\nThis will overwrite all current data.`; 335 336 if (data.courseId && data.courseId !== currentCourseId) { 337 confirmMsg = 338 'â ï¸ Course Mismatch Warning\n\n' + 339 `Backup is from: "${backupCourseName}"\n` + 340 `Current course: "${currentCourseName}"\n\n` + 341 'This will overwrite all data in the current course. Continue?'; 342 } 343 344 if (await showConfirm(`${confirmMsg}\n\nYour current data will be automatically backed up before restoring.`)) { 345 await downloadFullBackup(); 346 writeCourseExportData(currentCourseId, data); 347 const sync = _getSyncService?.(); 348 if (sync) { 349 try { 350 await sync.flushCloudCourseSave(currentCourseId); 351 } catch (error) { 352 await showAlert(`Could not save the restored course online.\n\n${error.message}`); 353 return; 354 } 355 } 356 location.reload(); 357 } 358 } finally { 359 if (backupInput) backupInput.value = ''; 360 } 361 }; 362 reader.onerror = async () => { 363 await showAlert('Invalid backup file â could not be read. Please check the file and try again.'); 364 if (backupInput) backupInput.value = ''; 365 }; 366 reader.readAsText(file); 367} 368 369export async function factoryReset() { 370 const courseId = CourseManager.getActiveCourseId(); 371 if (!courseId) { 372 await showAlert('Open a course first.'); 373 return; 374 } 375 376 const courseName = CourseManager.getCourseName(courseId) || 'this course'; 377 const ok = await showConfirm( 378 `Factory reset for "${courseName}"?\n\n` + 379 'This will permanently delete this course from your online workspace.\n\n' + 380 'Your other courses will not be affected.', 381 ); 382 if (!ok) return; 383 384 if (onlineOnlyState.saveTimers[courseId]) { 385 clearTimeout(onlineOnlyState.saveTimers[courseId]); 386 delete onlineOnlyState.saveTimers[courseId]; 387 } 388 if (onlineOnlyState.savePromises[courseId]) { 389 try { await onlineOnlyState.savePromises[courseId]; } catch (_) {} 390 } 391 392 const sync = _getSyncService?.(); 393 try { 394 await sync?.deleteCloudCourse(courseId); 395 } catch (error) { 396 await showAlert(`Could not delete this online course.\n\n${error.message}`); 397 return; 398 } 399 delete onlineOnlyState.pendingSaveAfterCurrent[courseId]; 400 401 withOnlineOnlyHydration(() => { 402 Object.values(getCourseStorageKeys(courseId)).forEach((key) => localStorage.removeItem(key)); 403 const courses = CourseManager.getAllCourses().filter((c) => c.id !== courseId); 404 CourseManager.saveCourseRegistry(courses); 405 sessionStorage.removeItem(GLOBAL_KEYS.activeSessionCourse); 406 try { 407 if (courses.length > 0) { 408 localStorage.setItem(GLOBAL_KEYS.lastActiveCourse, courses[0].id); 409 } else { 410 localStorage.removeItem(GLOBAL_KEYS.lastActiveCourse); 411 } 412 } catch (_) {} 413 }); 414 415 location.reload(); 416} 417 418// --------------------------------------------------------------------------- 419// CSV exports 420// --------------------------------------------------------------------------- 421 422export function exportRosterCSV() { 423 const students = _getStudentData?.() || []; 424 let csv = 'Name,Expertise,Present,Late,Absent,Empty,Private Notes\n';
425 students.forEach((student) => { 426 const totals = _getAttendanceTotals?.(student) || { P: 0, L: 0, A: 0, empty: 0 }; 427 const notes = String(student.profileNotes || '').replace(/"/g, '""').replace(/\n/g, ' '); 428 csv += `"${student.name}","${student.expertise || ''}",${totals.P},${totals.L},${totals.A},${totals.empty},"${notes}"\n`; 429 }); 430 downloadCSV(csv, `Roster_${new Date().toISOString().split('T')[0]}.csv`); 431} 432 433export function exportAttendanceCSV() { 434 const students = _getStudentData?.() || []; 435 const classCount = _getConfig?.()?.classCount || 0; 436 let csv = `Name,${Array.from({ length: classCount }, (_, i) => `Class ${i + 1}`).join(',')}\n`; 437 students.forEach((student) => { 438 let row = `"${student.name}"`; 439 (student.history || []).forEach((entry) => { 440 row += `,${entry.status === 'empty' ? '' : entry.status}`; 441 }); 442 csv += `${row}\n`; 443 }); 444 downloadCSV(csv, `Attendance_${new Date().toISOString().split('T')[0]}.csv`); 445} 446 447export function exportCallLogCSV() { 448 const archive = _getLessonArchive?.() || {}; 449 let csv = 'Class,Date,Topic,Timestamp,Entry\n'; 450 Object.keys(archive).sort((a, b) => Number(a) - Number(b)).forEach((classNum) => { 451 const lesson = archive[classNum]; 452 if (!lesson.callLog) return; 453 lesson.callLog.split('\n').forEach((line) => { 454 if (!line.trim()) return; 455 const match = line.match(/\[(.*?)\] (.*)/); 456 if (match) { 457 const timestamp = match[1]; 458 const entry = match[2].replace(/"/g, '""'); 459 csv += `${classNum},"${lesson.date || ''}","${lesson.topic || ''}","${timestamp}","${entry}"\n`; 460 } 461 }); 462 }); 463 downloadCSV(csv, `CallLog_${new Date().toISOString().split('T')[0]}.csv`); 464} 465 466// --------------------------------------------------------------------------- 467// File input binding 468// --------------------------------------------------------------------------- 469 470export function bindBackupInput() { 471 const backupInput = document.getElementById('backupInput'); 472 if (!backupInput || backupInput.dataset.bound === 'true') return; 473 backupInput.dataset.bound = 'true'; 474 backupInput.addEventListener('change', () => { 475 if (backupInput.files && backupInput.files[0]) { 476 triggerRestore(); 477 } 478 }); 479}
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.