edoxen 0.5.0 → 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 (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/CLAUDE.md +10 -6
  6. data/Gemfile +4 -0
  7. data/README.adoc +417 -181
  8. data/TODO.meeting-agenda/02-lutaml-canonical.md +1 -1
  9. data/TODO.meeting-agenda/09-verify-and-release.md +3 -3
  10. data/TODO.meeting-agenda/README.md +2 -2
  11. data/edoxen.gemspec +10 -9
  12. data/lib/edoxen/action.rb +5 -4
  13. data/lib/edoxen/agenda.rb +4 -7
  14. data/lib/edoxen/agenda_item.rb +12 -5
  15. data/lib/edoxen/approval.rb +2 -2
  16. data/lib/edoxen/attendance.rb +29 -0
  17. data/lib/edoxen/body_vocabulary_entry.rb +24 -0
  18. data/lib/edoxen/body_vocabulary_host.rb +40 -0
  19. data/lib/edoxen/cli.rb +186 -108
  20. data/lib/edoxen/consideration.rb +6 -4
  21. data/lib/edoxen/contact.rb +35 -0
  22. data/lib/edoxen/contact_collection.rb +26 -0
  23. data/lib/edoxen/contact_identifier.rb +13 -0
  24. data/lib/edoxen/contact_method.rb +16 -0
  25. data/lib/edoxen/date_time_range.rb +16 -0
  26. data/lib/edoxen/deadline.rb +1 -1
  27. data/lib/edoxen/decision.rb +57 -0
  28. data/lib/edoxen/decision_collection.rb +10 -0
  29. data/lib/edoxen/decision_date.rb +9 -0
  30. data/lib/edoxen/decision_metadata.rb +27 -0
  31. data/lib/edoxen/decision_relation.rb +11 -0
  32. data/lib/edoxen/declaration.rb +25 -0
  33. data/lib/edoxen/entity_ref.rb +74 -0
  34. data/lib/edoxen/enums.rb +129 -8
  35. data/lib/edoxen/error.rb +1 -1
  36. data/lib/edoxen/extension_attribute.rb +78 -0
  37. data/lib/edoxen/host_ref.rb +1 -1
  38. data/lib/edoxen/link_checker.rb +194 -0
  39. data/lib/edoxen/localized_name.rb +16 -0
  40. data/lib/edoxen/localized_string.rb +22 -0
  41. data/lib/edoxen/meeting.rb +42 -36
  42. data/lib/edoxen/meeting_collection.rb +1 -2
  43. data/lib/edoxen/meeting_collection_metadata.rb +4 -2
  44. data/lib/edoxen/meeting_component.rb +37 -0
  45. data/lib/edoxen/meeting_extension.rb +25 -0
  46. data/lib/edoxen/meeting_identifier.rb +1 -1
  47. data/lib/edoxen/meeting_series.rb +26 -0
  48. data/lib/edoxen/minutes.rb +30 -0
  49. data/lib/edoxen/minutes_section.rb +26 -0
  50. data/lib/edoxen/motion.rb +51 -0
  51. data/lib/edoxen/name.rb +29 -0
  52. data/lib/edoxen/officer.rb +19 -0
  53. data/lib/edoxen/officers_host.rb +19 -0
  54. data/lib/edoxen/person.rb +5 -9
  55. data/lib/edoxen/physical_venue.rb +15 -0
  56. data/lib/edoxen/recurrence.rb +27 -0
  57. data/lib/edoxen/recurrence_by_day.rb +10 -0
  58. data/lib/edoxen/reference.rb +1 -1
  59. data/lib/edoxen/reference_data.rb +85 -0
  60. data/lib/edoxen/schema_validator.rb +3 -4
  61. data/lib/edoxen/source_url.rb +4 -4
  62. data/lib/edoxen/statement.rb +21 -0
  63. data/lib/edoxen/structured_identifier.rb +2 -2
  64. data/lib/edoxen/topic.rb +39 -0
  65. data/lib/edoxen/topic_asset.rb +14 -0
  66. data/lib/edoxen/topic_document.rb +16 -0
  67. data/lib/edoxen/venue.rb +79 -0
  68. data/lib/edoxen/venue_collection.rb +18 -0
  69. data/lib/edoxen/venue_validator.rb +74 -0
  70. data/lib/edoxen/version.rb +1 -1
  71. data/lib/edoxen/virtual_venue.rb +15 -0
  72. data/lib/edoxen/vote_record.rb +29 -0
  73. data/lib/edoxen/voting.rb +45 -0
  74. data/lib/edoxen/voting_counts.rb +23 -0
  75. data/lib/edoxen.rb +53 -14
  76. data/schema/edoxen.yaml +1378 -247
  77. data/schema/meeting.yaml +1258 -201
  78. data/sig/edoxen.rbs +581 -1
  79. metadata +82 -21
  80. data/lib/edoxen/_metadata.rb.deprecated +0 -57
  81. data/lib/edoxen/localization.rb +0 -18
  82. data/lib/edoxen/location.rb +0 -15
  83. data/lib/edoxen/meeting_localization.rb +0 -15
  84. data/lib/edoxen/resolution.rb +0 -43
  85. data/lib/edoxen/resolution_collection.rb +0 -10
  86. data/lib/edoxen/resolution_date.rb +0 -11
  87. data/lib/edoxen/resolution_metadata.rb +0 -22
  88. data/lib/edoxen/resolution_relation.rb +0 -11
  89. data/lib/edoxen/resolution_set.rb +0 -17
  90. data/lib/edoxen/schedule_item.rb +0 -16
@@ -58,4 +58,4 @@ LUTAML notation, wire names are snake_case per D7.)
58
58
  - [ ] `README.adoc` updated: new "Meeting model" section, file index
