edoxen 0.3.0 → 0.5.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +24 -10
  3. data/README.adoc +170 -31
  4. data/TODO.meeting-agenda/01-design-decisions.md +132 -0
  5. data/TODO.meeting-agenda/02-lutaml-canonical.md +61 -0
  6. data/TODO.meeting-agenda/03-ruby-models.md +73 -0
  7. data/TODO.meeting-agenda/04-schema.md +34 -0
  8. data/TODO.meeting-agenda/05-fixtures.md +43 -0
  9. data/TODO.meeting-agenda/06-specs.md +52 -0
  10. data/TODO.meeting-agenda/07-cli.md +55 -0
  11. data/TODO.meeting-agenda/08-docs.md +35 -0
  12. data/TODO.meeting-agenda/09-verify-and-release.md +41 -0
  13. data/TODO.meeting-agenda/10-future-enhancements.md +72 -0
  14. data/TODO.meeting-agenda/README.md +30 -0
  15. data/lib/edoxen/action.rb +0 -6
  16. data/lib/edoxen/agenda.rb +26 -0
  17. data/lib/edoxen/agenda_item.rb +17 -0
  18. data/lib/edoxen/approval.rb +0 -7
  19. data/lib/edoxen/cli.rb +199 -95
  20. data/lib/edoxen/consideration.rb +0 -6
  21. data/lib/edoxen/date_range.rb +11 -0
  22. data/lib/edoxen/deadline.rb +10 -0
  23. data/lib/edoxen/enums.rb +24 -0
  24. data/lib/edoxen/error.rb +32 -2
  25. data/lib/edoxen/host_ref.rb +14 -0
  26. data/lib/edoxen/localization.rb +0 -12
  27. data/lib/edoxen/location.rb +15 -0
  28. data/lib/edoxen/meeting.rb +68 -0
  29. data/lib/edoxen/meeting_collection.rb +26 -0
  30. data/lib/edoxen/meeting_collection_metadata.rb +11 -0
  31. data/lib/edoxen/meeting_identifier.rb +0 -5
  32. data/lib/edoxen/meeting_localization.rb +15 -0
  33. data/lib/edoxen/meeting_relation.rb +12 -0
  34. data/lib/edoxen/person.rb +14 -0
  35. data/lib/edoxen/reference.rb +13 -0
  36. data/lib/edoxen/resolution.rb +21 -12
  37. data/lib/edoxen/resolution_collection.rb +0 -5
  38. data/lib/edoxen/resolution_date.rb +0 -5
  39. data/lib/edoxen/resolution_metadata.rb +5 -9
  40. data/lib/edoxen/resolution_relation.rb +0 -6
  41. data/lib/edoxen/schedule_item.rb +16 -0
  42. data/lib/edoxen/schema_validator.rb +12 -28
  43. data/lib/edoxen/source_url.rb +6 -8
  44. data/lib/edoxen/structured_identifier.rb +0 -5
  45. data/lib/edoxen/url.rb +0 -6
  46. data/lib/edoxen/version.rb +1 -1
  47. data/lib/edoxen.rb +17 -0
  48. data/schema/edoxen.yaml +7 -0
  49. data/schema/meeting.yaml +358 -0
  50. metadata +27 -2
data/lib/edoxen/cli.rb CHANGED
@@ -4,54 +4,101 @@ require "thor"
4
4
  require "fileutils"
5
5
 
6
6
  module Edoxen
7
- # Thor command-line surface for the gem. Two responsibilities:
8
- # * `validate PATTERN` — runs both JSON-Schema validation and the
9
- # Ruby model parser against each matching YAML file.
10
- # * `normalize PATTERN (--output DIR | --inplace)` — round-trips each
11
- # matching YAML file through the Ruby model, preserving any
12
- # `# yaml-language-server: $schema=...` directive on the first line.
13
- #
14
- # The CLI deliberately does NOT own schema-or-model decisions — those
15
- # live in `SchemaValidator` and `Lutaml::Model` respectively. It only
16
- # glues them together and formats output.
17
7
  class Cli < Thor
