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
data/edoxen.gemspec CHANGED
@@ -8,25 +8,24 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["Ribose Inc."]
9
9
  spec.email = ["open.source@ribose.com"]
10
10
 
11
- spec.summary = "Edoxen is a set of information models used for representing resolution and decision information."
11
+ spec.summary = "Edoxen is a generic information model for meetings, agendas, and decisions."
12
12
  spec.description = <<~HEREDOC
13
- Edoxen provides a Ruby library for working with resolution models, allowing
14
- users to create, manipulate, and serialize resolution data in a structured
15
- format. It is built on top of the lutaml-model serialization framework,
16
- which provides a flexible and extensible way to define data models and
17
- serialize them to YAML or JSON formats.
13
+ Edoxen provides a Ruby library for working with a generic meeting/decision
14
+ model, allowing users to create, manipulate, and serialize meeting, agenda,
15
+ motion, voting, and decision data in a structured format. Built on top of
16
+ the lutaml-model serialization framework, with profile-based customization
17
+ (ISO 8601-2 §15) for domain-specific extensions.
18
18
  HEREDOC
19
19
 
20
20
  spec.homepage = "https://github.com/metanorma/edoxen"
21
21
  spec.license = "BSD-2-Clause"
22
- spec.required_ruby_version = ">= 3.0.0"
22
+ spec.required_ruby_version = ">= 3.1.0"
23
23
 
24
24
  spec.metadata["homepage_uri"] = spec.homepage
25
25
  spec.metadata["source_code_uri"] = "https://github.com/metanorma/edoxen"
26
26
  spec.metadata["changelog_uri"] = "https://github.com/metanorma/edoxen"
27
+ spec.metadata["rubygems_mfa_required"] = "true"
27
28
 
28
- # Specify which files should be added to the gem when it is released.
29
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
30
29
  spec.files = Dir.chdir(__dir__) do
31
30
  `git ls-files -z`.split("\x0").reject do |f|
