tidy-file-organizer 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.
@@ -0,0 +1,57 @@
1
+ module TidyFileOrganizer
2
+ module FileHelper
3
+ BYTE_UNITS = %w[B KB MB GB TB].freeze
4
+ BYTES_PER_UNIT = 1024.0
5
+
6
+ # Collect files
7
+ def collect_files(target_dir, recursive: false, exclude_dirs: [])
8
+ if recursive
9
+ collect_files_recursively(target_dir, exclude_dirs)
10
+ else
11
+ collect_files_in_directory(target_dir)
12
+ end
13
+ end
14
+
15
+ # Get relative path
16
+ def relative_path(file_path, base_dir)
17
+ file_path.sub("#{base_dir}/", '')
18
+ end
19
+
20
+ # Convert file size to human-readable format
21
+ def human_readable_size(size)
22
+ unit_index = 0
23
+ size_float = size.to_f
24
+
25
+ while size_float >= BYTES_PER_UNIT && unit_index < BYTE_UNITS.size - 1
26
+ size_float /= BYTES_PER_UNIT
27
+ unit_index += 1
28
+ end
29
+
30
+ format('%.2f %s', size_float, BYTE_UNITS[unit_index])
31
+ end
32
+
33
+ private
34
+
35
+ def collect_files_in_directory(target_dir)
36
+ Dir.children(target_dir)
37
+ .map { |entry| File.join(target_dir, entry) }
38
+ .select { |path| File.file?(path) }
39
+ end
40
+
41
+ def collect_files_recursively(target_dir, exclude_dirs)
42
+ pattern = File.join(target_dir, '**', '*')
43
+ Dir.glob(pattern).select do |path|
44
+ File.file?(path) && !excluded_path?(path, target_dir, exclude_dirs)
45
+ end
46
+ end
47
+
48
+ def excluded_path?(path, target_dir, exclude_dirs)
49
+ return false if exclude_dirs.empty?
50
+
51
+ relative = path.sub("#{target_dir}/", '')
52
+ path_parts = relative.split('/')
53
+
54
+ exclude_dirs.any? { |dir| path_parts.include?(dir) }
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,57 @@
1
+ require 'fileutils'
2
+
3
+ module TidyFileOrganizer
4
+ class FileMover
5
+ attr_reader :target_dir
6
+
7
+ def initialize(target_dir)
8
+ @target_dir = File.expand_path(target_dir)
9
+ end
10
+
11
+ # Move file to specified directory
12
+ def move_file(file_path, dest_dir_name, dry_run: false)
13
+ filename = File.basename(file_path)
14
+ relative = relative_path(file_path)
15
+ dest_dir = File.join(@target_dir, dest_dir_name)
16
+ dest_path = File.join(dest_dir, filename)
17
+
18
+ return handle_conflict(relative, dest_dir_name, dry_run) if conflicting?(file_path, dest_path)
19
+ return handle_skip(relative, dry_run) if already_in_place?(file_path, dest_path)
20
+
21
+ perform_move(file_path, dest_dir, dest_path, relative, dest_dir_name, dry_run)
22
+ end
23
+
24
+ private
25
+
26
+ def relative_path(file_path)
27
+ file_path.sub("#{@target_dir}/", '')
28
+ end
29
+
30
+ def conflicting?(file_path, dest_path)
31
+ File.exist?(dest_path) && file_path != dest_path
32
+ end
33
+
34
+ def already_in_place?(file_path, dest_path)
35
+ file_path == dest_path
36
+ end
37
+
38
+ def handle_conflict(relative, dest_dir_name, dry_run)
39
+ message = "⚠️ Conflict: #{relative} -> #{dest_dir_name}/ (filename conflict)"
40
+ puts dry_run ? "[Dry-run] #{message}" : message
41
+ end
42
+
43
+ def handle_skip(relative, dry_run)
44
+ puts I18n.t('organizer.skip', file: relative) if dry_run
45
+ end
46
+
47
+ def perform_move(file_path, dest_dir, dest_path, relative, dest_dir_name, dry_run)
48
+ if dry_run
49
+ puts "[Dry-run] #{relative} -> #{dest_dir_name}/"
50
+ else
51
+ FileUtils.mkdir_p(dest_dir)
52
+ FileUtils.mv(file_path, dest_path)
53
+ puts "Moved: #{relative} -> #{dest_dir_name}/"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,43 @@
1
+ require 'yaml'
2
+
3
+ module TidyFileOrganizer
4
+ module I18n
5
+ class << self
6
+ def locale
7
+ @locale ||= detect_locale
8
+ end
9
+
10
+ def locale=(value)
11
+ @locale = value
12
+ end
13
+
14
+ def t(key, **options)
15
+ translations = load_translations(locale)
16
+ text = translations.dig(*key.to_s.split('.')) || key.to_s
17
+
18
+ # Variable substitution
19
+ options.each do |k, v|
20
+ text = text.gsub("%{#{k}}", v.to_s)
21
+ end
22
+
23
+ text
24
+ end
25
+
26
+ private
27
+
28
+ def detect_locale
29
+ lang = ENV['LANG'].to_s
30
+ lang.start_with?('ja') ? :ja : :en
31
+ end
32
+
33
+ def load_translations(locale)
34
+ @translations ||= {}
35
+ @translations[locale] ||= begin
36
+ locale_file = File.expand_path("locale/#{locale}.yml", __dir__)
37
+ yaml_data = YAML.load_file(locale_file)
38
+ yaml_data[locale.to_s]
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,110 @@
1
+ en:
2
+ setup:
3
+ title: tidy-file-organizer Setup
4
+ separator: "============================================================"
5
+ section_separator: "------------------------------------------------------------"
6
+ target_directory: "Target directory: %{dir}"
7
+ language_setting: "[0] Folder Name Language Setting"
8
+ language_description: Choose the language for organized folder names
9
+ language_option_1: " 1: English (e.g., Images, Documents, Screenshots)"
10
+ language_option_2: " 2: Japanese (e.g., 画像, 書類, スクリーンショット)"
11
+ current_setting: "Current setting: %{setting}"
12
+ language_prompt: "Choose (1=English, 2=Japanese, Enter=keep current): "
13
+ invalid_input: Invalid input. Using default (English).
14
+ extensions_title: "[1] File Extension Rules"
15
+ extensions_description: Specify destination folders based on file extensions
16
+ keywords_title: "[2] Keyword Rules"
17
+ keywords_description: Specify destination folders based on keywords in filenames
18
+ keywords_note: "※Keywords have priority over extensions"
19
+ input_format: "Input format: extension_list:folder_name extension_list:folder_name ..."
20
+ default_values: "Default values:"
21
+ current_config: "Current config: %{config}"
22
+ new_config_prompt: "Enter new config (Enter to use defaults): "
23
+ config_saved: "✓ Configuration saved"
24
+ save_location: " Location: %{path}"
25
+ next_steps: "Next steps:"
26
+ step_dry_run: " 1. Dry-run: tidyify run %{dir} --dry-run"
27
+ step_execute: " 2. Execute: tidyify run %{dir}"
28
+ none: none
29
+ english: English
30
+ japanese: Japanese
31
+
32
+ organizer:
33
+ no_config: Configuration not found. Please run 'setup' command first.
34
+ starting: "--- Starting organization (%{dir}) %{mode} ---"
35
+ dry_run_mode: "[Dry-run mode]"
36
+ recursive_mode: "[Recursive mode]"
37
+ moved: "Moved: %{file} -> %{dest}"
38
+ dry_run_moved: "[Dry-run] %{file} -> %{dest}"
39
+ cleaned_up: "Cleaned up: %{dir} (removed empty directory)"
40
+ no_files: No files to organize.
41
+ completed: Organization completed.
42
+ skip: "[Skip] %{file} (already in correct location)"
43
+
44
+ date_organizer:
45
+ starting: "--- Starting date-based organization (%{dir}) %{mode} ---"
46
+ pattern: "Pattern: %{pattern}"
47
+ invalid_pattern: "Invalid pattern: %{pattern}. Use year, year-month, or year-month-day."
48
+
49
+ duplicate_detector:
50
+ starting: "--- Starting duplicate detection (%{dir}) ---"
51
+ file_count: "File count: %{count}"
52
+ calculating: Calculating hashes...
53
+ progress: "Progress: %{current}/%{total}"
54
+ result_title: "=== Duplicate Detection Results ==="
55
+ duplicate_groups: "Duplicate groups: %{count}"
56
+ duplicate_files: "Duplicate files: %{count}"
57
+ no_duplicates: No duplicates found.
58
+ group_title: "--- Group %{num} (%{count} files, hash: %{hash}...) ---"
59
+ file_size: "File size: %{size}"
60
+ wasted_space: "Wasted space: %{size}"
61
+ total_wasted: "Total wasted space: %{size}"
62
+ deletion_starting: "--- Starting duplicate deletion %{mode} ---"
63
+ deletion_plan: "--- Deletion Plan ---"
64
+ will_keep: "Keep: %{file}"
65
+ will_delete: "Delete: %{file} (%{size})"
66
+ kept: "Keep: %{file}"
67
+ deleted: "Delete: %{file} (%{size})"
68
+ confirm_deletion: "Delete these files? [yes/no]: "
69
+ deletion_cancelled: Deletion cancelled.
70
+ invalid_response: Invalid response. Please enter yes or no.
71
+ summary: "--- Summary ---"
72
+ deleted_count: "Deleted files: %{count}"
73
+ saved_space: "Saved disk space: %{size}"
74
+ confirm_header: Duplicate File Deletion Confirmation
75
+ confirm_separator: "============================================================"
76
+ files_to_delete: "Files to delete: %{count}"
77
+ space_to_save: "Disk space to save: %{size}"
78
+ files_list_title: "Files to delete:"
79
+ kept_file: " Kept file: %{file}"
80
+ executing_deletion: Executing deletion...
81
+
82
+ cli:
83
+ error_directory_required: "Error: Please specify target directory"
84
+ usage: "Usage: tidyify [command] [target_directory] [options]"
85
+ commands: "Commands:"
86
+ cmd_setup: " setup Set up organization rules interactively (defaults to current dir)"
87
+ cmd_run: " run Organize files based on configuration (defaults to current dir)"
88
+ cmd_organize_date: " organize-by-date Organize files by modification date"
89
+ cmd_find_dup: " find-duplicates Find duplicate files"
90
+ cmd_remove_dup: " remove-duplicates Remove duplicate files (keep first one)"
91
+ options: "Options:"
92
+ opt_dry_run: " --dry-run Simulate without actual execution"
93
+ opt_recursive: " --recursive, -r Process files in subdirectories recursively"
94
+ opt_pattern: " --pattern=<pattern> Date pattern (year, year-month, year-month-day)"
95
+ opt_no_confirm: " --no-confirm Skip confirmation before deletion (remove-duplicates only)"
96
+ examples: "Examples:"
97
+ ex_setup: |2
98
+ tidyify setup # Set up current directory
99
+ tidyify setup ~/Downloads # Set up specific directory
100
+ ex_run_current: " tidyify run # Organize current directory"
101
+ ex_run_dry: " tidyify run ~/Downloads --dry-run # Dry-run"
102
+ ex_run_exec: " tidyify run ~/Downloads --recursive # Execute"
103
+ ex_organize_date: " tidyify organize-by-date ~/Downloads --pattern=year-month"
104
+ ex_find_dup: " tidyify find-duplicates ~/Downloads --recursive"
105
+ ex_remove_dup: " tidyify remove-duplicates ~/Downloads --recursive"
106
+ ex_remove_no_confirm: " tidyify remove-duplicates ~/Downloads --no-confirm"
107
+
108
+ post_install:
109
+ created_default_en: "✓ Created default config file (English): %{path}"
110
+ created_default_ja: "✓ Created default config file (Japanese): %{path}"
@@ -0,0 +1,110 @@
1
+ ja:
2
+ setup:
3
+ title: tidy-file-organizer セットアップ
4
+ separator: "============================================================"
5
+ section_separator: "------------------------------------------------------------"
6
+ target_directory: "対象ディレクトリ: %{dir}"
7
+ language_setting: "[0] フォルダ名の言語設定"
8
+ language_description: 整理先フォルダ名を日本語にするか英語にするか選択します
9
+ language_option_1: " 1: English (例: Images, Documents, Screenshots)"
10
+ language_option_2: " 2: 日本語 (例: 画像, 書類, スクリーンショット)"
11
+ current_setting: "現在の設定: %{setting}"
12
+ language_prompt: "選択 (1=English, 2=日本語, Enter=現在の設定のまま): "
13
+ invalid_input: 無効な入力です。デフォルト(English)を使用します。
14
+ extensions_title: "[1] 拡張子による整理ルール"
15
+ extensions_description: ファイルの拡張子に基づいて整理先フォルダを指定します
16
+ keywords_title: "[2] キーワードによる整理ルール"
17
+ keywords_description: ファイル名に含まれるキーワードで整理先フォルダを指定します
18
+ keywords_note: "※キーワードは拡張子より優先されます"
19
+ input_format: "入力形式: 拡張子リスト:フォルダ名 拡張子リスト:フォルダ名 ..."
20
+ default_values: "デフォルト値:"
21
+ current_config: "現在の設定: %{config}"
22
+ new_config_prompt: "新しい設定を入力 (デフォルト値を使う場合はEnter): "
23
+ config_saved: "✓ 設定を保存しました"
24
+ save_location: " 保存先: %{path}"
25
+ next_steps: "次のステップ:"
26
+ step_dry_run: " 1. シミュレーション: tidyify run %{dir} --dry-run"
27
+ step_execute: " 2. 実際に整理を実行: tidyify run %{dir}"
28
+ none: なし
29
+ english: English
30
+ japanese: 日本語
31
+
32
+ organizer:
33
+ no_config: 設定が見つかりません。先に 'setup' コマンドを実行してください。
34
+ starting: "--- 整理を開始します (%{dir}) %{mode} ---"
35
+ dry_run_mode: "[Dry-run モード]"
36
+ recursive_mode: "[再帰モード]"
37
+ moved: "Moved: %{file} -> %{dest}"
38
+ dry_run_moved: "[Dry-run] %{file} -> %{dest}"
39
+ cleaned_up: "Cleaned up: %{dir} (空ディレクトリを削除)"
40
+ no_files: 整理対象のファイルが見つかりませんでした。
41
+ completed: 整理が完了しました。
42
+ skip: "[Skip] %{file} (既に正しい場所にあります)"
43
+
44
+ date_organizer:
45
+ starting: "--- 日付ベースの整理を開始します (%{dir}) %{mode} ---"
46
+ pattern: "整理パターン: %{pattern}"
47
+ invalid_pattern: "無効なパターン: %{pattern}。year, year-month, year-month-day のいずれかを使用してください。"
48
+
49
+ duplicate_detector:
50
+ starting: "--- 重複ファイルの検出を開始します (%{dir}) ---"
51
+ file_count: "ファイル数: %{count}"
52
+ calculating: ハッシュ値を計算中...
53
+ progress: "進捗: %{current}/%{total}"
54
+ result_title: "=== 重複ファイルの検出結果 ==="
55
+ duplicate_groups: "重複グループ数: %{count}"
56
+ duplicate_files: "重複ファイル数: %{count}"
57
+ no_duplicates: 重複ファイルは見つかりませんでした。
58
+ group_title: "--- グループ %{num} (%{count} 件, ハッシュ: %{hash}...) ---"
59
+ file_size: "ファイルサイズ: %{size}"
60
+ wasted_space: "無駄な容量: %{size}"
61
+ total_wasted: "合計無駄容量: %{size}"
62
+ deletion_starting: "--- 重複ファイルの削除を開始します %{mode} ---"
63
+ deletion_plan: "--- 削除計画 ---"
64
+ will_keep: "保持: %{file}"
65
+ will_delete: "削除: %{file} (%{size})"
66
+ kept: "保持: %{file}"
67
+ deleted: "削除: %{file} (%{size})"
68
+ confirm_deletion: "これらのファイルを削除しますか? [yes/no]: "
69
+ deletion_cancelled: 削除をキャンセルしました。
70
+ invalid_response: 無効な応答です。yes か no を入力してください。
71
+ summary: "--- サマリー ---"
72
+ deleted_count: "削除されたファイル数: %{count}"
73
+ saved_space: "節約されたディスク容量: %{size}"
74
+ confirm_header: 重複ファイル削除の確認
75
+ confirm_separator: "============================================================"
76
+ files_to_delete: "削除対象のファイル数: %{count}"
77
+ space_to_save: "節約されるディスク容量: %{size}"
78
+ files_list_title: "削除対象のファイル:"
79
+ kept_file: " 保持されるファイル: %{file}"
80
+ executing_deletion: 削除を実行します...
81
+
82
+ cli:
83
+ error_directory_required: "エラー: 対象ディレクトリを指定してください"
84
+ usage: "Usage: tidyify [command] [target_directory] [options]"
85
+ commands: "Commands:"
86
+ cmd_setup: " setup 整理ルールをインタラクティブに設定します(ディレクトリ省略時はカレントディレクトリ)"
87
+ cmd_run: " run 設定に基づいてファイルを整理します(ディレクトリ省略時はカレントディレクトリ)"
88
+ cmd_organize_date: " organize-by-date ファイルを更新日時ベースで整理します"
89
+ cmd_find_dup: " find-duplicates 重複ファイルを検出します"
90
+ cmd_remove_dup: " remove-duplicates 重複ファイルを削除します(最初のファイルを保持)"
91
+ options: "Options:"
92
+ opt_dry_run: " --dry-run 実際には実行せず、シミュレーションのみ行います"
93
+ opt_recursive: " --recursive, -r サブディレクトリ内のファイルも再帰的に処理します"
94
+ opt_pattern: " --pattern=<pattern> 日付整理のパターン (year, year-month, year-month-day)"
95
+ opt_no_confirm: " --no-confirm 削除前の確認をスキップします(remove-duplicatesのみ)"
96
+ examples: "Examples:"
97
+ ex_setup: |2
98
+ tidyify setup # Setup current directory
99
+ tidyify setup ~/Downloads # Setup specific directory
100
+ ex_run_current: " tidyify run # Organize current directory"
101
+ ex_run_dry: " tidyify run ~/Downloads --dry-run # Dry-run simulation"
102
+ ex_run_exec: " tidyify run ~/Downloads --recursive # Execute with recursive"
103
+ ex_organize_date: " tidyify organize-by-date ~/Downloads --pattern=year-month"
104
+ ex_find_dup: " tidyify find-duplicates ~/Downloads --recursive"
105
+ ex_remove_dup: " tidyify remove-duplicates ~/Downloads --recursive"
106
+ ex_remove_no_confirm: " tidyify remove-duplicates ~/Downloads --no-confirm"
107
+
108
+ post_install:
109
+ created_default_en: "✓ デフォルト設定ファイル(英語)を作成しました: %{path}"
110
+ created_default_ja: "✓ デフォルト設定ファイル(日本語)を作成しました: %{path}"
@@ -0,0 +1,117 @@
1
+ require 'fileutils'
2
+
3
+ module TidyFileOrganizer
4
+ class Organizer
5
+ include FileHelper
6
+
7
+ attr_reader :target_dir
8
+
9
+ def initialize(target_dir)
10
+ @target_dir = File.expand_path(target_dir)
11
+ @config_manager = Config.new(@target_dir)
12
+ @file_mover = FileMover.new(@target_dir)
13
+ @organized_dirs = []
14
+ end
15
+
16
+ def setup
17
+ SetupPrompt.new(@config_manager).run(@target_dir)
18
+ end
19
+
20
+ def run(dry_run: false, recursive: false)
21
+ config = @config_manager.load
22
+ return handle_missing_config unless config
23
+
24
+ print_header(dry_run, recursive)
25
+
26
+ @organized_dirs = extract_organized_dirs(config)
27
+
28
+ files = collect_files(@target_dir, recursive: recursive, exclude_dirs: @organized_dirs)
29
+ return handle_empty_files if files.empty?
30
+
31
+ organize_files(files, config, dry_run)
32
+ cleanup_empty_directories if recursive && !dry_run
33
+
34
+ print_completion_message
35
+ end
36
+
37
+ private
38
+
39
+ def handle_missing_config
40
+ puts I18n.t('organizer.no_config')
41
+ end
42
+
43
+ def print_header(dry_run, recursive)
44
+ mode_label = dry_run ? I18n.t('organizer.dry_run_mode') : ''
45
+ recursive_label = recursive ? I18n.t('organizer.recursive_mode') : ''
46
+ puts I18n.t('organizer.starting', dir: @target_dir, mode: "#{mode_label} #{recursive_label}")
47
+ end
48
+
49
+ def extract_organized_dirs(config)
50
+ (config[:extensions].keys + config[:keywords].keys).uniq
51
+ end
52
+
53
+ def handle_empty_files
54
+ puts I18n.t('organizer.no_files')
55
+ end
56
+
57
+ def print_completion_message
58
+ puts "\n#{I18n.t('organizer.completed')}"
59
+ end
60
+
61
+ def organize_files(files, config, dry_run)
62
+ files.each do |file_path|
63
+ destination_dir = determine_destination(file_path, config)
64
+ next unless destination_dir
65
+
66
+ @file_mover.move_file(file_path, destination_dir, dry_run: dry_run)
67
+ end
68
+ end
69
+
70
+ def determine_destination(file_path, config)
71
+ filename = File.basename(file_path)
72
+ extension = extract_extension(file_path)
73
+
74
+ find_by_keyword(filename, config[:keywords]) ||
75
+ find_by_extension(extension, config[:extensions])
76
+ end
77
+
78
+ def extract_extension(file_path)
79
+ File.extname(file_path).delete('.').downcase
80
+ end
81
+
82
+ def find_by_keyword(filename, keywords_config)
83
+ keywords_config.each do |dir, keywords|
84
+ return dir if keywords.any? { |kw| filename.include?(kw) }
85
+ end
86
+ nil
87
+ end
88
+
89
+ def find_by_extension(extension, extensions_config)
90
+ extensions_config.each do |dir, extensions|
91
+ return dir if extensions.include?(extension)
92
+ end
93
+ nil
94
+ end
95
+
96
+ def cleanup_empty_directories
97
+ Dir.glob(File.join(@target_dir, '**/*')).reverse_each do |path|
98
+ next unless should_cleanup?(path)
99
+
100
+ remove_empty_directory(path)
101
+ end
102
+ end
103
+
104
+ def should_cleanup?(path)
105
+ File.directory?(path) &&
106
+ path != @target_dir &&
107
+ !@organized_dirs.include?(File.basename(path)) &&
108
+ Dir.empty?(path)
109
+ end
110
+
111
+ def remove_empty_directory(path)
112
+ Dir.rmdir(path)
113
+ relative = relative_path(path, @target_dir)
114
+ puts I18n.t('organizer.cleaned_up', dir: relative)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,31 @@
1
+ require 'fileutils'
2
+ require_relative 'i18n'
3
+
4
+ module TidyFileOrganizer
5
+ class PostInstall
6
+ CONFIG_DIR = File.expand_path('~/.config/tidy-file-organizer').freeze
7
+ DEFAULT_CONFIG_PATH = File.join(CONFIG_DIR, 'default.yml').freeze
8
+ DEFAULT_CONFIG_JA_PATH = File.join(CONFIG_DIR, 'default.ja.yml').freeze
9
+
10
+ def self.run
11
+ FileUtils.mkdir_p(CONFIG_DIR)
12
+
13
+ # 英語版(デフォルト)
14
+ source = File.expand_path('../../config/default.yml', __dir__)
15
+ unless File.exist?(DEFAULT_CONFIG_PATH)
16
+ FileUtils.cp(source, DEFAULT_CONFIG_PATH)
17
+ puts I18n.t('post_install.created_default_en', path: DEFAULT_CONFIG_PATH)
18
+ end
19
+
20
+ # 日本語版
21
+ source_ja = File.expand_path('../../config/default.ja.yml', __dir__)
22
+ unless File.exist?(DEFAULT_CONFIG_JA_PATH)
23
+ FileUtils.cp(source_ja, DEFAULT_CONFIG_JA_PATH)
24
+ puts I18n.t('post_install.created_default_ja', path: DEFAULT_CONFIG_JA_PATH)
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ # Auto-execute on gem install
31
+ TidyFileOrganizer::PostInstall.run if __FILE__ == $PROGRAM_NAME