Last active
September 4, 2026 08:38
-
-
Save alex-quiterio/4e2399b838d184e30cab0b12357c035e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # frozen_string_literal: true | |
| require 'fileutils' | |
| require 'optparse' | |
| require 'time' | |
| RESET = "\e[0m" | |
| DIM = "\e[2m" | |
| BOLD = "\e[1m" | |
| def tilde(path) | |
| path.sub(/\A#{Regexp.escape(ENV['HOME'].to_s)}/, '~') | |
| end | |
| def human_size(bytes) | |
| units = %w[B KB MB GB TB] | |
| index = 0 | |
| size = bytes.to_f | |
| while size >= 1024 && index < units.size - 1 | |
| size /= 1024 | |
| index += 1 | |
| end | |
| format(index.zero? ? '%d %s' : '%.1f %s', size, units[index]) | |
| end | |
| # A single table, one row per yyyy-mm bucket, redrawn in place as files are | |
| # grouped instead of a stream of per-file log lines. Rows appear as their month | |
| # is first seen, so the block only ever grows. On a non-tty it prints once, at | |
| # the end. | |
| class GroupTable | |
| HEADERS = %w[Month Files Size Share].freeze | |
| BAR_WIDTH = 22 | |
| EIGHTHS = %w[▏ ▎ ▍ ▌ ▋ ▊ ▉].freeze | |
| ERRORS_SHOWN = 3 | |
| def initialize(source:, dry_run:, moving:, date_label:, verbose: false) | |
| @source = source | |
| @dry_run = dry_run | |
| @moving = moving | |
| @date_label = date_label | |
| @verbose = verbose | |
| @tty = $stdout.tty? | |
| @drawn_lines = 0 | |
| @months = {} # 'yyyy-mm' => { files:, bytes: } | |
| @skipped = Hash.new { |hash, key| hash[key] = [] } # pattern => [filename] | |
| @errors = [] # [[filename, message]] | |
| @status = nil | |
| @month_width = [HEADERS[0].length, 7].max | |
| @files_width = HEADERS[1].length | |
| @size_width = 9 | |
| @table_width = column_widths.sum { |width| width + 2 } + 5 | |
| end | |
| def record(month, bytes, destination) | |
| bucket = (@months[month] ||= { files: 0, bytes: 0 }) | |
| bucket[:files] += 1 | |
| bucket[:bytes] += bytes | |
| @status = "#{@dry_run ? 'would ' : ''}#{@moving ? 'move' : 'copy'} → #{destination}" | |
| end | |
| def skip(pattern, filename) | |
| @skipped[pattern] << filename | |
| end | |
| def error(filename, message) | |
| @errors << [filename, message] | |
| end | |
| def draw(final: false) | |
| return unless @tty || final | |
| rendered = render | |
| if @tty | |
| print "\e[?25l" | |
| print "\e[#{@drawn_lines}A" if @drawn_lines.positive? | |
| rendered.each { |line| print "\e[2K#{line}\n" } | |
| print "\e[?25h" if final | |
| else | |
| rendered.each { |line| puts line } | |
| end | |
| @drawn_lines = rendered.size | |
| end | |
| def restore_cursor | |
| print "\e[?25h" if @tty | |
| end | |
| private | |
| def render | |
| return [empty_line] + detail_lines if @months.empty? | |
| lines = [title] | |
| lines << border('┌', '┬', '┐') | |
| lines << cells(*HEADERS, style: BOLD) | |
| lines << border('├', '┼', '┤') | |
| busiest = @months.values.map { |bucket| bucket[:files] }.max | |
| @months.keys.sort.each do |month| | |
| bucket = @months[month] | |
| lines << cells(month, bucket[:files].to_s, human_size(bucket[:bytes]), | |
| bar(bucket[:files], busiest)) | |
| end | |
| lines << border('├', '┼', '┤') | |
| lines << cells('Total', total_files.to_s, human_size(total_bytes), months_counted, style: BOLD) | |
| lines << border('└', '┴', '┘') | |
| lines << "#{DIM}#{clip(@status || 'scanning…')}#{RESET}" | |
| lines << "#{DIM}#{clip(footer)}#{RESET}" | |
| lines.concat(detail_lines) | |
| end | |
| def title | |
| mode = @dry_run ? "#{DIM}(dry run)#{RESET} " : '' | |
| action = @moving ? 'move' : 'copy' | |
| "#{BOLD}Group#{RESET} #{mode}#{DIM}#{@source} · #{action} · by date #{@date_label}#{RESET}" | |
| end | |
| # A directory with nothing to group is one line, not an empty table: this | |
| # script is run in a loop over every __triage-* folder. | |
| def empty_line | |
| details = [skipped_summary, errors_summary].compact | |
| tail = details.empty? ? 'nothing to group' : "nothing to group — #{details.join(', ')}" | |
| "#{BOLD}Group#{RESET} #{DIM}#{@source} · #{tail}#{RESET}" | |
| end | |
| def footer | |
| details = [skipped_summary, errors_summary].compact | |
| return 'clean run' if details.empty? | |
| details.join(' · ') | |
| end | |
| def skipped_summary | |
| total = @skipped.values.sum(&:size) | |
| return nil if total.zero? | |
| "#{total} skipped (#{@skipped.keys.sort.join(', ')})" | |
| end | |
| def errors_summary | |
| return nil if @errors.empty? | |
| "#{@errors.size} #{@errors.size == 1 ? 'error' : 'errors'}" | |
| end | |
| # Verbose spells out every failure and every skipped name; otherwise only the | |
| # first few failures, so the block stays a glance rather than a log. | |
| def detail_lines | |
| lines = @errors.take(@verbose ? @errors.size : ERRORS_SHOWN).map do |filename, message| | |
| detail('!', "#{filename}: #{message}") | |
| end | |
| hidden = @errors.size - lines.size | |
| lines << detail('!', "+#{hidden} more") if hidden.positive? | |
| return lines unless @verbose | |
| @skipped.sort.each do |pattern, filenames| | |
| lines << detail('~', "#{pattern}: #{filenames.sort.join(', ')}") | |
| end | |
| lines | |
| end | |
| def detail(marker, text) | |
| "#{DIM} #{marker} #{clip(text, @table_width - 4)}#{RESET}" | |
| end | |
| # One eighth-block of resolution per column, scaled to the busiest month. | |
| def bar(files, busiest) | |
| filled = files.to_f / busiest * BAR_WIDTH | |
| full = filled.floor | |
| eighths = ((filled - full) * 8).round | |
| if eighths == 8 | |
| full += 1 | |
| eighths = 0 | |
| end | |
| blocks = ('█' * full) + (eighths.zero? ? '' : EIGHTHS[eighths - 1]) | |
| blocks + ('░' * (BAR_WIDTH - blocks.length)) | |
| end | |
| def months_counted | |
| "#{@months.size} #{@months.size == 1 ? 'month' : 'months'}" | |
| end | |
| def clip(text, width = @table_width) | |
| text.length > width ? "#{text[0, width - 1]}…" : text | |
| end | |
| def column_widths | |
| [@month_width, @files_width, @size_width, BAR_WIDTH] | |
| end | |
| def border(left, middle, right) | |
| "#{DIM}#{left}#{column_widths.map { |width| '─' * (width + 2) }.join(middle)}#{right}#{RESET}" | |
| end | |
| def cells(month, files, size, share, style: nil) | |
| columns = [ | |
| month.ljust(@month_width), | |
| files.rjust(@files_width), | |
| size.rjust(@size_width), | |
| share.ljust(BAR_WIDTH) | |
| ] | |
| pipe = "#{DIM}│#{RESET}" | |
| row = columns.map { |column| "#{style}#{column}#{RESET if style}" }.join(" #{pipe} ") | |
| "#{pipe} #{row} #{pipe}" | |
| end | |
| def total_files | |
| @months.values.sum { |bucket| bucket[:files] } | |
| end | |
| def total_bytes | |
| @months.values.sum { |bucket| bucket[:bytes] } | |
| end | |
| end | |
| class FileGrouper | |
| def initialize(source_dir, options = {}) | |
| @source_dir = File.expand_path(source_dir) | |
| @dry_run = options[:dry_run] || false | |
| @use_creation_date = options[:use_creation_date] || false | |
| @verbose = options[:verbose] || false | |
| @exclude_patterns = options[:exclude] || [] | |
| @move_files = options.fetch(:move, true) | |
| end | |
| def group_files | |
| unless Dir.exist?(@source_dir) | |
| warn "Error: Directory '#{@source_dir}' does not exist" | |
| exit 1 | |
| end | |
| table = GroupTable.new(source: tilde(@source_dir), dry_run: @dry_run, moving: @move_files, | |
| date_label: @use_creation_date ? 'created' : 'modified', | |
| verbose: @verbose) | |
| begin | |
| Dir.glob(File.join(@source_dir, '*')).sort.each { |file_path| process(file_path, table) } | |
| table.draw(final: true) | |
| ensure | |
| table.restore_cursor | |
| end | |
| end | |
| private | |
| def process(file_path, table) | |
| return if File.directory?(file_path) | |
| filename = File.basename(file_path) | |
| pattern = matching_exclude(filename) | |
| return table.skip(pattern, filename) if pattern | |
| year_month = file_date(file_path).strftime('%Y-%m') | |
| target_dir = File.join(@source_dir, year_month) | |
| target_path = File.join(target_dir, filename) | |
| target_path = unique_name(target_path) if File.exist?(target_path) && target_path != file_path | |
| bytes = File.size?(file_path).to_i | |
| transfer(file_path, target_dir, target_path) unless @dry_run | |
| table.record(year_month, bytes, "#{year_month}/#{File.basename(target_path)}") | |
| table.draw | |
| rescue StandardError => e | |
| table.error(filename, e.message) | |
| table.draw | |
| end | |
| def transfer(file_path, target_dir, target_path) | |
| FileUtils.mkdir_p(target_dir) unless Dir.exist?(target_dir) | |
| if @move_files | |
| FileUtils.mv(file_path, target_path) | |
| else | |
| FileUtils.cp(file_path, target_path) | |
| end | |
| end | |
| # birthtime is the real creation date on macOS; ctime (change time) is the | |
| # best the filesystem offers where birthtime is unsupported. | |
| def file_date(file_path) | |
| return File.mtime(file_path) unless @use_creation_date | |
| begin | |
| File.birthtime(file_path) | |
| rescue NotImplementedError | |
| File.ctime(file_path) | |
| end | |
| end | |
| def matching_exclude(filename) | |
| @exclude_patterns.find do |pattern| | |
| File.fnmatch?(pattern, filename, File::FNM_CASEFOLD) | |
| end | |
| end | |
| def unique_name(original_path) | |
| dir = File.dirname(original_path) | |
| basename = File.basename(original_path, '.*') | |
| extension = File.extname(original_path) | |
| counter = 1 | |
| loop do | |
| new_path = File.join(dir, "#{basename}_#{counter}#{extension}") | |
| return new_path unless File.exist?(new_path) | |
| counter += 1 | |
| end | |
| end | |
| end | |
| # Command-line interface | |
| if __FILE__ == $PROGRAM_NAME | |
| options = { | |
| dry_run: false, | |
| use_creation_date: false, | |
| verbose: false, | |
| exclude: [], | |
| move: true | |
| } | |
| parser = OptionParser.new do |opts| | |
| opts.banner = "Usage: #{$PROGRAM_NAME} [options] DIRECTORY" | |
| opts.separator '' | |
| opts.separator 'Groups files in a directory by year-month (yyyy-mm) based on their dates.' | |
| opts.separator '' | |
| opts.separator 'Options:' | |
| opts.on('-d', '--dry-run', 'Preview changes without modifying files') do | |
| options[:dry_run] = true | |
| options[:verbose] = true # Automatically enable verbose in dry-run mode | |
| end | |
| opts.on('-c', '--creation-date', 'Use creation date instead of modification date') do | |
| options[:use_creation_date] = true | |
| end | |
| opts.on('-m', '--move', 'Move files (the default)') do | |
| options[:move] = true | |
| end | |
| opts.on('-C', '--copy', 'Copy files instead of moving them') do | |
| options[:move] = false | |
| end | |
| opts.on('-v', '--verbose', 'Spell out every error and every skipped file') do | |
| options[:verbose] = true | |
| end | |
| opts.on('-e', '--exclude PATTERN', 'Exclude files matching pattern (can be used multiple times)') do |pattern| | |
| options[:exclude] << pattern | |
| end | |
| opts.on('-h', '--help', 'Show this help message') do | |
| puts opts | |
| exit | |
| end | |
| end | |
| begin | |
| parser.parse! | |
| if ARGV.empty? | |
| warn 'Error: Please specify a directory' | |
| warn parser | |
| exit 1 | |
| end | |
| FileGrouper.new(ARGV[0], options).group_files | |
| rescue OptionParser::InvalidOption => e | |
| warn "Error: #{e.message}" | |
| warn parser | |
| exit 1 | |
| end | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment