Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea7356ba13 | ||
|
|
3a5633d92f | ||
|
|
e7a87e79f3 | ||
|
|
5df0b5bd6d | ||
|
|
125b7d925e | ||
|
|
0784a3512d | ||
|
|
c761faa787 | ||
|
|
97e3758bc1 | ||
|
|
48445020c8 | ||
|
|
880061e54d | ||
|
|
992d00a9a9 | ||
|
|
b21cc30c8a | ||
|
|
6ce7979719 | ||
|
|
c89b4a0837 | ||
|
|
39ccb715db | ||
|
|
db72898471 | ||
|
|
65b800806f |
@@ -28,5 +28,62 @@ To start the development server run `npm run dev`.
|
|||||||
|
|
||||||
### Production
|
### Production
|
||||||
|
|
||||||
To build the ViteJS project run `npm run build`, for deployment a `.env` files can be used to set the `BASE_URL` variable.
|
To build the ViteJS project run `npm run build`, for deployment a `.env` file 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
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,9 @@
|
|||||||
"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": "",
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
-43
@@ -1,6 +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 { ToolOverlay } from './components/ToolOverlay.jsx'
|
// import { ToolOverlay } from './components/ToolOverlay.jsx'
|
||||||
//
|
//
|
||||||
@@ -10,20 +11,15 @@ import { useEffect, useState } from 'preact/hooks'
|
|||||||
// 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 { OptionBar } from './components/OptionBar.jsx'
|
import { Courses } from './components/view/Courses.jsx'
|
||||||
import {
|
import { Schedule } from './components/view/Schedule.jsx'
|
||||||
prettyAulaName,
|
import { prettyAulaName, prettyProfName, usePersistentState } from './utils.jsx'
|
||||||
prettyProfName,
|
|
||||||
clearOldPersistentStates,
|
|
||||||
usePersistentState,
|
|
||||||
} from './utils.jsx'
|
|
||||||
|
|
||||||
import { SettingsBar } from './components/SettingsBar.jsx'
|
import { SettingsBar } from './components/SettingsBar.jsx'
|
||||||
|
|
||||||
@@ -31,29 +27,11 @@ import { SettingsBar } from './components/SettingsBar.jsx'
|
|||||||
window._ = _
|
window._ = _
|
||||||
window.dataBuffer = {}
|
window.dataBuffer = {}
|
||||||
|
|
||||||
// NOTA: magistrale *non* è quello con i corsi a cavallo
|
|
||||||
const TIMETABLE_IDS = {
|
|
||||||
'anno-1': '687e66d62d13e6001edd366d',
|
|
||||||
'anno-2': '687e681301ee9300198320e6',
|
|
||||||
'anno-3': '687e68bb01ee9300198320eb',
|
|
||||||
'magistrale': '687e6a9f96ea080019d439d5',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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'
|
||||||
) {
|
) {
|
||||||
@@ -82,7 +60,7 @@ function formatEvents(timetable) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCalendari(date) {
|
async function loadCalendari(date, timetableIds) {
|
||||||
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)
|
||||||
@@ -117,23 +95,23 @@ async function loadCalendari(date) {
|
|||||||
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(TIMETABLE_IDS['anno-1']),
|
req(timetableIds['anno-1']),
|
||||||
req(TIMETABLE_IDS['anno-2']),
|
req(timetableIds['anno-2']),
|
||||||
req(TIMETABLE_IDS['anno-3']),
|
req(timetableIds['anno-3']),
|
||||||
req(TIMETABLE_IDS['magistrale']),
|
req(timetableIds['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 = specialEventPatches(_.concat(...results), 'id')
|
const allRaw = _.uniqBy(specialEventPatches(_.concat(...results)), 'id')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'anno-1': formatEvents(timetablesRaw[0]),
|
'anno-1': formatEvents(timetablesRaw[0]),
|
||||||
@@ -178,13 +156,56 @@ 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', 'tutti')
|
||||||
const [timetables, setTimetables] = useState(null)
|
const [timetables, setTimetables] = useState(null)
|
||||||
useEffect(async () => {
|
|
||||||
setTimetables(await loadCalendari(new Date(date)))
|
// Dynamically fetch latest semester data at runtime (hourly cache-busted)
|
||||||
}, [date])
|
useEffect(() => {
|
||||||
|
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)
|
||||||
@@ -198,7 +219,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'
|
||||||
@@ -267,10 +288,10 @@ const App = ({}) => {
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDate('2025-09-29T00:00:00.000Z')
|
setDate(semesterData.firstMondayDate)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Vai al 29 settembre! 🚀
|
{semesterData.buttonText}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* <p>
|
{/* <p>
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Automated & manual update script for semester timetable data
|
||||||
|
// 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 path from 'path'
|
||||||
|
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
|
||||||
|
function getCurrentAcademicYear() {
|
||||||
|
const now = new Date()
|
||||||
|
const currentYear = now.getFullYear()
|
||||||
|
const currentMonth = now.getMonth() // 0-based
|
||||||
|
|
||||||
|
// Academic year starts in September, but August belongs to the new year
|
||||||
|
const startYear = currentMonth >= 7 ? currentYear : currentYear - 1
|
||||||
|
const endYear = startYear + 1
|
||||||
|
|
||||||
|
return { startYear, endYear }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch webpage content with redirect support and timeout
|
||||||
|
async function fetchPage(url) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'PHC-Orario-Bot/1.0 (+https://lab.phc.dm.unipi.it/orario)',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml',
|
||||||
|
},
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${url}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse timetable IDs from the schedule page
|
||||||
|
async function parseTimetableIds() {
|
||||||
|
try {
|
||||||
|
const html = await fetchPage(
|
||||||
|
'https://www.dm.unipi.it/didattica/lezioni-esami/orario-delle-lezioni/',
|
||||||
|
)
|
||||||
|
const ids = {}
|
||||||
|
|
||||||
|
// Extract links with their text to match correctly
|
||||||
|
const linkPattern =
|
||||||
|
/<a[^>]*href="[^"]*linkCalendarioId=([a-f0-9]{24})"[^>]*>([^<]+)<\/a>/g
|
||||||
|
const links = [...html.matchAll(linkPattern)]
|
||||||
|
|
||||||
|
for (const [, id, text] of links) {
|
||||||
|
const cleanText = text.trim()
|
||||||
|
|
||||||
|
if (cleanText === 'Triennale I anno') {
|
||||||
|
ids['anno-1'] = id
|
||||||
|
} else if (cleanText === 'Triennale II anno') {
|
||||||
|
ids['anno-2'] = id
|
||||||
|
} else if (cleanText === 'Triennale III anno') {
|
||||||
|
ids['anno-3'] = id
|
||||||
|
} else if (cleanText === 'Magistrale') {
|
||||||
|
ids['magistrale'] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedKeys = ['anno-1', 'anno-2', 'anno-3', 'magistrale']
|
||||||
|
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) {
|
||||||
|
console.error('Error parsing timetable IDs:', error.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse semester start date from calendar page
|
||||||
|
async function parseSemesterStartDate() {
|
||||||
|
try {
|
||||||
|
const { startYear, endYear } = getCurrentAcademicYear()
|
||||||
|
const currentMonth = new Date().getMonth()
|
||||||
|
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)}/`
|
||||||
|
|
||||||
|
let html
|
||||||
|
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
|
||||||
|
const semesterText = currentSemester === 1 ? 'I semestre' : 'II semestre'
|
||||||
|
const semesterRegex = new RegExp(
|
||||||
|
`Lezioni\\s+${semesterText}.*?(\\d{1,2}\\s+\\w+\\s+\\d{4})`,
|
||||||
|
'si',
|
||||||
|
)
|
||||||
|
const match = html.match(semesterRegex)
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return match[1]
|
||||||
|
} else {
|
||||||
|
console.error(`Could not find ${semesterText} start date in calendar`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing semester start date:', error.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first Monday after a given date string
|
||||||
|
function getFirstMondayAfter(dateStr) {
|
||||||
|
const [day, monthName, year] = dateStr.split(' ')
|
||||||
|
|
||||||
|
const monthMap = {
|
||||||
|
gennaio: 0,
|
||||||
|
febbraio: 1,
|
||||||
|
marzo: 2,
|
||||||
|
aprile: 3,
|
||||||
|
maggio: 4,
|
||||||
|
giugno: 5,
|
||||||
|
luglio: 6,
|
||||||
|
agosto: 7,
|
||||||
|
settembre: 8,
|
||||||
|
ottobre: 9,
|
||||||
|
novembre: 10,
|
||||||
|
dicembre: 11,
|
||||||
|
}
|
||||||
|
|
||||||
|
const month = monthMap[monthName.toLowerCase()]
|
||||||
|
if (month === undefined) {
|
||||||
|
throw new Error(`Unknown month: ${monthName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(parseInt(year), month, parseInt(day))
|
||||||
|
const dayOfWeek = startDate.getDay() // 0=Sunday, 1=Monday, etc.
|
||||||
|
|
||||||
|
let daysToAdd
|
||||||
|
if (dayOfWeek === 0) {
|
||||||
|
daysToAdd = 1 // Sunday -> Monday
|
||||||
|
} else if (dayOfWeek === 1) {
|
||||||
|
daysToAdd = 7 // Monday -> Next Monday
|
||||||
|
} else {
|
||||||
|
daysToAdd = 8 - dayOfWeek
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Date(startDate)
|
||||||
|
result.setDate(startDate.getDate() + daysToAdd)
|
||||||
|
|
||||||
|
return new Date(Date.UTC(result.getFullYear(), result.getMonth(), result.getDate()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date for button text (Italian format)
|
||||||
|
function formatDateForButton(date) {
|
||||||
|
const day = date.getUTCDate()
|
||||||
|
const monthNames = [
|
||||||
|
'gennaio',
|
||||||
|
'febbraio',
|
||||||
|
'marzo',
|
||||||
|
'aprile',
|
||||||
|
'maggio',
|
||||||
|
'giugno',
|
||||||
|
'luglio',
|
||||||
|
'agosto',
|
||||||
|
'settembre',
|
||||||
|
'ottobre',
|
||||||
|
'novembre',
|
||||||
|
'dicembre',
|
||||||
|
]
|
||||||
|
const month = monthNames[date.getUTCMonth()]
|
||||||
|
|
||||||
|
return `${day} ${month}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe atomic file write
|
||||||
|
function atomicWriteFileSync(filePath, content) {
|
||||||
|
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([
|
||||||
|
parseTimetableIds(),
|
||||||
|
parseSemesterStartDate(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!timetableIds) {
|
||||||
|
throw new Error('Failed to parse timetable IDs from dm.unipi.it')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!semesterStartDateStr) {
|
||||||
|
throw new Error('Failed to parse semester start date from dm.unipi.it')
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstMonday = getFirstMondayAfter(semesterStartDateStr)
|
||||||
|
const academicYear = getCurrentAcademicYear()
|
||||||
|
|
||||||
|
const newSemesterData = {
|
||||||
|
timetableIds,
|
||||||
|
semesterStartDateStr,
|
||||||
|
firstMondayDate: firstMonday.toISOString(),
|
||||||
|
buttonText: `Vai al ${formatDateForButton(firstMonday)}! 🚀`,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
academicYear,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against existing data to see if anything meaningful changed
|
||||||
|
let existingData = null
|
||||||
|
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 =
|
||||||
|
!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 {
|
||||||
|
getCurrentAcademicYear,
|
||||||
|
parseTimetableIds,
|
||||||
|
parseSemesterStartDate,
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"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:29:22.054Z",
|
||||||
|
"academicYear": {
|
||||||
|
"startYear": 2026,
|
||||||
|
"endYear": 2027
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[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
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[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
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user