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