diff --git a/Gemfile b/Gemfile index 1c5badb29..4abcb1b82 100644 --- a/Gemfile +++ b/Gemfile @@ -71,3 +71,5 @@ gem "logs", "~> 0.3.0" gem "delayed_job_active_record", "~> 4.1" gem "activerecord-nulldb-adapter", "~> 0.8.0" + +gem "memoist", "~> 0.16.2" diff --git a/Gemfile.lock b/Gemfile.lock index 2d6f3110c..a35008e95 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -147,6 +147,7 @@ GEM mail (2.7.1) mini_mime (>= 0.1.1) marcel (1.0.1) + memoist (0.16.2) method_source (1.0.0) mini_mime (1.1.0) mini_portile2 (2.6.1) @@ -332,6 +333,7 @@ DEPENDENCIES jbuilder (~> 2.11) listen (~> 3.7) logs (~> 0.3.0) + memoist (~> 0.16.2) pg (~> 1.2) public_suffix (~> 4.0) puma (~> 5.5) @@ -353,4 +355,4 @@ RUBY VERSION ruby 3.0.0p0 BUNDLED WITH - 2.2.4 + 2.2.15 diff --git a/app/controllers/models_controller.rb b/app/controllers/models_controller.rb index 86196cdd6..cbf5233cc 100644 --- a/app/controllers/models_controller.rb +++ b/app/controllers/models_controller.rb @@ -16,6 +16,15 @@ def update redirect_to [@library, @model] end + def merge + if (@parent = @model.parent) + @model.merge_into_parent! + redirect_to [@library, @parent] + else + render status: :bad_request + end + end + def bulk_edit @creators = Creator.all @models = @library.models diff --git a/app/jobs/library_scan_job.rb b/app/jobs/library_scan_job.rb index b45577522..10dc8f2c4 100644 --- a/app/jobs/library_scan_job.rb +++ b/app/jobs/library_scan_job.rb @@ -1,15 +1,15 @@ class LibraryScanJob < ApplicationJob queue_as :default - def self.model_pattern - lower = Rails.configuration.formats[:models].map(&:downcase) - upper = Rails.configuration.formats[:models].map(&:upcase) + def self.file_pattern + lower = Rails.configuration.formats[:models].map(&:downcase) + Rails.configuration.formats[:images].map(&:downcase) + upper = Rails.configuration.formats[:models].map(&:upcase) + Rails.configuration.formats[:images].map(&:upcase) "*.{#{lower.zip(upper).flatten.join(",")}}" end def perform(library) # For each directory in the library, create a model - all_3d_files = Dir.glob(File.join(library.path, "**", LibraryScanJob.model_pattern)) + all_3d_files = Dir.glob(File.join(library.path, "**", LibraryScanJob.file_pattern)) model_folders = all_3d_files.map { |f| File.dirname(f) }.uniq model_folders = model_folders.map { |f| f.gsub(/\/files$/, "").gsub(/\/images$/, "") }.uniq # Ignore thingiverse subfolders model_folders.each do |path| diff --git a/app/models/model.rb b/app/models/model.rb index 0a82fe5e5..fe5adacb3 100644 --- a/app/models/model.rb +++ b/app/models/model.rb @@ -1,4 +1,6 @@ class Model < ApplicationRecord + extend Memoist + belongs_to :library belongs_to :creator, optional: true has_many :parts, dependent: :destroy @@ -17,4 +19,29 @@ def autogenerate_tags_from_path! tag_list.add(path.split(File::SEPARATOR)[1..-2].map { |y| y.split(/[\W_+-]/).filter { |x| x.length > 1 } }.flatten) save! end + + def parent + library.models.find_by_path File.join(File.split(path)[0..-2]) + end + memoize :parent + + def merge_into_parent! + return unless parent + + dirname = File.split(path)[-1] + images.each do |image| + image.update( + filename: File.join(dirname, image.filename), + model: parent + ) + end + parts.each do |part| + part.update( + filename: File.join(dirname, part.filename), + model: parent + ) + end + reload + destroy + end end diff --git a/app/views/models/show.html.erb b/app/views/models/show.html.erb index 981b67469..1261881a0 100644 --- a/app/views/models/show.html.erb +++ b/app/views/models/show.html.erb @@ -33,6 +33,7 @@ <%= card :secondary, "Actions" do %> <%= link_to "Edit Details", edit_library_model_path(@library, @model), class: "btn btn-primary" %> + <%= link_to "Merge Into Parent", merge_library_model_path(@library, @model), class: "btn btn-danger", method: :post if @model.parent %> <% end %> <%= render 'tags_card', tags: @model.tags, selected: nil %> diff --git a/config/routes.rb b/config/routes.rb index e3e27abf2..e4ad42c6b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,9 @@ post "/", controller: :search, action: :index resources :libraries do resources :models, except: [:index, :destroy] do + member do + post "merge" + end collection do get "edit", action: "bulk_edit" patch "update", action: "bulk_update" diff --git a/spec/factories/image.rb b/spec/factories/image.rb new file mode 100644 index 000000000..b908dc27f --- /dev/null +++ b/spec/factories/image.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :image do + filename { Faker::File.file_name(ext: "jpg") } + model { build :model } + end +end diff --git a/spec/fixtures/library/model_one/nested_model/part_one.stl b/spec/fixtures/library/model_one/nested_model/part_one.stl new file mode 100644 index 000000000..5d2d33331 Binary files /dev/null and b/spec/fixtures/library/model_one/nested_model/part_one.stl differ diff --git a/spec/jobs/library_scan_job_spec.rb b/spec/jobs/library_scan_job_spec.rb index 68d427317..24d66f5c5 100644 --- a/spec/jobs/library_scan_job_spec.rb +++ b/spec/jobs/library_scan_job_spec.rb @@ -9,18 +9,18 @@ create(:library, path: File.join(Rails.root, "spec", "fixtures", "library")) end - it "generates a case-insensitive pattern for model files" do - expect(LibraryScanJob.model_pattern).to eq "*.{stl,STL,obj,OBJ,3mf,3MF,blend,BLEND,mix,MIX,ply,PLY}" + it "generates a case-insensitive pattern for files" do + expect(LibraryScanJob.file_pattern).to eq "*.{stl,STL,obj,OBJ,3mf,3MF,blend,BLEND,mix,MIX,ply,PLY,jpg,JPG,png,PNG}" end it "can scan a library directory" do - expect { LibraryScanJob.perform_now(library) }.to change { library.models.count }.to(3) - expect(library.models.map(&:name)).to match_array ["Model One", "Model Two", "Thingiverse Model"] - expect(library.models.map(&:path)).to match_array ["/model_one", "/subfolder/model_two", "/thingiverse_model"] + expect { LibraryScanJob.perform_now(library) }.to change { library.models.count }.to(4) + expect(library.models.map(&:name)).to match_array ["Model One", "Model Two", "Nested Model", "Thingiverse Model"] + expect(library.models.map(&:path)).to match_array ["/model_one", "/subfolder/model_two", "/model_one/nested_model", "/thingiverse_model"] end it "queues up model scans" do - expect { LibraryScanJob.perform_now(library) }.to have_enqueued_job(ModelScanJob).exactly(3).times + expect { LibraryScanJob.perform_now(library) }.to have_enqueued_job(ModelScanJob).exactly(4).times end it "removes models with no parts" do diff --git a/spec/models/model_spec.rb b/spec/models/model_spec.rb index 2456b7e29..51c2cf7f2 100644 --- a/spec/models/model_spec.rb +++ b/spec/models/model_spec.rb @@ -23,6 +23,7 @@ context "with a library on disk" do before :each do + allow(File).to receive(:exist?).and_call_original allow(File).to receive(:exist?).with("/library1").and_return(true) allow(File).to receive(:exist?).with("/library2").and_return(true) end @@ -40,4 +41,48 @@ expect(build(:model, library: library2, path: "model")).to be_valid end end + + context "nested inside another" do + before :each do + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with("/library").and_return(true) + end + + let(:library) { create(:library, path: "/library") } + + it "identifies the parent" do + parent = create(:model, library: library, path: "model") + child = create(:model, library: library, path: "model/nested") + expect(child.parent).to eql parent + end + + context "merging into parent" do + before :each do + @parent = create(:model, library: library, path: "model") + @child = create(:model, library: library, path: "model/nested") + end + + it "moves parts" do + part = create(:part, model: @child, filename: "part.stl") + @child.merge_into_parent! + part.reload + expect(part.filename).to eql "nested/part.stl" + expect(part.model).to eql @parent + end + + it "moves images" do + image = create(:image, model: @child, filename: "image.jpg") + @child.merge_into_parent! + image.reload + expect(image.filename).to eql "nested/image.jpg" + expect(image.model).to eql @parent + end + + it "deletes merged model" do + expect { + @child.merge_into_parent! + }.to change { Model.count }.from(2).to(1) + end + end + end end