edupage-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +250 -0
- data/exe/edupage +7 -0
- data/lib/edupage/account.rb +121 -0
- data/lib/edupage/cache.rb +101 -0
- data/lib/edupage/cli/formatter.rb +56 -0
- data/lib/edupage/cli/table.rb +136 -0
- data/lib/edupage/cli.rb +543 -0
- data/lib/edupage/client.rb +152 -0
- data/lib/edupage/config.rb +96 -0
- data/lib/edupage/credentials/env.rb +27 -0
- data/lib/edupage/credentials/keychain.rb +120 -0
- data/lib/edupage/credentials.rb +118 -0
- data/lib/edupage/errors.rb +74 -0
- data/lib/edupage/model.rb +131 -0
- data/lib/edupage/models/assignment.rb +83 -0
- data/lib/edupage/models/classroom.rb +13 -0
- data/lib/edupage/models/day.rb +51 -0
- data/lib/edupage/models/grade.rb +100 -0
- data/lib/edupage/models/lesson.rb +58 -0
- data/lib/edupage/models/parent.rb +8 -0
- data/lib/edupage/models/period.rb +13 -0
- data/lib/edupage/models/person.rb +25 -0
- data/lib/edupage/models/school_class.rb +16 -0
- data/lib/edupage/models/student.rb +45 -0
- data/lib/edupage/models/subject.rb +9 -0
- data/lib/edupage/models/teacher.rb +25 -0
- data/lib/edupage/models/term.rb +36 -0
- data/lib/edupage/models/timeline_item.rb +87 -0
- data/lib/edupage/parsers/base.rb +150 -0
- data/lib/edupage/parsers/gcall.rb +53 -0
- data/lib/edupage/parsers/timeline.rb +46 -0
- data/lib/edupage/parsers/userhome.rb +84 -0
- data/lib/edupage/parsers/znamky.rb +83 -0
- data/lib/edupage/registry/resources.rb +175 -0
- data/lib/edupage/registry.rb +286 -0
- data/lib/edupage/relation.rb +185 -0
- data/lib/edupage/school.rb +358 -0
- data/lib/edupage/serializer.rb +53 -0
- data/lib/edupage/server/api.rb +113 -0
- data/lib/edupage/server/auth.rb +45 -0
- data/lib/edupage/server/mcp.rb +107 -0
- data/lib/edupage/server.rb +57 -0
- data/lib/edupage/session.rb +166 -0
- data/lib/edupage/session_store.rb +131 -0
- data/lib/edupage/version.rb +3 -0
- data/lib/edupage/year.rb +77 -0
- data/lib/edupage.rb +64 -0
- metadata +201 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Models
|
|
3
|
+
# One day of a student's timetable.
|
|
4
|
+
class Day < Model
|
|
5
|
+
attribute :timetable_number, "tt_num", cast: :integer
|
|
6
|
+
attribute :timetable_day, "tt_day", cast: :integer
|
|
7
|
+
attribute :timetable_week, "tt_week", cast: :integer
|
|
8
|
+
attribute :timetable_term, "tt_term", cast: :integer
|
|
9
|
+
|
|
10
|
+
attr_reader :date
|
|
11
|
+
|
|
12
|
+
def initialize(data, school: nil, date: nil)
|
|
13
|
+
super(data, school: school)
|
|
14
|
+
@date = date.is_a?(String) ? Date.parse(date) : date
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Real lessons only; Edupage pads the plan with empty period rows.
|
|
18
|
+
def lessons
|
|
19
|
+
@lessons ||= Relation.wrap(all_entries.select(&:lesson?))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Including the structural rows, for anyone rendering a full grid.
|
|
23
|
+
def entries = Relation.wrap(all_entries)
|
|
24
|
+
|
|
25
|
+
def lesson_at(period) = lessons.find { |l| l.period_number.to_s == period.to_s }
|
|
26
|
+
|
|
27
|
+
# Days off still come back with a plan, just without lessons.
|
|
28
|
+
def teaching_day? = lessons.any?
|
|
29
|
+
|
|
30
|
+
def absences = Array(data["student_absents"])
|
|
31
|
+
|
|
32
|
+
def id = date&.iso8601
|
|
33
|
+
def name = id
|
|
34
|
+
|
|
35
|
+
# Compact one-line view of the day, used by the table renderers.
|
|
36
|
+
def lesson_summary = lessons.map { |l| l.subject&.short || "?" }.join(" ")
|
|
37
|
+
|
|
38
|
+
def to_s = "#{id} #{lessons.map(&:to_s).join(" ")}"
|
|
39
|
+
|
|
40
|
+
def to_h
|
|
41
|
+
super.merge(date: id, lessons: lessons.map(&:to_h)).compact
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def all_entries
|
|
47
|
+
@all_entries ||= Array(data["plan"]).map { |entry| Lesson.new(entry, school: school, date: date) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Models
|
|
3
|
+
# A single mark.
|
|
4
|
+
#
|
|
5
|
+
# Edupage splits a grade in two: the row in `vsetkyZnamky` holds the value and who
|
|
6
|
+
# it belongs to, while everything descriptive - what it was for, how much it counts,
|
|
7
|
+
# the class average - lives on the event it references. Both are merged here, with
|
|
8
|
+
# the event kept under #event.
|
|
9
|
+
class Grade < Model
|
|
10
|
+
# p_typ_udalosti on the event: "1" is a classic 1-5 mark, "3" is points.
|
|
11
|
+
TYPE_MARK = "1".freeze
|
|
12
|
+
TYPE_POINTS = "3".freeze
|
|
13
|
+
|
|
14
|
+
# Edupage stores weights as a percentage of a "normal" grade, where 20 means 1.0.
|
|
15
|
+
WEIGHT_UNIT = 20.0
|
|
16
|
+
|
|
17
|
+
attribute :id, "znamkaid"
|
|
18
|
+
attribute :value, "data"
|
|
19
|
+
# The sub-period the grade sits in: "P2" for ordinary marks, "V2" for the ones
|
|
20
|
+
# that make up the end-of-term report. #term is the half-year it rolls up into.
|
|
21
|
+
attribute :period, "mesiac"
|
|
22
|
+
attribute :state, "stav"
|
|
23
|
+
attribute :provider
|
|
24
|
+
attribute :event_id, "udalostid"
|
|
25
|
+
attribute :created_at, "datum", cast: :time
|
|
26
|
+
attribute :signed_at, "podpisane", cast: :time
|
|
27
|
+
attribute :signed_by_parent_at, "podpisane_rodic", cast: :time
|
|
28
|
+
attribute(:subject) { |d| school&.subject(d["predmetid"]) }
|
|
29
|
+
attribute(:student) { |d| school&.student(d["studentid"]) }
|
|
30
|
+
attribute(:teacher) { |d| school&.teacher(d["ucitelid"]) }
|
|
31
|
+
|
|
32
|
+
attr_reader :event, :year, :term
|
|
33
|
+
|
|
34
|
+
def initialize(data, school: nil, event: nil, year: nil, term: nil)
|
|
35
|
+
super(data, school: school)
|
|
36
|
+
@event = event || {}
|
|
37
|
+
@year = year
|
|
38
|
+
@term = term
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# --- from the event -------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def title = presence(event["p_meno"])
|
|
44
|
+
def short = presence(event["p_skratka"])
|
|
45
|
+
def type = event["p_typ_udalosti"]
|
|
46
|
+
def due_on = Model.parse_date(event["p_termin"])
|
|
47
|
+
def class_average = event["priemer"]&.to_f
|
|
48
|
+
def school_class = school&.school_class(event["TriedaID"])
|
|
49
|
+
|
|
50
|
+
def weight
|
|
51
|
+
raw = event["p_vaha"]
|
|
52
|
+
raw.nil? || raw.to_s.empty? ? 1.0 : raw.to_f / WEIGHT_UNIT
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- derived --------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def mark? = type == TYPE_MARK
|
|
58
|
+
def points? = type == TYPE_POINTS
|
|
59
|
+
|
|
60
|
+
def points = points? ? Float(value, exception: false) : nil
|
|
61
|
+
|
|
62
|
+
def max_points
|
|
63
|
+
return nil unless points?
|
|
64
|
+
|
|
65
|
+
Float(event["p_vaha_body"], exception: false)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def percentage
|
|
69
|
+
return nil unless points && max_points&.positive?
|
|
70
|
+
|
|
71
|
+
(points / max_points * 100).round(2)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def signed? = !signed_at.nil?
|
|
75
|
+
def signed_by_parent? = !signed_by_parent_at.nil?
|
|
76
|
+
|
|
77
|
+
def name = title || value
|
|
78
|
+
|
|
79
|
+
def match_strings = [id, value, title, short, subject&.short, subject&.name].compact.map(&:to_s)
|
|
80
|
+
|
|
81
|
+
def to_s
|
|
82
|
+
parts = [created_at&.strftime("%Y-%m-%d"), subject&.short, value]
|
|
83
|
+
parts << "(#{title})" if title
|
|
84
|
+
parts.compact.join(" ")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_h
|
|
88
|
+
super.merge(
|
|
89
|
+
title: title, weight: weight, type: type, points: points, max_points: max_points,
|
|
90
|
+
percentage: percentage, class_average: class_average,
|
|
91
|
+
year: year&.id, term: term&.id, signed_by_parent: signed_by_parent?
|
|
92
|
+
).compact
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def presence(value) = value.nil? || value.to_s.empty? ? nil : value.to_s
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Models
|
|
3
|
+
# One entry in a day's plan.
|
|
4
|
+
#
|
|
5
|
+
# Edupage mixes real lessons with structural rows (empty periods, breaks); #lesson?
|
|
6
|
+
# separates them. Each entry carries both a flat form (subjectid, teacherids, ...)
|
|
7
|
+
# and a nested presentation form under "header"/"infos"; the flat form is used here
|
|
8
|
+
# and the nested one is left in #data for anything not modelled.
|
|
9
|
+
class Lesson < Model
|
|
10
|
+
attribute :type
|
|
11
|
+
attribute :period_number, "period"
|
|
12
|
+
attribute :start_time, "starttime"
|
|
13
|
+
attribute :end_time, "endtime"
|
|
14
|
+
attribute :lesson_id, "lid"
|
|
15
|
+
attribute(:groups) { |d| Array(d["groupnames"]).reject { |g| g.to_s.empty? } }
|
|
16
|
+
attribute(:subject) { |d| school&.subject(d["subjectid"]) }
|
|
17
|
+
attribute(:period) { |d| school&.period(d["period"]) }
|
|
18
|
+
attribute(:teachers) { |d| Array(d["teacherids"]).filter_map { |id| school&.teacher(id) } }
|
|
19
|
+
attribute(:classrooms) { |d| Array(d["classroomids"]).filter_map { |id| school&.classroom(id) } }
|
|
20
|
+
attribute(:classes) { |d| Array(d["classids"]).filter_map { |id| school&.school_class(id) } }
|
|
21
|
+
|
|
22
|
+
attr_reader :date
|
|
23
|
+
|
|
24
|
+
def initialize(data, school: nil, date: nil)
|
|
25
|
+
super(data, school: school)
|
|
26
|
+
@date = date
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def lesson? = type == "lesson"
|
|
30
|
+
|
|
31
|
+
def teacher = teachers.first
|
|
32
|
+
def classroom = classrooms.first
|
|
33
|
+
|
|
34
|
+
# "Učivo: ..." - what was actually taught, when the teacher filled it in.
|
|
35
|
+
def curriculum
|
|
36
|
+
info_text("wd")&.sub(/\A[^:]*:\s*/, "")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def id = [date, period_number, data["subjectid"]].compact.join(":")
|
|
40
|
+
|
|
41
|
+
def name = subject&.name
|
|
42
|
+
def short = subject&.short
|
|
43
|
+
|
|
44
|
+
def to_s
|
|
45
|
+
return "#{period_number}. -" unless lesson?
|
|
46
|
+
|
|
47
|
+
"#{period_number}. #{subject&.short || "?"}#{groups.empty? ? "" : " (#{groups.join(", ")})"}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def info_text(type)
|
|
53
|
+
Array(data["infos"]).find { |info| info["type"] == type }
|
|
54
|
+
&.dig("texts", 0, "text")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Models
|
|
3
|
+
# One slot in the daily bell schedule ("2nd period, 08:45-09:30").
|
|
4
|
+
class Period < Model
|
|
5
|
+
attribute :name
|
|
6
|
+
attribute :short
|
|
7
|
+
attribute :start_time, "starttime"
|
|
8
|
+
attribute :end_time, "endtime"
|
|
9
|
+
|
|
10
|
+
def to_s = "#{name}. #{start_time}-#{end_time}"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Models
|
|
3
|
+
# Shared shape of everyone Edupage knows about: students, teachers and parents all
|
|
4
|
+
# carry the same name/gender/validity fields under the same keys.
|
|
5
|
+
class Person < Model
|
|
6
|
+
attribute :first_name, "firstname"
|
|
7
|
+
attribute :last_name, "lastname"
|
|
8
|
+
attribute :gender
|
|
9
|
+
attribute :valid_from, "datefrom", cast: :date
|
|
10
|
+
attribute :valid_to, "dateto", cast: :date
|
|
11
|
+
attribute :inactive, "isOut", cast: :boolean
|
|
12
|
+
|
|
13
|
+
alias inactive? inactive
|
|
14
|
+
|
|
15
|
+
def full_name = [first_name, last_name].compact.join(" ").strip
|
|
16
|
+
alias name full_name
|
|
17
|
+
|
|
18
|
+
# Edupage addresses people by a typed string such as "Student113506" or
|
|
19
|
+
# "Ucitel37135"; it appears all over the timeline payloads.
|
|
20
|
+
def user_string = "#{self.class::USER_STRING_PREFIX}#{id}"
|
|
21
|
+
|
|
22
|
+
def match_strings = (super + [full_name, user_string]).uniq
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Models
|
|
3
|
+
# A class group such as "2.A". Named SchoolClass because `Class` is taken.
|
|
4
|
+
class SchoolClass < Model
|
|
5
|
+
attribute :name
|
|
6
|
+
attribute :short
|
|
7
|
+
attribute :grade, "grade", cast: :integer
|
|
8
|
+
attribute(:teacher) { |d| school&.teacher(d["teacherid"]) }
|
|
9
|
+
attribute(:classroom) { |d| school&.classroom(d["classroomid"]) }
|
|
10
|
+
|
|
11
|
+
def students
|
|
12
|
+
Relation.new(-> { school.students.select { |s| s.school_class == self } })
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Models
|
|
3
|
+
# A pupil. For a parent account these are the account's own children, which is the
|
|
4
|
+
# entry point to everything year- and child-scoped.
|
|
5
|
+
class Student < Person
|
|
6
|
+
USER_STRING_PREFIX = "Student".freeze
|
|
7
|
+
|
|
8
|
+
attribute :number_in_class, "numberinclass", cast: :integer
|
|
9
|
+
attribute :number, "number", cast: :integer
|
|
10
|
+
attribute(:school_class) { |d| school&.school_class(d["classid"]) }
|
|
11
|
+
attribute(:parents) do |d|
|
|
12
|
+
%w[parent1id parent2id parent3id].filter_map { |key| school&.parent(d[key]) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def class_name = school_class&.name
|
|
16
|
+
|
|
17
|
+
# --- year-scoped navigation -------------------------------------------------
|
|
18
|
+
# Grades and terms only exist within a school year, and Edupage keeps the
|
|
19
|
+
# selected year in the session, so they hang off a Year rather than the student.
|
|
20
|
+
|
|
21
|
+
def years = school.years_for(self)
|
|
22
|
+
|
|
23
|
+
def year(id)
|
|
24
|
+
years.find { |y| y.id == id.to_s } or
|
|
25
|
+
raise NotFoundError, "#{full_name} has no school year #{id}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def current_year = years.find(&:current?) || years.first
|
|
29
|
+
|
|
30
|
+
# Convenience delegations: the common case is "this year".
|
|
31
|
+
def grades(...) = current_year.grades(...)
|
|
32
|
+
def terms(...) = current_year.terms(...)
|
|
33
|
+
|
|
34
|
+
# --- child-scoped, year-agnostic --------------------------------------------
|
|
35
|
+
|
|
36
|
+
def timetable(range = nil) = school.timetable_for(self, range)
|
|
37
|
+
def lessons(date = nil) = school.lessons_for(self, date)
|
|
38
|
+
def timeline = school.timeline_for(self)
|
|
39
|
+
def assignments = school.assignments_for(self)
|
|
40
|
+
def homeworks = assignments.where(type: Assignment::HOMEWORK_TYPES)
|
|
41
|
+
|
|
42
|
+
def to_s = "#{full_name}#{class_name ? " (#{class_name})" : ""}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Models
|
|
3
|
+
# A teacher. `short` is the code that appears in timetable cells.
|
|
4
|
+
class Teacher < Person
|
|
5
|
+
USER_STRING_PREFIX = "Ucitel".freeze
|
|
6
|
+
|
|
7
|
+
attribute :short
|
|
8
|
+
attribute :name_prefix, "nameprefix"
|
|
9
|
+
attribute :name_suffix, "namesuffix"
|
|
10
|
+
attribute :hidden_in_classbook, "cb_hidden", cast: :boolean
|
|
11
|
+
attribute(:classroom) { |d| school&.classroom(d["classroomid"]) }
|
|
12
|
+
|
|
13
|
+
alias hidden_in_classbook? hidden_in_classbook
|
|
14
|
+
|
|
15
|
+
# "Mgr. Mária Kováčová, PhD."
|
|
16
|
+
def full_name_with_titles
|
|
17
|
+
leading = [name_prefix.to_s.strip, full_name].reject(&:empty?).join(" ")
|
|
18
|
+
suffix = name_suffix.to_s.strip
|
|
19
|
+
suffix.empty? ? leading : "#{leading}, #{suffix}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def match_strings = (super + [short]).compact.reject(&:empty?).uniq
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Models
|
|
3
|
+
# Half of a school year ("1. polrok"), as reported by yearterms.
|
|
4
|
+
#
|
|
5
|
+
# `grade_count` comes straight from Edupage and is the cheapest way to tell whether
|
|
6
|
+
# a term is worth fetching at all.
|
|
7
|
+
class Term < Model
|
|
8
|
+
attribute :id, "term"
|
|
9
|
+
attribute :name, "termName"
|
|
10
|
+
attribute :year_id, "yearid"
|
|
11
|
+
attribute :year_name, "yearName"
|
|
12
|
+
attribute :starts_on, "dateFrom", cast: :date
|
|
13
|
+
attribute :ends_on, "dateTo", cast: :date
|
|
14
|
+
attribute :grade_count, "numGrades", cast: :integer
|
|
15
|
+
|
|
16
|
+
attr_reader :year
|
|
17
|
+
|
|
18
|
+
def initialize(data, school: nil, year: nil)
|
|
19
|
+
super(data, school: school)
|
|
20
|
+
@year = year
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def short = id
|
|
24
|
+
|
|
25
|
+
def grades = Relation.new(-> { year.grades_for(self) })
|
|
26
|
+
|
|
27
|
+
def current?
|
|
28
|
+
return false unless starts_on && ends_on
|
|
29
|
+
|
|
30
|
+
(starts_on..ends_on).cover?(Date.today)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_s = "#{year_name} #{name} (#{grade_count} grades)"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Models
|
|
3
|
+
# One entry in the school feed.
|
|
4
|
+
#
|
|
5
|
+
# Edupage packs a second JSON document into the `data` field as a string; its shape
|
|
6
|
+
# depends on the item type. #payload decodes it lazily.
|
|
7
|
+
class TimelineItem < Model
|
|
8
|
+
# The types worth naming; there are many more and they pass through as-is.
|
|
9
|
+
TYPES = {
|
|
10
|
+
"sprava" => :message,
|
|
11
|
+
"nastenka" => :noticeboard,
|
|
12
|
+
"homework" => :homework,
|
|
13
|
+
"znamka" => :grade,
|
|
14
|
+
"student_absent" => :absence,
|
|
15
|
+
"ospravedlnenka" => :absence_note,
|
|
16
|
+
"substitution" => :substitution,
|
|
17
|
+
"timetable" => :timetable,
|
|
18
|
+
"stravamenu" => :canteen_menu,
|
|
19
|
+
"payments" => :payments,
|
|
20
|
+
"signin" => :signin,
|
|
21
|
+
"event" => :event,
|
|
22
|
+
"ucivo" => :curriculum,
|
|
23
|
+
"h_testvysledok" => :test_result,
|
|
24
|
+
"testpridelenie" => :test_assignment
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
attribute :id, "timelineid"
|
|
28
|
+
attribute :raw_type, "typ"
|
|
29
|
+
attribute :text
|
|
30
|
+
attribute :recipient_user_string, "user"
|
|
31
|
+
attribute :recipient_name, "user_meno"
|
|
32
|
+
attribute :owner_user_string, "vlastnik"
|
|
33
|
+
attribute :owner_name, "vlastnik_meno"
|
|
34
|
+
attribute :created_at, "cas_pridania", cast: :time
|
|
35
|
+
attribute :occurred_at, "cas_udalosti", cast: :time
|
|
36
|
+
attribute :reply_to_id, "reakcia_na"
|
|
37
|
+
attribute :reply_count, "pocet_reakcii", cast: :integer
|
|
38
|
+
attribute :removed, "removed", cast: :boolean
|
|
39
|
+
attribute :other_id, "ineid"
|
|
40
|
+
|
|
41
|
+
alias removed? removed
|
|
42
|
+
|
|
43
|
+
attr_reader :student
|
|
44
|
+
|
|
45
|
+
def initialize(data, school: nil, student: nil)
|
|
46
|
+
super(data, school: school)
|
|
47
|
+
@student = student
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A friendly symbol where one exists, otherwise the raw Edupage string.
|
|
51
|
+
def type = TYPES.fetch(raw_type) { raw_type }
|
|
52
|
+
|
|
53
|
+
def reply? = !reply_to_id.nil? && !reply_to_id.to_s.empty?
|
|
54
|
+
|
|
55
|
+
# The embedded JSON document, or an empty hash.
|
|
56
|
+
def payload
|
|
57
|
+
@payload ||= begin
|
|
58
|
+
raw = data["data"]
|
|
59
|
+
parsed = raw.is_a?(String) ? (JSON.parse(raw) rescue nil) : raw
|
|
60
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def owner
|
|
65
|
+
@owner ||= school&.resolve_user_string(owner_user_string)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def recipient
|
|
69
|
+
@recipient ||= school&.resolve_user_string(recipient_user_string)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def title = payload["nazov"].to_s.empty? ? nil : payload["nazov"]
|
|
73
|
+
|
|
74
|
+
def name = title || text
|
|
75
|
+
def match_strings = [id, title, text, raw_type].compact.map(&:to_s).reject(&:empty?)
|
|
76
|
+
|
|
77
|
+
def to_s
|
|
78
|
+
label = [title, text].compact.map { |s| s.to_s.gsub(/\s+/, " ") }.reject(&:empty?).first
|
|
79
|
+
"#{created_at&.strftime("%Y-%m-%d %H:%M")} [#{type}] #{label}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def to_h
|
|
83
|
+
super.merge(type: type, title: title, payload_keys: payload.keys).compact
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "strscan"
|
|
3
|
+
|
|
4
|
+
module Edupage
|
|
5
|
+
module Parsers
|
|
6
|
+
# Every Edupage page ships its data as an argument to a JavaScript call:
|
|
7
|
+
#
|
|
8
|
+
# $j(document).ready(function() { ... .userhome({"dbi":{...}}); });
|
|
9
|
+
#
|
|
10
|
+
# The upstream JS library pulls these out with `fn\(([\s\S]*?)\);` which stops at
|
|
11
|
+
# the first `);` - including one inside a string value, which does occur in message
|
|
12
|
+
# bodies and homework titles. This scans for balanced brackets instead, so the
|
|
13
|
+
# payload is extracted correctly regardless of its contents.
|
|
14
|
+
module Base
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Returns the parsed JSON argument of the first call to +function+, or nil.
|
|
18
|
+
#
|
|
19
|
+
# +arg+ selects which argument to take. Most calls put the payload first, but
|
|
20
|
+
# /gcall answers with `classbook.fill("Student113506", {...})`.
|
|
21
|
+
def extract_blob(html, function, arg: 0)
|
|
22
|
+
raw = extract_raw(html, function, arg: arg)
|
|
23
|
+
return nil unless raw
|
|
24
|
+
|
|
25
|
+
JSON.parse(raw)
|
|
26
|
+
rescue JSON::ParserError => e
|
|
27
|
+
raise ParseError.new("#{function}(...) argument #{arg} is not valid JSON: #{e.message}", snippet: raw)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def extract_blob!(html, function, arg: 0)
|
|
31
|
+
extract_blob(html, function, arg: arg) or
|
|
32
|
+
raise ParseError.new("Could not find #{function}(...) in the response", snippet: html)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The literal source text of the first bracket-balanced argument.
|
|
36
|
+
#
|
|
37
|
+
# Scanning happens on a binary view of the document. StringScanner#pos counts
|
|
38
|
+
# bytes while String#[] counts characters, and these pages are full of Slovak
|
|
39
|
+
# diacritics - mixing the two silently walks off the end of a string literal and
|
|
40
|
+
# starts counting brackets inside it.
|
|
41
|
+
def extract_raw(html, function, arg: 0)
|
|
42
|
+
bytes = html.b
|
|
43
|
+
needle = function.b
|
|
44
|
+
index = 0
|
|
45
|
+
|
|
46
|
+
while (found = bytes.index(needle, index))
|
|
47
|
+
index = found + needle.bytesize
|
|
48
|
+
open = skip_spaces(bytes, index)
|
|
49
|
+
next unless bytes[open] == "("
|
|
50
|
+
|
|
51
|
+
start = argument_start(bytes, open, arg)
|
|
52
|
+
next unless start && ["{", "["].include?(bytes[start])
|
|
53
|
+
|
|
54
|
+
finish = match_bracket(bytes, start)
|
|
55
|
+
return bytes[start..finish].force_encoding(Encoding::UTF_8) if finish
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ASC settings are plain assignments rather than a call:
|
|
62
|
+
# ASC.gsechash = "803e65e8";
|
|
63
|
+
# ASC.gpid = 62780423;
|
|
64
|
+
def extract_assignments(html, namespace)
|
|
65
|
+
html.scan(/#{Regexp.escape(namespace)}\.([A-Za-z0-9_$]+)\s*=\s*([^\n;]*);/)
|
|
66
|
+
.each_with_object({}) do |(key, value), result|
|
|
67
|
+
value = value.strip
|
|
68
|
+
next if value.start_with?("function")
|
|
69
|
+
|
|
70
|
+
result[key] = (JSON.parse("[#{value}]").first rescue value)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def skip_spaces(text, index)
|
|
75
|
+
index += 1 while text[index] =~ /\s/
|
|
76
|
+
index
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
BRACKETS = { "{" => "}", "[" => "]" }.freeze
|
|
80
|
+
|
|
81
|
+
# Matches either a complete JSON string literal or a single structural bracket.
|
|
82
|
+
#
|
|
83
|
+
# Consuming whole string literals in one match is what makes this correct: values
|
|
84
|
+
# in these payloads routinely contain brackets, and the timeline embeds an entire
|
|
85
|
+
# JSON document as an escaped string, so brackets must only be counted outside
|
|
86
|
+
# string literals.
|
|
87
|
+
TOKEN = /"(?:[^"\\]|\\.)*"|[{}\[\]]/m.freeze
|
|
88
|
+
|
|
89
|
+
# As TOKEN, plus the punctuation that separates call arguments.
|
|
90
|
+
ARGUMENT_TOKEN = /"(?:[^"\\]|\\.)*"|[{}\[\](),]/m.freeze
|
|
91
|
+
|
|
92
|
+
# Byte index where argument +wanted+ of a call begins, given the position of the
|
|
93
|
+
# opening parenthesis. Commas inside strings, objects or nested calls do not
|
|
94
|
+
# count as separators.
|
|
95
|
+
def argument_start(text, open, wanted)
|
|
96
|
+
return skip_spaces(text, open + 1) if wanted.zero?
|
|
97
|
+
|
|
98
|
+
scanner = StringScanner.new(text)
|
|
99
|
+
scanner.pos = open + 1
|
|
100
|
+
depth = 0
|
|
101
|
+
seen = 0
|
|
102
|
+
|
|
103
|
+
while scanner.scan_until(ARGUMENT_TOKEN)
|
|
104
|
+
token = scanner.matched
|
|
105
|
+
next if token.start_with?('"')
|
|
106
|
+
|
|
107
|
+
case token
|
|
108
|
+
when "{", "[", "(" then depth += 1
|
|
109
|
+
when "}", "]" then depth -= 1
|
|
110
|
+
when ")"
|
|
111
|
+
return nil if depth.zero? # argument list ended before we got there
|
|
112
|
+
|
|
113
|
+
depth -= 1
|
|
114
|
+
when ","
|
|
115
|
+
next unless depth.zero?
|
|
116
|
+
|
|
117
|
+
seen += 1
|
|
118
|
+
return skip_spaces(text, scanner.pos) if seen == wanted
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Byte index of the bracket closing the one at +start+, or nil when unbalanced.
|
|
126
|
+
# +text+ must be a binary string; see #extract_raw.
|
|
127
|
+
def match_bracket(text, start)
|
|
128
|
+
scanner = StringScanner.new(text)
|
|
129
|
+
scanner.pos = start + 1
|
|
130
|
+
stack = [BRACKETS[text[start]]]
|
|
131
|
+
|
|
132
|
+
while scanner.scan_until(TOKEN)
|
|
133
|
+
char = scanner.matched
|
|
134
|
+
next if char.start_with?('"') # a whole string literal, skipped wholesale
|
|
135
|
+
|
|
136
|
+
if BRACKETS.key?(char)
|
|
137
|
+
stack.push(BRACKETS[char])
|
|
138
|
+
else
|
|
139
|
+
return nil unless char == stack.last
|
|
140
|
+
|
|
141
|
+
stack.pop
|
|
142
|
+
return scanner.pos - 1 if stack.empty?
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Parsers
|
|
3
|
+
# Timetable for an arbitrary date range.
|
|
4
|
+
#
|
|
5
|
+
# /user/ only ever carries about four days. Anything wider goes through /gcall,
|
|
6
|
+
# Edupage's generic widget RPC, which answers with a blob of JavaScript rather than
|
|
7
|
+
# JSON - the payload sits in the second argument of a `classbook.fill(...)` call.
|
|
8
|
+
class Gcall
|
|
9
|
+
PATH = "/gcall".freeze
|
|
10
|
+
CLASSBOOK_PATH = "/dashboard/eb.php?barNoSkin=1".freeze
|
|
11
|
+
FILL = "classbook.fill".freeze
|
|
12
|
+
|
|
13
|
+
def initialize(school)
|
|
14
|
+
@school = school
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns { "2026-09-21" => {day payload}, ... } for the requested range.
|
|
18
|
+
def load(student:, from:, to:)
|
|
19
|
+
body = @school.session.with_cursor(child: student.id) do |session|
|
|
20
|
+
session.post(PATH, {
|
|
21
|
+
"gpid" => gpid(session),
|
|
22
|
+
"gsh" => gsec_hash,
|
|
23
|
+
"action" => "loadData",
|
|
24
|
+
"datefrom" => from.iso8601,
|
|
25
|
+
"dateto" => to.iso8601
|
|
26
|
+
}).body
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
payload = Base.extract_blob(body, FILL, arg: 1)
|
|
30
|
+
return {} unless payload
|
|
31
|
+
|
|
32
|
+
payload["dates"] || {}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def gsec_hash
|
|
38
|
+
@school.document.gsec_hash or
|
|
39
|
+
raise ParseError, "No gsecHash on the dashboard; /gcall cannot be called without it"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The widget id is minted per page load and appears only in the class book page.
|
|
43
|
+
def gpid(session)
|
|
44
|
+
@gpid ||= begin
|
|
45
|
+
ids = session.get(CLASSBOOK_PATH).body.scan(/gpid="?(\d+)"?/).flatten.uniq
|
|
46
|
+
raise ParseError, "No gpid on the class book page" if ids.empty?
|
|
47
|
+
|
|
48
|
+
ids.last
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|