@mrselva/attendance 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +118 -2
  2. package/index.js +686 -0
  3. package/package.json +28 -4
package/README.md CHANGED
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
1
+ # @mrselva/attendance
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@mrselva/attendance.svg)](https://www.npmjs.com/package/@mrselva/attendance)
4
+ [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE)
5
+ [![Node.js Version](https://img.shields.io/badge/node-%3E%3D16.0.0-brightgreen.svg)](https://nodejs.org)
6
+
7
+ > An automated CLI converter that extracts vertical-wise attendance data from KG-KITE / SoI multi-sheet attendance spreadsheets, generates DigiCampus-compliant upload workbooks, and bundles them into clean ZIP archives.
8
+
9
+ Run instantly with **`npx`** — no cloning or installation required!
10
+
11
+ ---
12
+
13
+ ## ⚡ Quick Start
14
+
15
+ Open your terminal in any folder containing your attendance spreadsheet (or an `input/` folder):
16
+
17
+ ```bash
18
+ npx @mrselva/attendance
19
+ ```
20
+
21
+ Or install globally:
22
+
23
+ ```bash
24
+ npm install -g @mrselva/attendance
25
+ attendance
26
+ ```
27
+
28
+ ---
29
+
30
+ ## ✨ Features
31
+
32
+ - **Zero Configuration**: Automatically discovers `.xlsx` or `.xls` spreadsheets in `./input` or the current working directory.
33
+ - **Interactive CLI Wizard**: Clean, color-coded terminal prompts to pick verticals and date ranges.
34
+ - **Comprehensive Vertical Support**: Detects lab vertical sheets:
35
+ - `AD`: AI & Data Science Lab
36
+ - `BW`: Blockchain and Web3.0 Lab
37
+ - `CD`: Cloud and DevOps Lab
38
+ - `CS`: Cyber Security Lab
39
+ - `DMA`: Data & Mobile Analytics Lab
40
+ - `EI`: Embedded & IoT Lab
41
+ - `FW`: Full Stack Web Lab
42
+ - `SOAL`: School of AI Learning
43
+ - `SOP`: School of Programming
44
+ - **Smart Date Filtering**:
45
+ - Export **All recorded dates** (creates one upload file per instructional date)
46
+ - Export a **Single date** (e.g. `2024-09-15`)
47
+ - Export a **Date range** (from `YYYY-MM-DD` to `YYYY-MM-DD`)
48
+ - Export the **Latest date only**
49
+ - **DigiCampus Compatibility**: Creates individual files with a `Students` sheet formatted with:
50
+ - `Email Id`
51
+ - `Registration Id`
52
+ - `Attendance *` (`PRESENT` or `ABSENT`)
53
+ - **Status Normalization**: Accurately normalizes marks (`P`, `1`, `OD`, `ELITE` → `PRESENT`; `A`, `0`, unrecorded → `ABSENT`).
54
+ - **Automatic ZIP Packaging**: Compresses generated files directly into `./output/<Vertical>_Attendance.zip`.
55
+
56
+ ---
57
+
58
+ ## 🚀 CLI Usage & Flags
59
+
60
+ You can run the interactive wizard or pass non-interactive CLI flags for automation:
61
+
62
+ ```bash
63
+ # Interactive mode
64
+ npx @mrselva/attendance
65
+
66
+ # Export ALL dates for a specific vertical
67
+ npx @mrselva/attendance -v BW -a
68
+
69
+ # Export a single date
70
+ npx @mrselva/attendance -v CD -d 2024-09-12
71
+
72
+ # Export the latest date recorded
73
+ npx @mrselva/attendance -v AD --latest
74
+
75
+ # Process ALL verticals at once
76
+ npx @mrselva/attendance -v ALL -a
77
+
78
+ # Specify a custom input spreadsheet file
79
+ npx @mrselva/attendance -f "2024-28_SoI_Attendance_Sheet.xlsx" -v FW -a
80
+ ```
81
+
82
+ ### Options Reference
83
+
84
+ | Flag | Long Flag | Description |
85
+ | :--- | :--- | :--- |
86
+ | `-v <code\|ALL>` | `--vertical <code\|ALL>` | Vertical code (e.g. `AD`, `BW`, `CD`, `CS`, `DMA`, `EI`, `FW`, or `ALL`) |
87
+ | `-a` | `--all` | Export all recorded instructional dates |
88
+ | `-d <date>` | `--date <YYYY-MM-DD>` | Export a specific date |
89
+ | | `--latest` | Export the latest instructional date |
90
+ | `-f <file>` | `--file <path>` | Specify input Excel file path or filename |
91
+ | `-h` | `--help` | Display usage and help message |
92
+
93
+ ---
94
+
95
+ ## 📁 Directory Structure
96
+
97
+ ```text
98
+ your-folder/
99
+ ├── input/ # (Optional) Place source spreadsheets here
100
+ │ └── 2024-28_SoI_Attendance.xlsx
101
+ └── output/ # Automatically generated output
102
+ ├── BW_Attendance.zip
103
+ └── CD_Attendance.zip
104
+ ```
105
+
106
+ > **Note:** If an `input/` folder is not found, the CLI will look for Excel files directly in your current working directory.
107
+
108
+ ---
109
+
110
+ ## 🛠 Requirements
111
+
112
+ - **Node.js**: `v16.0.0` or higher
113
+ - **npm**: `v7.0.0` or higher (included with Node.js)
114
+
115
+ ---
116
+
117
+ ## 📄 License
118
+
119
+ [ISC](LICENSE) &copy; [mrselva-eth](https://github.com/mrselva-eth)
package/index.js ADDED
@@ -0,0 +1,686 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Attendance to DigiCampus Converter
4
+ * Single-file Node.js CLI script
5
+ *
6
+ * Features:
7
+ * - Scans ./input for attendance spreadsheets (.xlsx / .xls)
8
+ * - Interactively prompts for vertical selection (AD, BW, CD, CS, DMA, EI, FW, etc.)
9
+ * - Allows exporting all recorded dates, specific date, range, or latest date
10
+ * - Generates individual DigiCampus-compliant Excel files ('Students' sheet with Email Id, Registration Id, Attendance *)
11
+ * - Automatically packages all generated files into a ZIP archive in ./output
12
+ * - Works both interactively (in terminal) and with optional CLI flags (--vertical, --all, etc.)
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const readline = require('readline');
18
+ const xlsx = require('xlsx');
19
+ const AdmZip = require('adm-zip');
20
+
21
+ // Paths (relative to user current working directory where npx is executed)
22
+ const INPUT_DIR = path.resolve(process.cwd(), 'input');
23
+ const OUTPUT_DIR = path.resolve(process.cwd(), 'output');
24
+
25
+ // Known vertical full names mapping
26
+ const VERTICAL_NAMES = {
27
+ 'AD': 'AI & Data Science Lab',
28
+ 'BW': 'Blockchain and Web3.0 Lab',
29
+ 'CD': 'Cloud and DevOps Lab',
30
+ 'CS': 'Cyber Security Lab',
31
+ 'DMA': 'Data & Mobile Analytics Lab',
32
+ 'EI': 'Embedded & IoT Lab',
33
+ 'FW': 'Full Stack Web Lab',
34
+ 'SOAL': 'School of AI Learning',
35
+ 'SOP': 'School of Programming'
36
+ };
37
+
38
+ // ANSI color helpers
39
+ const colors = {
40
+ reset: '\x1b[0m',
41
+ bold: '\x1b[1m',
42
+ dim: '\x1b[2m',
43
+ green: '\x1b[32m',
44
+ cyan: '\x1b[36m',
45
+ yellow: '\x1b[33m',
46
+ red: '\x1b[31m',
47
+ magenta: '\x1b[35m',
48
+ blue: '\x1b[34m'
49
+ };
50
+
51
+ function logHeader(text) {
52
+ console.log(`\n${colors.bold}${colors.cyan}=== ${text} ===${colors.reset}`);
53
+ }
54
+
55
+ function logSuccess(text) {
56
+ console.log(`${colors.green}✓ ${text}${colors.reset}`);
57
+ }
58
+
59
+ function logInfo(text) {
60
+ console.log(`${colors.blue}ℹ ${text}${colors.reset}`);
61
+ }
62
+
63
+ function logWarn(text) {
64
+ console.log(`${colors.yellow}⚠ ${text}${colors.reset}`);
65
+ }
66
+
67
+ function logError(text) {
68
+ console.log(`${colors.red}✖ ${text}${colors.reset}`);
69
+ }
70
+
71
+ // Ensure required directories exist
72
+ function ensureDirs() {
73
+ if (!fs.existsSync(INPUT_DIR)) {
74
+ fs.mkdirSync(INPUT_DIR, { recursive: true });
75
+ }
76
+ if (!fs.existsSync(OUTPUT_DIR)) {
77
+ fs.mkdirSync(OUTPUT_DIR, { recursive: true });
78
+ }
79
+ }
80
+
81
+ // Find excel files in input folder or current working directory
82
+ function findInputFiles() {
83
+ ensureDirs();
84
+ const results = [];
85
+ const seenPaths = new Set();
86
+
87
+ const isExcel = f => {
88
+ const ext = path.extname(f).toLowerCase();
89
+ return (ext === '.xlsx' || ext === '.xls') && !path.basename(f).startsWith('~$');
90
+ };
91
+
92
+ // 1. Check ./input directory first
93
+ if (fs.existsSync(INPUT_DIR)) {
94
+ try {
95
+ const files = fs.readdirSync(INPUT_DIR).filter(isExcel);
96
+ for (const f of files) {
97
+ const fullPath = path.join(INPUT_DIR, f);
98
+ if (!seenPaths.has(fullPath)) {
99
+ seenPaths.add(fullPath);
100
+ results.push({ name: f, fullPath, relativePath: path.join('input', f) });
101
+ }
102
+ }
103
+ } catch (e) {}
104
+ }
105
+
106
+ // 2. Also check current working directory
107
+ try {
108
+ const cwdFiles = fs.readdirSync(process.cwd()).filter(isExcel);
109
+ for (const f of cwdFiles) {
110
+ const fullPath = path.join(process.cwd(), f);
111
+ if (!seenPaths.has(fullPath)) {
112
+ seenPaths.add(fullPath);
113
+ results.push({ name: f, fullPath, relativePath: f });
114
+ }
115
+ }
116
+ } catch (e) {}
117
+
118
+ return results;
119
+ }
120
+
121
+ // Format date into YYYY-MM-DD
122
+ function formatDate(val, cellRaw) {
123
+ if (val === null || val === undefined) return null;
124
+
125
+ // If number (Excel date code)
126
+ if (typeof val === 'number') {
127
+ try {
128
+ const parsed = xlsx.SSF.parse_date_code(val);
129
+ if (parsed && parsed.y && parsed.m && parsed.d) {
130
+ const y = String(parsed.y).padStart(4, '0');
131
+ const m = String(parsed.m).padStart(2, '0');
132
+ const d = String(parsed.d).padStart(2, '0');
133
+ return `${y}-${m}-${d}`;
134
+ }
135
+ } catch (e) {}
136
+ }
137
+
138
+ // If Date object
139
+ if (val instanceof Date) {
140
+ const y = val.getUTCFullYear();
141
+ const m = String(val.getUTCMonth() + 1).padStart(2, '0');
142
+ const d = String(val.getUTCDate()).padStart(2, '0');
143
+ return `${y}-${m}-${d}`;
144
+ }
145
+
146
+ // If string, inspect raw formatted text
147
+ const str = String(val).trim();
148
+ const isoMatch = str.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
149
+ if (isoMatch) {
150
+ return `${isoMatch[1]}-${isoMatch[2].padStart(2, '0')}-${isoMatch[3].padStart(2, '0')}`;
151
+ }
152
+
153
+ if (cellRaw && cellRaw.w) {
154
+ const dt = new Date(cellRaw.w);
155
+ if (!isNaN(dt.getTime())) {
156
+ const y = dt.getFullYear();
157
+ const m = String(dt.getMonth() + 1).padStart(2, '0');
158
+ const d = String(dt.getDate()).padStart(2, '0');
159
+ return `${y}-${m}-${d}`;
160
+ }
161
+ }
162
+
163
+ const dt = new Date(str);
164
+ if (!isNaN(dt.getTime())) {
165
+ const y = dt.getFullYear();
166
+ const m = String(dt.getMonth() + 1).padStart(2, '0');
167
+ const d = String(dt.getDate()).padStart(2, '0');
168
+ return `${y}-${m}-${d}`;
169
+ }
170
+
171
+ return null;
172
+ }
173
+
174
+ // Normalize attendance status:
175
+ // Returns 'PRESENT', 'ABSENT', or null (if empty, holiday, remark, etc.)
176
+ function normalizeStatus(val) {
177
+ if (val === null || val === undefined) return null;
178
+ const s = String(val).trim().toUpperCase();
179
+ if (s === '') return null;
180
+
181
+ if (s === 'PRESENT' || s === 'P' || s === '1' || s === 'OD' || s === 'ELITE') {
182
+ return 'PRESENT';
183
+ }
184
+ if (s === 'ABSENT' || s === 'A' || s === '0') {
185
+ return 'ABSENT';
186
+ }
187
+
188
+ // Non-attendance values (holidays, test codes, remarks)
189
+ return null;
190
+ }
191
+
192
+ // Parse a vertical sheet to extract students and date columns
193
+ function parseVerticalSheet(ws, sheetName) {
194
+ const rows = xlsx.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null });
195
+ if (!rows || rows.length < 2) return null;
196
+
197
+ const headerRow = rows[0];
198
+
199
+ let regColIdx = -1;
200
+ let nameColIdx = -1;
201
+ let emailColIdx = -1;
202
+ let rollColIdx = -1;
203
+ let isKiteEmail = false;
204
+ const dateColumns = [];
205
+
206
+ // Analyze header row
207
+ for (let c = 0; c < headerRow.length; c++) {
208
+ const hVal = headerRow[c];
209
+ if (hVal === null || hVal === undefined) continue;
210
+
211
+ const hStr = String(hVal).trim().toLowerCase();
212
+
213
+ if (hStr.includes('register') || hStr.includes('reg no') || hStr.includes('reg.') || hStr.includes('registration id')) {
214
+ regColIdx = c;
215
+ } else if (hStr.includes('student name') || hStr === 'name') {
216
+ nameColIdx = c;
217
+ } else if (hStr.includes('kite email')) {
218
+ emailColIdx = c;
219
+ isKiteEmail = true;
220
+ } else if (!isKiteEmail && (hStr.includes('email id') || hStr.includes('email'))) {
221
+ if (!hStr.includes('soi')) {
222
+ emailColIdx = c;
223
+ }
224
+ } else if (hStr.includes('roll number') || hStr.includes('roll no')) {
225
+ rollColIdx = c;
226
+ } else {
227
+ const cellAddress = xlsx.utils.encode_cell({ r: 0, c: c });
228
+ const cellRaw = ws[cellAddress];
229
+ const dateStr = formatDate(hVal, cellRaw);
230
+ if (dateStr) {
231
+ const display = cellRaw && cellRaw.w ? cellRaw.w : dateStr;
232
+ dateColumns.push({ colIdx: c, dateStr, displayDate: display });
233
+ }
234
+ }
235
+ }
236
+
237
+ if (regColIdx === -1 || dateColumns.length === 0) {
238
+ return null; // Not a vertical attendance sheet
239
+ }
240
+
241
+ // Parse student records
242
+ const students = [];
243
+ for (let r = 1; r < rows.length; r++) {
244
+ const row = rows[r];
245
+ if (!row) continue;
246
+
247
+ const sno = row[0];
248
+ const name = nameColIdx !== -1 ? String(row[nameColIdx] || '').trim() : '';
249
+ const regNo = regColIdx !== -1 ? String(row[regColIdx] || '').trim() : '';
250
+ const email = emailColIdx !== -1 ? String(row[emailColIdx] || '').trim() : '';
251
+ const rollNo = rollColIdx !== -1 ? String(row[rollColIdx] || '').trim() : '';
252
+
253
+ // Ignore summary/totals rows at bottom
254
+ const isSummary = /no\s*\.?\s*of/i.test(name) ||
255
+ /no\s*\.?\s*of/i.test(regNo) ||
256
+ /total/i.test(name) ||
257
+ /total/i.test(regNo) ||
258
+ /present/i.test(regNo) ||
259
+ /absent/i.test(regNo);
260
+ if (isSummary) continue;
261
+
262
+ if (!name || name.length < 2) continue;
263
+ if (sno !== null && sno !== undefined && isNaN(Number(sno))) continue;
264
+ if (!regNo && !rollNo) continue;
265
+
266
+ students.push({
267
+ rowIdx: r,
268
+ sno: sno,
269
+ name: name,
270
+ regNo: regNo,
271
+ rollNo: rollNo,
272
+ email: email || (rollNo ? `${rollNo.toLowerCase()}@kgkite.ac.in` : '')
273
+ });
274
+ }
275
+
276
+ if (students.length === 0) return null;
277
+
278
+ // Filter dates to only those that have actual student attendance recorded
279
+ const instructionalDates = [];
280
+ for (const dc of dateColumns) {
281
+ let presentCount = 0;
282
+ let absentCount = 0;
283
+
284
+ for (const student of students) {
285
+ const cellVal = rows[student.rowIdx] ? rows[student.rowIdx][dc.colIdx] : null;
286
+ const norm = normalizeStatus(cellVal);
287
+ if (norm === 'PRESENT') {
288
+ presentCount++;
289
+ } else if (norm === 'ABSENT') {
290
+ absentCount++;
291
+ }
292
+ }
293
+
294
+ // Only if at least one student was marked PRESENT or ABSENT
295
+ if (presentCount + absentCount > 0) {
296
+ instructionalDates.push({
297
+ ...dc,
298
+ presentCount,
299
+ absentCount,
300
+ totalStudents: students.length
301
+ });
302
+ }
303
+ }
304
+
305
+ // Sort dates chronologically
306
+ instructionalDates.sort((a, b) => a.dateStr.localeCompare(b.dateStr));
307
+
308
+ return {
309
+ sheetName,
310
+ verticalName: VERTICAL_NAMES[sheetName] || sheetName,
311
+ students,
312
+ dates: instructionalDates,
313
+ allDateColumns: dateColumns
314
+ };
315
+ }
316
+
317
+ // Generate DigiCampus Excel workbook for a single date
318
+ function generateDigiCampusWorkbook(verticalData, dateItem, rows) {
319
+ const wb = xlsx.utils.book_new();
320
+
321
+ // DigiCampus Template Header
322
+ const wsData = [
323
+ ['Email Id', 'Registration Id', 'Attendance *']
324
+ ];
325
+
326
+ for (const student of verticalData.students) {
327
+ const rawVal = rows[student.rowIdx] ? rows[student.rowIdx][dateItem.colIdx] : null;
328
+ let status = normalizeStatus(rawVal);
329
+ // On an instructional day, any unrecorded or blank student is marked ABSENT
330
+ if (!status) {
331
+ status = 'ABSENT';
332
+ }
333
+
334
+ wsData.push([
335
+ student.email,
336
+ student.regNo,
337
+ status
338
+ ]);
339
+ }
340
+
341
+ const ws = xlsx.utils.aoa_to_sheet(wsData);
342
+
343
+ // Set column widths for clean readability
344
+ ws['!cols'] = [
345
+ { wch: 35 }, // Email Id
346
+ { wch: 20 }, // Registration Id
347
+ { wch: 15 } // Attendance *
348
+ ];
349
+
350
+ xlsx.utils.book_append_sheet(wb, ws, 'Students');
351
+ return wb;
352
+ }
353
+
354
+ // Parse Command Line Arguments (allows both CLI flags and interactive mode)
355
+ function parseArgs() {
356
+ const args = process.argv.slice(2);
357
+ const flags = {
358
+ vertical: null,
359
+ all: false,
360
+ date: null,
361
+ startDate: null,
362
+ endDate: null,
363
+ latest: false,
364
+ file: null,
365
+ help: false
366
+ };
367
+
368
+ for (let i = 0; i < args.length; i++) {
369
+ const a = args[i];
370
+ if (a === '-h' || a === '--help') {
371
+ flags.help = true;
372
+ } else if (a === '-v' || a === '--vertical') {
373
+ flags.vertical = args[++i];
374
+ } else if (a === '-a' || a === '--all' || a === '--all-dates') {
375
+ flags.all = true;
376
+ } else if (a === '-d' || a === '--date') {
377
+ flags.date = args[++i];
378
+ } else if (a === '--latest') {
379
+ flags.latest = true;
380
+ } else if (a === '-f' || a === '--file') {
381
+ flags.file = args[++i];
382
+ } else if (!a.startsWith('-') && !flags.vertical) {
383
+ flags.vertical = a;
384
+ }
385
+ }
386
+
387
+ return flags;
388
+ }
389
+
390
+ // Interactive prompt helper
391
+ async function askQuestion(lineIterator, promptText) {
392
+ process.stdout.write(promptText);
393
+ const next = await lineIterator.next();
394
+ if (next.done) return '';
395
+ return (next.value || '').trim();
396
+ }
397
+
398
+ // Main execution function
399
+ async function main() {
400
+ const flags = parseArgs();
401
+
402
+ if (flags.help) {
403
+ console.log(`
404
+ Usage: npx @mrselva/attendance [options] [vertical]
405
+ or: attendance [options] [vertical]
406
+ or: node index.js [options] [vertical]
407
+
408
+ Options:
409
+ -v, --vertical <code/name> Vertical code (e.g., AD, BW, CD, CS, DMA, EI, FW, ALL)
410
+ -a, --all Export all instructional dates automatically
411
+ -d, --date <YYYY-MM-DD> Export a single specific date
412
+ --latest Export the latest instructional date
413
+ -f, --file <filename/path> Specify input Excel file name or path
414
+ -h, --help Show this help message
415
+
416
+ Running without options launches the interactive CLI wizard.
417
+ `);
418
+ process.exit(0);
419
+ }
420
+
421
+ console.log(`\n${colors.bold}${colors.magenta}╔═════════════════════════════════════════════════════════════════════╗`);
422
+ console.log(`║ KG-KITE / SoI Attendance -> DigiCampus Exporter ║`);
423
+ console.log(`╚═════════════════════════════════════════════════════════════════════╝${colors.reset}`);
424
+
425
+ ensureDirs();
426
+
427
+ let selectedFileObj = null;
428
+
429
+ // Create readline interface for interactive questions
430
+ const rl = readline.createInterface({
431
+ input: process.stdin,
432
+ output: process.stdout,
433
+ terminal: process.stdin.isTTY
434
+ });
435
+ const lines = rl[Symbol.asyncIterator]();
436
+
437
+ if (flags.file) {
438
+ const directPath = path.resolve(process.cwd(), flags.file);
439
+ const inInputPath = path.join(INPUT_DIR, flags.file);
440
+ if (fs.existsSync(directPath) && fs.statSync(directPath).isFile()) {
441
+ selectedFileObj = { name: path.basename(directPath), fullPath: directPath, relativePath: flags.file };
442
+ } else if (fs.existsSync(inInputPath) && fs.statSync(inInputPath).isFile()) {
443
+ selectedFileObj = { name: path.basename(inInputPath), fullPath: inInputPath, relativePath: path.join('input', flags.file) };
444
+ } else {
445
+ logError(`Specified file "${flags.file}" was not found.`);
446
+ rl.close();
447
+ process.exit(1);
448
+ }
449
+ } else {
450
+ const inputFiles = findInputFiles();
451
+ if (inputFiles.length === 0) {
452
+ logError(`No Excel spreadsheets (.xlsx / .xls) found in: ${INPUT_DIR} or current directory.`);
453
+ console.log(`\n${colors.yellow}Please place your attendance sheet (e.g. 2024-28_SoI_Attendance_Sheet.xlsx) in the 'input' folder or current directory and run this script again.${colors.reset}\n`);
454
+ rl.close();
455
+ process.exit(1);
456
+ }
457
+
458
+ if (inputFiles.length > 1) {
459
+ logHeader('Select Input Attendance File');
460
+ inputFiles.forEach((fileObj, idx) => {
461
+ console.log(` [${colors.bold}${idx + 1}${colors.reset}] ${fileObj.relativePath}`);
462
+ });
463
+ const ans = await askQuestion(lines, `\nSelect file [1-${inputFiles.length}] (default: 1): `);
464
+ const num = parseInt(ans.trim(), 10);
465
+ if (!isNaN(num) && num >= 1 && num <= inputFiles.length) {
466
+ selectedFileObj = inputFiles[num - 1];
467
+ } else {
468
+ selectedFileObj = inputFiles[0];
469
+ }
470
+ } else {
471
+ selectedFileObj = inputFiles[0];
472
+ }
473
+ }
474
+
475
+ const inputFilePath = selectedFileObj.fullPath;
476
+ logInfo(`Reading attendance workbook: ${colors.bold}${selectedFileObj.relativePath || selectedFileObj.name}${colors.reset}`);
477
+
478
+ let workbook;
479
+ try {
480
+ workbook = xlsx.readFile(inputFilePath, { cellDates: false });
481
+ } catch (err) {
482
+ logError(`Failed to read Excel file: ${err.message}`);
483
+ rl.close();
484
+ process.exit(1);
485
+ }
486
+
487
+ // Discover all vertical sheets
488
+ const verticalSheets = [];
489
+ const excludedSheets = ['pivot table', 'summary', 'template', 'students data'];
490
+
491
+ for (const sheetName of workbook.SheetNames) {
492
+ if (excludedSheets.some(ex => sheetName.toLowerCase().includes(ex))) {
493
+ continue;
494
+ }
495
+ const ws = workbook.Sheets[sheetName];
496
+ const parsed = parseVerticalSheet(ws, sheetName);
497
+ if (parsed) {
498
+ verticalSheets.push(parsed);
499
+ }
500
+ }
501
+
502
+ if (verticalSheets.length === 0) {
503
+ logError('No valid vertical attendance sheets with students and date columns were found.');
504
+ rl.close();
505
+ process.exit(1);
506
+ }
507
+
508
+ // Vertical Selection
509
+ let chosenVertical = null;
510
+ let processAll = false;
511
+
512
+ if (flags.vertical) {
513
+ const vArg = flags.vertical.toUpperCase().trim();
514
+ if (vArg === 'ALL' || vArg === 'A') {
515
+ processAll = true;
516
+ } else {
517
+ chosenVertical = verticalSheets.find(v =>
518
+ v.sheetName.toUpperCase() === vArg ||
519
+ v.verticalName.toUpperCase().includes(vArg)
520
+ );
521
+ if (!chosenVertical) {
522
+ logWarn(`Vertical "${flags.vertical}" not found. Falling back to interactive selection.`);
523
+ }
524
+ }
525
+ }
526
+
527
+ // Interactive selection if not resolved from flags
528
+ if (!chosenVertical && !processAll) {
529
+ logHeader('Select Vertical');
530
+ console.log('Available verticals found in the spreadsheet:\n');
531
+
532
+ verticalSheets.forEach((v, idx) => {
533
+ const code = colors.bold + v.sheetName.padEnd(5) + colors.reset;
534
+ const name = (v.verticalName !== v.sheetName ? `(${v.verticalName})` : '').padEnd(35);
535
+ const countInfo = `${colors.dim}${v.students.length} students, ${v.dates.length} dates recorded${colors.reset}`;
536
+ console.log(` [${colors.bold}${idx + 1}${colors.reset}] ${code} ${name} ${countInfo}`);
537
+ });
538
+ console.log(` [${colors.bold}A${colors.reset}] ${colors.bold}ALL ${colors.reset} (Process ALL verticals at once)`);
539
+
540
+ while (!chosenVertical && !processAll) {
541
+ const ans = (await askQuestion(lines, `\nEnter vertical number or code [1-${verticalSheets.length} / Code]: `)).trim();
542
+
543
+ if (ans.toUpperCase() === 'A' || ans.toUpperCase() === 'ALL') {
544
+ processAll = true;
545
+ break;
546
+ }
547
+
548
+ const num = parseInt(ans, 10);
549
+ if (!isNaN(num) && num >= 1 && num <= verticalSheets.length) {
550
+ chosenVertical = verticalSheets[num - 1];
551
+ break;
552
+ }
553
+
554
+ const matched = verticalSheets.find(v =>
555
+ v.sheetName.toLowerCase() === ans.toLowerCase() ||
556
+ v.verticalName.toLowerCase().includes(ans.toLowerCase())
557
+ );
558
+ if (matched) {
559
+ chosenVertical = matched;
560
+ break;
561
+ }
562
+
563
+ logWarn(`Invalid selection "${ans}". Please enter 1-${verticalSheets.length} or the vertical code.`);
564
+ }
565
+ }
566
+
567
+ const targets = processAll ? verticalSheets : [chosenVertical];
568
+
569
+ // Date selection logic
570
+ let dateFilterMode = 'ALL';
571
+ let singleDateVal = flags.date || null;
572
+ let dateRangeVal = null;
573
+
574
+ if (flags.all) {
575
+ dateFilterMode = 'ALL';
576
+ } else if (flags.latest) {
577
+ dateFilterMode = 'LATEST';
578
+ } else if (flags.date) {
579
+ dateFilterMode = 'SINGLE';
580
+ } else if (!processAll) {
581
+ const totalDates = chosenVertical.dates.length;
582
+ const firstDate = totalDates > 0 ? chosenVertical.dates[0].dateStr : 'N/A';
583
+ const lastDate = totalDates > 0 ? chosenVertical.dates[totalDates - 1].dateStr : 'N/A';
584
+
585
+ logHeader(`Date Selection for Vertical: ${chosenVertical.sheetName}`);
586
+ console.log(`Found ${colors.bold}${totalDates}${colors.reset} instructional dates (${firstDate} to ${lastDate}).\n`);
587
+ console.log(` [${colors.bold}1${colors.reset}] Export ${colors.green}ALL ${totalDates} dates${colors.reset} (Recommended - generates upload files for each date)`);
588
+ console.log(` [${colors.bold}2${colors.reset}] Export a ${colors.cyan}single specific date${colors.reset} (e.g., ${lastDate})`);
589
+ console.log(` [${colors.bold}3${colors.reset}] Export a ${colors.yellow}date range${colors.reset} (from YYYY-MM-DD to YYYY-MM-DD)`);
590
+ console.log(` [${colors.bold}4${colors.reset}] Export the ${colors.magenta}latest date only${colors.reset} (${lastDate})`);
591
+
592
+ const dateChoice = (await askQuestion(lines, `\nChoose export option [1-4] (default: 1): `)).trim();
593
+
594
+ if (dateChoice === '2') {
595
+ dateFilterMode = 'SINGLE';
596
+ const dAns = (await askQuestion(lines, `Enter date (YYYY-MM-DD) [default: ${lastDate}]: `)).trim();
597
+ singleDateVal = dAns || lastDate;
598
+ } else if (dateChoice === '3') {
599
+ dateFilterMode = 'RANGE';
600
+ const startAns = (await askQuestion(lines, `Enter start date (YYYY-MM-DD) [default: ${firstDate}]: `)).trim() || firstDate;
601
+ const endAns = (await askQuestion(lines, `Enter end date (YYYY-MM-DD) [default: ${lastDate}]: `)).trim() || lastDate;
602
+ dateRangeVal = { start: startAns, end: endAns };
603
+ } else if (dateChoice === '4') {
604
+ dateFilterMode = 'LATEST';
605
+ } else {
606
+ dateFilterMode = 'ALL';
607
+ }
608
+ }
609
+
610
+ rl.close();
611
+
612
+ // Process selected verticals
613
+ logHeader('Processing & Generating DigiCampus Files');
614
+
615
+ for (const vert of targets) {
616
+ console.log(`\n${colors.bold}Vertical: ${vert.sheetName} - ${vert.verticalName}${colors.reset}`);
617
+ console.log(` Students: ${colors.bold}${vert.students.length}${colors.reset}`);
618
+
619
+ let datesToExport = [...vert.dates];
620
+
621
+ if (dateFilterMode === 'SINGLE' && singleDateVal) {
622
+ datesToExport = datesToExport.filter(d => d.dateStr === singleDateVal);
623
+ if (datesToExport.length === 0) {
624
+ logWarn(`Date ${singleDateVal} was not found among instructional dates for ${vert.sheetName}.`);
625
+ const fallbackCol = vert.allDateColumns.find(d => d.dateStr === singleDateVal);
626
+ if (fallbackCol) {
627
+ datesToExport = [{ ...fallbackCol, presentCount: 0, absentCount: 0, totalStudents: vert.students.length }];
628
+ } else {
629
+ logError(`Date ${singleDateVal} does not exist in the spreadsheet!`);
630
+ continue;
631
+ }
632
+ }
633
+ } else if (dateFilterMode === 'RANGE' && dateRangeVal) {
634
+ datesToExport = datesToExport.filter(d => d.dateStr >= dateRangeVal.start && d.dateStr <= dateRangeVal.end);
635
+ } else if (dateFilterMode === 'LATEST' && datesToExport.length > 0) {
636
+ datesToExport = [datesToExport[datesToExport.length - 1]];
637
+ }
638
+
639
+ if (datesToExport.length === 0) {
640
+ logWarn(`No matching dates found to export for ${vert.sheetName}.`);
641
+ continue;
642
+ }
643
+
644
+ console.log(` Dates to export: ${colors.bold}${datesToExport.length}${colors.reset}`);
645
+
646
+ const ws = workbook.Sheets[vert.sheetName];
647
+ const rows = xlsx.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null });
648
+
649
+ // Build ZIP archive directly in memory
650
+ const zip = new AdmZip();
651
+
652
+ for (const dateItem of datesToExport) {
653
+ const fileName = `Upload_Attendance_${vert.sheetName}_${dateItem.dateStr}.xlsx`;
654
+ const outWb = generateDigiCampusWorkbook(vert, dateItem, rows);
655
+ const fileBuffer = xlsx.write(outWb, { type: 'buffer', bookType: 'xlsx' });
656
+ zip.addFile(fileName, fileBuffer);
657
+ }
658
+
659
+ // Save only the ZIP archive in output/
660
+ const zipFileName = `${vert.sheetName}_Attendance.zip`;
661
+ const zipFilePath = path.join(OUTPUT_DIR, zipFileName);
662
+ zip.writeZip(zipFilePath);
663
+
664
+ // Clean up any previously created unzipped folder for this vertical if it exists
665
+ const oldVertFolder = path.join(OUTPUT_DIR, `${vert.sheetName}_Attendance`);
666
+ if (fs.existsSync(oldVertFolder) && fs.statSync(oldVertFolder).isDirectory()) {
667
+ fs.rmSync(oldVertFolder, { recursive: true, force: true });
668
+ }
669
+
670
+ const zipStat = fs.statSync(zipFilePath);
671
+ const zipSizeKb = (zipStat.size / 1024).toFixed(1);
672
+
673
+ logSuccess(`Generated ZIP archive containing ${datesToExport.length} DigiCampus files:`);
674
+ console.log(` ${colors.bold}${colors.green}${zipFilePath}${colors.reset} (${zipSizeKb} KB)`);
675
+ }
676
+
677
+ // Final summary
678
+ logHeader('Export Complete');
679
+ console.log(`Check the ${colors.bold}${colors.cyan}./output${colors.reset} directory for all generated files and ZIP archives.\n`);
680
+ }
681
+
682
+ main().catch(err => {
683
+ logError(`An unexpected error occurred: ${err.message}`);
684
+ console.error(err);
685
+ process.exit(1);
686
+ });
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@mrselva/attendance",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Extract vertical attendance data and export into DigiCampus Excel templates with ZIP archive",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "start": "node index.js"
8
+ },
9
+ "keywords": ["attendance", "digicampus", "excel", "cli", "npx"],
10
+ "author": "mrselva",
11
+ "license": "ISC",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "bin": {
16
+ "attendance": "./index.js"
17
+ },
18
+ "files": [
19
+ "index.js",
20
+ "README.md"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/mrselva-eth/attendance.git"
25
+ },
26
+ "dependencies": {
27
+ "adm-zip": "^0.6.1",
28
+ "xlsx": "^0.18.5"
29
+ }
30
+ }