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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE +5 -0
- data/README.md +146 -0
- data/bin/soft-sort +6 -0
- data/config/default_rules.yml +74 -0
- data/lib/soft_sort/classification/ai_provider.rb +17 -0
- data/lib/soft_sort/classification/classification_result.rb +6 -0
- data/lib/soft_sort/classification/content_classifier.rb +38 -0
- data/lib/soft_sort/classification/context_classifier.rb +23 -0
- data/lib/soft_sort/classification/rule_matcher.rb +41 -0
- data/lib/soft_sort/classification/sensitive_file_detector.rb +14 -0
- data/lib/soft_sort/classification/type_classifier.rb +54 -0
- data/lib/soft_sort/cli.rb +167 -0
- data/lib/soft_sort/configuration.rb +72 -0
- data/lib/soft_sort/duplicates/duplicate_detector.rb +17 -0
- data/lib/soft_sort/errors.rb +7 -0
- data/lib/soft_sort/execution/executor.rb +66 -0
- data/lib/soft_sort/execution/recovery_manager.rb +87 -0
- data/lib/soft_sort/execution/undo_manager.rb +45 -0
- data/lib/soft_sort/metadata/image_metadata.rb +24 -0
- data/lib/soft_sort/planning/organization_planner.rb +115 -0
- data/lib/soft_sort/reporting/json_reporter.rb +7 -0
- data/lib/soft_sort/reporting/preview_formatter.rb +17 -0
- data/lib/soft_sort/reporting/statistics_reporter.rb +13 -0
- data/lib/soft_sort/scanning/directory_scanner.rb +69 -0
- data/lib/soft_sort/scanning/file_metadata.rb +6 -0
- data/lib/soft_sort/storage/root_lock.rb +21 -0
- data/lib/soft_sort/storage/transaction_journal.rb +56 -0
- data/lib/soft_sort/version.rb +5 -0
- data/lib/soft_sort/watching/directory_watcher.rb +42 -0
- data/lib/soft_sort.rb +36 -0
- 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,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,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,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
|