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
@@ -0,0 +1,185 @@
1
+ module Edupage
2
+ # Lazy, chainable collection.
3
+ #
4
+ # Nothing is fetched until the relation is enumerated, so `student.homeworks` costs
5
+ # nothing and `student.homeworks.where(...).first` fetches once. Filters are applied
6
+ # in Ruby because Edupage has no query interface - the payload always arrives whole.
7
+ class Relation
8
+ include Enumerable
9
+
10
+ def initialize(loader = nil, records: nil)
11
+ @loader = loader
12
+ @records = records
13
+ @conditions = []
14
+ @order = []
15
+ @limit_value = nil
16
+ @offset_value = 0
17
+ end
18
+
19
+ def self.wrap(records) = new(records: Array(records))
20
+
21
+ def each(&block) = resolved.each(&block)
22
+
23
+ # Conditions are ANDed. Values may be:
24
+ # exact where(short: "MAT") - case-insensitive
25
+ # regexp where(name: /Jana/)
26
+ # range where(due: Date.today..)
27
+ # array where(type: %i[hw test]) - any of
28
+ # model where(subject: subject)
29
+ # predicate where(due: ->(d) { d.monday? })
30
+ def where(**conditions)
31
+ chain { |copy| copy.conditions.concat(conditions.to_a) }
32
+ end
33
+
34
+ # order(:due) or order(due: :desc) or order(:subject, :due)
35
+ def order(*keys, **directions)
36
+ pairs = keys.map { |k| [k, :asc] } + directions.to_a
37
+ chain { |copy| copy.order_keys.concat(pairs) }
38
+ end
39
+
40
+ def limit(count) = chain { |copy| copy.limit_value = count }
41
+ def offset(count) = chain { |copy| copy.offset_value = count }
42
+
43
+ def find_by(**conditions) = where(**conditions).first
44
+
45
+ # find(id) looks a record up by id, like ActiveRecord. With a block it stays
46
+ # Enumerable#find, so `relation.find { |r| ... }` keeps working.
47
+ def find(id = nil, &block)
48
+ return super(&block) if block
49
+
50
+ resolved.find { |record| record.respond_to?(:id) && record.id.to_s == id.to_s }
51
+ end
52
+
53
+ def first(count = nil) = count ? resolved.first(count) : resolved.first
54
+ def last(count = nil) = count ? resolved.last(count) : resolved.last
55
+ # count, count(value) and count { ... } all behave as Enumerable defines them;
56
+ # the bare form is the common "how many records" case.
57
+ def count(*args, &block)
58
+ return resolved.size if args.empty? && block.nil?
59
+
60
+ resolved.count(*args, &block)
61
+ end
62
+
63
+ def size = resolved.size
64
+ def empty? = resolved.empty?
65
+ def to_a = resolved.dup
66
+ def to_ary = to_a
67
+
68
+ def inspect
69
+ "#<Edupage::Relation #{loaded? ? "#{resolved.size} records" : "not loaded"}>"
70
+ end
71
+
72
+ def loaded? = !@resolved.nil?
73
+
74
+ protected
75
+
76
+ attr_accessor :limit_value, :offset_value
77
+ attr_reader :conditions
78
+
79
+ def order_keys = @order
80
+
81
+ def initialize_copy(source)
82
+ super
83
+ @conditions = source.conditions.dup
84
+ @order = source.order_keys.dup
85
+ @resolved = nil
86
+ end
87
+
88
+ private
89
+
90
+ def chain
91
+ copy = dup
92
+ yield copy
93
+ copy
94
+ end
95
+
96
+ def resolved
97
+ @resolved ||= begin
98
+ records = @records || Array(@loader.call)
99
+ records = records.select { |record| matches_all?(record) } unless @conditions.empty?
100
+ records = sort(records) unless @order.empty?
101
+ records = records.drop(@offset_value || 0)
102
+ limit = @limit_value
103
+ limit ? records.first(limit) : records
104
+ end
105
+ end
106
+
107
+ def matches_all?(record)
108
+ @conditions.all? { |key, query| matches?(record, key, query) }
109
+ end
110
+
111
+ # `name` is the one filter that searches the record rather than a single attribute:
112
+ # asking for a subject by "MAT" should find "Matematika", and a teacher by their
113
+ # short code should find them too. Every other key compares its attribute directly.
114
+ NAME_KEY = :name
115
+
116
+ def matches?(record, key, query)
117
+ return true if query.nil?
118
+
119
+ if key == NAME_KEY && record.respond_to?(:match_strings)
120
+ return Array(query).any? { |q| record.matches?(q) } if query.is_a?(Array)
121
+
122
+ return record.matches?(query)
123
+ end
124
+
125
+ return false unless record.respond_to?(key)
126
+
127
+ value = record.public_send(key)
128
+
129
+ case query
130
+ when Proc then query.call(value)
131
+ when Array then query.any? { |q| matches_value?(value, q) }
132
+ else matches_value?(value, query)
133
+ end
134
+ end
135
+
136
+ def matches_value?(value, query)
137
+ case query
138
+ when Range then !value.nil? && query.cover?(value)
139
+ when Regexp then matchable(value).any? { |candidate| candidate.match?(query) }
140
+ else
141
+ # Delegating to the model lets `where(subject: "MAT")` hit either the short
142
+ # code or the full name without the caller knowing which.
143
+ return value.matches?(query) if value.is_a?(Model)
144
+ return value == query if query.is_a?(Model)
145
+
146
+ matchable(value).any? { |candidate| candidate.casecmp?(query.to_s) }
147
+ end
148
+ end
149
+
150
+ def matchable(value)
151
+ case value
152
+ when Model then value.match_strings
153
+ when Array then value.flat_map { |v| matchable(v) }
154
+ when nil then []
155
+ else [value.to_s]
156
+ end
157
+ end
158
+
159
+ def sort(records)
160
+ records.sort do |a, b|
161
+ @order.reduce(0) do |result, (key, direction)|
162
+ next result unless result.zero?
163
+
164
+ comparison = compare(read(a, key), read(b, key))
165
+ direction.to_sym == :desc ? -comparison : comparison
166
+ end
167
+ end
168
+ end
169
+
170
+ def read(record, key) = record.respond_to?(key) ? record.public_send(key) : nil
171
+
172
+ # nil sorts last, so an unset due date does not lead the list.
173
+ def compare(left, right)
174
+ return 0 if left.nil? && right.nil?
175
+ return 1 if left.nil?
176
+ return -1 if right.nil?
177
+
178
+ # Models sort by their display name; mixed types fall back to string order.
179
+ left = left.name.to_s if left.is_a?(Model)
180
+ right = right.name.to_s if right.is_a?(Model)
181
+
182
+ (left <=> right) || (left.to_s <=> right.to_s)
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,358 @@
1
+ module Edupage
2
+ # One school reachable by the account, and the owner of everything fetched from it.
3
+ #
4
+ # Fetching is centralised here for two reasons:
5
+ #
6
+ # * /user/ is one 375 KB request that backs roughly ten different collections, so it
7
+ # is fetched once per (child, year) and shared.
8
+ # * The child and year cursors live in the session, so every fetch has to be wrapped
9
+ # in Session#with_cursor. Doing that in one place keeps models from having to know
10
+ # about it.
11
+ class School
12
+ USER_PATH = "/user/".freeze
13
+
14
+ attr_reader :session, :cache
15
+
16
+ def initialize(session, cache: Cache.new)
17
+ @session = session
18
+ @cache = cache
19
+ @documents = {}
20
+ end
21
+
22
+ def origin = session.origin
23
+ def role = session.role
24
+ def parent? = session.parent?
25
+ def name = document.school_name
26
+ def id = origin
27
+ def short = origin
28
+
29
+ def match_strings = [origin, name].compact
30
+ def matches?(query)
31
+ return true if query.nil?
32
+
33
+ case query
34
+ when Regexp then match_strings.any? { |c| c.match?(query) }
35
+ else match_strings.any? { |c| c.casecmp?(query.to_s) }
36
+ end
37
+ end
38
+
39
+ # --- school directory -----------------------------------------------------------
40
+
41
+ def students(year: nil)
42
+ scope = parent? ? own_children(year: year) : build(Models::Student, "students", year: year)
43
+ Relation.wrap(scope)
44
+ end
45
+
46
+ def teachers(year: nil) = Relation.wrap(build(Models::Teacher, "teachers", year: year))
47
+ def parents(year: nil) = Relation.wrap(build(Models::Parent, "parents", year: year))
48
+ def classes(year: nil) = Relation.wrap(build(Models::SchoolClass, "classes", year: year))
49
+ def classrooms(year: nil) = Relation.wrap(build(Models::Classroom, "classrooms", year: year))
50
+ def subjects(year: nil) = Relation.wrap(build(Models::Subject, "subjects", year: year))
51
+ def periods(year: nil) = Relation.wrap(build(Models::Period, "periods", year: year))
52
+
53
+ # id lookups, memoized per year so resolving a lesson's teacher is not O(n) each time
54
+ # Looks through every pupil, not only a parent's own children, because timeline
55
+ # items and grades can reference classmates.
56
+ def student(id)
57
+ return nil if id.nil? || id.to_s.empty?
58
+
59
+ (@student_index ||= all_students.to_h { |s| [s.id, s] })[id.to_s]
60
+ end
61
+
62
+ def teacher(id) = lookup(:teachers, id)
63
+ def parent(id) = lookup(:parents, id)
64
+ def school_class(id) = lookup(:classes, id)
65
+ def classroom(id) = lookup(:classrooms, id)
66
+ def subject(id) = lookup(:subjects, id)
67
+ def period(id) = lookup(:periods, id)
68
+
69
+ # --- years ----------------------------------------------------------------------
70
+
71
+ # The real current school year, regardless of where the session cursor happens to
72
+ # be pointing.
73
+ def current_year
74
+ @current_year ||= begin
75
+ # Bootstrap: the year cursor is shared and persists across processes, so a
76
+ # previous command may have left it in the past. Read the page once without
77
+ # pinning anything, take autoYear from it - that value ignores the cursor - and
78
+ # keep the document if it happens to already be the right year.
79
+ doc = fetch_document(child: nil, year: nil)
80
+ @documents[[nil, doc.current_year]] ||= doc if doc.selected_year == doc.current_year
81
+ doc.current_year
82
+ end
83
+ end
84
+
85
+ # Every school year the student has data for, newest first.
86
+ #
87
+ # yearterms lists them all in one response, together with each term's date span and
88
+ # grade count, so the listing needs exactly one fetch - pinned to the current year
89
+ # so repeated runs agree. Asking Edupage about each year separately would be both
90
+ # slower and unreliable: switching to a year the child was never enrolled in is
91
+ # silently ignored, and the page trails the switch by a request or two.
92
+ def years_for(student)
93
+ @years ||= {}
94
+ @years[student.id] ||= begin
95
+ rows = znamky_for(student, year: current_year).year_terms
96
+ names = rows.to_h { |row| [row["yearid"].to_s, row["yearName"]] }
97
+ ids = (rows.map { |row| row["yearid"].to_s } + [current_year.to_s])
98
+ .uniq.reject(&:empty?).sort.reverse
99
+
100
+ Relation.wrap(
101
+ ids.map do |id|
102
+ Year.new(school: self, student: student, id: id, name: names[id],
103
+ current: id == current_year.to_s,
104
+ term_rows: rows.select { |row| row["yearid"].to_s == id })
105
+ end
106
+ )
107
+ end
108
+ end
109
+
110
+ # The parsed grades page for a student, year and half-year.
111
+ #
112
+ # Child and year are session cursors; the half-year is a query parameter. Omitting
113
+ # the term yields whichever half Edupage considers current, which is what the year
114
+ # and term listings are read from.
115
+ def znamky_for(student, year: nil, term: nil)
116
+ year = (year || current_year).to_s
117
+ key = [student.id, year, term&.to_s]
118
+
119
+ (@znamky ||= {})[key] ||= settle({ year: year, student: student.id },
120
+ "grades for #{student.full_name}") do
121
+ parsed = fetch_znamky(student, year, term)
122
+ [parsed, { year: parsed.year_id, student: parsed.student_id }]
123
+ end
124
+ end
125
+
126
+ # --- timetable ------------------------------------------------------------------
127
+
128
+ # range may be nil (today), a Date, or a Date range.
129
+ def timetable_for(student, range = nil)
130
+ case range
131
+ when nil then day_for(student, Date.today)
132
+ when Date then day_for(student, range)
133
+ when Range then Relation.new(-> { days_for(student, range.first, range.last) })
134
+ else raise ArgumentError, "Expected a Date or a Date range, got #{range.class}"
135
+ end
136
+ end
137
+
138
+ def lessons_for(student, date = nil)
139
+ day = timetable_for(student, date || Date.today)
140
+ day ? day.lessons : Relation.wrap([])
141
+ end
142
+
143
+ # --- timeline and assignments ---------------------------------------------------
144
+
145
+ # The whole feed, or just one student's share of it.
146
+ #
147
+ # For a parent the feed is shared across children, so one fetch serves everyone and
148
+ # the split happens locally via childGroups.
149
+ def timeline_for(student = nil)
150
+ Relation.new(lambda {
151
+ rows = student ? feed.for_student(student.id) : feed.items
152
+ rows.map { |row| build_timeline_item(row, student) }
153
+ })
154
+ end
155
+
156
+ def assignments_for(student = nil)
157
+ Relation.new(lambda {
158
+ rows = student ? feed.for_student(student.id) : feed.items
159
+ rows.select { |row| Models::Assignment.assignment?(row) }
160
+ .map { |row| Models::Assignment.new(row, school: self, student: student) }
161
+ })
162
+ end
163
+
164
+ # Turns an Edupage userstring ("Student113506", "Ucitel37135", "Rodic-20079") into
165
+ # the record it names, when we have one.
166
+ def resolve_user_string(value)
167
+ return nil if value.nil? || value.to_s.empty?
168
+
169
+ match = value.to_s.match(/\A(Student|StudentOnly|Ucitel|Rodic)(-?\d+)\z/)
170
+ return nil unless match
171
+
172
+ case match[1]
173
+ when "Student", "StudentOnly" then student(match[2])
174
+ when "Ucitel" then teacher(match[2])
175
+ when "Rodic" then parent(match[2])
176
+ end
177
+ end
178
+
179
+ # --- fetching -------------------------------------------------------------------
180
+
181
+ # The parsed /user/ document for a given child and year.
182
+ #
183
+ # Edupage usually applies a year switch immediately but occasionally serves a
184
+ # dashboard from before it (several app servers, each with its own cache), so the
185
+ # year the document reports is verified and the fetch retried once. Without this,
186
+ # `school.year(2025).classes` can silently return this year's class list.
187
+ def document(child: nil, year: nil)
188
+ # Never fall back to whatever year the shared session was left on: pin it.
189
+ year ||= current_year
190
+ key = [child&.to_s, year&.to_s]
191
+
192
+ # Both cursors are verified, not just the year: the dashboard trails a child
193
+ # switch the same way, and an unverified fetch hands back one child's timetable
194
+ # under the other child's name.
195
+ @documents[key] ||= settle({ year: year, child: child }, "dashboard") do
196
+ parsed = fetch_document(child: child, year: year)
197
+ [parsed, { year: parsed.selected_year, child: parsed.logged_child }]
198
+ end
199
+ end
200
+
201
+ def reload!
202
+ @documents.clear
203
+ @lookups = nil
204
+ @built = nil
205
+ @years = nil
206
+ @znamky = nil
207
+ @current_year = nil
208
+ @feed = nil
209
+ @student_index = nil
210
+ self
211
+ end
212
+
213
+ def to_h
214
+ { origin: origin, name: name, role: role, current_year: current_year }
215
+ end
216
+
217
+ def inspect = "#<Edupage::School #{origin.inspect} role=#{role.inspect}>"
218
+
219
+ private
220
+
221
+ # How many times to refetch while waiting for a year switch to show up.
222
+ SETTLE_ATTEMPTS = 4
223
+
224
+ # Refetches until the page reports the child and year that were asked for.
225
+ #
226
+ # Both cursor switches are acknowledged immediately but the pages trail them by one
227
+ # or two requests. Serving that lagging page would be the worst possible outcome -
228
+ # one child's timetable or last year's grades presented as the other's - so the
229
+ # response is checked against what was requested and refused if it never catches up.
230
+ #
231
+ # A year the student was never enrolled in never takes effect at all, and surfaces
232
+ # here as NotFoundError rather than as somebody else's data.
233
+ def settle(expected, what)
234
+ wanted = expected.compact.transform_values(&:to_s)
235
+ return yield.first if wanted.empty?
236
+
237
+ seen = nil
238
+
239
+ SETTLE_ATTEMPTS.times do
240
+ parsed, actual = yield
241
+ actual = actual.transform_values { |v| v&.to_s }
242
+ return parsed if wanted.all? { |key, value| actual[key] == value }
243
+
244
+ seen = actual
245
+ Edupage.logger.debug("#{origin}: #{what} came back as #{actual.inspect}, waiting for #{wanted.inspect}")
246
+ end
247
+
248
+ raise NotFoundError,
249
+ "#{origin} never returned #{what} for #{describe(wanted)} " \
250
+ "(it keeps answering with #{describe(seen)}). The student may not have been enrolled then."
251
+ end
252
+
253
+ def describe(values)
254
+ return "nothing" if values.nil?
255
+
256
+ values.map { |key, value| "#{key}=#{value}" }.join(" ")
257
+ end
258
+
259
+ def feed
260
+ @feed ||= Parsers::Timeline.from_userhome(document)
261
+ end
262
+
263
+ # Assignment rows become Assignments; everything else stays a plain item.
264
+ def build_timeline_item(row, student)
265
+ klass = Models::Assignment.assignment?(row) ? Models::Assignment : Models::TimelineItem
266
+ klass.new(row, school: self, student: student)
267
+ end
268
+
269
+ # Every pupil in the school, as opposed to #students which narrows to a parent's
270
+ # own children.
271
+ def all_students(year: nil)
272
+ build(Models::Student, "students", year: year)
273
+ end
274
+
275
+ # Cached payloads are the parsed blobs rather than the raw HTML: a tenth of the
276
+ # size, and no need to re-scan 375 KB on every read.
277
+ def fetch_znamky(student, year, term)
278
+ blob = cache.fetch(origin, year, student.id, "znamky-#{term || "current"}",
279
+ kind: :znamky, immutable: closed_year?(year)) do
280
+ html = session.with_cursor(child: student.id, year: year) do |s|
281
+ s.get(Parsers::Znamky.path_for(term: term)).body
282
+ end
283
+ parsed = Parsers::Znamky.parse(html)
284
+ { "data" => parsed.data, "settings" => parsed.settings }
285
+ end
286
+
287
+ Parsers::Znamky.new(blob["data"], settings: blob["settings"] || {})
288
+ end
289
+
290
+ def fetch_document(child:, year:)
291
+ blob = cache.fetch(origin, year, child || "default", "user",
292
+ kind: :document, immutable: closed_year?(year)) do
293
+ html = session.with_cursor(child: child, year: year) { |s| s.get(USER_PATH).body }
294
+ parsed = Parsers::Userhome.parse(html)
295
+ { "data" => parsed.data, "edubar" => parsed.edubar, "asc" => parsed.asc }
296
+ end
297
+
298
+ Parsers::Userhome.new(blob["data"], edubar: blob["edubar"] || {}, asc: blob["asc"] || {})
299
+ end
300
+
301
+ # A year that has ended can no longer change, so its pages are cached indefinitely.
302
+ def closed_year?(year)
303
+ year && @current_year && year.to_s < @current_year.to_s
304
+ end
305
+
306
+ # For a parent account, `students` means the account's own children rather than
307
+ # every pupil in the school.
308
+ def own_children(year: nil)
309
+ doc = document(year: year)
310
+ ids = doc.parent_student_ids
311
+ all = build(Models::Student, "students", year: year)
312
+ return all if ids.empty?
313
+
314
+ ids.filter_map { |id| all.find { |student| student.id == id } }
315
+ end
316
+
317
+ def build(model, collection, year: nil)
318
+ cache_key = [model, collection, year&.to_s]
319
+ (@built ||= {})[cache_key] ||=
320
+ document(year: year).collection(collection).map { |row| model.new(row, school: self) }
321
+ end
322
+
323
+ # Lookups always use the currently loaded year: a lesson fetched for 2025 must
324
+ # resolve its teacher against 2025's directory.
325
+ def lookup(collection, id)
326
+ return nil if id.nil? || id.to_s.empty?
327
+
328
+ @lookups ||= {}
329
+ @lookups[collection] ||= public_send(collection).to_h { |record| [record.id, record] }
330
+ @lookups[collection][id.to_s]
331
+ end
332
+
333
+ def day_for(student, date)
334
+ days_for(student, date, date).first
335
+ end
336
+
337
+ def days_for(student, from, to)
338
+ doc = document(child: student.id)
339
+ dates = doc.dates
340
+
341
+ missing = (from..to).map(&:iso8601) - dates.keys
342
+ dates = dates.merge(fetch_range(student, from, to)) unless missing.empty?
343
+
344
+ (from..to).filter_map do |date|
345
+ row = dates[date.iso8601]
346
+ Models::Day.new(row, school: self, date: date) if row
347
+ end
348
+ end
349
+
350
+ # /user/ only ever carries about four days; anything wider comes from /gcall.
351
+ def fetch_range(student, from, to)
352
+ cache.fetch(origin, current_year, student.id, "tt-#{from.iso8601}-#{to.iso8601}",
353
+ kind: :timetable) do
354
+ Parsers::Gcall.new(self).load(student: student, from: from, to: to)
355
+ end
356
+ end
357
+ end
358
+ end
@@ -0,0 +1,53 @@
1
+ module Edupage
2
+ # Turns whatever a resolver returned into plain JSON-ready data.
3
+ #
4
+ # Every surface goes through here, so `edupage grades --json`, the REST response and
5
+ # the MCP tool result are the same bytes. Models decide their own shape in #to_h;
6
+ # this only deals with collections, dates and stray objects.
7
+ module Serializer
8
+ module_function
9
+
10
+ def call(result)
11
+ case result
12
+ when Relation, Array then result.map { |item| one(item) }
13
+ else one(result)
14
+ end
15
+ end
16
+
17
+ def one(object)
18
+ case object
19
+ when nil then nil
20
+ when Hash then object.transform_keys(&:to_sym).transform_values { |v| one(v) }
21
+ when Array then object.map { |item| one(item) }
22
+ when Date, Time then object.iso8601
23
+ when String, Numeric, TrueClass, FalseClass then object
24
+ else
25
+ object.respond_to?(:to_h) ? one(object.to_h) : object.to_s
26
+ end
27
+ end
28
+
29
+ # Value for one column of a table, addressed by method name. Predicate names are
30
+ # allowed so table_fields can say :class_wide? without a shadow accessor.
31
+ def field(object, name)
32
+ return nil unless object.respond_to?(name)
33
+
34
+ display(object.public_send(name))
35
+ end
36
+
37
+ def header(name) = name.to_s.delete_suffix("?").tr("_", " ")
38
+
39
+ def display(value)
40
+ case value
41
+ when nil then ""
42
+ when true then "yes"
43
+ when false then "no"
44
+ when Date then value.iso8601
45
+ when Time then value.strftime("%Y-%m-%d %H:%M")
46
+ when Array then value.map { |v| display(v) }.reject(&:empty?).join(", ")
47
+ when Relation then display(value.to_a)
48
+ when Model then value.name.to_s
49
+ else value.to_s
50
+ end
51
+ end
52
+ end
53
+ end