@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.
- package/README.md +24 -16
- package/index.js +729 -145
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](https://nodejs.org)
|
|
6
6
|
|
|
7
|
-
> A fast and lightweight CLI tool to
|
|
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
|
-
- **
|
|
34
|
-
- **
|
|
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
|
-
- **
|
|
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
|
-
#
|
|
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
|
|
57
|
-
npx @mrselva/attendance -
|
|
59
|
+
# 2nd Year - Export Batch 2
|
|
60
|
+
npx @mrselva/attendance -y 2 -b 2 -v SHEET_CODE -a
|
|
58
61
|
|
|
59
|
-
# Export
|
|
60
|
-
npx @mrselva/attendance -v
|
|
62
|
+
# 2nd Year - Export Both Batches separately
|
|
63
|
+
npx @mrselva/attendance -y 2 -b all -v ALL -a
|
|
61
64
|
|
|
62
|
-
#
|
|
63
|
-
npx @mrselva/attendance -v
|
|
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" -
|
|
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
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
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
|
-
//
|
|
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('
|
|
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
|
|
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)
|
|
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[
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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 = '
|
|
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 =
|
|
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',
|
|
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
|
-
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
703
|
-
const
|
|
704
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
738
|
-
|
|
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
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
1342
|
+
if (datesToExport.length === 0) {
|
|
1343
|
+
logWarn(`No matching dates found to export for ${vert.sheetName}${batchTag}.`);
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
766
1346
|
|
|
767
|
-
|
|
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
|
-
|
|
771
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
792
|
-
|
|
1373
|
+
const zipStat = fs.statSync(zipFilePath);
|
|
1374
|
+
const zipSizeKb = (zipStat.size / 1024).toFixed(1);
|
|
793
1375
|
|
|
794
|
-
|
|
795
|
-
|
|
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(`
|
|
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.
|
|
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
|
+
}
|