bulkrax 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/bulkrax/importers_controller.rb +1 -0
- data/app/factories/bulkrax/object_factory.rb +51 -7
- data/app/jobs/bulkrax/create_relationships_job.rb +2 -33
- data/app/jobs/bulkrax/import_collection_job.rb +2 -0
- data/app/jobs/bulkrax/import_file_set_job.rb +69 -0
- data/app/jobs/bulkrax/import_work_job.rb +2 -0
- data/app/jobs/bulkrax/importer_job.rb +2 -0
- data/app/models/bulkrax/csv_collection_entry.rb +1 -1
- data/app/models/bulkrax/csv_entry.rb +12 -3
- data/app/models/bulkrax/csv_file_set_entry.rb +26 -0
- data/app/models/bulkrax/importer.rb +13 -1
- data/app/models/concerns/bulkrax/dynamic_record_lookup.rb +39 -0
- data/app/models/concerns/bulkrax/import_behavior.rb +7 -2
- data/app/models/concerns/bulkrax/importer_exporter_behavior.rb +4 -2
- data/app/parsers/bulkrax/application_parser.rb +8 -0
- data/app/parsers/bulkrax/bagit_parser.rb +1 -1
- data/app/parsers/bulkrax/csv_parser.rb +57 -5
- data/app/parsers/bulkrax/oai_dc_parser.rb +1 -1
- data/app/views/bulkrax/importers/index.html.erb +8 -6
- data/app/views/bulkrax/importers/show.html.erb +46 -4
- data/db/migrate/20211220195027_add_file_set_counters_to_importer_runs.rb +7 -0
- data/db/migrate/20220118001339_add_import_attempts_to_entries.rb +5 -0
- data/db/migrate/20220119213325_add_work_counters_to_importer_runs.rb +6 -0
- data/lib/bulkrax/version.rb +1 -1
- metadata +18 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a35c9f6307c21a9933950a3d383ab04e5a49e15b9980d76f6cfb52a5378f5dd0
|
4
|
+
data.tar.gz: d5a58eeb2b755c68e6c66103afb5dbc649205aed73b2d85167246ca912bb07bf
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d01b9ecd45171f2b20a5c33605fafe645b7c389a141ed70b41a04ecb7bcf1d1f1c4f430ea50fda7d7ef702fd12123cfd904868ba6468a306a41eca5643061e45
|
7
|
+
data.tar.gz: 943eb873c86e40adf96cd7b0badb1ccce719f088d01aada4e18df90ec5a4f4df5b5ebfda5bfe6f74943c6fa371632937a0918f6aed70a4e14a75bcef0b7eaa0b
|
@@ -37,6 +37,7 @@ module Bulkrax
|
|
37
37
|
|
38
38
|
@work_entries = @importer.entries.where(type: @importer.parser.entry_class.to_s).page(params[:work_entries_page]).per(30)
|
39
39
|
@collection_entries = @importer.entries.where(type: @importer.parser.collection_entry_class.to_s).page(params[:collections_entries_page]).per(30)
|
40
|
+
@file_set_entries = @importer.entries.where(type: @importer.parser.file_set_entry_class.to_s).page(params[:file_set_entries_page]).per(30)
|
40
41
|
end
|
41
42
|
end
|
42
43
|
|
@@ -4,11 +4,13 @@ module Bulkrax
|
|
4
4
|
class ObjectFactory
|
5
5
|
extend ActiveModel::Callbacks
|
6
6
|
include Bulkrax::FileFactory
|
7
|
+
include DynamicRecordLookup
|
8
|
+
|
7
9
|
define_model_callbacks :save, :create
|
8
|
-
attr_reader :attributes, :object, :source_identifier_value, :klass, :replace_files, :update_files, :work_identifier, :collection_field_mapping
|
10
|
+
attr_reader :attributes, :object, :source_identifier_value, :klass, :replace_files, :update_files, :work_identifier, :collection_field_mapping, :related_parents_parsed_mapping
|
9
11
|
|
10
12
|
# rubocop:disable Metrics/ParameterLists
|
11
|
-
def initialize(attributes:, source_identifier_value:, work_identifier:, collection_field_mapping:, replace_files: false, user: nil, klass: nil, update_files: false)
|
13
|
+
def initialize(attributes:, source_identifier_value:, work_identifier:, collection_field_mapping:, related_parents_parsed_mapping: nil, replace_files: false, user: nil, klass: nil, update_files: false)
|
12
14
|
ActiveSupport::Deprecation.warn(
|
13
15
|
'Creating Collections using the collection_field_mapping will no longer be supported as of Bulkrax version 3.0.' \
|
14
16
|
' Please configure Bulkrax to use related_parents_field_mapping and related_children_field_mapping instead.'
|
@@ -19,6 +21,7 @@ module Bulkrax
|
|
19
21
|
@user = user || User.batch_user
|
20
22
|
@work_identifier = work_identifier
|
21
23
|
@collection_field_mapping = collection_field_mapping
|
24
|
+
@related_parents_parsed_mapping = related_parents_parsed_mapping
|
22
25
|
@source_identifier_value = source_identifier_value
|
23
26
|
@klass = klass || Bulkrax.default_work_type.constantize
|
24
27
|
end
|
@@ -33,7 +36,7 @@ module Bulkrax
|
|
33
36
|
arg_hash = { id: attributes[:id], name: 'UPDATE', klass: klass }
|
34
37
|
@object = find
|
35
38
|
if object
|
36
|
-
object.reindex_extent = Hyrax::Adapters::NestingIndexAdapter::LIMITED_REINDEX
|
39
|
+
object.reindex_extent = Hyrax::Adapters::NestingIndexAdapter::LIMITED_REINDEX if object.respond_to?(:reindex_extent)
|
37
40
|
ActiveSupport::Notifications.instrument('import.importer', arg_hash) { update }
|
38
41
|
else
|
39
42
|
ActiveSupport::Notifications.instrument('import.importer', arg_hash.merge(name: 'CREATE')) { create }
|
@@ -51,10 +54,16 @@ module Bulkrax
|
|
51
54
|
|
52
55
|
def update
|
53
56
|
raise "Object doesn't exist" unless object
|
54
|
-
destroy_existing_files if @replace_files && klass
|
57
|
+
destroy_existing_files if @replace_files && ![Collection, FileSet].include?(klass)
|
55
58
|
attrs = attribute_update
|
56
59
|
run_callbacks :save do
|
57
|
-
klass == Collection
|
60
|
+
if klass == Collection
|
61
|
+
update_collection(attrs)
|
62
|
+
elsif klass == FileSet
|
63
|
+
update_file_set(attrs)
|
64
|
+
else
|
65
|
+
work_actor.update(environment(attrs))
|
66
|
+
end
|
58
67
|
end
|
59
68
|
log_updated(object)
|
60
69
|
end
|
@@ -90,10 +99,16 @@ module Bulkrax
|
|
90
99
|
def create
|
91
100
|
attrs = create_attributes
|
92
101
|
@object = klass.new
|
93
|
-
object.reindex_extent = Hyrax::Adapters::NestingIndexAdapter::LIMITED_REINDEX
|
102
|
+
object.reindex_extent = Hyrax::Adapters::NestingIndexAdapter::LIMITED_REINDEX if object.respond_to?(:reindex_extent)
|
94
103
|
run_callbacks :save do
|
95
104
|
run_callbacks :create do
|
96
|
-
klass == Collection
|
105
|
+
if klass == Collection
|
106
|
+
create_collection(attrs)
|
107
|
+
elsif klass == FileSet
|
108
|
+
create_file_set(attrs)
|
109
|
+
else
|
110
|
+
work_actor.create(environment(attrs))
|
111
|
+
end
|
97
112
|
end
|
98
113
|
end
|
99
114
|
log_created(object)
|
@@ -150,6 +165,35 @@ module Bulkrax
|
|
150
165
|
object.save!
|
151
166
|
end
|
152
167
|
|
168
|
+
# This method is heavily inspired by Hyrax's AttachFilesToWorkJob
|
169
|
+
def create_file_set(attrs)
|
170
|
+
work = find_record(attributes[related_parents_parsed_mapping].first)
|
171
|
+
work_permissions = work.permissions.map(&:to_hash)
|
172
|
+
file_set_attrs = attrs.slice(*object.attributes.keys)
|
173
|
+
object.assign_attributes(file_set_attrs)
|
174
|
+
|
175
|
+
attrs['uploaded_files'].each do |uploaded_file_id|
|
176
|
+
uploaded_file = ::Hyrax::UploadedFile.find(uploaded_file_id)
|
177
|
+
next if uploaded_file.file_set_uri.present?
|
178
|
+
|
179
|
+
actor = ::Hyrax::Actors::FileSetActor.new(object, @user)
|
180
|
+
uploaded_file.update(file_set_uri: actor.file_set.uri)
|
181
|
+
actor.file_set.permissions_attributes = work_permissions
|
182
|
+
actor.create_metadata
|
183
|
+
actor.create_content(uploaded_file)
|
184
|
+
actor.attach_to_work(work)
|
185
|
+
end
|
186
|
+
|
187
|
+
object.save!
|
188
|
+
end
|
189
|
+
|
190
|
+
def update_file_set(attrs)
|
191
|
+
file_set_attrs = attrs.slice(*object.attributes.keys)
|
192
|
+
actor = ::Hyrax::Actors::FileSetActor.new(object, @user)
|
193
|
+
|
194
|
+
actor.update_metadata(file_set_attrs)
|
195
|
+
end
|
196
|
+
|
153
197
|
# Add child to parent's #member_collections
|
154
198
|
# Add parent to child's #member_of_collections
|
155
199
|
def persist_collection_memberships(parent:, child:)
|
@@ -17,6 +17,8 @@ module Bulkrax
|
|
17
17
|
# NOTE: In the context of this job, "identifier" is used to generically refer
|
18
18
|
# to either a record's ID or an Bulkrax::Entry's source_identifier.
|
19
19
|
class CreateRelationshipsJob < ApplicationJob
|
20
|
+
include DynamicRecordLookup
|
21
|
+
|
20
22
|
queue_as :import
|
21
23
|
|
22
24
|
attr_accessor :base_entry, :child_record, :parent_record, :importer_run
|
@@ -74,39 +76,6 @@ module Bulkrax
|
|
74
76
|
end
|
75
77
|
end
|
76
78
|
|
77
|
-
# This method allows us to create relationships with preexisting records (by their ID) OR
|
78
|
-
# with records that are concurrently being imported (by their Bulkrax::Entry source_identifier).
|
79
|
-
#
|
80
|
-
# @param identifier [String] Work/Collection ID or Bulkrax::Entry source_identifier
|
81
|
-
# @return [Work, Collection, nil] Work or Collection if found, otherwise nil
|
82
|
-
def find_record(identifier)
|
83
|
-
record = Entry.find_by(identifier: identifier)
|
84
|
-
record ||= ::Collection.where(id: identifier).first
|
85
|
-
if record.blank?
|
86
|
-
available_work_types.each do |work_type|
|
87
|
-
record ||= work_type.where(id: identifier).first
|
88
|
-
end
|
89
|
-
end
|
90
|
-
record = record.factory.find if record.is_a?(Entry)
|
91
|
-
|
92
|
-
record
|
93
|
-
end
|
94
|
-
|
95
|
-
# Check if the record is a Work
|
96
|
-
def curation_concern?(record)
|
97
|
-
available_work_types.include?(record.class)
|
98
|
-
end
|
99
|
-
|
100
|
-
# @return [Array<Class>] list of work type classes
|
101
|
-
def available_work_types
|
102
|
-
# If running in a Hyku app, do not reference disabled work types
|
103
|
-
@available_work_types ||= if defined?(::Hyku)
|
104
|
-
::Site.instance.available_works.map(&:constantize)
|
105
|
-
else
|
106
|
-
::Hyrax.config.curation_concerns
|
107
|
-
end
|
108
|
-
end
|
109
|
-
|
110
79
|
def user
|
111
80
|
@user ||= importer_run.importer.user
|
112
81
|
end
|
@@ -11,9 +11,11 @@ module Bulkrax
|
|
11
11
|
entry.build
|
12
12
|
entry.save
|
13
13
|
add_user_to_permission_template!(entry)
|
14
|
+
ImporterRun.find(args[1]).increment!(:processed_records)
|
14
15
|
ImporterRun.find(args[1]).increment!(:processed_collections)
|
15
16
|
ImporterRun.find(args[1]).decrement!(:enqueued_records)
|
16
17
|
rescue => e
|
18
|
+
ImporterRun.find(args[1]).increment!(:failed_records)
|
17
19
|
ImporterRun.find(args[1]).increment!(:failed_collections)
|
18
20
|
ImporterRun.find(args[1]).decrement!(:enqueued_records)
|
19
21
|
raise e
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bulkrax
|
4
|
+
class MissingParentError < ::StandardError; end
|
5
|
+
class ImportFileSetJob < ApplicationJob
|
6
|
+
include DynamicRecordLookup
|
7
|
+
|
8
|
+
queue_as :import
|
9
|
+
|
10
|
+
def perform(entry_id, importer_run_id)
|
11
|
+
entry = Entry.find(entry_id)
|
12
|
+
parent_identifier = entry.raw_metadata[entry.related_parents_raw_mapping]&.strip
|
13
|
+
|
14
|
+
validate_parent!(parent_identifier)
|
15
|
+
|
16
|
+
entry.build
|
17
|
+
if entry.succeeded?
|
18
|
+
# rubocop:disable Rails/SkipsModelValidations
|
19
|
+
ImporterRun.find(importer_run_id).increment!(:processed_records)
|
20
|
+
ImporterRun.find(importer_run_id).increment!(:processed_file_sets)
|
21
|
+
else
|
22
|
+
ImporterRun.find(importer_run_id).increment!(:failed_records)
|
23
|
+
ImporterRun.find(importer_run_id).increment!(:failed_file_sets)
|
24
|
+
# rubocop:enable Rails/SkipsModelValidations
|
25
|
+
end
|
26
|
+
ImporterRun.find(importer_run_id).decrement!(:enqueued_records) # rubocop:disable Rails/SkipsModelValidations
|
27
|
+
entry.save!
|
28
|
+
|
29
|
+
rescue MissingParentError => e
|
30
|
+
# try waiting for the parent record to be created
|
31
|
+
entry.import_attempts += 1
|
32
|
+
entry.save!
|
33
|
+
if entry.import_attempts < 5
|
34
|
+
ImportFileSetJob
|
35
|
+
.set(wait: (entry.import_attempts + 1).minutes)
|
36
|
+
.perform_later(entry_id, importer_run_id)
|
37
|
+
else
|
38
|
+
ImporterRun.find(importer_run_id).decrement!(:enqueued_records) # rubocop:disable Rails/SkipsModelValidations
|
39
|
+
entry.status_info(e)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
attr_reader :parent_record
|
46
|
+
|
47
|
+
def validate_parent!(parent_identifier)
|
48
|
+
# if parent_identifier is missing, it will be caught by #validate_presence_of_parent!
|
49
|
+
return if parent_identifier.blank?
|
50
|
+
|
51
|
+
find_parent_record(parent_identifier)
|
52
|
+
check_parent_exists!(parent_identifier)
|
53
|
+
check_parent_is_a_work!(parent_identifier)
|
54
|
+
end
|
55
|
+
|
56
|
+
def check_parent_exists!(parent_identifier)
|
57
|
+
raise MissingParentError, %(Unable to find a record with the identifier "#{parent_identifier}") if parent_record.blank?
|
58
|
+
end
|
59
|
+
|
60
|
+
def check_parent_is_a_work!(parent_identifier)
|
61
|
+
error_msg = %(A record with the ID "#{parent_identifier}" was found, but it was a #{parent_record.class}, which is not an valid/available work type)
|
62
|
+
raise ::StandardError, error_msg unless curation_concern?(parent_record)
|
63
|
+
end
|
64
|
+
|
65
|
+
def find_parent_record(parent_identifier)
|
66
|
+
@parent_record ||= find_record(parent_identifier)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -10,11 +10,13 @@ module Bulkrax
|
|
10
10
|
entry.build
|
11
11
|
if entry.status == "Complete"
|
12
12
|
ImporterRun.find(args[1]).increment!(:processed_records)
|
13
|
+
ImporterRun.find(args[1]).increment!(:processed_works)
|
13
14
|
ImporterRun.find(args[1]).decrement!(:enqueued_records) unless ImporterRun.find(args[1]).enqueued_records <= 0 # rubocop:disable Style/IdenticalConditionalBranches
|
14
15
|
else
|
15
16
|
# do not retry here because whatever parse error kept you from creating a work will likely
|
16
17
|
# keep preventing you from doing so.
|
17
18
|
ImporterRun.find(args[1]).increment!(:failed_records)
|
19
|
+
ImporterRun.find(args[1]).increment!(:failed_works)
|
18
20
|
ImporterRun.find(args[1]).decrement!(:enqueued_records) unless ImporterRun.find(args[1]).enqueued_records <= 0 # rubocop:disable Style/IdenticalConditionalBranches
|
19
21
|
end
|
20
22
|
entry.save!
|
@@ -20,6 +20,7 @@ module Bulkrax
|
|
20
20
|
|
21
21
|
importer.import_collections
|
22
22
|
importer.import_works
|
23
|
+
importer.import_file_sets
|
23
24
|
end
|
24
25
|
|
25
26
|
def unzip_imported_file(parser)
|
@@ -31,6 +32,7 @@ module Bulkrax
|
|
31
32
|
def update_current_run_counters(importer)
|
32
33
|
importer.current_run.total_work_entries = importer.limit || importer.parser.works_total
|
33
34
|
importer.current_run.total_collection_entries = importer.parser.collections_total
|
35
|
+
importer.current_run.total_file_set_entries = importer.parser.file_sets_total
|
34
36
|
importer.current_run.save!
|
35
37
|
end
|
36
38
|
|
@@ -9,7 +9,7 @@ module Bulkrax
|
|
9
9
|
# Use identifier set by CsvParser#unique_collection_identifier, which falls back
|
10
10
|
# on the Collection's first title if record[source_identifier] is not present
|
11
11
|
def add_identifier
|
12
|
-
self.parsed_metadata[work_identifier] = self.identifier
|
12
|
+
self.parsed_metadata[work_identifier] = [self.identifier].flatten
|
13
13
|
end
|
14
14
|
|
15
15
|
def add_collection_type_gid
|
@@ -40,9 +40,9 @@ module Bulkrax
|
|
40
40
|
|
41
41
|
self.parsed_metadata = {}
|
42
42
|
add_identifier
|
43
|
-
add_metadata_for_model
|
44
43
|
add_visibility
|
45
44
|
add_ingested_metadata
|
45
|
+
add_metadata_for_model
|
46
46
|
add_rights_statement
|
47
47
|
add_collections
|
48
48
|
add_local
|
@@ -57,6 +57,9 @@ module Bulkrax
|
|
57
57
|
def add_metadata_for_model
|
58
58
|
if factory_class == Collection
|
59
59
|
add_collection_type_gid
|
60
|
+
elsif factory_class == FileSet
|
61
|
+
add_path_to_file
|
62
|
+
validate_presence_of_parent!
|
60
63
|
else
|
61
64
|
add_file unless importerexporter.metadata_only?
|
62
65
|
add_admin_set_id
|
@@ -68,7 +71,9 @@ module Bulkrax
|
|
68
71
|
'Creating Collections using the collection_field_mapping will no longer be supported as of Bulkrax version 3.0.' \
|
69
72
|
' Please configure Bulkrax to use related_parents_field_mapping and related_children_field_mapping instead.'
|
70
73
|
)
|
71
|
-
|
74
|
+
# we do not want to sort the values in the record before adding the metadata.
|
75
|
+
# if we do, the factory_class will be set to the default_work_type for all values that come before "model" or "work type"
|
76
|
+
record.each do |key, value|
|
72
77
|
next if self.parser.collection_field_mapping.to_s == key_without_numbers(key)
|
73
78
|
|
74
79
|
index = key[/\d+/].to_i - 1 if key[/\d+/].to_i != 0
|
@@ -83,7 +88,11 @@ module Bulkrax
|
|
83
88
|
elsif record['file'].is_a?(Array)
|
84
89
|
self.parsed_metadata['file'] = record['file']
|
85
90
|
end
|
86
|
-
self.parsed_metadata['file'] = self.parsed_metadata['file'].map
|
91
|
+
self.parsed_metadata['file'] = self.parsed_metadata['file'].map do |f|
|
92
|
+
next if f.blank?
|
93
|
+
|
94
|
+
path_to_file(f.tr(' ', '_'))
|
95
|
+
end.compact
|
87
96
|
end
|
88
97
|
|
89
98
|
def build_export_metadata
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bulkrax
|
4
|
+
class CsvFileSetEntry < CsvEntry
|
5
|
+
def factory_class
|
6
|
+
::FileSet
|
7
|
+
end
|
8
|
+
|
9
|
+
def add_path_to_file
|
10
|
+
parsed_metadata['file'].each_with_index do |filename, i|
|
11
|
+
path_to_file = ::File.join(parser.path_to_files, filename)
|
12
|
+
|
13
|
+
parsed_metadata['file'][i] = path_to_file
|
14
|
+
end
|
15
|
+
raise ::StandardError, 'one or more file paths are invalid' unless parsed_metadata['file'].map { |file_path| ::File.file?(file_path) }.all?
|
16
|
+
|
17
|
+
parsed_metadata['file']
|
18
|
+
end
|
19
|
+
|
20
|
+
def validate_presence_of_parent!
|
21
|
+
return if parsed_metadata[related_parents_parsed_mapping]&.map(&:present?)&.any?
|
22
|
+
|
23
|
+
raise StandardError, 'File set must be related to at least one work'
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -99,7 +99,12 @@ module Bulkrax
|
|
99
99
|
@current_run ||= if file? && zip?
|
100
100
|
self.importer_runs.create!
|
101
101
|
else
|
102
|
-
|
102
|
+
entry_counts = {
|
103
|
+
total_work_entries: self.limit || parser.works_total,
|
104
|
+
total_collection_entries: parser.collections_total,
|
105
|
+
total_file_set_entries: parser.file_sets_total
|
106
|
+
}
|
107
|
+
self.importer_runs.create!(entry_counts)
|
103
108
|
end
|
104
109
|
end
|
105
110
|
|
@@ -134,6 +139,13 @@ module Bulkrax
|
|
134
139
|
status_info(e)
|
135
140
|
end
|
136
141
|
|
142
|
+
def import_file_sets
|
143
|
+
self.save if self.new_record? # Object needs to be saved for statuses
|
144
|
+
parser.create_file_sets
|
145
|
+
rescue StandardError => e
|
146
|
+
status_info(e)
|
147
|
+
end
|
148
|
+
|
137
149
|
# Prepend the base_url to ensure unique set identifiers
|
138
150
|
# @todo - move to parser, as this is OAI specific
|
139
151
|
def unique_collection_identifier(id)
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bulkrax
|
4
|
+
module DynamicRecordLookup
|
5
|
+
# Search entries, collections, and every available work type for a record that
|
6
|
+
# has the provided identifier.
|
7
|
+
#
|
8
|
+
# @param identifier [String] Work/Collection ID or Bulkrax::Entry source_identifier
|
9
|
+
# @return [Work, Collection, nil] Work or Collection if found, otherwise nil
|
10
|
+
def find_record(identifier)
|
11
|
+
record = Entry.find_by(identifier: identifier)
|
12
|
+
record ||= ::Collection.where(id: identifier).first # rubocop:disable Rails/FindBy
|
13
|
+
if record.blank?
|
14
|
+
available_work_types.each do |work_type|
|
15
|
+
record ||= work_type.where(id: identifier).first # rubocop:disable Rails/FindBy
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
record.is_a?(Entry) ? record.factory.find : record
|
20
|
+
end
|
21
|
+
|
22
|
+
# Check if the record is a Work
|
23
|
+
def curation_concern?(record)
|
24
|
+
available_work_types.include?(record.class)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
# @return [Array<Class>] list of work type classes
|
30
|
+
def available_work_types
|
31
|
+
# If running in a Hyku app, do not include disabled work types
|
32
|
+
@available_work_types ||= if defined?(::Hyku)
|
33
|
+
::Site.instance.available_works.map(&:constantize)
|
34
|
+
else
|
35
|
+
::Hyrax.config.curation_concerns
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -11,9 +11,9 @@ module Bulkrax
|
|
11
11
|
unless self.importerexporter.validate_only
|
12
12
|
raise CollectionsCreatedError unless collections_created?
|
13
13
|
@item = factory.run!
|
14
|
+
parent_jobs if self.parsed_metadata[related_parents_parsed_mapping].present?
|
15
|
+
child_jobs if self.parsed_metadata[related_children_parsed_mapping].present?
|
14
16
|
end
|
15
|
-
parent_jobs if self.parsed_metadata[related_parents_parsed_mapping].present?
|
16
|
-
child_jobs if self.parsed_metadata[related_children_parsed_mapping].present?
|
17
17
|
rescue RSolr::Error::Http, CollectionsCreatedError => e
|
18
18
|
raise e
|
19
19
|
rescue StandardError => e
|
@@ -26,12 +26,16 @@ module Bulkrax
|
|
26
26
|
|
27
27
|
def parent_jobs
|
28
28
|
self.parsed_metadata[related_parents_parsed_mapping].each do |parent_identifier|
|
29
|
+
next if parent_identifier.blank?
|
30
|
+
|
29
31
|
CreateRelationshipsJob.perform_later(entry_identifier: self.identifier, parent_identifier: parent_identifier, importer_run: self.last_run)
|
30
32
|
end
|
31
33
|
end
|
32
34
|
|
33
35
|
def child_jobs
|
34
36
|
self.parsed_metadata[related_children_parsed_mapping].each do |child_identifier|
|
37
|
+
next if child_identifier.blank?
|
38
|
+
|
35
39
|
CreateRelationshipsJob.perform_later(entry_identifier: self.identifier, child_identifier: child_identifier, importer_run: self.last_run)
|
36
40
|
end
|
37
41
|
end
|
@@ -92,6 +96,7 @@ module Bulkrax
|
|
92
96
|
source_identifier_value: identifier,
|
93
97
|
work_identifier: parser.work_identifier,
|
94
98
|
collection_field_mapping: parser.collection_field_mapping,
|
99
|
+
related_parents_parsed_mapping: related_parents_parsed_mapping,
|
95
100
|
replace_files: replace_files,
|
96
101
|
user: user,
|
97
102
|
klass: factory_class,
|
@@ -20,15 +20,17 @@ module Bulkrax
|
|
20
20
|
(last_imported_at || Time.current) + frequency.to_seconds if schedulable? && last_imported_at.present?
|
21
21
|
end
|
22
22
|
|
23
|
-
def increment_counters(index, collection
|
23
|
+
def increment_counters(index, collection: false, file_set: false)
|
24
24
|
# Only set the totals if they were not set on initialization
|
25
25
|
if collection
|
26
26
|
current_run.total_collection_entries = index + 1 unless parser.collections_total.positive?
|
27
|
+
elsif file_set
|
28
|
+
current_run.total_file_set_entries = index + 1 unless parser.file_sets_total.positive?
|
27
29
|
else
|
28
30
|
# TODO: differentiate between work and collection counts for exporters
|
29
31
|
current_run.total_work_entries = index + 1 unless limit.to_i.positive? || parser.total.positive?
|
30
32
|
end
|
31
|
-
current_run.enqueued_records
|
33
|
+
current_run.enqueued_records += 1
|
32
34
|
current_run.save!
|
33
35
|
end
|
34
36
|
|
@@ -114,6 +114,10 @@ module Bulkrax
|
|
114
114
|
raise StandardError, 'must be defined' if importer?
|
115
115
|
end
|
116
116
|
|
117
|
+
def create_file_sets
|
118
|
+
raise StandardError, 'must be defined' if importer?
|
119
|
+
end
|
120
|
+
|
117
121
|
# Optional, define if using browse everything for file upload
|
118
122
|
def retrieve_cloud_files(files); end
|
119
123
|
|
@@ -234,6 +238,10 @@ module Bulkrax
|
|
234
238
|
0
|
235
239
|
end
|
236
240
|
|
241
|
+
def file_sets_total
|
242
|
+
0
|
243
|
+
end
|
244
|
+
|
237
245
|
def write
|
238
246
|
write_files
|
239
247
|
zip
|
@@ -59,7 +59,7 @@ module Bulkrax
|
|
59
59
|
}
|
60
60
|
new_entry = find_or_create_entry(collection_entry_class, collection, 'Bulkrax::Importer', metadata)
|
61
61
|
ImportCollectionJob.perform_now(new_entry.id, current_run.id)
|
62
|
-
increment_counters(index, true)
|
62
|
+
increment_counters(index, collection: true)
|
63
63
|
end
|
64
64
|
end
|
65
65
|
|
@@ -25,7 +25,7 @@ module Bulkrax
|
|
25
25
|
# retrieve a list of unique collections
|
26
26
|
records.map do |r|
|
27
27
|
collections = []
|
28
|
-
r[collection_field_mapping].split(/\s*[;|]\s*/).each { |title| collections << { title: title } } if r[collection_field_mapping].present?
|
28
|
+
r[collection_field_mapping].split(/\s*[;|]\s*/).each { |title| collections << { title: title, from_collection_field_mapping: true } } if r[collection_field_mapping].present?
|
29
29
|
model_field_mappings.each do |model_mapping|
|
30
30
|
collections << r if r[model_mapping.to_sym]&.downcase == 'collection'
|
31
31
|
end
|
@@ -38,13 +38,27 @@ module Bulkrax
|
|
38
38
|
end
|
39
39
|
|
40
40
|
def works
|
41
|
-
records - collections
|
41
|
+
records - collections - file_sets
|
42
42
|
end
|
43
43
|
|
44
44
|
def works_total
|
45
45
|
works.size
|
46
46
|
end
|
47
47
|
|
48
|
+
def file_sets
|
49
|
+
records.map do |r|
|
50
|
+
file_sets = []
|
51
|
+
model_field_mappings.each do |model_mapping|
|
52
|
+
file_sets << r if r[model_mapping.to_sym]&.downcase == 'fileset'
|
53
|
+
end
|
54
|
+
file_sets
|
55
|
+
end.flatten.compact.uniq
|
56
|
+
end
|
57
|
+
|
58
|
+
def file_sets_total
|
59
|
+
file_sets.size
|
60
|
+
end
|
61
|
+
|
48
62
|
# We could use CsvEntry#fields_from_data(data) but that would mean re-reading the data
|
49
63
|
def import_fields
|
50
64
|
@import_fields ||= records.inject(:merge).keys.compact.uniq
|
@@ -74,11 +88,31 @@ module Bulkrax
|
|
74
88
|
collections.each_with_index do |collection, index|
|
75
89
|
next if collection.blank?
|
76
90
|
break if records.find_index(collection).present? && limit_reached?(limit, records.find_index(collection))
|
77
|
-
|
78
|
-
|
91
|
+
ActiveSupport::Deprecation.warn(
|
92
|
+
'Creating Collections using the collection_field_mapping will no longer be supported as of Bulkrax version 3.0.' \
|
93
|
+
' Please configure Bulkrax to use related_parents_field_mapping and related_children_field_mapping instead.'
|
94
|
+
)
|
95
|
+
|
96
|
+
## BEGIN
|
97
|
+
# Add required metadata to collections being imported using the collection_field_mapping, which only have a :title
|
98
|
+
# TODO: Remove once collection_field_mapping is removed
|
99
|
+
metadata = if collection.delete(:from_collection_field_mapping)
|
100
|
+
uci = unique_collection_identifier(collection)
|
101
|
+
{
|
102
|
+
title: collection[:title],
|
103
|
+
work_identifier => uci,
|
104
|
+
source_identifier => uci,
|
105
|
+
visibility: 'open',
|
106
|
+
collection_type_gid: ::Hyrax::CollectionType.find_or_create_default_collection_type.gid
|
107
|
+
}
|
108
|
+
end
|
109
|
+
collection_hash = metadata.presence || collection
|
110
|
+
## END
|
111
|
+
|
112
|
+
new_entry = find_or_create_entry(collection_entry_class, collection_hash[source_identifier], 'Bulkrax::Importer', collection_hash)
|
79
113
|
# TODO: add support for :delete option
|
80
114
|
ImportCollectionJob.perform_now(new_entry.id, current_run.id)
|
81
|
-
increment_counters(index, true)
|
115
|
+
increment_counters(index, collection: true)
|
82
116
|
end
|
83
117
|
importer.record_status
|
84
118
|
rescue StandardError => e
|
@@ -104,6 +138,20 @@ module Bulkrax
|
|
104
138
|
status_info(e)
|
105
139
|
end
|
106
140
|
|
141
|
+
def create_file_sets
|
142
|
+
file_sets.each_with_index do |file_set, index|
|
143
|
+
next unless record_has_source_identifier(file_set, records.find_index(file_set))
|
144
|
+
break if limit_reached?(limit, records.find_index(file_set))
|
145
|
+
|
146
|
+
new_entry = find_or_create_entry(file_set_entry_class, file_set[source_identifier], 'Bulkrax::Importer', file_set.to_h)
|
147
|
+
ImportFileSetJob.perform_later(new_entry.id, current_run.id)
|
148
|
+
increment_counters(index, file_set: true)
|
149
|
+
end
|
150
|
+
importer.record_status
|
151
|
+
rescue StandardError => e
|
152
|
+
status_info(e)
|
153
|
+
end
|
154
|
+
|
107
155
|
def write_partial_import_file(file)
|
108
156
|
import_filename = import_file_path.split('/').last
|
109
157
|
partial_import_filename = "#{File.basename(import_filename, '.csv')}_corrected_entries.csv"
|
@@ -179,6 +227,10 @@ module Bulkrax
|
|
179
227
|
CsvCollectionEntry
|
180
228
|
end
|
181
229
|
|
230
|
+
def file_set_entry_class
|
231
|
+
CsvFileSetEntry
|
232
|
+
end
|
233
|
+
|
182
234
|
# See https://stackoverflow.com/questions/2650517/count-the-number-of-lines-in-a-file-without-reading-entire-file-into-memory
|
183
235
|
# Changed to grep as wc -l counts blank lines, and ignores the final unescaped line (which may or may not contain data)
|
184
236
|
def total
|
@@ -76,7 +76,7 @@ module Bulkrax
|
|
76
76
|
new_entry = collection_entry_class.where(importerexporter: importerexporter, identifier: unique_collection_identifier, raw_metadata: metadata).first_or_create!
|
77
77
|
# perform now to ensure this gets created before work imports start
|
78
78
|
ImportCollectionJob.perform_now(new_entry.id, importerexporter.current_run.id)
|
79
|
-
increment_counters(index, true)
|
79
|
+
increment_counters(index, collection: true)
|
80
80
|
end
|
81
81
|
end
|
82
82
|
|
@@ -24,6 +24,7 @@
|
|
24
24
|
<th scope="col">Entries Deleted Upstream</th>
|
25
25
|
<th scope="col">Total Collection Entries</th>
|
26
26
|
<th scope="col">Total Work Entries</th>
|
27
|
+
<th scope="col">Total File Set Entries</th>
|
27
28
|
<th scope="col"></th>
|
28
29
|
<th scope="col"></th>
|
29
30
|
<th scope="col"></th>
|
@@ -36,12 +37,13 @@
|
|
36
37
|
<td><%= importer.status %></td>
|
37
38
|
<td><%= importer.last_imported_at.strftime("%b %d, %Y") if importer.last_imported_at %></td>
|
38
39
|
<td><%= importer.next_import_at.strftime("%b %d, %Y") if importer.next_import_at %></td>
|
39
|
-
<td><%= importer.
|
40
|
-
<td><%= (importer.
|
41
|
-
<td><%= (importer.
|
42
|
-
<td><%= importer.
|
43
|
-
<td><%= importer.
|
44
|
-
<td><%= importer.
|
40
|
+
<td><%= importer.last_run&.enqueued_records %></td>
|
41
|
+
<td><%= (importer.last_run&.processed_records || 0) %></td>
|
42
|
+
<td><%= (importer.last_run&.failed_records || 0) %></td>
|
43
|
+
<td><%= importer.last_run&.deleted_records %></td>
|
44
|
+
<td><%= importer.last_run&.total_collection_entries %></td>
|
45
|
+
<td><%= importer.last_run&.total_work_entries %></td>
|
46
|
+
<td><%= importer.last_run&.total_file_set_entries %></td>
|
45
47
|
<td><%= link_to raw('<span class="glyphicon glyphicon-info-sign"></span>'), importer_path(importer) %></td>
|
46
48
|
<td><%= link_to raw('<span class="glyphicon glyphicon-pencil"></span>'), edit_importer_path(importer) %></td>
|
47
49
|
<td><%= link_to raw('<span class="glyphicon glyphicon-remove"></span>'), importer, method: :delete, data: { confirm: 'Are you sure?' } %></td>
|
@@ -56,14 +56,19 @@
|
|
56
56
|
|
57
57
|
<%= render partial: 'bulkrax/shared/bulkrax_field_mapping', locals: {item: @importer} %>
|
58
58
|
|
59
|
-
<p class="bulkrax-p-align">
|
59
|
+
<p class="bulkrax-p-align" title="<%= @importer.last_run&.processed_works %> processed, <%= @importer.last_run&.failed_works %> failed">
|
60
60
|
<strong>Total Works:</strong>
|
61
|
-
<%= @importer.
|
61
|
+
<%= @importer.last_run&.total_work_entries %>
|
62
62
|
</p>
|
63
63
|
|
64
|
-
<p class="bulkrax-p-align">
|
64
|
+
<p class="bulkrax-p-align" title="<%= @importer.last_run&.processed_collections %> processed, <%= @importer.last_run&.failed_collections %> failed">
|
65
65
|
<strong>Total Collections:</strong>
|
66
|
-
<%= @importer.
|
66
|
+
<%= @importer.last_run&.total_collection_entries %>
|
67
|
+
</p>
|
68
|
+
|
69
|
+
<p class="bulkrax-p-align" title="<%= @importer.last_run&.processed_file_sets %> processed, <%= @importer.last_run&.failed_file_sets %> failed">
|
70
|
+
<strong>Total File Sets:</strong>
|
71
|
+
<%= @importer.last_run&.total_file_set_entries %>
|
67
72
|
</p>
|
68
73
|
|
69
74
|
<div class="bulkrax-nav-tab-bottom-margin">
|
@@ -71,6 +76,7 @@
|
|
71
76
|
<ul class="bulkrax-nav-tab-top-margin tab-nav nav nav-tabs" role="tablist">
|
72
77
|
<li role="presentation" class='active'><a href="#work-entries" aria-controls="work-entries" role="tab" data-toggle="tab">Work Entries</a></li>
|
73
78
|
<li role="presentation"><a href="#collection-entries" aria-controls="collection-entries" role="tab" data-toggle="tab">Collection Entries</a></li>
|
79
|
+
<li role="presentation"><a href="#file-set-entries" aria-controls="file-set-entries" role="tab" data-toggle="tab">File Set Entries</a></li>
|
74
80
|
</ul>
|
75
81
|
<!-- Tab panes -->
|
76
82
|
<div class="tab-content outline">
|
@@ -158,6 +164,42 @@
|
|
158
164
|
<%= page_entries_info(@collection_entries) %><br />
|
159
165
|
<%= paginate(@collection_entries, theme: 'blacklight', param_name: :collections_entries_page, params: {anchor: 'collection-entries'}) %>
|
160
166
|
</div>
|
167
|
+
<div role="tabpanel" class="tab-pane bulkrax-nav-tab-table-left-align" id="file-set-entries">
|
168
|
+
<table class='table table-striped'>
|
169
|
+
<thead>
|
170
|
+
<tr>
|
171
|
+
<th>Identifier</th>
|
172
|
+
<th>Entry ID</th>
|
173
|
+
<th>Status</th>
|
174
|
+
<th>Errors</th>
|
175
|
+
<th>Status Set At</th>
|
176
|
+
<th>Actions</th>
|
177
|
+
</tr>
|
178
|
+
</thead>
|
179
|
+
<tbody>
|
180
|
+
<% @file_set_entries.each do |e| %>
|
181
|
+
<tr>
|
182
|
+
<td><%= link_to e.identifier, bulkrax.importer_entry_path(@importer.id, e.id) %></td>
|
183
|
+
<td><%= e.id %></td>
|
184
|
+
<% if e.status == "Complete" %>
|
185
|
+
<td><span class="glyphicon glyphicon-ok" style="color: green;"></span> <%= e.status %></td>
|
186
|
+
<% else %>
|
187
|
+
<td><span class="glyphicon glyphicon-remove" style="color: red;"></span> <%= e.status %></td>
|
188
|
+
<% end %>
|
189
|
+
<% if e.last_error.present? %>
|
190
|
+
<td><%= link_to e.last_error.dig("error_class"), bulkrax.importer_entry_path(@importer.id, e.id) %></td>
|
191
|
+
<% else %>
|
192
|
+
<td></td>
|
193
|
+
<% end %>
|
194
|
+
<td><%= e.status_at %></td>
|
195
|
+
<td><%= link_to raw('<span class="glyphicon glyphicon-info-sign"></span>'), bulkrax.importer_entry_path(@importer.id, e.id) %></td>
|
196
|
+
</tr>
|
197
|
+
<% end %>
|
198
|
+
</tbody>
|
199
|
+
</table>
|
200
|
+
<%= page_entries_info(@file_set_entries) %><br />
|
201
|
+
<%= paginate(@file_set_entries, theme: 'blacklight', param_name: :file_set_entries_page, params: {anchor: 'file-set-entries'}) %>
|
202
|
+
</div>
|
161
203
|
</div>
|
162
204
|
</div>
|
163
205
|
|
@@ -0,0 +1,7 @@
|
|
1
|
+
class AddFileSetCountersToImporterRuns < ActiveRecord::Migration[5.2]
|
2
|
+
def change
|
3
|
+
add_column :bulkrax_importer_runs, :processed_file_sets, :integer, default: 0 unless column_exists?(:bulkrax_importer_runs, :processed_file_sets)
|
4
|
+
add_column :bulkrax_importer_runs, :failed_file_sets, :integer, default: 0 unless column_exists?(:bulkrax_importer_runs, :failed_file_sets)
|
5
|
+
add_column :bulkrax_importer_runs, :total_file_set_entries, :integer, default: 0 unless column_exists?(:bulkrax_importer_runs, :total_file_set_entries)
|
6
|
+
end
|
7
|
+
end
|
@@ -0,0 +1,6 @@
|
|
1
|
+
class AddWorkCountersToImporterRuns < ActiveRecord::Migration[5.2]
|
2
|
+
def change
|
3
|
+
add_column :bulkrax_importer_runs, :processed_works, :integer, default: 0 unless column_exists?(:bulkrax_importer_runs, :processed_works)
|
4
|
+
add_column :bulkrax_importer_runs, :failed_works, :integer, default: 0 unless column_exists?(:bulkrax_importer_runs, :failed_works)
|
5
|
+
end
|
6
|
+
end
|
data/lib/bulkrax/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: bulkrax
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Rob Kaufman
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-
|
11
|
+
date: 2022-02-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -224,7 +224,11 @@ dependencies:
|
|
224
224
|
- - ">="
|
225
225
|
- !ruby/object:Gem::Version
|
226
226
|
version: '0'
|
227
|
-
description:
|
227
|
+
description: Bulkrax is a batteries included importer for Samvera applications. It
|
228
|
+
currently includes support for OAI-PMH (DC and Qualified DC) and CSV out of the
|
229
|
+
box. It is also designed to be extensible, allowing you to easily add new importers
|
230
|
+
in to your application or to include them with other gems. Bulkrax provides a full
|
231
|
+
admin interface including creating, editing, scheduling and reviewing imports.
|
228
232
|
email:
|
229
233
|
- rob@notch8.com
|
230
234
|
executables: []
|
@@ -261,6 +265,7 @@ files:
|
|
261
265
|
- app/jobs/bulkrax/export_work_job.rb
|
262
266
|
- app/jobs/bulkrax/exporter_job.rb
|
263
267
|
- app/jobs/bulkrax/import_collection_job.rb
|
268
|
+
- app/jobs/bulkrax/import_file_set_job.rb
|
264
269
|
- app/jobs/bulkrax/import_work_job.rb
|
265
270
|
- app/jobs/bulkrax/importer_job.rb
|
266
271
|
- app/mailers/bulkrax/application_mailer.rb
|
@@ -271,6 +276,7 @@ files:
|
|
271
276
|
- app/models/bulkrax/application_record.rb
|
272
277
|
- app/models/bulkrax/csv_collection_entry.rb
|
273
278
|
- app/models/bulkrax/csv_entry.rb
|
279
|
+
- app/models/bulkrax/csv_file_set_entry.rb
|
274
280
|
- app/models/bulkrax/entry.rb
|
275
281
|
- app/models/bulkrax/exporter.rb
|
276
282
|
- app/models/bulkrax/exporter_run.rb
|
@@ -286,6 +292,7 @@ files:
|
|
286
292
|
- app/models/bulkrax/status.rb
|
287
293
|
- app/models/bulkrax/xml_entry.rb
|
288
294
|
- app/models/concerns/bulkrax/download_behavior.rb
|
295
|
+
- app/models/concerns/bulkrax/dynamic_record_lookup.rb
|
289
296
|
- app/models/concerns/bulkrax/errored_entries.rb
|
290
297
|
- app/models/concerns/bulkrax/export_behavior.rb
|
291
298
|
- app/models/concerns/bulkrax/file_factory.rb
|
@@ -353,6 +360,9 @@ files:
|
|
353
360
|
- db/migrate/20210806065737_increase_text_sizes.rb
|
354
361
|
- db/migrate/20211004170708_change_bulkrax_statuses_error_message_column_type_to_text.rb
|
355
362
|
- db/migrate/20211203195233_rename_children_counters_to_relationships.rb
|
363
|
+
- db/migrate/20211220195027_add_file_set_counters_to_importer_runs.rb
|
364
|
+
- db/migrate/20220118001339_add_import_attempts_to_entries.rb
|
365
|
+
- db/migrate/20220119213325_add_work_counters_to_importer_runs.rb
|
356
366
|
- lib/bulkrax.rb
|
357
367
|
- lib/bulkrax/engine.rb
|
358
368
|
- lib/bulkrax/version.rb
|
@@ -368,7 +378,7 @@ homepage: https://github.com/samvera-labs/bulkrax
|
|
368
378
|
licenses:
|
369
379
|
- Apache-2.0
|
370
380
|
metadata: {}
|
371
|
-
post_install_message:
|
381
|
+
post_install_message:
|
372
382
|
rdoc_options: []
|
373
383
|
require_paths:
|
374
384
|
- lib
|
@@ -383,8 +393,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
383
393
|
- !ruby/object:Gem::Version
|
384
394
|
version: '0'
|
385
395
|
requirements: []
|
386
|
-
rubygems_version: 3.1.
|
387
|
-
signing_key:
|
396
|
+
rubygems_version: 3.1.4
|
397
|
+
signing_key:
|
388
398
|
specification_version: 4
|
389
|
-
summary:
|
399
|
+
summary: Import and export tool for Hyrax and Hyku
|
390
400
|
test_files: []
|