locabulary 0.2.0 → 0.3.0

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,25 @@
1
+ module Locabulary
2
+ # Container for all exceptions in the Locabulary ecosystem
3
+ module Exceptions
4
+ class RuntimeError < ::RuntimeError
5
+ end
6
+
7
+ # There is a problem with the hierarchy; A child is being defined without a defined parent.
8
+ class MissingHierarchicalParentError < RuntimeError
9
+ attr_reader :predicate_name, :error
10
+ def initialize(predicate_name, error)
11
+ @predicate_name = predicate_name
12
+ @error = error
13
+ super("Expected #{predicate_name.inspect} to have a welformed tree. Error: #{error}")
14
+ end
15
+ end
16
+
17
+ # There is a problem with the hierarchy; Instead of a tree we have a multitude of trees
18
+ class TooManyHierarchicalRootsError < RuntimeError
19
+ attr_reader :predicate_name, :roots
20
+ def initialize(predicate_name, roots)
21
+ super("Expected fewer root slugs for #{predicate_name.inspect}. Roots encountered: #{roots.inspect}")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,65 @@
1
+ require 'locabulary/exceptions'
2
+ require 'locabulary/items/base'
3
+
4
+ module Locabulary
5
+ module Items
6
+ # Responsible for exposing the data structure logic of the Administrative Units
7
+ #
8
+ # @see ./data/administrative_units.json
9
+ class AdministrativeUnit < Locabulary::Items::Base
10
+ configure do |config|
11
+ config.attribute_names = [
12
+ :predicate_name, :term_label, :term_uri, :description, :grouping, :classification, :affiliation, :default_presentation_sequence,
13
+ :homepage, :activated_on, :deactivated_on
14
+ ]
15
+ end
16
+
17
+ # [String] What is the URL of the homepage. Please note the term_uri is reserved for something that is more resolvable by machines.
18
+ # And while the homepage may look resolvable, it is not as meaningful for longterm preservation.
19
+ attr_reader :homepage
20
+ attr_reader :classification
21
+ attr_reader :grouping
22
+ attr_reader :affiliation
23
+
24
+ private
25
+
26
+ attr_writer :homepage, :classification, :grouping, :affiliation
27
+
28
+ public
29
+
30
+ def initialize(*args)
31
+ super
32
+ @children = []
33
+ end
34
+
35
+ attr_reader :children
36
+
37
+ HIERARCHY_SEPARATOR = '::'.freeze
38
+ def slugs
39
+ term_label.split(HIERARCHY_SEPARATOR)
40
+ end
41
+
42
+ def parent_slugs
43
+ slugs[0..-2]
44
+ end
45
+
46
+ def parent_term_label
47
+ parent_slugs.join(HIERARCHY_SEPARATOR)
48
+ end
49
+
50
+ def root_slug
51
+ slugs[0]
52
+ end
53
+
54
+ def selectable?
55
+ children.count == 0
56
+ end
57
+
58
+ def selectable_label
59
+ slugs[1..-1].join(HIERARCHY_SEPARATOR)
60
+ end
61
+
62
+ alias selectable_id id
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,105 @@
1
+ require 'dry/configurable'
2
+ module Locabulary
3
+ module Items
4
+ # A singular item in the controlled vocubulary.
5
+ # @see https://en.wikipedia.org/wiki/Resource_Description_Framework
6
+ class Base
7
+ extend Dry::Configurable
8
+
9
+ setting :attribute_names, [
10
+ :predicate_name, :term_label, :term_uri, :deposit_label, :description, :grouping, :affiliation, :default_presentation_sequence,
11
+ :activated_on, :deactivated_on
12
+ ].freeze
13
+
14
+ def attribute_names
15
+ self.class.config.attribute_names
16
+ end
17
+
18
+ # [String] the trait for a given subject that we are describing by way of the term_label/term_uri
19
+ attr_reader :predicate_name
20
+
21
+ # [String] the human friendly version of the meaning for this given trait
22
+ # @note For the time being, please regard the term_label as immutable; If you need a modification, deactivate this one and activate a
23
+ # new one
24
+ attr_reader :term_label
25
+
26
+ # [String] the machine friendly version of the meaning for this given trait
27
+ # @note For the time being, please regard the term_uri as immutable; If you need a modification, deactivate this one and activate a
28
+ # new one
29
+ attr_reader :term_uri
30
+
31
+ # [String] a side-car of more exhaustive information related to this particular term
32
+ attr_reader :description
33
+
34
+ # [Date] When was this particular item activated
35
+ attr_reader :activated_on
36
+
37
+ # [Date] When was this particular item deactivated
38
+ attr_reader :deactivated_on
39
+
40
+ # [Integer, nil] What is the order in which
41
+ # @see Locabulary::Item#presentation_sequence for details on how this is calculated
42
+ attr_reader :default_presentation_sequence
43
+
44
+ # @deprecated
45
+ attr_reader :deposit_label
46
+
47
+ # @deprecated
48
+ attr_reader :grouping
49
+
50
+ # @deprecated
51
+ attr_reader :affiliation
52
+
53
+ def initialize(attributes = {})
54
+ attribute_names.each do |key|
55
+ value = attributes.fetch(key) { attributes.fetch(key.to_s, nil) }
56
+ send("#{key}=", value)
57
+ end
58
+ end
59
+
60
+ def to_h
61
+ attribute_names.each_with_object({}) do |key, mem|
62
+ mem[key.to_s] = send(key) unless send(key).to_s.strip == ''
63
+ mem
64
+ end
65
+ end
66
+ alias as_json to_h
67
+
68
+ def to_persistence_format_for_fedora
69
+ return term_uri unless term_uri.to_s.strip == ''
70
+ term_label
71
+ end
72
+ alias id to_persistence_format_for_fedora
73
+
74
+ private
75
+
76
+ attr_writer(*config.attribute_names)
77
+
78
+ def predicate_name=(input)
79
+ @predicate_name = input.to_s
80
+ end
81
+
82
+ def default_presentation_sequence=(input)
83
+ @default_presentation_sequence = input.to_s.strip == '' ? nil : input.to_i
84
+ end
85
+
86
+ public
87
+
88
+ include Comparable
89
+
90
+ def <=>(other)
91
+ predicate_name_sort = predicate_name <=> other.predicate_name
92
+ return predicate_name_sort unless predicate_name_sort == 0
93
+ presentation_sequence_sort = presentation_sequence <=> other.presentation_sequence
94
+ return presentation_sequence_sort unless presentation_sequence_sort == 0
95
+ term_label <=> other.term_label
96
+ end
97
+
98
+ SORT_SEQUENCE_FOR_NIL = 100_000_000
99
+ private_constant :SORT_SEQUENCE_FOR_NIL
100
+ def presentation_sequence
101
+ default_presentation_sequence || SORT_SEQUENCE_FOR_NIL
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,32 @@
1
+ require 'locabulary/items/base'
2
+ require 'hanami/utils/string'
3
+ module Locabulary
4
+ # A container for the various types of Locabulary Items
5
+ module Items
6
+ module_function
7
+
8
+ # @api public
9
+ # @since 0.2.1
10
+ def build(options = {})
11
+ predicate_name = options.fetch(:predicate_name) { options.fetch('predicate_name') }
12
+ builder_for(predicate_name: predicate_name).call(options)
13
+ end
14
+
15
+ # @api public
16
+ # @since 0.2.1
17
+ #
18
+ # @param options [Hash]
19
+ # @option predicate_name [String] Used for lookup of the correct Locabulary::Item type
20
+ def builder_for(options = {})
21
+ predicate_name = options.fetch(:predicate_name)
22
+ possible_class_name_for_predicate_name = Hanami::Utils::String.new(predicate_name).singularize.classify
23
+ klass = begin
24
+ Items.const_get(possible_class_name_for_predicate_name)
25
+ rescue NameError
26
+ Items::Base
27
+ end
28
+ klass.method(:new)
29
+ end
30
+ end
31
+ end
32
+ require 'locabulary/items/administrative_unit'
@@ -0,0 +1,105 @@
1
+ require "google/api_client"
2
+ require "google_drive"
3
+ require 'highline/import'
4
+ require 'locabulary'
5
+ require 'locabulary/items'
6
+ require 'json'
7
+
8
+ module Locabulary
9
+ # Responsible for capturing predicate_name from a given source and writing it to a file
10
+ class JsonCreator
11
+ def initialize(document_key, predicate_name, data_fetcher = default_data_fetcher)
12
+ @document_key = document_key
13
+ @predicate_name = predicate_name
14
+ @output_filepath = Locabulary.filename_for_predicate_name(predicate_name: predicate_name)
15
+ @data_fetcher = data_fetcher
16
+ end
17
+
18
+ attr_reader :document_key, :predicate_name, :data_fetcher, :spreadsheet_data, :json_data
19
+ attr_accessor :output_filepath
20
+
21
+ def create_or_update
22
+ rows = data_fetcher.call(document_key)
23
+ data = extract_data_from(rows)
24
+ convert_to_json(data)
25
+ end
26
+
27
+ # :nocov:
28
+ def write_to_file
29
+ File.open(output_filepath, "w") do |f|
30
+ f.puts json_data
31
+ end
32
+ end
33
+ # :nocov:
34
+
35
+ private
36
+
37
+ def extract_data_from(rows)
38
+ spreadsheet_data = []
39
+ header = rows[0]
40
+ rows[1..-1].each do |row|
41
+ # The activated_on is a present hack reflecting a previous value
42
+ row_data = { "predicate_name" => predicate_name, "activated_on" => "2015-07-22", "default_presentation_sequence" => nil }
43
+ row.each_with_index do |cell, index|
44
+ row_data[header[index]] = cell unless cell.to_s.strip == ''
45
+ end
46
+ spreadsheet_data << row_data
47
+ end
48
+ spreadsheet_data
49
+ end
50
+
51
+ def default_data_fetcher
52
+ ->(document_key) { GoogleSpreadsheet.new(document_key).all_rows }
53
+ end
54
+
55
+ def convert_to_json(data)
56
+ json_array = data.map do |row|
57
+ Locabulary::Items.build(row).to_h
58
+ end
59
+ @json_data = JSON.pretty_generate("predicate_name" => predicate_name, "values" => json_array)
60
+ end
61
+
62
+ # :nocov:
63
+ # Responsible for interacting with Google Sheets and retrieiving relevant information
64
+ class GoogleSpreadsheet
65
+ attr_reader :access_token, :document_key, :session
66
+
67
+ private :session
68
+
69
+ def initialize(document_key)
70
+ @document_key = document_key
71
+ configure_oauth!
72
+ @session = GoogleDrive.login_with_oauth(access_token)
73
+ end
74
+
75
+ def configure_oauth!
76
+ client = Google::APIClient.new
77
+ auth = client.authorization
78
+ auth.client_id = client_secrets.fetch('client_id')
79
+ auth.client_secret = client_secrets.fetch('client_secret')
80
+ auth.scope = ["https://www.googleapis.com/auth/drive.readonly"]
81
+ auth.redirect_uri = "urn:ietf:wg:oauth:2.0:oob"
82
+ puts "\n Open the following URL, login with your credentials and get the authorization code \n\n #{auth.authorization_uri}\n\n"
83
+ auth.code = ask('Authorization Code: ')
84
+ auth.fetch_access_token!
85
+ @access_token = auth.access_token
86
+ end
87
+
88
+ def all_rows
89
+ session.spreadsheet_by_key(document_key).worksheets[0].rows
90
+ end
91
+
92
+ def client_secrets
93
+ @secrets ||= YAML.load(File.open(File.join(secrets_path)))
94
+ end
95
+
96
+ def secrets_path
97
+ if File.exist? File.join(File.dirname(__FILE__), '../../config/client_secrets.yml')
98
+ File.join(File.dirname(__FILE__), '../../config/client_secrets.yml')
99
+ else
100
+ File.join(File.dirname(__FILE__), '../../config/client_secrets.example.yml')
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -2,26 +2,17 @@ require 'dry/validation'
2
2
  require 'dry/validation/schema'
