@mrselva/attendance 1.1.0 → 1.1.3

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 +31 -46
  2. package/index.js +286 -134
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -4,15 +4,15 @@
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
- > An automated CLI converter that extracts vertical-wise attendance data from KG-KITE / SoI multi-sheet attendance spreadsheets, generates DigiCampus-compliant upload workbooks, and bundles them into clean ZIP archives.
7
+ > A fast and lightweight CLI tool to extract, normalize, and export multi-sheet attendance spreadsheets into organized Excel files and ZIP archives.
8
8
 
9
- Run instantly with **`npx`** — no cloning or installation required!
9
+ Run instantly with **`npx`** — no installation required!
10
10
 
11
11
  ---
12
12
 
13
13
  ## ⚡ Quick Start
14
14
 
15
- Open your terminal in any folder containing your attendance spreadsheet (or an `input/` folder):
15
+ Open your terminal in any directory and run:
16
16
 
17
17
  ```bash
18
18
  npx @mrselva/attendance
@@ -29,64 +29,55 @@ attendance
29
29
 
30
30
  ## ✨ Features
31
31
 
32
- - **Zero Configuration & Auto-Import**: If no file is in `./input/`, an OS file picker dialog pops up so you can simply click your spreadsheet (from Downloads or anywhere), and it automatically imports it into `./input/`! Drag & drop file paths into the terminal is also supported.
33
- - **Interactive CLI Wizard**: Clean, color-coded terminal prompts to pick verticals and date ranges.
34
- - **Comprehensive Vertical Support**: Detects lab vertical sheets:
35
- - `AD`: AI & Data Science Lab
36
- - `BW`: Blockchain and Web3.0 Lab
37
- - `CD`: Cloud and DevOps Lab
38
- - `CS`: Cyber Security Lab
39
- - `DMA`: Data & Mobile Analytics Lab
40
- - `EI`: Embedded & IoT Lab
41
- - `FW`: Full Stack Web Lab
42
- - `SOAL`: School of AI Learning
43
- - `SOP`: School of Programming
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
+ - **Cohort & Batch Support**:
34
+ - **3rd Year**: Exports standard whole-class roster.
35
+ - **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.
44
37
  - **Smart Date Filtering**:
45
- - Export **All recorded dates** (creates one upload file per instructional date)
46
- - Export a **Single date** (e.g. `2024-09-15`)
38
+ - Export **All recorded dates**
39
+ - Export a **Single date** (e.g. `YYYY-MM-DD`)
47
40
  - Export a **Date range** (from `YYYY-MM-DD` to `YYYY-MM-DD`)
48
41
  - Export the **Latest date only**
49
- - **DigiCampus Compatibility**: Creates individual files with a `Students` sheet formatted with:
50
- - `Email Id`
51
- - `Registration Id`
52
- - `Attendance *` (`PRESENT` or `ABSENT`)
53
- - **Status Normalization**: Accurately normalizes marks (`P`, `1`, `OD`, `ELITE` → `PRESENT`; `A`, `0`, unrecorded → `ABSENT`).
54
- - **Automatic ZIP Packaging**: Compresses generated files directly into `./output/<Vertical>_Attendance.zip`.
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/`.
55
44
 
56
45
  ---
57
46
 
58
47
  ## 🚀 CLI Usage & Flags
59
48
 
60
- You can run the interactive wizard or pass non-interactive CLI flags for automation:
49
+ Run the interactive wizard or pass flags for automation:
61
50
 
62
51
  ```bash
63
- # Interactive mode
52
+ # Interactive mode (asks Year, Batch, Sheet, Dates)
64
53
  npx @mrselva/attendance
65
54
 
66
- # Export ALL dates for a specific vertical
67
- npx @mrselva/attendance -v BW -a
55
+ # 2nd Year - Export Batch 1
56
+ npx @mrselva/attendance -y 2 -b 1 -v SHEET_CODE -a
68
57
 
69
- # Export a single date
70
- npx @mrselva/attendance -v CD -d 2024-09-12
58
+ # 2nd Year - Export Batch 2
59
+ npx @mrselva/attendance -y 2 -b 2 -v SHEET_CODE -a
71
60
 
72
- # Export the latest date recorded
73
- npx @mrselva/attendance -v AD --latest
61
+ # 2nd Year - Export Both Batches separately
62
+ npx @mrselva/attendance -y 2 -b all -v ALL -a
74
63
 
75
- # Process ALL verticals at once
76
- npx @mrselva/attendance -v ALL -a
64
+ # 3rd Year - Export standard roster
65
+ npx @mrselva/attendance -y 3 -v SHEET_CODE -a
77
66
 
78
67
  # Specify a custom input spreadsheet file
79
- npx @mrselva/attendance -f "2024-28_SoI_Attendance_Sheet.xlsx" -v FW -a
68
+ npx @mrselva/attendance -f "attendance_sheet.xlsx" -y 2 -b 1 -a
80
69
  ```