32
31
  (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
@@ -39,5 +38,6 @@ Gem::Specification.new do |spec|
39
38
  spec.add_dependency "json_schemer", "~> 2.0"
40
39
  spec.add_dependency "lutaml-model", "~> 0.7"
41
40
  spec.add_dependency "thor", "~> 1.0"
42
- spec.add_dependency "unlocodes", "~> 0.1"
41
+ spec.add_dependency "unlocodes", "~> 0.3"
42
+ spec.add_dependency "iata", "~> 0.1"
43
43
  end
data/lib/edoxen/action.rb CHANGED
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Edoxen
4
- # Verb + one effective date + human-readable message. Used inside a
5
- # Localization to express the multilingual part of an action.
4
+ # Verb + one effective date + per-field Localized message. Each
5
+ # `Action` belongs to a `Decision`; the message field carries one
6
+ # `LocalizedString` per ISO 24229 spelling.
6
7
  class Action < Lutaml::Model::Serializable
7
8
  attribute :type, :string, values: Enums::ACTION_TYPE
8
- attribute :date_effective, ResolutionDate
9
- attribute :message, :string
9
+ attribute :date_effective, DecisionDate
10
+ attribute :message, LocalizedString, collection: true
10
11
  end
11
12
  end
data/lib/edoxen/agenda.rb CHANGED
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Edoxen
4
- # The business-order document of a Meeting. Distinct from the
5
- # timetable (Schedule) — the Agenda orders topics; the Schedule
6
- # orders time slots.
4
+ # The business-order document of a Meeting. Distinct from the timetable
5
+ # (MeetingComponent) — the Agenda orders topics; components order time
6
+ # slots. Opening/closing sessions are components, not agenda entries.
7
7
  #
8
8
  # An Agenda may be versioned independently of the Meeting: a draft
9
9
  # agenda circulates weeks before; a final agenda at meeting time;
@@ -14,11 +14,8 @@ module Edoxen
14
14
  attribute :status, :string, values: Enums::AGENDA_STATUS
15
15
  attribute :source_doc, :string
16
16
  attribute :items, AgendaItem, collection: true
17
- attribute :opening_session, ScheduleItem
18
- attribute :closing_session, ScheduleItem
17
+ attribute :extensions, MeetingExtension, collection: true
19
18
 
20
- # Find an agenda item by its label (e.g., "5.2"). Returns nil
21
- # when no item matches.
22
19
  def find_item(label)
23
20
  items&.find { |item| item.label == label.to_s }
24
21
  end
@@ -3,15 +3,22 @@
3
3
  module Edoxen
4
4
  # One entry on an Agenda. `label` is the visible identifier (e.g.,
5
5
  # "5.2"); `kind` discriminates numbered/unnumbered/header/opening/
6
- # closing; `outcome` records what happened; `resolution_ref`
7
- # optionally links to the URN of the resolution this item produced.
6
+ # closing/aob; `outcome` records what happened; `decision_ref`
7
+ # optionally links to the URN of the decision this item produced.
8
+ #
9
+ # Topics (0..*) are the subject(s) of discussion at this agenda item.
10
+ # AOB (Any Other Business) items have 0 topics until raised during
11
+ # the meeting.
8
12
  class AgendaItem < Lutaml::Model::Serializable
9
13
  attribute :label, :string
10
14
  attribute :kind, :string, values: Enums::AGENDA_ITEM_KIND
11
- attribute :title, :string
12
- attribute :description, :string
15
+ attribute :title, LocalizedString, collection: true
16
+ attribute :description, LocalizedString, collection: true
13
17
  attribute :references, Reference, collection: true
14
18
  attribute :outcome, :string, values: Enums::AGENDA_ITEM_OUTCOME
15
- attribute :resolution_ref, :string
19
+ attribute :decision_ref, :string
20
+ attribute :topics, Topic, collection: true
21
+ attribute :components, :string, collection: true
22
+ attribute :extensions, MeetingExtension, collection: true
16
23
  end
17
24
  end
@@ -6,7 +6,7 @@ module Edoxen
6
6
  class Approval < Lutaml::Model::Serializable
7
7
  attribute :type, :string, values: Enums::APPROVAL_TYPE
8
8
  attribute :degree, :string, values: Enums::APPROVAL_DEGREE
9
- attribute :date, ResolutionDate
10
- attribute :message, :string
9
+ attribute :date, DecisionDate
10
+ attribute :message, LocalizedString, collection: true
11
11
  end
12
12
  end
@@ -1,23 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Edoxen
4
- # One attendance record per person. Mirrors the ParticipationStatus
5
- # enum from edoxen-model/models/participation_status.lutaml.
6
- # Optional `affiliation` and `notes` fields let callers annotate
7
- # proxy representatives and any meeting-specific details.
4
+ # One attendance record per person. Combines:
5
+ # - status: ParticipationStatus (in-meeting observation: present/absent/
6
+ # apologies/observer/excused)
7
+ # - role: AttendanceRole (structural importance: chair/required/optional/
8
+ # non_participant — from iCalendar ROLE, plain English)
9
+ # - response: AttendanceResponse (RSVP state: pending/confirmed/declined/
10
+ # tentative/delegated — from iCalendar PARTSTAT, plain English)
8
11
  class Attendance < Lutaml::Model::Serializable
9
12
  attribute :person, Person
10
13
  attribute :status, :string, values: Enums::PARTICIPATION_STATUS
14
+ attribute :role, :string, values: Enums::ATTENDANCE_ROLE
15
+ attribute :response, :string, values: Enums::ATTENDANCE_RESPONSE
11
16
  attribute :affiliation, :string
12
17
  attribute :proxy_for, Person
13
18
  attribute :notes, :string
19
+ attribute :extensions, MeetingExtension, collection: true
14
20
 
15
- key_value do
16
- map "person", to: :person
17
- map "status", to: :status
18
- map "affiliation", to: :affiliation
19
- map "proxy_for", to: :proxy_for
20
- map "notes", to: :notes
21
+ def present?
22
+ status == "present"
23
+ end
24
+
25
+ def declined?
26
+ response == "declined"
21
27
  end
22
28
  end
23
29
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # BodyVocabularyEntry — one entry in a per-dataset body_vocabulary
5
+ # list (1.0, 1.0 design review).
6
+ #
7
+ # Maps a free-form `body_type` (e.g. "CIML Meeting", "Plenary") to a
8
+ # short canonical value (e.g. "plenary"). Bodies declare their
9
+ # vocabulary on the collection metadata; consumers look up the
10
+ # canonical_type via the parent collection's vocabulary.
11
+ #
12
+ # SSOT: the body_vocabulary list on collection metadata is the single
13
+ # source of truth for body_type → canonical_type resolution within
14
+ # that dataset.
15
+ #
16
+ # Permissive: when no vocabulary entry matches a body_type, the gem
17
+ # returns the body_type string itself (with a warning at debug
18
+ # level). Strict mode is a v3.x concern.
19
+ class BodyVocabularyEntry < Lutaml::Model::Serializable
20
+ attribute :body_type, :string
21
+ attribute :canonical_type, :string
22
+ attribute :definition, :string
23
+ end
24
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Mixed into metadata classes that carry a per-dataset
5
+ # `body_vocabulary[]` collection (1.0, 1.0 design review). Provides
6
+ # the attribute declaration and the `canonical_type_for` lookup in
7
+ # one place so DecisionMetadata and MeetingCollectionMetadata share
8
+ # a single implementation.
9
+ #
10
+ # Permissive by design: when no vocabulary entry matches a
11
+ # body_type, the body_type string itself is returned (with no
12
+ # warning). Strict mode is a v3.x concern.
13
+ #
14
+ # == Why the `included` hook declares the attribute
15
+ #
16
+ # The block-form `included` callback calls
17
+ # `base.attribute :body_vocabulary, ...` on the includer so both
18
+ # consumers (DecisionMetadata, MeetingCollectionMetadata) pick up
19
+ # the attribute and the lookup method from one source. This is a
20
+ # deliberate DRY trade-off: the alternative — declaring the
21
+ # attribute in each metadata class — leaves the lookup method
22
+ # without its required backing field on any class that forgets the
23
+ # declaration. The hook makes the field and the method inseparable.
24
+ module BodyVocabularyHost
25
+ def self.included(base)
26
+ base.attribute :body_vocabulary, BodyVocabularyEntry, collection: true
27
+ end
28
+
29
+ # Resolve a body_type to its canonical_type via this collection's
30
+ # vocabulary. Returns the body_type unchanged when nil/empty or
31
+ # when no matching entry exists.
32
+ def canonical_type_for(body_type)
33
+ return body_type if body_type.nil? || body_type.to_s.empty?
34
+ return body_type unless body_vocabulary
35
+
36
+ entry = body_vocabulary.find { |e| e.body_type == body_type }
37
+ entry ? entry.canonical_type : body_type
38
+ end
39
+ end
40
+ end
data/lib/edoxen/cli.rb CHANGED
@@ -5,17 +5,60 @@ require "fileutils"
5
5
 
6
6
  module Edoxen
7
7
  class Cli < Thor
8
+ # Each top-level YAML document is either a Decision collection or a
9
+ # Meeting (single or collection). The two share the same batch
10
+ # scaffold (expand → header → loop → tally → exit) but differ in
11
+ # which schema validates them and which model class loads them.
12
+ #
13
+ # A Profile bundles those two facts so the Thor command methods are
14
+ # one-line delegations. Adding a third document kind (e.g. an
15
+ # attendance-only file) is one Profile constant + one desc block —
16
+ # no new scaffolding.
17
+ Profile = Struct.new(:name, :schema_file, :loader) do
18
+ def schema_path
19
+ File.expand_path("../../schema/#{schema_file}", __dir__)
20
+ end
21
+
22
+ # Returns the loaded model object, raising StandardError on failure.
23
+ # Single-vs-collection detection is up to the loader (Meeting side
24
+ # sniffs the top-level key; Decision side always returns a
25
+ # DecisionCollection).
26
+ def load(content)
27
+ loader.call(content)
28
+ end
29
+ end
30
+
31
+ PROFILES = {
32
+ decisions: Profile.new(
33
+ "decisions", "edoxen.yaml",
34
+ lambda do |content|
35
+ data = YAML.safe_load(content, permitted_classes: [Date, Time])
36
+ case Batch.decision_kind(data)
37
+ when :contacts then ContactCollection.from_yaml(content)
38
+ when :venues then VenueCollection.from_yaml(content)
39
+ else DecisionCollection.from_yaml(content)
40
+ end
41
+ end
42
+ ),
43
+ meetings: Profile.new(
44
+ "meetings", "meeting.yaml",
45
+ lambda do |content|
46
+ data = YAML.safe_load(content, permitted_classes: [Date, Time])
47
+ case Batch.meetings_kind(data)
48
+ when :collection then MeetingCollection.from_yaml(content)
49
+ when :series then MeetingSeries.from_yaml(content)
50
+ else Meeting.from_yaml(content)
51
+ end
52
+ end
53
+ )
54
+ }.freeze
55
+
8
56
  # Deep module behind the per-command interface. Owns the
9
57
  # 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.
58
+ # `validate` and `normalize` collapse to one-line delegations.
59
+ # Also owns the per-profile shape sniffers that pick the correct
60
+ # model class for each YAML file (one of N shapes accepted by the
61
+ # canonical `oneOf` roots).
19
62
  module Batch
20
63
  # Per-file outcome. `ok` carries an optional message appended to
21
64
  # the success indicator (e.g. "NORMALIZED → /out/path"). `bad`
@@ -32,11 +75,38 @@ module Edoxen
32
75
 
33
76
  module_function
34
77
 
35
- # Run a batch over every YAML file matching `pattern`.
78
+ # Discriminate the three top-level shapes the canonical
79
+ # `schema/edoxen.yaml` accepts via `oneOf`:
36
80
  #
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.
81
+ # * :contacts — `{ contacts: [...] }` (ContactCollection registry).
82
+ # * :venues — `{ venues: [...] }` (VenueCollection registry).
83
+ # * :decisions — anything else (DecisionCollection, the default).
84
+ def decision_kind(data)
85
+ return :decisions unless data.is_a?(Hash)
86
+
87
+ return :contacts if data["contacts"].is_a?(Array)
88
+ return :venues if data["venues"].is_a?(Array)
89
+
90
+ :decisions
91
+ end
92
+
93
+ # Discriminate the three top-level shapes the canonical
94
+ # `schema/meeting.yaml` accepts via `oneOf`:
95
+ #
96
+ # * :collection — `{ meetings: [...] }`
97
+ # * :series — `{ identifier: [...], meeting_refs: [...] }`
98
+ # (or any hash with `identifier` + `recurrence`)
99
+ # * :meeting — anything else (single Meeting).
100
+ def meetings_kind(data)
101
+ return :meeting unless data.is_a?(Hash)
102
+
103
+ return :collection if data["meetings"].is_a?(Array)
104
+ return :series if data["identifier"].is_a?(Array) &&
105
+ (data.key?("meeting_refs") || data.key?("recurrence"))
106
+
107
+ :meeting
108
+ end
109
+
40
110
  def run(cli, pattern, header:, summary_extra: [])
41
111
  files = expand(pattern)
42
112
  if files.empty?
@@ -84,62 +154,32 @@ module Edoxen
84
154
 
85
155
  package_name "edoxen"
86
156
 
157
+ # --- Decision-side commands -----------------------------------------
158
+
87
159
  desc "validate YAML_FILE_PATTERN",
88
- "Validate one or more Edoxen YAML files against the schema and the model."
160
+ "Validate Decision YAML file(s) against schema/edoxen.yaml and the model."
89
161
 
90
162
  def validate(pattern)
91
- validator = SchemaValidator.new
92
- Batch.run(self, pattern, header: "🔍 Validating") do |file|
93
- schema_errors = validator.validate_file(file)
94
- model_errors = collect_model_errors(file)
95
- if schema_errors.empty? && model_errors.empty?
96
- Batch::Result.ok("VALID")
97
- else
98
- errors = (schema_errors + model_errors).map(&:to_clickable_format)
99
- Batch::Result.bad(errors)
100
- end
101
- end
163
+ run_validate(PROFILES.fetch(:decisions), pattern)
102
164
  end
103
165
 
104
166
  desc "normalize YAML_FILE_PATTERN",
105
- "Round-trip YAML file(s) through the Edoxen model (--output DIR or --inplace)."
167
+ "Round-trip Decision YAML file(s) through the model (--output DIR or --inplace)."
106
168
 
107
169
  option :output, type: :string, desc: "Output directory for normalized files"
108
170
  option :inplace, type: :boolean, desc: "Modify files in place (no backup)"
109
171
 
110
172
  def normalize(pattern)
111
- unless valid_normalize_options?
112
- say normalize_options_error, :red
113
- exit 1
114
- end
115
-
116
- summary_extra = [
117
- [" Output directory", options[:output]],
118
- [" Mode", options[:inplace] ? "in place" : "--output"]
119
- ].compact
120
-
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}"])
125
- end
173
+ run_normalize(PROFILES.fetch(:decisions), pattern)
126
174
  end
