edoxen 0.7.2 → 0.8.1

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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +26 -1
  3. data/.rubocop_todo.yml +1 -1
  4. data/CHANGELOG.md +133 -0
  5. data/README.adoc +414 -182
  6. data/edoxen.gemspec +10 -10
  7. data/lib/edoxen/action.rb +5 -4
  8. data/lib/edoxen/agenda.rb +4 -7
  9. data/lib/edoxen/agenda_item.rb +12 -5
  10. data/lib/edoxen/approval.rb +2 -2
  11. data/lib/edoxen/attendance.rb +16 -10
  12. data/lib/edoxen/body_vocabulary_entry.rb +24 -0
  13. data/lib/edoxen/body_vocabulary_host.rb +40 -0
  14. data/lib/edoxen/cli.rb +175 -115
  15. data/lib/edoxen/consideration.rb +6 -4
  16. data/lib/edoxen/contact.rb +35 -0
  17. data/lib/edoxen/contact_collection.rb +26 -0
  18. data/lib/edoxen/contact_identifier.rb +13 -0
  19. data/lib/edoxen/contact_method.rb +16 -0
  20. data/lib/edoxen/date_time_range.rb +16 -0
  21. data/lib/edoxen/deadline.rb +1 -1
  22. data/lib/edoxen/decision.rb +57 -0
  23. data/lib/edoxen/decision_collection.rb +10 -0
  24. data/lib/edoxen/decision_date.rb +9 -0
  25. data/lib/edoxen/decision_metadata.rb +27 -0
  26. data/lib/edoxen/decision_relation.rb +11 -0
  27. data/lib/edoxen/declaration.rb +25 -0
  28. data/lib/edoxen/entity_ref.rb +74 -0
  29. data/lib/edoxen/enums.rb +123 -9
  30. data/lib/edoxen/error.rb +1 -1
  31. data/lib/edoxen/extension_attribute.rb +78 -0
  32. data/lib/edoxen/host_ref.rb +1 -1
  33. data/lib/edoxen/link_checker.rb +143 -34
  34. data/lib/edoxen/localized_name.rb +16 -0
  35. data/lib/edoxen/localized_string.rb +22 -0
  36. data/lib/edoxen/meeting.rb +34 -42
  37. data/lib/edoxen/meeting_collection.rb +1 -2
  38. data/lib/edoxen/meeting_collection_metadata.rb +4 -2
  39. data/lib/edoxen/meeting_component.rb +37 -0
  40. data/lib/edoxen/meeting_extension.rb +25 -0
  41. data/lib/edoxen/meeting_identifier.rb +1 -1
  42. data/lib/edoxen/meeting_series.rb +26 -0
  43. data/lib/edoxen/minutes.rb +3 -4
  44. data/lib/edoxen/minutes_section.rb +12 -13
  45. data/lib/edoxen/motion.rb +51 -0
  46. data/lib/edoxen/name.rb +29 -0
  47. data/lib/edoxen/officer.rb +19 -0
  48. data/lib/edoxen/officers_host.rb +19 -0
  49. data/lib/edoxen/person.rb +5 -9
  50. data/lib/edoxen/physical_venue.rb +15 -0
  51. data/lib/edoxen/recurrence.rb +27 -0
  52. data/lib/edoxen/recurrence_by_day.rb +10 -0
  53. data/lib/edoxen/reference.rb +1 -1
  54. data/lib/edoxen/reference_data.rb +34 -44
  55. data/lib/edoxen/schema_validator.rb +3 -4
  56. data/lib/edoxen/source_url.rb +4 -4
  57. data/lib/edoxen/statement.rb +21 -0
  58. data/lib/edoxen/structured_identifier.rb +2 -2
  59. data/lib/edoxen/topic.rb +39 -0
  60. data/lib/edoxen/topic_asset.rb +14 -0
  61. data/lib/edoxen/topic_document.rb +16 -0
  62. data/lib/edoxen/venue.rb +79 -0
  63. data/lib/edoxen/venue_collection.rb +18 -0
  64. data/lib/edoxen/venue_validator.rb +74 -0
  65. data/lib/edoxen/version.rb +1 -1
  66. data/lib/edoxen/virtual_venue.rb +15 -0
  67. data/lib/edoxen/vote_record.rb +17 -13
  68. data/lib/edoxen/voting.rb +45 -0
  69. data/lib/edoxen/voting_counts.rb +23 -0
  70. data/lib/edoxen.rb +47 -15
  71. data/schema/edoxen.yaml +1376 -246
  72. data/schema/meeting.yaml +1207 -272
  73. data/sig/edoxen.rbs +581 -1
  74. metadata +62 -22
  75. data/lib/edoxen/_metadata.rb.deprecated +0 -57
  76. data/lib/edoxen/localization.rb +0 -18
  77. data/lib/edoxen/location.rb +0 -15
  78. data/lib/edoxen/meeting_localization.rb +0 -15
  79. data/lib/edoxen/resolution.rb +0 -43
  80. data/lib/edoxen/resolution_collection.rb +0 -10
  81. data/lib/edoxen/resolution_date.rb +0 -11
  82. data/lib/edoxen/resolution_metadata.rb +0 -30
  83. data/lib/edoxen/resolution_relation.rb +0 -11
  84. data/lib/edoxen/resolution_set.rb +0 -17
  85. data/lib/edoxen/schedule_item.rb +0 -27
  86. data/lib/edoxen/schedule_item_localization.rb +0 -21
