@mrselva/attendance 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +12 -8
  2. package/index.js +497 -82
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE)
5
5
  [![Node.js Version](https://img.shields.io/badge/node-%3E%3D16.0.0-brightgreen.svg)](https://nodejs.org)
6
6
 
7
- > A fast and lightweight CLI tool to extract, normalize, and export multi-sheet attendance spreadsheets into organized Excel files and ZIP archives.
7
+ > A fast and lightweight CLI tool to normalize, match, and export multi-sheet attendance spreadsheets into organized DigiCampus-compatible Excel files and ZIP archives.
8
8
 
9
9
  Run instantly with **`npx`** — no installation required!
10
10
 
@@ -29,18 +29,19 @@ attendance
29
29
 
30
30
  ## ✨ Features
31
31
 
32
- - **Zero Configuration & Auto-Import**: If no file is in `./input/`, an interactive file picker window opens so you can select your spreadsheet directly from your computer, or drag & drop file paths into the terminal.
32
+ - **Zero Configuration & Auto-Import**: If no file is in `./input/`, an interactive native file picker window opens so you can click and select your spreadsheet directly from your computer, or drag & drop file paths into the terminal.
33
+ - **Spreadsheet Normalization Engine**: Raw sheets often contain mismatched column orders, duplicate headers (like repeated departments), extra spaces in header names, and stray empty rows. The CLI automatically converts uploaded files into an understandable, standardized format, saves it to `./normalized/Normalized_<filename>.xlsx`, and uses this clean file for all operations.
34
+ - **DigiCampus Template Integration**: Upload or browse your DigiCampus template (`Upload_Attendance.xlsx`). The CLI fills attendance directly against the exact student roster required by DigiCampus.
33
35
  - **Cohort & Batch Support**:
34
36
  - **3rd Year**: Exports standard whole-class roster.
35
37
  - **2nd Year**: Automatically detects the empty row separating **Batch 1** (above the empty row) and **Batch 2** (below the empty row). Prompts you to export Batch 1, Batch 2, or Both Batches!
36
- - **Multi-Sheet Detection**: Automatically discovers all relevant sheets and rosters across the workbook.
37
38
  - **Smart Date Filtering**:
38
39
  - Export **All recorded dates**
39
40
  - Export a **Single date** (e.g. `YYYY-MM-DD`)
40
41
  - Export a **Date range** (from `YYYY-MM-DD` to `YYYY-MM-DD`)
41
42
  - Export the **Latest date only**
42
- - **Status Normalization**: Accurately normalizes attendance status (`PRESENT`, `ABSENT`, etc.) and handles blanks cleanly.
43
- - **Automatic ZIP Packaging**: Compresses generated export files directly into clean ZIP archives in `./output/`.
43
+ - **Date-First Output Naming**: Generated files inside the ZIP start with the date (e.g. `2026-10-09_Upload_Attendance_BW_Batch1.xlsx`) for easy chronological sorting.
44
+ - **Automatic ZIP Packaging**: Compresses generated export files directly into clean ZIP archives in `./output/` with full cohort and batch metadata in the archive name.
44
45
 
45
46
  ---
46
47
 
@@ -49,11 +50,11 @@ attendance
49
50
  Run the interactive wizard or pass flags for automation:
50
51
 
51
52
  ```bash
52
- # Interactive mode (asks Year, Batch, Sheet, Dates)
53
+ # Interactive mode (prompts for File, Year, Template, Batch, Sheet, Dates)
53
54
  npx @mrselva/attendance
54
55
 
55
- # 2nd Year - Export Batch 1
56
- npx @mrselva/attendance -y 2 -b 1 -v SHEET_CODE -a
56
+ # 2nd Year - Export Batch 1 with template
57
+ npx @mrselva/attendance -y 2 -b 1 -v SHEET_CODE -t "path/to/Upload_Attendance.xlsx" -a
57
58
 
58
59
  # 2nd Year - Export Batch 2
59
60
  npx @mrselva/attendance -y 2 -b 2 -v SHEET_CODE -a
@@ -75,6 +76,7 @@ npx @mrselva/attendance -f "attendance_sheet.xlsx" -y 2 -b 1 -a
75
76
  | `-y <2\|3>` | `--year <2\|3>` | Academic year (`2` for 2nd year batched, `3` for 3rd year standard) |
76
77
  | `-b <1\|2\|all>` | `--batch <1\|2\|all>` | Batch selection for 2nd year (`1`, `2`, or `all` for both) |
77
78
  | `-v <code>` | `--vertical <code>` | Sheet code or name (or `ALL`) |
79
+ | `-t <file>` | `--template <path>` | DigiCampus upload template Excel file (.xlsx) |
78
80
  | `-a` | `--all` | Export all recorded dates |
79
81
  | `-d <date>` | `--date <YYYY-MM-DD>` | Export a specific date |
80
82
  | | `--latest` | Export the latest recorded date |
@@ -88,6 +90,8 @@ npx @mrselva/attendance -f "attendance_sheet.xlsx" -y 2 -b 1 -a
88
90
  ```text
89
91
  your-folder/
90
92
  ├── input/ # Place source spreadsheets here (or auto-import)
93
+ ├── normalized/ # Clean, standardized master spreadsheets
94
+ ├── digi/ # DigiCampus upload templates
91
95
  └── output/ # Automatically generated ZIP archives
92
96
  ```
93
97
 
package/index.js CHANGED
@@ -5,10 +5,17 @@
5
5
  *
6
6
  * Features:
7
7
  * - Scans ./input or current directory for attendance spreadsheets (.xlsx / .xls)
8
- * - Interactively prompts for sheet/category selection
9
- * - Allows exporting all recorded dates, specific date, range, or latest date
10
- * - Generates individual formatted Excel files with ZIP archive in ./output
11
- * - Works both interactively (in terminal) and with CLI flags (--vertical, --all, etc.)
8
+ * - Interactively prompts for file selection or opens OS file picker
9
+ * - Prompts for Academic Year (3rd Year standard vs 2nd Year batched)
10
+ * - Prompts for optional DigiCampus template upload (Upload_Attendance.xlsx)
11
+ * - Normalization Engine: Automatically standardizes irregular/mismatched raw sheets,
12
+ * cleans duplicate headers, fixes column orders, removes empty rows, assigns batches,
13
+ * and maintains a clean standardized file in ./normalized/Normalized_<file>.xlsx
14
+ * - Uses the normalized workbook for all downstream parsing and exports
15
+ * - Supports exporting specific dates, date ranges, latest date, or all instructional dates
16
+ * - Generates DigiCampus-compatible Excel files ('Students' sheet with Email Id, Registration Id, Attendance *)
17
+ * - Packages exports into ZIP archives in ./output with metadata naming
18
+ * - Preserves date-first file naming (YYYY-MM-DD_Upload_Attendance_...)
12
19
  */
13
20
 
14
21
  const fs = require('fs');
@@ -21,6 +28,8 @@ const AdmZip = require('adm-zip');
21
28
  // Paths (relative to user current working directory where npx is executed)
22
29
  const INPUT_DIR = path.resolve(process.cwd(), 'input');
23
30
  const OUTPUT_DIR = path.resolve(process.cwd(), 'output');
31
+ const NORMALIZED_DIR = path.resolve(process.cwd(), 'normalized');
32
+ const DIGI_DIR = path.resolve(process.cwd(), 'digi');
24
33
 
25
34
  // Optional category/sheet code to display name mapping
26
35
  const VERTICAL_NAMES = {};
@@ -66,6 +75,12 @@ function ensureDirs() {
66
75
  if (!fs.existsSync(OUTPUT_DIR)) {
67
76
  fs.mkdirSync(OUTPUT_DIR, { recursive: true });
68
77
  }
78
+ if (!fs.existsSync(NORMALIZED_DIR)) {
79
+ fs.mkdirSync(NORMALIZED_DIR, { recursive: true });
80
+ }
81
+ if (!fs.existsSync(DIGI_DIR)) {
82
+ fs.mkdirSync(DIGI_DIR, { recursive: true });
83
+ }
69
84
  }
70
85
 
71
86
  // Find excel files in input folder or current working directory
@@ -179,7 +194,6 @@ function normalizeStatus(val) {
179
194
  return null;
180
195
  }
181
196
 
182
-
183
197
  // Check if a row represents summary / totals
184
198
  function isSummaryRow(row, name, regNo) {
185
199
  if (name && (/no\s*\.?\s*of/i.test(name) || /total/i.test(name) || /present/i.test(name) || /absent/i.test(name))) {
@@ -205,8 +219,13 @@ function getInstructionalDates(students, dateColumns, rows) {
205
219
  let absentCount = 0;
206
220
 
207
221
  for (const student of students) {
208
- const cellVal = rows[student.rowIdx] ? rows[student.rowIdx][dc.colIdx] : null;
209
- const norm = normalizeStatus(cellVal);
222
+ let norm = null;
223
+ if (student.attendance && student.attendance[dc.dateStr] !== undefined) {
224
+ norm = student.attendance[dc.dateStr];
225
+ } else if (rows && student.rowIdx !== undefined) {
226
+ const cellVal = rows[student.rowIdx] ? rows[student.rowIdx][dc.colIdx] : null;
227
+ norm = normalizeStatus(cellVal);
228
+ }
210
229
  if (norm === 'PRESENT') {
211
230
  presentCount++;
212
231
  } else if (norm === 'ABSENT') {
@@ -227,6 +246,202 @@ function getInstructionalDates(students, dateColumns, rows) {
227
246
  return instructionalDates;
228
247
  }
229
248
 
249
+ /**
250
+ * Normalization Engine:
251
+ * Converts raw uploaded attendance sheets with mismatched column orders, duplicate headers,
252
+ * extra spaces, and irregular blank rows into a unified, clean default format workbook.
253
+ *
254
+ * Standardized columns per sheet:
255
+ * ['S.No', 'Student Name', 'Register Number', 'Roll Number', 'Email Id', 'Department', 'Section', 'Gender', 'Batch', ...dateStrings]
256
+ */
257
+ function createNormalizedWorkbook(rawWorkbook, selectedYear = 2) {
258
+ const normWb = xlsx.utils.book_new();
259
+ const excludedSheets = ['pivot table', 'summary', 'template', 'students data', 'student data', 'long leave'];
260
+ let sheetCount = 0;
261
+
262
+ for (const sheetName of rawWorkbook.SheetNames) {
263
+ if (excludedSheets.some(ex => sheetName.toLowerCase().includes(ex))) {
264
+ continue;
265
+ }
266
+ const ws = rawWorkbook.Sheets[sheetName];
267
+ const rows = xlsx.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null });
268
+ if (!rows || rows.length < 2) continue;
269
+
270
+ const headerRow = rows[0];
271
+ let snoColIdx = -1;
272
+ let nameColIdx = -1;
273
+ let regColIdx = -1;
274
+ let rollColIdx = -1;
275
+ let emailColIdx = -1;
276
+ let deptColIdx = -1;
277
+ let secColIdx = -1;
278
+ let genderColIdx = -1;
279
+ const dateColumns = [];
280
+
281
+ for (let c = 0; c < headerRow.length; c++) {
282
+ const hVal = headerRow[c];
283
+ if (hVal === null || hVal === undefined) continue;
284
+ const hStr = String(hVal).trim().toLowerCase();
285
+
286
+ if (hStr.includes('register') || hStr.includes('reg no') || hStr.includes('reg.') || hStr.includes('registration id')) {
287
+ regColIdx = c;
288
+ } else if (hStr.includes('student name') || hStr === 'name') {
289
+ nameColIdx = c;
290
+ } else if (hStr.includes('kite email')) {
291
+ emailColIdx = c;
292
+ } else if (emailColIdx === -1 && (hStr.includes('email id') || hStr.includes('email') || hStr.includes('mail')) && !hStr.includes('soi')) {
293
+ emailColIdx = c;
294
+ } else if (hStr.includes('roll number') || hStr.includes('roll no')) {
295
+ rollColIdx = c;
296
+ } else if (hStr.includes('s.no') || hStr === 'sno' || hStr.includes('sl.no')) {
297
+ snoColIdx = c;
298
+ } else if (deptColIdx === -1 && (hStr.includes('department') || hStr === 'dept' || hStr === 'branch')) {
299
+ deptColIdx = c;
300
+ } else if (secColIdx === -1 && (hStr.includes('section') || hStr === 'sec')) {
301
+ secColIdx = c;
302
+ } else if (genderColIdx === -1 && (hStr.includes('gender') || hStr === 'sex')) {
303
+ genderColIdx = c;
304
+ } else {
305
+ const cellAddress = xlsx.utils.encode_cell({ r: 0, c: c });
306
+ const cellRaw = ws[cellAddress];
307
+ const dateStr = formatDate(hVal, cellRaw);
308
+ if (dateStr) {
309
+ dateColumns.push({ colIdx: c, dateStr });
310
+ }
311
+ }
312
+ }
313
+
314
+ if (regColIdx === -1 || dateColumns.length === 0) {
315
+ continue; // Skip non-vertical sheets
316
+ }
317
+
318
+ const stdHeader = [
319
+ 'S.No',
320
+ 'Student Name',
321
+ 'Register Number',
322
+ 'Roll Number',
323
+ 'Email Id',
324
+ 'Department',
325
+ 'Section',
326
+ 'Gender',
327
+ 'Batch',
328
+ ...dateColumns.map(d => d.dateStr)
329
+ ];
330
+
331
+ const normData = [stdHeader];
332
+ let currentBatch = 1;
333
+ let sawBlankAfterBatch1 = false;
334
+ let studentCounter = 0;
335
+
336
+ for (let r = 1; r < rows.length; r++) {
337
+ const row = rows[r];
338
+ if (!row || row.length === 0) {
339
+ if (studentCounter > 0 && currentBatch === 1) {
340
+ sawBlankAfterBatch1 = true;
341
+ }
342
+ continue;
343
+ }
344
+
345
+ const name = nameColIdx !== -1 ? String(row[nameColIdx] || '').trim() : '';
346
+ const regNo = regColIdx !== -1 ? String(row[regColIdx] || '').trim() : '';
347
+ if (isSummaryRow(row, name, regNo)) break;
348
+
349
+ const rollNo = rollColIdx !== -1 ? String(row[rollColIdx] || '').trim() : '';
350
+ const email = emailColIdx !== -1 ? String(row[emailColIdx] || '').trim() : '';
351
+ const dept = deptColIdx !== -1 ? String(row[deptColIdx] || '').trim() : '';
352
+ const sec = secColIdx !== -1 ? String(row[secColIdx] || '').trim() : '';
353
+ const gender = genderColIdx !== -1 ? String(row[genderColIdx] || '').trim() : '';
354
+
355
+ const isStudent = name.length >= 2 && (regNo.length > 0 || rollNo.length > 0);
356
+ if (!isStudent) {
357
+ if (studentCounter > 0 && currentBatch === 1) {
358
+ sawBlankAfterBatch1 = true;
359
+ }
360
+ continue;
361
+ }
362
+
363
+ // Switch to Batch 2 in 2nd Year when empty separator row was encountered
364
+ if (selectedYear === 2 && currentBatch === 1 && sawBlankAfterBatch1) {
365
+ currentBatch = 2;
366
+ }
367
+
368
+ studentCounter++;
369
+ const attendanceRow = dateColumns.map(dc => normalizeStatus(row[dc.colIdx]) || '');
370
+
371
+ normData.push([
372
+ studentCounter,
373
+ name,
374
+ regNo,
375
+ rollNo,
376
+ email,
377
+ dept,
378
+ sec,
379
+ gender,
380
+ selectedYear === 2 ? `Batch ${currentBatch}` : 'Batch 1',
381
+ ...attendanceRow
382
+ ]);
383
+ }
384
+
385
+ const normWs = xlsx.utils.aoa_to_sheet(normData);
386
+ normWs['!cols'] = [
387
+ { wch: 6 },
388
+ { wch: 25 },
389
+ { wch: 18 },
390
+ { wch: 15 },
391
+ { wch: 35 },
392
+ { wch: 20 },
393
+ { wch: 10 },
394
+ { wch: 10 },
395
+ { wch: 12 },
396
+ ...dateColumns.map(() => ({ wch: 12 }))
397
+ ];
398
+
399
+ xlsx.utils.book_append_sheet(normWb, normWs, sheetName);
400
+ sheetCount++;
401
+ }
402
+
403
+ return { normWb, sheetCount };
404
+ }
405
+
406
+ // Load DigiCampus Upload Template (e.g. Upload_Attendance.xlsx)
407
+ function loadTemplate(filePath) {
408
+ if (!filePath || !fs.existsSync(filePath)) return null;
409
+ const wb = xlsx.readFile(filePath);
410
+ const sheetName = wb.SheetNames[0];
411
+ const ws = wb.Sheets[sheetName];
412
+ const rows = xlsx.utils.sheet_to_json(ws, { header: 1, raw: true, defval: '' });
413
+ if (!rows || rows.length < 2) return null;
414
+
415
+ const headerRow = rows[0];
416
+ let emailCol = -1;
417
+ let regCol = -1;
418
+ let attCol = -1;
419
+
420
+ for (let c = 0; c < headerRow.length; c++) {
421
+ const h = String(headerRow[c] || '').trim().toLowerCase();
422
+ if (h.includes('email')) emailCol = c;
423
+ else if (h.includes('reg')) regCol = c;
424
+ else if (h.includes('attend')) attCol = c;
425
+ }
426
+
427
+ if (emailCol === -1 || regCol === -1) return null;
428
+
429
+ const students = [];
430
+ for (let r = 1; r < rows.length; r++) {
431
+ const row = rows[r];
432
+ const email = String(row[emailCol] || '').trim();
433
+ const regNo = String(row[regCol] || '').trim();
434
+ if (!email && !regNo) continue;
435
+ students.push({
436
+ email,
437
+ regNo,
438
+ defaultStatus: attCol !== -1 ? String(row[attCol] || '').trim() : ''
439
+ });
440
+ }
441
+
442
+ return { sheetName, students };
443
+ }
444
+
230
445
  // Parse a vertical sheet to extract students (batch 1 & 2) and date columns
231
446
  function parseVerticalSheet(ws, sheetName) {
232
447
  const rows = xlsx.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null });
@@ -238,6 +453,11 @@ function parseVerticalSheet(ws, sheetName) {
238
453
  let nameColIdx = -1;
239
454
  let emailColIdx = -1;
240
455
  let rollColIdx = -1;
456
+ let batchColIdx = -1;
457
+ let deptColIdx = -1;
458
+ let secColIdx = -1;
459
+ let genderColIdx = -1;
460
+ let snoColIdx = -1;
241
461
  const dateColumns = [];
242
462
 
243
463
  // Analyze header row
@@ -251,10 +471,22 @@ function parseVerticalSheet(ws, sheetName) {
251
471
  regColIdx = c;
252
472
  } else if (hStr.includes('student name') || hStr === 'name') {
253
473
  nameColIdx = c;
254
- } else if (hStr.includes('email id') || hStr.includes('email') || hStr.includes('mail')) {
474
+ } else if (hStr.includes('kite email')) {
475
+ emailColIdx = c;
476
+ } else if (emailColIdx === -1 && (hStr.includes('email id') || hStr.includes('email') || hStr.includes('mail')) && !hStr.includes('soi')) {
255
477
  emailColIdx = c;
256
478
  } else if (hStr.includes('roll number') || hStr.includes('roll no')) {
257
479
  rollColIdx = c;
480
+ } else if (hStr.includes('s.no') || hStr === 'sno' || hStr.includes('sl.no')) {
481
+ snoColIdx = c;
482
+ } else if (hStr === 'batch') {
483
+ batchColIdx = c;
484
+ } else if (deptColIdx === -1 && (hStr.includes('department') || hStr === 'dept' || hStr === 'branch')) {
485
+ deptColIdx = c;
486
+ } else if (secColIdx === -1 && (hStr.includes('section') || hStr === 'sec')) {
487
+ secColIdx = c;
488
+ } else if (genderColIdx === -1 && (hStr.includes('gender') || hStr === 'sex')) {
489
+ genderColIdx = c;
258
490
  } else {
259
491
  const cellAddress = xlsx.utils.encode_cell({ r: 0, c: c });
260
492
  const cellRaw = ws[cellAddress];
@@ -270,7 +502,7 @@ function parseVerticalSheet(ws, sheetName) {
270
502
  return null; // Not a vertical attendance sheet
271
503
  }
272
504
 
273
- // Parse student records, separating Batch 1 and Batch 2 across empty separator row
505
+ // Parse student records, separating Batch 1 and Batch 2
274
506
  const batch1Students = [];
275
507
  const batch2Students = [];
276
508
  let currentBatch = 1;
@@ -285,11 +517,14 @@ function parseVerticalSheet(ws, sheetName) {
285
517
  continue;
286
518
  }
287
519
 
288
- const sno = row[0];
520
+ const sno = snoColIdx !== -1 ? row[snoColIdx] : r;
289
521
  const name = nameColIdx !== -1 ? String(row[nameColIdx] || '').trim() : '';
290
522
  const regNo = regColIdx !== -1 ? String(row[regColIdx] || '').trim() : '';
291
523
  const email = emailColIdx !== -1 ? String(row[emailColIdx] || '').trim() : '';
292
524
  const rollNo = rollColIdx !== -1 ? String(row[rollColIdx] || '').trim() : '';
525
+ const dept = deptColIdx !== -1 ? String(row[deptColIdx] || '').trim() : '';
526
+ const sec = secColIdx !== -1 ? String(row[secColIdx] || '').trim() : '';
527
+ const gender = genderColIdx !== -1 ? String(row[genderColIdx] || '').trim() : '';
293
528
 
294
529
  // Ignore summary/totals rows at bottom
295
530
  if (isSummaryRow(row, name, regNo)) {
@@ -297,7 +532,6 @@ function parseVerticalSheet(ws, sheetName) {
297
532
  }
298
533
 
299
534
  const isStudent = name.length >= 2 && (regNo.length > 0 || rollNo.length > 0);
300
-
301
535
  if (!isStudent) {
302
536
  if (batch1Students.length > 0 && currentBatch === 1) {
303
537
  sawBlankAfterBatch1 = true;
@@ -305,20 +539,35 @@ function parseVerticalSheet(ws, sheetName) {
305
539
  continue;
306
540
  }
307
541
 
308
- // Valid student row
309
- const snoNum = Number(sno);
310
- if (batch1Students.length > 0 && currentBatch === 1 && (sawBlankAfterBatch1 || snoNum === 1)) {
311
- currentBatch = 2;
542
+ // Determine batch (from normalized batch column or blank row boundary)
543
+ if (batchColIdx !== -1) {
544
+ const bStr = String(row[batchColIdx] || '').toLowerCase();
545
+ currentBatch = bStr.includes('2') ? 2 : 1;
546
+ } else {
547
+ if (batch1Students.length > 0 && currentBatch === 1 && sawBlankAfterBatch1) {
548
+ currentBatch = 2;
549
+ }
550
+ }
551
+
552
+ // Build attendance dictionary for each date
553
+ const attendance = {};
554
+ for (const dc of dateColumns) {
555
+ const cellVal = row[dc.colIdx];
556
+ attendance[dc.dateStr] = normalizeStatus(cellVal) || '';
312
557
  }
313
558
 
314
559
  const studentObj = {
315
560
  rowIdx: r,
316
- sno: sno,
317
- name: name,
318
- regNo: regNo,
319
- rollNo: rollNo,
561
+ sno,
562
+ name,
563
+ regNo,
564
+ rollNo,
320
565
  email: email || '',
321
- batch: currentBatch
566
+ dept,
567
+ sec,
568
+ gender,
569
+ batch: currentBatch,
570
+ attendance
322
571
  };
323
572
 
324
573
  if (currentBatch === 1) {
@@ -350,8 +599,8 @@ function parseVerticalSheet(ws, sheetName) {
350
599
  };
351
600
  }
352
601
 
353
- // Generate formatted Excel workbook for a single date
354
- function generateExportWorkbook(verticalData, dateItem, rows, studentList) {
602
+ // Generate formatted Excel workbook for a single date (DigiCampus Upload format)
603
+ function generateExportWorkbook(verticalData, dateItem, rows, studentList, parsedTemplate) {
355
604
  const wb = xlsx.utils.book_new();
356
605
 
357
606
  // Export Sheet Header
@@ -361,19 +610,90 @@ function generateExportWorkbook(verticalData, dateItem, rows, studentList) {
361
610
 
362
611
  const students = studentList || verticalData.students;
363
612
 
364
- for (const student of students) {
365
- const rawVal = rows[student.rowIdx] ? rows[student.rowIdx][dateItem.colIdx] : null;
366
- let status = normalizeStatus(rawVal);
367
- // On an instructional day, any unrecorded or blank student is marked ABSENT
368
- if (!status) {
369
- status = 'ABSENT';
613
+ if (parsedTemplate && parsedTemplate.students && parsedTemplate.students.length > 0) {
614
+ // Build lookup maps for fast matching
615
+ const studentByReg = new Map();
616
+ const studentByEmail = new Map();
617
+
618
+ for (const st of students) {
619
+ if (st.regNo) studentByReg.set(st.regNo.toUpperCase().trim(), st);
620
+ if (st.email) studentByEmail.set(st.email.toLowerCase().trim(), st);
370
621
  }
371
622
 
372
- wsData.push([
373
- student.email,
374
- student.regNo,
375
- status
376
- ]);
623
+ // Check how many students in the template match this cohort
624
+ let matchCount = 0;
625
+ for (const ts of parsedTemplate.students) {
626
+ const tReg = (ts.regNo || '').toUpperCase().trim();
627
+ const tEmail = (ts.email || '').toLowerCase().trim();
628
+ if ((tReg && studentByReg.has(tReg)) || (tEmail && studentByEmail.has(tEmail))) {
629
+ matchCount++;
630
+ }
631
+ }
632
+
633
+ if (matchCount > 0) {
634
+ // Use exact template roster and fill attendance
635
+ for (const tStudent of parsedTemplate.students) {
636
+ const tReg = (tStudent.regNo || '').toUpperCase().trim();
637
+ const tEmail = (tStudent.email || '').toLowerCase().trim();
638
+
639
+ let matched = null;
640
+ if (tReg && studentByReg.has(tReg)) {
641
+ matched = studentByReg.get(tReg);
642
+ } else if (tEmail && studentByEmail.has(tEmail)) {
643
+ matched = studentByEmail.get(tEmail);
644
+ }
645
+
646
+ let status = 'ABSENT';
647
+ if (matched) {
648
+ if (matched.attendance && matched.attendance[dateItem.dateStr]) {
649
+ status = matched.attendance[dateItem.dateStr];
650
+ } else if (rows && matched.rowIdx !== undefined) {
651
+ const rawVal = rows[matched.rowIdx] ? rows[matched.rowIdx][dateItem.colIdx] : null;
652
+ status = normalizeStatus(rawVal) || 'ABSENT';
653
+ }
654
+ }
655
+
656
+ wsData.push([
657
+ tStudent.email,
658
+ tStudent.regNo,
659
+ status
660
+ ]);
661
+ }
662
+ } else {
663
+ // Fallback to vertical roster if template had 0 matches for this vertical
664
+ for (const student of students) {
665
+ let status = 'ABSENT';
666
+ if (student.attendance && student.attendance[dateItem.dateStr]) {
667
+ status = student.attendance[dateItem.dateStr];
668
+ } else if (rows && student.rowIdx !== undefined) {
669
+ const rawVal = rows[student.rowIdx] ? rows[student.rowIdx][dateItem.colIdx] : null;
670
+ status = normalizeStatus(rawVal) || 'ABSENT';
671
+ }
672
+
673
+ wsData.push([
674
+ student.email,
675
+ student.regNo,
676
+ status
677
+ ]);
678
+ }
679
+ }
680
+ } else {
681
+ // Standard vertical roster
682
+ for (const student of students) {
683
+ let status = 'ABSENT';
684
+ if (student.attendance && student.attendance[dateItem.dateStr]) {
685
+ status = student.attendance[dateItem.dateStr];
686
+ } else if (rows && student.rowIdx !== undefined) {
687
+ const rawVal = rows[student.rowIdx] ? rows[student.rowIdx][dateItem.colIdx] : null;
688
+ status = normalizeStatus(rawVal) || 'ABSENT';
689
+ }
690
+
691
+ wsData.push([
692
+ student.email,
693
+ student.regNo,
694
+ status
695
+ ]);
696
+ }
377
697
  }
378
698
 
379
699
  const ws = xlsx.utils.aoa_to_sheet(wsData);
@@ -389,19 +709,18 @@ function generateExportWorkbook(verticalData, dateItem, rows, studentList) {
389
709
  return wb;
390
710
  }
391
711
 
392
- // Parse Command Line Arguments (allows both CLI flags and interactive mode)
712
+ // Parse Command Line Arguments
393
713
  function parseArgs() {
394
714
  const args = process.argv.slice(2);
395
715
  const flags = {
396
716
  vertical: null,
397
717
  all: false,
398
718
  date: null,
399
- startDate: null,
400
- endDate: null,
401
719
  latest: false,
402
720
  file: null,
403
721
  year: null,
404
722
  batch: null,
723
+ template: null,
405
724
  help: false
406
725
  };
407
726
 
@@ -415,6 +734,8 @@ function parseArgs() {
415
734
  flags.year = args[++i];
416
735
  } else if (a === '-b' || a === '--batch') {
417
736
  flags.batch = args[++i];
737
+ } else if (a === '-t' || a === '--template') {
738
+ flags.template = args[++i];
418
739
  } else if (a === '-a' || a === '--all' || a === '--all-dates') {
419
740
  flags.all = true;
420
741
  } else if (a === '-d' || a === '--date') {
@@ -445,7 +766,7 @@ function cleanPath(input) {
445
766
  }
446
767
 
447
768
  // Open native OS file picker dialog
448
- function openFileDialog() {
769
+ function openFileDialog(title = 'Select Attendance Spreadsheet') {
449
770
  const platform = process.platform;
450
771
  try {
451
772
  if (platform === 'win32') {
@@ -453,7 +774,7 @@ function openFileDialog() {
453
774
  Add-Type -AssemblyName System.Windows.Forms
454
775
  $dlg = New-Object System.Windows.Forms.OpenFileDialog
455
776
  $dlg.Filter = 'Excel Files (*.xlsx;*.xls)|*.xlsx;*.xls|All Files (*.*)|*.*'
456
- $dlg.Title = 'Select Attendance Spreadsheet'
777
+ $dlg.Title = '${title.replace(/'/g, "''")}'
457
778
  $dlg.RestoreDirectory = $true
458
779
  $result = $dlg.ShowDialog()
459
780
  if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
@@ -467,14 +788,14 @@ if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
467
788
  });
468
789
  return output ? output.trim() : null;
469
790
  } else if (platform === 'darwin') {
470
- const appleScript = 'POSIX path of (choose file with prompt "Select Attendance Spreadsheet" of type {"xlsx", "xls"})';
791
+ const appleScript = `POSIX path of (choose file with prompt "${title}" of type {"xlsx", "xls"})`;
471
792
  const output = execFileSync('osascript', ['-e', appleScript], {
472
793
  encoding: 'utf8',
473
794
  stdio: ['ignore', 'pipe', 'ignore']
474
795
  });
475
796
  return output ? output.trim() : null;
476
797
  } else {
477
- const output = execFileSync('zenity', ['--file-selection', '--file-filter=Excel files | *.xlsx *.xls', '--title=Select Attendance Spreadsheet'], {
798
+ const output = execFileSync('zenity', ['--file-selection', '--file-filter=Excel files | *.xlsx *.xls', `--title=${title}`], {
478
799
  encoding: 'utf8',
479
800
  stdio: ['ignore', 'pipe', 'ignore']
480
801
  });
@@ -517,6 +838,25 @@ function importFileToInput(srcPath) {
517
838
  };
518
839
  }
519
840
 
841
+ // Copy / import a selected template into ./digi/
842
+ function importTemplateToDigi(srcPath) {
843
+ ensureDirs();
844
+ const fileName = path.basename(srcPath);
845
+ const destPath = path.join(DIGI_DIR, fileName);
846
+
847
+ if (path.resolve(srcPath) !== path.resolve(destPath)) {
848
+ try {
849
+ fs.copyFileSync(srcPath, destPath);
850
+ logSuccess(`Imported template "${fileName}" into ./digi/ folder!`);
851
+ return destPath;
852
+ } catch (err) {
853
+ logWarn(`Could not copy into ./digi/ (${err.message}). Using original path.`);
854
+ return srcPath;
855
+ }
856
+ }
857
+ return destPath;
858
+ }
859
+
520
860
  // Interactive prompt for file selection when needed
521
861
  async function promptForFile(lines) {
522
862
  logHeader('Select Attendance Spreadsheet');
@@ -543,7 +883,7 @@ async function promptForFile(lines) {
543
883
  }
544
884
  } else {
545
885
  console.log(`${colors.dim}Opening file picker dialog... Please select your file in the window.${colors.reset}`);
546
- const picked = openFileDialog();
886
+ const picked = openFileDialog('Select Attendance Spreadsheet');
547
887
  if (picked && fs.existsSync(picked) && fs.statSync(picked).isFile()) {
548
888
  return picked;
549
889
  }
@@ -575,6 +915,7 @@ Options:
575
915
  -y, --year <2|3> Academic year (2 for 2nd year batched, 3 for 3rd year standard)
576
916
  -b, --batch <1|2|all> Batch selection for 2nd year (1, 2, or all)
577
917
  -v, --vertical <code/name> Sheet/vertical code (e.g., AD, BW, CD, CS, DMA, EI, FW, ALL)
918
+ -t, --template <path> DigiCampus upload template Excel file (.xlsx)
578
919
  -a, --all Export all instructional dates automatically
579
920
  -d, --date <YYYY-MM-DD> Export a single specific date
580
921
  --latest Export the latest instructional date
@@ -602,6 +943,7 @@ Running without options launches the interactive CLI wizard.
602
943
  });
603
944
  const lines = rl[Symbol.asyncIterator]();
604
945
 
946
+ // 1. Select / Import Input Attendance File
605
947
  if (flags.file) {
606
948
  const directPath = path.resolve(process.cwd(), flags.file);
607
949
  const inInputPath = path.join(INPUT_DIR, flags.file);
@@ -620,7 +962,6 @@ Running without options launches the interactive CLI wizard.
620
962
  const inputFiles = findInputFiles();
621
963
 
622
964
  if (inputFiles.length === 0) {
623
- // Auto prompt to browse or drag & drop when input/ is empty
624
965
  const chosen = await promptForFile(lines);
625
966
  selectedFileObj = importFileToInput(chosen);
626
967
  } else if (inputFiles.length === 1) {
@@ -655,19 +996,117 @@ Running without options launches the interactive CLI wizard.
655
996
  }
656
997
  }
657
998
 
658
- const inputFilePath = selectedFileObj.fullPath;
659
- logInfo(`Reading attendance workbook: ${colors.bold}${selectedFileObj.relativePath || selectedFileObj.name}${colors.reset}`);
660
-
661
- let workbook;
999
+ // 2. Academic Year Selection (needed for cohort batch separation during normalization)
1000
+ let selectedYear = null;
1001
+ if (flags.year) {
1002
+ const yStr = String(flags.year).trim();
1003
+ if (yStr.startsWith('2')) selectedYear = 2;
1004
+ else if (yStr.startsWith('3')) selectedYear = 3;
1005
+ }
1006
+
1007
+ if (!selectedYear) {
1008
+ logHeader('Select Academic Year');
1009
+ console.log('Choose student cohort / year:\n');
1010
+ console.log(` [${colors.bold}1${colors.reset}] ${colors.green}3rd Year${colors.reset} (Standard whole class / single list) [Default]`);
1011
+ console.log(` [${colors.bold}2${colors.reset}] ${colors.cyan}2nd Year${colors.reset} (Batched: Batch 1 & Batch 2 separated by empty row)`);
1012
+
1013
+ const yearAns = (await askQuestion(lines, `\nSelect year [1 or 2] (default: 1): `)).trim();
1014
+ if (yearAns === '2' || yearAns.toLowerCase().startsWith('2')) {
1015
+ selectedYear = 2;
1016
+ } else {
1017
+ selectedYear = 3;
1018
+ }
1019
+ }
1020
+
1021
+ // 3. DigiCampus Template Upload / Selection
1022
+ let parsedTemplate = null;
1023
+ let templateFilePath = null;
1024
+
1025
+ if (flags.template) {
1026
+ const directT = path.resolve(process.cwd(), flags.template);
1027
+ const inDigiT = path.join(DIGI_DIR, flags.template);
1028
+ if (fs.existsSync(directT) && fs.statSync(directT).isFile()) {
1029
+ templateFilePath = directT;
1030
+ } else if (fs.existsSync(inDigiT) && fs.statSync(inDigiT).isFile()) {
1031
+ templateFilePath = inDigiT;
1032
+ } else {
1033
+ logWarn(`Template file "${flags.template}" not found. Proceeding without template.`);
1034
+ }
1035
+ } else {
1036
+ logHeader('DigiCampus Upload Template');
1037
+ console.log('Upload a DigiCampus template (Upload_Attendance.xlsx) to match exact student rosters:');
1038
+ console.log(` [${colors.bold}1${colors.reset}] ${colors.green}Browse template file${colors.reset} (Opens file picker window) [Default]`);
1039
+ console.log(` [${colors.bold}2${colors.reset}] ${colors.cyan}Enter or drag & drop template path${colors.reset}`);
1040
+ console.log(` [${colors.bold}3${colors.reset}] ${colors.dim}Skip template${colors.reset} (Export directly from attendance sheet roster)\n`);
1041
+
1042
+ const tChoice = (await askQuestion(lines, `Choose option [1-3] (default: 1): `)).trim();
1043
+
1044
+ if (tChoice === '2') {
1045
+ const manualT = await askQuestion(lines, 'Enter or drag & drop template file path: ');
1046
+ const cleanedT = cleanPath(manualT);
1047
+ if (cleanedT && fs.existsSync(cleanedT) && fs.statSync(cleanedT).isFile()) {
1048
+ templateFilePath = cleanedT;
1049
+ } else if (cleanedT) {
1050
+ logWarn(`Template file not found at "${cleanedT}". Continuing without template.`);
1051
+ }
1052
+ } else if (tChoice === '3' || tChoice.toLowerCase() === 's' || tChoice.toLowerCase() === 'skip') {
1053
+ logInfo('Skipping DigiCampus template. Exporting directly from attendance sheet roster.');
1054
+ } else {
1055
+ // Default: 1 (Browse dialog)
1056
+ console.log(`${colors.dim}Opening file picker dialog... Please select your DigiCampus template.${colors.reset}`);
1057
+ const pickedT = openFileDialog('Select DigiCampus Template (Upload_Attendance.xlsx)');
1058
+ if (pickedT && fs.existsSync(pickedT) && fs.statSync(pickedT).isFile()) {
1059
+ templateFilePath = pickedT;
1060
+ } else {
1061
+ logInfo('No template file selected. Continuing with attendance sheet roster.');
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ if (templateFilePath) {
1067
+ const importedTPath = importTemplateToDigi(templateFilePath);
1068
+ parsedTemplate = loadTemplate(importedTPath);
1069
+ if (parsedTemplate && parsedTemplate.students.length > 0) {
1070
+ logSuccess(`Loaded DigiCampus template with ${colors.bold}${parsedTemplate.students.length}${colors.reset} students.`);
1071
+ } else {
1072
+ logWarn('Uploaded template contained no student records. Continuing with attendance sheet roster.');
1073
+ parsedTemplate = null;
1074
+ }
1075
+ }
1076
+
1077
+ // 4. Normalization Engine: Standardize uploaded spreadsheet
1078
+ logHeader('Standardizing & Normalizing Attendance Sheet');
1079
+ logInfo(`Reading raw workbook: ${colors.bold}${selectedFileObj.relativePath || selectedFileObj.name}${colors.reset}`);
1080
+
1081
+ let rawWorkbook;
662
1082
  try {
663
- workbook = xlsx.readFile(inputFilePath, { cellDates: false });
1083
+ rawWorkbook = xlsx.readFile(selectedFileObj.fullPath, { cellDates: false });
664
1084
  } catch (err) {
665
1085
  logError(`Failed to read Excel file: ${err.message}`);
666
1086
  rl.close();
667
1087
  process.exit(1);
668
1088
  }
669
1089
 
670
- // Discover all vertical sheets
1090
+ const { normWb, sheetCount } = createNormalizedWorkbook(rawWorkbook, selectedYear);
1091
+ if (sheetCount === 0) {
1092
+ logError('No valid vertical attendance sheets with students and dates could be normalized.');
1093
+ rl.close();
1094
+ process.exit(1);
1095
+ }
1096
+
1097
+ const normBaseName = path.basename(selectedFileObj.name, path.extname(selectedFileObj.name));
1098
+ const normFileName = `Normalized_${normBaseName}.xlsx`;
1099
+ const normFilePath = path.join(NORMALIZED_DIR, normFileName);
1100
+ xlsx.writeFile(normWb, normFilePath);
1101
+
1102
+ logSuccess(`Standardized ${sheetCount} vertical sheet(s) into default format!`);
1103
+ logSuccess(`Saved clean normalized workbook to: ${colors.bold}normalized/${normFileName}${colors.reset}`);
1104
+ logInfo(`Using standardized workbook for all export operations.`);
1105
+
1106
+ // Use the clean, standardized workbook as the active workbook
1107
+ const workbook = normWb;
1108
+
1109
+ // 5. Discover all vertical sheets from normalized workbook
671
1110
  const verticalSheets = [];
672
1111
  const excludedSheets = ['pivot table', 'summary', 'template', 'students data'];
673
1112
 
@@ -683,34 +1122,12 @@ Running without options launches the interactive CLI wizard.
683
1122
  }
684
1123
 
685
1124
  if (verticalSheets.length === 0) {
686
- logError('No valid vertical attendance sheets with students and date columns were found.');
1125
+ logError('No valid vertical attendance sheets were found in the standardized workbook.');
687
1126
  rl.close();
688
1127
  process.exit(1);
689
1128
  }
690
1129
 
691
- // Academic Year Selection (2nd Year vs 3rd Year)
692
- let selectedYear = null;
693
- if (flags.year) {
694
- const yStr = String(flags.year).trim();
695
- if (yStr.startsWith('2')) selectedYear = 2;
696
- else if (yStr.startsWith('3')) selectedYear = 3;
697
- }
698
-
699
- if (!selectedYear) {
700
- logHeader('Select Academic Year');
701
- console.log('Choose student cohort / year:\n');
702
- console.log(` [${colors.bold}1${colors.reset}] ${colors.green}3rd Year${colors.reset} (Standard whole class / single list) [Default]`);
703
- console.log(` [${colors.bold}2${colors.reset}] ${colors.cyan}2nd Year${colors.reset} (Batched: Batch 1 & Batch 2 separated by empty row)`);
704
-
705
- const yearAns = (await askQuestion(lines, `\nSelect year [1 or 2] (default: 1): `)).trim();
706
- if (yearAns === '2' || yearAns.toLowerCase().startsWith('2')) {
707
- selectedYear = 2;
708
- } else {
709
- selectedYear = 3;
710
- }
711
- }
712
-
713
- // Batch selection for 2nd Year
1130
+ // 6. Batch selection for 2nd Year
714
1131
  let selectedBatch = null; // 1, 2, or 'BOTH'
715
1132
  if (selectedYear === 2) {
716
1133
  if (flags.batch) {
@@ -745,7 +1162,7 @@ Running without options launches the interactive CLI wizard.
745
1162
  logInfo(`Cohort: ${colors.bold}3rd Year (Standard Whole Class)${colors.reset}`);
746
1163
  }
747
1164
 
748
- // Vertical Selection
1165
+ // 7. Vertical Selection
749
1166
  let chosenVertical = null;
750
1167
  let processAll = false;
751
1168
 
@@ -764,7 +1181,6 @@ Running without options launches the interactive CLI wizard.
764
1181
  }
765
1182
  }
766
1183
 
767
- // Interactive selection if not resolved from flags
768
1184
  if (!chosenVertical && !processAll) {
769
1185
  logHeader('Select Vertical');
770
1186
  console.log('Available verticals found in the spreadsheet:\n');
@@ -817,7 +1233,6 @@ Running without options launches the interactive CLI wizard.
817
1233
 
818
1234
  const targets = processAll ? verticalSheets : [chosenVertical];
819
1235
 
820
- // Helper to get dates for chosen cohort & batch
821
1236
  function getDatesForVertical(vert) {
822
1237
  if (selectedYear === 2) {
823
1238
  if (selectedBatch === 1) return vert.batch1Dates;
@@ -827,7 +1242,7 @@ Running without options launches the interactive CLI wizard.
827
1242
  return vert.dates;
828
1243
  }
829
1244
 
830
- // Date selection logic
1245
+ // 8. Date selection logic
831
1246
  let dateFilterMode = 'ALL';
832
1247
  let singleDateVal = flags.date || null;
833
1248
  let dateRangeVal = null;
@@ -872,7 +1287,7 @@ Running without options launches the interactive CLI wizard.
872
1287
 
873
1288
  rl.close();
874
1289
 
875
- // Process selected verticals
1290
+ // 9. Process selected verticals and generate export files
876
1291
  logHeader('Processing & Generating Export Files');
877
1292
 
878
1293
  for (const vert of targets) {
@@ -909,7 +1324,7 @@ Running without options launches the interactive CLI wizard.
909
1324
  if (dateFilterMode === 'SINGLE' && singleDateVal) {
910
1325
  datesToExport = datesToExport.filter(d => d.dateStr === singleDateVal);
911
1326
  if (datesToExport.length === 0) {
912
- logWarn(`Date ${singleDateVal} was not found among instructional dates for ${vert.sheetName}${batchTag}.`);
1327
+ logWarn(`Date ${singleDateVal} was not found among recorded dates for ${vert.sheetName}${batchTag}.`);
913
1328
  const fallbackCol = vert.allDateColumns.find(d => d.dateStr === singleDateVal);
914
1329
  if (fallbackCol) {
915
1330
  datesToExport = [{ ...fallbackCol, presentCount: 0, absentCount: 0, totalStudents: batchItem.students.length }];
@@ -933,13 +1348,12 @@ Running without options launches the interactive CLI wizard.
933
1348
 
934
1349
  const rows = vert.rows || xlsx.utils.sheet_to_json(workbook.Sheets[vert.sheetName], { header: 1, raw: true, defval: null });
935
1350
  const zip = new AdmZip();
936
-
937
1351
  const yearTag = selectedYear === 2 ? '2nd_Year' : '3rd_Year';
938
1352
 
939
1353
  for (const dateItem of datesToExport) {
940
1354
  const batchFileSuffix = batchItem.label ? `_${batchItem.label}` : '';
941
1355
  const fileName = `${dateItem.dateStr}_Upload_Attendance_${vert.sheetName}${batchFileSuffix}.xlsx`;
942
- const outWb = generateExportWorkbook(vert, dateItem, rows, batchItem.students);
1356
+ const outWb = generateExportWorkbook(vert, dateItem, rows, batchItem.students, parsedTemplate);
943
1357
  const fileBuffer = xlsx.write(outWb, { type: 'buffer', bookType: 'xlsx' });
944
1358
  zip.addFile(fileName, fileBuffer);
945
1359
  }
@@ -950,7 +1364,7 @@ Running without options launches the interactive CLI wizard.
950
1364
  const zipFilePath = path.join(OUTPUT_DIR, zipFileName);
951
1365
  zip.writeZip(zipFilePath);
952
1366
 
953
- // Clean up any previously created unzipped folder for this vertical if it exists
1367
+ // Clean up legacy unzipped folder if any existed
954
1368
  const oldVertFolder = path.join(OUTPUT_DIR, zipBaseName);
955
1369
  if (fs.existsSync(oldVertFolder) && fs.statSync(oldVertFolder).isDirectory()) {
956
1370
  fs.rmSync(oldVertFolder, { recursive: true, force: true });
@@ -966,7 +1380,8 @@ Running without options launches the interactive CLI wizard.
966
1380
 
967
1381
  // Final summary
968
1382
  logHeader('Export Complete');
969
- console.log(`Check the ${colors.bold}${colors.cyan}./output${colors.reset} directory for all generated files and ZIP archives.\n`);
1383
+ console.log(`Normalized master file: ${colors.bold}${colors.cyan}./normalized/${normFileName}${colors.reset}`);
1384
+ console.log(`DigiCampus export archives: ${colors.bold}${colors.green}./output/*.zip${colors.reset}\n`);
970
1385
  }
971
1386
 
972
1387
  main().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrselva/attendance",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "Extract multi-sheet attendance data and export formatted Excel sheets with ZIP archives",
5
5
  "main": "index.js",
6
6
  "scripts": {