127
175
 
176
+ # --- Meeting-side commands ------------------------------------------
177
+
128
178
  desc "validate-meetings YAML_FILE_PATTERN",
129
179
  "Validate Meeting/Agenda YAML file(s) against schema/meeting.yaml."
130
180
 
131
181
  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
182
+ run_validate(PROFILES.fetch(:meetings), pattern)
143
183
  end
144
184
 
145
185
  desc "normalize-meetings YAML_FILE_PATTERN",
@@ -149,24 +189,34 @@ module Edoxen
149
189
  option :inplace, type: :boolean, desc: "Modify files in place (no backup)"
150
190
 
151
191
  def normalize_meetings(pattern)
152
- unless valid_normalize_options?
153
- say normalize_options_error, :red
192
+ run_normalize(PROFILES.fetch(:meetings), pattern)
193
+ end
194
+
195
+ # --- Cross-document link checking ----------------------------------
196
+
197
+ desc "check-links DIRECTORY",
198
+ "Scan a directory of Meeting/DecisionCollection YAML and report " \
199
+ "any cross-document URN that does not resolve."
200
+
201
+ def check_links(directory)
202
+ unless File.directory?(directory)
203
+ say "Error: #{directory} is not a directory", :red
154
204
  exit 1
155
205
  end
156
206
 
