soft-sort 1.0.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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +35 -0
  3. data/LICENSE +5 -0
  4. data/README.md +146 -0
  5. data/bin/soft-sort +6 -0
  6. data/config/default_rules.yml +74 -0
  7. data/lib/soft_sort/classification/ai_provider.rb +17 -0
  8. data/lib/soft_sort/classification/classification_result.rb +6 -0
  9. data/lib/soft_sort/classification/content_classifier.rb +38 -0
  10. data/lib/soft_sort/classification/context_classifier.rb +23 -0
  11. data/lib/soft_sort/classification/rule_matcher.rb +41 -0
  12. data/lib/soft_sort/classification/sensitive_file_detector.rb +14 -0
  13. data/lib/soft_sort/classification/type_classifier.rb +54 -0
  14. data/lib/soft_sort/cli.rb +167 -0
  15. data/lib/soft_sort/configuration.rb +72 -0
  16. data/lib/soft_sort/duplicates/duplicate_detector.rb +17 -0
  17. data/lib/soft_sort/errors.rb +7 -0
  18. data/lib/soft_sort/execution/executor.rb +66 -0
  19. data/lib/soft_sort/execution/recovery_manager.rb +87 -0
  20. data/lib/soft_sort/execution/undo_manager.rb +45 -0
  21. data/lib/soft_sort/metadata/image_metadata.rb +24 -0
  22. data/lib/soft_sort/planning/organization_planner.rb +115 -0
  23. data/lib/soft_sort/reporting/json_reporter.rb +7 -0
  24. data/lib/soft_sort/reporting/preview_formatter.rb +17 -0
  25. data/lib/soft_sort/reporting/statistics_reporter.rb +13 -0
  26. data/lib/soft_sort/scanning/directory_scanner.rb +69 -0
  27. data/lib/soft_sort/scanning/file_metadata.rb +6 -0
  28. data/lib/soft_sort/storage/root_lock.rb +21 -0
  29. data/lib/soft_sort/storage/transaction_journal.rb +56 -0
  30. data/lib/soft_sort/version.rb +5 -0
  31. data/lib/soft_sort/watching/directory_watcher.rb +42 -0
  32. data/lib/soft_sort.rb +36 -0
  33. metadata +75 -0
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ class Configuration
5
+ attr_reader :data
6
+
7
+ def initialize(data)
8
+ @data = data
9
+ validate!
10
+ end
11
+
12
+ def self.load(path = nil)
13
+ file = path || File.expand_path('../../config/default_rules.yml', __dir__)
14
+ new(YAML.safe_load_file(file, permitted_classes: [], aliases: false) || {})
15
+ rescue Psych::Exception, Errno::ENOENT => e
16
+ raise ConfigurationError, "invalid configuration: #{e.message}"
17
+ end
18
+
19
+ def categories = data.fetch('categories', {})
20
+ def context_rules = Array(data['context_rules'])
21
+ def projects = data.fetch('projects', {})
22
+ def exclusions = data.fetch('exclusions', {})
23
+ def organization = data.fetch('organization', {})
24
+
25
+ def extension_categories
26
+ categories.each_with_object({}) do |(name, cfg), h|
27
+ Array(cfg['extensions']).each do |e|
28
+ h[e.downcase] = [name, cfg['destination']]
29
+ end
30
+ end
31
+ end
32
+
33
+ def validate!
34
+ raise ConfigurationError, 'configuration must be a mapping' unless data.is_a?(Hash)
35
+
36
+ categories.each_value do |cfg|
37
+ unless cfg.is_a?(Hash) && safe_destination?(cfg['destination'].to_s)
38
+ raise ConfigurationError, 'category destination is invalid'
39
+ end
40
+ end
41
+ context_rules.each do |r|
42
+ unless r.is_a?(Hash) && r['name'] && Array(r['keywords']).any? && safe_destination?(r['destination'].to_s)
43
+ raise ConfigurationError, 'context rule is invalid'
44
+ end
45
+ end
46
+ projects.each_value do |project|
47
+ unless project.is_a?(Hash) && Array(project['aliases']).any? && safe_destination?(project['destination'].to_s)
48
+ raise ConfigurationError, 'project rule is invalid'
49
+ end
50
+ end
51
+ validate_organization!
52
+ end
53
+
54
+ def validate_organization!
55
+ date_format = organization.fetch('date_format', 'year_month')
56
+ date_source = organization.fetch('date_source', 'auto')
57
+ collision = organization.fetch('collision_policy', 'skip')
58
+ raise ConfigurationError, 'invalid date format' unless %w[year year_month year_month_day].include?(date_format)
59
+ raise ConfigurationError, 'invalid date source' unless %w[auto modified created captured].include?(date_source)
60
+ raise ConfigurationError, 'invalid collision policy' unless %w[skip review rename].include?(collision)
61
+ inspection = data.fetch('content_inspection', {})
62
+ limit = inspection.fetch('max_bytes', 1_048_576)
63
+ raise ConfigurationError, 'invalid content inspection size limit' unless limit.is_a?(Integer) && limit.positive?
64
+ ai = data.fetch('ai', {})
65
+ raise ConfigurationError, 'AI must be disabled or specify a provider' if ai.fetch('enabled', false) && ai.fetch('provider', 'none') == 'none'
66
+ end
67
+
68
+ def safe_destination?(value)
69
+ !value.empty? && !Pathname.new(value).absolute? && value.split(%r{[\\/]}).none? { |part| part == '..' }
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ class DuplicateDetector
5
+ def initialize(root)
6
+ @root = File.expand_path(root)
7
+ end
8
+
9
+ def find(files)
10
+ candidates = files.select(&:eligible).group_by(&:size).select { |_size, group| group.length > 1 }
11
+ candidates.values.flat_map do |group|
12
+ grouped = group.group_by { |file| Digest::SHA256.file(file.absolute_path).hexdigest }
13
+ grouped.values.select { |items| items.length > 1 }.map { |items| { hash: Digest::SHA256.file(items.first.absolute_path).hexdigest, files: items.map(&:relative_path), size: items.first.size } }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ class Error < StandardError; end
5
+ class ConfigurationError < Error; end
6
+ class ScanError < Error; end
7
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ class Executor
5
+ def initialize(root, journal: TransactionJournal.new(root))
6
+ @root = File.expand_path(root)
7
+ @journal = journal
8
+ end
9
+
10
+ def execute(items, apply: false)
11
+ RootLock.new(@root).synchronize do
12
+ operations = items.map { |item| operation(item) }
13
+ record = @journal.create(@root, operations)
14
+ return record unless apply
15
+
16
+ record['status'] = 'in_progress'
17
+ @journal.update(record)
18
+ operations.each do |operation|
19
+ move(operation) if operation['action'] == 'move'
20
+ @journal.update(record)
21
+ end
22
+ record['status'] = operations.any? { |op| op['status'] == 'failed' } ? 'failed' : 'completed'
23
+ @journal.update(record)
24
+ end
25
+ rescue SystemCallError => e
26
+ record['status'] = 'failed' if record
27
+ record['error'] = e.message if record
28
+ @journal.update(record) if record
29
+ raise Error, "organization failed: #{e.message}"
30
+ end
31
+
32
+ private
33
+
34
+ def operation(item)
35
+ source = File.join(@root, item[:source])
36
+ { 'source' => item[:source], 'destination' => item[:destination], 'action' => item[:action],
37
+ 'status' => item[:action] == 'move' ? 'pending' : 'skipped', 'sha256' => file_hash(source), 'size' => File.size(source) }
38
+ end
39
+
40
+ def move(op)
41
+ source = File.join(@root, op['source'])
42
+ destination = File.join(@root, op['destination'])
43
+ raise Error, 'source changed before move' unless File.file?(source) && file_hash(source) == op['sha256']
44
+ raise Error, 'destination already exists' if File.exist?(destination)
45
+ raise Error, 'destination escapes selected root' unless destination.start_with?(@root + File::SEPARATOR)
46
+
47
+ raise Error, 'source or destination is a symbolic link' if File.symlink?(source) || File.symlink?(destination)
48
+
49
+ FileUtils.mkdir_p(File.dirname(destination))
50
+ File.link(source, destination)
51
+ File.unlink(source)
52
+ op['status'] = 'moved'
53
+ rescue StandardError => e
54
+ op['status'] = 'failed'
55
+ op['error'] = e.message
56
+ end
57
+
58
+ def file_hash(path)
59
+ return nil unless File.file?(path)
60
+
61
+ digest = Digest::SHA256.new
62
+ File.open(path, 'rb') { |file| digest.update(file.read(1024 * 1024)) until file.eof? }
63
+ digest.hexdigest
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ class RecoveryManager
5
+ def initialize(root)
6
+ @root = File.expand_path(root)
7
+ @journal = TransactionJournal.new(@root)
8
+ end
9
+
10
+ def inspect(transaction_id: nil)
11
+ records = @journal.all.select { |record| record['root'] == @root && recoverable_transaction?(record) }
12
+ records = records.select { |record| record['id'] == transaction_id } if transaction_id
13
+ records.filter_map { |record| reconcile(record) }
14
+ end
15
+
16
+ def apply(transaction_id: nil)
17
+ RootLock.new(@root).synchronize do
18
+ inspect(transaction_id: transaction_id).map do |report|
19
+ recovered = retry_recoverable(report)
20
+ record = @journal.all.find { |item| item['id'] == report['transaction'] }
21
+ if record
22
+ recovered['operations'].each do |operation|
23
+ journal_operation = record['operations'].find { |item| item['source'] == operation['source'] }
24
+ journal_operation['status'] = 'moved' if journal_operation && operation['state'] == 'completed'
25
+ end
26
+ record['status'] = recovered['conflicts'].zero? ? 'recovered' : 'recovery_required'
27
+ @journal.update(record)
28
+ recovered['status'] = record['status']
29
+ end
30
+ recovered
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def recoverable_transaction?(record)
38
+ %w[planned prepared in_progress interrupted failed recovery_required partially_undone].include?(record['status'])
39
+ end
40
+
41
+ def reconcile(record)
42
+ states = record['operations'].map { |operation| reconcile_operation(operation) }
43
+ { 'transaction' => record['id'], 'status' => record['status'], 'operations' => states,
44
+ 'completed' => states.count { |item| item['state'] == 'completed' },
45
+ 'pending' => states.count { |item| item['state'] == 'pending' },
46
+ 'conflicts' => states.count { |item| item['state'] == 'conflict' },
47
+ 'recoverable' => states.count { |item| item['state'] == 'recoverable' } }
48
+ end
49
+
50
+ def reconcile_operation(operation)
51
+ return operation.merge('state' => 'skipped') unless operation['action'] == 'move'
52
+
53
+ source = File.join(@root, operation['source'])
54
+ destination = File.join(@root, operation['destination'])
55
+ return operation.merge('state' => 'unsafe') if File.symlink?(source) || File.symlink?(destination)
56
+ source_exists = File.file?(source)
57
+ destination_exists = File.file?(destination)
58
+ if source_exists && !destination_exists
59
+ return operation.merge('state' => Digest::SHA256.file(source).hexdigest == operation['sha256'] ? 'recoverable' : 'conflict')
60
+ end
61
+ return operation.merge('state' => 'completed') if !source_exists && destination_exists && hash(destination) == operation['sha256']
62
+ return operation.merge('state' => 'conflict') if source_exists && destination_exists
63
+
64
+ operation.merge('state' => 'recovery_required')
65
+ end
66
+
67
+ def retry_recoverable(report)
68
+ report['operations'].each do |operation|
69
+ next unless operation['state'] == 'recoverable'
70
+
71
+ source = File.join(@root, operation['source'])
72
+ destination = File.join(@root, operation['destination'])
73
+ FileUtils.mkdir_p(File.dirname(destination))
74
+ File.link(source, destination)
75
+ File.unlink(source)
76
+ operation['state'] = 'completed'
77
+ end
78
+ report
79
+ end
80
+
81
+ def hash(path)
82
+ Digest::SHA256.file(path).hexdigest
83
+ rescue SystemCallError
84
+ nil
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ class UndoManager
5
+ def initialize(root)
6
+ @root = File.expand_path(root)
7
+ @journal = TransactionJournal.new(@root)
8
+ end
9
+
10
+ def undo(transaction_id: nil, dry_run: false)
11
+ return undo_locked(transaction_id: transaction_id, dry_run: dry_run) if dry_run
12
+
13
+ RootLock.new(@root).synchronize { undo_locked(transaction_id: transaction_id, dry_run: false) }
14
+ end
15
+
16
+ private
17
+
18
+ def undo_locked(transaction_id:, dry_run:)
19
+ records = @journal.all.select { |record| record['root'] == @root }
20
+ record = transaction_id ? records.reverse.find { |item| item['id'] == transaction_id } : records.reverse.find { |item| item['status'] == 'completed' }
21
+ raise Error, 'transaction not found' unless record
22
+
23
+ results = record['operations'].reverse.map do |op|
24
+ next { 'source' => op['destination'], 'destination' => op['source'], 'status' => 'skipped' } unless op['status'] == 'moved'
25
+ from = File.join(@root, op['destination'])
26
+ to = File.join(@root, op['source'])
27
+ if dry_run
28
+ { 'source' => op['destination'], 'destination' => op['source'], 'status' => 'planned' }
29
+ elsif !File.file?(from) || File.exist?(to) || Digest::SHA256.file(from).hexdigest != op['sha256']
30
+ { 'source' => op['destination'], 'destination' => op['source'], 'status' => 'conflict' }
31
+ else
32
+ raise Error, 'undo source or destination is a symbolic link' if File.symlink?(from) || File.symlink?(to)
33
+
34
+ FileUtils.mkdir_p(File.dirname(to))
35
+ File.link(from, to)
36
+ File.unlink(from)
37
+ { 'source' => op['destination'], 'destination' => op['source'], 'status' => 'undone' }
38
+ end
39
+ end
40
+ record['status'] = results.any? { |item| item['status'] == 'conflict' } ? 'partially_undone' : 'undone'
41
+ @journal.update(record) unless dry_run
42
+ results
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ class ImageMetadata
5
+ def self.read(file, max_bytes: 65_536)
6
+ return nil unless %w[png jpg jpeg].include?(file.extension)
7
+
8
+ bytes = File.open(file.absolute_path, 'rb') { |stream| stream.read(max_bytes) }
9
+ return png_metadata(bytes) if file.extension == 'png'
10
+
11
+ nil
12
+ rescue SystemCallError
13
+ nil
14
+ end
15
+
16
+ def self.png_metadata(bytes)
17
+ signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].pack('C*')
18
+ return nil unless bytes && bytes.byteslice(0, 8) == signature && bytes.bytesize >= 20
19
+
20
+ { width: bytes.byteslice(16, 4).unpack1('N'), height: bytes.byteslice(20, 4).unpack1('N'), capture_time: nil }
21
+ end
22
+ private_class_method :png_metadata
23
+ end
24
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ class OrganizationPlanner
5
+ def initialize(root, config)
6
+ @root = File.expand_path(root)
7
+ @matcher = RuleMatcher.new(config)
8
+ @protect_existing = config.data.fetch('safety', {}).fetch('protect_existing_project_structures', true)
9
+ @organization = config.organization
10
+ end
11
+
12
+ def plan(files)
13
+ results = files.select(&:eligible).map { |file| build(file) }
14
+ apply_collision_policy(results)
15
+ destinations = results.group_by { |item| item[:destination] }.transform_values(&:length)
16
+ results.each do |result|
17
+ result[:action] = 'conflict' if destinations[result[:destination]] > 1 || existing_destination?(result)
18
+ end
19
+ results
20
+ end
21
+
22
+ private
23
+
24
+ def existing_destination?(result)
25
+ File.exist?(File.join(@root, result[:destination])) && result[:source] != result[:destination]
26
+ end
27
+
28
+ def build(file)
29
+ classification = @matcher.classify(file)
30
+ destination_root = date_destination(classification.destination, file)
31
+ destination = Pathname.new(File.join(destination_root, file.filename)).cleanpath.to_s
32
+ raise ConfigurationError, 'destination escapes selected root' if destination == '..' || destination.start_with?('../')
33
+
34
+ action = if destination == file.relative_path
35
+ 'skip'
36
+ elsif @protect_existing && file.parent_directory != '.'
37
+ 'skip'
38
+ elsif file.sensitive_reason
39
+ 'review'
40
+ elsif classification.requires_review
41
+ 'review'
42
+ else
43
+ 'move'
44
+ end
45
+ { source: file.relative_path, destination: destination, type_category: classification.type_category,
46
+ context_category: classification.context_category, matched_rule: classification.matched_rule,
47
+ confidence: classification.confidence, action: action,
48
+ warning: warning_for(file, classification) }
49
+ end
50
+
51
+ def date_destination(destination, file)
52
+ return destination unless @organization.fetch('date_folders', false)
53
+
54
+ timestamp = case @organization.fetch('date_source', 'auto')
55
+ when 'created' then file.created_at
56
+ when 'modified' then file.modified_at
57
+ else file.created_at || file.modified_at
58
+ end
59
+ return destination unless timestamp.respond_to?(:year)
60
+
61
+ parts = case @organization.fetch('date_format', 'year_month')
62
+ when 'year' then [timestamp.year]
63
+ when 'year_month_day' then [timestamp.year, format('%02d', timestamp.month), format('%02d', timestamp.day)]
64
+ else [timestamp.year, format('%02d', timestamp.month)]
65
+ end
66
+ File.join(destination, *parts.map(&:to_s))
67
+ end
68
+
69
+ def apply_collision_policy(results)
70
+ used = {}
71
+ results.each do |result|
72
+ collision = result[:action] == 'move' && (File.exist?(File.join(@root, result[:destination])) || used.key?(result[:destination].downcase))
73
+ unless collision
74
+ used[result[:destination].downcase] = true
75
+ next
76
+ end
77
+
78
+ case @organization.fetch('collision_policy', 'skip')
79
+ when 'rename'
80
+ result[:destination] = available_name(result[:destination], used)
81
+ when 'review'
82
+ result[:action] = 'review'
83
+ result[:warning] = 'destination collision requires review'
84
+ end
85
+ used[result[:destination].downcase] = true
86
+ end
87
+ end
88
+
89
+ def available_name(destination, used)
90
+ return destination unless used.key?(destination.downcase) || File.exist?(File.join(@root, destination))
91
+
92
+ extension = File.extname(destination)
93
+ stem = destination.delete_suffix(extension)
94
+ index = 1
95
+ candidate = "#{stem} (#{index})#{extension}"
96
+ while used.key?(candidate.downcase) || File.exist?(File.join(@root, candidate))
97
+ index += 1
98
+ candidate = "#{stem} (#{index})#{extension}"
99
+ end
100
+ candidate
101
+ end
102
+
103
+ def warning_for(file, classification)
104
+ return file.sensitive_reason if file.sensitive_reason
105
+ return 'existing directory structure protected' if protected_structure?(file)
106
+ return 'classification requires review' if classification.requires_review
107
+
108
+ nil
109
+ end
110
+
111
+ def protected_structure?(file)
112
+ @protect_existing && file.parent_directory != '.'
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ class JsonReporter
5
+ def self.dump(object) = JSON.generate(object)
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ class PreviewFormatter
5
+ def self.scan(report)
6
+ "Files: #{report[:total]} | Eligible: #{report[:eligible]} | Excluded: #{report[:excluded]} | Size: #{report[:bytes]} bytes\n" + report[:categories].map { |k, v|
7
+ " #{k}: #{v}"
8
+ }.join("\n")
9
+ end
10
+
11
+ def self.plan(items)
12
+ items.map do |i|
13
+ "#{i[:action].upcase.ljust(8)} #{i[:source]} -> #{i[:destination]} (#{i[:type_category]}, #{i[:confidence]})"
14
+ end.join("\n")
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ class StatisticsReporter
5
+ def self.build(scan, plan, duplicates: [])
6
+ { total_files: scan[:files].length, eligible_files: scan[:files].count(&:eligible), total_bytes: scan[:files].select(&:eligible).sum(&:size),
7
+ planned_moves: plan.count { |item| item[:action] == 'move' }, conflicts: plan.count { |item| item[:action] == 'conflict' },
8
+ reviews: plan.count { |item| item[:action] == 'review' }, duplicate_groups: duplicates.length,
9
+ categories: plan.group_by { |item| item[:type_category] }.transform_values(&:length),
10
+ contexts: plan.group_by { |item| item[:context_category] || 'Unclassified' }.transform_values(&:length) }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ class DirectoryScanner
5
+ DEFAULT_EXCLUDED = %w[.git node_modules .venv vendor/bundle __pycache__ .soft-sort].freeze
6
+ DEFAULT_TEMP = %w[part crdownload download tmp].freeze
7
+ attr_reader :root
8
+
9
+ def initialize(root, config:, recursive: false)
10
+ expanded = root.to_s.start_with?('~') ? File.expand_path(root) : root.to_s
11
+ @root = File.expand_path(expanded)
12
+ raise ScanError, "directory does not exist: #{root}" unless File.exist?(@root)
13
+ raise ScanError, "not a directory: #{root}" unless File.directory?(@root)
14
+
15
+ @config = config
16
+ @recursive = recursive
17
+ end
18
+
19
+ def scan
20
+ files = []
21
+ warnings = []
22
+ paths = @recursive ? Dir.glob(File.join(@root, '**', '*'), File::FNM_DOTMATCH) : Dir.children(@root).map { |n| File.join(@root, n) }
23
+ paths.each do |path|
24
+ next unless File.file?(path) || File.symlink?(path)
25
+
26
+ rel = Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
27
+ begin
28
+ reason = exclusion_reason(rel, path)
29
+ stat = File.lstat(path)
30
+ files << FileMetadata.new(absolute_path: path, relative_path: rel, filename: File.basename(path),
31
+ extension: File.extname(path).delete_prefix('.').downcase, size: stat.file? ? stat.size : 0,
32
+ modified_at: stat.mtime, created_at: birthtime(stat),
33
+ parent_directory: File.dirname(rel), eligible: reason.nil?, exclusion_reason: reason,
34
+ sensitive_reason: SensitiveFileDetector.reason(File.basename(path)))
35
+ rescue SystemCallError => e
36
+ warnings << "#{rel}: #{e.message}"
37
+ end
38
+ end
39
+ { files: files, warnings: warnings }
40
+ rescue SystemCallError => e
41
+ raise ScanError, "unable to scan #{@root}: #{e.message}"
42
+ end
43
+
44
+ private
45
+
46
+ def birthtime(stat)
47
+ stat.birthtime
48
+ rescue NotImplementedError, SystemCallError
49
+ nil
50
+ end
51
+
52
+ def exclusion_reason(rel, path)
53
+ return 'symbolic link' if File.symlink?(path)
54
+
55
+ parts = rel.split(File::SEPARATOR)
56
+ return 'hidden file or directory' if !@config.data.dig('safety', 'include_hidden') && parts.any? do |p|
57
+ p.start_with?('.')
58
+ end
59
+
60
+ dirs = DEFAULT_EXCLUDED + Array(@config.exclusions['directories'])
61
+ return 'excluded directory' if parts[0...-1].any? { |p| dirs.include?(p) }
62
+ if (Array(@config.exclusions['extensions']).map(&:downcase) + DEFAULT_TEMP).include?(File.extname(path).delete_prefix('.').downcase)
63
+ return 'temporary or incomplete download'
64
+ end
65
+
66
+ nil
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ FileMetadata = Struct.new(:absolute_path, :relative_path, :filename, :extension, :size, :modified_at, :created_at,
5
+ :parent_directory, :eligible, :exclusion_reason, :sensitive_reason, keyword_init: true)
6
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ class RootLock
5
+ def initialize(root)
6
+ @path = File.join(File.expand_path(root), '.soft-sort', 'root.lock')
7
+ end
8
+
9
+ def synchronize
10
+ FileUtils.mkdir_p(File.dirname(@path))
11
+ File.open(@path, File::RDWR | File::CREAT, 0o600) do |lock|
12
+ lock.flock(File::LOCK_EX)
13
+ yield
14
+ ensure
15
+ lock&.flock(File::LOCK_UN)
16
+ end
17
+ rescue SystemCallError => e
18
+ raise Error, "unable to lock selected root: #{e.message}"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ class TransactionJournal
5
+ def initialize(root)
6
+ @directory = File.join(File.expand_path(root), '.soft-sort')
7
+ @path = File.join(@directory, 'transactions.jsonl')
8
+ @warnings = []
9
+ end
10
+
11
+ attr_reader :warnings
12
+
13
+ def create(root, operations)
14
+ record = { 'id' => SecureRandom.uuid, 'root' => File.expand_path(root), 'started_at' => Time.now.utc.iso8601,
15
+ 'completed_at' => nil, 'status' => 'planned', 'operations' => operations }
16
+ append(record)
17
+ record
18
+ end
19
+
20
+ def update(record)
21
+ record['completed_at'] = Time.now.utc.iso8601 if %w[completed failed undone partially_undone].include?(record['status'])
22
+ append(record)
23
+ record
24
+ end
25
+
26
+ def all
27
+ return [] unless File.file?(@path)
28
+
29
+ records = {}
30
+ File.foreach(@path).with_index(1) do |line, line_number|
31
+ begin
32
+ record = JSON.parse(line)
33
+ records[record['id']] = record
34
+ rescue JSON::ParserError => e
35
+ @warnings << "line #{line_number}: malformed journal record (#{e.message})"
36
+ end
37
+ end
38
+ records.values
39
+ end
40
+
41
+ private
42
+
43
+ def append(record)
44
+ FileUtils.mkdir_p(@directory)
45
+ File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o600) do |file|
46
+ file.flock(File::LOCK_EX)
47
+ file.write(JSON.generate(record))
48
+ file.write("\n")
49
+ file.flush
50
+ file.fsync
51
+ ensure
52
+ file&.flock(File::LOCK_UN)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SoftSort
4
+ VERSION = '1.0.0'
5
+ end