18
- package_name "edoxen"
8
+ # Deep module behind the per-command interface. Owns the
9
+ # expand/sort/empty/header/loop/tally/summary/exit scaffold so
10
+ # `validate` and `normalize` collapse to per-file blocks.
11
+ #
12
+ # Commands call `Batch.run(self, pattern, header:)` and yield a block
13
+ # that returns `Result.ok(message)` or `Result.bad(errors)`. The
14
+ # batch runner prints progress, tallies, prints the summary, and
15
+ # exits with the right code.
16
+ #
17
+ # In-process; no adapter. The seam is the call site in each
18
+ # command method — internal to the CLI.
19
+ module Batch
20
+ # Per-file outcome. `ok` carries an optional message appended to
21
+ # the success indicator (e.g. "NORMALIZED → /out/path"). `bad`
22
+ # carries a list of pre-formatted error strings.
23
+ Result = Struct.new(:status, :message, :errors) do
24
+ def self.ok(message = nil)
25
+ new(:ok, message, nil)
26
+ end
19
27
 
20
- desc "validate YAML_FILE_PATTERN",
21
- "Validate one or more Edoxen YAML files against the schema and the model."
28
+ def self.bad(errors)
29
+ new(:bad, nil, Array(errors))
30
+ end
31
+ end
22
32
 
23
- def validate(pattern)
24
- files = expand_yaml_pattern(pattern)
25
- if files.empty?
26
- say "No files found matching pattern: #{pattern}", :red
27
- exit 1
33
+ module_function
34
+
35
+ # Run a batch over every YAML file matching `pattern`.
36
+ #
37
+ # Yields each file path to the caller's block. Block must return
38
+ # a Batch::Result. The batch runner handles progress output,
39
+ # tallies, summary, and the exit code.
40
+ def run(cli, pattern, header:, summary_extra: [])
41
+ files = expand(pattern)
42
+ if files.empty?
43
+ cli.say "No files found matching pattern: #{pattern}", :red
44
+ exit 1
45
+ end
46
+
47
+ cli.say "#{header} #{files.size} file(s)...", :blue
48
+
49
+ ok_count = 0
50
+ bad_count = 0
51
+
52
+ files.each do |file|
53
+ $stdout.print " #{File.basename(file)}... "
54
+ result = yield file
55
+ if result.status == :ok
56
+ label = result.message ? "✅ #{result.message}" : "✅"
57
+ cli.say label, :green
58
+ ok_count += 1
59
+ else
60
+ cli.say "❌ INVALID", :red
61
+ bad_count += 1
62
+ Array(result.errors).each { |e| cli.say " #{e}", :red }
63
+ end
64
+ end
65
+
66
+ print_summary(cli, files.size, ok_count, bad_count, summary_extra)
67
+ exit(bad_count.positive? ? 1 : 0)
28
68
  end
29
69
 
30
- say "🔍 Validating #{files.size} file(s)...", :blue
70
+ def expand(pattern)
71
+ Dir.glob(pattern).select { |f| File.file?(f) && f.match?(/\.ya?ml\z/i) }.sort
72
+ end
31
73
 
32
- validator = SchemaValidator.new
33
- valid_count = 0
34
- invalid_count = 0
74
+ def print_summary(cli, total, ok_count, bad_count, summary_extra)
75
+ cli.say "\n📊 Summary:", :blue
76
+ cli.say " Total: #{total}", :blue
77
+ cli.say " Success: #{ok_count}, Failed: #{bad_count}",
78
+ bad_count.positive? ? :red : :green
79
+ rate = total.zero? ? 0 : ((ok_count.to_f / total) * 100).round(1)
80
+ cli.say " Success rate: #{rate}%", :blue
81
+ summary_extra.each { |label, value| cli.say " #{label}: #{value}", :blue }
82
+ end
83
+ end
35
84
 
36
- files.each do |file|
37
- print " #{File.basename(file)}... "
85
+ package_name "edoxen"
38
86
 
87
+ desc "validate YAML_FILE_PATTERN",
88
+ "Validate one or more Edoxen YAML files against the schema and the model."
89
+
90
+ def validate(pattern)
91
+ validator = SchemaValidator.new
92
+ Batch.run(self, pattern, header: "🔍 Validating") do |file|
39
93
  schema_errors = validator.validate_file(file)
