@mrselva/attendance 1.1.2 → 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 +24 -16
  2. package/index.js +729 -145
  3. package/package.json +2 -2
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,16 +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.
33
- - **Interactive CLI Wizard**: Clean, color-coded terminal prompts to pick sheets and date filters.
34
- - **Multi-Sheet Detection**: Automatically discovers all relevant sheets and rosters across the workbook.
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.
35
+ - **Cohort & Batch Support**:
36
+ - **3rd Year**: Exports standard whole-class roster.
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!
35
38
  - **Smart Date Filtering**:
36
39
  - Export **All recorded dates**
37
40
  - Export a **Single date** (e.g. `YYYY-MM-DD`)
38
41
  - Export a **Date range** (from `YYYY-MM-DD` to `YYYY-MM-DD`)
39
42
  - Export the **Latest date only**
40
- - **Status Normalization**: Accurately normalizes attendance status (`PRESENT`, `ABSENT`, etc.) and handles blanks cleanly.
41
- - **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.
42
45
 
43
46
  ---
44
47
 
@@ -47,30 +50,33 @@ attendance
47
50
  Run the interactive wizard or pass flags for automation:
48
51
 
49
52
  ```bash
50
- # Interactive mode
53
+ # Interactive mode (prompts for File, Year, Template, Batch, Sheet, Dates)
51
54
  npx @mrselva/attendance
52
55
 
53
- # Export all dates for a specific sheet
54
- npx @mrselva/attendance -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
55
58
 
56
- # Export a single date
57
- npx @mrselva/attendance -v SHEET_CODE -d 2024-09-12
59
+ # 2nd Year - Export Batch 2
60
+ npx @mrselva/attendance -y 2 -b 2 -v SHEET_CODE -a
58
61
 
59
- # Export the latest date recorded
60
- npx @mrselva/attendance -v SHEET_CODE --latest
62
+ # 2nd Year - Export Both Batches separately
63
+ npx @mrselva/attendance -y 2 -b all -v ALL -a
61
64
 
62
- # Process ALL sheets at once
63
- npx @mrselva/attendance -v ALL -a
65
+ # 3rd Year - Export standard roster
66
+ npx @mrselva/attendance -y 3 -v SHEET_CODE -a
64
67
 
65
68
  # Specify a custom input spreadsheet file
66
- npx @mrselva/attendance -f "attendance_sheet.xlsx" -v SHEET_CODE -a
69
+ npx @mrselva/attendance -f "attendance_sheet.xlsx" -y 2 -b 1 -a
67
70
  ```
68
71
 
69
72
  ### Options Reference
70
73
 
71
74
  | Flag | Long Flag | Description |
72
75
  | :--- | :--- | :--- |
76
+ | `-y <2\|3>` | `--year <2\|3>` | Academic year (`2` for 2nd year batched, `3` for 3rd year standard) |
77
+ | `-b <1\|2\|all>` | `--batch <1\|2\|all>` | Batch selection for 2nd year (`1`, `2`, or `all` for both) |
73
78
  | `-v <code>` | `--vertical <code>` | Sheet code or name (or `ALL`) |
79
+ | `-t <file>` | `--template <path>` | DigiCampus upload template Excel file (.xlsx) |
74
80
  | `-a` | `--all` | Export all recorded dates |
75
81
  | `-d <date>` | `--date <YYYY-MM-DD>` | Export a specific date |
76
82
  | | `--latest` | Export the latest recorded date |
@@ -84,6 +90,8 @@ npx @mrselva/attendance -f "attendance_sheet.xlsx" -v SHEET_CODE -a
84
90
  ```text
85
91
  your-folder/
86
92
  ├── input/ # Place source spreadsheets here (or auto-import)
93
+ ├── normalized/ # Clean, standardized master spreadsheets
94
+ ├── digi/ # DigiCampus upload templates
87
95
  └── output/ # Automatically generated ZIP archives
88
96
  ```
89
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,255 @@ function normalizeStatus(val) {
179
194
  return null;
180
195
  }
181
196
 
