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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +250 -0
  3. data/exe/edupage +7 -0
  4. data/lib/edupage/account.rb +121 -0
  5. data/lib/edupage/cache.rb +101 -0
  6. data/lib/edupage/cli/formatter.rb +56 -0
  7. data/lib/edupage/cli/table.rb +136 -0
  8. data/lib/edupage/cli.rb +543 -0
  9. data/lib/edupage/client.rb +152 -0
  10. data/lib/edupage/config.rb +96 -0
  11. data/lib/edupage/credentials/env.rb +27 -0
  12. data/lib/edupage/credentials/keychain.rb +120 -0
  13. data/lib/edupage/credentials.rb +118 -0
  14. data/lib/edupage/errors.rb +74 -0
  15. data/lib/edupage/model.rb +131 -0
  16. data/lib/edupage/models/assignment.rb +83 -0
  17. data/lib/edupage/models/classroom.rb +13 -0
  18. data/lib/edupage/models/day.rb +51 -0
  19. data/lib/edupage/models/grade.rb +100 -0
  20. data/lib/edupage/models/lesson.rb +58 -0
  21. data/lib/edupage/models/parent.rb +8 -0
  22. data/lib/edupage/models/period.rb +13 -0
  23. data/lib/edupage/models/person.rb +25 -0
  24. data/lib/edupage/models/school_class.rb +16 -0
  25. data/lib/edupage/models/student.rb +45 -0
  26. data/lib/edupage/models/subject.rb +9 -0
  27. data/lib/edupage/models/teacher.rb +25 -0
  28. data/lib/edupage/models/term.rb +36 -0
  29. data/lib/edupage/models/timeline_item.rb +87 -0
  30. data/lib/edupage/parsers/base.rb +150 -0
  31. data/lib/edupage/parsers/gcall.rb +53 -0
  32. data/lib/edupage/parsers/timeline.rb +46 -0
  33. data/lib/edupage/parsers/userhome.rb +84 -0
  34. data/lib/edupage/parsers/znamky.rb +83 -0
  35. data/lib/edupage/registry/resources.rb +175 -0
  36. data/lib/edupage/registry.rb +286 -0
  37. data/lib/edupage/relation.rb +185 -0
  38. data/lib/edupage/school.rb +358 -0
  39. data/lib/edupage/serializer.rb +53 -0
  40. data/lib/edupage/server/api.rb +113 -0
  41. data/lib/edupage/server/auth.rb +45 -0
  42. data/lib/edupage/server/mcp.rb +107 -0
  43. data/lib/edupage/server.rb +57 -0
  44. data/lib/edupage/session.rb +166 -0
  45. data/lib/edupage/session_store.rb +131 -0
  46. data/lib/edupage/version.rb +3 -0
  47. data/lib/edupage/year.rb +77 -0
  48. data/lib/edupage.rb +64 -0
  49. 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,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "edupage"
5
+ require "edupage/cli"
6
+
7
+ Edupage::CLI.start(ARGV)
@@ -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