40
94
  model_errors = collect_model_errors(file)
41
-
42
95
  if schema_errors.empty? && model_errors.empty?
43
- say "VALID", :green
44
- valid_count += 1
96
+ Batch::Result.ok("VALID")
45
97
  else
46
- say "❌ INVALID", :red
47
- invalid_count += 1
48
- schema_errors.each { |e| say " #{e.to_clickable_format}", :red }
49
- model_errors.each { |m| say " #{file}:1:1: #{m}", :red }
98
+ errors = (schema_errors + model_errors).map(&:to_clickable_format)
99
+ Batch::Result.bad(errors)
50
100
  end
51
101
  end
52
-
53
- print_summary(files.size, valid_count, invalid_count, validator_type: :binary)
54
- exit(invalid_count.positive? ? 1 : 0)
55
102
  end
56
103
 
57
104
  desc "normalize YAML_FILE_PATTERN",
@@ -61,77 +108,103 @@ module Edoxen
61
108
  option :inplace, type: :boolean, desc: "Modify files in place (no backup)"
62
109
 
63
110
  def normalize(pattern)
64
- if options[:output] && options[:inplace]
65
- say "Error: Cannot use both --output and --inplace options", :red
111
+ unless valid_normalize_options?
112
+ say normalize_options_error, :red
66
113
  exit 1
67
114
  end
68
115
 
69
- unless options[:output] || options[:inplace]
70
- say "Error: Must specify either --output or --inplace option", :red
71
- exit 1
72
- end
116
+ summary_extra = [
117
+ [" Output directory", options[:output]],
118
+ [" Mode", options[:inplace] ? "in place" : "--output"]
119
+ ].compact
73
120
 
74
- files = expand_yaml_pattern(pattern)
75
- if files.empty?
76
- say "No files found matching pattern: #{pattern}", :red
77
- exit 1
121
+ Batch.run(self, pattern, header: "🔄 Normalizing", summary_extra: summary_extra) do |file|
122
+ Batch::Result.ok(normalize_file(file))
123
+ rescue StandardError => e
124
+ Batch::Result.bad(["#{file}:1:1: #{e.message}"])
78
125
  end
126
+ end
79
127
 
80
- say "🔄 Normalizing #{files.size} file(s)...", :blue
128
+ desc "validate-meetings YAML_FILE_PATTERN",
129
+ "Validate Meeting/Agenda YAML file(s) against schema/meeting.yaml."
81
130
 
82
- success_count = 0
83
- error_count = 0
131
+ def validate_meetings(pattern)
132
+ validator = SchemaValidator.new(meeting_schema_path)
133
+ Batch.run(self, pattern, header: "🔍 Validating meetings") do |file|
134
+ schema_errors = validator.validate_file(file)
135
+ model_errors = collect_meeting_model_errors(file)
136
+ if schema_errors.empty? && model_errors.empty?
137
+ Batch::Result.ok("VALID")
138
+ else
139
+ errors = (schema_errors + model_errors).map(&:to_clickable_format)
140
+ Batch::Result.bad(errors)
141
+ end
142
+ end
143
+ end
84
144
 
85
- files.each do |file|
86
- print " #{File.basename(file)}... "
87
- begin
88
- yaml_language_server_comment = extract_yaml_language_server_comment(File.read(file))
89
- normalized = ResolutionCollection.from_yaml(File.read(file)).to_yaml
90
- normalized = "#{yaml_language_server_comment}\n#{normalized}" if yaml_language_server_comment
145
+ desc "normalize-meetings YAML_FILE_PATTERN",
146
+ "Round-trip Meeting YAML file(s) through the model (--output DIR or --inplace)."
91
147
 
92
- if options[:inplace]
93
- File.write(file, normalized)
94
- say "✅ NORMALIZED", :green
95
- else
96
- out = File.join(options[:output], File.basename(file))
97
- FileUtils.mkdir_p(File.dirname(out))
98
- File.write(out, normalized)
99
- say "✅ NORMALIZED → #{out}", :green
100
- end
101
- success_count += 1
102
- rescue StandardError => e
103
- say "❌ FAILED — #{e.message}", :red
104
- error_count += 1
105
- end
148
+ option :output, type: :string, desc: "Output directory for normalized files"
149
+ option :inplace, type: :boolean, desc: "Modify files in place (no backup)"
150
+
151
+ def normalize_meetings(pattern)
152
+ unless valid_normalize_options?
153
+ say normalize_options_error, :red
154
+ exit 1
106
155
  end