157
- summary_extra = [
158
- [" Output directory", options[:output]],
159
- [" Mode", options[:inplace] ? "in place" : "--output"]
160
- ].compact
207
+ say "🔗 Checking cross-document URN links in #{directory}...", :blue
208
+ errors = Edoxen::LinkChecker.check(directory)
209
+ return say(" No broken or duplicate links found", :green) if errors.empty?
161
210
 
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
211
+ say "❌ Found #{errors.size} link error(s):", :red
212
+ errors.each { |e| say " #{e.message}", :red }
213
+ exit 1
167
214
  end
168
215
 
216
+ # --- Reference-data lookups -----------------------------------------
217
+
169
218
  desc "unlocode CODE", "Resolve a UN/LOCODE via the canonical registry."
219
+
170
220
  def unlocode(code)
171
221
  entry = Edoxen::ReferenceData.find_unlocode(code)
172
222
  if entry.nil?
@@ -180,18 +230,59 @@ module Edoxen
180
230
  say " Subdiv: #{entry.subdivision}" if entry.subdivision
181
231
  coords = entry.coordinates
182
232
  say " Coords: #{coords}" if coords
183
- funcs = entry.functions.compact.map(&:to_s)
233
+ funcs = entry.function_codes.compact
184
234
  say " Functions: #{funcs.join(", ")}" unless funcs.empty?