@@ -1,31 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml"
4
+ require "date"
5
+
3
6
  module Edoxen
4
- # Walks a directory tree of Meeting and ResolutionCollection YAML
7
+ # Walks a directory tree of Meeting and DecisionCollection YAML
5
8
  # files and asserts that every cross-document URN link resolves.
6
9
  #
7
10
  # Invariants enforced:
8
11
  #
9
- # * Every Meeting#urn (if set) is referenced by exactly one
10
- # ResolutionCollection#metadata.meeting_urn (if the collection
11
- # exists in the scanned directory).
12
- # * Every ResolutionCollection#metadata.meeting_urn (if set)
12
+ # * Every Meeting#urn (if set) is unique across the scanned tree.
13
+ # * Every DecisionCollection#metadata.meeting_urn (if set)
13
14
  # resolves to a Meeting whose urn matches.
14
15
  #
15
16
  # Use from CLI / scripts via the public `check` class method. Returns
16
17
  # an array of `LinkError` records (empty = ok).
17
18
  #
18
19
  # All file IO is read-only. No edits to the YAMLs.
20
+ #
21
+ # Architecture: two passes.
22
+ #
23
+ # 1. `index_pass` walks every file, classifies its top-level shape
24
+ # via one of three MECE predicates (`meeting_collection_shape?`,
25
+ # `meeting_shape?`, `decision_collection_shape?`), and records
26
+ # URN → IndexEntry pairs. Duplicate URNs are reported here.
27
+ # 2. `verify_pass` checks every cross-document link in the indexed
28
+ # collections against the indexed meetings.
29
+ #
30
+ # Adding a new link type is one new field-reading line in
31
+ # `index_pass` (if the source side needs indexing) plus one
32
+ # iteration in `verify_pass`. Adding a new top-level shape is one
33
+ # new predicate + one new branch in `index_pass`.
19
34
  class LinkChecker
20
- LinkError = Struct.new(:file, :pointer, :urn, :kind) do
35
+ # Internal: file + line pair carried by the URN indexes.
36
+ IndexEntry = Struct.new(:file, :line)
37
+
38
+ # Cross-document link failure. `line` is 1-based. `pointer` is the
39
+ # dotted in-document path that carries the bad reference (e.g.
40
+ # `metadata.meeting_urn` or `meetings[2].urn`).
41
+ LinkError = Struct.new(:file, :line, :pointer, :urn, :kind) do
21
42
  def message
22
- "#{file}: #{pointer} references #{urn} (#{kind})"
43
+ "#{file}:#{line}: #{pointer} references #{urn} (#{kind})"
23
44
  end
45
+
46
+ alias_method :to_clickable_format, :message
24
47
  end
25
48
 
26
49
  # Walk `dir` (recursive scan of *.yaml and *.yml), classify each
27
- # file as Meeting or ResolutionCollection, and assert every
28
- # cross-document URN resolves to a real record.
50
+ # file, and assert every cross-document URN resolves to a real
51
+ # record.
29
52
  #
