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.
- checksums.yaml +4 -4
- data/.rubocop.yml +26 -1
- data/.rubocop_todo.yml +1 -1
- data/CHANGELOG.md +133 -0
- data/CLAUDE.md +10 -6
- data/Gemfile +4 -0
- data/README.adoc +417 -181
- data/TODO.meeting-agenda/02-lutaml-canonical.md +1 -1
- data/TODO.meeting-agenda/09-verify-and-release.md +3 -3
- data/TODO.meeting-agenda/README.md +2 -2
- data/edoxen.gemspec +10 -9
- data/lib/edoxen/action.rb +5 -4
- data/lib/edoxen/agenda.rb +4 -7
- data/lib/edoxen/agenda_item.rb +12 -5
- data/lib/edoxen/approval.rb +2 -2
- data/lib/edoxen/attendance.rb +29 -0
- data/lib/edoxen/body_vocabulary_entry.rb +24 -0
- data/lib/edoxen/body_vocabulary_host.rb +40 -0
- data/lib/edoxen/cli.rb +186 -108
- data/lib/edoxen/consideration.rb +6 -4
- data/lib/edoxen/contact.rb +35 -0
- data/lib/edoxen/contact_collection.rb +26 -0
- data/lib/edoxen/contact_identifier.rb +13 -0
- data/lib/edoxen/contact_method.rb +16 -0
- data/lib/edoxen/date_time_range.rb +16 -0
- data/lib/edoxen/deadline.rb +1 -1
- data/lib/edoxen/decision.rb +57 -0
- data/lib/edoxen/decision_collection.rb +10 -0
- data/lib/edoxen/decision_date.rb +9 -0
- data/lib/edoxen/decision_metadata.rb +27 -0
- data/lib/edoxen/decision_relation.rb +11 -0
- data/lib/edoxen/declaration.rb +25 -0
- data/lib/edoxen/entity_ref.rb +74 -0
- data/lib/edoxen/enums.rb +129 -8
- data/lib/edoxen/error.rb +1 -1
- data/lib/edoxen/extension_attribute.rb +78 -0
- data/lib/edoxen/host_ref.rb +1 -1
- data/lib/edoxen/link_checker.rb +194 -0
- data/lib/edoxen/localized_name.rb +16 -0
- data/lib/edoxen/localized_string.rb +22 -0
- data/lib/edoxen/meeting.rb +42 -36
- data/lib/edoxen/meeting_collection.rb +1 -2
- data/lib/edoxen/meeting_collection_metadata.rb +4 -2
- data/lib/edoxen/meeting_component.rb +37 -0
- data/lib/edoxen/meeting_extension.rb +25 -0
- data/lib/edoxen/meeting_identifier.rb +1 -1
- data/lib/edoxen/meeting_series.rb +26 -0
- data/lib/edoxen/minutes.rb +30 -0
- data/lib/edoxen/minutes_section.rb +26 -0
- data/lib/edoxen/motion.rb +51 -0
- data/lib/edoxen/name.rb +29 -0
- data/lib/edoxen/officer.rb +19 -0
- data/lib/edoxen/officers_host.rb +19 -0
- data/lib/edoxen/person.rb +5 -9
- data/lib/edoxen/physical_venue.rb +15 -0
- data/lib/edoxen/recurrence.rb +27 -0
- data/lib/edoxen/recurrence_by_day.rb +10 -0
- data/lib/edoxen/reference.rb +1 -1
- data/lib/edoxen/reference_data.rb +85 -0
- data/lib/edoxen/schema_validator.rb +3 -4
- data/lib/edoxen/source_url.rb +4 -4
- data/lib/edoxen/statement.rb +21 -0
- data/lib/edoxen/structured_identifier.rb +2 -2
- data/lib/edoxen/topic.rb +39 -0
- data/lib/edoxen/topic_asset.rb +14 -0
- data/lib/edoxen/topic_document.rb +16 -0
- data/lib/edoxen/venue.rb +79 -0
- data/lib/edoxen/venue_collection.rb +18 -0
- data/lib/edoxen/venue_validator.rb +74 -0
- data/lib/edoxen/version.rb +1 -1
- data/lib/edoxen/virtual_venue.rb +15 -0
- data/lib/edoxen/vote_record.rb +29 -0
- data/lib/edoxen/voting.rb +45 -0
- data/lib/edoxen/voting_counts.rb +23 -0
- data/lib/edoxen.rb +53 -14
- data/schema/edoxen.yaml +1378 -247
- data/schema/meeting.yaml +1258 -201
- data/sig/edoxen.rbs +581 -1
- metadata +82 -21
- data/lib/edoxen/_metadata.rb.deprecated +0 -57
- data/lib/edoxen/localization.rb +0 -18
- data/lib/edoxen/location.rb +0 -15
- data/lib/edoxen/meeting_localization.rb +0 -15
- data/lib/edoxen/resolution.rb +0 -43
- data/lib/edoxen/resolution_collection.rb +0 -10
- data/lib/edoxen/resolution_date.rb +0 -11
- data/lib/edoxen/resolution_metadata.rb +0 -22
- data/lib/edoxen/resolution_relation.rb +0 -11
- data/lib/edoxen/resolution_set.rb +0 -17
- 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 `
|
|
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
|
-
| `
|
|
15
|
-
| `
|
|
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 `
|
|
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
|
-
- `
|
|
29
|
-
- `
|
|
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
|
|
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
|
|
14
|
-
users to create, manipulate, and serialize
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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 +
|
|
5
|
-
#
|
|
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,
|
|
9
|
-
attribute :message, :
|
|
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
|
-
#
|
|
6
|
-
#
|
|
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 :
|
|
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
|
data/lib/edoxen/agenda_item.rb
CHANGED
|
@@ -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; `
|
|
7
|
-
# optionally links to the URN of the
|
|
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, :
|
|
12
|
-
attribute :description, :
|
|
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 :
|
|
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
|
data/lib/edoxen/approval.rb
CHANGED
|
@@ -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,
|
|
10
|
-
attribute :message, :
|
|
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
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
-
#
|
|
78
|
+
# Discriminate the three top-level shapes the canonical
|
|
79
|
+
# `schema/edoxen.yaml` accepts via `oneOf`:
|
|
36
80
|
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
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
|
|
160
|
+
"Validate Decision YAML file(s) against schema/edoxen.yaml and the model."
|
|
89
161
|
|
|
90
162
|
def validate(pattern)
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 "
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
163
|
-
Batch::Result.ok(
|
|
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
|
-
|
|
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
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
data/lib/edoxen/consideration.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Edoxen
|
|
4
|
-
# Basis for a
|
|
5
|
-
# one effective date and the
|
|
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,
|
|
9
|
-
attribute :message, :
|
|
10
|
+
attribute :date_effective, DecisionDate
|
|
11
|
+
attribute :message, LocalizedString, collection: true
|
|
10
12
|
end
|
|
11
13
|
end
|