PageSourceSearch

https://classaction.cv/app/js/features/export-backup.js

js classaction.cv collected 2026-10-07 13:18:39 UTC 16,713 bytes, 479 lines download raw bytes

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.