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,46 @@
1
+ require "json"
2
+
3
+ module Edupage
4
+ module Parsers
5
+ # The timeline is the school's activity feed: messages, homework, absences, canteen
6
+ # menus, sign-ups, substitutions.
7
+ #
8
+ # For a parent it is a single feed covering every child, not one feed per child, so
9
+ # nothing needs switching to read it - but each item has to be attributed. That is
10
+ # what childGroups is for: it lists every userstring that stands for a given child
11
+ # ("Student113506", "Trieda-2", "CustPlan8503", ...), and an item belongs to the
12
+ # child whose list contains its recipient.
13
+ class Timeline
14
+ PATH = "/timeline/?akcia=getData".freeze
15
+
16
+ attr_reader :items, :child_groups
17
+
18
+ def initialize(items, child_groups: {})
19
+ @items = Array(items)
20
+ @child_groups = child_groups
21
+ end
22
+
23
+ # /user/ already carries the recent feed, so the common case needs no extra fetch.
24
+ def self.from_userhome(document)
25
+ new(document.items, child_groups: document.child_groups)
26
+ end
27
+
28
+ def self.parse(json, child_groups: {})
29
+ payload = json.is_a?(String) ? JSON.parse(json) : json
30
+ new(payload["items"] || payload["timelineItems"] || [], child_groups: child_groups)
31
+ end
32
+
33
+ # Userstrings that stand for the given student.
34
+ def groups_for(student_id)
35
+ Array(child_groups[student_id.to_s]).to_set
36
+ end
37
+
38
+ def for_student(student_id)
39
+ groups = groups_for(student_id)
40
+ return items if groups.empty?
41
+
42
+ items.select { |item| groups.include?(item["user"].to_s) }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,84 @@
1
+ module Edupage
2
+ module Parsers
3
+ # Reads /user/, which is the workhorse page: one fetch carries the whole school
4
+ # directory (dbi), four days of timetable (dp), the timeline feed (items) and the
5
+ # per-child group membership needed to attribute those items.
6
+ #
7
+ # Three separate payloads live in that HTML and all three matter:
8
+ # .userhome({...}) the data above
9
+ # .edubar({...}) session state - selected year, current child
10
+ # ASC.x = y; school identity and the gsecHash needed by /gcall
11
+ class Userhome
12
+ def self.parse(html)
13
+ new(
14
+ Base.extract_blob!(html, ".userhome"),
15
+ edubar: Base.extract_blob(html, ".edubar") || {},
16
+ asc: Base.extract_assignments(html, "ASC")
17
+ )
18
+ end
19
+
20
+ attr_reader :data, :edubar, :asc
21
+
22
+ def initialize(data, edubar: {}, asc: {})
23
+ @data = data
24
+ @edubar = edubar
25
+ @asc = asc
26
+ end
27
+
28
+ # --- school directory ---------------------------------------------------------
29
+
30
+ def dbi = data["dbi"] || {}
31
+
32
+ # dbi collections are keyed by id, except periods which arrive as an array.
33
+ def collection(name) = normalize(dbi[name])
34
+
35
+ # --- timetable ----------------------------------------------------------------
36
+
37
+ def dates = data.dig("dp", "dates") || {}
38
+
39
+ # --- timeline -----------------------------------------------------------------
40
+
41
+ def items = Array(data["items"])
42
+
43
+ # Maps a child id to every userstring that stands for them ("Student113506",
44
+ # "Trieda-2", "CustPlan8503", ...). Timeline items are addressed to one of these,
45
+ # so this is how a shared parent feed is split per child.
46
+ def child_groups = data["childGroups"] || {}
47
+
48
+ def parent_student_ids = Array(data["parentStudentids"]).map(&:to_s)
49
+
50
+ # --- identity and session state -----------------------------------------------
51
+
52
+ def user_id = data["userid"] || edubar["loggedUser"]
53
+ def user_row = data["userrow"] || {}
54
+ def user_name = edubar["loggedUserName"] || [user_row["p_meno"], user_row["p_priezvisko"]].compact.join(" ")
55
+ def login = user_row["p_www_login"]
56
+ def email = user_row["p_mail"]
57
+
58
+ def school_name = asc["school_name"]
59
+ def origin = asc["edupage"] || edubar["edupage"]
60
+ def language = asc["lang"]
61
+ def gsec_hash = asc["gsechash"]
62
+
63
+ # The year the session is pointed at, which is not necessarily the current one.
64
+ def selected_year = edubar["selectedYear"]&.to_s
65
+
66
+ # The real current year, unaffected by the cursor.
67
+ def current_year = edubar["autoYear"]&.to_s || selected_year
68
+
69
+ def logged_child = edubar["loggedChild"]&.to_s
70
+
71
+ def teaching_days = Array(edubar["vyucovacieDni"])
72
+
73
+ private
74
+
75
+ def normalize(value)
76
+ case value
77
+ when Hash then value.values
78
+ when Array then value
79
+ else []
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,83 @@
1
+ module Edupage
2
+ module Parsers
3
+ # Reads /znamky/, which carries a whole school year for the currently selected
4
+ # child: the grades themselves, the events they hang off, and the list of years and
5
+ # terms the child has data for.
6
+ #
7
+ # The page takes its year from the session cursor (see Session#set_year), so the
8
+ # caller pins the year and this just reads whatever came back.
9
+ class Znamky
10
+ PATH = "/znamky/?barNoSkin=1".freeze
11
+ VIEWER = "znamkyStudentViewer".freeze
12
+ SETTINGS = "initZnamkovanieSettings".freeze
13
+
14
+ # One request only ever returns one half-year, selected by `nadobdobie`. Asking
15
+ # for a year therefore means asking for each of its terms and merging.
16
+ def self.path_for(term: nil)
17
+ term ? "#{PATH}&nadobdobie=#{term}" : PATH
18
+ end
19
+
20
+ def self.parse(html)
21
+ new(Base.extract_blob!(html, VIEWER), settings: Base.extract_blob(html, SETTINGS) || {})
22
+ end
23
+
24
+ attr_reader :data, :settings
25
+
26
+ def initialize(data, settings: {})
27
+ @data = data
28
+ @settings = settings
29
+ end
30
+
31
+ def student_id = data["studentid"]&.to_s
32
+ def year_id = data["yearid"]&.to_s
33
+ def year_name = data["skRok"]
34
+
35
+ # The half-year this response covers.
36
+ def term_id = data["nadobdobie"]&.to_s
37
+
38
+ def grades = Array(data["vsetkyZnamky"])
39
+
40
+ # Grades carry an event id but none of the interesting metadata; the title,
41
+ # weight, class average and maximum points all live on the event.
42
+ # Shape: { provider => { event_id => event } }
43
+ def events
44
+ @events ||= (data["vsetkyUdalosti"] || {}).each_with_object({}) do |(provider, rows), result|
45
+ result[provider] = normalize(rows).to_h { |event| [event["UdalostID"].to_s, event] }
46
+ end
47
+ end
48
+
49
+ def event_for(grade)
50
+ events.dig(grade["provider"].to_s, grade["udalostid"].to_s)
51
+ end
52
+
53
+ # Every (year, term) pair the child has, each with how many grades are in it -
54
+ # the cheapest way to find where the data actually is.
55
+ def year_terms = Array(data["yearterms"])
56
+
57
+ # Period definitions for the year, keyed by id (P1, KL1, V1, P2, ...).
58
+ #
59
+ # A grade's `mesiac` is one of these sub-periods, not the half-year itself: the
60
+ # P2 response contains grades marked P2 and V2 ("vysvedčenie"). `nadobdobie`
61
+ # points a sub-period at the half-year it belongs to.
62
+ def periods = settings["obdobia"] || {}
63
+
64
+ # Half-year a sub-period rolls up into; a half-year maps to itself.
65
+ def parent_period(id)
66
+ periods.dig(id.to_s, "nadobdobie") || id.to_s
67
+ end
68
+
69
+ # Subjects this child is graded in, which is a subset of the school's subjects.
70
+ def subjects = normalize(data["predmety"])
71
+
72
+ private
73
+
74
+ def normalize(value)
75
+ case value
76
+ when Hash then value.values
77
+ when Array then value
78
+ else []
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,175 @@
1
+ module Edupage
2
+ # Every resource this tool exposes, declared once.
3
+ #
4
+ # Adding a resource here gives it a CLI command, a REST route and an MCP tool with no
5
+ # further work; leaving one out of any surface is impossible by construction.
6
+ class Registry
7
+ # --- account ----------------------------------------------------------------------
8
+
9
+ resource :account do
10
+ scope :account
11
+ summary "The logged-in account"
12
+ table_fields :username, :name
13
+ resolve ->(account, _) { account }
14
+ end
15
+
16
+ resource :schools do
17
+ scope :account
18
+ summary "Schools this account can reach"
19
+ table_fields :origin, :name, :role
20
+ resolve ->(account, _) { account.schools }
21
+ end
22
+
23
+ # --- school directory -------------------------------------------------------------
24
+ #
25
+ # These all read one /user/ document, so asking for several in a row costs one fetch.
26
+
27
+ resource :students do
28
+ scope :school
29
+ summary "Students visible to the account (a parent sees their own children)"
30
+ param :name, desc: "Filter by name"
31
+ param :class, desc: "Filter by class, e.g. 2.A"
32
+ table_fields :full_name, :class_name, :number_in_class
33
+ resolve lambda { |school, p|
34
+ school.students(year: p[:year]).where(name: p[:name], school_class: p[:class])
35
+ }
36
+ end
37
+
38
+ resource :teachers do
39
+ scope :school
40
+ summary "Teachers"
41
+ param :name, desc: "Filter by name or short code"
42
+ table_fields :short, :full_name_with_titles
43
+ resolve ->(school, p) { school.teachers(year: p[:year]).where(name: p[:name]) }
44
+ end
45
+
46
+ resource :parents do
47
+ scope :school
48
+ summary "Parents"
49
+ param :name, desc: "Filter by name"
50
+ table_fields :full_name
51
+ resolve ->(school, p) { school.parents(year: p[:year]).where(name: p[:name]) }
52
+ end
53
+
54
+ resource :classes do
55
+ scope :school
56
+ summary "Classes"
57
+ param :name, desc: "Filter by name"
58
+ table_fields :name, :grade, :teacher
59
+ resolve ->(school, p) { school.classes(year: p[:year]).where(name: p[:name]) }
60
+ end
61
+
62
+ resource :classrooms do
63
+ scope :school
64
+ summary "Rooms"
65
+ param :name, desc: "Filter by name"
66
+ table_fields :short, :name
67
+ resolve ->(school, p) { school.classrooms(year: p[:year]).where(name: p[:name]) }
68
+ end
69
+
70
+ resource :subjects do
71
+ scope :school
72
+ summary "Subjects"
73
+ param :name, desc: "Filter by name or short code"
74
+ table_fields :short, :name
75
+ resolve ->(school, p) { school.subjects(year: p[:year]).where(name: p[:name]) }
76
+ end
77
+
78
+ resource :periods do
79
+ scope :school
80
+ summary "The bell schedule"
81
+ table_fields :name, :start_time, :end_time
82
+ resolve ->(school, p) { school.periods(year: p[:year]) }
83
+ end
84
+
85
+ # --- student ----------------------------------------------------------------------
86
+
87
+ resource :timetable do
88
+ scope :student
89
+ summary "Timetable days"
90
+ param :date, type: :date, desc: "A single day (defaults to today)"
91
+ param :from, type: :date, desc: "Start of a range"
92
+ param :to, type: :date, desc: "End of a range"
93
+ table_fields :date, :lesson_summary
94
+ resolve lambda { |student, p|
95
+ if p[:from] || p[:to]
96
+ from = p[:from] || Date.today
97
+ student.timetable(from..(p[:to] || from + 6))
98
+ else
99
+ Relation.wrap([student.timetable(p[:date])].compact)
100
+ end
101
+ }
102
+ end
103
+
104
+ resource :lessons do
105
+ scope :student
106
+ summary "Lessons on one day"
107
+ param :date, type: :date, desc: "Defaults to today"
108
+ param :subject, desc: "Filter by subject"
109
+ table_fields :period_number, :subject, :teachers, :classrooms, :start_time, :end_time
110
+ resolve ->(student, p) { student.lessons(p[:date]).where(subject: p[:subject]) }
111
+ end
112
+
113
+ resource :assignments do
114
+ scope :student
115
+ summary "Homework, tests, exams and projects"
116
+ param :type, desc: "Filter by type, e.g. hw or test"
117
+ param :subject, desc: "Filter by subject"
118
+ param :due, type: :date, desc: "Only items due on or after this date"
119
+ table_fields :due_on, :type, :subject, :title
120
+ resolve lambda { |student, p|
121
+ scope = student.assignments.where(type: p[:type], subject: p[:subject])
122
+ scope = scope.where(due_on: p[:due]..) if p[:due]
123
+ scope.order(:due_on)
124
+ }
125
+ end
126
+
127
+ resource :homeworks do
128
+ scope :student
129
+ summary "Homework only, including tasks set to one pupil rather than the class"
130
+ param :subject, desc: "Filter by subject"
131
+ param :due, type: :date, desc: "Only items due on or after this date"
132
+ table_fields :due_on, :subject, :title, :class_wide?
133
+ resolve lambda { |student, p|
134
+ scope = student.homeworks.where(subject: p[:subject])
135
+ scope = scope.where(due_on: p[:due]..) if p[:due]
136
+ scope.order(:due_on)
137
+ }
138
+ end
139
+
140
+ resource :timeline do
141
+ scope :student
142
+ summary "The school feed for this student"
143
+ param :type, desc: "Filter by item type, e.g. message or homework"
144
+ table_fields :created_at, :type, :name
145
+ resolve ->(student, p) { student.timeline.where(type: p[:type]).order(created_at: :desc) }
146
+ end
147
+
148
+ resource :years do
149
+ scope :student
150
+ summary "School years this student has data for"
151
+ table_fields :id, :name, :current?, :grade_count
152
+ resolve ->(student, _) { student.years }
153
+ end
154
+
155
+ # --- year -------------------------------------------------------------------------
156
+
157
+ resource :terms do
158
+ scope :year
159
+ summary "Terms of a school year, with how many grades each holds"
160
+ table_fields :id, :name, :starts_on, :ends_on, :grade_count
161
+ resolve ->(year, _) { year.terms }
162
+ end
163
+
164
+ resource :grades do
165
+ scope :year
166
+ summary "Grades for a school year"
167
+ param :term, type: :enum, values: %w[P1 P2], desc: "Half-year"
168
+ param :subject, desc: "Filter by subject"
169
+ table_fields :created_at, :subject, :value, :weight, :title
170
+ resolve lambda { |year, p|
171
+ year.grades.where(term: p[:term], subject: p[:subject]).order(:created_at)
172
+ }
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,286 @@
1
+ module Edupage
2
+ # The single description of what this tool can read.
3
+ #
4
+ # The library is hand-written and idiomatic; the CLI, the REST API and the MCP server
5
+ # are all generated from the declarations here. Nothing about a resource - its name,
6
+ # its filters, their types, what it returns - is written down more than once, so the
7
+ # three surfaces cannot drift apart. spec/registry_parity_spec.rb enforces that.
8
+ #
9
+ # Registry.resource :grades do
10
+ # scope :year
11
+ # summary "Grades for a school year"
12
+ # param :term, type: :enum, values: %w[P1 P2], desc: "Half-year"
13
+ # resolve ->(year, p) { year.grades.where(term: p[:term]) }
14
+ # end
15
+ class Registry
16
+ # Which session cursors a resource needs, and therefore which object resolves it.
17
+ #
18
+ # The scopes nest: a year implies a student, a student implies a school. Surfaces
19
+ # read this to build their own shape - URL segments for REST, options for the CLI,
20
+ # input schema properties for MCP - so a resource can never be wired up to one
21
+ # surface and forgotten in another.
22
+ # `chain` is the levels a resource actually stands on; `params` additionally carries
23
+ # the optional year filter that school- and student-scoped resources accept. The two
24
+ # differ on purpose: `students` takes `--year` to pick which year's directory to
25
+ # read, but it is not a year-scoped resource and must never require a student.
26
+ SCOPES = {
27
+ account: { receiver: :account, chain: [], params: [] },
28
+ school: { receiver: :school, chain: %i[school], params: %i[school year] },
29
+ student: { receiver: :student, chain: %i[school student], params: %i[school student year] },
30
+ year: { receiver: :year, chain: %i[school student year], params: %i[school student year] }
31
+ }.freeze
32
+
33
+ # Parameters implied by the scope rather than declared by the resource.
34
+ #
35
+ # school and student are required wherever they apply: no level of the chain may be
36
+ # skipped, and a caller that omits one is told what it could have chosen. The year is
37
+ # the exception - "now" is unambiguous, so it defaults to the current year and the
38
+ # CLI shows which one it used.
39
+ SCOPE_PARAMS = {
40
+ school: { type: :string, desc: "School origin, e.g. zsdemo", required: true },
41
+ student: { type: :string, desc: "Student name or id", required: true },
42
+ year: { type: :string, desc: "School year, e.g. 2025 (defaults to the current one)",
43
+ required: false }
44
+ }.freeze
45
+
46
+ Param = Struct.new(:name, :type, :desc, :values, :required, keyword_init: true) do
47
+ def enum? = type == :enum
48
+
49
+ def json_schema
50
+ base = case type
51
+ when :integer then { "type" => "integer" }
52
+ when :boolean then { "type" => "boolean" }
53
+ when :date then { "type" => "string", "format" => "date" }
54
+ else { "type" => "string" }
55
+ end
56
+ base["enum"] = values if enum? && values
57
+ base["description"] = desc if desc
58
+ base
59
+ end
60
+ end
61
+
62
+ # One readable thing, described once.
63
+ class Resource
64
+ attr_reader :name
65
+
66
+ def initialize(name)
67
+ @name = name.to_sym
68
+ @scope = :account
69
+ @summary = nil
70
+ @params = {}
71
+ @table_fields = []
72
+ @resolver = nil
73
+ end
74
+
75
+ # --- DSL ----------------------------------------------------------------------
76
+
77
+ def scope(value = nil)
78
+ return @scope if value.nil?
79
+
80
+ unless SCOPES.key?(value)
81
+ raise ArgumentError, "Unknown scope #{value.inspect}; expected one of #{SCOPES.keys.inspect}"
82
+ end
83
+
84
+ @scope = value
85
+ end
86
+
87
+ def summary(value = nil)
88
+ value.nil? ? @summary : (@summary = value)
89
+ end
90
+
91
+ def param(name, type: :string, desc: nil, values: nil, required: false)
92
+ @params[name.to_sym] = Param.new(name: name.to_sym, type: type, desc: desc,
93
+ values: values, required: required)
94
+ end
95
+
96
+ def table_fields(*fields)
97
+ fields.empty? ? @table_fields : (@table_fields = fields.flatten.map(&:to_sym))
98
+ end
99
+
100
+ def resolve(callable = nil, &block)
101
+ @resolver = callable || block
102
+ end
103
+
104
+ # --- reading ------------------------------------------------------------------
105
+
106
+ def own_params = @params.values
107
+
108
+ # Scope parameters first, then the resource's own filters.
109
+ def all_params
110
+ scope_params + own_params
111
+ end
112
+
113
+ def scope_params
114
+ SCOPES.fetch(@scope)[:params].map do |key|
115
+ Param.new(name: key, **SCOPE_PARAMS.fetch(key))
116
+ end
117
+ end
118
+
119
+ def receiver_kind = SCOPES.fetch(@scope)[:receiver]
120
+
121
+ # Levels of the chain this resource actually stands on, outermost first. Surfaces
122
+ # use it to show which school, student and year an answer came from.
123
+ def scope_chain = SCOPES.fetch(@scope)[:chain]
124
+
125
+ def singular? = @table_fields.empty? && name.to_s.end_with?("account")
126
+
127
+ # REST path, derived from the scope so it cannot disagree with the CLI or MCP.
128
+ def rest_path
129
+ case @scope
130
+ when :account then "/api/v1/#{name}"
131
+ when :school then "/api/v1/schools/:school/#{name}"
132
+ when :student then "/api/v1/schools/:school/students/:student/#{name}"
133
+ when :year then "/api/v1/schools/:school/students/:student/years/:year/#{name}"
134
+ end
135
+ end
136
+
137
+ def tool_name = "edupage_#{name}"
138
+
139
+ def call(context, params = {})
140
+ raise Error, "Resource #{name} has no resolver" unless @resolver
141
+
142
+ @resolver.call(context.receiver_for(self), normalize(params))
143
+ end
144
+
145
+ # Drops unknown keys and blank values so surfaces can pass their raw input.
146
+ def normalize(params)
147
+ known = all_params.map(&:name)
148
+ params.to_h.each_with_object({}) do |(key, value), result|
149
+ key = key.to_sym
150
+ next unless known.include?(key)
151
+ next if value.nil? || value.to_s.empty?
152
+
153
+ result[key] = cast(key, value)
154
+ end
155
+ end
156
+
157
+ def validate!
158
+ raise ArgumentError, "Resource #{name} needs a summary" if @summary.nil?
159
+ raise ArgumentError, "Resource #{name} needs a resolver" if @resolver.nil?
160
+
161
+ self
162
+ end
163
+
164
+ private
165
+
166
+ def cast(key, value)
167
+ param = all_params.find { |p| p.name == key }
168
+ case param&.type
169
+ when :integer then Integer(value, exception: false) || value
170
+ when :boolean then [true, "true", "1", 1].include?(value)
171
+ when :date then value.is_a?(Date) ? value : (Date.parse(value.to_s) rescue value)
172
+ else value
173
+ end
174
+ end
175
+ end
176
+
177
+ # Resolves the object a resource should be called on, from surface-level strings.
178
+ #
179
+ # This is where "--student Jana" or ".../students/113506/..." becomes a Student,
180
+ # once, for every surface.
181
+ class Context
182
+ def initialize(account:, school: nil, student: nil, year: nil)
183
+ @account = account
184
+ @school_ref = school
185
+ @student_ref = student
186
+ @year_ref = year
187
+ end
188
+
189
+ def account = @account
190
+
191
+ # Each level resolves the same way: an explicit choice wins, a single option is
192
+ # taken silently, and anything else is refused rather than guessed.
193
+ #
194
+ # Config defaults deliberately do not count as a choice. `default_school` is the
195
+ # login anchor - which *.edupage.org host to authenticate against - and letting it
196
+ # also decide which school's data you are reading is how you end up looking at the
197
+ # wrong child's school without noticing.
198
+ def school
199
+ @school ||= @account.school(@school_ref)
200
+ end
201
+
202
+ def student
203
+ @student ||= begin
204
+ ref = @student_ref
205
+ candidates = school.students
206
+
207
+ if ref.nil?
208
+ raise AmbiguousScopeError.new(:student, student_candidates(candidates)) if candidates.count > 1
209
+
210
+ candidates.first or raise NotFoundError, "#{school.origin} has no students for this account"
211
+ else
212
+ resolve_student(ref, candidates) or
213
+ raise NotFoundError, "No student #{ref.inspect}. Available: #{candidates.map(&:full_name).join(", ")}"
214
+ end
215
+ end
216
+ end
217
+
218
+ def year
219
+ @year ||= explicit_year? ? student.year(@year_ref) : student.current_year
220
+ end
221
+
222
+ # Whether the year came from the caller or was filled in as "now". The CLI says so
223
+ # in its output, because in September the current year is usually empty and the
224
+ # interesting data is in the previous one.
225
+ def year_defaulted? = !explicit_year?
226
+
227
+ def explicit_year?
228
+ !@year_ref.nil? && @year_ref.to_s != "current"
229
+ end
230
+
231
+ def receiver_for(resource)
232
+ public_send(resource.receiver_kind)
233
+ end
234
+
235
+ private
236
+
237
+ # Exact match on id or full name first, then a unique partial name match, so
238
+ # "--student Jana" works without typing the surname. An ambiguous prefix is an
239
+ # error rather than an arbitrary pick.
240
+ def resolve_student(ref, candidates)
241
+ exact = candidates.find { |student| student.matches?(ref) }
242
+ return exact if exact
243
+
244
+ needle = ref.to_s.downcase
245
+ partial = candidates.select { |student| student.full_name.downcase.include?(needle) }
246
+ return partial.first if partial.size == 1
247
+
248
+ if partial.size > 1
249
+ raise NotFoundError,
250
+ "#{ref.inspect} matches several students: #{partial.map(&:full_name).join(", ")}"
251
+ end
252
+
253
+ nil
254
+ end
255
+
256
+ def student_candidates(students)
257
+ students.map do |student|
258
+ { id: student.id, label: [student.full_name, student.class_name].compact.join(", ") }
259
+ end
260
+ end
261
+ end
262
+
263
+ class << self
264
+ def resources = @resources ||= {}
265
+
266
+ def resource(name, &block)
267
+ entry = Resource.new(name)
268
+ entry.instance_eval(&block)
269
+ resources[entry.name] = entry.validate!
270
+ end
271
+
272
+ def [](name) = resources[name.to_sym]
273
+ def all = resources.values
274
+ def names = resources.keys
275
+ def each(&block) = all.each(&block)
276
+
277
+ def fetch(name)
278
+ self[name] or raise NotFoundError, "Unknown resource #{name.inspect}; known: #{names.join(", ")}"
279
+ end
280
+ end
281
+ end
282
+ end
283
+
284
+ # Declarations reopen Registry rather than defining a constant of their own, so they sit
285
+ # outside Zeitwerk's file-to-constant mapping and are loaded here instead.
286
+ require_relative "registry/resources"