30
53
  # @return [Array<LinkError>] empty when all links resolve.
31
54
  def self.check(dir)
@@ -34,52 +57,138 @@ module Edoxen
34
57
 
35
58
  def initialize(dir)
36
59
  @dir = dir
37
- @meetings_by_urn = {} # urn → file
38
- @collections_by_meeting_urn = {} # meeting_urn → file
60
+ @meetings_by_urn = {} # urn → IndexEntry(file, line)
61
+ @collections_by_meeting_urn = {} # meeting_urn → IndexEntry(file, line)
39
62
  end
40
63
 
41
64
  def check
42
- require "yaml"
65
+ errors = []
66
+ index_pass(errors)
67
+ verify_pass(errors)
68
+ errors
69
+ end
43
70
 
44
- Dir.glob(File.join(@dir, "**", "*.{yaml,yml}")).sort.each do |file|
45
- data = safe_load_yaml(file)
71
+ private
72
+
73
+ # Phase 1: walk the tree, classify each file, populate the URN
74
+ # indexes. Reports duplicate-URN errors as they surface — the
75
+ # first claim keeps the index slot; subsequent claimants are
76
+ # rejected and flagged, so dangling-link reports always point at
77
+ # the authoritative source.
78
+ def index_pass(errors)
79
+ # `Dir.glob` order is filesystem-dependent; the duplicate-URN
80
+ # specs assert on which file is the "first claim", so sort
81
+ # explicitly for determinism.
82
+ Dir.glob(File.join(@dir, "**", "*.{yaml,yml}")).sort.each do |file| # rubocop:disable Lint/RedundantDirGlobSort
83
+ content = File.read(file)
84
+ data = safe_load_yaml(content)
46
85
  next unless data.is_a?(Hash)
47
86
 
48
- if meeting_shape?(data)
49
- urn = data["urn"]
50
- @meetings_by_urn[urn] = file if urn
51
- elsif collection_shape?(data)
52
- meeting_urn = data.dig("metadata", "meeting_urn")
53
- @collections_by_meeting_urn[meeting_urn] = file if meeting_urn
87
+ line_map = SchemaValidator::LineMap.build(content)
88
+
89
+ case data
90
+ when ->(d) { meeting_collection_shape?(d) }
91
+ index_meeting_collection(data, file, line_map, errors)
92
+ when ->(d) { meeting_shape?(d) }
93
+ line = line_of(line_map, "/urn")
94
+ register_meeting(data["urn"], file, line, "urn", errors) if data["urn"]
95
+ when ->(d) { decision_collection_shape?(d) }
96
+ index_decision_collection(data, file, line_map)
54
97
  end
55
98
  end
99
+ end
56
100
 
57
- errors = []
58
-
59
- # ResolutionCollection → Meeting
60
- @collections_by_meeting_urn.each do |meeting_urn, file|
101
+ # Phase 2: every cross-document link is checked here. New link
102
+ # types land as additional iterations over the relevant index.
103
+ def verify_pass(errors)
104
+ @collections_by_meeting_urn.each do |meeting_urn, entry|
61
105
  next if @meetings_by_urn.key?(meeting_urn)
62
106
 
63
- errors << LinkError.new(file, "metadata.meeting_urn", meeting_urn, "no matching Meeting")
107
+ errors << LinkError.new(entry.file, entry.line, "metadata.meeting_urn", meeting_urn,
108
+ "no matching Meeting")
64
109
  end
65
-
66
- errors
67
110
  end
68
111
 
69
- private
112
+ # ---- Shape predicates (MECE) --------------------------------------
113
+ #
114
+ # The three predicates partition the universe of edoxen YAML
115
+ # shapes that participate in cross-document URN linking. Files
116
+ # that match none of them (e.g. MeetingSeries, random `_data/*.yaml`)
117
+ # are silently skipped.
70
118
 
71
- def safe_load_yaml(file)
72
- YAML.safe_load(File.read(file))
73
- rescue Psych::SyntaxError, ArgumentError
74
- nil
119
+ # A MeetingCollection wraps its meetings under a top-level
120
+ # `meetings:` array. Its own top level carries no `identifier` or
121
+ # `type`, so this predicate is disjoint from `meeting_shape?`.
122
+ def meeting_collection_shape?(data)
123
+ data["meetings"].is_a?(Array)
75
124
  end
