edoxen 0.7.2 → 0.8.2

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 (90) 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 +18 -5
  10. data/lib/edoxen/approval.rb +2 -2
  11. data/lib/edoxen/attendance.rb +16 -10
  12. data/lib/edoxen/body.rb +26 -0
  13. data/lib/edoxen/body_register.rb +17 -0
  14. data/lib/edoxen/body_vocabulary_entry.rb +24 -0
  15. data/lib/edoxen/body_vocabulary_host.rb +40 -0
  16. data/lib/edoxen/cli.rb +175 -115
  17. data/lib/edoxen/consideration.rb +6 -4
  18. data/lib/edoxen/contact.rb +37 -0
  19. data/lib/edoxen/contact_identifier.rb +13 -0
  20. data/lib/edoxen/contact_method.rb +16 -0
  21. data/lib/edoxen/contact_register.rb +26 -0
  22. data/lib/edoxen/date_time_range.rb +16 -0
  23. data/lib/edoxen/deadline.rb +1 -1
  24. data/lib/edoxen/decision.rb +57 -0
  25. data/lib/edoxen/decision_collection.rb +10 -0
  26. data/lib/edoxen/decision_date.rb +9 -0
  27. data/lib/edoxen/decision_metadata.rb +27 -0
  28. data/lib/edoxen/decision_relation.rb +11 -0
  29. data/lib/edoxen/declaration.rb +25 -0
  30. data/lib/edoxen/entity_ref.rb +74 -0
  31. data/lib/edoxen/entity_resolver.rb +68 -0
  32. data/lib/edoxen/enums.rb +123 -9
  33. data/lib/edoxen/error.rb +1 -1
  34. data/lib/edoxen/extension_attribute.rb +78 -0
  35. data/lib/edoxen/host_ref.rb +1 -1
  36. data/lib/edoxen/link_checker.rb +143 -34
  37. data/lib/edoxen/localized_name.rb +16 -0
  38. data/lib/edoxen/localized_string.rb +22 -0
  39. data/lib/edoxen/meeting.rb +36 -42
  40. data/lib/edoxen/meeting_collection.rb +1 -2
  41. data/lib/edoxen/meeting_collection_metadata.rb +4 -2
  42. data/lib/edoxen/meeting_component.rb +37 -0
  43. data/lib/edoxen/meeting_extension.rb +25 -0
  44. data/lib/edoxen/meeting_identifier.rb +1 -1
  45. data/lib/edoxen/meeting_series.rb +26 -0
  46. data/lib/edoxen/minutes.rb +3 -4
  47. data/lib/edoxen/minutes_section.rb +12 -13
  48. data/lib/edoxen/motion.rb +51 -0
  49. data/lib/edoxen/name.rb +29 -0
  50. data/lib/edoxen/officer.rb +19 -0
  51. data/lib/edoxen/officers_host.rb +19 -0
  52. data/lib/edoxen/person.rb +5 -9
  53. data/lib/edoxen/physical_venue.rb +15 -0
  54. data/lib/edoxen/recurrence.rb +27 -0
  55. data/lib/edoxen/recurrence_by_day.rb +10 -0
  56. data/lib/edoxen/reference.rb +1 -1
  57. data/lib/edoxen/reference_data.rb +34 -44
  58. data/lib/edoxen/schema_validator.rb +3 -4
  59. data/lib/edoxen/source_url.rb +4 -4
  60. data/lib/edoxen/statement.rb +21 -0
  61. data/lib/edoxen/structured_identifier.rb +2 -2
  62. data/lib/edoxen/topic.rb +39 -0
  63. data/lib/edoxen/topic_asset.rb +14 -0
  64. data/lib/edoxen/topic_document.rb +16 -0
  65. data/lib/edoxen/urn_for.rb +68 -0
  66. data/lib/edoxen/venue.rb +81 -0
  67. data/lib/edoxen/venue_register.rb +18 -0
  68. data/lib/edoxen/venue_validator.rb +74 -0
  69. data/lib/edoxen/version.rb +1 -1
  70. data/lib/edoxen/virtual_venue.rb +15 -0
  71. data/lib/edoxen/vote_record.rb +17 -13
  72. data/lib/edoxen/voting.rb +45 -0
  73. data/lib/edoxen/voting_counts.rb +23 -0
  74. data/lib/edoxen.rb +51 -15
  75. data/schema/edoxen.yaml +1427 -246
  76. data/schema/meeting.yaml +1311 -308
  77. data/sig/edoxen.rbs +581 -1
  78. metadata +66 -22
  79. data/lib/edoxen/_metadata.rb.deprecated +0 -57
  80. data/lib/edoxen/localization.rb +0 -18
  81. data/lib/edoxen/location.rb +0 -15
  82. data/lib/edoxen/meeting_localization.rb +0 -15
  83. data/lib/edoxen/resolution.rb +0 -43
  84. data/lib/edoxen/resolution_collection.rb +0 -10
  85. data/lib/edoxen/resolution_date.rb +0 -11
  86. data/lib/edoxen/resolution_metadata.rb +0 -30
  87. data/lib/edoxen/resolution_relation.rb +0 -11
  88. data/lib/edoxen/resolution_set.rb +0 -17
  89. data/lib/edoxen/schedule_item.rb +0 -27
  90. data/lib/edoxen/schedule_item_localization.rb +0 -21
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # ExtensionAttribute — one typed key/value pair within a
5
+ # MeetingExtension. Polymorphic on value type so consumers don't
6
+ # have to re-parse strings back into Int/Float/Bool/Date.
7
+ #
8
+ # Wire form (1.0+, per 1.0 design review):
9
+ #
10
+ # - key: "quorum"
11
+ # type: "integer"
12
+ # intValue: 7
13
+ #
14
+ # - key: "name"
15
+ # type: "string"
16
+ # value: "quic" # string variant uses bare `value` (1.0 wire)
17
+ #
18
+ # - key: "start"
19
+ # type: "datetime"
20
+ # dateTimeValue: 2026-07-04T10:00:00Z
21
+ #
22
+ # The `type` discriminator tells consumers which value field to read
23
+ # without probing all six. Exactly one of the value fields should be
24
+ # set; the schema enforces this.
25
+ #
26
+ # String values use the bare `value:` wire name for back-compat with
27
+ # 1.0 fixtures. Typed variants (integer/float/boolean/date/datetime)
28
+ # use camelCase wire names. The gem unifies them behind `#typed_value`.
29
+ class ExtensionAttribute < Lutaml::Model::Serializable
30
+ attribute :key, :string
31
+
32
+ # String variant — Ruby attribute is `value`, wire name is `value`
33
+ # (matches 1.0; `type: "string"` discriminator added in 1.0).
34
+ attribute :value, :string
35
+
36
+ # Typed variants — added in 1.0.
37
+ attribute :integer_value, :integer
38
+ attribute :float_value, :float
39
+ attribute :boolean_value, :boolean
40
+ attribute :date_value, :date
41
+ attribute :date_time_value, :date_time
42
+
43
+ # Wire discriminator: "string" | "integer" | "float" | "boolean" |
44
+ # "date" | "datetime". Tells consumers which value field to read.
45
+ attribute :type, :string
46
+
47
+ key_value do
48
+ map "key", to: :key
49
+ map "type", to: :type
50
+ map "value", to: :value
51
+ map "intValue", to: :integer_value
52
+ map "floatValue", to: :float_value
53
+ map "booleanValue", to: :boolean_value
54
+ map "dateValue", to: :date_value
55
+ map "dateTimeValue", to: :date_time_value
56
+ end
57
+
58
+ # Convenience: read whichever typed value is set. Returns nil when
59
+ # none of the value fields is populated.
60
+ def typed_value
61
+ case type
62
+ when "integer" then integer_value
63
+ when "float" then float_value
64
+ when "boolean" then boolean_value unless boolean_value.nil?
65
+ when "date" then date_value
66
+ when "datetime" then date_time_value
67
+ else
68
+ # Default to string for back-compat with 1.0 (no `type`).
69
+ value
70
+ end
71
+ end
72
+
73
+ # Back-compat alias — 1.0 introduced `value` as the canonical Ruby
74
+ # attribute; the older `string_value` alias keeps callers that
75
+ # already migrated to that name working.
76
+ alias string_value value
77
+ end
78
+ end
@@ -9,6 +9,6 @@ module Edoxen
9
9
  attribute :ref, :string
