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
data/lib/edoxen/person.rb CHANGED
@@ -1,14 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Edoxen
4
- # Identity + role + affiliation + contact. Used for meeting officers
5
- # (chair, secretary, local contact) and host-ref contacts.
6
- class Person < Lutaml::Model::Serializable
7
- attribute :name, :string
8
- attribute :role, :string
9
- attribute :affiliation, :string
10
- attribute :email, :string
11
- attribute :phone, :string
12
- attribute :orcid, :string
4
+ # A Contact that is specifically an individual human. Inherits all
5
+ # Contact fields. The old `email`, `phone`, and `orcid` fields are
6
+ # replaced by entries in `contact_methods` (kind=email / kind=phone)
7
+ # and `identifiers` (kind=orcid).
8
+ class Person < Contact
13
9
  end
14
10
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # PhysicalVenue — type marker for a Venue with kind == "physical".
5
+ #
6
+ # The wire format is flat on Venue (all fields live there). This
7
+ # subclass exists for type-checking and to host physical-only helpers
8
+ # so callers can ask `venue.is_a?(PhysicalVenue)` and validators
9
+ # can dispatch by type.
10
+ class PhysicalVenue < Venue
11
+ def initialize(attributes = {})
12
+ super(attributes.merge(kind: "physical"))
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Recurrence — structured representation of ISO 8601-2 §13 recurring
5
+ # time intervals. QUERYABLE (each BYxxx part is its own attribute).
6
+ #
7
+ # Round-trip to/from ISO 8601-2 wire string is handled by adapters
8
+ # in ReferenceData (NOT hand-rolled to_h — declared via lutaml-model
9
+ # mapping).
10
+ class Recurrence < Lutaml::Model::Serializable
11
+ attribute :freq, :string, values: Enums::RECURRENCE_FREQ
12
+ attribute :interval, :integer, default: 1
13
+ attribute :count, :integer
14
+ attribute :until, :date_time
15
+ attribute :by_day, RecurrenceByDay, collection: true
16
+ attribute :by_month_day, :integer, collection: true
17
+ attribute :by_month, :integer, collection: true
18
+ attribute :by_week_no, :integer, collection: true
19
+ attribute :by_year_day, :integer, collection: true
20
+ attribute :by_hour, :integer, collection: true
21
+ attribute :by_minute, :integer, collection: true
22
+ attribute :by_second, :integer, collection: true
23
+ attribute :by_set_pos, :integer, collection: true
24
+ attribute :week_start, :string, default: "MO"
25
+ attribute :extensions, MeetingExtension, collection: true
26
+ end
27
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # RecurrenceByDay — a BYDAY part of an ISO 8601-2 §13 recurrence.
5
+ # `ordinal` is null for "every", +1 for "first", -1 for "last", etc.
6
+ class RecurrenceByDay < Lutaml::Model::Serializable
7
+ attribute :ordinal, :integer
8
+ attribute :weekday, :string
9
+ end
10
+ end
@@ -8,6 +8,6 @@ module Edoxen
8
8
  class Reference < Lutaml::Model::Serializable
9
9
  attribute :ref, :string
10
10
  attribute :kind, :string
11
- attribute :title, :string
11
+ attribute :title, LocalizedString, collection: true
12
12
  end