76
125
 
126
+ # A standalone Meeting carries `identifier` (array) and `type`.
127
+ # MeetingSeries has `identifier` + `kind` (no `type`), so it is
128
+ # correctly excluded — MeetingSeries does not yet participate in
129
+ # cross-document URN linking.
77
130
  def meeting_shape?(data)
78
131
  data["identifier"].is_a?(Array) && data.key?("type")
79
132
  end
80
133
 
81
- def collection_shape?(data)
82
- data["resolutions"].is_a?(Array) || data["metadata"].is_a?(Hash)
134
+ # A DecisionCollection carries `decisions` or `resolutions`. The
135
+ # earlier `metadata.is_a?(Hash)` branch was too permissive — it
136
+ # matched any file with a top-level `metadata:` key, including
137
+ # unrelated YAML. A real DecisionCollection always has one of the
138
+ # two collection keys.
139
+ def decision_collection_shape?(data)
140
+ data["decisions"].is_a?(Array) || data["resolutions"].is_a?(Array)
141
+ end
142
+
143
+ # ---- Indexers -----------------------------------------------------
144
+
145
+ # Index each nested meeting that satisfies `meeting_shape?` — the
146
+ # same bar applied to standalone meeting files — so a malformed
147
+ # entry carrying only a `urn` cannot make a dangling link appear
148
+ # to resolve. Duplicate URNs are reported via `register_meeting`.
149
+ def index_meeting_collection(data, file, line_map, errors)
150
+ data["meetings"].each_with_index do |meeting, idx|
151
+ next unless meeting.is_a?(Hash) && meeting_shape?(meeting)
152
+ next unless meeting["urn"]
153
+
154
+ line = line_of(line_map, "/meetings/#{idx}/urn")
155
+ register_meeting(meeting["urn"], file, line, "meetings[#{idx}].urn", errors)
156
+ end
157
+ end
158
+
159
+ def index_decision_collection(data, file, line_map)
160
+ meeting_urn = data.dig("metadata", "meeting_urn")
161
+ return unless meeting_urn
162
+
163
+ line = line_of(line_map, "/metadata/meeting_urn")
164
+ @collections_by_meeting_urn[meeting_urn] = IndexEntry.new(file, line)
165
+ end
166
+
167
+ # Record a Meeting#urn → file/line entry. A second claim on the
168
+ # same URN is reported as a duplicate-URN error and the index
169
+ # keeps the first claim (so dangling-link reports point at the
170
+ # authoritative source, not the most-recent overwrite).
171
+ def register_meeting(urn, file, line, pointer, errors)
172
+ prior = @meetings_by_urn[urn]
173
+ if prior
174
+ errors << LinkError.new(file, line, pointer, urn,
175
+ "duplicate Meeting URN (first seen at #{prior.file}:#{prior.line})")
176
+ return
177
+ end
178
+
179
+ @meetings_by_urn[urn] = IndexEntry.new(file, line)
180
+ end
181
+
182
+ # ---- Helpers ------------------------------------------------------
183
+
184
+ def line_of(line_map, json_pointer)
185
+ SchemaValidator::LineMap.locate(json_pointer, line_map).first
186
+ end
187
+
188
+ def safe_load_yaml(content)
189
+ YAML.safe_load(content, permitted_classes: [Date, Time])
190
+ rescue Psych::Exception, ArgumentError
191
+ nil
83
192
  end
84
193
  end