81
70
 
82
71
  ### Options Reference
83
72
 
84
73
  | Flag | Long Flag | Description |
85
74
  | :--- | :--- | :--- |
86
- | `-v <code\|ALL>` | `--vertical <code\|ALL>` | Vertical code (e.g. `AD`, `BW`, `CD`, `CS`, `DMA`, `EI`, `FW`, or `ALL`) |
87
- | `-a` | `--all` | Export all recorded instructional dates |
75
+ | `-y <2\|3>` | `--year <2\|3>` | Academic year (`2` for 2nd year batched, `3` for 3rd year standard) |
76
+ | `-b <1\|2\|all>` | `--batch <1\|2\|all>` | Batch selection for 2nd year (`1`, `2`, or `all` for both) |
77
+ | `-v <code>` | `--vertical <code>` | Sheet code or name (or `ALL`) |
78
+ | `-a` | `--all` | Export all recorded dates |
88
79
  | `-d <date>` | `--date <YYYY-MM-DD>` | Export a specific date |
89
- | | `--latest` | Export the latest instructional date |
80
+ | | `--latest` | Export the latest recorded date |
90
81
  | `-f <file>` | `--file <path>` | Specify input Excel file path or filename |
91
82
  | `-h` | `--help` | Display usage and help message |
92
83
 
@@ -96,24 +87,18 @@ npx @mrselva/attendance -f "2024-28_SoI_Attendance_Sheet.xlsx" -v FW -a
96
87
 
97
88
  ```text
98
89
  your-folder/
99
- ├── input/ # (Optional) Place source spreadsheets here
100
- │ └── 2024-28_SoI_Attendance.xlsx
101
- └── output/ # Automatically generated output
102
- ├── BW_Attendance.zip
103
- └── CD_Attendance.zip
90
+ ├── input/ # Place source spreadsheets here (or auto-import)
91
+ └── output/ # Automatically generated ZIP archives
104
92
  ```
105
93
 
106
- > **Note:** If an `input/` folder is not found, the CLI will look for Excel files directly in your current working directory.
107
-
108
94
  ---
109
95
 
110
96
  ## 🛠 Requirements
111
97
 
112
98
  - **Node.js**: `v16.0.0` or higher
113
- - **npm**: `v7.0.0` or higher (included with Node.js)
114
99
 
115
100
  ---
116
101
 
117
102
  ## 📄 License
118
103
 