182
- // Parse a vertical sheet to extract students and date columns
197
+ // Check if a row represents summary / totals
198
+ function isSummaryRow(row, name, regNo) {
199
+ if (name && (/no\s*\.?\s*of/i.test(name) || /total/i.test(name) || /present/i.test(name) || /absent/i.test(name))) {
200
+ return true;
201
+ }
202
+ if (regNo && (/no\s*\.?\s*of/i.test(regNo) || /total/i.test(regNo) || /present/i.test(regNo) || /absent/i.test(regNo))) {
203
+ return true;
204
+ }
205
+ if (row && Array.isArray(row)) {
206
+ const text = row.map(cell => String(cell || '').trim()).join(' ').toLowerCase();
207
+ return /no\s*\.?\s*of\s*(present|absent)/i.test(text) ||
208
+ /\b(total\s*present|total\s*absent|no\s*\.?\s*of\s*students)\b/i.test(text);
209
+ }
210
+ return false;
211
+ }
212
+
213
+ // Calculate instructional dates for a given student roster
214
+ function getInstructionalDates(students, dateColumns, rows) {
215
+ if (!students || students.length === 0) return [];
216
+ const instructionalDates = [];
217
+ for (const dc of dateColumns) {
218
+ let presentCount = 0;
219
+ let absentCount = 0;
220
+
221
+ for (const student of students) {
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
+ }
229
+ if (norm === 'PRESENT') {
230
+ presentCount++;
231
+ } else if (norm === 'ABSENT') {
232
+ absentCount++;
233
+ }
234
+ }
235
+
236
+ if (presentCount + absentCount > 0) {
237
+ instructionalDates.push({
238
+ ...dc,
239
+ presentCount,
240
+ absentCount,
241
+ totalStudents: students.length
242
+ });
243
+ }
244
+ }
245
+ instructionalDates.sort((a, b) => a.dateStr.localeCompare(b.dateStr));
246
+ return instructionalDates;
247
+ }
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
+
445
+ // Parse a vertical sheet to extract students (batch 1 & 2) and date columns
183
446
  function parseVerticalSheet(ws, sheetName) {
184
447
  const rows = xlsx.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null });
185
448
  if (!rows || rows.length < 2) return null;
@@ -190,6 +453,11 @@ function parseVerticalSheet(ws, sheetName) {
190
453
  let nameColIdx = -1;
191
454
  let emailColIdx = -1;
192
455
  let rollColIdx = -1;
456
+ let batchColIdx = -1;
457
+ let deptColIdx = -1;
458
+ let secColIdx = -1;
459
+ let genderColIdx = -1;
460
+ let snoColIdx = -1;
193
461
  const dateColumns = [];
194
462
 
195
463
  // Analyze header row
@@ -203,10 +471,22 @@ function parseVerticalSheet(ws, sheetName) {
203
471
  regColIdx = c;
204
472
  } else if (hStr.includes('student name') || hStr === 'name') {
205
473
  nameColIdx = c;
206
- } 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')) {
207
477
  emailColIdx = c;
208
478
  } else if (hStr.includes('roll number') || hStr.includes('roll no')) {
209
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;
210
490
  } else {
211
491
  const cellAddress = xlsx.utils.encode_cell({ r: 0, c: c });
212
492
  const cellRaw = ws[cellAddress];
@@ -222,84 +502,105 @@ function parseVerticalSheet(ws, sheetName) {
222
502
  return null; // Not a vertical attendance sheet
223
503
  }
224
504
 
225
- // Parse student records
226
- const students = [];
505
+ // Parse student records, separating Batch 1 and Batch 2
506
+ const batch1Students = [];
507
+ const batch2Students = [];
508
+ let currentBatch = 1;
509
+ let sawBlankAfterBatch1 = false;
510
+
227
511
  for (let r = 1; r < rows.length; r++) {
228
512
  const row = rows[r];
229
- if (!row) continue;
513
+ if (!row || row.length === 0) {
514
+ if (batch1Students.length > 0 && currentBatch === 1) {
515
+ sawBlankAfterBatch1 = true;
516
+ }
517
+ continue;
518
+ }
230
519
 
231
- const sno = row[0];
520
+ const sno = snoColIdx !== -1 ? row[snoColIdx] : r;
232
521
  const name = nameColIdx !== -1 ? String(row[nameColIdx] || '').trim() : '';
233
522
  const regNo = regColIdx !== -1 ? String(row[regColIdx] || '').trim() : '';
234
523
  const email = emailColIdx !== -1 ? String(row[emailColIdx] || '').trim() : '';
235
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() : '';
236
528
 
237
529
  // Ignore summary/totals rows at bottom
238
- const isSummary = /no\s*\.?\s*of/i.test(name) ||
239
- /no\s*\.?\s*of/i.test(regNo) ||
240
- /total/i.test(name) ||
241
- /total/i.test(regNo) ||
242
- /present/i.test(regNo) ||
243
- /absent/i.test(regNo);
244
- if (isSummary) continue;
245
-
246
- if (!name || name.length < 2) continue;
247
- if (sno !== null && sno !== undefined && isNaN(Number(sno))) continue;
248
- if (!regNo && !rollNo) continue;
249
-
250
- students.push({
251
- rowIdx: r,
252
- sno: sno,
253
- name: name,
254
- regNo: regNo,
255
- rollNo: rollNo,
256
- email: email || ''
257
- });
258
- }
259
-
260
- if (students.length === 0) return null;
530
+ if (isSummaryRow(row, name, regNo)) {
531
+ break;
532
+ }
261
533
 
262
- // Filter dates to only those that have actual student attendance recorded
263
- const instructionalDates = [];
264
- for (const dc of dateColumns) {
265
- let presentCount = 0;
266
- let absentCount = 0;
534
+ const isStudent = name.length >= 2 && (regNo.length > 0 || rollNo.length > 0);
535
+ if (!isStudent) {
536
+ if (batch1Students.length > 0 && currentBatch === 1) {
537
+ sawBlankAfterBatch1 = true;
538
+ }
539
+ continue;
540
+ }
267
541
 
268
- for (const student of students) {
269
- const cellVal = rows[student.rowIdx] ? rows[student.rowIdx][dc.colIdx] : null;
270
- const norm = normalizeStatus(cellVal);
271
- if (norm === 'PRESENT') {
272
- presentCount++;
273
- } else if (norm === 'ABSENT') {
274
- absentCount++;
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;
275
549
  }
276
550
  }
277
551
 
278
- // Only if at least one student was marked PRESENT or ABSENT
279
- if (presentCount + absentCount > 0) {
280
- instructionalDates.push({
281
- ...dc,
282
- presentCount,
283
- absentCount,
284
- totalStudents: students.length
285
- });
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) || '';
557
+ }
558
+
559
+ const studentObj = {
560
+ rowIdx: r,
561
+ sno,
562
+ name,
563
+ regNo,
564
+ rollNo,
565
+ email: email || '',
566
+ dept,
567
+ sec,
568
+ gender,
569
+ batch: currentBatch,
570
+ attendance
571
+ };
572
+
573
+ if (currentBatch === 1) {
574
+ batch1Students.push(studentObj);
575
+ } else {
576
+ batch2Students.push(studentObj);
286
577
  }
287
578
  }