85
194
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # One language-specific value of a translatable Name field.
5
+ # Mirrors LocalizedString but carries a structured Name value rather
6
+ # than a plain String.
7
+ #
8
+ # Use case: a single Contact can be known by different names in
9
+ # different languages — `张建方` (zho-Hans) and `Jianfang Zhang`
10
+ # (acadsin:zho-Hani:Latn:2002 Pinyin romanization) are the same person.
11
+ class LocalizedName < Lutaml::Model::Serializable
12
+ attribute :spelling, :string
13
+ attribute :value, Name
14
+ attribute :extensions, MeetingExtension, collection: true
15
+ end
16
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # One language-specific value of a translatable String field.
5
+ # Replaces the v2.x pattern of a separate MeetingLocalization/
6
+ # Localization/ComponentLocalization object that duplicated the
7
+ # entity's translatable fields.
8
+ #
9
+ # `spelling` is an ISO 24229 spelling/conversion system code:
10
+ # - Spelling system: {lang}-{script}[-{country}][-{extension}]
11
+ # e.g. `eng`, `zho-Hans`, `ind-Latn-pre1972`
12
+ # - Conversion system: {authority}:{source}:{target}:{identifying}
13
+ # e.g. `acadsin:zho-Hani:Latn:2002`
14
+ #
15
+ # Always verbose — single-language data uses the same
16
+ # `[{ spelling, value }]` shape as multi-language data.
17
+ class LocalizedString < Lutaml::Model::Serializable
18
+ attribute :spelling, :string
19
+ attribute :value, :string
20
+ attribute :extensions, MeetingExtension, collection: true
21
+ end
22
+ end
@@ -1,78 +1,70 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Edoxen
4
- # A single Meeting — the event that produces Resolutions. Carries
5
- # identity, time, venue, officers, agenda, schedule, deadlines, and
6
- # URN links to one or more ResolutionCollection documents.
4
+ # A single Meeting (event) produces Decisions via Motions and Votings.
5
+ # Carries identity, time, polymorphic venues, officers, agenda, components,
6
+ # attendance, minutes, and URN links to one or more DecisionCollection
7
+ # documents.
7
8
  #
8
- # Meetings and ResolutionCollections are kept as separate documents
9
- # and joined by URN (Meeting.resolution_refs ↔
10
- # ResolutionCollection.metadata.meeting_urn) because they have
11
- # different lifetimes: agendas exist weeks before a meeting;
12
- # resolutions only after adoption.
9
+ # 1.0 (per-field localization, ISO 24229):
10
+ # - Removed `localizations[]` collection.
11
+ # - Added per-field LocalizedString for title, general_area,
12
+ # practical_info, note.
13
13
  class Meeting < Lutaml::Model::Serializable
14
+ include OfficersHost
15
+
14
16
  attribute :identifier, StructuredIdentifier, collection: true
15
17
  attribute :urn, :string
16
18
  attribute :ordinal, :integer
19
+ attribute :series_ref, :string
17
20
  attribute :type, :string, values: Enums::MEETING_TYPE
18
21
  attribute :status, :string, values: Enums::MEETING_STATUS
19
- attribute :year, :integer
20
-
21
- attribute :date_range, DateRange
22
+ attribute :visibility, :string, values: Enums::VISIBILITY
23
+ attribute :body_type, :string
22
24
 
23
- attribute :committee, :string
24
- attribute :committee_group, :string
25
+ attribute :title, LocalizedString, collection: true
26
+ attribute :scheduled_date_range, DateRange
27
+ attribute :occurred_date_range, DateTimeRange
28
+ attribute :recurrence, Recurrence
25
29
 
26
- attribute :venues, Location, collection: true
27
- attribute :general_area, :string
30
+ attribute :venues, Venue, collection: true
31
+ attribute :general_area, LocalizedString, collection: true
32
+ attribute :practical_info, LocalizedString, collection: true
28
33
  attribute :city, :string
29
34
  attribute :country_code, :string
30
- attribute :virtual, :boolean
31
35
 
32
- attribute :chair, Person
33
- attribute :secretary, Person
34
- attribute :host, :string
36
+ attribute :committee, :string
37
+ attribute :committee_group, :string
38
+
39
+ attribute :officers, Officer, collection: true
35
40
  attribute :hosts, HostRef, collection: true
36
41
 
37
42
  attribute :source_urls, SourceUrl, collection: true
38
43
  attribute :landing_url, :string
39
44
  attribute :registration_url, :string
45
+ attribute :note, LocalizedString, collection: true
46
+ attribute :contact, Contact
40
47
 
41
48
  attribute :agenda, Agenda
42
- attribute :schedule, ScheduleItem, collection: true
49
+ attribute :components, MeetingComponent, collection: true
43
50
  attribute :deadlines, Deadline, collection: true
44
51
 
45
52
  attribute :attendance, Attendance, collection: true
46
- attribute :vote_records, VoteRecord, collection: true
47
53
  attribute :minutes, Minutes, collection: true
