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,113 @@
1
+ require "json"
2
+ require "sinatra/base"
3
+
4
+ module Edupage
5
+ module Server
6
+ # REST API and MCP endpoint.
7
+ #
8
+ # Routes are generated from Registry: the path comes from the resource's scope and
9
+ # the query parameters from its declared filters, so a resource reachable from the
10
+ # CLI is reachable here with the same name and the same options. Everything is GET;
11
+ # this tool never writes to Edupage.
12
+ class API < Sinatra::Base
13
+ configure do
14
+ set :show_exceptions, false
15
+ set :raise_errors, false
16
+ set :logging, false
17
+ # Sinatra 4 rejects unexpected Host headers by default. What actually limits
18
+ # exposure here is the bind address (loopback unless asked otherwise) plus the
19
+ # bearer token, and the default list breaks reverse proxies and test clients
20
+ # for no gain, so host checking is left off deliberately.
21
+ set :host_authorization, { permitted_hosts: [] }
22
+ end
23
+
24
+ class << self
25
+ attr_accessor :account_options, :mcp_server
26
+ end
27
+
28
+ before do
29
+ content_type :json
30
+ next if request.path_info == "/healthz"
31
+
32
+ halt(401, error_body("Unauthorized: send Authorization: Bearer <token>")) unless Auth.authorized?(request)
33
+ end
34
+
35
+ get "/healthz" do
36
+ content_type :json
37
+ JSON.generate(status: "ok", version: VERSION)
38
+ end
39
+
40
+ # One route per resource, with the path shape dictated by the scope.
41
+ Registry.each do |resource|
42
+ get resource.rest_path do
43
+ records = resolve(resource)
44
+ payload = Serializer.call(records)
45
+
46
+ JSON.pretty_generate(
47
+ data: payload,
48
+ meta: {
49
+ resource: resource.name,
50
+ count: payload.is_a?(Array) ? payload.size : 1
51
+ }
52
+ )
53
+ end
54
+ end
55
+
56
+ # MCP over HTTP, so `edupage server` covers both surfaces.
57
+ %i[get post delete].each do |verb|
58
+ public_send(verb, "/mcp") do
59
+ transport = self.class.mcp_server&.http_transport
60
+ halt(503, error_body("MCP is not enabled on this server")) unless transport
61
+
62
+ status, headers, body = transport.handle_request(request)
63
+ headers.each { |key, value| response.headers[key] = value }
64
+ halt(status, Array(body).join)
65
+ end
66
+ end
67
+
68
+ # Error blocks set the status and return the body; halting from inside one
69
+ # bypasses Sinatra's own response handling and yields the wrong status.
70
+ # A level of the chain was left unspecified. The path normally makes that
71
+ # impossible, but the answer still has to say what could have been chosen.
72
+ error AmbiguousScopeError do
73
+ error = env["sinatra.error"]
74
+ status 400
75
+ JSON.generate(error: error.message, level: error.level, candidates: error.candidates)
76
+ end
77
+
78
+ error NotFoundError do
79
+ status 404
80
+ error_body(env["sinatra.error"].message)
81
+ end
82
+
83
+ error MissingCredentialsError, LoginError, SessionExpiredError do
84
+ status 401
85
+ error_body(env["sinatra.error"].message)
86
+ end
87
+
88
+ error Edupage::Error do
89
+ status 500
90
+ error_body(env["sinatra.error"].message)
91
+ end
92
+
93
+ private
94
+
95
+ def resolve(resource)
96
+ merged = self.class.account_options.to_h.merge(symbolize(params))
97
+ context = Registry::Context.new(
98
+ account: Edupage.account(username: merged[:username], school: merged[:school]),
99
+ school: merged[:school], student: merged[:student], year: merged[:year]
100
+ )
101
+ resource.call(context, merged)
102
+ end
103
+
104
+ def symbolize(hash)
105
+ hash.to_h { |key, value| [key.to_sym, value] }
106
+ end
107
+
108
+ def error_body(message)
109
+ JSON.generate(error: message)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,45 @@
1
+ module Edupage
2
+ module Server
3
+ # Bearer-token check for the HTTP surfaces.
4
+ #
5
+ # The server hands out a child's school record, so it binds to localhost and
6
+ # requires a token by default. Binding anywhere else has to be asked for explicitly.
7
+ module Auth
8
+ LOOPBACK = %w[127.0.0.1 ::1 localhost].freeze
9
+
10
+ module_function
11
+
12
+ def token = Edupage.config.server_token
13
+
14
+ def loopback?(host) = LOOPBACK.include?(host.to_s)
15
+
16
+ # Refuses to expose the account to a network without a deliberate choice.
17
+ def check_binding!(host)
18
+ return if loopback?(host)
19
+
20
+ Edupage.logger.warn(
21
+ "Binding to #{host} makes this account reachable from the network; a token is required."
22
+ )
23
+ end
24
+
25
+ def authorized?(request)
26
+ presented = bearer(request) || request.params["token"]
27
+ return false if presented.nil? || presented.empty?
28
+
29
+ secure_compare(presented, token)
30
+ end
31
+
32
+ def bearer(request)
33
+ header = request.env["HTTP_AUTHORIZATION"].to_s
34
+ header[/\ABearer\s+(.+)\z/i, 1]
35
+ end
36
+
37
+ # Constant-time comparison; a token check that returns early leaks its prefix.
38
+ def secure_compare(left, right)
39
+ return false unless left.bytesize == right.bytesize
40
+
41
+ left.bytes.zip(right.bytes).reduce(0) { |result, (a, b)| result | (a ^ b) }.zero?
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,107 @@
1
+ require "mcp"
2
+
3
+ module Edupage
4
+ module Server
5
+ # MCP server exposing the same resources as the CLI and the REST API.
6
+ #
7
+ # Tools are generated from Registry, so the tool list, its arguments and their types
8
+ # cannot diverge from the other surfaces. Every tool is read-only and says so, which
9
+ # lets clients skip approval prompts.
10
+ class MCP
11
+ SERVER_NAME = "edupage".freeze
12
+
13
+ INSTRUCTIONS = <<~TEXT.freeze
14
+ Read-only access to an Edupage school account: schools, students, timetables,
15
+ homework, and grades.
16
+
17
+ Most data belongs to one student and one school year. Call edupage_students to
18
+ find the students, and edupage_years to find which years hold data - in early
19
+ autumn the current year is usually empty and the interesting grades are in the
20
+ previous one. Pass `student` as a name or id and `year` as e.g. 2025.
21
+ TEXT
22
+
23
+ def initialize(account_options: {})
24
+ @account_options = account_options
25
+ end
26
+
27
+ def server
28
+ @server ||= ::MCP::Server.new(
29
+ name: SERVER_NAME,
30
+ title: "Edupage",
31
+ version: VERSION,
32
+ instructions: INSTRUCTIONS,
33
+ tools: tools
34
+ )
35
+ end
36
+
37
+ # Blocks, serving MCP on stdin/stdout for editors and desktop clients.
38
+ def run_stdio
39
+ ::MCP::Server::Transports::StdioTransport.new(server).open
40
+ end
41
+
42
+ # Rack entry point for the /mcp endpoint of `edupage server`.
43
+ def http_transport
44
+ @http_transport ||= ::MCP::Server::Transports::StreamableHTTPTransport.new(
45
+ server,
46
+ stateless: true,
47
+ enable_json_response: true
48
+ )
49
+ end
50
+
51
+ def handle_http(request) = http_transport.handle_request(request)
52
+
53
+ def tools
54
+ Registry.all.map { |resource| build_tool(resource) }
55
+ end
56
+
57
+ private
58
+
59
+ def build_tool(resource)
60
+ options = @account_options
61
+
62
+ ::MCP::Tool.define(
63
+ name: resource.tool_name,
64
+ title: resource.name.to_s.tr("_", " ").capitalize,
65
+ description: describe(resource),
66
+ input_schema: input_schema(resource),
67
+ annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
68
+ ) do |**arguments|
69
+ MCP.respond(resource, arguments, options)
70
+ end
71
+ end
72
+
73
+ # Runs a resource and wraps the result the way MCP expects. Kept as a module
74
+ # method so the tool blocks stay tiny and testable.
75
+ def self.respond(resource, arguments, account_options)
76
+ params = account_options.merge(arguments.transform_keys(&:to_sym))
77
+ context = Registry::Context.new(
78
+ account: Edupage.account(username: params[:username], school: params[:school]),
79
+ school: params[:school], student: params[:student], year: params[:year]
80
+ )
81
+ payload = Serializer.call(resource.call(context, params))
82
+
83
+ ::MCP::Tool::Response.new([{ type: "text", text: JSON.pretty_generate(payload) }])
84
+ rescue Edupage::Error => e
85
+ ::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }], error: true)
86
+ end
87
+
88
+ def describe(resource)
89
+ scope_note =
90
+ case resource.scope
91
+ when :year then " Needs a student; year defaults to the current one."
92
+ when :student then " Needs a student."
93
+ else ""
94
+ end
95
+
96
+ "#{resource.summary}.#{scope_note}"
97
+ end
98
+
99
+ def input_schema(resource)
100
+ properties = resource.all_params.to_h { |param| [param.name.to_s, param.json_schema] }
101
+ required = resource.all_params.select(&:required).map { |p| p.name.to_s }
102
+
103
+ { type: "object", properties: properties, required: required }
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,57 @@
1
+ module Edupage
2
+ # `edupage server`: the REST API and the MCP endpoint in one process.
3
+ module Server
4
+ DEFAULT_HOST = "127.0.0.1".freeze
5
+ DEFAULT_PORT = 4567
6
+
7
+ module_function
8
+
9
+ def start(host: nil, port: nil, token: nil, account_options: {})
10
+ require "puma"
11
+ require "rack"
12
+
13
+ host ||= Edupage.config.server["host"] || DEFAULT_HOST
14
+ port = (port || Edupage.config.server["port"] || DEFAULT_PORT).to_i
15
+
16
+ if token
17
+ Edupage.config["server"] = Edupage.config.server.merge("token" => token)
18
+ Edupage.config.save
19
+ end
20
+
21
+ Auth.check_binding!(host)
22
+ resolved_token = Auth.token
23
+
24
+ API.account_options = account_options
25
+ API.mcp_server = MCP.new(account_options: account_options)
26
+
27
+ announce(host, port, resolved_token)
28
+ run(API, host: host, port: port)
29
+ end
30
+
31
+ def run(app, host:, port:)
32
+ server = Puma::Server.new(app)
33
+ server.add_tcp_listener(host, port)
34
+ trap("INT") { server.stop }
35
+ trap("TERM") { server.stop }
36
+ server.run.join
37
+ end
38
+
39
+ def announce(host, port, token)
40
+ base = "http://#{host}:#{port}"
41
+ $stderr.puts <<~BANNER
42
+ edupage #{VERSION}
43
+
44
+ REST #{base}/api/v1/...
45
+ MCP #{base}/mcp
46
+ health #{base}/healthz
47
+
48
+ token #{token}
49
+
50
+ Example:
51
+ curl -H "Authorization: Bearer #{token}" #{base}/api/v1/schools
52
+
53
+ Stored in #{Edupage.config.path}. Ctrl-C to stop.
54
+ BANNER
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,166 @@
1
+ module Edupage
2
+ # A live connection to one Edupage school.
3
+ #
4
+ # Two things make this more than a cookie holder:
5
+ #
6
+ # 1. Re-login. Sessions expire server-side; any request can come back as a redirect
7
+ # to /login/. #request notices that, logs in again and retries once.
8
+ #
9
+ # 2. Cursors. Edupage keeps the "current child" and "current school year" in the
10
+ # session, not in the request, so asking for Jana's 2025 grades means mutating
11
+ # shared server state first. #with_cursor does that under a cross-process lock.
12
+ class Session
13
+ SWITCH_CHILD_PATH = "/login/switchchild".freeze
14
+ SET_YEAR_PATH = "/znamky/?what=setyear".freeze
15
+ PING_PATH = "/login/eauth?portalping".freeze
16
+
17
+ attr_reader :origin, :username, :userid, :role, :name
18
+
19
+ def initialize(origin:, username:, session_id: nil, credentials:, userid: nil, role: nil,
20
+ name: nil, store: SessionStore.new, client: nil)
21
+ @origin = origin
22
+ @username = username
23
+ @credentials = credentials
24
+ @store = store
25
+ @userid = userid
26
+ @role = role
27
+ @name = name
28
+ @client = client || Client.new(origin: origin, session_id: session_id)
29
+
30
+ # Cursor state is only trustworthy while we hold the lock, since another process
31
+ # sharing this session can switch it at any time. #release_lock clears it.
32
+ @cursor = { child: nil, year: nil }
33
+ @lock_depth = 0
34
+ end
35
+
36
+ def session_id = @client.session_id
37
+
38
+ def get(path) = request(:get, path)
39
+ def post(path, data = {}) = request(:post, path, data)
40
+
41
+ def parent? = role.to_s.downcase.start_with?("rodic")
42
+
43
+ # Cheap liveness check: 49 bytes, versus 375 KB for /user/.
44
+ def valid?
45
+ !@client.post(PING_PATH, "gpids" => "").expired?
46
+ end
47
+
48
+ # Logs in again and persists the new session id.
49
+ def refresh!
50
+ users = Client.mauth(school: origin, username: username, password: @credentials.password)
51
+ entry = users.find { |u| u[:origin] == origin }
52
+ raise SessionExpiredError, "Account #{username} no longer has access to #{origin}" unless entry
53
+ raise TwoFactorRequiredError, origin if entry[:needs_2fa]
54
+
55
+ @client.session_id = entry[:session_id]
56
+ @userid = entry[:userid]
57
+ @role = entry[:role]
58
+ @name = [entry[:first_name], entry[:last_name]].compact.join(" ")
59
+ persist!
60
+ # A fresh session starts with the server's own defaults, not ours.
61
+ @cursor = { child: nil, year: nil }
62
+ self
63
+ end
64
+
65
+ # Runs the block with the session pointing at the given child and/or school year.
66
+ #
67
+ # Holds an exclusive lock for the whole block - not just the switch - so a
68
+ # concurrent process cannot move the cursor between our switch and our fetch, which
69
+ # would silently return another child's or another year's data.
70
+ #
71
+ # Re-entrant: nested calls reuse the held lock and skip switches that are already
72
+ # in effect.
73
+ def with_cursor(child: nil, year: nil)
74
+ with_lock do
75
+ # Setting the second cursor can trigger a re-login, which resets the first one
76
+ # server-side. Re-apply until both actually hold before handing over control.
77
+ 2.times do
78
+ set_child(child) if child
79
+ set_year(year) if year
80
+ break if cursors_match?(child, year)
81
+ end
82
+
83
+ unless cursors_match?(child, year)
84
+ raise Error, "Could not pin session #{origin} to child=#{child.inspect} year=#{year.inspect}"
85
+ end
86
+
87
+ yield self
88
+ end
89
+ end
90
+
91
+ def current_child = @cursor[:child]
92
+ def current_year = @cursor[:year]
93
+
94
+ def persist!
95
+ @store.store(username, origin, session_id: session_id, userid: userid, role: role, name: name)
96
+ end
97
+
98
+ def inspect
99
+ "#<Edupage::Session origin=#{origin.inspect} username=#{username.inspect} role=#{role.inspect}>"
100
+ end
101
+
102
+ private
103
+
104
+ def cursors_match?(child, year)
105
+ (child.nil? || @cursor[:child] == child.to_s) &&
106
+ (year.nil? || @cursor[:year] == year.to_s)
107
+ end
108
+
109
+ def with_lock(&block)
110
+ return yield if @lock_depth.positive?
111
+
112
+ @store.with_lock do
113
+ @lock_depth += 1
114
+ begin
115
+ block.call
116
+ ensure
117
+ @lock_depth -= 1
118
+ # Outside the lock another process may move the cursors, so what we knew
119
+ # about them expires with the lock.
120
+ @cursor = { child: nil, year: nil } if @lock_depth.zero?
121
+ end
122
+ end
123
+ end
124
+
125
+ def set_child(student_id)
126
+ id = student_id.to_s
127
+ return if @cursor[:child] == id
128
+
129
+ response = get("#{SWITCH_CHILD_PATH}?studentid=#{id}")
130
+ unless response.ok? && response.body.to_s.strip.start_with?("OK")
131
+ raise Error, "Failed to switch to child #{id} (HTTP #{response.status})"
132
+ end
133
+
134
+ @cursor[:child] = id
135
+ end
136
+
137
+ def set_year(year_id)
138
+ id = year_id.to_s
139
+ return if @cursor[:year] == id
140
+
141
+ response = post(SET_YEAR_PATH, "znamky_yearid" => id)
142
+ # The endpoint echoes the year it actually selected. That echo is the only
143
+ # trustworthy confirmation: /user/ reports the new selectedYear immediately most
144
+ # of the time, but occasionally lags a request behind (Edupage runs several app
145
+ # servers and the dashboard appears to be cached per server). Consumers that care
146
+ # about the year of a document - /user/ carries year-dependent dbi - must check
147
+ # it themselves and refetch; see School#document.
148
+ selected = response.body.to_s.strip.delete('"')
149
+ raise Error, "Failed to switch to year #{id} (HTTP #{response.status})" unless response.ok?
150
+ raise Error, "Edupage selected year #{selected} instead of #{id}" unless selected == id
151
+
152
+ @cursor[:year] = selected
153
+ end
154
+
155
+ def request(method, path, data = nil, retried: false)
156
+ response = data.nil? ? @client.get(path) : @client.post(path, data)
157
+ return response unless response.expired?
158
+
159
+ raise SessionExpiredError, "Session for #{origin} expired and re-login did not help" if retried
160
+
161
+ Edupage.logger.debug("Session for #{origin} expired, logging in again")
162
+ refresh!
163
+ request(method, path, data, retried: true)
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,131 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "time"
4
+
5
+ module Edupage
6
+ # Persists one Edupage session id per (username, school) so consecutive commands
7
+ # reuse a session instead of logging in again.
8
+ #
9
+ # The file holds live session ids, so it is created 0600 and every read-modify-write
10
+ # runs under an exclusive flock - `edupage server` and a CLI invocation routinely run
11
+ # at the same time.
12
+ class SessionStore
13
+ FILENAME = "sessions.json".freeze
14
+
15
+ attr_reader :path
16
+
17
+ def initialize(path: self.class.default_path)
18
+ @path = path
19
+ end
20
+
21
+ def self.default_path
22
+ File.join(Config.cache_dir, FILENAME)
23
+ end
24
+
25
+ def fetch(username, origin)
26
+ data = read
27
+ entry = data.dig(username.to_s, origin.to_s)
28
+ return nil unless entry && entry["session_id"]
29
+
30
+ symbolize(entry)
31
+ end
32
+
33
+ def all(username)
34
+ read.fetch(username.to_s, {}).transform_values { |e| symbolize(e) }
35
+ end
36
+
37
+ def store(username, origin, session_id:, userid: nil, role: nil, name: nil)
38
+ transaction do |data|
39
+ data[username.to_s] ||= {}
40
+ data[username.to_s][origin.to_s] = {
41
+ "session_id" => session_id,
42
+ "userid" => userid,
43
+ "role" => role,
44
+ "name" => name,
45
+ "saved_at" => Time.now.iso8601
46
+ }.compact
47
+ end
48
+ end
49
+
50
+ # Removes one school's session, or every session for the user when origin is nil.
51
+ def delete(username, origin = nil)
52
+ transaction do |data|
53
+ if origin
54
+ data[username.to_s]&.delete(origin.to_s)
55
+ data.delete(username.to_s) if data[username.to_s]&.empty?
56
+ else
57
+ data.delete(username.to_s)
58
+ end
59
+ end
60
+ end
61
+
62
+ def clear
63
+ transaction { |data| data.clear }
64
+ end
65
+
66
+ # Exclusive advisory lock used by Session#with_cursor. The server-side cursors
67
+ # (current child, current year) are global to a session, so two processes sharing a
68
+ # session must not interleave a switch with someone else's fetch.
69
+ def with_lock
70
+ FileUtils.mkdir_p(File.dirname(lock_path))
71
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |file|
72
+ file.flock(File::LOCK_EX)
73
+ begin
74
+ yield
75
+ ensure
76
+ file.flock(File::LOCK_UN)
77
+ end
78
+ end
79
+ end
80
+
81
+ def lock_path = "#{path}.lock"
82
+
83
+ private
84
+
85
+ def read
86
+ return {} unless File.exist?(path)
87
+
88
+ # UTF-8 explicitly: the stored display name can contain diacritics, and a process
89
+ # started without LANG would otherwise read it as US-ASCII and fail to parse.
90
+ content = File.read(path, encoding: Encoding::UTF_8)
91
+ return {} if content.strip.empty?
92
+
93
+ JSON.parse(content)
94
+ rescue JSON::ParserError
95
+ # A truncated file is a cache, not a source of truth - start over rather than
96
+ # making every command fail.
97
+ Edupage.logger.warn("Discarding unreadable session store at #{path}")
98
+ {}
99
+ end
100
+
101
+ def transaction
102
+ FileUtils.mkdir_p(File.dirname(path))
103
+ File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
104
+ file.set_encoding(Encoding::UTF_8)
105
+ file.flock(File::LOCK_EX)
106
+ begin
107
+ raw = file.read
108
+ data = raw.strip.empty? ? {} : (JSON.parse(raw) rescue {})
109
+ yield data
110
+ file.rewind
111
+ file.write(JSON.pretty_generate(data))
112
+ file.flush
113
+ file.truncate(file.pos)
114
+ data
115
+ ensure
116
+ file.flock(File::LOCK_UN)
117
+ end
118
+ end
119
+ end
120
+
121
+ def symbolize(entry)
122
+ {
123
+ session_id: entry["session_id"],
124
+ userid: entry["userid"],
125
+ role: entry["role"],
126
+ name: entry["name"],
127
+ saved_at: entry["saved_at"]
128
+ }
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,3 @@
1
+ module Edupage
2
+ VERSION = "0.1.0".freeze
3
+ end
@@ -0,0 +1,77 @@
1
+ module Edupage
2
+ # A school year for one student - the level at which grades and terms exist.
3
+ #
4
+ # Edupage keeps the selected year in the session rather than in the request, so this
5
+ # is a scope object rather than a fetched record: it remembers which (student, year)
6
+ # it stands for and pins both cursors around every fetch it makes.
7
+ class Year
8
+ attr_reader :school, :student, :id, :name
9
+
10
+ # term_rows are the yearterms entries for this year, already read while listing the
11
+ # years. Keeping them means #terms and #grade_count cost nothing.
12
+ def initialize(school:, student:, id:, name: nil, current: false, term_rows: nil)
13
+ @school = school
14
+ @student = student
15
+ @id = id.to_s
16
+ @name = name || "#{@id}/#{@id.to_i + 1}"
17
+ @current = current
18
+ @term_rows = term_rows
19
+ end
20
+
21
+ def current? = @current
22
+
23
+ # Every grade in the year. Edupage serves one half-year per request, so this is the
24
+ # union of the terms rather than a single fetch.
25
+ def grades
26
+ Relation.new(-> { terms.flat_map { |term| grades_for(term) } })
27
+ end
28
+
29
+ def terms
30
+ Relation.new(-> { build_terms })
31
+ end
32
+
33
+ # Grades of one half-year.
34
+ def grades_for(term)
35
+ document = school.znamky_for(student, year: id, term: term.id)
36
+ document.grades.map do |row|
37
+ Models::Grade.new(row, school: school, event: document.event_for(row),
38
+ year: self, term: term)
39
+ end
40
+ end
41
+
42
+ def term(id)
43
+ terms.find { |t| t.id.to_s.casecmp?(id.to_s) } or
44
+ raise NotFoundError, "#{student.full_name} has no term #{id} in #{name}"
45
+ end
46
+
47
+ def grade_count = terms.sum { |t| t.grade_count.to_i }
48
+
49
+ # Year-scoped views of the school directory: dbi changes between years, so a class
50
+ # list from 2025 is not the same as this year's.
51
+ def classes = school.classes(year: id)
52
+ def teachers = school.teachers(year: id)
53
+ def subjects = school.subjects(year: id)
54
+
55
+ def match_strings = [id, name].compact
56
+ def matches?(query) = query.nil? || match_strings.any? { |c| c.casecmp?(query.to_s) }
57
+
58
+ def to_h = { id: id, name: name, current: current?, grade_count: grade_count }
59
+ def to_s = "#{name}#{current? ? " (current)" : ""}"
60
+ def inspect = "#<Edupage::Year #{name} student=#{student.full_name.inspect}>"
61
+
62
+ def ==(other) = other.is_a?(Year) && id == other.id && student == other.student
63
+ alias eql? ==
64
+ def hash = [Year, id, student.id].hash
65
+
66
+ private
67
+
68
+ def term_rows
69
+ @term_rows ||= school.znamky_for(student, year: id).year_terms
70
+ .select { |row| row["yearid"].to_s == id }
71
+ end
72
+
73
+ def build_terms
74
+ term_rows.map { |row| Models::Term.new(row, school: school, year: self) }
75
+ end
76
+ end
77
+ end