3
3
 
4
4
  module Locabulary
5
- class Schema < Dry::Validation::Schema
6
- key(:predicate_name) { |predicate_name| predicate_name.format?(/\A[a-z_]+\Z/) & predicate_name.filled? }
7
- key(:values) do |values|
8
- values.array? do
9
- values.each do |value|
10
- value.hash? do
11
- value.key(:term_label) { |term_label| term_label.filled? }
12
- value.optional(:term_uri) { |term_label| term_label.none? | term_label.str? }
13
- value.optional(:deposit_label) { |deposit_label| deposit_label.none? | deposit_label.str? }
14
- value.optional(:description) { |description| description.none? | description.str? }
15
- value.optional(:grouping) { |grouping| grouping.none? | grouping.str? }
16
- value.optional(:affiliation) { |affiliation| affiliation.none? | affiliation.str? }
17
- value.optional(:default_presentation_sequence) do |default_presentation_sequence|
18
- default_presentation_sequence.none? | default_presentation_sequence.int?
19
- end
20
- value.key(:activated_on) { |activated_on| activated_on.format?(/\A\d{4}-\d{2}-\d{2}\Z/) }
21
- value.optional(:deactivated_on) { |deactivated_on| deactivated_on.none? | deactivated_on.format?(/\A\d{4}-\d{2}-\d{2}\Z/) }
22
- end
23
- end
24
- end
5
+ # Responsible for providing a defined and clear schema for each of the locabulary items.
6
+ Schema = Dry::Validation.Schema do
7
+ key(:predicate_name).required(format?: /\A[a-z_]+\Z/)
8
+ key(:values).each do
9
+ key(:term_label).required(:str?)
10
+ optional(:description).maybe(:str?)
11
+ optional(:grouping).maybe(:str?)
12
+ optional(:affiliation).maybe(:str?)
13
+ optional(:default_presentation_sequence).maybe(:int?)
14
+ key(:activated_on).required(format?: /\A\d{4}-\d{2}-\d{2}\Z/)
15
+ optional(:deactivated_on).maybe(format?: /\A\d{4}-\d{2}-\d{2}\Z/)
25
16
  end