54
+ attribute :declarations, Declaration, collection: true
48
55
 
49
- attribute :localizations, MeetingLocalization, collection: true
50
- attribute :relations, MeetingRelation, collection: true
51
- attribute :resolution_refs, :string, collection: true
52
-
53
- # Lookup by ISO 639-3 language code. Mirrors Resolution#in_language.
54
- def in_language(code, fallback: false)
55
- match = localizations&.find { |loc| loc.language_code == code.to_s }
56
- return match if match
57
-
58
- fallback ? localizations&.first : nil
59
- end
56
+ # Outcomes (canonical location on Meeting)
57
+ attribute :decisions, Decision, collection: true
58
+ attribute :motions, Motion, collection: true
59
+ attribute :votings, Voting, collection: true
60
60
 
61
- # English when available, else the first declared localization.
62
- def primary_localization
63
- in_language("eng", fallback: true)
64
- end
61
+ attribute :relations, MeetingRelation, collection: true
62
+ attribute :extensions, MeetingExtension, collection: true
65
63
 
66
- # Find an agenda item by label. Returns nil when no agenda or no
67
- # matching label.
68
64
  def find_agenda_item(label)
69
65
  agenda&.find_item(label)
70
66
  end
71
67
 
72
- # Resolve the meeting's UN/LOCODE via the canonical `unlocode` gem
73
- # registry. Returns an `Unlocodes::Entry` (with `#name`, `#country`,
74
- # `#coordinates`, `#iata`, `#functions`, etc.) or nil when the
75
- # city field is empty / not in the registry.
76
68
  def city_entry
77
69
  return nil if city.nil? || city.to_s.empty?
78
70
 
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Edoxen
4
- # Top-level container for many Meetings. Parallel to
5
- # ResolutionCollection.
4
+ # Top-level container for many Meetings. Parallel to DecisionCollection.
6
5
  class MeetingCollection < Lutaml::Model::Serializable
7
6
  attribute :metadata, MeetingCollectionMetadata
8
7
  attribute :meetings, Meeting, collection: true
@@ -2,10 +2,12 @@
2
2
 
3
3
  module Edoxen
4
4
  # Top-level wrapper for many Meetings in a single YAML file. Parallel
5
- # to ResolutionCollection. The metadata block carries display-level
5
+ # to DecisionCollection. The metadata block carries display-level
6
6
  # info (title, source); per-meeting identity lives on each Meeting.
7
7
  class MeetingCollectionMetadata < Lutaml::Model::Serializable
8
- attribute :title, :string
8
+ include BodyVocabularyHost
9
+
10
+ attribute :title, LocalizedString, collection: true
9
11
  attribute :source, :string
10
12
  end
11
13
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # MeetingComponent — flat sub-event of a Meeting.
5
+ # Replaces legacy ScheduleItem.
6
+ #
7
+ # 1.0 (per-field localization, ISO 24229):
8
+ # - Removed `localizations[]` collection.
9
+ # - Per-field LocalizedString for title, description, time_label.
10
+ class MeetingComponent < Lutaml::Model::Serializable
11
+ include OfficersHost
12
+
13
+ attribute :identifier, :string
14
+ attribute :urn, :string
15
+ attribute :kind, :string, values: Enums::COMPONENT_KIND
16
+ # Identifier of the body this component belongs to. Optional;
17
+ # usually inherited from the parent Meeting.
18
+ attribute :body_type, :string
19
+ attribute :title, LocalizedString, collection: true
20
+ attribute :description, LocalizedString, collection: true
21
+ attribute :starts_at, :date_time
22
+ attribute :ends_at, :date_time
23
+ attribute :time_label, LocalizedString, collection: true
24
+ attribute :venue_refs, :string, collection: true
25
+ attribute :officers, Officer, collection: true
26
+ attribute :agenda_ref, :string
27
+ attribute :minutes_ref, :string
28
+ attribute :attendance_refs, :string, collection: true
29
+ attribute :extensions, MeetingExtension, collection: true
30
+
31
+ def duration_seconds
32
+ return nil unless starts_at && ends_at
33
+
34
+ ends_at.to_time - starts_at.to_time
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # MeetingExtension — the profile mechanism (ISO 8601-2 §15).
5
+ # Every core entity has an `extensions: MeetingExtension[0..*]` slot.
6
+ # Adopters register their own profile namespace (e.g. "legco",
7
+ # "us-congress", "ietf") and define `kind` values within it.
8
+ #
9
+ # Field semantics (tightened 1.0, per 1.0 design review):
10
+ #
11
+ # profile — the profile namespace (lowercase, hyphen-separated).
12
+ # kind — discriminator within the profile.
13
+ # ref — URN of the profile document this extension references.
14
+ # attributes — typed key/value pairs (ExtensionAttribute).
15
+ #
16
+ # Recursion (`extensions: MeetingExtension[0..*]`) was removed in
17
+ # 1.0 — no documented use case. Profiles needing nesting can encode
18
+ # it via dotted keys ("vote.count", "vote.method") in `attributes[]`.
19
+ class MeetingExtension < Lutaml::Model::Serializable
20
+ attribute :profile, :string
21
+ attribute :kind, :string
22
+ attribute :ref, :string
23
+ attribute :attributes, ExtensionAttribute, collection: true
24
+ end
25
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Edoxen
4
4
  # Identifier of a meeting (venue + date). Singular — the meeting a
