edoxen 0.1.2 → 0.3.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.
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+ require_relative "resolution_date"
5
+ require_relative "url"
6
+ require_relative "localization"
7
+
8
+ module Edoxen
9
+ # A per-language source-URL record. Carries the URL ref, its format
10
+ # (pdf, html, ...), and the language_code (ISO 639-3) for which the
11
+ # URL is the canonical source.
12
+ class SourceUrl < Lutaml::Model::Serializable
13
+ attribute :ref, :string
14
+ attribute :format, :string
15
+ attribute :language_code, :string
16
+
17
+ key_value do
18
+ map "ref", to: :ref
19
+ map "format", to: :format
20
+ map "language_code", to: :language_code
21
+ end
22
+ end
23
+
24
+ class Metadata < Lutaml::Model::Serializable
25
+ attribute :title, :string
26
+ attribute :identifier, :string
27
+ attribute :dates, ResolutionDate, collection: true
28
+ attribute :source, :string
29
+ attribute :venue, :string
30
+ attribute :chair, :string
31
+ attribute :urls, Url, collection: true
32
+
33
+ # OIML extensions — see TODO.complete/14 for the glossarist-style
34
+ # i18n model. `title_localized` carries the per-language title
35
+ # parallel to Resolution#localizations. `source_urls` carries the
36
+ # per-language PDF URLs. `city`/`country_code` carry the IATA /
37
+ # ISO 3166-1 alpha-2 codes for the host venue.
38
+ attribute :title_localized, Localization, collection: true
39
+ attribute :source_urls, SourceUrl, collection: true
40
+ attribute :city, :string
41
+ attribute :country_code, :string
42
+
43
+ key_value do
44
+ map "title", to: :title
45
+ map "identifier", to: :identifier
46
+ map "dates", to: :dates
47
+ map "source", to: :source
48
+ map "venue", to: :venue
49
+ map "chair", to: :chair
50
+ map "urls", to: :urls
51
+ map "title_localized", to: :title_localized
52
+ map "source_urls", to: :source_urls
53
+ map "city", to: :city
54
+ map "country_code", to: :country_code
55
+ end
56
+ end
57
+ end
data/lib/edoxen/action.rb CHANGED
@@ -1,32 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "lutaml/model"
4
- require_relative "resolution_date"
5
-
6
3
  module Edoxen
4
+ # Verb + one effective date + human-readable message. Used inside a
5
+ # Localization to express the multilingual part of an action.
7
6
  class Action < Lutaml::Model::Serializable
8
- ACTION_TYPE_ENUM = %w[
9
- accepts acknowledges adoption adopts agrees allocates appoints appreciates
10
- appreciation approves asks assigns chairs communicating confirms consults considers
11
- creates decides defines delegates delivering directs disbands drafting elects
12
- empowers encourages endorses estabilishes establishes gathering identifies
13
- instructs investigates notes notifies recognises nominates
14
- recognizes recommends registers regrets request requests resolves restates reminds replaces
15
- scopes secures sends supports thanks welcomes withdraws
16
- ].freeze
17
-
18
- attribute :type, :string, values: ACTION_TYPE_ENUM
19
- attribute :dates, ResolutionDate, collection: true
7
+ attribute :type, :string, values: Enums::ACTION_TYPE
8
+ attribute :date_effective, ResolutionDate
20
9
  attribute :message, :string
21
- attribute :subject, :string
22
- attribute :degree, :string
23
-
24
- key_value do
25
- map "type", to: :type
26
- map "message", to: :message
27
- map "subject", to: :subject
28
- map "degree", to: :degree
29
- map "dates", to: :dates
30
- end
31
10
  end
32
11
  end
@@ -1,23 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "lutaml/model"
4
- require_relative "resolution_date"
5
-
6
3
  module Edoxen
4
+ # Approval record: type (affirmative / negative), degree (consensus level),
5
+ # date of the approval event, and a human-readable elaboration.
7
6
  class Approval < Lutaml::Model::Serializable
8
- APPROVAL_TYPE_ENUM = %w[affirmative negative].freeze
9
- APPROVAL_DEGREE_ENUM = %w[unanimous majority minority].freeze
10
-
11
- attribute :type, :string, values: APPROVAL_TYPE_ENUM
12
- attribute :degree, :string, values: APPROVAL_DEGREE_ENUM
13
- attribute :dates, ResolutionDate, collection: true
7
+ attribute :type, :string, values: Enums::APPROVAL_TYPE
8
+ attribute :degree, :string, values: Enums::APPROVAL_DEGREE
9
+ attribute :date, ResolutionDate
14
10
  attribute :message, :string
15
-
16
- key_value do
17
- map "type", to: :type
18
- map "degree", to: :degree
19
- map "dates", to: :dates
20
- map "message", to: :message
21
- end
22
11
  end
23
12
  end
data/lib/edoxen/cli.rb CHANGED
@@ -2,172 +2,180 @@
2
2
 
3
3
  require "thor"
4
4
  require "fileutils"
5
- require_relative "schema_validator"
6
5
 
7
6
  module Edoxen
8
7
  class Cli < Thor
9
- desc "validate YAML_FILE_PATTERN", "Validate YAML files against Edoxen schema"
10
- def validate(pattern)
11
- files = expand_pattern(pattern)
8
+ # Deep module behind the per-command interface. Owns the
9
+ # expand/sort/empty/header/loop/tally/summary/exit scaffold so
10
+ # `validate` and `normalize` collapse to per-file blocks.
11
+ #
12
+ # Commands call `Batch.run(self, pattern, header:)` and yield a block
13
+ # that returns `Result.ok(message)` or `Result.bad(errors)`. The
14
+ # batch runner prints progress, tallies, prints the summary, and
15
+ # exits with the right code.
16
+ #
17
+ # In-process; no adapter. The seam is the call site in each
18
+ # command method — internal to the CLI.
19
+ module Batch
20
+ # Per-file outcome. `ok` carries an optional message appended to
21
+ # the success indicator (e.g. "NORMALIZED → /out/path"). `bad`
22
+ # carries a list of pre-formatted error strings.
23
+ Result = Struct.new(:status, :message, :errors) do
24
+ def self.ok(message = nil)
25
+ new(:ok, message, nil)
26
+ end
12
27
 
13
- if files.empty?
14
- say "No files found matching pattern: #{pattern}", :red
15
- exit 1
28
+ def self.bad(errors)
29
+ new(:bad, nil, Array(errors))
30
+ end
16
31
  end
17
32
 
18
- say "🔍 Validating #{files.size} file(s)...", :blue
19
-
20
- validator = SchemaValidator.new
21
- valid_count = 0
22
- invalid_count = 0
23
-
24
- files.each do |file|
25
- print " #{File.basename(file)}... "
26
-
27
- # Schema validation
28
- schema_errors = validator.validate_file(file)
29
-
30
- # Model parsing validation
31
- model_errors = []
32
- begin
33
- content = File.read(file)
34
- Edoxen::ResolutionSet.from_yaml(content)
35
- rescue StandardError => e
36
- model_errors << "Model parsing failed: #{e.message}"
33
+ module_function
34
+
35
+ # Run a batch over every YAML file matching `pattern`.
36
+ #
37
+ # Yields each file path to the caller's block. Block must return
38
+ # a Batch::Result. The batch runner handles progress output,
39
+ # tallies, summary, and the exit code.
40
+ def run(cli, pattern, header:, summary_extra: [])
41
+ files = expand(pattern)
42
+ if files.empty?
43
+ cli.say "No files found matching pattern: #{pattern}", :red
44
+ exit 1
37
45
  end
38
46
 
39
- if schema_errors.empty? && model_errors.empty?
40
- say "✅ VALID", :green
41
- valid_count += 1
42
- else
43
- say "❌ INVALID", :red
44
- invalid_count += 1
45
-
46
- # Show schema validation errors with clickable links
47
- unless schema_errors.empty?
48
- say " Schema Validation Errors:", :red
49
- schema_errors.each do |error|
50
- say " #{error.to_clickable_format}", :red
51
- end
52
- end
47
+ cli.say "#{header} #{files.size} file(s)...", :blue
48
+
49
+ ok_count = 0
50
+ bad_count = 0
53
51
 
54
- # Show model parsing errors
55
- unless model_errors.empty?
56
- say " Model Parsing Errors:", :red
57
- model_errors.each do |error|
58
- say " #{file}:1:1: #{error}", :red
59
- end
52
+ files.each do |file|
53
+ $stdout.print " #{File.basename(file)}... "
54
+ result = yield file
55
+ if result.status == :ok
56
+ label = result.message ? " #{result.message}" : "✅"
57
+ cli.say label, :green
58
+ ok_count += 1
59
+ else
60
+ cli.say "❌ INVALID", :red
61
+ bad_count += 1
62
+ Array(result.errors).each { |e| cli.say " #{e}", :red }
60
63
  end
61
64
  end
62
- end
63
-
64
- say "\n📊 Validation Summary:", :blue
65
- say " Valid files: #{valid_count}", :green
66
- say " Invalid files: #{invalid_count}", invalid_count.positive? ? :red : :green
67
- say " Success rate: #{((valid_count.to_f / files.size) * 100).round(1)}%", :blue
68
-
69
- exit(invalid_count.positive? ? 1 : 0)
70
- end
71
65
 
72
- desc "normalize YAML_FILE_PATTERN", "Normalize YAML files using Edoxen schema"
73
- option :output, type: :string, desc: "Output directory for normalized files"
74
- option :inplace, type: :boolean, desc: "Modify files in place (no backup)"
75
- def normalize(pattern)
76
- if options[:output] && options[:inplace]
77
- say "Error: Cannot use both --output and --inplace options", :red
78
- exit 1
66
+ print_summary(cli, files.size, ok_count, bad_count, summary_extra)
67
+ exit(bad_count.positive? ? 1 : 0)
79
68
  end
80
69
 
81
- unless options[:output] || options[:inplace]
82
- say "Error: Must specify either --output or --inplace option", :red
83
- exit 1
70
+ def expand(pattern)
71
+ Dir.glob(pattern).select { |f| File.file?(f) && f.match?(/\.ya?ml\z/i) }.sort
84
72
  end
85
73
 
86
- files = expand_pattern(pattern)
87
-
88
- if files.empty?
89
- say "No files found matching pattern: #{pattern}", :red
90
- exit 1
74
+ def print_summary(cli, total, ok_count, bad_count, summary_extra)
75
+ cli.say "\n📊 Summary:", :blue
76
+ cli.say " Total: #{total}", :blue
77
+ cli.say " Success: #{ok_count}, Failed: #{bad_count}",
78
+ bad_count.positive? ? :red : :green
79
+ rate = total.zero? ? 0 : ((ok_count.to_f / total) * 100).round(1)
80
+ cli.say " Success rate: #{rate}%", :blue
81
+ summary_extra.each { |label, value| cli.say " #{label}: #{value}", :blue }
91
82
  end
83
+ end
92
84
 
93
- say "🔄 Normalizing #{files.size} file(s)...", :blue
94
-
95
- success_count = 0
96
- error_count = 0
97
-
98
- files.each do |file|
99
- print " #{File.basename(file)}... "
100
-
101
- begin
102
- # Load and parse the file
103
- content = File.read(file)
104
-
105
- # Extract yaml-language-server comment if present
106
- yaml_language_server_comment = extract_yaml_language_server_comment(content)
85
+ package_name "edoxen"
107
86
 
108
- resolution_set = Edoxen::ResolutionSet.from_yaml(content)
87
+ desc "validate YAML_FILE_PATTERN",
88
+ "Validate one or more Edoxen YAML files against the schema and the model."
109
89
 
110
- # Normalize by serializing back to YAML
111
- normalized_yaml = resolution_set.to_yaml
90
+ 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
102
+ end
112
103
 
113
- # Prepend the yaml-language-server comment if it was present
114
- normalized_yaml = "#{yaml_language_server_comment}\n#{normalized_yaml}" if yaml_language_server_comment
104
+ desc "normalize YAML_FILE_PATTERN",
105
+ "Round-trip YAML file(s) through the Edoxen model (--output DIR or --inplace)."
115
106
 
116
- if options[:inplace]
117
- # Write directly to the original file
118
- File.write(file, normalized_yaml)
119
- say "✅ NORMALIZED", :green
120
- else
121
- # Write to output directory
122
- output_file = File.join(options[:output], File.basename(file))
123
- FileUtils.mkdir_p(File.dirname(output_file))
124
- File.write(output_file, normalized_yaml)
125
- say "✅ NORMALIZED → #{output_file}", :green
126
- end
107
+ option :output, type: :string, desc: "Output directory for normalized files"
108
+ option :inplace, type: :boolean, desc: "Modify files in place (no backup)"
127
109
 
128
- success_count += 1
129
- rescue StandardError => e
130
- say "❌ FAILED", :red
131
- say " Error: #{e.message}", :red
132
- error_count += 1
133
- end
110
+ def normalize(pattern)
111
+ unless valid_normalize_options?
112
+ say normalize_options_error, :red
113
+ exit 1
134
114
  end
135
115
 
136
- say "\n📊 Normalization Summary:", :blue
137
- say " Successful: #{success_count}", :green
138
- say " Failed: #{error_count}", error_count.positive? ? :red : :green
139
- say " Success rate: #{((success_count.to_f / files.size) * 100).round(1)}%", :blue
116
+ summary_extra = [
117
+ [" Output directory", options[:output]],
118
+ [" Mode", options[:inplace] ? "in place" : "--output"]
119
+ ].compact
140
120
 
141
- if options[:output]
142
- say " Output directory: #{options[:output]}", :blue
143
- elsif options[:inplace]
144
- say " Files modified in place", :yellow
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}"])
145
125
  end
146
-
147
- exit(error_count.positive? ? 1 : 0)
148
126
  end
149
127
 
150
128
  private
151
129
 
152
- def expand_pattern(pattern)
153
- # Handle glob patterns
154
- files = Dir.glob(pattern).select { |f| File.file?(f) }
155
-
156
- # Filter for YAML files
157
- files.select { |f| f.match?(/\.(ya?ml)$/i) }.sort
130
+ def collect_model_errors(file)
131
+ ResolutionCollection.from_yaml(File.read(file))
132
+ []
133
+ rescue StandardError => e
134
+ [Edoxen::ValidationError.new(
135
+ file: file, line: 1, column: 1,
136
+ message_text: "Model parsing failed: #{e.message}",
137
+ source: Edoxen::ValidationError::SOURCE_MODEL
138
+ )]
158
139
  end
159
140
 
160
141
  def extract_yaml_language_server_comment(content)
