edupage-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +250 -0
- data/exe/edupage +7 -0
- data/lib/edupage/account.rb +121 -0
- data/lib/edupage/cache.rb +101 -0
- data/lib/edupage/cli/formatter.rb +56 -0
- data/lib/edupage/cli/table.rb +136 -0
- data/lib/edupage/cli.rb +543 -0
- data/lib/edupage/client.rb +152 -0
- data/lib/edupage/config.rb +96 -0
- data/lib/edupage/credentials/env.rb +27 -0
- data/lib/edupage/credentials/keychain.rb +120 -0
- data/lib/edupage/credentials.rb +118 -0
- data/lib/edupage/errors.rb +74 -0
- data/lib/edupage/model.rb +131 -0
- data/lib/edupage/models/assignment.rb +83 -0
- data/lib/edupage/models/classroom.rb +13 -0
- data/lib/edupage/models/day.rb +51 -0
- data/lib/edupage/models/grade.rb +100 -0
- data/lib/edupage/models/lesson.rb +58 -0
- data/lib/edupage/models/parent.rb +8 -0
- data/lib/edupage/models/period.rb +13 -0
- data/lib/edupage/models/person.rb +25 -0
- data/lib/edupage/models/school_class.rb +16 -0
- data/lib/edupage/models/student.rb +45 -0
- data/lib/edupage/models/subject.rb +9 -0
- data/lib/edupage/models/teacher.rb +25 -0
- data/lib/edupage/models/term.rb +36 -0
- data/lib/edupage/models/timeline_item.rb +87 -0
- data/lib/edupage/parsers/base.rb +150 -0
- data/lib/edupage/parsers/gcall.rb +53 -0
- data/lib/edupage/parsers/timeline.rb +46 -0
- data/lib/edupage/parsers/userhome.rb +84 -0
- data/lib/edupage/parsers/znamky.rb +83 -0
- data/lib/edupage/registry/resources.rb +175 -0
- data/lib/edupage/registry.rb +286 -0
- data/lib/edupage/relation.rb +185 -0
- data/lib/edupage/school.rb +358 -0
- data/lib/edupage/serializer.rb +53 -0
- data/lib/edupage/server/api.rb +113 -0
- data/lib/edupage/server/auth.rb +45 -0
- data/lib/edupage/server/mcp.rb +107 -0
- data/lib/edupage/server.rb +57 -0
- data/lib/edupage/session.rb +166 -0
- data/lib/edupage/session_store.rb +131 -0
- data/lib/edupage/version.rb +3 -0
- data/lib/edupage/year.rb +77 -0
- data/lib/edupage.rb +64 -0
- metadata +201 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d88122f0b43b1dd402338602fa5ea159a965330816f0a5104bbc05ebc7032dde
|
|
4
|
+
data.tar.gz: 6c648935914a8e1c78da2ed253c3f625384a61af26442ac63ce027981cf8c73c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0d533b61ff18ec78c95a8282e123cf4271eb554949277fed4391619a20b007e5c2b9dc5f7c9965ece0bb19971eff3ca73b403df894f69bc628c9022ee6a86592
|
|
7
|
+
data.tar.gz: 188a7210840c7f1263229aaf857c89fe080f6d7376c33d198d1707f956436faca839dc4e0ede25544242ef18c1192e4c866b591d3bdf772f89cd69c31233c506
|
data/README.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# edupage-cli
|
|
2
|
+
|
|
3
|
+
Read-only prístup k účtu na [Edupage](https://www.edupage.org) - ako Ruby knižnica, CLI,
|
|
4
|
+
REST API a MCP server. Všetky štyri povrchy stoja na tom istom kóde a drží ich v súlade
|
|
5
|
+
parity test.
|
|
6
|
+
|
|
7
|
+
Edupage nemá verejné API. Každá stránka je server-rendered HTML s JSON-om zabaleným
|
|
8
|
+
v `<script>` blokoch a session si navyše drží skryté kurzory "aktuálne dieťa" a
|
|
9
|
+
"aktuálny školský rok", ktoré treba nastaviť skôr, než má fetch vôbec zmysel. Toto
|
|
10
|
+
všetko rieši knižnica za teba.
|
|
11
|
+
|
|
12
|
+
## Inštalácia
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bundle install
|
|
16
|
+
bundle exec exe/edupage login
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`login` heslo najprv overí voči Edupage a až potom ho uloží do macOS keychainu, takže
|
|
20
|
+
sa tam nikdy nedostane preklep.
|
|
21
|
+
|
|
22
|
+
## Credentials
|
|
23
|
+
|
|
24
|
+
Hľadajú sa v tomto poradí:
|
|
25
|
+
|
|
26
|
+
| Poradie | Zdroj |
|
|
27
|
+
|---|---|
|
|
28
|
+
| 1 | `EDUPAGE_USERNAME`, `EDUPAGE_PASSWORD`, `EDUPAGE_SCHOOL` |
|
|
29
|
+
| 2 | macOS keychain (service `edupage-cli`), cez `edupage login` |
|
|
30
|
+
|
|
31
|
+
`edupage auth` ukáže, ktorý zdroj sa práve používa, a upozorní, keď `EDUPAGE_PASSWORD`
|
|
32
|
+
prekrýva heslo v keychaine - inak by to vyzeralo, že `edupage login` nič neurobil.
|
|
33
|
+
|
|
34
|
+
Netajné nastavenia sú v `~/.config/edupage-cli/config.yml`, session a cache stránok
|
|
35
|
+
v `~/.cache/edupage-cli/`.
|
|
36
|
+
|
|
37
|
+
## Reťazec
|
|
38
|
+
|
|
39
|
+
Všetko visí na `account > škola > študent > ročník` a žiadna úroveň sa nedá preskočiť.
|
|
40
|
+
Každá sa rozhoduje rovnako: explicitný výber vyhráva, jediná možnosť sa vezme ticho,
|
|
41
|
+
čokoľvek iné skončí chybou so zoznamom možností.
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
$ edupage students
|
|
45
|
+
No school selected. Pick one:
|
|
46
|
+
--school zsdemo Základná škola Demo
|
|
47
|
+
--school zusdemo Základná umelecká škola Demo
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Default v configu sa **za výber nepočíta**: `default_school` hovorí len to, na ktorý
|
|
51
|
+
`*.edupage.org` host sa prihlásiť, nikdy nie to, čie dáta čítaš.
|
|
52
|
+
|
|
53
|
+
Jedinou výnimkou je ročník - ten sa doplní na aktuálny, lebo "teraz" je jednoznačné.
|
|
54
|
+
Výstup vždy povie, ktorý rok použil, a keď je prázdny, ukáže, kde dáta sú:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
$ edupage grades --school zsdemo --student Jana
|
|
58
|
+
school : zsdemo (Základná škola Demo)
|
|
59
|
+
student : Jana Nováková (4.A)
|
|
60
|
+
year : 2026/2027 (current) [default]
|
|
61
|
+
No records.
|
|
62
|
+
Nothing here for 2026/2027 (current). Try --year 2025 (172), --year 2024 (116).
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## CLI
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
edupage schools
|
|
69
|
+
edupage students --school zsdemo
|
|
70
|
+
edupage timetable --school zsdemo --student Jana --from 2026-09-21 --to 2026-09-25
|
|
71
|
+
edupage homeworks --school zsdemo --student Jana --due 2026-09-14
|
|
72
|
+
edupage years --school zsdemo --student Jana
|
|
73
|
+
edupage grades --school zsdemo --student Jana --year 2025 --term P1 --subject MAT
|
|
74
|
+
edupage timeline --school zsdemo --student Peter --type message
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Globálne prepínače: `--school --student --year --username --json --yaml --no-cache
|
|
78
|
+
--verbose`. Meno študenta stačí zadať ako unikátny prefix, takže `--student Jana`
|
|
79
|
+
postačuje.
|
|
80
|
+
|
|
81
|
+
Tabuľkový výstup má hlavičku s tým, z akej školy, študenta a roka dáta pochádzajú;
|
|
82
|
+
`--json` a `--yaml` ju nemajú, aby zostali bajt na bajt zhodné s REST a MCP odpoveďami.
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
$ edupage grades --school zsdemo --student Jana --year 2025
|
|
86
|
+
school : zsdemo (Základná škola Demo)
|
|
87
|
+
student : Jana Nováková (4.A)
|
|
88
|
+
year : 2025/2026
|
|
89
|
+
┌──────────────────┬───────────────────────────────┬───────┬────────┬─────────────────────────┐
|
|
90
|
+
│ created at │ subject │ value │ weight │ title │
|
|
91
|
+
├──────────────────┼───────────────────────────────┼───────┼────────┼─────────────────────────┤
|
|
92
|
+
│ 2025-09-13 10:53 │ Slovenský jazyk a literatúra │ 1 │ 1.0 │ Čítanie │
|
|
93
|
+
│ 2025-09-20 08:16 │ Matematika │ 1 │ 1.0 │ Sčítanie a odčítanie │
|
|
94
|
+
│ │ │ │ │ do 20 bez prechodu │
|
|
95
|
+
└──────────────────┴───────────────────────────────┴───────┴────────┴─────────────────────────┘
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Tabuľka sa prispôsobí šírke terminálu: keď sa nezmestí, uberá sa vždy najširšiemu
|
|
99
|
+
stĺpcu a dlhý text sa zalomí, takže dátumy a známky zostanú celé.
|
|
100
|
+
|
|
101
|
+
Servisné príkazy: `edupage auth`, `edupage session status|refresh|logout`,
|
|
102
|
+
`edupage cache info|clear`, `edupage config path|get|set`.
|
|
103
|
+
|
|
104
|
+
## Knižnica
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
require "edupage"
|
|
108
|
+
|
|
109
|
+
school = Edupage.account.school("zsdemo")
|
|
110
|
+
jana = school.students.find_by(name: /Jana/)
|
|
111
|
+
|
|
112
|
+
jana.timetable # dnes
|
|
113
|
+
jana.timetable(Date.today..Date.today + 6) # týždeň
|
|
114
|
+
jana.homeworks.where(subject: "SJL").order(:due_on)
|
|
115
|
+
|
|
116
|
+
jana.years # 2026 (aktuálny), 2025, 2024 ...
|
|
117
|
+
jana.year(2025).grades.where(term: :P1, subject: "MAT")
|
|
118
|
+
jana.grades # skratka pre aktuálny rok
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Kolekcie sú lazy a reťaziteľné: `where order limit offset find_by first count`.
|
|
122
|
+
`where(name: ...)` hľadá naprieč menom, skratkou aj id záznamu; každý iný kľúč porovnáva
|
|
123
|
+
svoj atribút a berie hodnotu, regexp, range, pole alebo lambdu.
|
|
124
|
+
|
|
125
|
+
## Server
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
edupage server # REST na /api/v1, MCP na /mcp
|
|
129
|
+
edupage mcp # MCP cez stdio, pre editory a desktop klientov
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Počúva na `127.0.0.1` a pri prvom spustení si vygeneruje bearer token do configu.
|
|
133
|
+
Všetky rúty sú `GET`, všetky MCP tooly sú označené ako read-only.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
curl -H "Authorization: Bearer $TOKEN" \
|
|
137
|
+
"http://127.0.0.1:4567/api/v1/schools/zsdemo/students/Jana/years/2025/grades?term=P1"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Zapojenie MCP
|
|
141
|
+
|
|
142
|
+
`edupage mcp-add` zaregistruje server u klienta, `edupage mcp-config` iba vypíše
|
|
143
|
+
`mcpServers` záznam, keď si ho chceš umiestniť sám.
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
edupage mcp-add claude-code --scope user # cez `claude mcp add`
|
|
147
|
+
edupage mcp-add claude-desktop # zlúči sa do claude_desktop_config.json
|
|
148
|
+
edupage mcp-add all --scope user # oboje
|
|
149
|
+
edupage mcp-add all --dry-run # ukáže, čo by spravil, nezmení nič
|
|
150
|
+
edupage mcp-add all --http # zaregistruje HTTP endpoint namiesto stdio
|
|
151
|
+
|
|
152
|
+
edupage mcp-config # len JSON a nič iné
|
|
153
|
+
edupage mcp-config --http
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Pridávanie je **idempotentné**: zhodný záznam nechá tak, odlišný zosúladí a chýbajúci
|
|
157
|
+
doplní. Config Claude Desktopu sa zlučuje, nie prepisuje - ostatné servery aj nesúvisiace
|
|
158
|
+
nastavenia zostanú - a predošlá verzia sa odloží ako `.bak`.
|
|
159
|
+
|
|
160
|
+
Pre lokálny nástroj je lepší default stdio: proces vlastní klient, takže netreba nič
|
|
161
|
+
autentifikovať ani držať bežiaci server. `--http` mieri na `/mcp` bežiaceho
|
|
162
|
+
`edupage server` a nesie token v `Authorization` hlavičke, čo sa hodí, keď jeden proces
|
|
163
|
+
zdieľa viac klientov.
|
|
164
|
+
|
|
165
|
+
Ani jeden variant nepripína školu ani študenta - to sú úrovne reťazca a model si ich má
|
|
166
|
+
zvoliť pri každom volaní.
|
|
167
|
+
|
|
168
|
+
Vygenerovaný stdio záznam používa absolútne cesty a nastavuje `BUNDLE_GEMFILE`, lebo MCP
|
|
169
|
+
klienti spúšťajú servery z vlastného pracovného adresára. A spúšťajú ich aj **bez
|
|
170
|
+
nastaveného locale**, čo je dôvod, prečo každý súbor otvárame explicitne ako UTF-8
|
|
171
|
+
a nie cez `Encoding.default_external`.
|
|
172
|
+
|
|
173
|
+
## Ako povrchy zostávajú v súlade
|
|
174
|
+
|
|
175
|
+
Resources sú deklarované raz, v `lib/edupage/registry/resources.rb`:
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
resource :grades do
|
|
179
|
+
scope :year
|
|
180
|
+
summary "Grades for a school year"
|
|
181
|
+
param :term, type: :enum, values: %w[P1 P2], desc: "Half-year"
|
|
182
|
+
resolve ->(year, p) { year.grades.where(term: p[:term]) }
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Z toho sa vygeneruje CLI príkaz, REST rúta aj MCP tool a `spec/registry_parity_spec.rb`
|
|
187
|
+
spadne, ak niektorý chýba alebo sa mu rozídu parametre. Výstup `--json`, telo REST
|
|
188
|
+
odpovede aj výsledok MCP toolu idú cez jeden serializer, takže sú bajt na bajt zhodné.
|
|
189
|
+
|
|
190
|
+
`scope` zároveň deklaruje, na ktorých úrovniach reťazca resource stojí, takže ho všetky
|
|
191
|
+
tri povrchy vynucujú rovnako: REST ich nesie ako segmenty cesty, MCP input schéma ich
|
|
192
|
+
označí ako required a CLI odmietne so zoznamom možností.
|
|
193
|
+
|
|
194
|
+
## Poznámky k samotnému Edupage
|
|
195
|
+
|
|
196
|
+
Veci, ktoré stojí za to vedieť - všetky overené proti živému účtu:
|
|
197
|
+
|
|
198
|
+
- **Jeden login môže pokrývať viac škôl.** `mauth` vráti jednu session na každú školu.
|
|
199
|
+
- **Session drží aktuálne dieťa a aktuálny školský rok.** Prepnutie ktoréhokoľvek z nich
|
|
200
|
+
je side effect na zdieľanom stave servera, takže prebieha pod file lockom a každá
|
|
201
|
+
odpoveď sa kontroluje proti tomu, čo sa pýtalo.
|
|
202
|
+
- **Tieto prepnutia zaostávajú.** Edupage prepnutie potvrdí okamžite, ale ešte request
|
|
203
|
+
alebo dva servíruje stránku predošlého dieťaťa či roka, takže sa fetch opakuje, kým sa
|
|
204
|
+
stránka nezhoduje. Vrátiť zaostávajúcu stránku by potichu znamenalo rozvrh iného dieťaťa.
|
|
205
|
+
- **Číselníky sa medzi rokmi menia.** Zoznam tried za 2025 nie je ten istý ako za 2026,
|
|
206
|
+
preto je rok súčasťou každého cache kľúča.
|
|
207
|
+
- **Timeline je spoločná pre všetky deti rodiča** a delí sa lokálne podľa mapy
|
|
208
|
+
`childGroups`.
|
|
209
|
+
- **Domáce úlohy majú dva tvary** - zadané triede alebo jednému žiakovi. Treba oba: na
|
|
210
|
+
účte, na ktorom to vzniklo, je 15 zo 16 úloh jedného dieťaťa toho druhého druhu.
|
|
211
|
+
|
|
212
|
+
## Vývoj
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
bundle exec rspec
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Testy bežia proti ručne písaným payloadom, nie proti nahratým stránkam: tie skutočné
|
|
219
|
+
majú stovky kilobajtov a sú v nich mená cudzích detí.
|
|
220
|
+
|
|
221
|
+
Keby predsa len vznikla VCR kazeta, `spec/support/cassette_scrubber.rb` z nej pred
|
|
222
|
+
zápisom na disk vyhádže osobné údaje - mená, názvy škôl, subdomény aj e-maily, a to
|
|
223
|
+
v tele odpovede, v URI aj v hlavičkách. Mená nezoberie z pevného zoznamu, ale **z odpovede
|
|
224
|
+
samotnej** - z polí, kam ich Edupage vždy dáva - a potom nahradí každý ich výskyt vrátane
|
|
225
|
+
tých vo voľnom texte správ. Pseudonym je odvodený z pôvodnej hodnoty, takže ten istý
|
|
226
|
+
človek je v každej kazete ten istý vymyslený človek a krížové odkazy v payloade
|
|
227
|
+
zostanú platné; späť sa z toho dostať nedá. Keďže slovenčina skloňuje, hľadá sa aj
|
|
228
|
+
kmeň mena, takže zmiznú aj tvary ako `Janu` či `Kováčovej`, nielen základný tvar.
|
|
229
|
+
|
|
230
|
+
Overiť sa to dá proti živému účtu:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
EDUPAGE_LIVE_USERNAME=you@example.com EDUPAGE_LIVE_SCHOOL=yourschool \
|
|
234
|
+
bundle exec rspec spec/live_recording_spec.rb --tag live
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Ten test si zoznam mien, ktoré sa v kazete nesmú objaviť, **načíta zo živého účtu**, nie
|
|
238
|
+
z ručne napísaného zoznamu - spadne teda aj vtedy, keď pribudne spolužiak alebo druhá
|
|
239
|
+
škola, ktorú scrubber nevie nájsť. Zároveň kontroluje, že sa kazeta uložila ako čitateľný
|
|
240
|
+
text: Edupage servíruje stránky gzipnuté a komprimované telo by prešlo každou kontrolou
|
|
241
|
+
na meno, hoci by v ňom boli všetky.
|
|
242
|
+
|
|
243
|
+
CI beží na Ruby 3.2, 3.3 a 3.4 na Linuxe, plus jeden macOS job, ktorý si vytvorí vlastný
|
|
244
|
+
odomknutý keychain, aby sa keychain testy naozaj spustili a nepreskočili.
|
|
245
|
+
|
|
246
|
+
## Rozsah
|
|
247
|
+
|
|
248
|
+
Read-only, zámerne. Nič tu do Edupage nezapisuje - žiadne odpovede na správy, žiadne
|
|
249
|
+
označovanie úloh za hotové, žiadne podpisovanie známok. `spec/registry_parity_spec.rb`
|
|
250
|
+
aj samostatný CI job overujú, že sa v `lib/` neobjaví write endpoint.
|
data/exe/edupage
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
# The logged-in person, and the entry point to everything else.
|
|
3
|
+
#
|
|
4
|
+
# One set of credentials can reach several schools - a parent with children at two
|
|
5
|
+
# schools gets two entries from a single login, each with its own session - so an
|
|
6
|
+
# account owns a list of schools rather than being tied to one.
|
|
7
|
+
class Account
|
|
8
|
+
class << self
|
|
9
|
+
# Reuses stored sessions when they are still alive and only logs in when needed.
|
|
10
|
+
def login(username: nil, school: nil, credentials: nil, store: SessionStore.new, cache: nil)
|
|
11
|
+
credentials ||= Credentials.new(username: username, school: school)
|
|
12
|
+
username = credentials.username
|
|
13
|
+
|
|
14
|
+
sessions = restore(username, credentials, store)
|
|
15
|
+
sessions = authenticate(credentials, store) if sessions.empty?
|
|
16
|
+
|
|
17
|
+
new(username: username, sessions: sessions, credentials: credentials,
|
|
18
|
+
store: store, cache: cache)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# A dead session is renewed rather than dropped: sessions expire one school at a
|
|
24
|
+
# time, and dropping one would hide that school until all the others died too.
|
|
25
|
+
def restore(username, credentials, store)
|
|
26
|
+
store.all(username).filter_map do |origin, entry|
|
|
27
|
+
session = Session.new(
|
|
28
|
+
origin: origin, username: username, session_id: entry[:session_id],
|
|
29
|
+
credentials: credentials, userid: entry[:userid], role: entry[:role],
|
|
30
|
+
name: entry[:name], store: store
|
|
31
|
+
)
|
|
32
|
+
next session if session.valid?
|
|
33
|
+
|
|
34
|
+
Edupage.logger.debug("Stored session for #{origin} expired, logging in again")
|
|
35
|
+
renew(session, store)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Only a school the account can no longer reach is dropped, and forgotten so it is
|
|
40
|
+
# not pinged again. A wrong password still raises.
|
|
41
|
+
#
|
|
42
|
+
# A school behind 2FA stays listed, so asking for its data names the real problem
|
|
43
|
+
# instead of "no such school"; the other schools must stay usable meanwhile.
|
|
44
|
+
def renew(session, store)
|
|
45
|
+
session.refresh!
|
|
46
|
+
rescue TwoFactorRequiredError
|
|
47
|
+
session
|
|
48
|
+
rescue SessionExpiredError
|
|
49
|
+
store.delete(session.username, session.origin)
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def authenticate(credentials, store)
|
|
54
|
+
anchor = credentials.school
|
|
55
|
+
unless anchor
|
|
56
|
+
raise MissingCredentialsError,
|
|
57
|
+
"No school to log in against. Pass --school, set EDUPAGE_SCHOOL, or set default_school."
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
Client.mauth(school: anchor, username: credentials.username, password: credentials.password)
|
|
61
|
+
.map do |entry|
|
|
62
|
+
Session.new(
|
|
63
|
+
origin: entry[:origin], username: credentials.username, session_id: entry[:session_id],
|
|
64
|
+
credentials: credentials, userid: entry[:userid], role: entry[:role],
|
|
65
|
+
name: [entry[:first_name], entry[:last_name]].compact.join(" "), store: store
|
|
66
|
+
).tap(&:persist!)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
attr_reader :username, :credentials
|
|
72
|
+
|
|
73
|
+
def initialize(username:, sessions:, credentials:, store: SessionStore.new, cache: nil)
|
|
74
|
+
@username = username
|
|
75
|
+
@sessions = sessions
|
|
76
|
+
@credentials = credentials
|
|
77
|
+
@store = store
|
|
78
|
+
@cache = cache
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def schools
|
|
82
|
+
@schools ||= Relation.wrap(@sessions.map { |session| School.new(session, cache: cache) })
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def cache = @cache ||= Cache.new
|
|
86
|
+
|
|
87
|
+
# One school is taken silently; several without a choice is refused.
|
|
88
|
+
#
|
|
89
|
+
# The config's default_school is not consulted here on purpose - it says where to log
|
|
90
|
+
# in, not which school's data to read. See Registry::Context.
|
|
91
|
+
def school(origin = nil)
|
|
92
|
+
if origin.nil?
|
|
93
|
+
raise AmbiguousScopeError.new(:school, school_candidates) if schools.count > 1
|
|
94
|
+
|
|
95
|
+
return schools.first || raise(NotFoundError, "#{username} has access to no schools")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
schools.find { |school| school.matches?(origin) } or
|
|
99
|
+
raise NotFoundError,
|
|
100
|
+
"No school #{origin.inspect} for #{username}. Available: #{schools.map(&:origin).join(", ")}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def school_candidates
|
|
104
|
+
schools.map { |school| { id: school.origin, label: school.name } }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Taken from the login rather than any school page, so it works before any fetch.
|
|
108
|
+
def name = @sessions.map(&:name).compact.reject(&:empty?).first
|
|
109
|
+
|
|
110
|
+
def to_h
|
|
111
|
+
{ username: username, name: name, schools: schools.map(&:origin) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Forgets the stored sessions. The keychain password is untouched.
|
|
115
|
+
def logout!
|
|
116
|
+
@store.delete(username)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def inspect = "#<Edupage::Account #{username.inspect} schools=#{@sessions.map(&:origin).inspect}>"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Edupage
|
|
6
|
+
# File-backed cache for parsed Edupage payloads.
|
|
7
|
+
#
|
|
8
|
+
# Fetching is expensive in a way that matters here: one dashboard is ~375 KB and a
|
|
9
|
+
# single CLI invocation needs it before it can answer anything. Caching the parsed
|
|
10
|
+
# payload keyed by (school, year, child, resource) makes consecutive commands cheap.
|
|
11
|
+
#
|
|
12
|
+
# The year is part of the key because Edupage's directory changes between years - the
|
|
13
|
+
# class list for 2025 is genuinely different from 2026's - and the child is part of it
|
|
14
|
+
# because timetables are per-child.
|
|
15
|
+
#
|
|
16
|
+
# Anything from a finished school year never changes, so it is kept indefinitely;
|
|
17
|
+
# everything else gets a short TTL.
|
|
18
|
+
class Cache
|
|
19
|
+
FOREVER = Float::INFINITY
|
|
20
|
+
|
|
21
|
+
TTL = {
|
|
22
|
+
document: 3600, # dashboard: directory, four days of timetable, the feed
|
|
23
|
+
znamky: 900, # grades for the current year
|
|
24
|
+
timetable: 3600, # an explicitly fetched date range
|
|
25
|
+
default: 900
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :root
|
|
29
|
+
|
|
30
|
+
def initialize(root: Config.cache_dir, enabled: true)
|
|
31
|
+
@root = root
|
|
32
|
+
@enabled = enabled
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def enabled? = @enabled
|
|
36
|
+
|
|
37
|
+
def disable! = (@enabled = false)
|
|
38
|
+
|
|
39
|
+
# Reads a cached value or computes and stores it.
|
|
40
|
+
#
|
|
41
|
+
# `immutable: true` marks data from a closed school year, which is kept forever.
|
|
42
|
+
def fetch(*key, kind: :default, immutable: false, &block)
|
|
43
|
+
return block.call unless enabled?
|
|
44
|
+
|
|
45
|
+
path = path_for(key)
|
|
46
|
+
ttl = immutable ? FOREVER : TTL.fetch(kind, TTL[:default])
|
|
47
|
+
|
|
48
|
+
cached = read(path, ttl)
|
|
49
|
+
return cached if cached
|
|
50
|
+
|
|
51
|
+
block.call.tap { |value| write(path, value) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read(path, ttl)
|
|
55
|
+
return nil unless File.exist?(path)
|
|
56
|
+
return nil unless fresh?(path, ttl)
|
|
57
|
+
|
|
58
|
+
# Explicit UTF-8, never the locale's default: these payloads are full of Slovak
|
|
59
|
+
# names, and a process launched without LANG (an MCP client, a launchd job) gets
|
|
60
|
+
# US-ASCII as default_external, which makes every read of a cached page fail.
|
|
61
|
+
JSON.parse(File.read(path, encoding: Encoding::UTF_8))
|
|
62
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def write(path, value)
|
|
67
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
68
|
+
# Written via a temporary file so a concurrent reader never sees a half-written
|
|
69
|
+
# document; several processes share this directory.
|
|
70
|
+
temp = "#{path}.#{Process.pid}.tmp"
|
|
71
|
+
File.write(temp, JSON.generate(value), mode: "w:UTF-8")
|
|
72
|
+
File.rename(temp, path)
|
|
73
|
+
value
|
|
74
|
+
rescue SystemCallError => e
|
|
75
|
+
Edupage.logger.debug("Could not cache #{path}: #{e.message}")
|
|
76
|
+
value
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def clear
|
|
80
|
+
FileUtils.rm_rf(Dir.glob(File.join(root, "*")))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def entries = Dir.glob(File.join(root, "**", "*.json"))
|
|
84
|
+
|
|
85
|
+
def path_for(key)
|
|
86
|
+
parts = key.flatten.compact.map { |part| sanitize(part) }
|
|
87
|
+
File.join(root, *parts[0..-2], "#{parts.last}.json")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def fresh?(path, ttl)
|
|
93
|
+
return true if ttl == FOREVER
|
|
94
|
+
|
|
95
|
+
Time.now - File.mtime(path) < ttl
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Ids can be negative and origins are hostnames; keep both filesystem-safe.
|
|
99
|
+
def sanitize(part) = part.to_s.gsub(/[^A-Za-z0-9_.-]/, "_")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "yaml"
|
|
3
|
+
|
|
4
|
+
module Edupage
|
|
5
|
+
class CLI < Thor
|
|
6
|
+
# Renders a resolver result for a terminal.
|
|
7
|
+
#
|
|
8
|
+
# JSON and YAML go through Serializer, the same one the REST API and the MCP server
|
|
9
|
+
# use, so `--json` output is byte-identical across surfaces. The table view is the
|
|
10
|
+
# only thing unique to the CLI; Table draws it.
|
|
11
|
+
class Formatter
|
|
12
|
+
def initialize(output: $stdout, format: :table, width: nil)
|
|
13
|
+
@output = output
|
|
14
|
+
@format = format
|
|
15
|
+
@width = width || Table.terminal_width
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def render(result, resource: nil)
|
|
19
|
+
case @format
|
|
20
|
+
when :json then @output.puts(JSON.pretty_generate(Serializer.call(result)))
|
|
21
|
+
when :yaml then @output.puts(YAML.dump(deep_stringify(Serializer.call(result))))
|
|
22
|
+
else render_table(result, resource)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def render_table(result, resource)
|
|
29
|
+
records = result.is_a?(Relation) || result.is_a?(Array) ? result.to_a : [result]
|
|
30
|
+
return @output.puts("No records.") if records.empty?
|
|
31
|
+
|
|
32
|
+
fields = resource&.table_fields
|
|
33
|
+
fields = default_fields(records.first) if fields.nil? || fields.empty?
|
|
34
|
+
|
|
35
|
+
rows = records.map { |record| fields.map { |f| Serializer.field(record, f).to_s } }
|
|
36
|
+
headers = fields.map { |f| Serializer.header(f) }
|
|
37
|
+
@output.puts(Table.boxed(headers, rows, width: @width))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Falls back to whatever the object reports, for resources with no table_fields.
|
|
41
|
+
def default_fields(record)
|
|
42
|
+
return record.to_h.keys if record.respond_to?(:to_h)
|
|
43
|
+
|
|
44
|
+
[:to_s]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def deep_stringify(value)
|
|
48
|
+
case value
|
|
49
|
+
when Hash then value.to_h { |k, v| [k.to_s, deep_stringify(v)] }
|
|
50
|
+
when Array then value.map { |v| deep_stringify(v) }
|
|
51
|
+
else value
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
require "strings"
|
|
2
|
+
require "tty-screen"
|
|
3
|
+
require "tty-table"
|
|
4
|
+
require "unicode/display_width"
|
|
5
|
+
|
|
6
|
+
module Edupage
|
|
7
|
+
class CLI < Thor
|
|
8
|
+
# The one place tty-table is configured.
|
|
9
|
+
#
|
|
10
|
+
# Two shapes cover every aligned thing the CLI prints: #boxed for a result set
|
|
11
|
+
# (unicode frame, header row) and #plain for a short aligned list with no frame,
|
|
12
|
+
# such as the school/student/year block above a table.
|
|
13
|
+
#
|
|
14
|
+
# TTY::Table is always written out in full here - a bare `Table` inside this
|
|
15
|
+
# namespace is this module, not tty-table's.
|
|
16
|
+
module Table
|
|
17
|
+
# Below this a column carries no information, so shrinking stops there and the
|
|
18
|
+
# table is allowed to stay wider than the terminal.
|
|
19
|
+
MIN_COLUMN_WIDTH = 12
|
|
20
|
+
|
|
21
|
+
# One space of padding on each side of every column, inside the frame.
|
|
22
|
+
BOXED_PADDING = [0, 1].freeze
|
|
23
|
+
|
|
24
|
+
# The borderless renderer already separates columns with a single space, so its
|
|
25
|
+
# own padding would only make a label/value block airy.
|
|
26
|
+
PLAIN_PADDING = [0, 0].freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# A framed table with a header row.
|
|
31
|
+
def boxed(headers, rows, width: terminal_width)
|
|
32
|
+
# Unicode borders cost one vertical bar per column plus the closing one, on
|
|
33
|
+
# top of the two padding spaces each column already takes.
|
|
34
|
+
overhead = 3 * headers.size + 1
|
|
35
|
+
widths = fit(::TTY::Table.new(headers, rows), width, overhead)
|
|
36
|
+
|
|
37
|
+
table = ::TTY::Table.new(wrap(headers, widths), rows.map { |row| wrap(row, widths) })
|
|
38
|
+
table.render(:unicode, width: canvas(widths, overhead, width), column_widths: widths,
|
|
39
|
+
multiline: true, padding: BOXED_PADDING)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# An aligned list with no border and no header: label/value pairs, candidate
|
|
43
|
+
# lists, anything that used to be built with ljust.
|
|
44
|
+
def plain(rows, width: terminal_width, indent: 0)
|
|
45
|
+
return "" if rows.empty?
|
|
46
|
+
|
|
47
|
+
# The null border draws nothing; the single space between columns is all there
|
|
48
|
+
# is on top of the content.
|
|
49
|
+
overhead = rows.first.size - 1
|
|
50
|
+
widths = fit(::TTY::Table.new(rows), width - indent, overhead)
|
|
51
|
+
|
|
52
|
+
table = ::TTY::Table.new(rows.map { |row| wrap(row, widths) })
|
|
53
|
+
rendered = table.render(:basic, width: canvas(widths, overhead, width - indent),
|
|
54
|
+
column_widths: widths,
|
|
55
|
+
multiline: true, padding: PLAIN_PADDING)
|
|
56
|
+
|
|
57
|
+
# Indented here rather than through the renderer's own :indent option, which
|
|
58
|
+
# only reaches the first line of a row and leaves wrapped continuations hanging
|
|
59
|
+
# to the left of the column they belong to.
|
|
60
|
+
#
|
|
61
|
+
# The renderer also pads the last column out to its full width; nothing follows
|
|
62
|
+
# it, so that is just trailing whitespace.
|
|
63
|
+
rendered.lines.map { |line| line.rstrip.empty? ? "" : "#{" " * indent}#{line.rstrip}" }
|
|
64
|
+
.join("\n")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def terminal_width = ::TTY::Screen.width
|
|
68
|
+
|
|
69
|
+
# Natural column widths, narrowed until the table fits the terminal.
|
|
70
|
+
#
|
|
71
|
+
# tty-table's own `resize: true` splits the width evenly between columns, which
|
|
72
|
+
# squeezes short ones like `value` and wraps their headers for no reason. The
|
|
73
|
+
# budget comes off the widest column instead: dates and marks stay whole and
|
|
74
|
+
# only the free-text column gives ground.
|
|
75
|
+
def fit(table, width, overhead)
|
|
76
|
+
widths = ::TTY::Table::Columns.widths_from(table)
|
|
77
|
+
budget = width - overhead
|
|
78
|
+
|
|
79
|
+
while widths.sum > budget
|
|
80
|
+
widest = widths.each_index.max_by { |index| widths[index] }
|
|
81
|
+
break if widths[widest] <= MIN_COLUMN_WIDTH
|
|
82
|
+
|
|
83
|
+
widths[widest] -= 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
widths
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The width handed to the renderer. Once every column is down to
|
|
90
|
+
# MIN_COLUMN_WIDTH the table can still be wider than the terminal - a timetable
|
|
91
|
+
# has six columns and a narrow window. Reporting the real total keeps it
|
|
92
|
+
# horizontal and lets the terminal wrap the overflow; a width it does not fit in
|
|
93
|
+
# makes tty-table flip the whole thing into vertical orientation and print a
|
|
94
|
+
# warning on stdout, which is worse and would end up in a pipe.
|
|
95
|
+
def canvas(widths, overhead, width) = [width, widths.sum + overhead].max
|
|
96
|
+
|
|
97
|
+
# Breaks the cells across lines before tty-table sees them, so the renderer has
|
|
98
|
+
# nothing left to wrap.
|
|
99
|
+
#
|
|
100
|
+
# strings 0.2.1, which tty-table wraps with, can return a line one character
|
|
101
|
+
# wider than asked for. In Strings::Wrap.format_line a word that ends exactly on
|
|
102
|
+
# the boundary leaves the line buffer empty; the space that follows it is then
|
|
103
|
+
# flushed as an empty line and glued onto the next word, which comes back out one
|
|
104
|
+
# character over - Strings.wrap("Slovenský jazyk", 9) gives "\nSlovenský \njazyk".
|
|
105
|
+
# Inside a frame that single character tears the border open.
|
|
106
|
+
#
|
|
107
|
+
# Wrapping one column short absorbs it: an overlong line still fits. That costs a
|
|
108
|
+
# character, so it is only done for the cells that actually trip the bug.
|
|
109
|
+
def wrap(cells, widths)
|
|
110
|
+
cells.each_with_index.map do |cell, index|
|
|
111
|
+
text = cell.to_s
|
|
112
|
+
width = widths[index]
|
|
113
|
+
next text if fits?(text, width)
|
|
114
|
+
|
|
115
|
+
lines = wrap_lines(text, width)
|
|
116
|
+
lines = wrap_lines(text, width - 1) if width > 1 && spoiled?(lines, width)
|
|
117
|
+
# The leading empty line the bug emits carries no content of its own.
|
|
118
|
+
lines.shift while lines.first&.empty?
|
|
119
|
+
lines.join("\n")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def wrap_lines(text, width) = ::Strings.wrap(text, width).lines.map(&:chomp)
|
|
124
|
+
|
|
125
|
+
def spoiled?(lines, width)
|
|
126
|
+
lines.first&.empty? || lines.any? { |line| ::Unicode::DisplayWidth.of(line) > width }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A cell already inside its column is handed over untouched: the renderer has
|
|
130
|
+
# nothing to wrap, so it cannot widen it either.
|
|
131
|
+
def fits?(text, width)
|
|
132
|
+
text.lines.all? { |line| ::Unicode::DisplayWidth.of(line.chomp) <= width }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|