13
13
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Built-in reference data for ISO codes used across the Edoxen model:
5
+ # ISO 3166-1 alpha-2 country codes, ISO 639-3 language codes, ISO
6
+ # 15924 script codes, UN/LOCODEs, and IATA airport codes.
7
+ #
8
+ # UN/LOCODE lookups delegate to the `unlocodes` gem (the canonical
9
+ # Ruby registry of the UNECE UN/LOCODE dataset).
10
+ # IATA lookups delegate to the `iata` gem (the canonical Ruby registry
11
+ # of the IATA airport code list, sourced from Wikidata).
12
+ #
13
+ # Reference data sources:
14
+ # * https://www.iso.org/iso-3166-country-codes.html
15
+ # * https://iso639-3.sil.org/
16
+ # * https://unicode.org/iso15924/
17
+ # * https://unece.org/trade/cefact/unlocode-code-list-country-and-territory
18
+ # * https://www.wikidata.org/wiki/Property:P238 (IATA airport code)
19
+ module ReferenceData
20
+ require "unlocodes"
21
+ require "iata"
22
+
23
+ COUNTRY_CODES = %w[
24
+ AE AF AR AT AU BE BG BR BY CA CH CL CN CO CY CZ DE DK EE ES FI FR
25
+ GB GR HK HR HU ID IE IL IN IS IT JP KE KR LK LT LU LV MT MX MY NL NO PH
26
+ NZ PL PT RO RS RU SA SE SG SI SK TH TN TR TW UA US VN ZA
27
+ ].freeze
28
+
29
+ LANGUAGE_CODES = %w[
30
+ ara chi deu eng fra jpn rus spa zho
31
+ ].freeze
32
+
33
+ SCRIPT_CODES = %w[
34
+ Arab Cyrl Hans Hant Hang Hebr Jpan Kore Latn
35
+ ].freeze
36
+
37
+ UNLOCODES = %w[
38
+ AUMEL ATVIE BEBRU BGSOF BRBSB CHGVA CNCAN CNHKG CNSHA CNXSZ COCTG
39
+ CYLCA CZPRG DEBER DEHAM DEFRA DKCPH ESMAD FIHEL FRARC FRLYS FRMRS
40
+ FRPAR GBLON HKHKG HUBUD IDJKT IEDUB IEORK ILTLV INDEL ISREY ITROM
41
+ JPTYO KEMBA KRSEL LKCMB LVRIX LULUX MYKUL NLRTM NOOSL NZAKL PHMNL
42
+ PLWAW PTLIS ROBUH RSBEG SESTO SGSIN SKBTS THBKK THCNM TRIST TWTPE
43
+ UAIEV USMIA USNYC USORL VNSGN ZACPT
44
+ ].freeze
45
+
46
+ # Memoise the registries so we don't re-parse the bundled datasets on
47
+ # every lookup. Each gem's Registry is immutable after construction.
48
+ @unlocodes_registry = nil
49
+ @iata_registry = nil
50
+
51
+ class << self
52
+ # @return [Unlocodes::Registry]
53
+ def unlocodes_registry
54
+ @unlocodes_registry ||= Unlocodes::Registry.load_default
55
+ end
56
+
57
+ # @return [Iata::Registry]
58
+ def iata_registry
59
+ @iata_registry ||= Iata::Registry.load_default
60
+ end
61
+
62
+ # Look up a UN/LOCODE entry via the canonical `unlocodes` gem.
63
+ # @param code [String, #to_s] 5-character UN/LOCODE
64
+ # @return [Unlocodes::Entry, nil]
65
+ def find_unlocode(code)
66
+ unlocodes_registry.find(code.to_s.upcase)
67
+ end
68
+
69
+ def unlocode_exists?(code)
70
+ !find_unlocode(code).nil?
71
+ end
72
+
73
+ # Look up an IATA airport/city code via the canonical `iata` gem.
74
+ # @param code [String, #to_s] 3-character IATA code
75
+ # @return [Iata::Entry, nil]
76
+ def find_iata(code)
77
+ iata_registry.find(code.to_s.upcase)
78
+ end
79
+
80
+ def iata_exists?(code)
81
+ !find_iata(code).nil?
82
+ end
83
+ end
84
+ end
85
+ end
@@ -9,7 +9,7 @@ module Edoxen
9
9
  # The SchemaValidator is intentionally small and only owns one concern:
10
10
  # JSON-Schema validation with line-accurate error reporting. It does not
11
11
  # touch the Ruby model — CLI layers SchemaValidator and
12
- # `ResolutionCollection.from_yaml` to catch both schema-level violations
12
+ # `DecisionCollection.from_yaml` to catch both schema-level violations
13
13
  # (additionalProperties, required, enum) and structural issues
14
14
  # (numeric/date coercion, missing nested classes) the schema can't express.
15
15
  #