59
59
  extended.
60
60
  - [ ] Old `meeting-model.adoc` renamed to `.deprecated`.
61
- - [ ] Commit + PR to `metanorma/edoxen-model`.
61
+ - [ ] Commit + PR to `edoxen/edoxen-model`.
@@ -11,8 +11,8 @@ Land everything via PRs. Verify GHA passes. Merge. Bump edoxen minor
11
11
 
12
12
  | Repo | Branch | PRs against |
13
13
  |---|---|---|
14
- | `metanorma/edoxen-model` | `feat/meeting-agenda-model` | main |
15
- | `metanorma/edoxen` | `feat/meeting-agenda-model` | main |
14
+ | `edoxen/edoxen-model` | `feat/meeting-agenda-model` | main |
15
+ | `edoxen/edoxen` | `feat/meeting-agenda-model` | main |
16
16
 
17
17
  Order: edoxen-model first (canonical), then edoxen (mirror).
18
18
 
@@ -31,7 +31,7 @@ Per branch, before pushing:
31
31
  ## Post-merge
32
32
 
33
33
  - [ ] Trigger `gh workflow run release.yml --ref main -f next_version=minor`
34
- on `metanorma/edoxen`.
34
+ on `edoxen/edoxen`.
35
35
  - [ ] Confirm new version appears on rubygems.org.
36
36
  - [ ] Confirm tag `vX.Y.Z` pushed.
37
37
 
@@ -25,6 +25,6 @@ work only.
25
25
 
26
26
  ## Branches
27
27
 
28
- - `metanorma/edoxen-model` ← `feat/meeting-agenda-model` (Phase 2)
29
- - `metanorma/edoxen` ← `feat/meeting-agenda-model` (Phases 3–8)
28
+ - `edoxen/edoxen-model` ← `feat/meeting-agenda-model` (Phase 2)
29
+ - `edoxen/edoxen` ← `feat/meeting-agenda-model` (Phases 3–8)
30
30
  - Phase 9 merges both, then releases `edoxen` as a minor bump.
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,4 +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"
41
+ spec.add_dependency "unlocodes", "~> 0.3"
42
+ spec.add_dependency "iata", "~> 0.1"
42
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
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
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)
11
+ class Attendance < Lutaml::Model::Serializable
12
+ attribute :person, Person
13
+ attribute :status, :string, values: Enums::PARTICIPATION_STATUS
14
+ attribute :role, :string, values: Enums::ATTENDANCE_ROLE
15
+ attribute :response, :string, values: Enums::ATTENDANCE_RESPONSE
16
+ attribute :affiliation, :string
17
+ attribute :proxy_for, Person
18
+ attribute :notes, :string
19
+ attribute :extensions, MeetingExtension, collection: true
20
+
21
+ def present?
22
+ status == "present"
23
+ end
24
+
25
+ def declined?
26
+ response == "declined"
27
+ end
28
+ end
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,71 +154,116 @@ 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
173
+ run_normalize(PROFILES.fetch(:decisions), pattern)
174
+ end
175
+
176
+ # --- Meeting-side commands ------------------------------------------
177
+
178
+ desc "validate-meetings YAML_FILE_PATTERN",
179
+ "Validate Meeting/Agenda YAML file(s) against schema/meeting.yaml."
180
+
181
+ def validate_meetings(pattern)
182
+ run_validate(PROFILES.fetch(:meetings), pattern)
183
+ end
184
+
185
+ desc "normalize-meetings YAML_FILE_PATTERN",
186
+ "Round-trip Meeting YAML file(s) through the model (--output DIR or --inplace)."
187
+
188
+ option :output, type: :string, desc: "Output directory for normalized files"
189
+ option :inplace, type: :boolean, desc: "Modify files in place (no backup)"
190
+
191
+ def normalize_meetings(pattern)
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
113
204
  exit 1