161
- lines = content.split("\n")
142
+ lines = content.split("\n").first(5)
143
+ lines.find { |l| l.strip.match?(/\A#\s*yaml-language-server:\s*\$schema=/) }&.rstrip
144
+ end
162
145
 
163
- # Look for yaml-language-server comment in the first few lines
164
- lines.first(5).each do |line|
165
- if line.strip.match?(/^#\s*yaml-language-server:\s*\$schema=/)
166
- return line.rstrip # Only strip trailing whitespace, keep the #
167
- end
146
+ def valid_normalize_options?
147
+ return false if options[:output] && options[:inplace]
148
+ return false unless options[:output] || options[:inplace]
149
+
150
+ true
151
+ end
152
+
153
+ def normalize_options_error
154
+ if options[:output] && options[:inplace]
155
+ "Error: Cannot use both --output and --inplace options"
156
+ else
157
+ "Error: Must specify either --output or --inplace option"
168
158
  end
159
+ end
169
160
 
170
- nil
161
+ # Writes the normalized YAML either to the original file (--inplace)
162
+ # or under the --output directory. Returns a one-line status message
163
+ # for the batch runner to print after the ✅.
164
+ def normalize_file(file)
165
+ original = File.read(file)
166
+ yaml_language_server_comment = extract_yaml_language_server_comment(original)
167
+ normalized = ResolutionCollection.from_yaml(original).to_yaml
168
+ normalized = "#{yaml_language_server_comment}\n#{normalized}" if yaml_language_server_comment
169
+
170
+ if options[:inplace]
171
+ File.write(file, normalized)
172
+ "NORMALIZED"
173
+ else
174
+ out = File.join(options[:output], File.basename(file))
175
+ FileUtils.mkdir_p(File.dirname(out))
176
+ File.write(out, normalized)
177
+ "NORMALIZED → #{out}"
178
+ end
171
179
  end
172
180
  end
173
181
  end
@@ -1,25 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "lutaml/model"
4
- require_relative "resolution_date"
5
-
6
3
  module Edoxen
4
+ # Basis for a resolution: a verb (having, noting, considering, ...) plus
5
+ # one effective date and the elaborated reasoning.
7
6
  class Consideration < Lutaml::Model::Serializable
8
- CONSIDERATION_TYPE_ENUM = %w[
9
- acknowledging basing considering identifying noting recalling recognises according following
10
- recognising recognizing
11
- ].freeze
12
-
13
- attribute :type, :string, values: CONSIDERATION_TYPE_ENUM
14
- attribute :dates, ResolutionDate, collection: true
7
+ attribute :type, :string, values: Enums::CONSIDERATION_TYPE
8
+ attribute :date_effective, ResolutionDate
15
9
  attribute :message, :string
16
- attribute :subject, :string
17
-
18
- key_value do
19
- map "type", to: :type
20
- map "dates", to: :dates
21
- map "message", to: :message
22
- map "subject", to: :subject
23
- end
24
10
  end
25
11
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Single source of truth for every enum used by the Edoxen information model.
5
+ #
6
+ # Mirrors ../edoxen-model/models/*.lutaml, deduped.
7
+ # Both:
8
+ # * Ruby model attribute declarations (`attribute :type, :string, values: Enums::ACTION_TYPE`)
9
+ # * JSON-Schema (`schema/edoxen.yaml`)
10
+ # reference these constants.
11
+ #
12
+ # The schema <-> Ruby enum-sync spec asserts the YAML schema's enum arrays
13
+ # equal these arrays. If you change a constant here, change the schema in
14
+ # the same PR.
15
+ module Enums
16
+ ACTION_TYPE = %w[
17
+ adopts thanks approves decides declares asks invites
18
+ resolves confirms welcomes recommends requests congratulates
19
+ instructs urges appoints calls-upon encourages affirms elects
20
+ authorizes charges states remarks judges sanctions abrogates empowers
21
+ ].freeze
22
+
23
+ CONSIDERATION_TYPE = %w[
24
+ having noting recognizing acknowledging recalling reaffirming
25
+ considering taking-into-account pursuant-to bearing-in-mind
26
+ emphasizing concerned accepts observing referring acting empowers
27
+ ].freeze
28
+
29
+ RESOLUTION_TYPE = %w[resolution recommendation decision declaration].freeze
30
+
31
+ RESOLUTION_RELATION_TYPE = %w[
32
+ annexOf hasAnnex updates refines replaces considers
33
+ ].freeze
34
+
35
+ RESOLUTION_DATE_TYPE = %w[adoption drafted discussed].freeze
36
+
37
+ APPROVAL_TYPE = %w[affirmative negative].freeze
38
+
39
+ APPROVAL_DEGREE = %w[unanimous majority minority].freeze
40
+
41
+ URL_KIND = %w[access report].freeze
42
+ end
43
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Base class for any Edoxen-level error. Reserved for raise-on-failure paths.
5
+ class Error < StandardError; end
6
+
7
+ # Unified validation failure shape. Produced by both:
8
+ # * SchemaValidator — JSON-Schema violations and YAML syntax errors
9
+ # carry source: :schema (or :syntax for Psych::SyntaxError).
10
+ # * ResolutionCollection.from_yaml rescues in the CLI — model parse
11
+ # failures carry source: :model.
12
+ #
13
+ # One type at the seam means callers (CLI, future renderers, tests)
14
+ # handle one shape instead of N.
15
+ class ValidationError < Error
16
+ SOURCE_SCHEMA = :schema
17
+ SOURCE_MODEL = :model
18
+ SOURCE_SYNTAX = :syntax
19
+
20
+ attr_reader :file, :line, :column, :pointer, :message_text, :source
21
+
22
+ def initialize(file:, line:, column:, message_text:, pointer: "", source: SOURCE_SCHEMA)
23
+ @file = file
24
+ @line = line
25
+ @column = column
26
+ @pointer = pointer.to_s
27
+ @message_text = message_text
28
+ @source = source
29
+ super(to_clickable_format)
30
+ end
31
+
32
+ def to_clickable_format
33
+ suffix = @pointer.empty? ? "" : " at `#{@pointer}`"
34
+ "#{@file}:#{@line}:#{@column}: #{@message_text}#{suffix}"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # A monolingual rendering of a Resolution. Mirrors the glossarist
5
+ # LocalizedConcept pattern: language-agnostic fields live on the
6
+ # parent Resolution; per-language content lives here.
7
+ class Localization < Lutaml::Model::Serializable
8
+ attribute :language_code, :string
9
+ attribute :script, :string
10
+ attribute :title, :string
11
+ attribute :subject, :string
12
+ attribute :message, :string
13
+ attribute :considering, :string
14
+ attribute :considerations, Consideration, collection: true
15
+ attribute :approvals, Approval, collection: true
16
+ attribute :actions, Action, collection: true
17
+ end
18
+ end
@@ -1,19 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # MeetingIdentifier {
4
- # venue: String
5
- # date: DateTime
6
- # }
7
- require "lutaml/model"
8
-
9
3
  module Edoxen
4
+ # Identifier of a meeting (venue + date). Singular — the meeting a
5
+ # particular Resolution belongs to.
10
6
  class MeetingIdentifier < Lutaml::Model::Serializable
11
7
  attribute :venue, :string
12
8
  attribute :date, :date
13
-
14
- key_value do
15
- map "venue", to: :venue
16
- map "date", to: :date
17
- end
18
9
  end
19
10
  end
@@ -1,47 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "lutaml/model"
4
- require_relative "resolution_date"
5
- require_relative "consideration"
6
- require_relative "approval"
7
- require_relative "action"
8
- require_relative "meeting_identifier"
9
- require_relative "resolution_relation"
10
-
11
3
  module Edoxen
4
+ # A formal Resolution. Language-agnostic admin fields live here; every
5
+ # translatable field is wrapped inside `localizations[]` (one entry per
6
+ # available language; at least one is required by the schema).
7
+ #
8
+ # Wire names follow lutaml-model's default convention: each declared
9
+ # attribute serializes to its snake_case name on the wire. Override
10
+ # with an explicit `key_value do; map "wire", to: :attr; end` block
11
+ # only when the wire name differs.
12
12
  class Resolution < Lutaml::Model::Serializable
13
- RESOLUTION_TYPE_ENUM = %w[resolution recommendation decision declaration].freeze
14
-
13
+ attribute :identifier, StructuredIdentifier, collection: true
14
+ attribute :type, :string, values: Enums::RESOLUTION_TYPE
15
+ attribute :doi, :string
16
+ attribute :urn, :string
17
+ attribute :agenda_item, :string
15
18
  attribute :dates, ResolutionDate, collection: true
16
- attribute :subject, :string
17
- attribute :title, :string
18
- attribute :type, :string, values: RESOLUTION_TYPE_ENUM
19
- attribute :identifier, :string
20
- attribute :message, :string
21
- attribute :considering, :string
22
- attribute :considerations, Consideration, collection: true
23
- attribute :approvals, Approval, collection: true
24
- attribute :actions, Action, collection: true
25
- attribute :meeting_identifier, MeetingIdentifier
26
- attribute :relations, ResolutionRelation, collection: true
27
19
  attribute :categories, :string, collection: true
20
+ attribute :meeting, MeetingIdentifier
21
+ attribute :relations, ResolutionRelation, collection: true
28
22
  attribute :urls, Url, collection: true
23
+ attribute :localizations, Localization, collection: true
24
+
25
+ # Lookup by ISO 639-3 language code. Returns nil when no exact match
26
+ # exists and `fallback:` is false (the default); returns the first
27
+ # localization otherwise. Keeps the language-preference policy in
28
+ # one place so callers stop reimplementing `find { |l| ... }`.
29
+ def in_language(code, fallback: false)
30
+ match = localizations&.find { |loc| loc.language_code == code.to_s }
31
+ return match if match
32
+
33
+ fallback ? localizations&.first : nil
34
+ end
29
35
 
30
- key_value do
31
- map "dates", to: :dates
32
- map "subject", to: :subject
33
- map "title", to: :title
34
- map "type", to: :type
35
- map "identifier", to: :identifier
36
- map "message", to: :message
37
- map "considering", to: :considering
38
- map "considerations", to: :considerations
39
- map "approvals", to: :approvals
40
- map "actions", to: :actions
41
- map "meeting_identifier", to: :meeting_identifier
42
- map "relations", to: :relations
43
- map "categories", to: :categories
44
- map "urls", to: :urls
36
+ # The canonical rendering — English when available, else the first
37
+ # declared localization. Mirrors the glossarist LocalizedConcept
38
+ # "preferred language" notion.
39
+ def primary_localization
40
+ in_language("eng", fallback: true)
45
41
  end
46
42
  end
47
43
  end