-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbtu-timetable-helper.user.js
More file actions
342 lines (299 loc) · 14.5 KB
/
btu-timetable-helper.user.js
File metadata and controls
342 lines (299 loc) · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
// ==UserScript==
// @name BTU Classroom Table Exporter
// @namespace https://timetable.usltd.ge/
// @version 1.4
// @description Export course groups from BTU Classroom to JSON, HTML Table, CSV, and Markdown. Supports partial export and language selection.
// @author Luka Mamukashvili <mamukashvili.luka@usltd.ge>
// @match https://classroom.btu.edu.ge/en/student/me/course/groups/*
// @match https://classroom.btu.edu.ge/ge/student/me/course/groups/*
// @match https://classroom.btu.edu.ge/en/student/me/course/groups/*/*
// @match https://classroom.btu.edu.ge/ge/student/me/course/groups/*/*
// @match https://classroom.btu.edu.ge/en/student/me/course/index/*
// @match https://classroom.btu.edu.ge/ge/student/me/course/index/*
// @match https://classroom.btu.edu.ge/en/student/me/course/index/*/*
// @match https://classroom.btu.edu.ge/ge/student/me/course/index/*/*
// @match https://timetable.usltd.ge/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// Auto-hide the userscript prompt if visited on the SPA
if (location.hostname === 'timetable.usltd.ge') {
// Expose a variable and fire an event to immediately notify the React app
const s = document.createElement('script');
s.textContent = 'window.__BTU_USERSCRIPT_ACTIVE = true; window.dispatchEvent(new CustomEvent("btu-userscript-detected"));';
document.head.appendChild(s);
s.remove();
// Inject a quick CSS rule in case React already painted the hint element
const style = document.createElement('style');
style.textContent = '#userscript-hint { display: none !important; }';
document.head.appendChild(style);
return; // Halt execution so we don't try to find BTU tables here
}
// Wait for the main groups table to load in the DOM
const initInterval = setInterval(() => {
const table = document.getElementById('groups');
if (table) {
clearInterval(initInterval);
initExporter(table);
}
}, 500);
function initExporter(table) {
// Prevent double injection
if (table.hasAttribute('data-export-injected')) return;
table.setAttribute('data-export-injected', 'true');
// 1. Inject Checkboxes for partial export
const groupTitles = table.querySelectorAll('.group_title');
groupTitles.forEach(titleEl => {
const row = titleEl.closest('tr');
const firstCell = row.querySelector('td');
if (firstCell) {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'btu-export-cb';
checkbox.checked = true; // Default to selected
checkbox.title = 'Include this group in the export';
checkbox.style.marginRight = '10px';
checkbox.style.cursor = 'pointer';
checkbox.style.width = '16px';
checkbox.style.height = '16px';
checkbox.style.verticalAlign = 'middle';
firstCell.insertBefore(checkbox, firstCell.firstChild);
}
});
// 2. Inject Export Buttons Panel
const btnContainer = document.createElement('div');
btnContainer.style.margin = '15px 0';
btnContainer.style.padding = '12px';
btnContainer.style.backgroundColor = '#f5f5f5';
btnContainer.style.border = '1px solid #e3e3e3';
btnContainer.style.borderRadius = '4px';
btnContainer.style.display = 'flex';
btnContainer.style.gap = '10px';
btnContainer.style.alignItems = 'center';
btnContainer.style.flexWrap = 'wrap';
const label = document.createElement('strong');
label.innerText = 'Export Selected: ';
btnContainer.appendChild(label);
// 3. Inject Language Selector
const currentLang = location.href.includes('/en/') ? 'en' : (location.href.includes('/ge/') ? 'ge' : 'en');
const langSelect = document.createElement('select');
langSelect.id = 'btu-export-lang';
langSelect.style.padding = '4px 8px';
langSelect.style.borderRadius = '4px';
langSelect.style.border = '1px solid #ccc';
langSelect.innerHTML = `
<option value="en" ${currentLang === 'en' ? 'selected' : ''}>English</option>
<option value="ge" ${currentLang === 'ge' ? 'selected' : ''}>Georgian</option>
`;
btnContainer.appendChild(langSelect);
const createBtn = (text, onClick) => {
const btn = document.createElement('button');
btn.innerText = text;
btn.className = 'btn btn-default btn-sm'; // Using existing BTU bootstrap classes
btn.style.marginLeft = '5px';
btn.onclick = async (e) => {
e.preventDefault();
btn.disabled = true;
const originalText = btn.innerText;
btn.innerText = 'Loading...';
try {
await onClick();
} catch (err) {
console.error("Export failed:", err);
alert("Export failed. See console for details.");
} finally {
btn.disabled = false;
btn.innerText = originalText;
}
};
return btn;
};
btnContainer.appendChild(createBtn('Clean HTML', () => processExport(generateHTML, 'html', 'text/html')));
btnContainer.appendChild(createBtn('JSON', () => processExport(generateJSON, 'json', 'application/json')));
btnContainer.appendChild(createBtn('CSV', () => processExport(generateCSV, 'csv', 'text/csv;charset=utf-8;')));
btnContainer.appendChild(createBtn('Markdown', () => processExport(generateMarkdown, 'md', 'text/markdown')));
table.parentNode.insertBefore(btnContainer, table);
}
// --- Core Export Orchestrator ---
async function processExport(generatorFunc, extension, mimeType) {
const doc = await getTargetDocument();
if (!doc) return; // Halt if redirect was required
const checkedIds = getCheckedIds();
const data = extractData(doc, checkedIds);
if (data.length === 0) {
alert("No groups selected to export.");
return;
}
// Generate filename based on course title, falling back to 'schedule'
const courseNameRaw = data[0].courseTitle || 'schedule';
const courseName = courseNameRaw.replace(/[\/\\?%*:|"<>]/g, '-').trim() || 'schedule';
const filename = `${courseName}.${extension}`;
const content = generatorFunc(data);
download(content, filename, mimeType);
}
// --- Document & State Fetching ---
async function getTargetDocument() {
const targetLang = document.getElementById('btu-export-lang').value;
const currentLang = location.href.includes('/en/') ? 'en' : (location.href.includes('/ge/') ? 'ge' : null);
// If the requested language is the currently active one, just use the current document
if (targetLang === currentLang || !currentLang) {
return document;
}
const targetUrl = location.href.replace(`/${currentLang}/`, `/${targetLang}/`);
try {
const response = await fetch(targetUrl);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Ensure the main table exists in the fetched HTML (if site loads dynamically)
if (!doc.getElementById('groups')) {
console.warn("Table not found in background HTML. Page likely rendered dynamically.");
const confirmNav = confirm("The selected language needs to be loaded directly. Redirect now?");
if (confirmNav) {
window.location.href = targetUrl;
}
return null;
}
return doc;
} catch (e) {
console.error("Fetch failed", e);
alert("Failed to fetch the alternative language. Will use the current page instead.");
return document; // Fallback to current
}
}
function getCheckedIds() {
const checked = [];
document.querySelectorAll('.btu-export-cb:checked').forEach(cb => {
const titleEl = cb.closest('tr').querySelector('.group_title');
if (titleEl) {
checked.push(titleEl.getAttribute('data-id'));
}
});
return checked;
}
// --- Data Extraction Logic ---
function extractData(doc, checkedIds) {
const data = [];
const groupTitles = doc.querySelectorAll('.group_title');
const legendEl = doc.querySelector('legend');
const courseTitle = legendEl ? legendEl.textContent.trim() : 'Unknown Course';
groupTitles.forEach(titleEl => {
const dataId = titleEl.getAttribute('data-id');
// Skip if user unchecked the partial export checkbox
if (!checkedIds.includes(dataId)) return;
const mainRow = titleEl.closest('tr');
const groupName = titleEl.textContent.trim();
// Extract Instructor (Can be a text node or an <a> tag after the user icon)
let instructor = "";
const userIcon = mainRow.querySelector('.glyphicon-user');
if (userIcon) {
const textNodeContent = userIcon.nextSibling ? userIcon.nextSibling.textContent.trim() : "";
if (textNodeContent) {
// Fallback for older table formats where it's a plain text node
instructor = textNodeContent;
} else {
// For newer tables, the text node is empty whitespace, so we grab the next element
const nextEl = userIcon.nextElementSibling;
// Check if it's an <a> tag and verify it's not the message icon button
if (nextEl && nextEl.tagName === 'A' && !nextEl.querySelector('.glyphicon')) {
instructor = nextEl.textContent.trim();
}
}
}
// Extract Schedule from the nested hidden row
const schedules = [];
const schedRow = doc.getElementById('tr-' + dataId);
if (schedRow) {
const schedRows = schedRow.querySelectorAll('table tbody tr');
schedRows.forEach(sr => {
const tds = sr.querySelectorAll('td');
if (tds.length >= 3) {
schedules.push({
day: tds[0].textContent.trim(),
time: tds[1].textContent.trim(),
room: tds[2].textContent.trim()
});
}
});
}
data.push({ courseTitle, groupName, instructor, schedules });
});
return data;
}
// --- Format Generators ---
function generateJSON(data) {
return JSON.stringify(data, null, 2);
}
function generateHTML(data) {
let html = '<table border="1" style="border-collapse: collapse;">\n';
if (data.length > 0) {
html += ` <caption><strong>${data[0].courseTitle}</strong></caption>\n`;
}
html += ' <thead>\n <tr>\n <th>Group</th>\n <th>Instructor</th>\n <th>Day</th>\n <th>Time</th>\n <th>Room</th>\n </tr>\n </thead>\n <tbody>\n';
data.forEach(group => {
if (group.schedules.length === 0) {
html += ` <tr><td>${group.groupName}</td><td>${group.instructor}</td><td></td><td></td><td></td></tr>\n`;
} else {
group.schedules.forEach((sched, index) => {
html += ' <tr>\n';
// Use rowspan for the first line of the group to avoid repeating names
if (index === 0) {
html += ` <td rowspan="${group.schedules.length}">${group.groupName}</td>\n`;
html += ` <td rowspan="${group.schedules.length}">${group.instructor}</td>\n`;
}
html += ` <td>${sched.day}</td>\n <td>${sched.time}</td>\n <td>${sched.room}</td>\n </tr>\n`;
});
}
});
html += ' </tbody>\n</table>';
return html; // Pure table component, no html/body/doctype
}
function generateCSV(data) {
let csv = '\uFEFF'; // UTF-8 BOM so Excel reads Georgian characters correctly
csv += '"Course","Group","Instructor","Day","Time","Room"\n';
data.forEach(group => {
if (group.schedules.length === 0) {
csv += `"${group.courseTitle}","${group.groupName}","${group.instructor}","","",""\n`;
} else {
group.schedules.forEach(sched => {
csv += `"${group.courseTitle}","${group.groupName}","${group.instructor}","${sched.day}","${sched.time}","${sched.room}"\n`;
});
}
});
return csv;
}
function generateMarkdown(data) {
let md = '';
if (data.length > 0) {
md += `**Course:** ${data[0].courseTitle}\n\n`;
}
md += '| Group | Instructor | Day | Time | Room |\n';
md += '| --- | --- | --- | --- | --- |\n';
data.forEach(group => {
if (group.schedules.length === 0) {
md += `| ${group.groupName} | ${group.instructor} | | | |\n`;
} else {
group.schedules.forEach((sched, index) => {
// Only show Group Name and Instructor on the first row of that group for readability
const gName = index === 0 ? group.groupName : "";
const instr = index === 0 ? group.instructor : "";
md += `| ${gName} | ${instr} | ${sched.day} | ${sched.time} | ${sched.room} |\n`;
});
}
});
return md;
}
// --- File Downloader ---
function download(content, fileName, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
})();