locabulary 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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