288
579
 
289
- // Sort dates chronologically
290
- instructionalDates.sort((a, b) => a.dateStr.localeCompare(b.dateStr));
580
+ const allStudents = [...batch1Students, ...batch2Students];
581
+ if (allStudents.length === 0) return null;
582
+
583
+ const allDates = getInstructionalDates(allStudents, dateColumns, rows);
584
+ const batch1Dates = getInstructionalDates(batch1Students, dateColumns, rows);
585
+ const batch2Dates = getInstructionalDates(batch2Students, dateColumns, rows);
291
586
 
292
587
  return {
293
588
  sheetName,
294
589
  verticalName: VERTICAL_NAMES[sheetName] || sheetName,
295
- students,
296
- dates: instructionalDates,
297
- allDateColumns: dateColumns
590
+ students: allStudents,
591
+ batch1Students,
592
+ batch2Students,
593
+ hasBatches: batch1Students.length > 0 && batch2Students.length > 0,
594
+ dates: allDates,
595
+ batch1Dates,
596
+ batch2Dates,
597
+ allDateColumns: dateColumns,
598
+ rows
298
599
  };
299
600
  }
300
601
 
301
- // Generate formatted Excel workbook for a single date
302
- function generateExportWorkbook(verticalData, dateItem, rows) {
602
+ // Generate formatted Excel workbook for a single date (DigiCampus Upload format)
603
+ function generateExportWorkbook(verticalData, dateItem, rows, studentList, parsedTemplate) {
303
604
  const wb = xlsx.utils.book_new();
304
605
 
305
606
  // Export Sheet Header
@@ -307,19 +608,92 @@ function generateExportWorkbook(verticalData, dateItem, rows) {
307
608
  ['Email Id', 'Registration Id', 'Attendance *']
308
609
  ];
309
610
 
310
- for (const student of verticalData.students) {
311
- const rawVal = rows[student.rowIdx] ? rows[student.rowIdx][dateItem.colIdx] : null;
312
- let status = normalizeStatus(rawVal);
313
- // On an instructional day, any unrecorded or blank student is marked ABSENT
314
- if (!status) {
315
- status = 'ABSENT';
611
+ const students = studentList || verticalData.students;
612
+
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);
621
+ }
622
+
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
+ }
316
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
+ }
317
690
 
318
- wsData.push([
319
- student.email,
320
- student.regNo,
321
- status
322
- ]);
691
+ wsData.push([
692
+ student.email,
693
+ student.regNo,
694
+ status
695
+ ]);
696
+ }
323
697
  }
324
698
 
325
699
  const ws = xlsx.utils.aoa_to_sheet(wsData);
@@ -335,17 +709,18 @@ function generateExportWorkbook(verticalData, dateItem, rows) {
335
709
  return wb;
336
710
  }
