diff --git a/README.md b/README.md index a18909ab..cdfb3760 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ bundle exec rails runner "pwd = SecureRandom.alphanumeric(16); u = User.create!( bundle exec rails sync:assets[scan_output.applications-backup.csv] # Run these after full assets ingest is complete. -bundle exec rails sync:cleanup_folder_assets -bundle exec rails duplicates:detect + +bundle exec rails sync:post_ingest ``` diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss index 13abd701..6418d763 100644 --- a/app/assets/stylesheets/application.css.scss +++ b/app/assets/stylesheets/application.css.scss @@ -242,6 +242,10 @@ div.wunderbaum div.wb-header span.wb-col.wb-active { padding: 1px 4px; } +.wb-tree .wb-title.wb-title-empty { + color: #dc3545; +} + .popup-select { display: block; width: 100%; diff --git a/app/javascript/controllers/wunderbaum_controller.js b/app/javascript/controllers/wunderbaum_controller.js index 34a434f2..57031c26 100644 --- a/app/javascript/controllers/wunderbaum_controller.js +++ b/app/javascript/controllers/wunderbaum_controller.js @@ -266,6 +266,13 @@ export default class extends Controller { titleElem.innerHTML = `${node.title}`; } + + if (isFolder) { + const isEmpty = node.data.has_descendant_assets === false; + titleElem.classList.toggle("wb-title-empty", isEmpty); + } else { + titleElem.classList.remove("wb-title-empty"); + } }, buttonClick: (e) => { diff --git a/app/serializers/file_tree_search_result_serializer.rb b/app/serializers/file_tree_search_result_serializer.rb index fdec66ff..b6afa231 100644 --- a/app/serializers/file_tree_search_result_serializer.rb +++ b/app/serializers/file_tree_search_result_serializer.rb @@ -1,6 +1,7 @@ class FileTreeSearchResultSerializer < ActiveModel::Serializer attributes :id, :folder, :parent_folder_id, :path, - :migration_status, :assigned_to_id, :assigned_to + :migration_status, :assigned_to_id, :assigned_to, + :has_descendant_assets def folder folder? @@ -34,6 +35,11 @@ def assigned_to object.assigned_to&.name.to_s end + def has_descendant_assets + return nil unless folder? + object.has_descendant_assets + end + private def folder? diff --git a/app/serializers/isilon_folder_serializer.rb b/app/serializers/isilon_folder_serializer.rb index b66672db..22392619 100644 --- a/app/serializers/isilon_folder_serializer.rb +++ b/app/serializers/isilon_folder_serializer.rb @@ -1,6 +1,6 @@ class IsilonFolderSerializer < ActiveModel::Serializer attributes :title, :full_path, :folder, :id, :lazy, - :assigned_to_id, :assigned_to, + :assigned_to_id, :assigned_to, :has_descendant_assets, :parent_folder_id, :path, :key, :notes def title diff --git a/db/migrate/20260213151000_add_has_descendant_assets_to_isilon_folders.rb b/db/migrate/20260213151000_add_has_descendant_assets_to_isilon_folders.rb new file mode 100644 index 00000000..d4afdecb --- /dev/null +++ b/db/migrate/20260213151000_add_has_descendant_assets_to_isilon_folders.rb @@ -0,0 +1,5 @@ +class AddHasDescendantAssetsToIsilonFolders < ActiveRecord::Migration[7.2] + def change + add_column :isilon_folders, :has_descendant_assets, :boolean, null: false, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index a7a39df3..68fd2a64 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_02_13_143000) do +ActiveRecord::Schema[7.2].define(version: 2026_02_13_151000) do create_table "admins", force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false @@ -97,6 +97,7 @@ t.datetime "updated_at", null: false t.integer "assigned_to_id" t.text "notes" + t.boolean "has_descendant_assets", default: false, null: false t.index ["assigned_to_id"], name: "index_isilon_folders_on_assigned_to_id" t.index ["parent_folder_id"], name: "index_isilon_folders_on_parent_folder_id" t.index ["volume_id", "full_path"], name: "index_isilon_folders_on_volume_id_and_full_path", unique: true diff --git a/lib/tasks/isilon_import.rake b/lib/tasks/isilon_import.rake index 20747620..194e3532 100644 --- a/lib/tasks/isilon_import.rake +++ b/lib/tasks/isilon_import.rake @@ -47,6 +47,79 @@ namespace :sync do puts "Deleted #{count} asset(s)" end + desc "Backfill has_descendant_assets for folders" + task :refresh_folder_descendant_assets, [ :volume_name ] => :environment do |_t, args| + args.with_defaults(volume_name: nil) + + folders = IsilonFolder.all + assets = IsilonAsset.where.not(parent_folder_id: nil) + volume = nil + + if args[:volume_name].present? + volume = Volume.find_by("LOWER(name) = ?", args[:volume_name].to_s.downcase) + if volume.nil? + puts "Volume not found: #{args[:volume_name]}" + next + end + + folders = folders.where(volume_id: volume.id) + assets = assets.where(volume_id: volume.id) + puts "Refreshing folder descendant flags for volume: #{volume.name} (#{volume.id})" + else + puts "Refreshing folder descendant flags across all volumes" + end + + folders.update_all(has_descendant_assets: false) + + if assets.none? + puts "No assets found; all folders marked empty." + next + end + + volume_filter = volume ? " AND volume_id = #{volume.id}" : "" + folder_volume_filter = volume ? " AND f.volume_id = #{volume.id}" : "" + update_volume_filter = volume ? " AND volume_id = #{volume.id}" : "" + + sql = <<~SQL.squish + WITH RECURSIVE ancestors AS ( + SELECT parent_folder_id AS folder_id + FROM isilon_assets + WHERE parent_folder_id IS NOT NULL#{volume_filter} + UNION + SELECT f.parent_folder_id + FROM isilon_folders f + INNER JOIN ancestors a ON f.id = a.folder_id + WHERE f.parent_folder_id IS NOT NULL#{folder_volume_filter} + ) + UPDATE isilon_folders + SET has_descendant_assets = TRUE + WHERE id IN (SELECT DISTINCT folder_id FROM ancestors)#{update_volume_filter} + SQL + + ActiveRecord::Base.connection.execute(sql) + puts "Updated folder descendant flags." + end + + desc "Post-ingest housekeeping: cleanup folder-assets, refresh empty-folder flags, detect duplicates" + task :post_ingest, [ :volume_name ] => :environment do |_t, args| + args.with_defaults(volume_name: nil) + + volume_name = args[:volume_name] + + puts "Running post-ingest housekeeping..." + + Rake::Task["sync:cleanup_folder_assets"].reenable + Rake::Task["sync:cleanup_folder_assets"].invoke(volume_name) + + Rake::Task["sync:refresh_folder_descendant_assets"].reenable + Rake::Task["sync:refresh_folder_descendant_assets"].invoke(volume_name) + + Rake::Task["duplicates:detect"].reenable + Rake::Task["duplicates:detect"].invoke + + puts "Post-ingest housekeeping complete." + end + desc "Export TIFF rule matches without updating migration_status" task :tiffs_export, [ :output_path, :volume_name ] => :environment do |_t, args| args.with_defaults(output_path: nil, volume_name: nil) diff --git a/spec/serializers/isilon_folder_serializer_spec.rb b/spec/serializers/isilon_folder_serializer_spec.rb index 1c66f340..5a01fef6 100644 --- a/spec/serializers/isilon_folder_serializer_spec.rb +++ b/spec/serializers/isilon_folder_serializer_spec.rb @@ -10,4 +10,12 @@ expect(serialized[:notes]).to eq("Serializer note") end + + it "includes has_descendant_assets" do + folder = create(:isilon_folder, has_descendant_assets: true) + + serialized = described_class.new(folder).serializable_hash + + expect(serialized[:has_descendant_assets]).to be(true) + end end diff --git a/spec/system/folder_asset_rendering_spec.rb b/spec/system/folder_asset_rendering_spec.rb index 219cc2eb..becdc143 100644 --- a/spec/system/folder_asset_rendering_spec.rb +++ b/spec/system/folder_asset_rendering_spec.rb @@ -8,7 +8,18 @@ :isilon_folder, volume: volume, parent_folder: nil, - full_path: "RootFolder" + full_path: "RootFolder", + has_descendant_assets: true + ) + end + + let!(:empty_folder) do + create( + :isilon_folder, + volume: volume, + parent_folder: nil, + full_path: "EmptyFolder", + has_descendant_assets: false ) end @@ -68,4 +79,20 @@ expect(asset_row).to have_css("[data-colid='preservica_reference_id']") expect(asset_row).to have_css("input[type='checkbox']") end + + it "adds a class for empty folders" do + empty_title = page + .all(".wb-title", visible: true) + .find { |title| title.text == "EmptyFolder" } + + expect(empty_title).to be_present + expect(empty_title[:class]).to include("wb-title-empty") + + non_empty_title = page + .all(".wb-title", visible: true) + .find { |title| title.text == "RootFolder" } + + expect(non_empty_title).to be_present + expect(non_empty_title[:class]).not_to include("wb-title-empty") + end end diff --git a/spec/tasks/sync_rake_spec.rb b/spec/tasks/sync_rake_spec.rb index c0100143..55559822 100644 --- a/spec/tasks/sync_rake_spec.rb +++ b/spec/tasks/sync_rake_spec.rb @@ -10,7 +10,13 @@ end before(:each) do - Rake::Task['sync:assets'].reenable if Rake::Task.task_defined?('sync:assets') + if Rake::Task.task_defined?('sync:assets') + Rake::Task['sync:assets'].reenable + end + + if Rake::Task.task_defined?('sync:refresh_folder_descendant_assets') + Rake::Task['sync:refresh_folder_descendant_assets'].reenable + end end describe 'sync:assets' do @@ -30,4 +36,33 @@ end end end + + describe 'sync:refresh_folder_descendant_assets' do + it 'marks ancestors of assets and clears empty folders' do + volume = create(:volume, name: 'Test Volume') + root_folder = create(:isilon_folder, + volume: volume, + parent_folder: nil, + full_path: '/Root', + has_descendant_assets: true) + child_folder = create(:isilon_folder, + volume: volume, + parent_folder: root_folder, + full_path: '/Root/Child', + has_descendant_assets: false) + empty_folder = create(:isilon_folder, + volume: volume, + parent_folder: nil, + full_path: '/Empty', + has_descendant_assets: true) + + create(:isilon_asset, parent_folder: child_folder, volume: volume) + + Rake::Task['sync:refresh_folder_descendant_assets'].invoke + + expect(root_folder.reload.has_descendant_assets).to be(true) + expect(child_folder.reload.has_descendant_assets).to be(true) + expect(empty_folder.reload.has_descendant_assets).to be(false) + end + end end