107
156
 
108
- print_summary(files.size, success_count, error_count, validator_type: :lax,
109
- extra: [
110
- [" Output directory", options[:output]],
111
- [" Mode", options[:inplace] ? "in place" : "--output"]
112
- ].compact)
113
- exit(error_count.positive? ? 1 : 0)
114
- end
157
+ summary_extra = [
158
+ [" Output directory", options[:output]],
159
+ [" Mode", options[:inplace] ? "in place" : "--output"]
160
+ ].compact
115
161
 
116
- no_commands do
117
- # Reserved for private Thor plumbing if we add it later.
162
+ Batch.run(self, pattern, header: "🔄 Normalizing meetings", summary_extra: summary_extra) do |file|
163
+ Batch::Result.ok(normalize_meeting_file(file))
164
+ rescue StandardError => e
165
+ Batch::Result.bad(["#{file}:1:1: #{e.message}"])
166
+ end
118
167
  end
119
168
 
120
169
  private
121
170
 
122
- def expand_yaml_pattern(pattern)
123
- Dir.glob(pattern).select { |f| File.file?(f) && f.match?(/\.ya?ml\z/i) }.sort
171
+ def meeting_schema_path
172
+ File.expand_path("../../schema/meeting.yaml", __dir__)
124
173
  end
125
174
 
126
- # Round-trip the file through the model to catch structural issues
127
- # (missing nested classes, type mismatches) that the JSON-Schema can't
128
- # express. The model is permissive about field names — schema is the
129
- # strict source.
130
175
  def collect_model_errors(file)
131
176
  ResolutionCollection.from_yaml(File.read(file))
132
177
  []
133
178
  rescue StandardError => e
134
- ["Model parsing failed: #{e.message}"]
179
+ [Edoxen::ValidationError.new(
180
+ file: file, line: 1, column: 1,
181
+ message_text: "Model parsing failed: #{e.message}",
182
+ source: Edoxen::ValidationError::SOURCE_MODEL
183
+ )]
184
+ end
185
+
186
+ def collect_meeting_model_errors(file)
187
+ load_meeting_document(file)
188
+ []
189
+ rescue StandardError => e
190
+ [Edoxen::ValidationError.new(
191
+ file: file, line: 1, column: 1,
192
+ message_text: "Meeting model parsing failed: #{e.message}",
193
+ source: Edoxen::ValidationError::SOURCE_MODEL
194
+ )]
195
+ end
196
+
197
+ # A meeting YAML may be either a single Meeting or a
198
+ # MeetingCollection. Detect by the presence of a top-level
199
+ # `meetings:` key and parse accordingly. Returns whichever
200
+ # model object was successfully constructed.
201
+ def load_meeting_document(file)
202
+ data = YAML.safe_load(File.read(file), permitted_classes: [Date, Time])
203
+ if data.is_a?(Hash) && data.key?("meetings")
204
+ MeetingCollection.from_yaml(File.read(file))
205
+ else
206
+ Meeting.from_yaml(File.read(file))
207
+ end
135
208
  end
136
209
 
137
210
  def extract_yaml_language_server_comment(content)
