Author SHA1 Message Date
BachoSeven 8ae010a715 feat: push new ids 2026-09-02 21:54:08 +02:00
BachoSeven 75af3ccd56 chore: update semester 2026-01-23 16:09:50 +01:00
10 changed files with 164 additions and 639 deletions
+1 -58
View File
@@ -28,62 +28,5 @@ To start the development server run `npm run dev`.
### Production ### Production
To build the ViteJS project run `npm run build`, for deployment a `.env` file can be used to set the `BASE_URL` variable. To build the ViteJS project run `npm run build`, for deployment a `.env` files can be used to set the `BASE_URL` variable.
## Semester Timetable Data & Automated Updates
The timetable IDs and semester dates are stored in `semester-data.json`.
The client application loads `semester-data.json` with a **Stale-While-Revalidate / Fallback** pattern:
- The bundled `src/semester-data.json` is used as an immediate fallback (ensuring instant render and offline availability).
- On startup, the client dynamically fetches `/semester-data.json` (hourly cache-busted). If new IDs or dates are published, the UI updates automatically without needing to rebuild or redeploy the frontend bundle.
### Updating Semester Data
To manually check and update semester data:
```bash
# Update both src/semester-data.json and public/semester-data.json
npm run update-data
# Or run directly with custom output path(s)
node src/scripts/update-semester-data.js --output /var/www/orario/semester-data.json
# Check without modifying files (exits with code 1 if updates found, 0 if up to date)
node src/scripts/update-semester-data.js --check
```
### Automated Daily Host Updates
To ensure the production site always has up-to-date timetable IDs each semester without manual commits, set up a daily check on the server hosting `/var/www/orario`.
#### Option 1: Systemd Timer (Recommended)
1. Copy the systemd unit files to `/etc/systemd/system/`:
```bash
sudo cp systemd/orario-update.service /etc/systemd/system/
sudo cp systemd/orario-update.timer /etc/systemd/system/
```
2. In `/etc/systemd/system/orario-update.service`, ensure the `ExecStart` path points to your repository's update script and your webroot target, for example:
```ini
ExecStart=/usr/bin/node /home/fra/tech/phc/orario/src/scripts/update-semester-data.js --output /var/www/orario/semester-data.json --quiet
```
3. Enable and start the timer:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now orario-update.timer
```
4. Verify status:
```bash
systemctl list-timers | grep orario
```
#### Option 2: Standard Cron (`crontab -e`)
Add a daily cron entry to the `caddy` (or deployment) user's crontab:
```bash
crontab -e
```
Add the following line (runs daily at 04:30 AM):
```cron
30 4 * * * /usr/bin/node /home/fra/tech/phc/orario/src/scripts/update-semester-data.js --output /var/www/orario/semester-data.json --quiet >> /var/log/orario-update.log 2>&1
```
BIN
View File
Binary file not shown.
+1 -3
View File
@@ -5,9 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build"
"update-data": "node src/scripts/update-semester-data.js",
"test": "node --test 'tests/**/*.test.js'"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
-16
View File
@@ -1,16 +0,0 @@
{
"timetableIds": {
"anno-1": "687e66d62d13e6001edd366d",
"anno-2": "687e681301ee9300198320e6",
"anno-3": "687e68bb01ee9300198320eb",
"magistrale": "687e6a9f96ea080019d439d5"
},
"semesterStartDateStr": "23 settembre 2026",
"firstMondayDate": "2026-09-28T00:00:00.000Z",
"buttonText": "Vai al 28 settembre! 🚀",
"lastUpdated": "2026-09-02T20:10:56.118Z",
"academicYear": {
"startYear": 2026,
"endYear": 2027
}
}
+38 -63
View File
@@ -1,7 +1,7 @@
import _ from 'lodash' import _ from 'lodash'
import { render } from 'preact' import { render } from 'preact'
import { useEffect, useState } from 'preact/hooks' import { useEffect, useState } from 'preact/hooks'
import defaultSemesterData from './semester-data.json' import semesterData from './semester-data.json'
// import { ToolOverlay } from './components/ToolOverlay.jsx' // import { ToolOverlay } from './components/ToolOverlay.jsx'
// //
@@ -11,15 +11,20 @@ import defaultSemesterData from './semester-data.json'
// MODE_SCHEDULE, // MODE_SCHEDULE,
// } from './components/EventsView.jsx' // } from './components/EventsView.jsx'
import { Courses } from './components/view/Courses.jsx'
import { Schedule } from './components/view/Schedule.jsx'
import { HamburgerMenu } from './components/HamburgerMenu.jsx' import { HamburgerMenu } from './components/HamburgerMenu.jsx'
import { Help } from './components/Help.jsx' import { Help } from './components/Help.jsx'
import { Icon } from './components/Icon.jsx' import { Icon } from './components/Icon.jsx'
import { OptionBar } from './components/OptionBar.jsx'
import { Popup } from './components/Popup.jsx' import { Popup } from './components/Popup.jsx'
import { Toolbar } from './components/Toolbar.jsx' import { Toolbar } from './components/Toolbar.jsx'
import { Courses } from './components/view/Courses.jsx' import { OptionBar } from './components/OptionBar.jsx'
import { Schedule } from './components/view/Schedule.jsx' import {
import { prettyAulaName, prettyProfName, usePersistentState } from './utils.jsx' prettyAulaName,
prettyProfName,
clearOldPersistentStates,
usePersistentState,
} from './utils.jsx'
import { SettingsBar } from './components/SettingsBar.jsx' import { SettingsBar } from './components/SettingsBar.jsx'
@@ -27,11 +32,24 @@ import { SettingsBar } from './components/SettingsBar.jsx'
window._ = _ window._ = _
window.dataBuffer = {} window.dataBuffer = {}
// NOTA: magistrale *non* è quello con i corsi a cavallo
const TIMETABLE_IDS = semesterData.timetableIds
// const DEFAULT_DATE_RANGE = {
// from: '2023-10-09T00:00:00.000Z',
// to: '2023-10-14T00:00:00.000Z',
// }
// const DATE_RANGES = {
// '64a7c1c651f079001d52e9c8': DEFAULT_DATE_RANGE,
// '6308e2dc09352a0208fefdd9': DEFAULT_DATE_RANGE,
// '6308e42a1df5cb026699ced4': DEFAULT_DATE_RANGE,
// '64a7c7091ab813002c5d9ede': DEFAULT_DATE_RANGE,
// }
function specialEventPatches(eventi) { function specialEventPatches(eventi) {
// Il laboratorio del primo anno in realtà è in due gruppi separati // Il laboratorio del primo anno in realtà è in due gruppi separati
eventi.forEach(evento => { eventi.forEach(evento => {
console.log(evento.id, evento.nome, evento.dataInizio, evento.dataFine)
if ( if (
evento.nome === 'LABORATORIO DI INTRODUZIONE ALLA MATEMATICA COMPUTAZIONALE' evento.nome === 'LABORATORIO DI INTRODUZIONE ALLA MATEMATICA COMPUTAZIONALE'
) { ) {
@@ -60,7 +78,7 @@ function formatEvents(timetable) {
}) })
} }
async function loadCalendari(date, timetableIds) { async function loadCalendari(date) {
function getMonday(d) { function getMonday(d) {
const day = d.getDay() const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1) const diff = d.getDate() - day + (day === 0 ? -6 : 1)
@@ -95,23 +113,23 @@ async function loadCalendari(date, timetableIds) {
method: 'POST', method: 'POST',
mode: 'cors', mode: 'cors',
credentials: 'omit', credentials: 'omit',
}, }
) )
return await req.json() return await req.json()
} }
const requests = [ const requests = [
req(timetableIds['anno-1']), req(TIMETABLE_IDS['anno-1']),
req(timetableIds['anno-2']), req(TIMETABLE_IDS['anno-2']),
req(timetableIds['anno-3']), req(TIMETABLE_IDS['anno-3']),
req(timetableIds['magistrale']), req(TIMETABLE_IDS['magistrale']),
] ]
const results = await Promise.all(requests) const results = await Promise.all(requests)
const timetablesRaw = results.map(timetable => const timetablesRaw = results.map(timetable =>
specialEventPatches(_.uniqBy(timetable, 'id')), specialEventPatches(_.uniqBy(timetable, 'id'))
) )
const allRaw = _.uniqBy(specialEventPatches(_.concat(...results)), 'id') const allRaw = specialEventPatches(_.concat(...results), 'id')
return { return {
'anno-1': formatEvents(timetablesRaw[0]), 'anno-1': formatEvents(timetablesRaw[0]),
@@ -156,56 +174,13 @@ const App = ({}) => {
// clearOldPersistentStates('e73cba02') // clearOldPersistentStates('e73cba02')
const [date, setDate] = useState(new Date().toISOString()) const [date, setDate] = useState(new Date().toISOString())
const [semesterData, setSemesterData] = useState(defaultSemesterData)
// Data Sources // Data Sources
const [view, setView] = usePersistentState('view', 'tutti') const [view, setView] = usePersistentState('view', 'magistrale')
const [timetables, setTimetables] = useState(null) const [timetables, setTimetables] = useState(null)
useEffect(async () => {
// Dynamically fetch latest semester data at runtime (hourly cache-busted) setTimetables(await loadCalendari(new Date(date)))
useEffect(() => { }, [date])
const cacheBust = Math.floor(Date.now() / 3600000)
const baseUrl = (import.meta.env.BASE_URL || './').replace(/\/?$/, '/')
fetch(`${baseUrl}semester-data.json?t=${cacheBust}`)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then(data => {
if (data && data.timetableIds && data.firstMondayDate) {
setSemesterData(prev => {
const idsEqual =
JSON.stringify(prev.timetableIds) ===
JSON.stringify(data.timetableIds)
const dateEqual = prev.firstMondayDate === data.firstMondayDate
const btnEqual = prev.buttonText === data.buttonText
if (!idsEqual || !dateEqual || !btnEqual) {
return { ...prev, ...data }
}
return prev
})
}
})
.catch(err => {
console.warn(
'Could not fetch latest semester-data.json, using bundled fallback:',
err,
)
})
}, [])
useEffect(() => {
let isMounted = true
loadCalendari(new Date(date), semesterData.timetableIds).then(data => {
if (isMounted) {
setTimetables(data)
}
})
return () => {
isMounted = false
}
}, [date, semesterData.timetableIds])
// View Modes // View Modes
// const [mode, setMode] = usePersistentState('orario.mode', MODE_COURSES) // const [mode, setMode] = usePersistentState('orario.mode', MODE_COURSES)
@@ -219,7 +194,7 @@ const App = ({}) => {
const [theme, setTheme] = usePersistentState( const [theme, setTheme] = usePersistentState(
'theme', 'theme',
'light', 'light'
// window.matchMedia('(prefers-color-scheme: dark)').matches // window.matchMedia('(prefers-color-scheme: dark)').matches
// ? 'dark' // ? 'dark'
// : 'light' // : 'light'
+122 -305
View File
@@ -1,369 +1,186 @@
#!/usr/bin/env node #!/usr/bin/env node
// Automated & manual update script for semester timetable data // Manual update script for semester timetable data
// Usage: // Run this script manually each semester to update timetable IDs and dates
// node src/scripts/update-semester-data.js // Usage: node src/scripts/update-semester-data.js
// node src/scripts/update-semester-data.js --output /var/www/orario/semester-data.json
// node src/scripts/update-semester-data.js --check
import fs from 'fs' import https from 'https';
import path from 'path' import fs from 'fs';
import { fileURLToPath } from 'url'
function parseArgs() {
const args = process.argv.slice(2)
const options = {
outputs: [],
checkOnly: false,
quiet: false,
}
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg === '--output' || arg === '-o') {
const next = args[++i]
if (next) {
options.outputs.push(
...next
.split(',')
.map(s => s.trim())
.filter(Boolean),
)
}
} else if (arg.startsWith('--output=')) {
options.outputs.push(
...arg
.slice('--output='.length)
.split(',')
.map(s => s.trim())
.filter(Boolean),
)
} else if (arg === '--check') {
options.checkOnly = true
} else if (arg === '--quiet' || arg === '-q') {
options.quiet = true
} else if (arg === '--help' || arg === '-h') {
console.log(`
Usage: node src/scripts/update-semester-data.js [options]
Options:
-o, --output <path> Target output file path(s) (comma-separated or multiple flags).
Default: src/semester-data.json, public/semester-data.json
--check Dry-run check: exit 0 if up to date, exit 1 if updates found.
-q, --quiet Suppress verbose output (ideal for cron jobs).
-h, --help Show this help message.
`)
process.exit(0)
}
}
if (options.outputs.length === 0) {
options.outputs = ['src/semester-data.json', 'public/semester-data.json']
}
return options
}
// Get current academic year // Get current academic year
function getCurrentAcademicYear() { function getCurrentAcademicYear() {
const now = new Date() const now = new Date();
const currentYear = now.getFullYear() const currentYear = now.getFullYear();
const currentMonth = now.getMonth() // 0-based const currentMonth = now.getMonth(); // 0-based
// Academic year starts in September, but August belongs to the new year // Academic year starts in September, but August belongs to the new year
const startYear = currentMonth >= 7 ? currentYear : currentYear - 1 const startYear = currentMonth >= 7 ? currentYear : currentYear - 1;
const endYear = startYear + 1 const endYear = startYear + 1;
return { startYear, endYear } return { startYear, endYear };
} }
// Fetch webpage content with redirect support and timeout // Fetch webpage content
async function fetchPage(url) { function fetchPage(url) {
const response = await fetch(url, { return new Promise((resolve, reject) => {
headers: { https.get(url, (res) => {
'User-Agent': 'PHC-Orario-Bot/1.0 (+https://lab.phc.dm.unipi.it/orario)', let data = '';
'Accept': 'text/html,application/xhtml+xml',
}, res.on('data', (chunk) => {
redirect: 'follow', data += chunk;
signal: AbortSignal.timeout(15000), });
})
res.on('end', () => {
if (!response.ok) { resolve(data);
throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${url}`) });
} }).on('error', (err) => {
reject(err);
return await response.text() });
});
} }
// Parse timetable IDs from the schedule page // Parse timetable IDs from the schedule page
async function parseTimetableIds() { async function parseTimetableIds() {
try { try {
const html = await fetchPage( const html = await fetchPage('https://www.dm.unipi.it/didattica/lezioni-esami/orario-delle-lezioni/');
'https://www.dm.unipi.it/didattica/lezioni-esami/orario-delle-lezioni/', const ids = {};
)
const ids = {}
// Extract links with their text to match correctly // Extract links with their text to match correctly
const linkPattern = const linkPattern = /<a[^>]*href="[^"]*linkCalendarioId=([a-f0-9]{24})"[^>]*>([^<]+)<\/a>/g;
/<a[^>]*href="[^"]*linkCalendarioId=([a-f0-9]{24})"[^>]*>([^<]+)<\/a>/g const links = [...html.matchAll(linkPattern)];
const links = [...html.matchAll(linkPattern)]
for (const [fullMatch, id, text] of links) {
for (const [, id, text] of links) { const cleanText = text.trim();
const cleanText = text.trim()
if (cleanText === 'Triennale I anno') { if (cleanText === 'Triennale I anno') {
ids['anno-1'] = id ids['anno-1'] = id;
} else if (cleanText === 'Triennale II anno') { } else if (cleanText === 'Triennale II anno') {
ids['anno-2'] = id ids['anno-2'] = id;
} else if (cleanText === 'Triennale III anno') { } else if (cleanText === 'Triennale III anno') {
ids['anno-3'] = id ids['anno-3'] = id;
} else if (cleanText === 'Magistrale') { } else if (cleanText === 'Magistrale') {
ids['magistrale'] = id ids['magistrale'] = id;
} }
} }
const expectedKeys = ['anno-1', 'anno-2', 'anno-3', 'magistrale'] return Object.keys(ids).length > 0 ? ids : null;
const missing = expectedKeys.filter(k => !ids[k])
if (missing.length > 0) {
console.warn(`Warning: Missing timetable IDs for: ${missing.join(', ')}`)
}
return Object.keys(ids).length > 0 ? ids : null
} catch (error) { } catch (error) {
console.error('Error parsing timetable IDs:', error.message) console.error('Error parsing timetable IDs:', error.message);
return null return null;
} }
} }
// Parse semester start date from calendar page // Parse semester start date from calendar page
async function parseSemesterStartDate() { async function parseSemesterStartDate() {
try { try {
const { startYear, endYear } = getCurrentAcademicYear() const { startYear, endYear } = getCurrentAcademicYear();
const currentMonth = new Date().getMonth() const currentMonth = new Date().getMonth();
const currentSemester = currentMonth >= 0 && currentMonth <= 6 ? 2 : 1 const currentSemester = currentMonth >= 0 && currentMonth <= 6 ? 2 : 1;
let url = `https://www.dm.unipi.it/didattica/lezioni-esami/calendario-delle-attivita-didattiche/calendario-delle-attivita-didattiche-a-a-${startYear}-${endYear.toString().slice(-2)}/` const url = `https://www.dm.unipi.it/didattica/lezioni-esami/calendario-delle-attivita-didattiche/calendario-delle-attivita-didattiche-a-a-${startYear}-${endYear.toString().slice(-2)}/`;
let html const html = await fetchPage(url);
try {
html = await fetchPage(url)
} catch {
// Fallback: check index page to discover link
const indexHtml = await fetchPage(
'https://www.dm.unipi.it/didattica/lezioni-esami/calendario-delle-attivita-didattiche/',
)
const linkMatch = indexHtml.match(
/href="([^"]*calendario-delle-attivita-didattiche-a-a-[^"]*)"/i,
)
if (linkMatch) {
url = linkMatch[1]
html = await fetchPage(url)
} else {
throw new Error('Could not find link to academic calendar')
}
}
// Look for the appropriate semester start date // Look for the appropriate semester start date
const semesterText = currentSemester === 1 ? 'I semestre' : 'II semestre' const semesterText = currentSemester === 1 ? 'I semestre' : 'II semestre';
const semesterRegex = new RegExp( const semesterRegex = new RegExp(`Lezioni\\s+${semesterText}.*?(\\d{1,2}\\s+\\w+\\s+\\d{4})`, 'si');
`Lezioni\\s+${semesterText}.*?(\\d{1,2}\\s+\\w+\\s+\\d{4})`, const match = html.match(semesterRegex);
'si',
)
const match = html.match(semesterRegex)
if (match) { if (match) {
return match[1] return match[1];
} else { } else {
console.error(`Could not find ${semesterText} start date in calendar`) console.error(`Could not find ${semesterText} start date in calendar`);
return null return null;
} }
} catch (error) { } catch (error) {
console.error('Error parsing semester start date:', error.message) console.error('Error parsing semester start date:', error.message);
return null return null;
} }
} }
// Find the first Monday after a given date string // Find the first Monday after a given date string
function getFirstMondayAfter(dateStr) { function getFirstMondayAfter(dateStr) {
const [day, monthName, year] = dateStr.split(' ') // Parse Italian date format (e.g., "24 settembre 2025")
const [day, monthName, year] = dateStr.split(' ');
const monthMap = { const monthMap = {
gennaio: 0, 'gennaio': 0, 'febbraio': 1, 'marzo': 2, 'aprile': 3,
febbraio: 1, 'maggio': 4, 'giugno': 5, 'luglio': 6, 'agosto': 7,
marzo: 2, 'settembre': 8, 'ottobre': 9, 'novembre': 10, 'dicembre': 11
aprile: 3, };
maggio: 4,
giugno: 5, const month = monthMap[monthName.toLowerCase()];
luglio: 6,
agosto: 7,
settembre: 8,
ottobre: 9,
novembre: 10,
dicembre: 11,
}
const month = monthMap[monthName.toLowerCase()]
if (month === undefined) { if (month === undefined) {
throw new Error(`Unknown month: ${monthName}`) throw new Error(`Unknown month: ${monthName}`);
} }
const startDate = new Date(parseInt(year), month, parseInt(day)) const startDate = new Date(parseInt(year), month, parseInt(day));
const dayOfWeek = startDate.getDay() // 0=Sunday, 1=Monday, etc. const dayOfWeek = startDate.getDay(); // 0=Sunday, 1=Monday, etc.
let daysToAdd // Calculate days to add to get to the next Monday
if (dayOfWeek === 0) { let daysToAdd;
daysToAdd = 1 // Sunday -> Monday if (dayOfWeek === 0) { // Sunday
} else if (dayOfWeek === 1) { daysToAdd = 1; // Next day is Monday
daysToAdd = 7 // Monday -> Next Monday } else if (dayOfWeek === 1) { // Monday
} else { daysToAdd = 7; // Next Monday
daysToAdd = 8 - dayOfWeek } else { // Tuesday-Saturday (2-6)
daysToAdd = 8 - dayOfWeek; // Days until next Monday
} }
const result = new Date(startDate) const result = new Date(startDate);
result.setDate(startDate.getDate() + daysToAdd) result.setDate(startDate.getDate() + daysToAdd);
return new Date(Date.UTC(result.getFullYear(), result.getMonth(), result.getDate())) // Create a new date in UTC to avoid timezone issues with the calendar
return new Date(Date.UTC(result.getFullYear(), result.getMonth(), result.getDate()));
} }
// Format date for button text (Italian format) // Format date for button text (Italian format)
function formatDateForButton(date) { function formatDateForButton(date) {
const day = date.getUTCDate() const day = date.getUTCDate();
const monthNames = [ const monthNames = [
'gennaio', 'gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno',
'febbraio', 'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre'
'marzo', ];
'aprile', const month = monthNames[date.getUTCMonth()];
'maggio',
'giugno', return `${day} ${month}`;
'luglio',
'agosto',
'settembre',
'ottobre',
'novembre',
'dicembre',
]
const month = monthNames[date.getUTCMonth()]
return `${day} ${month}`
} }
// Safe atomic file write // Main function
function atomicWriteFileSync(filePath, content) { async function updateAcademicData() {
const resolvedPath = path.resolve(process.cwd(), filePath)
const dir = path.dirname(resolvedPath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
const tmpPath = `${resolvedPath}.tmp.${Date.now()}`
fs.writeFileSync(tmpPath, content, { encoding: 'utf8', mode: 0o644 })
try {
fs.chmodSync(tmpPath, 0o644)
} catch {
// Ignore chmod errors if not supported or not owner
}
fs.renameSync(tmpPath, resolvedPath)
}
// Main execution function
async function main() {
const options = parseArgs()
const [timetableIds, semesterStartDateStr] = await Promise.all([ const [timetableIds, semesterStartDateStr] = await Promise.all([
parseTimetableIds(), parseTimetableIds(),
parseSemesterStartDate(), parseSemesterStartDate()
]) ]);
// Fail fast if parsing failed
if (!timetableIds) { if (!timetableIds) {
throw new Error('Failed to parse timetable IDs from dm.unipi.it') throw new Error('Failed to parse timetable IDs from the website');
} }
if (!semesterStartDateStr) { if (!semesterStartDateStr) {
throw new Error('Failed to parse semester start date from dm.unipi.it') throw new Error('Failed to parse semester start date from the calendar');
} }
const firstMonday = getFirstMondayAfter(semesterStartDateStr) const firstMonday = getFirstMondayAfter(semesterStartDateStr);
const academicYear = getCurrentAcademicYear()
const semesterData = {
const newSemesterData = {
timetableIds, timetableIds,
semesterStartDateStr, semesterStartDateStr,
firstMondayDate: firstMonday.toISOString(), firstMondayDate: firstMonday.toISOString(),
buttonText: `Vai al ${formatDateForButton(firstMonday)}! 🚀`, buttonText: `Vai al ${formatDateForButton(firstMonday)}! 🚀`,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
academicYear, academicYear: getCurrentAcademicYear()
} };
// Check against existing data to see if anything meaningful changed // Save to src/semester-data.json
let existingData = null fs.writeFileSync('src/semester-data.json', JSON.stringify(semesterData, null, 2));
for (const outPath of options.outputs) {
const resolved = path.resolve(process.cwd(), outPath)
if (fs.existsSync(resolved)) {
try {
existingData = JSON.parse(fs.readFileSync(resolved, 'utf8'))
break
} catch {
// Ignore parse errors on old file
}
}
}
const idsChanged = console.log('✅ Semester data updated successfully');
!existingData ||
JSON.stringify(existingData.timetableIds) !==
JSON.stringify(newSemesterData.timetableIds)
const dateChanged =
!existingData || existingData.firstMondayDate !== newSemesterData.firstMondayDate
const hasMeaningfulChanges = idsChanged || dateChanged
if (options.checkOnly) {
if (hasMeaningfulChanges) {
if (!options.quiet) {
console.log('🔄 New semester data detected.')
console.log(JSON.stringify(newSemesterData, null, 2))
}
process.exit(1)
} else {
if (!options.quiet) {
console.log('✅ Semester data is up to date.')
}
process.exit(0)
}
}
const jsonString = JSON.stringify(newSemesterData, null, 2) + '\n'
for (const outPath of options.outputs) {
atomicWriteFileSync(outPath, jsonString)
if (!options.quiet) {
console.log(`💾 Saved updated data to ${outPath}`)
}
}
if (!options.quiet) {
if (hasMeaningfulChanges) {
console.log('🎉 Semester data successfully updated with new IDs/dates!')
} else {
console.log(
'✅ Timetable IDs and dates unchanged (lastUpdated timestamp refreshed).',
)
}
}
} }
export { // Run the script
getCurrentAcademicYear, updateAcademicData().catch(error => {
parseTimetableIds, console.error('💥 Script failed:', error);
parseSemesterStartDate, process.exit(1);
getFirstMondayAfter, });
formatDateForButton,
atomicWriteFileSync,
main,
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
main().catch(error => {
console.error('💥 Script failed:', error.message)
process.exit(1)
})
}
+2 -2
View File
@@ -8,9 +8,9 @@
"semesterStartDateStr": "23 settembre 2026", "semesterStartDateStr": "23 settembre 2026",
"firstMondayDate": "2026-09-28T00:00:00.000Z", "firstMondayDate": "2026-09-28T00:00:00.000Z",
"buttonText": "Vai al 28 settembre! 🚀", "buttonText": "Vai al 28 settembre! 🚀",
"lastUpdated": "2026-09-02T20:29:22.054Z", "lastUpdated": "2026-09-02T19:43:46.921Z",
"academicYear": { "academicYear": {
"startYear": 2026, "startYear": 2026,
"endYear": 2027 "endYear": 2027
} }
} }
-17
View File
@@ -1,17 +0,0 @@
[Unit]
Description=Daily update of Orario semester timetable IDs and dates
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
# NOTE: If your webroot (e.g. orario.volume) is created by Docker (owned by root:root),
# run this service as root by leaving User/Group unset (or User=root).
# The script automatically writes files with world-readable 0644 permissions so Caddy can read them.
# If you prefer running as caddy, ensure 'chown -R caddy:caddy <webroot>' is run after docker builds.
# User=caddy
# Group=caddy
WorkingDirectory=/srv/services/orario
ExecStart=/usr/bin/node /srv/services/orario/src/scripts/update-semester-data.js --output /srv/services/orario/orario.volume/semester-data.json --quiet
StandardOutput=journal
StandardError=journal
-12
View File
@@ -1,12 +0,0 @@
[Unit]
Description=Run Orario semester timetable update daily
[Timer]
# Run every day at 04:30 AM
OnCalendar=*-*-* 04:30:00
# Run on boot if system was off during scheduled time
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
-163
View File
@@ -1,163 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import {
parseTimetableIds,
parseSemesterStartDate,
getFirstMondayAfter,
formatDateForButton,
getCurrentAcademicYear,
atomicWriteFileSync,
} from '../src/scripts/update-semester-data.js';
const execFileAsync = promisify(execFile);
test('Academic Year calculation', () => {
const year = getCurrentAcademicYear();
assert.ok(typeof year.startYear === 'number');
assert.ok(typeof year.endYear === 'number');
assert.equal(year.endYear, year.startYear + 1);
});
test('Live HTML parsing from dm.unipi.it', async t => {
await t.test('extracts valid timetable IDs for all courses', async () => {
const ids = await parseTimetableIds();
assert.ok(ids, 'Timetable IDs should not be null');
const expectedKeys = ['anno-1', 'anno-2', 'anno-3', 'magistrale'];
for (const key of expectedKeys) {
assert.ok(ids[key], `Missing timetable ID for ${key}`);
assert.match(
ids[key],
/^[a-f0-9]{24}$/,
`Timetable ID for ${key} must be a 24-char hex string: ${ids[key]}`
);
}
});
await t.test('extracts valid semester start date from calendar page', async () => {
const startDateStr = await parseSemesterStartDate();
assert.ok(startDateStr, 'Semester start date string should not be null');
assert.match(
startDateStr,
/^\d{1,2}\s+[a-zA-Z]+\s+\d{4}$/,
`Date should match Italian format, got: ${startDateStr}`
);
const firstMonday = getFirstMondayAfter(startDateStr);
assert.ok(firstMonday instanceof Date);
assert.equal(firstMonday.getUTCDay(), 1, 'First Monday must be day 1 (Monday)');
const buttonText = formatDateForButton(firstMonday);
assert.match(buttonText, /^\d{1,2}\s+[a-zA-Z]+$/);
});
});
test('Cineca API verifies live parsed timetable IDs', async () => {
const ids = await parseTimetableIds();
assert.ok(ids, 'Need valid IDs to test Cineca API');
// Test the first year ID against Cineca API
const testId = ids['anno-1'];
const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const res = await fetch('https://apache.prod.up.cineca.it/api/Impegni/getImpegniCalendarioPubblico', {
method: 'POST',
headers: {
'content-type': 'application/json;charset=UTF-8',
},
body: JSON.stringify({
mostraImpegniAnnullati: true,
mostraIndisponibilitaTotali: false,
linkCalendarioId: testId,
clienteId: '628de8b9b63679f193b87046',
pianificazioneTemplate: false,
dataInizio: now.toISOString(),
dataFine: nextWeek.toISOString(),
}),
});
assert.equal(res.status, 200, `Cineca API should respond with HTTP 200 for ID ${testId}`);
const data = await res.json();
assert.ok(Array.isArray(data), 'Cineca response should be a JSON array of events');
});
test('Atomic file write safety and schema', async () => {
const tmpPath = path.resolve(process.cwd(), 'tests/scratch-semester-test.json');
try {
const sampleData = {
timetableIds: {
'anno-1': '687e66d62d13e6001edd366d',
'anno-2': '687e681301ee9300198320e6',
'anno-3': '687e68bb01ee9300198320eb',
'magistrale': '687e6a9f96ea080019d439d5',
},
semesterStartDateStr: '23 settembre 2026',
firstMondayDate: '2026-09-28T00:00:00.000Z',
buttonText: 'Vai al 28 settembre! 🚀',
lastUpdated: new Date().toISOString(),
academicYear: { startYear: 2026, endYear: 2027 },
};
atomicWriteFileSync(tmpPath, JSON.stringify(sampleData, null, 2));
assert.ok(fs.existsSync(tmpPath), 'Output file should exist');
const readBack = JSON.parse(fs.readFileSync(tmpPath, 'utf8'));
assert.deepEqual(readBack.timetableIds, sampleData.timetableIds);
assert.equal(readBack.buttonText, sampleData.buttonText);
} finally {
if (fs.existsSync(tmpPath)) {
fs.unlinkSync(tmpPath);
}
}
});
test('CLI command execution', async t => {
await t.test('--check flag runs and exits cleanly', async () => {
const { stdout, stderr } = await execFileAsync('node', [
'src/scripts/update-semester-data.js',
'--check',
]);
assert.match(stdout, /Semester data is up to date/);
assert.equal(stderr, '');
});
await t.test('--output flag writes to custom path', async () => {
const tmpPath = path.resolve(process.cwd(), 'tests/cli-output-test.json');
try {
const { stdout } = await execFileAsync('node', [
'src/scripts/update-semester-data.js',
'--output',
tmpPath,
'--quiet',
]);
assert.ok(fs.existsSync(tmpPath), 'Target output file was created');
const data = JSON.parse(fs.readFileSync(tmpPath, 'utf8'));
assert.ok(data.timetableIds['anno-1']);
} finally {
if (fs.existsSync(tmpPath)) {
fs.unlinkSync(tmpPath);
}
}
});
});
test('Dev server serves semester-data.json dynamically', async () => {
try {
const res = await fetch('http://localhost:3000/semester-data.json');
if (res.ok) {
const data = await res.json();
assert.ok(data.timetableIds);
assert.ok(data.timetableIds['anno-1']);
assert.ok(data.buttonText);
}
} catch {
// If dev server isn't running in background, test passes conditionally
}
});