5
- # particular Resolution belongs to.
5
+ # particular Decision belongs to.
6
6
  class MeetingIdentifier < Lutaml::Model::Serializable
7
7
  attribute :venue, :string
8
8
  attribute :date, :date
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # MeetingSeries — parent of recurring Meeting instances.
5
+ # Crossref `proceedings-series`, IETF meeting series, Apache Board
6
+ # meetings, etc. Carries a `Recurrence` (ISO 8601-2 §13) rule and
7
+ # a list of member Meeting URNs.
8
+ class MeetingSeries < Lutaml::Model::Serializable
9
+ attribute :identifier, StructuredIdentifier, collection: true
10
+ # Identifier of the body this series represents (e.g. 'ciml').
11
+ # Meetings and Decisions belonging to this series inherit the body
12
+ # via the series_ref join. Optional; most consumers have a single
13
+ # body and leave this unset.
14
+ attribute :body_type, :string
15
+ attribute :urn, :string
16
+ attribute :name, LocalizedString, collection: true
17
+ attribute :description, LocalizedString, collection: true
18
+ attribute :recurrence, Recurrence
19
+ attribute :term, :string
20
+ attribute :contact, Contact
21
+ attribute :hosts, HostRef, collection: true
22
+ attribute :kind, :string
23
+ attribute :meeting_refs, :string, collection: true
24
+ attribute :extensions, MeetingExtension, collection: true
25
+ end
26
+ end
@@ -7,16 +7,15 @@ module Edoxen
7
7
  # TODO.meeting-agenda/10-future-enhancements.md).
8
8
  #
9
9
  # Distinct from Agenda (the business order drafted before the
10
- # meeting) and from ResolutionCollection (the formal decisions
10
+ # meeting) and from DecisionCollection (the formal decisions
11
11
  # adopted). The Minutes are the running record.
12
12
  #
13
13
  # Sections are typically keyed by agenda-item number so consumers
14
- # can join Minutes ↔ AgendaItem ↔ Resolution by `number`/`label`.
14
+ # can join Minutes ↔ AgendaItem ↔ Decision by `number`/`label`.
15
15
  class Minutes < Lutaml::Model::Serializable
16
16
  attribute :identifier, StructuredIdentifier, collection: true
17
17
  attribute :urn, :string
18
- attribute :language_code, :string
19
- attribute :script, :string
18
+ attribute :spelling, :string
20
19
  attribute :source_doc, :string
21
20
  attribute :source_pages, :string
22
21
  attribute :sections, MinutesSection, collection: true
@@ -5,23 +5,22 @@ module Edoxen
5
5
  # item by its `number` field. Carries the narrative as Markdown
6
6
  # (the format the GLM-OCR pipeline emits) plus optional page range
7
7
  # for provenance back to the source PDF.
8
+ #
9
+ # Localization lives on the parent Minutes document, not on each
10
+ # section — Minutes has a per-document language_code; the section
11
+ # lookup is `Minutes#find_section(number)`. Earlier copies of this
12
+ # class inherited `in_language`/`primary_localization` from
13
+ # ScheduleItem/Meeting, but those methods referenced an undeclared
14
+ # `localizations` attribute and raised NameError on every call.
15
+ # Removed 2026-07-05.
8
16
  class MinutesSection < Lutaml::Model::Serializable