185
235
  end
186
236
 
237
+ desc "iata CODE", "Resolve an IATA airport/city code via the canonical registry."
238
+
239
+ def iata(code)
240
+ entry = Edoxen::ReferenceData.find_iata(code)
241
+ if entry.nil?
242
+ say "No entry for #{code.upcase} in the IATA registry.", :red
243
+ exit 1
244
+ end
245
+
246
+ say "IATA: #{entry.code}", :blue
247
+ say " Name: #{entry.name}"
248
+ say " Country: #{entry.country_iso2}"
249
+ end
250
+
187
251
  private
188
252
 
189
- def meeting_schema_path
190
- File.expand_path("../../schema/meeting.yaml", __dir__)
253
+ def run_validate(profile, pattern)
254
+ validator = SchemaValidator.new(profile.schema_path)
255
+ Batch.run(self, pattern, header: "🔍 Validating #{profile.name}") do |file|
256
+ schema_errors = validator.validate_file(file)
257
+ model_errors = collect_model_errors(profile, file)
258
+ if schema_errors.empty? && model_errors.empty?
259
+ Batch::Result.ok("VALID")
260
+ else
261
+ Batch::Result.bad((schema_errors + model_errors).map(&:to_clickable_format))
262
+ end
263
+ end
191
264
  end