26
17
  end
27
18
  end
@@ -0,0 +1,3 @@
1
+ module Locabulary
2
+ VERSION = '0.3.0'.freeze
3
+ end
data/lib/locabulary.rb CHANGED
@@ -1,43 +1,12 @@
1
1
  require 'date'
2
2
  require 'json'
3
+ require 'locabulary/exceptions'
4
+ require 'locabulary/items'
3
5
 
4
6
  # @since 0.1.0
5
7
  module Locabulary
6
- VERSION='0.2.0'.freeze
7
8
  DATA_DIRECTORY = File.expand_path("../../data/", __FILE__).freeze
8
9
 
9
- class RuntimeError < ::RuntimeError
10
- end
11
-
12
- class Item
13
- include Comparable
14
- ATTRIBUTE_NAMES = [:predicate_name, :term_label, :term_uri, :deposit_label, :description, :grouping, :affiliation, :default_presentation_sequence, :activated_on, :deactivated_on].freeze
15
-
16
- attr_reader(*ATTRIBUTE_NAMES)
17
-
18
- def initialize(attributes={})
19
- ATTRIBUTE_NAMES.each do |key|
20
- value = attributes.fetch(key) { attributes.fetch(key.to_s, nil) }
21
- send("#{key}=", value)
22
- end
23
- end
24
-
25
- SORT_SEQUENCE_FOR_NIL = 100_000_000
26
- def <=>(other)
27
- value = presentation_sequence <=> other.presentation_sequence
28
- return value unless value == 0
29
- term_label <=> other.term_label
30
- end
31
-
32
- def presentation_sequence
33
- default_presentation_sequence || SORT_SEQUENCE_FOR_NIL
34
- end
35
-
36
- private
37
-
38
- attr_writer(*ATTRIBUTE_NAMES)
39
- end
40
-
41
10
  module_function