10
10
  attribute :type, :string, values: Enums::HOST_TYPE
11
11
  attribute :role, :string
12
- attribute :contact, Person
12
+ attribute :contact, Contact
13
13
  end
14
14
  end
@@ -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,72 @@
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, Body
37
+ attribute :committee_group, Body
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
47
+ attribute :contacts, Contact, collection: true
48
+ attribute :bodies, Body, collection: true
40
49
 
41
50
  attribute :agenda, Agenda
42
- attribute :schedule, ScheduleItem, collection: true
51
+ attribute :components, MeetingComponent, collection: true
43
52
  attribute :deadlines, Deadline, collection: true
44
53
 
45
54
  attribute :attendance, Attendance, collection: true
46
- attribute :vote_records, VoteRecord, collection: true
47
55
  attribute :minutes, Minutes, collection: true
56
+ attribute :declarations, Declaration, collection: true
48
57
 
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
58
+ # Outcomes (canonical location on Meeting)
59
+ attribute :decisions, Decision, collection: true
60
+ attribute :motions, Motion, collection: true
61
+ attribute :votings, Voting, collection: true
57
62
 
58
- fallback ? localizations&.first : nil
59
- end
60
-
61
- # English when available, else the first declared localization.
62
- def primary_localization
63
- in_language("eng", fallback: true)
64
- end
63
+ attribute :relations, MeetingRelation, collection: true
64
+ attribute :extensions, MeetingExtension, collection: true
65
65
 
66
- # Find an agenda item by label. Returns nil when no agenda or no
67
- # matching label.
68
66
  def find_agenda_item(label)
69
67
  agenda&.find_item(label)
70
68
  end
71
69
 
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
70
  def city_entry
77
71
  return nil if city.nil? || city.to_s.empty?
78
72
 
@@ -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