192
265
 
193
- def collect_model_errors(file)
194
- ResolutionCollection.from_yaml(File.read(file))
266
+ def run_normalize(profile, pattern)
267
+ unless valid_normalize_options?
268
+ say normalize_options_error, :red
269
+ exit 1
270
+ end
271
+
272
+ summary_extra = [
273
+ [" Output directory", options[:output]],
274
+ [" Mode", options[:inplace] ? "in place" : "--output"]
275
+ ].compact
276
+
277
+ Batch.run(self, pattern, header: "🔄 Normalizing #{profile.name}", summary_extra: summary_extra) do |file|
278
+ Batch::Result.ok(normalize_file(profile, file))
279
+ rescue StandardError => e
280
+ Batch::Result.bad(["#{file}:1:1: #{e.message}"])
281
+ end
282
+ end
283
+
284
+ def collect_model_errors(profile, file)
285
+ profile.load(File.read(file))
195
286
  []
196
287
  rescue StandardError => e
197
288
  [Edoxen::ValidationError.new(
@@ -201,27 +292,26 @@ module Edoxen
201
292
  )]
202
293
  end
203
294
 
204
- def collect_meeting_model_errors(file)
205
- load_meeting_document(file)
206
- []
295
+ def normalize_file(profile, file)
296
+ original = File.read(file)
297
+ schema_comment = extract_yaml_language_server_comment(original)
298
+ normalized = profile.load(original).to_yaml
299
+ write_normalized(file, normalized, schema_comment)
207
300
  rescue StandardError => e
208
- [Edoxen::ValidationError.new(
209
- file: file, line: 1, column: 1,
210
- message_text: "Meeting model parsing failed: #{e.message}",
211
- source: Edoxen::ValidationError::SOURCE_MODEL
212
- )]
301
+ raise "#{file}: #{e.message}"
213
302
  end
214
303
 
215
- # A meeting YAML may be either a single Meeting or a
216
- # MeetingCollection. Detect by the presence of a top-level
217
- # `meetings:` key and parse accordingly. Returns whichever
218
- # model object was successfully constructed.
219
- def load_meeting_document(file)
220
- data = YAML.safe_load(File.read(file), permitted_classes: [Date, Time])
221
- if data.is_a?(Hash) && data.key?("meetings")
222
- MeetingCollection.from_yaml(File.read(file))
304
+ def write_normalized(file, normalized, schema_comment)
305
+ normalized = "#{schema_comment}\n#{normalized}" if schema_comment
306
+
307
+ if options[:inplace]
308
+ File.write(file, normalized)
309
+ "NORMALIZED"
223
310
  else