42
11
 
43
12
  # @api public
@@ -50,25 +19,68 @@ module Locabulary
50
19
  def active_items_for(options = {})
51
20
  predicate_name = options.fetch(:predicate_name)
52
21
  as_of = options.fetch(:as_of) { Date.today }
22
+ builder = Items.builder_for(predicate_name: predicate_name)
53
23
  active_cache[predicate_name] ||= begin
54
- filename = filename_for_predicate_name(predicate_name: predicate_name)
55
- json = JSON.parse(File.read(filename))
56
- predicate_name = json.fetch('predicate_name')
57
- json.fetch('values').each_with_object([]) do |item_values, mem|
58
- activated_on = Date.parse(item_values.fetch('activated_on'))
59
- next unless activated_on < as_of
60
- deactivated_on_value = item_values.fetch('deactivated_on', nil)
61
- if deactivated_on_value.nil?
62
- mem << Item.new(item_values.merge('predicate_name' => predicate_name))
63
- else
64
- deactivated_on = Date.parse(deactivated_on_value)
65
- next unless deactivated_on >= as_of
66
- mem << Item.new(item_values.merge('predicate_name' => predicate_name))
67
- end
68
- mem
69
- end.sort
24
+ collector = []
25
+ with_active_extraction_for(predicate_name, as_of) do |data|
26
+ collector << builder.call(data.merge('predicate_name' => predicate_name))
27
+ end
28
+ collector.sort
29
+ end
30
+ end
31
+
32
+ # @api public
33
+ # @since 0.2.0
34
+ def active_hierarchical_root(options = {})
35
+ predicate_name = options.fetch(:predicate_name)
36
+ as_of = options.fetch(:as_of) { Date.today }
37
+ builder = Items.builder_for(predicate_name: predicate_name)
38
+ active_hierarchical_root_cache[predicate_name] ||= begin
39
+ items = []
40
+ hierarchy_graph_keys = {}
41
+ top_level_slugs = Set.new
42
+ with_active_extraction_for(predicate_name, as_of) do |data|
43
+ item = builder.call(data.merge('predicate_name' => predicate_name))
44
+ items << item
45
+ top_level_slugs << item.root_slug
46
+ hierarchy_graph_keys[item.term_label] = item
47
+ end
48
+ associate_parents_and_childrens_for(hierarchy_graph_keys, items, predicate_name)
49
+ raise Exceptions::TooManyHierarchicalRootsError.new(predicate_name, top_level_slugs.to_a) if top_level_slugs.size > 1
50
+ hierarchy_graph_keys.fetch(top_level_slugs.first)
51
+ end
52
+ end
53
+
54
+ def associate_parents_and_childrens_for(hierarchy_graph_keys, items, predicate_name)
55
+ items.each do |item|
56
+ begin
57
+ hierarchy_graph_keys.fetch(item.parent_term_label).children << item unless item.parent_slugs.empty?
58
+ rescue KeyError => error
59
+ raise Exceptions::MissingHierarchicalParentError.new(predicate_name, error)
60
+ end
70
61
  end
