@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.
- package/README.md +118 -2
- package/index.js +686 -0
- package/package.json +28 -4
package/README.md
CHANGED
|
@@ -1,3 +1,119 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mrselva/attendance
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@mrselva/attendance)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](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) © [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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|