@@ -139,18 +212,49 @@ module Edoxen
139
212
  lines.find { |l| l.strip.match?(/\A#\s*yaml-language-server:\s*\$schema=/) }&.rstrip
140
213
  end
141
214
 
142
- def print_summary(total, ok_count, bad_count, validator_type:, extra: [])
143
- say "\n📊 Summary:", :blue
144
- say " Total: #{total}", :blue
145
- label_text = if validator_type == :binary
146
- " Valid: #{ok_count}, Invalid: #{bad_count}"
147
- else
148
- " Success: #{ok_count}, Failed: #{bad_count}"
149
- end
150
- say label_text, bad_count.positive? ? :red : :green
151
- success_rate = total.zero? ? 0 : ((ok_count.to_f / total) * 100).round(1)
152
- say " Success rate: #{success_rate}%", :blue
153
- extra.each { |label, value| say " #{label}: #{value}", :blue }
215
+ def valid_normalize_options?
216
+ return false if options[:output] && options[:inplace]
217
+ return false unless options[:output] || options[:inplace]
218
+
219
+ true
220
+ end
221
+
222
+ def normalize_options_error
223
+ if options[:output] && options[:inplace]
224
+ "Error: Cannot use both --output and --inplace options"
225
+ else
226
+ "Error: Must specify either --output or --inplace option"
227
+ end
228
+ end
229
+
230
+ # Writes the normalized YAML either to the original file (--inplace)
231
+ # or under the --output directory. Returns a one-line status message
232
+ # for the batch runner to print after the ✅.
233
+ def normalize_file(file)
234
+ original = File.read(file)
235
+ yaml_language_server_comment = extract_yaml_language_server_comment(original)
236
+ normalized = ResolutionCollection.from_yaml(original).to_yaml
237
+ write_normalized(file, normalized, yaml_language_server_comment)
238
+ end
239
+
240
+ def normalize_meeting_file(file)
241
+ yaml_language_server_comment = extract_yaml_language_server_comment(File.read(file))
242
+ normalized = load_meeting_document(file).to_yaml
243
+ write_normalized(file, normalized, yaml_language_server_comment)
244
+ end
245
+
246
+ def write_normalized(file, normalized, yaml_language_server_comment)
247
+ normalized = "#{yaml_language_server_comment}\n#{normalized}" if yaml_language_server_comment
248
+
249
+ if options[:inplace]
250
+ File.write(file, normalized)
251
+ "NORMALIZED"
252
+ else
253
+ out = File.join(options[:output], File.basename(file))
254
+ FileUtils.mkdir_p(File.dirname(out))
255
+ File.write(out, normalized)
256
+ "NORMALIZED → #{out}"
257
+ end
154
258
  end
155
259
  end
156
260
  end
@@ -7,11 +7,5 @@ module Edoxen
7
7
  attribute :type, :string, values: Enums::CONSIDERATION_TYPE
8
8
  attribute :date_effective, ResolutionDate
9
9
  attribute :message, :string
10
-
11
- key_value do
12
- map "type", to: :type
13
- map "date_effective", to: :date_effective
14
- map "message", to: :message
15
- end
16
10
  end
17
11
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Start + end pair for multi-day meetings. Both endpoints are Date.
5
+ # Either may be nil for one-sided ranges (theoretically), though the
6
+ # schema enforces both required when DateRange is present.
7
+ class DateRange < Lutaml::Model::Serializable
8
+ attribute :start, :date
9
+ attribute :end, :date
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # A time-bound requirement leading up to (or following) a meeting —
5
+ # registration cutoff, report-submission deadline, etc.
6
+ class Deadline < Lutaml::Model::Serializable
7
+ attribute :date, :date
8
+ attribute :description, :string
9
+ end
10
+ end
data/lib/edoxen/enums.rb CHANGED
@@ -39,5 +39,29 @@ module Edoxen
39
39
  APPROVAL_DEGREE = %w[unanimous majority minority].freeze
40
40
 
41
41
  URL_KIND = %w[access report].freeze
42
+
43
+ # --- Meeting/Agenda side ----------------------------------------------
44
+ # Mirrors edoxen-model/models/{meeting,agenda,...}_*.lutaml.
45
+ # schema/meeting.yaml references these via $defs/<EnumName>.enum.
46
+
47
+ MEETING_TYPE = %w[plenary working_group task_group ad_hoc joint conference_session].freeze
48
+
49
+ MEETING_STATUS = %w[upcoming completed cancelled].freeze
50
+
51
+ AGENDA_STATUS = %w[draft final amended cancelled superseded].freeze
52
+
53
+ AGENDA_ITEM_KIND = %w[numbered unnumbered header opening closing].freeze
54
+
55
+ AGENDA_ITEM_OUTCOME = %w[discussed resolved deferred adopted withdrawn].freeze
56
+
57
+ HOST_TYPE = %w[national_body liaison associate organizer].freeze
58
+
59
+ MEETING_RELATION_TYPE = %w[
60
+ continues_from continues_to joint_with supersedes superseded_by rescheduled_from rescheduled_to
61
+ ].freeze
62
+
63
+ SOURCE_URL_KIND = %w[
64
+ agenda_pdf minutes_pdf resolutions_pdf report_pdf register_url landing_page
65
+ ].freeze
42
66
  end
43
67
  end
data/lib/edoxen/error.rb CHANGED
@@ -1,7 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Edoxen
4
- # Base class for any Edoxen-level error. Reserved for raise-on-failure paths;
5
- # schema validation errors live under SchemaValidator::ValidationError.
4
+ # Base class for any Edoxen-level error. Reserved for raise-on-failure paths.
6
5
  class Error < StandardError; end
6
+
7
+ # Unified validation failure shape. Produced by both:
8
+ # * SchemaValidator — JSON-Schema violations and YAML syntax errors
9
+ # carry source: :schema (or :syntax for Psych::SyntaxError).
10
+ # * ResolutionCollection.from_yaml rescues in the CLI — model parse
11
+ # failures carry source: :model.
12
+ #
13
+ # One type at the seam means callers (CLI, future renderers, tests)
14
+ # handle one shape instead of N.
15
+ class ValidationError < Error
16
+ SOURCE_SCHEMA = :schema
17
+ SOURCE_MODEL = :model
18
+ SOURCE_SYNTAX = :syntax
19
+
20
+ attr_reader :file, :line, :column, :pointer, :message_text, :source
21
+
22
+ def initialize(file:, line:, column:, message_text:, pointer: "", source: SOURCE_SCHEMA)
23
+ @file = file
24
+ @line = line
25
+ @column = column
26
+ @pointer = pointer.to_s
27
+ @message_text = message_text
28
+ @source = source
29
+ super(to_clickable_format)
30
+ end
31
+
32
+ def to_clickable_format
33
+ suffix = @pointer.empty? ? "" : " at `#{@pointer}`"
34
+ "#{@file}:#{@line}:#{@column}: #{@message_text}#{suffix}"
35
+ end
36
+ end
7
37
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Typed reference to an organization that hosts or co-organizes a
5
+ # meeting. `ref` is a stable identifier into an external registry
6
+ # (national_bodies.yml, liaisons.yml, associates.yml in ISO/TC 154);
7
+ # `type` discriminates which registry.
8
+ class HostRef < Lutaml::Model::Serializable
9
+ attribute :ref, :string
10
+ attribute :type, :string, values: Enums::HOST_TYPE
11
+ attribute :role, :string
12
+ attribute :contact, Person
13
+ end
14
+ end
@@ -14,17 +14,5 @@ module Edoxen
14
14
  attribute :considerations, Consideration, collection: true
15
15
  attribute :approvals, Approval, collection: true
16
16
  attribute :actions, Action, collection: true
17
-
18
- key_value do
19
- map "language_code", to: :language_code
20
- map "script", to: :script
21
- map "title", to: :title
22
- map "subject", to: :subject
23
- map "message", to: :message
24
- map "considering", to: :considering
25
- map "considerations", to: :considerations
26
- map "approvals", to: :approvals
27
- map "actions", to: :actions
28
- end
29
17
  end
30
18
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Venue geography. Multi-venue meetings (e.g., a plenary week with
5
+ # different hotels) carry one Location per venue.
6
+ class Location < Lutaml::Model::Serializable
7
+ attribute :name, :string
8
+ attribute :address, :string
9
+ attribute :link, :string
10
+ attribute :phone, :string
11
+ attribute :note, :string
12
+ attribute :lat, :float
13
+ attribute :lon, :float
14
+ end
15
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
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.
7
+ #
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.
13
+ class Meeting < Lutaml::Model::Serializable
14
+ attribute :identifier, StructuredIdentifier, collection: true
15
+ attribute :urn, :string
16
+ attribute :ordinal, :integer
17
+ attribute :type, :string, values: Enums::MEETING_TYPE
18
+ attribute :status, :string, values: Enums::MEETING_STATUS
19
+ attribute :year, :integer
20
+
21
+ attribute :date_range, DateRange
22
+
23
+ attribute :committee, :string
24
+ attribute :committee_group, :string
25
+
26
+ attribute :venues, Location, collection: true
27
+ attribute :general_area, :string
28
+ attribute :city, :string
29
+ attribute :country_code, :string
30
+ attribute :virtual, :boolean
31
+
32
+ attribute :chair, Person
33
+ attribute :secretary, Person
34
+ attribute :host, :string
35
+ attribute :hosts, HostRef, collection: true
36
+
37
+ attribute :source_urls, SourceUrl, collection: true
38
+ attribute :landing_url, :string
39
+ attribute :registration_url, :string
40
+
41
+ attribute :agenda, Agenda
42
+ attribute :schedule, ScheduleItem, collection: true
43
+ attribute :deadlines, Deadline, collection: true
44
+
45
+ attribute :localizations, MeetingLocalization, collection: true
46
+ attribute :relations, MeetingRelation, collection: true
47
+ attribute :resolution_refs, :string, collection: true
48
+
49
+ # Lookup by ISO 639-3 language code. Mirrors Resolution#in_language.
50
+ def in_language(code, fallback: false)
51
+ match = localizations&.find { |loc| loc.language_code == code.to_s }
52
+ return match if match
53
+
54
+ fallback ? localizations&.first : nil
55
+ end
56
+
57
+ # English when available, else the first declared localization.
58
+ def primary_localization
59
+ in_language("eng", fallback: true)
60
+ end
61
+
62
+ # Find an agenda item by label. Returns nil when no agenda or no
63
+ # matching label.
64
+ def find_agenda_item(label)
65
+ agenda&.find_item(label)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Top-level container for many Meetings. Parallel to
5
+ # ResolutionCollection.
6
+ class MeetingCollection < Lutaml::Model::Serializable
7
+ attribute :metadata, MeetingCollectionMetadata
8
+ attribute :meetings, Meeting, collection: true
9
+
10
+ # Find a meeting by URN. Returns nil when no meeting matches.
11
+ def find_by_urn(urn)
12
+ meetings&.find { |m| m.urn == urn.to_s }
13
+ end
14
+
15
+ # Find a meeting by StructuredIdentifier components.
16
+ # Returns the first meeting whose any identifier matches both
17
+ # prefix and number.
18
+ def find_by_identifier(prefix:, number:)
19
+ meetings&.find do |meeting|
20
+ meeting.identifier&.any? do |id|
21
+ id.prefix == prefix.to_s && id.number == number.to_s
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Top-level wrapper for many Meetings in a single YAML file. Parallel
5
+ # to ResolutionCollection. The metadata block carries display-level
6
+ # info (title, source); per-meeting identity lives on each Meeting.
7
+ class MeetingCollectionMetadata < Lutaml::Model::Serializable
8
+ attribute :title, :string
9
+ attribute :source, :string
10
+ end
11
+ end
@@ -6,10 +6,5 @@ module Edoxen
6
6
  class MeetingIdentifier < Lutaml::Model::Serializable
7
7
  attribute :venue, :string
8
8
  attribute :date, :date
9
-
10
- key_value do
11
- map "venue", to: :venue
12
- map "date", to: :date
13
- end
14
9
  end
15
10
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Per-language content for a Meeting. Mirrors the glossarist
5
+ # pattern used by `Localization` for Resolutions: language-agnostic
6
+ # admin fields live on the parent Meeting; per-language content
7
+ # lives here.
8
+ class MeetingLocalization < Lutaml::Model::Serializable
9
+ attribute :language_code, :string
10
+ attribute :script, :string
11
+ attribute :title, :string
12
+ attribute :general_area, :string
13
+ attribute :practical_info, :string
14
+ end
15
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Directed link between two meetings, identified by their
5
+ # StructuredIdentifier. Reuses the same relation-type vocabulary
6
+ # pattern as ResolutionRelation.
7
+ class MeetingRelation < Lutaml::Model::Serializable
8
+ attribute :source, StructuredIdentifier
9
+ attribute :destination, StructuredIdentifier
10
+ attribute :type, :string, values: Enums::MEETING_RELATION_TYPE
11
+ end
12
+ end