71
62
  end
63
+ private :associate_parents_and_childrens_for
64
+
65
+ def with_active_extraction_for(predicate_name, as_of)
66
+ filename = filename_for_predicate_name(predicate_name: predicate_name)
67
+ json = JSON.parse(File.read(filename))
68
+ json.fetch('values').each do |data|
69
+ yield(data) if data_is_active?(data, as_of)
70
+ end
71
+ end
72
+ private :with_active_extraction_for
73
+
74
+ def data_is_active?(data, as_of)
75
+ activated_on = Date.parse(data.fetch('activated_on'))
76
+ return false unless activated_on < as_of
77
+ deactivated_on_value = data.fetch('deactivated_on', nil)
78
+ return true if deactivated_on_value.nil?
79
+ deactivated_on = Date.parse(deactivated_on_value)
80
+ return false unless deactivated_on >= as_of
81
+ true
82
+ end
83
+ private :data_is_active?
72
84
 
73
85
  # @api public
74
86
  # @since 0.1.0
@@ -87,27 +99,12 @@ module Locabulary
87
99
  active_items_for(predicate_name: predicate_name).map(&:term_label)
88
100
  end
89
101
 
90
-
91
- # @api public
92
- def active_nested_labels_for(options = {})
93
- format_active_items_for(active_labels_for(options))
94
- end
95
-
96
- # @api public
97
- def properties_for_uri(options = {})
98
- predicate_name = options.fetch(:predicate_name)
99
- term_uri = options.fetch(:term_uri)
100
- object = active_items_for(predicate_name: predicate_name).detect { |obj| obj.term_uri == term_uri }
101
- return object. if object
102
- term_uri
103
- end
104
-
105
102
  # @api private