@@ -72,7 +72,7 @@ module Edoxen
72
72
  end
73
73
 
74
74
  def load_schemer(path)
75
- JSONSchemer.schema(YAML.safe_load(File.read(path), permitted_classes: [Date, Time]))
75
+ JSONSchemer.schema(YAML.safe_load_file(path, permitted_classes: [Date, Time]))
76
76
  end
77
77
 
78
78
  # Coerce Date/Time instances back to ISO strings before handing the
@@ -81,8 +81,7 @@ module Edoxen
81
81
  # here keeps the gem OCP-compliant (no json_schemer plugin/tweak).
82
82
  def normalize_dates(value)
83
83
  case value
84
- when Date then value.iso8601
85
- when Time then value.iso8601
84
+ when Date, Time then value.iso8601
86
85
  when Hash then value.transform_values { |v| normalize_dates(v) }
87
86
  when Array then value.map { |v| normalize_dates(v) }
88
87
  else value
@@ -4,13 +4,13 @@ module Edoxen
4
4
  # Per-language canonical source URL (e.g. one PDF per language).
5
5
  # Carries the URL ref, its format, the ISO 639-3 language_code the URL
6
6
  # is the canonical source for, and an optional `kind` discriminator
7
- # (agenda_pdf, minutes_pdf, resolutions_pdf, ...) used by the Meeting
8
- # model. Existing Resolution fixtures without `kind` still parse —
9
- # the field is optional.
7
+ # (agenda_pdf, minutes_pdf, decisions_pdf, ...) used by the Meeting
8
+ # model. Existing fixtures without `kind` still parse — the field is
9
+ # optional.
10
10
  class SourceUrl < Lutaml::Model::Serializable
11
11
  attribute :ref, :string
12
12
  attribute :format, :string
13
- attribute :language_code, :string
13
+ attribute :spelling, :string
14
14
  attribute :kind, :string, values: Enums::SOURCE_URL_KIND
15
15
  end
16
16
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # One remark made by one or more meeting members on a topic or
5
+ # a minutes section. The `kind` discriminator separates the
6
+ # three BS 0:2006 statement types (statement, comment,
7
+ # standpoint); adding a new kind is a one-line StatementKind
8
+ # enum extension (OCP).
9
+ #
10
+ # Attachment points: `MinutesSection#statements[]` (per-meeting)
11
+ # and `Topic#statements[]` (standing position that travels with
12
+ # the topic across meetings). Same class, two attachment points
13
+ # — the semantic distinction comes from where the statement is
14
+ # attached, not from a subclass.
15
+ class Statement < Lutaml::Model::Serializable
16
+ attribute :kind, :string, values: Enums::STATEMENT_KIND
17
+ attribute :description, LocalizedString, collection: true
18
+ attribute :party, Person, collection: true
19
+ attribute :extensions, MeetingExtension, collection: true
20
+ end
21
+ end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Edoxen
4
- # A structured resolution identifier, e.g. prefix "ISO" + number "2019-01".
5
- # A Resolution carries 1..* StructuredIdentifier so a single resolution can
4
+ # A structured decision identifier, e.g. prefix "ISO" + number "2019-01".
5
+ # A Decision carries 1..* StructuredIdentifier so a single decision can
6
6
  # hold its TC number, its SC number, and any cross-cutting reference number
7
7
  # without forcing callers to flatten them into one opaque string.