9
17
  attribute :number, :string
10
- attribute :title, :string
11
- attribute :narrative, :string
18
+ attribute :title, LocalizedString, collection: true
19
+ attribute :narrative, LocalizedString, collection: true
12
20
  attribute :page_start, :integer
13
21
  attribute :page_end, :integer
14
22
  attribute :references, Reference, collection: true
15
-
16
- def in_language(code, fallback: false)
17
- match = localizations&.find { |loc| loc.language_code == code.to_s }
18
- return match if match
19
-
20
- fallback ? localizations&.first : nil
21
- end
22
-
23
- def primary_localization
24
- in_language("eng", fallback: true)
25
- end
23
+ attribute :statements, Statement, collection: true
24
+ attribute :topic_ref, :string
26
25
  end
27
26
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Motion — a procedural act that brings a Decision. "I move that..."
5
+ # Distinct from a Decision (formal outcome) and from a TopicDocument
6
+ # (written text). A Motion does NOT require any document.
7
+ #
8
+ # State machine (MotionStatus):
9
+ # introduced → seconded → debating → question_put → voting
10
+ # ├── carried → resulting_decision set
11
+ # ├── negatived
12
+ # ├── withdrawn
13
+ # └── lapsed
14
+ class Motion < Lutaml::Model::Serializable
15
+ attribute :identifier, :string
16
+ attribute :urn, :string
17
+ attribute :text, LocalizedString, collection: true
18
+ attribute :mover, Person
19
+ attribute :seconders, Person, collection: true
20
+ attribute :status, :string, values: Enums::MOTION_STATUS
21
+ attribute :introduced_at, :date_time
22
+ attribute :proposed_decision, :string
23
+ attribute :resulting_decision, :string
24
+ # Pilot EntityRef field (1.0, 1.0 design review). Parallel to
25
+ # `resulting_decision` (String). Prefer the typed form in new code;
26
+ # the bare String form is removed in 1.0.
27
+ attribute :resulting_decision_ref, EntityRef
28
+ attribute :votings, :string, collection: true
29
+ attribute :extensions, MeetingExtension, collection: true
30
+
31
+ def carried?
32
+ status == "carried"
33
+ end
34
+
35
+ def pending?
36
+ status && !Enums::MOTION_TERMINAL.include?(status)
37
+ end
38
+
39
+ # --- 1.0 derivation accessor (1.0 design review) --------------------
40
+ # Storage side: Voting.on_motion (SSOT for the Motion→Voting
41
+ # relationship). This computed method returns the Voting instances
42
+ # in `meeting` whose `on_motion` points at this Motion's URN.
43
+ # The stored `votings[]` field remains on the wire for back-compat
44
+ # through v2.x; 1.0 removes it and this becomes the only path.
45
+ def votings_in(meeting:)
46
+ return [] unless meeting && urn
47
+
48
+ meeting.votings.select { |voting| voting.on_motion == urn }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Structured personal/organisational name. VCARD conventions
5
+ # (RFC 6350): separate structured components (N) from a pre-formatted
6
+ # display string (FN). Either or both may be populated.
7
+ #
8
+ # `formatted` is the display string when callers have it pre-built;
9
+ # the structured fields (`family`, `given`, `additional`, `prefix`,
10
+ # `suffix`) let adopters store parsed components for sorting, indexing,
11
+ # or locale-aware rendering.
12
+ class Name < Lutaml::Model::Serializable
13
+ attribute :formatted, :string
14
+ attribute :family, :string
15
+ attribute :given, :string
16
+ attribute :additional, :string
17
+ attribute :prefix, :string
18
+ attribute :suffix, :string
19
+ attribute :extensions, MeetingExtension, collection: true
20
+
21
+ # Convenience: returns `formatted` if present, else builds one from
22
+ # the structured components. Useful for callers that always want a
23
+ # display string regardless of how the name was authored.
24
+ def display
25
+ formatted || [prefix, given, additional, family, suffix]
26
+ .reject { |s| s.to_s.empty? }.join(" ")
27
+ end
28
+ end
29
+ end