@mrselva/attendance 0.0.0-stage → 1.1.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 +824 -0
  3. package/package.json +34 -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 & Auto-Import**: If no file is in `./input/`, an OS file picker dialog pops up so you can simply click your spreadsheet (from Downloads or anywhere), and it automatically imports it into `./input/`! Drag & drop file paths into the terminal is also supported.
33
+ - **Interactive CLI Wizard**: Clean, color-coded terminal prompts to pick verticals and date ranges.
34
+ - **Comprehensive Vertical Support**: Detects lab vertical sheets:
35
+ - `AD`: AI & Data Science Lab
36
+ - `BW`: Blockchain and Web3.0 Lab
37
+ - `CD`: Cloud and DevOps Lab
38
+ - `CS`: Cyber Security Lab
39
+ - `DMA`: Data & Mobile Analytics Lab
40
+ - `EI`: Embedded & IoT Lab
41
+ - `FW`: Full Stack Web Lab
42
+ - `SOAL`: School of AI Learning
43
+ - `SOP`: School of Programming
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,824 @@
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 { execFileSync } = require('child_process');
19
+ const xlsx = require('xlsx');
20
+ const AdmZip = require('adm-zip');
21
+
22
+ // Paths (relative to user current working directory where npx is executed)
23
+ const INPUT_DIR = path.resolve(process.cwd(), 'input');
24
+ const OUTPUT_DIR = path.resolve(process.cwd(), 'output');
25
+
26
+ // Known vertical full names mapping
27
+ const VERTICAL_NAMES = {
28
+ 'AD': 'AI & Data Science Lab',
29
+ 'BW': 'Blockchain and Web3.0 Lab',
30
+ 'CD': 'Cloud and DevOps Lab',
31
+ 'CS': 'Cyber Security Lab',
32
+ 'DMA': 'Data & Mobile Analytics Lab',
33
+ 'EI': 'Embedded & IoT Lab',
34
+ 'FW': 'Full Stack Web Lab',
35
+ 'SOAL': 'School of AI Learning',
36
+ 'SOP': 'School of Programming'
37
+ };
38
+
39
+ // ANSI color helpers
40
+ const colors = {
41
+ reset: '\x1b[0m',
42
+ bold: '\x1b[1m',
43
+ dim: '\x1b[2m',
44
+ green: '\x1b[32m',
45
+ cyan: '\x1b[36m',
46
+ yellow: '\x1b[33m',
47
+ red: '\x1b[31m',
48
+ magenta: '\x1b[35m',
49
+ blue: '\x1b[34m'
50
+ };
51
+
52
+ function logHeader(text) {
53
+ console.log(`\n${colors.bold}${colors.cyan}=== ${text} ===${colors.reset}`);
54
+ }
55
+
56
+ function logSuccess(text) {
57
+ console.log(`${colors.green}✓ ${text}${colors.reset}`);
58
+ }
59
+
60
+ function logInfo(text) {
61
+ console.log(`${colors.blue}ℹ ${text}${colors.reset}`);
62
+ }
63
+
64
+ function logWarn(text) {
65
+ console.log(`${colors.yellow}⚠ ${text}${colors.reset}`);
66
+ }
67
+
68
+ function logError(text) {
69
+ console.log(`${colors.red}✖ ${text}${colors.reset}`);
70
+ }
71
+
72
+ // Ensure required directories exist
73
+ function ensureDirs() {
74
+ if (!fs.existsSync(INPUT_DIR)) {
75
+ fs.mkdirSync(INPUT_DIR, { recursive: true });
76
+ }
77
+ if (!fs.existsSync(OUTPUT_DIR)) {
78
+ fs.mkdirSync(OUTPUT_DIR, { recursive: true });
79
+ }
80
+ }
81
+
82
+ // Find excel files in input folder or current working directory
83
+ function findInputFiles() {
84
+ ensureDirs();
85
+ const results = [];
86
+ const seenPaths = new Set();
87
+
88
+ const isExcel = f => {
89
+ const ext = path.extname(f).toLowerCase();
90
+ return (ext === '.xlsx' || ext === '.xls') && !path.basename(f).startsWith('~$');
91
+ };
92
+
93
+ // 1. Check ./input directory first
94
+ if (fs.existsSync(INPUT_DIR)) {
95
+ try {
96
+ const files = fs.readdirSync(INPUT_DIR).filter(isExcel);
97
+ for (const f of files) {
98
+ const fullPath = path.join(INPUT_DIR, f);
99
+ if (!seenPaths.has(fullPath)) {
100
+ seenPaths.add(fullPath);
101
+ results.push({ name: f, fullPath, relativePath: path.join('input', f) });
102
+ }
103
+ }
104
+ } catch (e) {}
105
+ }
106
+
107
+ // 2. Also check current working directory
108
+ try {
109
+ const cwdFiles = fs.readdirSync(process.cwd()).filter(isExcel);
110
+ for (const f of cwdFiles) {
111
+ const fullPath = path.join(process.cwd(), f);
112
+ if (!seenPaths.has(fullPath)) {
113
+ seenPaths.add(fullPath);
114
+ results.push({ name: f, fullPath, relativePath: f });
115
+ }
116
+ }
117
+ } catch (e) {}
118
+
119
+ return results;
120
+ }
121
+
122
+ // Format date into YYYY-MM-DD
123
+ function formatDate(val, cellRaw) {
124
+ if (val === null || val === undefined) return null;
125
+
126
+ // If number (Excel date code)
127
+ if (typeof val === 'number') {
128
+ try {
129
+ const parsed = xlsx.SSF.parse_date_code(val);
130
+ if (parsed && parsed.y && parsed.m && parsed.d) {
131
+ const y = String(parsed.y).padStart(4, '0');
132
+ const m = String(parsed.m).padStart(2, '0');
133
+ const d = String(parsed.d).padStart(2, '0');
134
+ return `${y}-${m}-${d}`;
135
+ }
136
+ } catch (e) {}
137
+ }
138
+
139
+ // If Date object
140
+ if (val instanceof Date) {
141
+ const y = val.getUTCFullYear();
142
+ const m = String(val.getUTCMonth() + 1).padStart(2, '0');
143
+ const d = String(val.getUTCDate()).padStart(2, '0');
144
+ return `${y}-${m}-${d}`;
145
+ }
146
+
147
+ // If string, inspect raw formatted text
148
+ const str = String(val).trim();
149
+ const isoMatch = str.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
150
+ if (isoMatch) {
151
+ return `${isoMatch[1]}-${isoMatch[2].padStart(2, '0')}-${isoMatch[3].padStart(2, '0')}`;
152
+ }
153
+
154
+ if (cellRaw && cellRaw.w) {
155
+ const dt = new Date(cellRaw.w);
156
+ if (!isNaN(dt.getTime())) {
157
+ const y = dt.getFullYear();
158
+ const m = String(dt.getMonth() + 1).padStart(2, '0');
159
+ const d = String(dt.getDate()).padStart(2, '0');
160
+ return `${y}-${m}-${d}`;
161
+ }
162
+ }
163
+
164
+ const dt = new Date(str);
165
+ if (!isNaN(dt.getTime())) {
166
+ const y = dt.getFullYear();
167
+ const m = String(dt.getMonth() + 1).padStart(2, '0');
168
+ const d = String(dt.getDate()).padStart(2, '0');
169
+ return `${y}-${m}-${d}`;
170
+ }
171
+
172
+ return null;
173
+ }
174
+
175
+ // Normalize attendance status:
176
+ // Returns 'PRESENT', 'ABSENT', or null (if empty, holiday, remark, etc.)
177
+ function normalizeStatus(val) {
178
+ if (val === null || val === undefined) return null;
179
+ const s = String(val).trim().toUpperCase();
180
+ if (s === '') return null;
181
+
182
+ if (s === 'PRESENT' || s === 'P' || s === '1' || s === 'OD' || s === 'ELITE') {
183
+ return 'PRESENT';
184
+ }
185
+ if (s === 'ABSENT' || s === 'A' || s === '0') {
186
+ return 'ABSENT';
187
+ }
188
+
189
+ // Non-attendance values (holidays, test codes, remarks)
190
+ return null;
191
+ }
192
+
193
+ // Parse a vertical sheet to extract students and date columns
194
+ function parseVerticalSheet(ws, sheetName) {
195
+ const rows = xlsx.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null });
196
+ if (!rows || rows.length < 2) return null;
197
+
198
+ const headerRow = rows[0];
199
+
200
+ let regColIdx = -1;
201
+ let nameColIdx = -1;
202
+ let emailColIdx = -1;
203
+ let rollColIdx = -1;
204
+ let isKiteEmail = false;
205
+ const dateColumns = [];
206
+
207
+ // Analyze header row
208
+ for (let c = 0; c < headerRow.length; c++) {
209
+ const hVal = headerRow[c];
210
+ if (hVal === null || hVal === undefined) continue;
211
+
212
+ const hStr = String(hVal).trim().toLowerCase();
213
+
214
+ if (hStr.includes('register') || hStr.includes('reg no') || hStr.includes('reg.') || hStr.includes('registration id')) {
215
+ regColIdx = c;
216
+ } else if (hStr.includes('student name') || hStr === 'name') {
217
+ nameColIdx = c;
218
+ } else if (hStr.includes('kite email')) {
219
+ emailColIdx = c;
220
+ isKiteEmail = true;
221
+ } else if (!isKiteEmail && (hStr.includes('email id') || hStr.includes('email'))) {
222
+ if (!hStr.includes('soi')) {
223
+ emailColIdx = c;
224
+ }
225
+ } else if (hStr.includes('roll number') || hStr.includes('roll no')) {
226
+ rollColIdx = c;
227
+ } else {
228
+ const cellAddress = xlsx.utils.encode_cell({ r: 0, c: c });
229
+ const cellRaw = ws[cellAddress];
230
+ const dateStr = formatDate(hVal, cellRaw);
231
+ if (dateStr) {
232
+ const display = cellRaw && cellRaw.w ? cellRaw.w : dateStr;
233
+ dateColumns.push({ colIdx: c, dateStr, displayDate: display });
234
+ }
235
+ }
236
+ }
237
+
238
+ if (regColIdx === -1 || dateColumns.length === 0) {
239
+ return null; // Not a vertical attendance sheet
240
+ }
241
+
242
+ // Parse student records
243
+ const students = [];
244
+ for (let r = 1; r < rows.length; r++) {
245
+ const row = rows[r];
246
+ if (!row) continue;
247
+
248
+ const sno = row[0];
249
+ const name = nameColIdx !== -1 ? String(row[nameColIdx] || '').trim() : '';
250
+ const regNo = regColIdx !== -1 ? String(row[regColIdx] || '').trim() : '';
251
+ const email = emailColIdx !== -1 ? String(row[emailColIdx] || '').trim() : '';
252
+ const rollNo = rollColIdx !== -1 ? String(row[rollColIdx] || '').trim() : '';
253
+
254
+ // Ignore summary/totals rows at bottom
255
+ const isSummary = /no\s*\.?\s*of/i.test(name) ||
256
+ /no\s*\.?\s*of/i.test(regNo) ||
257
+ /total/i.test(name) ||
258
+ /total/i.test(regNo) ||
259
+ /present/i.test(regNo) ||
260
+ /absent/i.test(regNo);
261
+ if (isSummary) continue;
262
+
263
+ if (!name || name.length < 2) continue;
264
+ if (sno !== null && sno !== undefined && isNaN(Number(sno))) continue;
265
+ if (!regNo && !rollNo) continue;
266
+
267
+ students.push({
268
+ rowIdx: r,
269
+ sno: sno,
270
+ name: name,
271
+ regNo: regNo,
272
+ rollNo: rollNo,
273
+ email: email || (rollNo ? `${rollNo.toLowerCase()}@kgkite.ac.in` : '')
274
+ });
275
+ }
276
+
277
+ if (students.length === 0) return null;
278
+
279
+ // Filter dates to only those that have actual student attendance recorded
280
+ const instructionalDates = [];
281
+ for (const dc of dateColumns) {
282
+ let presentCount = 0;
283
+ let absentCount = 0;
284
+
285
+ for (const student of students) {
286
+ const cellVal = rows[student.rowIdx] ? rows[student.rowIdx][dc.colIdx] : null;
287
+ const norm = normalizeStatus(cellVal);
288
+ if (norm === 'PRESENT') {
289
+ presentCount++;
290
+ } else if (norm === 'ABSENT') {
291
+ absentCount++;
292
+ }
293
+ }
294
+
295
+ // Only if at least one student was marked PRESENT or ABSENT
296
+ if (presentCount + absentCount > 0) {
297
+ instructionalDates.push({
298
+ ...dc,
299
+ presentCount,
300
+ absentCount,
301
+ totalStudents: students.length
302
+ });
303
+ }
304
+ }
305
+
306
+ // Sort dates chronologically
307
+ instructionalDates.sort((a, b) => a.dateStr.localeCompare(b.dateStr));
308
+
309
+ return {
310
+ sheetName,
311
+ verticalName: VERTICAL_NAMES[sheetName] || sheetName,
312
+ students,
313
+ dates: instructionalDates,
314
+ allDateColumns: dateColumns
315
+ };
316
+ }
317
+
318
+ // Generate DigiCampus Excel workbook for a single date
319
+ function generateDigiCampusWorkbook(verticalData, dateItem, rows) {
320
+ const wb = xlsx.utils.book_new();
321
+
322
+ // DigiCampus Template Header
323
+ const wsData = [
324
+ ['Email Id', 'Registration Id', 'Attendance *']
325
+ ];
326
+
327
+ for (const student of verticalData.students) {
328
+ const rawVal = rows[student.rowIdx] ? rows[student.rowIdx][dateItem.colIdx] : null;
329
+ let status = normalizeStatus(rawVal);
330
+ // On an instructional day, any unrecorded or blank student is marked ABSENT
331
+ if (!status) {
332
+ status = 'ABSENT';
333
+ }
334
+
335
+ wsData.push([
336
+ student.email,
337
+ student.regNo,
338
+ status
339
+ ]);
340
+ }
341
+
342
+ const ws = xlsx.utils.aoa_to_sheet(wsData);
343
+
344
+ // Set column widths for clean readability
345
+ ws['!cols'] = [
346
+ { wch: 35 }, // Email Id
347
+ { wch: 20 }, // Registration Id
348
+ { wch: 15 } // Attendance *
349
+ ];
350
+
351
+ xlsx.utils.book_append_sheet(wb, ws, 'Students');
352
+ return wb;
353
+ }
354
+
355
+ // Parse Command Line Arguments (allows both CLI flags and interactive mode)
356
+ function parseArgs() {
357
+ const args = process.argv.slice(2);
358
+ const flags = {
359
+ vertical: null,
360
+ all: false,
361
+ date: null,
362
+ startDate: null,
363
+ endDate: null,
364
+ latest: false,
365
+ file: null,
366
+ help: false
367
+ };
368
+
369
+ for (let i = 0; i < args.length; i++) {
370
+ const a = args[i];
371
+ if (a === '-h' || a === '--help') {
372
+ flags.help = true;
373
+ } else if (a === '-v' || a === '--vertical') {
374
+ flags.vertical = args[++i];
375
+ } else if (a === '-a' || a === '--all' || a === '--all-dates') {
376
+ flags.all = true;
377
+ } else if (a === '-d' || a === '--date') {
378
+ flags.date = args[++i];
379
+ } else if (a === '--latest') {
380
+ flags.latest = true;
381
+ } else if (a === '-f' || a === '--file') {
382
+ flags.file = args[++i];
383
+ } else if (!a.startsWith('-') && !flags.vertical) {
384
+ flags.vertical = a;
385
+ }
386
+ }
387
+
388
+ return flags;
389
+ }
390
+
391
+ // Clean user input file path (strip quotes and shell prefixes)
392
+ function cleanPath(input) {
393
+ if (!input) return '';
394
+ let str = input.trim();
395
+ if (str.startsWith('&')) {
396
+ str = str.slice(1).trim();
397
+ }
398
+ if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) {
399
+ str = str.slice(1, -1).trim();
400
+ }
401
+ return str;
402
+ }
403
+
404
+ // Open native OS file picker dialog
405
+ function openFileDialog() {
406
+ const platform = process.platform;
407
+ try {
408
+ if (platform === 'win32') {
409
+ const psScript = `
410
+ Add-Type -AssemblyName System.Windows.Forms
411
+ $dlg = New-Object System.Windows.Forms.OpenFileDialog
412
+ $dlg.Filter = 'Excel Files (*.xlsx;*.xls)|*.xlsx;*.xls|All Files (*.*)|*.*'
413
+ $dlg.Title = 'Select Attendance Spreadsheet'
414
+ $dlg.RestoreDirectory = $true
415
+ $result = $dlg.ShowDialog()
416
+ if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
417
+ Write-Output $dlg.FileName
418
+ }
419
+ `;
420
+ const output = execFileSync('powershell.exe', ['-NoProfile', '-STA', '-Command', psScript], {
421
+ encoding: 'utf8',
422
+ stdio: ['ignore', 'pipe', 'ignore'],
423
+ timeout: 180000
424
+ });
425
+ return output ? output.trim() : null;
426
+ } else if (platform === 'darwin') {
427
+ const appleScript = 'POSIX path of (choose file with prompt "Select Attendance Spreadsheet" of type {"xlsx", "xls"})';
428
+ const output = execFileSync('osascript', ['-e', appleScript], {
429
+ encoding: 'utf8',
430
+ stdio: ['ignore', 'pipe', 'ignore']
431
+ });
432
+ return output ? output.trim() : null;
433
+ } else {
434
+ const output = execFileSync('zenity', ['--file-selection', '--file-filter=Excel files | *.xlsx *.xls', '--title=Select Attendance Spreadsheet'], {
435
+ encoding: 'utf8',
436
+ stdio: ['ignore', 'pipe', 'ignore']
437
+ });
438
+ return output ? output.trim() : null;
439
+ }
440
+ } catch (e) {
441
+ return null;
442
+ }
443
+ }
444
+
445
+ // Copy / import a selected spreadsheet into ./input/
446
+ function importFileToInput(srcPath) {
447
+ ensureDirs();
448
+ const fileName = path.basename(srcPath);
449
+ const destPath = path.join(INPUT_DIR, fileName);
450
+
451
+ if (path.resolve(srcPath) !== path.resolve(destPath)) {
452
+ try {
453
+ fs.copyFileSync(srcPath, destPath);
454
+ logSuccess(`Imported "${fileName}" into ./input/ folder!`);
455
+ return {
456
+ name: fileName,
457
+ fullPath: destPath,
458
+ relativePath: path.join('input', fileName)
459
+ };
460
+ } catch (err) {
461
+ logWarn(`Could not copy into ./input/ (${err.message}). Reading from original location.`);
462
+ return {
463
+ name: fileName,
464
+ fullPath: srcPath,
465
+ relativePath: srcPath
466
+ };
467
+ }
468
+ }
469
+
470
+ return {
471
+ name: fileName,
472
+ fullPath: destPath,
473
+ relativePath: path.relative(process.cwd(), destPath) || path.join('input', fileName)
474
+ };
475
+ }
476
+
477
+ // Interactive prompt for file selection when needed
478
+ async function promptForFile(lines) {
479
+ logHeader('Select Attendance Spreadsheet');
480
+ console.log('No attendance spreadsheet selected yet.');
481
+ console.log('Please choose how you want to select your Excel file (.xlsx / .xls):\n');
482
+ console.log(` [${colors.bold}1${colors.reset}] ${colors.green}Browse file${colors.reset} (Opens file picker window to click your file) [Default]`);
483
+ console.log(` [${colors.bold}2${colors.reset}] ${colors.cyan}Enter or drag & drop file path here${colors.reset}`);
484
+
485
+ while (true) {
486
+ const choice = (await askQuestion(lines, `\nChoose option [1 or 2] (default: 1): `)).trim();
487
+
488
+ if (choice === '2') {
489
+ const manual = await askQuestion(lines, 'Enter or drag & drop file path: ');
490
+ const cleaned = cleanPath(manual);
491
+ if (cleaned && fs.existsSync(cleaned) && fs.statSync(cleaned).isFile()) {
492
+ const ext = path.extname(cleaned).toLowerCase();
493
+ if (ext === '.xlsx' || ext === '.xls') {
494
+ return cleaned;
495
+ } else {
496
+ logWarn(`"${cleaned}" is not an Excel spreadsheet (.xlsx / .xls).`);
497
+ }
498
+ } else {
499
+ logError(`File not found at: "${cleaned}". Please check the path and try again.`);
500
+ }
501
+ } else {
502
+ console.log(`${colors.dim}Opening file picker dialog... Please select your file in the window.${colors.reset}`);
503
+ const picked = openFileDialog();
504
+ if (picked && fs.existsSync(picked) && fs.statSync(picked).isFile()) {
505
+ return picked;
506
+ }
507
+ logWarn('No file was selected in the dialog.');
508
+ console.log(`You can try browsing again [1], or enter/drag & drop the file path directly [2]:`);
509
+ }
510
+ }
511
+ }
512
+
513
+ // Interactive prompt helper
514
+ async function askQuestion(lineIterator, promptText) {
515
+ process.stdout.write(promptText);
516
+ const next = await lineIterator.next();
517
+ if (next.done) return '';
518
+ return (next.value || '').trim();
519
+ }
520
+
521
+ // Main execution function
522
+ async function main() {
523
+ const flags = parseArgs();
524
+
525
+ if (flags.help) {
526
+ console.log(`
527
+ Usage: npx @mrselva/attendance [options] [vertical]
528
+ or: attendance [options] [vertical]
529
+ or: node index.js [options] [vertical]
530
+
531
+ Options:
532
+ -v, --vertical <code/name> Vertical code (e.g., AD, BW, CD, CS, DMA, EI, FW, ALL)
533
+ -a, --all Export all instructional dates automatically
534
+ -d, --date <YYYY-MM-DD> Export a single specific date
535
+ --latest Export the latest instructional date
536
+ -f, --file <filename/path> Specify input Excel file name or path
537
+ -h, --help Show this help message
538
+
539
+ Running without options launches the interactive CLI wizard.
540
+ `);
541
+ process.exit(0);
542
+ }
543
+
544
+ console.log(`\n${colors.bold}${colors.magenta}╔═════════════════════════════════════════════════════════════════════╗`);
545
+ console.log(`║ KG-KITE / SoI Attendance -> DigiCampus Exporter ║`);
546
+ console.log(`╚═════════════════════════════════════════════════════════════════════╝${colors.reset}`);
547
+
548
+ ensureDirs();
549
+
550
+ let selectedFileObj = null;
551
+
552
+ // Create readline interface for interactive questions
553
+ const rl = readline.createInterface({
554
+ input: process.stdin,
555
+ output: process.stdout,
556
+ terminal: process.stdin.isTTY
557
+ });
558
+ const lines = rl[Symbol.asyncIterator]();
559
+
560
+ if (flags.file) {
561
+ const directPath = path.resolve(process.cwd(), flags.file);
562
+ const inInputPath = path.join(INPUT_DIR, flags.file);
563
+ let resolvedSrc = null;
564
+ if (fs.existsSync(directPath) && fs.statSync(directPath).isFile()) {
565
+ resolvedSrc = directPath;
566
+ } else if (fs.existsSync(inInputPath) && fs.statSync(inInputPath).isFile()) {
567
+ resolvedSrc = inInputPath;
568
+ } else {
569
+ logError(`Specified file "${flags.file}" was not found.`);
570
+ rl.close();
571
+ process.exit(1);
572
+ }
573
+ selectedFileObj = importFileToInput(resolvedSrc);
574
+ } else {
575
+ const inputFiles = findInputFiles();
576
+
577
+ if (inputFiles.length === 0) {
578
+ // Auto prompt to browse or drag & drop when input/ is empty
579
+ const chosen = await promptForFile(lines);
580
+ selectedFileObj = importFileToInput(chosen);
581
+ } else if (inputFiles.length === 1) {
582
+ logInfo(`Found existing attendance spreadsheet: ${colors.bold}${inputFiles[0].relativePath}${colors.reset}`);
583
+ console.log(` [${colors.bold}1${colors.reset}] Use this spreadsheet [Default]`);
584
+ console.log(` [${colors.bold}2${colors.reset}] Choose/import a different file`);
585
+ const pick = (await askQuestion(lines, `\nSelect option [1 or 2] (default: 1): `)).trim();
586
+ if (pick === '2') {
587
+ const chosen = await promptForFile(lines);
588
+ selectedFileObj = importFileToInput(chosen);
589
+ } else {
590
+ selectedFileObj = inputFiles[0];
591
+ }
592
+ } else {
593
+ logHeader('Select Input Attendance File');
594
+ inputFiles.forEach((fileObj, idx) => {
595
+ console.log(` [${colors.bold}${idx + 1}${colors.reset}] ${fileObj.relativePath}`);
596
+ });
597
+ console.log(` [${colors.bold}N${colors.reset}] ${colors.cyan}Import a new spreadsheet${colors.reset} (Browse or drag & drop)`);
598
+ const ans = (await askQuestion(lines, `\nSelect file [1-${inputFiles.length}] or N (default: 1): `)).trim();
599
+ if (ans.toUpperCase() === 'N') {
600
+ const chosen = await promptForFile(lines);
601
+ selectedFileObj = importFileToInput(chosen);
602
+ } else {
603
+ const num = parseInt(ans, 10);
604
+ if (!isNaN(num) && num >= 1 && num <= inputFiles.length) {
605
+ selectedFileObj = inputFiles[num - 1];
606
+ } else {
607
+ selectedFileObj = inputFiles[0];
608
+ }
609
+ }
610
+ }
611
+ }
612
+
613
+ const inputFilePath = selectedFileObj.fullPath;
614
+ logInfo(`Reading attendance workbook: ${colors.bold}${selectedFileObj.relativePath || selectedFileObj.name}${colors.reset}`);
615
+
616
+ let workbook;
617
+ try {
618
+ workbook = xlsx.readFile(inputFilePath, { cellDates: false });
619
+ } catch (err) {
620
+ logError(`Failed to read Excel file: ${err.message}`);
621
+ rl.close();
622
+ process.exit(1);
623
+ }
624
+
625
+ // Discover all vertical sheets
626
+ const verticalSheets = [];
627
+ const excludedSheets = ['pivot table', 'summary', 'template', 'students data'];
628
+
629
+ for (const sheetName of workbook.SheetNames) {
630
+ if (excludedSheets.some(ex => sheetName.toLowerCase().includes(ex))) {
631
+ continue;
632
+ }
633
+ const ws = workbook.Sheets[sheetName];
634
+ const parsed = parseVerticalSheet(ws, sheetName);
635
+ if (parsed) {
636
+ verticalSheets.push(parsed);
637
+ }
638
+ }
639
+
640
+ if (verticalSheets.length === 0) {
641
+ logError('No valid vertical attendance sheets with students and date columns were found.');
642
+ rl.close();
643
+ process.exit(1);
644
+ }
645
+
646
+ // Vertical Selection
647
+ let chosenVertical = null;
648
+ let processAll = false;
649
+
650
+ if (flags.vertical) {
651
+ const vArg = flags.vertical.toUpperCase().trim();
652
+ if (vArg === 'ALL' || vArg === 'A') {
653
+ processAll = true;
654
+ } else {
655
+ chosenVertical = verticalSheets.find(v =>
656
+ v.sheetName.toUpperCase() === vArg ||
657
+ v.verticalName.toUpperCase().includes(vArg)
658
+ );
659
+ if (!chosenVertical) {
660
+ logWarn(`Vertical "${flags.vertical}" not found. Falling back to interactive selection.`);
661
+ }
662
+ }
663
+ }
664
+
665
+ // Interactive selection if not resolved from flags
666
+ if (!chosenVertical && !processAll) {
667
+ logHeader('Select Vertical');
668
+ console.log('Available verticals found in the spreadsheet:\n');
669
+
670
+ verticalSheets.forEach((v, idx) => {
671
+ const code = colors.bold + v.sheetName.padEnd(5) + colors.reset;
672
+ const name = (v.verticalName !== v.sheetName ? `(${v.verticalName})` : '').padEnd(35);
673
+ const countInfo = `${colors.dim}${v.students.length} students, ${v.dates.length} dates recorded${colors.reset}`;
674
+ console.log(` [${colors.bold}${idx + 1}${colors.reset}] ${code} ${name} ${countInfo}`);
675
+ });
676
+ console.log(` [${colors.bold}A${colors.reset}] ${colors.bold}ALL ${colors.reset} (Process ALL verticals at once)`);
677
+
678
+ while (!chosenVertical && !processAll) {
679
+ const ans = (await askQuestion(lines, `\nEnter vertical number or code [1-${verticalSheets.length} / Code]: `)).trim();
680
+
681
+ if (ans.toUpperCase() === 'A' || ans.toUpperCase() === 'ALL') {
682
+ processAll = true;
683
+ break;
684
+ }
685
+
686
+ const num = parseInt(ans, 10);
687
+ if (!isNaN(num) && num >= 1 && num <= verticalSheets.length) {
688
+ chosenVertical = verticalSheets[num - 1];
689
+ break;
690
+ }
691
+
692
+ const matched = verticalSheets.find(v =>
693
+ v.sheetName.toLowerCase() === ans.toLowerCase() ||
694
+ v.verticalName.toLowerCase().includes(ans.toLowerCase())
695
+ );
696
+ if (matched) {
697
+ chosenVertical = matched;
698
+ break;
699
+ }
700
+
701
+ logWarn(`Invalid selection "${ans}". Please enter 1-${verticalSheets.length} or the vertical code.`);
702
+ }
703
+ }
704
+
705
+ const targets = processAll ? verticalSheets : [chosenVertical];
706
+
707
+ // Date selection logic
708
+ let dateFilterMode = 'ALL';
709
+ let singleDateVal = flags.date || null;
710
+ let dateRangeVal = null;
711
+
712
+ if (flags.all) {
713
+ dateFilterMode = 'ALL';
714
+ } else if (flags.latest) {
715
+ dateFilterMode = 'LATEST';
716
+ } else if (flags.date) {
717
+ dateFilterMode = 'SINGLE';
718
+ } else if (!processAll) {
719
+ const totalDates = chosenVertical.dates.length;
720
+ const firstDate = totalDates > 0 ? chosenVertical.dates[0].dateStr : 'N/A';
721
+ const lastDate = totalDates > 0 ? chosenVertical.dates[totalDates - 1].dateStr : 'N/A';
722
+
723
+ logHeader(`Date Selection for Vertical: ${chosenVertical.sheetName}`);
724
+ console.log(`Found ${colors.bold}${totalDates}${colors.reset} instructional dates (${firstDate} to ${lastDate}).\n`);
725
+ console.log(` [${colors.bold}1${colors.reset}] Export ${colors.green}ALL ${totalDates} dates${colors.reset} (Recommended - generates upload files for each date)`);
726
+ console.log(` [${colors.bold}2${colors.reset}] Export a ${colors.cyan}single specific date${colors.reset} (e.g., ${lastDate})`);
727
+ console.log(` [${colors.bold}3${colors.reset}] Export a ${colors.yellow}date range${colors.reset} (from YYYY-MM-DD to YYYY-MM-DD)`);
728
+ console.log(` [${colors.bold}4${colors.reset}] Export the ${colors.magenta}latest date only${colors.reset} (${lastDate})`);
729
+
730
+ const dateChoice = (await askQuestion(lines, `\nChoose export option [1-4] (default: 1): `)).trim();
731
+
732
+ if (dateChoice === '2') {
733
+ dateFilterMode = 'SINGLE';
734
+ const dAns = (await askQuestion(lines, `Enter date (YYYY-MM-DD) [default: ${lastDate}]: `)).trim();
735
+ singleDateVal = dAns || lastDate;
736
+ } else if (dateChoice === '3') {
737
+ dateFilterMode = 'RANGE';
738
+ const startAns = (await askQuestion(lines, `Enter start date (YYYY-MM-DD) [default: ${firstDate}]: `)).trim() || firstDate;
739
+ const endAns = (await askQuestion(lines, `Enter end date (YYYY-MM-DD) [default: ${lastDate}]: `)).trim() || lastDate;
740
+ dateRangeVal = { start: startAns, end: endAns };
741
+ } else if (dateChoice === '4') {
742
+ dateFilterMode = 'LATEST';
743
+ } else {
744
+ dateFilterMode = 'ALL';
745
+ }
746
+ }
747
+
748
+ rl.close();
749
+
750
+ // Process selected verticals
751
+ logHeader('Processing & Generating DigiCampus Files');
752
+
753
+ for (const vert of targets) {
754
+ console.log(`\n${colors.bold}Vertical: ${vert.sheetName} - ${vert.verticalName}${colors.reset}`);
755
+ console.log(` Students: ${colors.bold}${vert.students.length}${colors.reset}`);
756
+
757
+ let datesToExport = [...vert.dates];
758
+
759
+ if (dateFilterMode === 'SINGLE' && singleDateVal) {
760
+ datesToExport = datesToExport.filter(d => d.dateStr === singleDateVal);
761
+ if (datesToExport.length === 0) {
762
+ logWarn(`Date ${singleDateVal} was not found among instructional dates for ${vert.sheetName}.`);
763
+ const fallbackCol = vert.allDateColumns.find(d => d.dateStr === singleDateVal);
764
+ if (fallbackCol) {
765
+ datesToExport = [{ ...fallbackCol, presentCount: 0, absentCount: 0, totalStudents: vert.students.length }];
766
+ } else {
767
+ logError(`Date ${singleDateVal} does not exist in the spreadsheet!`);
768
+ continue;
769
+ }
770
+ }
771
+ } else if (dateFilterMode === 'RANGE' && dateRangeVal) {
772
+ datesToExport = datesToExport.filter(d => d.dateStr >= dateRangeVal.start && d.dateStr <= dateRangeVal.end);
773
+ } else if (dateFilterMode === 'LATEST' && datesToExport.length > 0) {
774
+ datesToExport = [datesToExport[datesToExport.length - 1]];
775
+ }
776
+
777
+ if (datesToExport.length === 0) {
778
+ logWarn(`No matching dates found to export for ${vert.sheetName}.`);
779
+ continue;
780
+ }
781
+
782
+ console.log(` Dates to export: ${colors.bold}${datesToExport.length}${colors.reset}`);
783
+
784
+ const ws = workbook.Sheets[vert.sheetName];
785
+ const rows = xlsx.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null });
786
+
787
+ // Build ZIP archive directly in memory
788
+ const zip = new AdmZip();
789
+
790
+ for (const dateItem of datesToExport) {
791
+ const fileName = `Upload_Attendance_${vert.sheetName}_${dateItem.dateStr}.xlsx`;
792
+ const outWb = generateDigiCampusWorkbook(vert, dateItem, rows);
793
+ const fileBuffer = xlsx.write(outWb, { type: 'buffer', bookType: 'xlsx' });
794
+ zip.addFile(fileName, fileBuffer);
795
+ }
796
+
797
+ // Save only the ZIP archive in output/
798
+ const zipFileName = `${vert.sheetName}_Attendance.zip`;
799
+ const zipFilePath = path.join(OUTPUT_DIR, zipFileName);
800
+ zip.writeZip(zipFilePath);
801
+
802
+ // Clean up any previously created unzipped folder for this vertical if it exists
803
+ const oldVertFolder = path.join(OUTPUT_DIR, `${vert.sheetName}_Attendance`);
804
+ if (fs.existsSync(oldVertFolder) && fs.statSync(oldVertFolder).isDirectory()) {
805
+ fs.rmSync(oldVertFolder, { recursive: true, force: true });
806
+ }
807
+
808
+ const zipStat = fs.statSync(zipFilePath);
809
+ const zipSizeKb = (zipStat.size / 1024).toFixed(1);
810
+
811
+ logSuccess(`Generated ZIP archive containing ${datesToExport.length} DigiCampus files:`);
812
+ console.log(` ${colors.bold}${colors.green}${zipFilePath}${colors.reset} (${zipSizeKb} KB)`);
813
+ }
814
+
815
+ // Final summary
816
+ logHeader('Export Complete');
817
+ console.log(`Check the ${colors.bold}${colors.cyan}./output${colors.reset} directory for all generated files and ZIP archives.\n`);
818
+ }
819
+
820
+ main().catch(err => {
821
+ logError(`An unexpected error occurred: ${err.message}`);
822
+ console.error(err);
823
+ process.exit(1);
824
+ });
package/package.json CHANGED
@@ -1,6 +1,36 @@
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.1.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": [
10
+ "attendance",
11
+ "digicampus",
12
+ "excel",
13
+ "cli",
14
+ "npx"
15
+ ],
16
+ "author": "mrselva",
17
+ "license": "ISC",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "bin": {
22
+ "attendance": "index.js"
23
+ },
24
+ "files": [
25
+ "index.js",
26
+ "README.md"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/mrselva-eth/attendance.git"
31
+ },
32
+ "dependencies": {
33
+ "adm-zip": "^0.6.1",
34
+ "xlsx": "^0.18.5"
35
+ }
36
+ }