106
103
  def filename_for_predicate_name(options = {})
107
104
  predicate_name = options.fetch(:predicate_name)
108
105
  filename = File.join(DATA_DIRECTORY, "#{File.basename(predicate_name)}.json")
109
106
  return filename if File.exist?(filename)
110
- raise Locabulary::RuntimeError, "Unable to find predicate_name: #{predicate_name}"
107
+ raise Locabulary::Exceptions::RuntimeError, "Unable to find predicate_name: #{predicate_name}"
111
108
  end
112
109
 
113
110
  # @api private
@@ -116,31 +113,13 @@ module Locabulary
116
113
  end
117
114
 
118
115
  # @api private
119
- def reset_active_cache!
120
- @active_cache = nil
116
+ def active_hierarchical_root_cache
117
+ @active_hierarchical_root_cache ||= {}
121
118
  end
122
119
 
123
120
  # @api private
124
- def format_active_items_for(items)
125
- root = {}
126
- items.each do |item|
127
- key, value = build_key_and_value(item)
128
- root[key] ||= []
129
- root[key] << value
130
- end
131
- root
132
- end
133
-
134
- # @api private
135
- def build_key_and_value(text)
136
- text_array = text.split(/(::)/)
137
- return text, text if text_array.size == 1
138
- return text, text_array.last if text_array.size == 3
139
- key = ""
140
- (0..(text_array.size-3)).each do |index|
141
- key << text_array[index]
142
- end
143
- value = text_array.last
144
- return key, value
121
+ def reset_active_cache!
122
+ @active_cache = nil
123
+ @active_hierarchical_root_cache = nil
145
124
  end
146
125
  end
data/locabulary.gemspec CHANGED
@@ -1,7 +1,7 @@
1
1
  # coding: utf-8
2
2
  lib = File.expand_path('../lib', __FILE__)
3
3
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require 'locabulary'
4
+ require 'locabulary/version'
5
5
 
6
6
  Gem::Specification.new do |spec|
7
7
  spec.name = "locabulary"
@@ -20,12 +20,18 @@ Gem::Specification.new do |spec|
20
20
  spec.license = 'APACHE2'
21
21
 
22
22
  spec.add_dependency "json", "~> 1.8"
23
+ spec.add_dependency "dry-configurable"
24
+ spec.add_dependency "hanami-utils"
23
25
 
24
26
  spec.add_development_dependency "dry-validation"
25
27
  spec.add_development_dependency "bundler"
26
- spec.add_development_dependency "minitest"
28
+ spec.add_development_dependency "rspec"
29
+ spec.add_development_dependency "rspec-its"
27
30
  spec.add_development_dependency "rake", "~> 10.0"
28
31
  spec.add_development_dependency 'google_drive'
29
32
  spec.add_development_dependency 'highline'
30
33
  spec.add_development_dependency "activesupport", "~>4.0"
34
+ spec.add_development_dependency "rubocop"
35
+ spec.add_development_dependency "simplecov"
36
+ spec.add_development_dependency "codeclimate-test-reporter"
31
37
  end
@@ -1,8 +1,13 @@
1
1
  #!/usr/bin/env ruby -U
2
+ if !ENV.key?('BUNDLE_GEMFILE')
3
+ $stderr.puts "You need to call the command via `bundle exec script/#{File.basename(__FILE__)}`"
4
+ $stderr.puts "You will also need to make sure you have valid information in ./config/client_secrets.example.yml."
5
+ abort(1)
6
+ end
2
7
 
3
- require_relative 'json_creator'
8
+ require 'locabulary/json_creator'
4
9
 
5
10
  puts "Updating Administrative Units"
6
- json_creator = JsonCreator.new "1oBW5FCTtYXsUi8roBiMRBLFY3dXamhTqy-kiG2rqu5Q", "administrative_units"
11
+ json_creator = Locabulary::JsonCreator.new "1oBW5FCTtYXsUi8roBiMRBLFY3dXamhTqy-kiG2rqu5Q", "administrative_units"
7
12
  json_creator.create_or_update
8
13
  json_creator.write_to_file