8
8
  class StructuredIdentifier < Lutaml::Model::Serializable
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Topic — the subject of discussion at a Meeting.
5
+ # A Topic can have documents (text), assets (non-text), references
6
+ # (external pointers), and is the anchor for Motions and Decisions.
7
+ #
8
+ # Cross-meeting threading via `resumption_of` (URN to a prior Topic
9
+ # in a prior Meeting) — pattern from HK LegCo OData schema.
10
+ class Topic < Lutaml::Model::Serializable
11
+ attribute :identifier, :string
12
+ attribute :urn, :string
13
+ attribute :title, LocalizedString, collection: true
14
+ attribute :description, LocalizedString, collection: true
15
+ attribute :status, :string, values: Enums::TOPIC_STATUS
16
+ attribute :resumption_of, :string
17
+ attribute :documents, TopicDocument, collection: true
18
+ attribute :assets, TopicAsset, collection: true
19
+ attribute :references, Reference, collection: true
20
+ attribute :motions, :string, collection: true
21
+ attribute :decisions, :string, collection: true
22
+ attribute :statements, Statement, collection: true
23
+ attribute :declarations, Declaration, collection: true
24
+ attribute :extensions, MeetingExtension, collection: true
25
+
26
+ # --- 1.0 derivation accessor (1.0 design review) --------------------
27
+ # Returns the Decisions in `collection` whose `about_topics` includes
28
+ # this Topic's URN.
29
+ #
30
+ # Storage side: Decision.aboutTopics (SSOT for the relationship).
31
+ # The stored `decisions[]` field remains on the wire for back-compat
32
+ # through v2.x; 1.0 removes it and this becomes the only path.
33
+ def decisions_in(collection:)
34
+ return [] unless collection && urn
35
+
36
+ collection.decisions.select { |decision| decision.about_topics&.include?(urn) }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # TopicAsset — non-text resource about a Topic.
5
+ # Examples: image, dataset, model, video.
6
+ class TopicAsset < Lutaml::Model::Serializable
7
+ attribute :identifier, :string
8
+ attribute :title, LocalizedString, collection: true
9
+ attribute :kind, :string
10
+ attribute :url, :string
11
+ attribute :format, :string
12
+ attribute :extensions, MeetingExtension, collection: true
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # TopicDocument — text-bearing document about a Topic.
5
+ # Examples: bill text, ISO draft, report, agenda attachment.
6
+ class TopicDocument < Lutaml::Model::Serializable
7
+ attribute :identifier, :string
8
+ attribute :title, LocalizedString, collection: true
9
+ attribute :version, :string
10
+ attribute :status, :string
11
+ attribute :url, :string
12
+ attribute :format, :string
13
+ attribute :spelling, :string
14
+ attribute :extensions, MeetingExtension, collection: true
15
+ end
16
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Venue — polymorphic by discriminator, flat on the wire.
5
+ #
6
+ # The `kind` field discriminates between physical and virtual venues.
7
+ # All possible fields (physical-specific and virtual-specific) live on
8
+ # this one class as optional attributes — only the ones matching the
9
+ # `kind` are populated.
10
+ #
11
+ # 1.0 (per-field localization, ISO 24229):
12
+ # - All translatable fields are Localized<String>[0..*].
13
+ # - Added: `urn` for registry storage; `ref` for reference-by-URN.
14
+ class Venue < Lutaml::Model::Serializable
15
+ attribute :ref, :string
16
+ attribute :urn, :string
17
+ attribute :kind, :string, values: Enums::VENUE_KIND
18
+ attribute :name, LocalizedString, collection: true
19
+ attribute :label, LocalizedString, collection: true
20
+ attribute :description, LocalizedString, collection: true
21
+ attribute :capacity, :integer
22
+ attribute :url, :string
23
+ attribute :contact_methods, ContactMethod, collection: true
24
+
25
+ # Physical-venue fields (populated when kind == "physical").
26
+ attribute :unlocode, :string
27
+ attribute :iata_code, :string
28
+ attribute :address, LocalizedString, collection: true
29
+ attribute :city, :string
30
+ attribute :country_code, :string
31
+ attribute :lat, :float
32
+ attribute :lon, :float
33
+ attribute :building, LocalizedString, collection: true
34
+ attribute :floor, LocalizedString, collection: true
35
+ attribute :room, LocalizedString, collection: true
36
+ attribute :access_notes, LocalizedString, collection: true
37
+
38
+ # Virtual-venue fields (populated when kind == "virtual").
39
+ attribute :uri, :string
40
+ attribute :features, :string, collection: true, values: Enums::VIRTUAL_FEATURE
41
+ attribute :passcode, :string
42
+ attribute :meeting_id, :string
43
+ attribute :dial_in_numbers, :string, collection: true
44
+ attribute :waiting_room, :boolean
45
+ attribute :registration_required, :boolean
46
+
47
+ attribute :extensions, MeetingExtension, collection: true
48
+
49
+ def reference?
50
+ !ref.nil? && !ref.to_s.empty?
51
+ end
52
+
53
+ def physical?
54
+ kind == "physical"
55
+ end
56
+
57
+ def virtual?
58
+ kind == "virtual"
59
+ end
60
+
61
+ def unlocode_entry
62
+ return nil if unlocode.nil? || unlocode.to_s.empty?
63
+
64
+ Edoxen::ReferenceData.find_unlocode(unlocode)
65
+ end
66
+
67
+ def iata_entry
68
+ return nil if iata_code.nil? || iata_code.to_s.empty?
69
+
70
+ Edoxen::ReferenceData.find_iata(iata_code)
71
+ end
72
+
73
+ def features_list
74
+ return "" if features.nil? || features.empty?
75
+
76
+ features.join(", ")
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Registry of Venues indexed by scoped URN. Mirrors ContactCollection:
5
+ # members carry `urn: urn:edoxen:venue:{scope}:{local-id}`; the
6
+ # collection's `scope` MUST match the scope segment in member URNs.
7
+ # Stream loading belongs to the service layer.
8
+ class VenueCollection < Lutaml::Model::Serializable
9
+ attribute :scope, :string
10
+ attribute :title, LocalizedString, collection: true
11
+ attribute :venues, Venue, collection: true
12
+ attribute :extensions, MeetingExtension, collection: true
13
+
14
+ def find_by_urn(urn)
15
+ venues&.find { |v| v.urn == urn }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # VenueValidator — validates Venue instances using the `unlocodes`
5
+ # and `iata` gems. Returns a list of errors; empty list means valid.
6
+ #
7
+ # For a Venue with `kind: "physical"`, validates that any populated
8
+ # `unlocode` and `iata_code` exist in the canonical registries.
9
+ #
10
+ # `valid?` and `validate` are pure queries — they do not mutate the
11
+ # venue. Callers who want city/country_code back-filled from the
12
+ # UN/LOCODE registry use `populate_from_registry!`, a separate,
13
+ # explicitly-mutating method.
14
+ class VenueValidator
15
+ attr_reader :venue, :errors
16
+
17
+ def initialize(venue)
18
+ @venue = venue
19
+ @errors = []
20
+ end
21
+
22
+ # Pure query. Populates `errors` and returns it; does not modify
23
+ # the venue.
24
+ def validate
25
+ errors.clear
26
+ validate_physical if venue.physical?
27
+ errors
28
+ end
29
+
30
+ # Pure boolean wrapper around `validate`.
31
+ def valid?
32
+ validate
33
+ errors.empty?
34
+ end
35
+
36
+ # Mutator: back-fill `venue.city` and `venue.country_code` from the
37
+ # UN/LOCODE entry. Raises `KeyError` when the unlocode is unknown
38
+ # or unset, so callers do not silently leave the venue untouched.
39
+ #
40
+ # Returns the venue (so callers can chain).
41
+ def populate_from_registry!
42
+ raise KeyError, "populate_from_registry! requires kind: physical" unless venue.physical?
43
+ raise KeyError, "populate_from_registry! requires unlocode" if venue.unlocode.nil? || venue.unlocode.to_s.empty?
44
+
45
+ entry = Edoxen::ReferenceData.find_unlocode(venue.unlocode)
46
+ raise KeyError, "Unknown UN/LOCODE: #{venue.unlocode}" if entry.nil?
47
+
48
+ venue.city ||= entry.name
49
+ venue.country_code ||= entry.country
50
+ venue
51
+ end
52
+
53
+ private
54
+
55
+ def validate_physical
56
+ validate_unlocode if venue.unlocode && !venue.unlocode.to_s.empty?
57
+ validate_iata_code if venue.iata_code && !venue.iata_code.to_s.empty?
58
+ end
59
+
60
+ def validate_unlocode
61
+ entry = Edoxen::ReferenceData.find_unlocode(venue.unlocode)
62
+ return if entry
63
+
64
+ errors << "Unknown UN/LOCODE: #{venue.unlocode}"
65
+ end
66
+
67
+ def validate_iata_code
68
+ entry = Edoxen::ReferenceData.find_iata(venue.iata_code)
69
+ return if entry
70
+
71
+ errors << "Unknown IATA code: #{venue.iata_code}"
72
+ end
73
+ end
74
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Edoxen
4
- VERSION = "0.5.0"
4
+ VERSION = "0.8.1"
5
5
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # VirtualVenue — type marker for a Venue with kind == "virtual".
5
+ #
6
+ # The wire format is flat on Venue (all fields live there). This
7
+ # subclass exists for type-checking and to host virtual-only helpers
8
+ # so callers can ask `venue.is_a?(VirtualVenue)` and validators
9
+ # can dispatch by type.
10
+ class VirtualVenue < Venue
11
+ def initialize(attributes = {})
12
+ super(attributes.merge(kind: "virtual"))
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # A single vote on a Decision, recorded against the Voting instance
5
+ # that captured it. `voting_ref` links to the Voting URN; `decision_ref`
6
+ # links to the Decision URN (often derivable from the Voting's motion,
7
+ # but explicit for clarity).
8
+ #
9
+ # `role` is open for adopter-defined values: teller (parliamentary
10
+ # division counter), proxy_holder, observer, etc.
11
+ class VoteRecord < Lutaml::Model::Serializable
12
+ attribute :decision_ref, :string
13
+ attribute :voting_ref, :string
14
+ attribute :person, Person
15
+ attribute :affiliation, :string
16
+ attribute :vote, :string, values: Enums::VOTE_TYPE
17
+ attribute :role, :string
18
+ attribute :notes, :string
19
+ attribute :extensions, MeetingExtension, collection: true
20
+
21
+ def affirmative?
22
+ vote == "affirmative"
23
+ end
24
+
25
+ def teller?
26
+ role == "teller"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Voting — state machine for a single vote on a Motion.
5
+ # Multiple votings can occur on the same Motion (e.g., unclear voice
6
+ # vote → chair calls a formal division → second Voting instance).
7
+ #
8
+ # State machine (VotingStatus):
9
+ # called → in_progress → decided | withdrawn | deferred
10
+ class Voting < Lutaml::Model::Serializable
11
+ attribute :identifier, :string
12
+ attribute :urn, :string
13
+ attribute :on_motion, :string
14
+ attribute :status, :string, values: Enums::VOTING_STATUS
15
+ attribute :voting_method, :string, values: Enums::VOTING_METHOD
16
+ attribute :called_by, Person
17
+ attribute :called_at, :date_time
18
+ attribute :result_declared_at, :date_time
19
+ attribute :result, :string, values: Enums::VOTING_OUTCOME
20
+ attribute :counts, VotingCounts
21
+ attribute :casting_vote, VoteRecord
22
+ attribute :vote_records, VoteRecord, collection: true
23
+ attribute :extensions, MeetingExtension, collection: true
24
+
25
+ def decided?
26
+ status == "decided"
27
+ end
28
+
29
+ def in_progress?
30
+ status == "in_progress"
31
+ end
32
+
33
+ def passed?
34
+ decided? && result == "passed"
35
+ end
36
+
37
+ def negatived?
38
+ decided? && result == "negatived"
39
+ end
40
+
41
+ def tied?
42
+ decided? && result == "tied"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # VotingCounts — tally for a Voting instance.
5
+ class VotingCounts < Lutaml::Model::Serializable
6
+ attribute :ayes, :integer
7
+ attribute :noes, :integer
8
+ attribute :abstentions, :integer
9
+ attribute :absent, :integer
10
+
11
+ def total
12
+ (ayes || 0) + (noes || 0) + (abstentions || 0) + (absent || 0)
13
+ end
14
+
15
+ def margin
16
+ (ayes || 0) - (noes || 0)
17
+ end
18
+
19
+ def tied?
20
+ ayes == noes
21
+ end
22
+ end
23
+ end