@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.
- package/README.md +31 -46
- package/index.js +286 -134
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](https://nodejs.org)
|
|
6
6
|
|
|
7
|
-
>
|
|
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
|
|
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
|
|
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
|
|
33
|
-
- **
|
|
34
|
-
- **
|
|
35
|
-
-
|
|
36
|
-
|
|
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**
|
|
46
|
-
- Export a **Single date** (e.g. `
|
|
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
|
-
- **
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
67
|
-
npx @mrselva/attendance -v
|
|
55
|
+
# 2nd Year - Export Batch 1
|
|
56
|
+
npx @mrselva/attendance -y 2 -b 1 -v SHEET_CODE -a
|
|
68
57
|
|
|
69
|
-
# Export
|
|
70
|
-
npx @mrselva/attendance -
|
|
58
|
+
# 2nd Year - Export Batch 2
|
|
59
|
+
npx @mrselva/attendance -y 2 -b 2 -v SHEET_CODE -a
|
|
71
60
|
|
|
72
|
-
# Export
|
|
73
|
-
npx @mrselva/attendance -v
|
|
61
|
+
# 2nd Year - Export Both Batches separately
|
|
62
|
+
npx @mrselva/attendance -y 2 -b all -v ALL -a
|
|
74
63
|
|
|
75
|
-
#
|
|
76
|
-
npx @mrselva/attendance -v
|
|
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 "
|
|
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
|
-
| `-
|
|
87
|
-
| `-
|
|
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
|
|
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/ #
|
|
100
|
-
|
|
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) © [mrselva
|
|
104
|
+
[ISC](LICENSE) © [mrselva](https://github.com/mrselva)
|
package/index.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Attendance
|
|
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
|
|
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
|
|
11
|
-
* -
|
|
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
|
-
//
|
|
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
|
-
|
|
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('
|
|
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
|
|
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)
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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 ||
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (students.length === 0) return null;
|
|
320
|
+
email: email || '',
|
|
321
|
+
batch: currentBatch
|
|
322
|
+
};
|
|
278
323
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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
|
|
319
|
-
function
|
|
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
|
-
//
|
|
357
|
+
// Export Sheet Header
|
|
323
358
|
const wsData = [
|
|
324
359
|
['Email Id', 'Registration Id', 'Attendance *']
|
|
325
360
|
];
|
|
326
361
|
|
|
327
|
-
|
|
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
|
-
-
|
|
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(`║
|
|
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
|
-
|
|
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
|
|
720
|
-
const
|
|
721
|
-
const
|
|
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
|
-
|
|
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
|
|
876
|
+
logHeader('Processing & Generating Export Files');
|
|
752
877
|
|
|
753
878
|
for (const vert of targets) {
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
927
|
+
if (datesToExport.length === 0) {
|
|
928
|
+
logWarn(`No matching dates found to export for ${vert.sheetName}${batchTag}.`);
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
781
931
|
|
|
782
|
-
|
|
932
|
+
console.log(` Dates to export: ${colors.bold}${datesToExport.length}${colors.reset}`);
|
|
783
933
|
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
|
|
788
|
-
const zip = new AdmZip();
|
|
937
|
+
const yearTag = selectedYear === 2 ? '2nd_Year' : '3rd_Year';
|
|
789
938
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
809
|
-
|
|
959
|
+
const zipStat = fs.statSync(zipFilePath);
|
|
960
|
+
const zipSizeKb = (zipStat.size / 1024).toFixed(1);
|
|
810
961
|
|
|
811
|
-
|
|
812
|
-
|
|
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.
|
|
4
|
-
"description": "Extract
|
|
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
|
+
}
|