224
- Meeting.from_yaml(File.read(file))
311
+ out = File.join(options[:output], File.basename(file))
312
+ FileUtils.mkdir_p(File.dirname(out))
313
+ File.write(out, normalized)
314
+ "NORMALIZED → #{out}"
225
315
  end
226
316
  end
227
317
 
@@ -244,35 +334,5 @@ module Edoxen
244
334
  "Error: Must specify either --output or --inplace option"
245
335
  end
246
336
  end
247
-
248
- # Writes the normalized YAML either to the original file (--inplace)
249
- # or under the --output directory. Returns a one-line status message
250
- # for the batch runner to print after the ✅.
251
- def normalize_file(file)
252
- original = File.read(file)
253
- yaml_language_server_comment = extract_yaml_language_server_comment(original)
254
- normalized = ResolutionCollection.from_yaml(original).to_yaml
255
- write_normalized(file, normalized, yaml_language_server_comment)
256
- end
257
-
258
- def normalize_meeting_file(file)
259
- yaml_language_server_comment = extract_yaml_language_server_comment(File.read(file))
260
- normalized = load_meeting_document(file).to_yaml
261
- write_normalized(file, normalized, yaml_language_server_comment)
262
- end
263
-
264
- def write_normalized(file, normalized, yaml_language_server_comment)
265
- normalized = "#{yaml_language_server_comment}\n#{normalized}" if yaml_language_server_comment
266
-
267
- if options[:inplace]
268
- File.write(file, normalized)
269
- "NORMALIZED"
270
- else
271
- out = File.join(options[:output], File.basename(file))
272
- FileUtils.mkdir_p(File.dirname(out))
273
- File.write(out, normalized)
274
- "NORMALIZED → #{out}"
275
- end
276
- end
277
337
  end
278
338
  end
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Edoxen
4
- # Basis for a resolution: a verb (having, noting, considering, ...) plus
5
- # one effective date and the elaborated reasoning.
4
+ # Basis for a Decision: a verb (having, noting, considering, ...) plus
5
+ # one effective date and the per-field Localized reasoning. Each
6
+ # `Consideration` belongs to a `Decision`; the message field carries
7
+ # one `LocalizedString` per ISO 24229 spelling.
6
8
  class Consideration < Lutaml::Model::Serializable
7
9
  attribute :type, :string, values: Enums::CONSIDERATION_TYPE
8
- attribute :date_effective, ResolutionDate
9
- attribute :message, :string
10
+ attribute :date_effective, DecisionDate
11
+ attribute :message, LocalizedString, collection: true
10
12
  end
11
13
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # VCARD-like abstract contact. Generalises Person for cases where the
5
+ # contact may be a person, an organisation, a department, a role
6
+ # ("Secretariat"), or any other entity with a name and one or more
7
+ # communication channels.
8
+ #
9
+ # 1.0 (per-field localization, ISO 24229):
10
+ # - All translatable fields are Localized<String/Name>[0..*]
11
+ # (one entry per spelling/conversion system code).
12
+ # - Added: `urn` for registry storage; `ref` for reference-by-URN.
13
+ #
14
+ # Reference vs inline: when `ref` is set, treat this Contact as a
15
+ # reference (other fields ignored). When `ref` is unset, the Contact
16
+ # is inline (full data). Both patterns are valid on every field that
17
+ # accepts a Contact.
18
+ class Contact < Lutaml::Model::Serializable
19
+ attribute :ref, :string
20
+ attribute :urn, :string
21
+ attribute :name, LocalizedName, collection: true
22
+ attribute :kind, :string
23
+ attribute :role, :string
24
+ attribute :title, LocalizedString, collection: true
25
+ attribute :affiliation, LocalizedString, collection: true
26
+ attribute :contact_methods, ContactMethod, collection: true
27
+ attribute :identifiers, ContactIdentifier, collection: true
28
+ attribute :address, LocalizedString, collection: true
29
+ attribute :extensions, MeetingExtension, collection: true
30
+
31
+ def reference?
32
+ !ref.nil? && !ref.to_s.empty?
33
+ end
34
+ end
35
+ end