337
711
 
338
- // Parse Command Line Arguments (allows both CLI flags and interactive mode)
712
+ // Parse Command Line Arguments
339
713
  function parseArgs() {
340
714
  const args = process.argv.slice(2);
341
715
  const flags = {
342
716
  vertical: null,
343
717
  all: false,
344
718
  date: null,
345
- startDate: null,
346
- endDate: null,
347
719
  latest: false,
348
720
  file: null,
721
+ year: null,
722
+ batch: null,
723
+ template: null,
349
724
  help: false
350
725
  };
351
726
 
@@ -355,6 +730,12 @@ function parseArgs() {
355
730
  flags.help = true;
356
731
  } else if (a === '-v' || a === '--vertical') {
357
732
  flags.vertical = args[++i];
733
+ } else if (a === '-y' || a === '--year') {
734
+ flags.year = args[++i];
735
+ } else if (a === '-b' || a === '--batch') {
736
+ flags.batch = args[++i];
737
+ } else if (a === '-t' || a === '--template') {
738
+ flags.template = args[++i];
358
739
  } else if (a === '-a' || a === '--all' || a === '--all-dates') {
359
740
  flags.all = true;
360
741
  } else if (a === '-d' || a === '--date') {
@@ -385,7 +766,7 @@ function cleanPath(input) {
385
766
  }
386
767
 
387
768
  // Open native OS file picker dialog
388
- function openFileDialog() {
769
+ function openFileDialog(title = 'Select Attendance Spreadsheet') {
389
770
  const platform = process.platform;
390
771
  try {
391
772
  if (platform === 'win32') {
@@ -393,7 +774,7 @@ function openFileDialog() {
393
774
  Add-Type -AssemblyName System.Windows.Forms
394
775
  $dlg = New-Object System.Windows.Forms.OpenFileDialog
395
776
  $dlg.Filter = 'Excel Files (*.xlsx;*.xls)|*.xlsx;*.xls|All Files (*.*)|*.*'
396
- $dlg.Title = 'Select Attendance Spreadsheet'
777
+ $dlg.Title = '${title.replace(/'/g, "''")}'
397
778
  $dlg.RestoreDirectory = $true
398
779
  $result = $dlg.ShowDialog()
399
780
  if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
@@ -407,14 +788,14 @@ if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
407
788
  });
408
789
  return output ? output.trim() : null;
409
790
  } else if (platform === 'darwin') {
410
- 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"})`;
411
792
  const output = execFileSync('osascript', ['-e', appleScript], {
412
793
  encoding: 'utf8',
413
794
  stdio: ['ignore', 'pipe', 'ignore']
414
795
  });
415
796
  return output ? output.trim() : null;
416
797
  } else {
417
- 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}`], {
418
799
  encoding: 'utf8',
419
800
  stdio: ['ignore', 'pipe', 'ignore']
420
801
  });
@@ -457,6 +838,25 @@ function importFileToInput(srcPath) {
457
838
  };
458
839
  }
459
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
+
460
860
  // Interactive prompt for file selection when needed
461
861
  async function promptForFile(lines) {
462
862
  logHeader('Select Attendance Spreadsheet');
@@ -483,7 +883,7 @@ async function promptForFile(lines) {
483
883
  }
484
884
  } else {
485
885
  console.log(`${colors.dim}Opening file picker dialog... Please select your file in the window.${colors.reset}`);
486
- const picked = openFileDialog();
886
+ const picked = openFileDialog('Select Attendance Spreadsheet');
487
887
  if (picked && fs.existsSync(picked) && fs.statSync(picked).isFile()) {
488
888
  return picked;
489
889
  }
@@ -512,7 +912,10 @@ Usage: npx @mrselva/attendance [options] [vertical]
512
912
  or: node index.js [options] [vertical]
513
913
 
514
914
  Options:
515
- -v, --vertical <code/name> Vertical code (e.g., AD, BW, CD, CS, DMA, EI, FW, ALL)
915
+ -y, --year <2|3> Academic year (2 for 2nd year batched, 3 for 3rd year standard)
916
+ -b, --batch <1|2|all> Batch selection for 2nd year (1, 2, or all)
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)
516
919
  -a, --all Export all instructional dates automatically
517
920
  -d, --date <YYYY-MM-DD> Export a single specific date
518
921
  --latest Export the latest instructional date
@@ -540,6 +943,7 @@ Running without options launches the interactive CLI wizard.
540
943
  });
541
944
  const lines = rl[Symbol.asyncIterator]();
542
945
 
946
+ // 1. Select / Import Input Attendance File
543
947
  if (flags.file) {
544
948
  const directPath = path.resolve(process.cwd(), flags.file);
545
949
  const inInputPath = path.join(INPUT_DIR, flags.file);
@@ -558,7 +962,6 @@ Running without options launches the interactive CLI wizard.
558
962
  const inputFiles = findInputFiles();
559
963
 
560
964
  if (inputFiles.length === 0) {
561
- // Auto prompt to browse or drag & drop when input/ is empty
562
965
  const chosen = await promptForFile(lines);
563
966
  selectedFileObj = importFileToInput(chosen);
564
967
  } else if (inputFiles.length === 1) {
@@ -593,19 +996,117 @@ Running without options launches the interactive CLI wizard.
593
996
  }
594
997
  }
595
998
 
596
- const inputFilePath = selectedFileObj.fullPath;
597
- logInfo(`Reading attendance workbook: ${colors.bold}${selectedFileObj.relativePath || selectedFileObj.name}${colors.reset}`);
598
-
599
- 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;
600
1082
  try {
601
- workbook = xlsx.readFile(inputFilePath, { cellDates: false });
1083
+ rawWorkbook = xlsx.readFile(selectedFileObj.fullPath, { cellDates: false });
602
1084
  } catch (err) {
603
1085
  logError(`Failed to read Excel file: ${err.message}`);
604
1086
  rl.close();
605
1087
  process.exit(1);
606
1088
  }
607
1089
 
608
- // 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
609
1110
  const verticalSheets = [];
610
1111
  const excludedSheets = ['pivot table', 'summary', 'template', 'students data'];
611
1112
 
@@ -621,12 +1122,47 @@ Running without options launches the interactive CLI wizard.
621
1122
  }
622
1123
 
623
1124
  if (verticalSheets.length === 0) {
624
- 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.');
625
1126
  rl.close();
626
1127
  process.exit(1);
627
1128
  }
628
1129
 
629
- // Vertical Selection
1130
+ // 6. Batch selection for 2nd Year
1131
+ let selectedBatch = null; // 1, 2, or 'BOTH'
1132
+ if (selectedYear === 2) {
1133
+ if (flags.batch) {
1134
+ const bStr = String(flags.batch).trim().toLowerCase();
1135
+ if (bStr === '1' || bStr === 'b1' || bStr === 'batch1' || bStr === '1st') selectedBatch = 1;
1136
+ else if (bStr === '2' || bStr === 'b2' || bStr === 'batch2' || bStr === '2nd') selectedBatch = 2;
1137
+ else if (bStr === 'all' || bStr === 'both' || bStr === '3') selectedBatch = 'BOTH';
1138
+ }
1139
+
1140
+ if (!selectedBatch) {
1141
+ logHeader('Select Batch for 2nd Year');
1142
+ console.log('Choose which batch to export:\n');
1143
+ console.log(` [${colors.bold}1${colors.reset}] ${colors.green}Batch 1${colors.reset} (Students above the empty row) [Default]`);
1144
+ console.log(` [${colors.bold}2${colors.reset}] ${colors.cyan}Batch 2${colors.reset} (Students below the empty row)`);
1145
+ console.log(` [${colors.bold}3${colors.reset}] ${colors.yellow}Both Batches${colors.reset} (Export Batch 1 and Batch 2 separately)`);
1146
+
1147
+ const batchAns = (await askQuestion(lines, `\nSelect batch [1-3] (default: 1): `)).trim();
1148
+ if (batchAns === '2' || batchAns.toLowerCase().startsWith('2')) {
1149
+ selectedBatch = 2;
1150
+ } else if (batchAns === '3' || batchAns.toLowerCase().startsWith('3') || batchAns.toLowerCase() === 'both' || batchAns.toLowerCase() === 'all') {
1151
+ selectedBatch = 'BOTH';
1152
+ } else {
1153
+ selectedBatch = 1;
1154
+ }
1155
+ }
1156
+ }
1157
+
1158
+ if (selectedYear === 2) {
1159
+ const batchLabelStr = selectedBatch === 'BOTH' ? 'Both Batches (Batch 1 & Batch 2)' : `Batch ${selectedBatch}`;
1160
+ logInfo(`Cohort: ${colors.bold}2nd Year${colors.reset} | Target: ${colors.bold}${batchLabelStr}${colors.reset}`);
1161
+ } else {
1162
+ logInfo(`Cohort: ${colors.bold}3rd Year (Standard Whole Class)${colors.reset}`);
1163
+ }
1164
+
1165
+ // 7. Vertical Selection
630
1166
  let chosenVertical = null;
631
1167
  let processAll = false;
632
1168
 
@@ -645,7 +1181,6 @@ Running without options launches the interactive CLI wizard.
645
1181
  }
646
1182
  }
647
1183
 
648
- // Interactive selection if not resolved from flags
649
1184
  if (!chosenVertical && !processAll) {
650
1185
  logHeader('Select Vertical');
651
1186
  console.log('Available verticals found in the spreadsheet:\n');
@@ -653,7 +1188,18 @@ Running without options launches the interactive CLI wizard.
653
1188
  verticalSheets.forEach((v, idx) => {
654
1189
  const code = colors.bold + v.sheetName.padEnd(5) + colors.reset;
655
1190
  const name = (v.verticalName !== v.sheetName ? `(${v.verticalName})` : '').padEnd(35);
656
- const countInfo = `${colors.dim}${v.students.length} students, ${v.dates.length} dates recorded${colors.reset}`;
1191
+ let countInfo = '';
1192
+ if (selectedYear === 2) {
1193
+ if (selectedBatch === 1) {
1194
+ countInfo = `${colors.dim}${v.batch1Students.length} students (Batch 1), ${v.batch1Dates.length} dates${colors.reset}`;
1195
+ } else if (selectedBatch === 2) {
1196
+ countInfo = `${colors.dim}${v.batch2Students.length} students (Batch 2), ${v.batch2Dates.length} dates${colors.reset}`;
1197
+ } else {
1198
+ countInfo = `${colors.dim}B1: ${v.batch1Students.length} sts, B2: ${v.batch2Students.length} sts${colors.reset}`;
1199
+ }
1200
+ } else {
1201
+ countInfo = `${colors.dim}${v.students.length} students, ${v.dates.length} dates recorded${colors.reset}`;
1202
+ }
657
1203
  console.log(` [${colors.bold}${idx + 1}${colors.reset}] ${code} ${name} ${countInfo}`);
658
1204
  });
659
1205
  console.log(` [${colors.bold}A${colors.reset}] ${colors.bold}ALL ${colors.reset} (Process ALL verticals at once)`);
@@ -687,7 +1233,16 @@ Running without options launches the interactive CLI wizard.
687
1233
 
688
1234
  const targets = processAll ? verticalSheets : [chosenVertical];
689
1235
 
690
- // Date selection logic
1236
+ function getDatesForVertical(vert) {
1237
+ if (selectedYear === 2) {
1238
+ if (selectedBatch === 1) return vert.batch1Dates;
1239
+ if (selectedBatch === 2) return vert.batch2Dates;
1240
+ return vert.dates;
1241
+ }
1242
+ return vert.dates;
1243
+ }
1244
+
1245
+ // 8. Date selection logic
691
1246
  let dateFilterMode = 'ALL';
692
1247
  let singleDateVal = flags.date || null;
693
1248
  let dateRangeVal = null;
@@ -699,11 +1254,13 @@ Running without options launches the interactive CLI wizard.
699
1254
  } else if (flags.date) {
700
1255
  dateFilterMode = 'SINGLE';
701
1256
  } else if (!processAll) {
702
- const totalDates = chosenVertical.dates.length;
703
- const firstDate = totalDates > 0 ? chosenVertical.dates[0].dateStr : 'N/A';
704
- const lastDate = totalDates > 0 ? chosenVertical.dates[totalDates - 1].dateStr : 'N/A';
1257
+ const refDates = getDatesForVertical(chosenVertical);
1258
+ const totalDates = refDates.length;
1259
+ const firstDate = totalDates > 0 ? refDates[0].dateStr : 'N/A';
1260
+ const lastDate = totalDates > 0 ? refDates[totalDates - 1].dateStr : 'N/A';
705
1261
 
706
- logHeader(`Date Selection for Vertical: ${chosenVertical.sheetName}`);
1262
+ const batchNameHeader = selectedYear === 2 ? (selectedBatch === 'BOTH' ? ' (Batches 1 & 2)' : ` (Batch ${selectedBatch})`) : '';
1263
+ logHeader(`Date Selection for Sheet: ${chosenVertical.sheetName}${batchNameHeader}`);
707
1264
  console.log(`Found ${colors.bold}${totalDates}${colors.reset} instructional dates (${firstDate} to ${lastDate}).\n`);
708
1265
  console.log(` [${colors.bold}1${colors.reset}] Export ${colors.green}ALL ${totalDates} dates${colors.reset} (Recommended - generates upload files for each date)`);
709
1266
  console.log(` [${colors.bold}2${colors.reset}] Export a ${colors.cyan}single specific date${colors.reset} (e.g., ${lastDate})`);
@@ -730,74 +1287,101 @@ Running without options launches the interactive CLI wizard.
730
1287
 
731
1288
  rl.close();
732
1289
 
733
- // Process selected verticals
1290
+ // 9. Process selected verticals and generate export files
734
1291
  logHeader('Processing & Generating Export Files');
735
1292
 
736
1293
  for (const vert of targets) {
737
- console.log(`\n${colors.bold}Sheet: ${vert.sheetName}${vert.verticalName !== vert.sheetName ? ' - ' + vert.verticalName : ''}${colors.reset}`);
738
- console.log(` Students: ${colors.bold}${vert.students.length}${colors.reset}`);
1294
+ let batchesToProcess = [];
1295
+ if (selectedYear === 2) {
1296
+ if (selectedBatch === 1) {
1297
+ batchesToProcess = [{ label: 'Batch1', students: vert.batch1Students, dates: vert.batch1Dates }];
1298
+ } else if (selectedBatch === 2) {
1299
+ batchesToProcess = [{ label: 'Batch2', students: vert.batch2Students, dates: vert.batch2Dates }];
1300
+ } else {
1301
+ // BOTH
1302
+ batchesToProcess = [
1303
+ { label: 'Batch1', students: vert.batch1Students, dates: vert.batch1Dates },
1304
+ { label: 'Batch2', students: vert.batch2Students, dates: vert.batch2Dates }
1305
+ ];
1306
+ }
1307
+ } else {
1308
+ // 3rd Year
1309
+ batchesToProcess = [{ label: '', students: vert.students, dates: vert.dates }];
1310
+ }
739
1311
 
740
- let datesToExport = [...vert.dates];
1312
+ for (const batchItem of batchesToProcess) {
1313
+ const batchTag = batchItem.label ? ` (${batchItem.label})` : '';
1314
+ console.log(`\n${colors.bold}Sheet: ${vert.sheetName}${vert.verticalName !== vert.sheetName ? ' - ' + vert.verticalName : ''}${batchTag}${colors.reset}`);
1315
+ console.log(` Students: ${colors.bold}${batchItem.students.length}${colors.reset}`);
741
1316
 
742
- if (dateFilterMode === 'SINGLE' && singleDateVal) {
743
- datesToExport = datesToExport.filter(d => d.dateStr === singleDateVal);
744
- if (datesToExport.length === 0) {
745
- logWarn(`Date ${singleDateVal} was not found among instructional dates for ${vert.sheetName}.`);
746
- const fallbackCol = vert.allDateColumns.find(d => d.dateStr === singleDateVal);
747
- if (fallbackCol) {
748
- datesToExport = [{ ...fallbackCol, presentCount: 0, absentCount: 0, totalStudents: vert.students.length }];
749
- } else {
750
- logError(`Date ${singleDateVal} does not exist in the spreadsheet!`);
751
- continue;
752
- }
1317
+ if (batchItem.students.length === 0) {
1318
+ logWarn(`No students found for ${vert.sheetName}${batchTag}. Skipping.`);
1319
+ continue;
753
1320
  }
754
- } else if (dateFilterMode === 'RANGE' && dateRangeVal) {
755
- datesToExport = datesToExport.filter(d => d.dateStr >= dateRangeVal.start && d.dateStr <= dateRangeVal.end);
756
- } else if (dateFilterMode === 'LATEST' && datesToExport.length > 0) {
757
- datesToExport = [datesToExport[datesToExport.length - 1]];
758
- }
759
1321
 
760
- if (datesToExport.length === 0) {
761
- logWarn(`No matching dates found to export for ${vert.sheetName}.`);
762
- continue;
763
- }
1322
+ let datesToExport = [...batchItem.dates];
1323
+
1324
+ if (dateFilterMode === 'SINGLE' && singleDateVal) {
1325
+ datesToExport = datesToExport.filter(d => d.dateStr === singleDateVal);
1326
+ if (datesToExport.length === 0) {
1327
+ logWarn(`Date ${singleDateVal} was not found among recorded dates for ${vert.sheetName}${batchTag}.`);
1328
+ const fallbackCol = vert.allDateColumns.find(d => d.dateStr === singleDateVal);
1329
+ if (fallbackCol) {
1330
+ datesToExport = [{ ...fallbackCol, presentCount: 0, absentCount: 0, totalStudents: batchItem.students.length }];
1331
+ } else {
1332
+ logError(`Date ${singleDateVal} does not exist in the spreadsheet!`);
1333
+ continue;
1334
+ }
1335
+ }
1336
+ } else if (dateFilterMode === 'RANGE' && dateRangeVal) {
1337
+ datesToExport = datesToExport.filter(d => d.dateStr >= dateRangeVal.start && d.dateStr <= dateRangeVal.end);
1338
+ } else if (dateFilterMode === 'LATEST' && datesToExport.length > 0) {
1339
+ datesToExport = [datesToExport[datesToExport.length - 1]];
1340
+ }
764
1341
 
765
- console.log(` Dates to export: ${colors.bold}${datesToExport.length}${colors.reset}`);
1342
+ if (datesToExport.length === 0) {
1343
+ logWarn(`No matching dates found to export for ${vert.sheetName}${batchTag}.`);
1344
+ continue;
1345
+ }
766
1346
 
767
- const ws = workbook.Sheets[vert.sheetName];
768
- const rows = xlsx.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null });
1347
+ console.log(` Dates to export: ${colors.bold}${datesToExport.length}${colors.reset}`);
769
1348
 
770
- // Build ZIP archive directly in memory
771
- const zip = new AdmZip();
1349
+ const rows = vert.rows || xlsx.utils.sheet_to_json(workbook.Sheets[vert.sheetName], { header: 1, raw: true, defval: null });
1350
+ const zip = new AdmZip();
1351
+ const yearTag = selectedYear === 2 ? '2nd_Year' : '3rd_Year';
772
1352
 
773
- for (const dateItem of datesToExport) {
774
- const fileName = `Upload_Attendance_${vert.sheetName}_${dateItem.dateStr}.xlsx`;
775
- const outWb = generateExportWorkbook(vert, dateItem, rows);
776
- const fileBuffer = xlsx.write(outWb, { type: 'buffer', bookType: 'xlsx' });
777
- zip.addFile(fileName, fileBuffer);
778
- }
1353
+ for (const dateItem of datesToExport) {
1354
+ const batchFileSuffix = batchItem.label ? `_${batchItem.label}` : '';
1355
+ const fileName = `${dateItem.dateStr}_Upload_Attendance_${vert.sheetName}${batchFileSuffix}.xlsx`;
1356
+ const outWb = generateExportWorkbook(vert, dateItem, rows, batchItem.students, parsedTemplate);
1357
+ const fileBuffer = xlsx.write(outWb, { type: 'buffer', bookType: 'xlsx' });
1358
+ zip.addFile(fileName, fileBuffer);
1359
+ }
779
1360
 
780
- // Save only the ZIP archive in output/
781
- const zipFileName = `${vert.sheetName}_Attendance.zip`;
782
- const zipFilePath = path.join(OUTPUT_DIR, zipFileName);
783
- zip.writeZip(zipFilePath);
1361
+ const batchZipSuffix = batchItem.label ? `_${batchItem.label}` : '';
1362
+ const zipBaseName = `${yearTag}_${vert.sheetName}${batchZipSuffix}_Attendance`;
1363
+ const zipFileName = `${zipBaseName}.zip`;
1364
+ const zipFilePath = path.join(OUTPUT_DIR, zipFileName);
1365
+ zip.writeZip(zipFilePath);
784
1366
 
785
- // Clean up any previously created unzipped folder for this vertical if it exists
786
- const oldVertFolder = path.join(OUTPUT_DIR, `${vert.sheetName}_Attendance`);
787
- if (fs.existsSync(oldVertFolder) && fs.statSync(oldVertFolder).isDirectory()) {
788
- fs.rmSync(oldVertFolder, { recursive: true, force: true });
789
- }
1367
+ // Clean up legacy unzipped folder if any existed
1368
+ const oldVertFolder = path.join(OUTPUT_DIR, zipBaseName);
1369
+ if (fs.existsSync(oldVertFolder) && fs.statSync(oldVertFolder).isDirectory()) {
1370
+ fs.rmSync(oldVertFolder, { recursive: true, force: true });
1371
+ }
790
1372
 
791
- const zipStat = fs.statSync(zipFilePath);
792
- const zipSizeKb = (zipStat.size / 1024).toFixed(1);
1373
+ const zipStat = fs.statSync(zipFilePath);
1374
+ const zipSizeKb = (zipStat.size / 1024).toFixed(1);
793
1375
 
794
- logSuccess(`Generated ZIP archive containing ${datesToExport.length} export files:`);
795
- console.log(` ${colors.bold}${colors.green}${zipFilePath}${colors.reset} (${zipSizeKb} KB)`);
1376
+ logSuccess(`Generated ZIP archive containing ${datesToExport.length} export files:`);
1377
+ console.log(` ${colors.bold}${colors.green}${zipFilePath}${colors.reset} (${zipSizeKb} KB)`);
1378
+ }
796
1379
  }
797
1380
 
798
1381
  // Final summary
799
1382
  logHeader('Export Complete');
800
- 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`);
801
1385
  }
802
1386
 
803
1387
  main().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrselva/attendance",
3
- "version": "1.1.2",
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": {
@@ -33,4 +33,4 @@
33
33
  "adm-zip": "^0.6.1",
34
34
  "xlsx": "^0.18.5"
35
35
  }
36
- }
36
+ }