119
- [ISC](LICENSE) &copy; [mrselva-eth](https://github.com/mrselva-eth)
104
+ [ISC](LICENSE) &copy; [mrselva](https://github.com/mrselva)
package/index.js CHANGED
@@ -1,15 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Attendance to DigiCampus Converter
3
+ * Attendance Spreadsheet Converter & Exporter
4
4
  * Single-file Node.js CLI script
5
5
  *
6
6
  * Features:
7
- * - Scans ./input for attendance spreadsheets (.xlsx / .xls)
8
- * - Interactively prompts for vertical selection (AD, BW, CD, CS, DMA, EI, FW, etc.)
7
+ * - Scans ./input or current directory for attendance spreadsheets (.xlsx / .xls)
8
+ * - Interactively prompts for sheet/category selection
9
9
  * - Allows exporting all recorded dates, specific date, range, or latest date
10
- * - Generates individual DigiCampus-compliant Excel files ('Students' sheet with Email Id, Registration Id, Attendance *)
11
- * - Automatically packages all generated files into a ZIP archive in ./output
12
- * - Works both interactively (in terminal) and with optional CLI flags (--vertical, --all, etc.)
10
+ * - Generates individual formatted Excel files with ZIP archive in ./output
11
+ * - Works both interactively (in terminal) and with CLI flags (--vertical, --all, etc.)
13
12
  */
14
13
 
15
14
  const fs = require('fs');
@@ -23,18 +22,8 @@ const AdmZip = require('adm-zip');
23
22
  const INPUT_DIR = path.resolve(process.cwd(), 'input');
24
23
  const OUTPUT_DIR = path.resolve(process.cwd(), 'output');
25
24
 
26
- // Known vertical full names mapping
27
- const VERTICAL_NAMES = {
28
- 'AD': 'AI & Data Science Lab',
29
- 'BW': 'Blockchain and Web3.0 Lab',
30
- 'CD': 'Cloud and DevOps Lab',
31
- 'CS': 'Cyber Security Lab',
32
- 'DMA': 'Data & Mobile Analytics Lab',
33
- 'EI': 'Embedded & IoT Lab',
34
- 'FW': 'Full Stack Web Lab',
35
- 'SOAL': 'School of AI Learning',
36
- 'SOP': 'School of Programming'
37
- };
25
+ // Optional category/sheet code to display name mapping
26
+ const VERTICAL_NAMES = {};
38
27
 
39
28
  // ANSI color helpers
40
29
  const colors = {
@@ -190,7 +179,55 @@ function normalizeStatus(val) {
190
179
  return null;
191
180
  }
192
181
 
193
- // Parse a vertical sheet to extract students and date columns
182
+
183
+ // Check if a row represents summary / totals
184
+ function isSummaryRow(row, name, regNo) {
185
+ if (name && (/no\s*\.?\s*of/i.test(name) || /total/i.test(name) || /present/i.test(name) || /absent/i.test(name))) {
186
+ return true;
187
+ }
188
+ if (regNo && (/no\s*\.?\s*of/i.test(regNo) || /total/i.test(regNo) || /present/i.test(regNo) || /absent/i.test(regNo))) {
189
+ return true;
190
+ }
191
+ if (row && Array.isArray(row)) {
192
+ const text = row.map(cell => String(cell || '').trim()).join(' ').toLowerCase();
193
+ return /no\s*\.?\s*of\s*(present|absent)/i.test(text) ||
194
+ /\b(total\s*present|total\s*absent|no\s*\.?\s*of\s*students)\b/i.test(text);
195
+ }
196
+ return false;
197
+ }
198
+
199
+ // Calculate instructional dates for a given student roster
200
+ function getInstructionalDates(students, dateColumns, rows) {
201
+ if (!students || students.length === 0) return [];
202
+ const instructionalDates = [];
203
+ for (const dc of dateColumns) {
204
+ let presentCount = 0;
205
+ let absentCount = 0;
206
+
207
+ for (const student of students) {
208
+ const cellVal = rows[student.rowIdx] ? rows[student.rowIdx][dc.colIdx] : null;
209
+ const norm = normalizeStatus(cellVal);
210
+ if (norm === 'PRESENT') {
211
+ presentCount++;
212
+ } else if (norm === 'ABSENT') {
213
+ absentCount++;
214
+ }
215
+ }
216
+
217
+ if (presentCount + absentCount > 0) {
218
+ instructionalDates.push({
219
+ ...dc,
220
+ presentCount,
221
+ absentCount,
222
+ totalStudents: students.length
223
+ });
224
+ }
225
+ }
226
+ instructionalDates.sort((a, b) => a.dateStr.localeCompare(b.dateStr));
227
+ return instructionalDates;
228
+ }
229
+
230
+ // Parse a vertical sheet to extract students (batch 1 & 2) and date columns
194
231
  function parseVerticalSheet(ws, sheetName) {
195
232
  const rows = xlsx.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null });
196
233
  if (!rows || rows.length < 2) return null;
@@ -201,7 +238,6 @@ function parseVerticalSheet(ws, sheetName) {
201
238
  let nameColIdx = -1;
202
239
  let emailColIdx = -1;
203
240
  let rollColIdx = -1;
204
- let isKiteEmail = false;
205
241
  const dateColumns = [];
206
242
 
207
243
  // Analyze header row
@@ -215,13 +251,8 @@ function parseVerticalSheet(ws, sheetName) {
215
251
  regColIdx = c;
216
252
  } else if (hStr.includes('student name') || hStr === 'name') {
217
253
  nameColIdx = c;
218
- } else if (hStr.includes('kite email')) {
254
+ } else if (hStr.includes('email id') || hStr.includes('email') || hStr.includes('mail')) {
219
255
  emailColIdx = c;
220
- isKiteEmail = true;
221
- } else if (!isKiteEmail && (hStr.includes('email id') || hStr.includes('email'))) {
222
- if (!hStr.includes('soi')) {
223
- emailColIdx = c;
224
- }
225
256
  } else if (hStr.includes('roll number') || hStr.includes('roll no')) {
226
257
  rollColIdx = c;
227
258
  } else {
@@ -239,11 +270,20 @@ function parseVerticalSheet(ws, sheetName) {
239
270
  return null; // Not a vertical attendance sheet
240
271
  }
241
272
 
242
- // Parse student records
243
- const students = [];
273
+ // Parse student records, separating Batch 1 and Batch 2 across empty separator row
274
+ const batch1Students = [];
275
+ const batch2Students = [];
276
+ let currentBatch = 1;
277
+ let sawBlankAfterBatch1 = false;
278
+
244
279
  for (let r = 1; r < rows.length; r++) {
245
280
  const row = rows[r];
246
- if (!row) continue;
281
+ if (!row || row.length === 0) {
282
+ if (batch1Students.length > 0 && currentBatch === 1) {
283
+ sawBlankAfterBatch1 = true;
284
+ }
285
+ continue;
286
+ }
247
287
 
248
288
  const sno = row[0];
249
289
  const name = nameColIdx !== -1 ? String(row[nameColIdx] || '').trim() : '';
@@ -252,79 +292,76 @@ function parseVerticalSheet(ws, sheetName) {
252
292
  const rollNo = rollColIdx !== -1 ? String(row[rollColIdx] || '').trim() : '';
253
293
 
254
294
  // Ignore summary/totals rows at bottom
255
- const isSummary = /no\s*\.?\s*of/i.test(name) ||
256
- /no\s*\.?\s*of/i.test(regNo) ||
257
- /total/i.test(name) ||
258
- /total/i.test(regNo) ||
259
- /present/i.test(regNo) ||
260
- /absent/i.test(regNo);
261
- if (isSummary) continue;
262
-
263
- if (!name || name.length < 2) continue;
264
- if (sno !== null && sno !== undefined && isNaN(Number(sno))) continue;
265
- if (!regNo && !rollNo) continue;
266
-
267
- students.push({
295
+ if (isSummaryRow(row, name, regNo)) {
296
+ break;
297
+ }
298
+
299
+ const isStudent = name.length >= 2 && (regNo.length > 0 || rollNo.length > 0);
300
+
301
+ if (!isStudent) {
302
+ if (batch1Students.length > 0 && currentBatch === 1) {
303
+ sawBlankAfterBatch1 = true;
304
+ }
305
+ continue;
306
+ }
307
+
308
+ // Valid student row
309
+ const snoNum = Number(sno);
310
+ if (batch1Students.length > 0 && currentBatch === 1 && (sawBlankAfterBatch1 || snoNum === 1)) {
311
+ currentBatch = 2;
312
+ }
313
+
314
+ const studentObj = {
268
315
  rowIdx: r,
269
316
  sno: sno,
270
317
  name: name,
271
318
  regNo: regNo,
272
319
  rollNo: rollNo,
273
- email: email || (rollNo ? `${rollNo.toLowerCase()}@kgkite.ac.in` : '')
274
- });
275
- }
276
-
277
- if (students.length === 0) return null;
320
+ email: email || '',
321
+ batch: currentBatch
322
+ };
278
323
 
279
- // Filter dates to only those that have actual student attendance recorded
280
- const instructionalDates = [];
281
- for (const dc of dateColumns) {
282
- let presentCount = 0;
283
- let absentCount = 0;
284
-
285
- for (const student of students) {
286
- const cellVal = rows[student.rowIdx] ? rows[student.rowIdx][dc.colIdx] : null;
287
- const norm = normalizeStatus(cellVal);
288
- if (norm === 'PRESENT') {
289
- presentCount++;
290
- } else if (norm === 'ABSENT') {
291
- absentCount++;
292
- }
293
- }
294
-
295
- // Only if at least one student was marked PRESENT or ABSENT
296
- if (presentCount + absentCount > 0) {
297
- instructionalDates.push({
298
- ...dc,
299
- presentCount,
300
- absentCount,
301
- totalStudents: students.length
302
- });
324
+ if (currentBatch === 1) {
325
+ batch1Students.push(studentObj);
326
+ } else {
327
+ batch2Students.push(studentObj);
303
328
  }
304
329
  }
305
330
 
306
- // Sort dates chronologically
307
- instructionalDates.sort((a, b) => a.dateStr.localeCompare(b.dateStr));
331
+ const allStudents = [...batch1Students, ...batch2Students];
332
+ if (allStudents.length === 0) return null;
333
+
334
+ const allDates = getInstructionalDates(allStudents, dateColumns, rows);
335
+ const batch1Dates = getInstructionalDates(batch1Students, dateColumns, rows);
336
+ const batch2Dates = getInstructionalDates(batch2Students, dateColumns, rows);
308
337
 
309
338
  return {
310
339
  sheetName,
311
340
  verticalName: VERTICAL_NAMES[sheetName] || sheetName,
312
- students,
313
- dates: instructionalDates,
314
- allDateColumns: dateColumns
341
+ students: allStudents,
342
+ batch1Students,
343
+ batch2Students,
344
+ hasBatches: batch1Students.length > 0 && batch2Students.length > 0,
345
+ dates: allDates,
346
+ batch1Dates,
347
+ batch2Dates,
348
+ allDateColumns: dateColumns,
349
+ rows
315
350
  };
316
351
  }
317
352
 
318
- // Generate DigiCampus Excel workbook for a single date
319
- function generateDigiCampusWorkbook(verticalData, dateItem, rows) {
353
+ // Generate formatted Excel workbook for a single date
354
+ function generateExportWorkbook(verticalData, dateItem, rows, studentList) {
320
355
  const wb = xlsx.utils.book_new();
321
356
 
322
- // DigiCampus Template Header
357
+ // Export Sheet Header
323
358
  const wsData = [
324
359
  ['Email Id', 'Registration Id', 'Attendance *']
325
360
  ];
326
361
 
327
- for (const student of verticalData.students) {
362
+ const students = studentList || verticalData.students;
363
+
364
+ for (const student of students) {
328
365
  const rawVal = rows[student.rowIdx] ? rows[student.rowIdx][dateItem.colIdx] : null;
329
366
  let status = normalizeStatus(rawVal);
330
367
  // On an instructional day, any unrecorded or blank student is marked ABSENT
@@ -363,6 +400,8 @@ function parseArgs() {
363
400
  endDate: null,
364
401
  latest: false,
365
402
  file: null,
403
+ year: null,
404
+ batch: null,
366
405
  help: false
367
406
  };
368
407
 
@@ -372,6 +411,10 @@ function parseArgs() {
372
411
  flags.help = true;
373
412
  } else if (a === '-v' || a === '--vertical') {
374
413
  flags.vertical = args[++i];
414
+ } else if (a === '-y' || a === '--year') {
415
+ flags.year = args[++i];
416
+ } else if (a === '-b' || a === '--batch') {
417
+ flags.batch = args[++i];
375
418
  } else if (a === '-a' || a === '--all' || a === '--all-dates') {
376
419
  flags.all = true;
377
420
  } else if (a === '-d' || a === '--date') {
@@ -529,7 +572,9 @@ Usage: npx @mrselva/attendance [options] [vertical]
529
572
  or: node index.js [options] [vertical]
530
573
 
531
574
  Options:
532
- -v, --vertical <code/name> Vertical code (e.g., AD, BW, CD, CS, DMA, EI, FW, ALL)
575
+ -y, --year <2|3> Academic year (2 for 2nd year batched, 3 for 3rd year standard)
576
+ -b, --batch <1|2|all> Batch selection for 2nd year (1, 2, or all)
577
+ -v, --vertical <code/name> Sheet/vertical code (e.g., AD, BW, CD, CS, DMA, EI, FW, ALL)
533
578
  -a, --all Export all instructional dates automatically
534
579
  -d, --date <YYYY-MM-DD> Export a single specific date
535
580
  --latest Export the latest instructional date
@@ -542,7 +587,7 @@ Running without options launches the interactive CLI wizard.
542
587
  }
543
588
 
544
589
  console.log(`\n${colors.bold}${colors.magenta}╔═════════════════════════════════════════════════════════════════════╗`);
545
- console.log(`║ KG-KITE / SoI Attendance -> DigiCampus Exporter ║`);
590
+ console.log(`║ Attendance Sheet Exporter & Converter ║`);
546
591
  console.log(`╚═════════════════════════════════════════════════════════════════════╝${colors.reset}`);
547
592
 
548
593
  ensureDirs();
@@ -643,6 +688,63 @@ Running without options launches the interactive CLI wizard.
643
688
  process.exit(1);
644
689
  }
645
690
 
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
714
+ let selectedBatch = null; // 1, 2, or 'BOTH'
715
+ if (selectedYear === 2) {
716
+ if (flags.batch) {
717
+ const bStr = String(flags.batch).trim().toLowerCase();
718
+ if (bStr === '1' || bStr === 'b1' || bStr === 'batch1' || bStr === '1st') selectedBatch = 1;
719
+ else if (bStr === '2' || bStr === 'b2' || bStr === 'batch2' || bStr === '2nd') selectedBatch = 2;
720
+ else if (bStr === 'all' || bStr === 'both' || bStr === '3') selectedBatch = 'BOTH';
721
+ }
722
+
723
+ if (!selectedBatch) {
724
+ logHeader('Select Batch for 2nd Year');
725
+ console.log('Choose which batch to export:\n');
726
+ console.log(` [${colors.bold}1${colors.reset}] ${colors.green}Batch 1${colors.reset} (Students above the empty row) [Default]`);
727
+ console.log(` [${colors.bold}2${colors.reset}] ${colors.cyan}Batch 2${colors.reset} (Students below the empty row)`);
728
+ console.log(` [${colors.bold}3${colors.reset}] ${colors.yellow}Both Batches${colors.reset} (Export Batch 1 and Batch 2 separately)`);
729
+
730
+ const batchAns = (await askQuestion(lines, `\nSelect batch [1-3] (default: 1): `)).trim();
731
+ if (batchAns === '2' || batchAns.toLowerCase().startsWith('2')) {
732
+ selectedBatch = 2;
733
+ } else if (batchAns === '3' || batchAns.toLowerCase().startsWith('3') || batchAns.toLowerCase() === 'both' || batchAns.toLowerCase() === 'all') {
734
+ selectedBatch = 'BOTH';
735
+ } else {
736
+ selectedBatch = 1;
737
+ }
738
+ }
739
+ }
740
+
741
+ if (selectedYear === 2) {
742
+ const batchLabelStr = selectedBatch === 'BOTH' ? 'Both Batches (Batch 1 & Batch 2)' : `Batch ${selectedBatch}`;
743
+ logInfo(`Cohort: ${colors.bold}2nd Year${colors.reset} | Target: ${colors.bold}${batchLabelStr}${colors.reset}`);
744
+ } else {
745
+ logInfo(`Cohort: ${colors.bold}3rd Year (Standard Whole Class)${colors.reset}`);
746
+ }
747
+
646
748
  // Vertical Selection
647
749
  let chosenVertical = null;
648
750
  let processAll = false;
@@ -670,7 +772,18 @@ Running without options launches the interactive CLI wizard.
670
772
  verticalSheets.forEach((v, idx) => {
671
773
  const code = colors.bold + v.sheetName.padEnd(5) + colors.reset;
672
774
  const name = (v.verticalName !== v.sheetName ? `(${v.verticalName})` : '').padEnd(35);
673
- const countInfo = `${colors.dim}${v.students.length} students, ${v.dates.length} dates recorded${colors.reset}`;
775
+ let countInfo = '';
776
+ if (selectedYear === 2) {
777
+ if (selectedBatch === 1) {
778
+ countInfo = `${colors.dim}${v.batch1Students.length} students (Batch 1), ${v.batch1Dates.length} dates${colors.reset}`;
779
+ } else if (selectedBatch === 2) {
780
+ countInfo = `${colors.dim}${v.batch2Students.length} students (Batch 2), ${v.batch2Dates.length} dates${colors.reset}`;
781
+ } else {
782
+ countInfo = `${colors.dim}B1: ${v.batch1Students.length} sts, B2: ${v.batch2Students.length} sts${colors.reset}`;
783
+ }
784
+ } else {
785
+ countInfo = `${colors.dim}${v.students.length} students, ${v.dates.length} dates recorded${colors.reset}`;
786
+ }
674
787
  console.log(` [${colors.bold}${idx + 1}${colors.reset}] ${code} ${name} ${countInfo}`);
675
788
  });
676
789
  console.log(` [${colors.bold}A${colors.reset}] ${colors.bold}ALL ${colors.reset} (Process ALL verticals at once)`);
@@ -704,6 +817,16 @@ Running without options launches the interactive CLI wizard.
704
817
 
705
818
  const targets = processAll ? verticalSheets : [chosenVertical];
706
819
 
820
+ // Helper to get dates for chosen cohort & batch
821
+ function getDatesForVertical(vert) {
822
+ if (selectedYear === 2) {
823
+ if (selectedBatch === 1) return vert.batch1Dates;
824
+ if (selectedBatch === 2) return vert.batch2Dates;
825
+ return vert.dates;
826
+ }
827
+ return vert.dates;
828
+ }
829
+
707
830
  // Date selection logic
708
831
  let dateFilterMode = 'ALL';
709
832
  let singleDateVal = flags.date || null;
@@ -716,11 +839,13 @@ Running without options launches the interactive CLI wizard.
716
839
  } else if (flags.date) {
717
840
  dateFilterMode = 'SINGLE';
718
841
  } else if (!processAll) {
719
- const totalDates = chosenVertical.dates.length;
720
- const firstDate = totalDates > 0 ? chosenVertical.dates[0].dateStr : 'N/A';
721
- const lastDate = totalDates > 0 ? chosenVertical.dates[totalDates - 1].dateStr : 'N/A';
842
+ const refDates = getDatesForVertical(chosenVertical);
843
+ const totalDates = refDates.length;
844
+ const firstDate = totalDates > 0 ? refDates[0].dateStr : 'N/A';
845
+ const lastDate = totalDates > 0 ? refDates[totalDates - 1].dateStr : 'N/A';
722
846
 
723
- logHeader(`Date Selection for Vertical: ${chosenVertical.sheetName}`);
847
+ const batchNameHeader = selectedYear === 2 ? (selectedBatch === 'BOTH' ? ' (Batches 1 & 2)' : ` (Batch ${selectedBatch})`) : '';
848
+ logHeader(`Date Selection for Sheet: ${chosenVertical.sheetName}${batchNameHeader}`);
724
849
  console.log(`Found ${colors.bold}${totalDates}${colors.reset} instructional dates (${firstDate} to ${lastDate}).\n`);
725
850
  console.log(` [${colors.bold}1${colors.reset}] Export ${colors.green}ALL ${totalDates} dates${colors.reset} (Recommended - generates upload files for each date)`);
726
851
  console.log(` [${colors.bold}2${colors.reset}] Export a ${colors.cyan}single specific date${colors.reset} (e.g., ${lastDate})`);
@@ -748,68 +873,95 @@ Running without options launches the interactive CLI wizard.
748
873
  rl.close();
749
874
 
750
875
  // Process selected verticals
751
- logHeader('Processing & Generating DigiCampus Files');
876
+ logHeader('Processing & Generating Export Files');
752
877
 
753
878
  for (const vert of targets) {
754
- console.log(`\n${colors.bold}Vertical: ${vert.sheetName} - ${vert.verticalName}${colors.reset}`);
755
- console.log(` Students: ${colors.bold}${vert.students.length}${colors.reset}`);
879
+ let batchesToProcess = [];
880
+ if (selectedYear === 2) {
881
+ if (selectedBatch === 1) {
882
+ batchesToProcess = [{ label: 'Batch1', students: vert.batch1Students, dates: vert.batch1Dates }];
883
+ } else if (selectedBatch === 2) {
884
+ batchesToProcess = [{ label: 'Batch2', students: vert.batch2Students, dates: vert.batch2Dates }];
885
+ } else {
886
+ // BOTH
887
+ batchesToProcess = [
888
+ { label: 'Batch1', students: vert.batch1Students, dates: vert.batch1Dates },
889
+ { label: 'Batch2', students: vert.batch2Students, dates: vert.batch2Dates }
890
+ ];
891
+ }
892
+ } else {
893
+ // 3rd Year
894
+ batchesToProcess = [{ label: '', students: vert.students, dates: vert.dates }];
895
+ }
756
896
 
757
- let datesToExport = [...vert.dates];
897
+ for (const batchItem of batchesToProcess) {
898
+ const batchTag = batchItem.label ? ` (${batchItem.label})` : '';
899
+ console.log(`\n${colors.bold}Sheet: ${vert.sheetName}${vert.verticalName !== vert.sheetName ? ' - ' + vert.verticalName : ''}${batchTag}${colors.reset}`);
900
+ console.log(` Students: ${colors.bold}${batchItem.students.length}${colors.reset}`);
758
901
 
759
- if (dateFilterMode === 'SINGLE' && singleDateVal) {
760
- datesToExport = datesToExport.filter(d => d.dateStr === singleDateVal);
761
- if (datesToExport.length === 0) {
762
- logWarn(`Date ${singleDateVal} was not found among instructional dates for ${vert.sheetName}.`);
763
- const fallbackCol = vert.allDateColumns.find(d => d.dateStr === singleDateVal);
764
- if (fallbackCol) {
765
- datesToExport = [{ ...fallbackCol, presentCount: 0, absentCount: 0, totalStudents: vert.students.length }];
766
- } else {
767
- logError(`Date ${singleDateVal} does not exist in the spreadsheet!`);
768
- continue;
902
+ if (batchItem.students.length === 0) {
903
+ logWarn(`No students found for ${vert.sheetName}${batchTag}. Skipping.`);
904
+ continue;
905
+ }
906
+
907
+ let datesToExport = [...batchItem.dates];
908
+
909
+ if (dateFilterMode === 'SINGLE' && singleDateVal) {
910
+ datesToExport = datesToExport.filter(d => d.dateStr === singleDateVal);
911
+ if (datesToExport.length === 0) {
912
+ logWarn(`Date ${singleDateVal} was not found among instructional dates for ${vert.sheetName}${batchTag}.`);
913
+ const fallbackCol = vert.allDateColumns.find(d => d.dateStr === singleDateVal);
914
+ if (fallbackCol) {
915
+ datesToExport = [{ ...fallbackCol, presentCount: 0, absentCount: 0, totalStudents: batchItem.students.length }];
916
+ } else {
917
+ logError(`Date ${singleDateVal} does not exist in the spreadsheet!`);
918
+ continue;
919
+ }
769
920
  }
921
+ } else if (dateFilterMode === 'RANGE' && dateRangeVal) {
922
+ datesToExport = datesToExport.filter(d => d.dateStr >= dateRangeVal.start && d.dateStr <= dateRangeVal.end);
923
+ } else if (dateFilterMode === 'LATEST' && datesToExport.length > 0) {
924
+ datesToExport = [datesToExport[datesToExport.length - 1]];
770
925
  }
771
- } else if (dateFilterMode === 'RANGE' && dateRangeVal) {
772
- datesToExport = datesToExport.filter(d => d.dateStr >= dateRangeVal.start && d.dateStr <= dateRangeVal.end);
773
- } else if (dateFilterMode === 'LATEST' && datesToExport.length > 0) {
774
- datesToExport = [datesToExport[datesToExport.length - 1]];
775
- }
776
926
 
777
- if (datesToExport.length === 0) {
778
- logWarn(`No matching dates found to export for ${vert.sheetName}.`);
779
- continue;
780
- }
927
+ if (datesToExport.length === 0) {
928
+ logWarn(`No matching dates found to export for ${vert.sheetName}${batchTag}.`);
929
+ continue;
930
+ }
781
931
 
782
- console.log(` Dates to export: ${colors.bold}${datesToExport.length}${colors.reset}`);
932
+ console.log(` Dates to export: ${colors.bold}${datesToExport.length}${colors.reset}`);
783
933
 
784
- const ws = workbook.Sheets[vert.sheetName];
785
- const rows = xlsx.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null });
934
+ const rows = vert.rows || xlsx.utils.sheet_to_json(workbook.Sheets[vert.sheetName], { header: 1, raw: true, defval: null });
935
+ const zip = new AdmZip();
786
936
 
787
- // Build ZIP archive directly in memory
788
- const zip = new AdmZip();
937
+ const yearTag = selectedYear === 2 ? '2nd_Year' : '3rd_Year';
789
938
 
790
- for (const dateItem of datesToExport) {
791
- const fileName = `Upload_Attendance_${vert.sheetName}_${dateItem.dateStr}.xlsx`;
792
- const outWb = generateDigiCampusWorkbook(vert, dateItem, rows);
793
- const fileBuffer = xlsx.write(outWb, { type: 'buffer', bookType: 'xlsx' });
794
- zip.addFile(fileName, fileBuffer);
795
- }
939
+ for (const dateItem of datesToExport) {
940
+ const batchFileSuffix = batchItem.label ? `_${batchItem.label}` : '';
941
+ const fileName = `${dateItem.dateStr}_Upload_Attendance_${vert.sheetName}${batchFileSuffix}.xlsx`;
942
+ const outWb = generateExportWorkbook(vert, dateItem, rows, batchItem.students);
943
+ const fileBuffer = xlsx.write(outWb, { type: 'buffer', bookType: 'xlsx' });
944
+ zip.addFile(fileName, fileBuffer);
945
+ }
796
946
 
797
- // Save only the ZIP archive in output/
798
- const zipFileName = `${vert.sheetName}_Attendance.zip`;
799
- const zipFilePath = path.join(OUTPUT_DIR, zipFileName);
800
- zip.writeZip(zipFilePath);
947
+ const batchZipSuffix = batchItem.label ? `_${batchItem.label}` : '';
948
+ const zipBaseName = `${yearTag}_${vert.sheetName}${batchZipSuffix}_Attendance`;
949
+ const zipFileName = `${zipBaseName}.zip`;
950
+ const zipFilePath = path.join(OUTPUT_DIR, zipFileName);
951
+ zip.writeZip(zipFilePath);
801
952
 
802
- // Clean up any previously created unzipped folder for this vertical if it exists
803
- const oldVertFolder = path.join(OUTPUT_DIR, `${vert.sheetName}_Attendance`);
804
- if (fs.existsSync(oldVertFolder) && fs.statSync(oldVertFolder).isDirectory()) {
805
- fs.rmSync(oldVertFolder, { recursive: true, force: true });
806
- }
953
+ // Clean up any previously created unzipped folder for this vertical if it exists
954
+ const oldVertFolder = path.join(OUTPUT_DIR, zipBaseName);
955
+ if (fs.existsSync(oldVertFolder) && fs.statSync(oldVertFolder).isDirectory()) {
956
+ fs.rmSync(oldVertFolder, { recursive: true, force: true });
957
+ }
807
958
 
808
- const zipStat = fs.statSync(zipFilePath);
809
- const zipSizeKb = (zipStat.size / 1024).toFixed(1);
959
+ const zipStat = fs.statSync(zipFilePath);
960
+ const zipSizeKb = (zipStat.size / 1024).toFixed(1);
810
961
 
811
- logSuccess(`Generated ZIP archive containing ${datesToExport.length} DigiCampus files:`);
812
- console.log(` ${colors.bold}${colors.green}${zipFilePath}${colors.reset} (${zipSizeKb} KB)`);
962
+ logSuccess(`Generated ZIP archive containing ${datesToExport.length} export files:`);
963
+ console.log(` ${colors.bold}${colors.green}${zipFilePath}${colors.reset} (${zipSizeKb} KB)`);
964
+ }
813
965
  }
814
966
 
815
967
  // Final summary
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@mrselva/attendance",
3
- "version": "1.1.0",
4
- "description": "Extract vertical attendance data and export into DigiCampus Excel templates with ZIP archive",
3
+ "version": "1.1.3",
4
+ "description": "Extract multi-sheet attendance data and export formatted Excel sheets with ZIP archives",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "start": "node index.js"
8
8
  },
9
9
  "keywords": [
10
10
  "attendance",
11
- "digicampus",
12
11
  "excel",
12
+ "spreadsheet",
13
13
  "cli",
14
14
  "npx"
15
15
  ],
@@ -33,4 +33,4 @@
33
33
  "adm-zip": "^0.6.1",
34
34
  "xlsx": "^0.18.5"
35
35
  }
36
- }
36
+ }