114
205
  end
115
206
 
116
- summary_extra = [
117
- [" Output directory", options[:output]],
118
- [" Mode", options[:inplace] ? "in place" : "--output"]
119
- ].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?
120
210
 
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}"])
211
+ say "❌ Found #{errors.size} link error(s):", :red
212
+ errors.each { |e| say " #{e.message}", :red }
213
+ exit 1
214
+ end
215
+
216
+ # --- Reference-data lookups -----------------------------------------
217
+
218
+ desc "unlocode CODE", "Resolve a UN/LOCODE via the canonical registry."
219
+
220
+ def unlocode(code)
221
+ entry = Edoxen::ReferenceData.find_unlocode(code)
222
+ if entry.nil?
223
+ say "No entry for #{code.upcase} in the UN/LOCODE registry.", :red
224
+ exit 1
125
225
  end
226
+
227
+ say "UN/LOCODE: #{entry.code}", :blue
228
+ say " Name: #{entry.name}"
229
+ say " Country: #{entry.country}"
230
+ say " Subdiv: #{entry.subdivision}" if entry.subdivision
231
+ coords = entry.coordinates
232
+ say " Coords: #{coords}" if coords
233
+ funcs = entry.function_codes.compact
234
+ say " Functions: #{funcs.join(", ")}" unless funcs.empty?
126
235
  end
127
236
 
128
- desc "validate-meetings YAML_FILE_PATTERN",
129
- "Validate Meeting/Agenda YAML file(s) against schema/meeting.yaml."
237
+ desc "iata CODE", "Resolve an IATA airport/city code via the canonical registry."
130
238
 
131
- def validate_meetings(pattern)
132
- validator = SchemaValidator.new(meeting_schema_path)
133
- Batch.run(self, pattern, header: "🔍 Validating meetings") do |file|
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
+
251
+ private
252
+
253
+ def run_validate(profile, pattern)
254
+ validator = SchemaValidator.new(profile.schema_path)
255
+ Batch.run(self, pattern, header: "🔍 Validating #{profile.name}") do |file|
134
256
  schema_errors = validator.validate_file(file)
135
- model_errors = collect_meeting_model_errors(file)
257
+ model_errors = collect_model_errors(profile, file)
136
258
  if schema_errors.empty? && model_errors.empty?
137
259
  Batch::Result.ok("VALID")
138
260
  else
139
- errors = (schema_errors + model_errors).map(&:to_clickable_format)
140
- Batch::Result.bad(errors)
261
+ Batch::Result.bad((schema_errors + model_errors).map(&:to_clickable_format))
141
262
  end
142
263
  end
143
264
  end
144
265
 
145
- desc "normalize-meetings YAML_FILE_PATTERN",
146
- "Round-trip Meeting YAML file(s) through the model (--output DIR or --inplace)."
147
-
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)
266
+ def run_normalize(profile, pattern)
152
267
  unless valid_normalize_options?
153
268
  say normalize_options_error, :red
154
269
  exit 1
@@ -159,21 +274,15 @@ module Edoxen
159
274
  [" Mode", options[:inplace] ? "in place" : "--output"]
160
275
  ].compact
161
276
 
162
- Batch.run(self, pattern, header: "🔄 Normalizing meetings", summary_extra: summary_extra) do |file|
163
- Batch::Result.ok(normalize_meeting_file(file))
277
+ Batch.run(self, pattern, header: "🔄 Normalizing #{profile.name}", summary_extra: summary_extra) do |file|
278
+ Batch::Result.ok(normalize_file(profile, file))
164
279
  rescue StandardError => e
165
280
  Batch::Result.bad(["#{file}:1:1: #{e.message}"])
166
281
  end
167
282
  end
168
283
 
169
- private
170
-
171
- def meeting_schema_path
172
- File.expand_path("../../schema/meeting.yaml", __dir__)
173
- end
174
-
175
- def collect_model_errors(file)
176
- ResolutionCollection.from_yaml(File.read(file))
284
+ def collect_model_errors(profile, file)
285
+ profile.load(File.read(file))
177
286
  []
178
287
  rescue StandardError => e
179
288
  [Edoxen::ValidationError.new(
@@ -183,27 +292,26 @@ module Edoxen
183
292
  )]
184
293
  end
185
294
 
186
- def collect_meeting_model_errors(file)
187
- load_meeting_document(file)
188
- []
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)
189
300
  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
- )]
301
+ raise "#{file}: #{e.message}"
195
302
  end
196
303
 
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))
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"
205
310
  else
206
- 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}"
207
315
  end
208
316
  end
209
317
 
@@ -226,35 +334,5 @@ module Edoxen
226
334
  "Error: Must specify either --output or --inplace option"
227
335
  end
228
336
  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
258
- end
259
337
  end
260
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