Last active
September 10, 2026 21:55
-
-
Save alex-quiterio/a6a98ff58d827c63514e83b4e2911875 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
| # frozen_string_literal: true | |
| require 'fileutils' | |
| # Define file type extensions | |
| IMAGE_EXTENSIONS = %w[.jpg .jpeg .png .gif .bmp .tiff .webp .tif .svg .HEIC] | |
| PDF_EXTENSIONS = %w[.pdf .epub .docx] | |
| CODE_EXTENSIONS = %w[.fish .sql .lisp .py .js .rb .java .c .cpp .h .hpp .swift .kt .go .ts .tsx .jsx .html .css .scss .less .sass .xml .json .yaml .yml .toml .ini .properties .config .log .txt .md .markdown .mdown .mkd .mkdn .mkdown .ron .twbx .sh] | |
| CERT_EXTENSIONS = %w[.cer .crt .pem .key .asc .pfx .p12 .p7b .p7c .der] | |
| MARKDOWN_EXTENSIONS = %w[.md .markdown .mdown .mkd .mkdn .mkdown .ron .vtt] | |
| APK_EXTENSIONS = %w[.apk .apkg] | |
| CSV_EXTENSIONS = %w[.csv .tsv .xlsx] | |
| SKILL_EXTENSIONS = %w[.skill] | |
| ZIP_EXTENSIONS = %w[.zip .rar .7z .tar .gz .bz2] | |
| AUDIO_EXTENSIONS = %w[.mp3 .wav .ogg .aac .flac .m4a] | |
| VIDEO_EXTENSIONS = %w[.mp4 .mkv .mov .avi .wmv] | |
| ORIGIN_FOLDER = "#{ENV['HOME']}/Desktop/triage" | |
| DESTINATION_FOLDERS = { | |
| # soft: "~/gdrive-remote-folder", | |
| soft: "#{ENV['HOME']}/Downloads", | |
| heavy: "#{ENV['HOME']}/Downloads" | |
| } | |
| # Categories in match priority order: the first one whose extension list holds | |
| # the file's extension wins, so e.g. .md lands in markdowns and not in codes. | |
| CATEGORIES = [ | |
| { label: 'Images', tier: :soft, dir: '__triage-images', extensions: IMAGE_EXTENSIONS }, | |
| { label: 'PDFs', tier: :soft, dir: '__triage-pdfs', extensions: PDF_EXTENSIONS }, | |
| { label: 'Audios', tier: :heavy, dir: '__triage-audios', extensions: AUDIO_EXTENSIONS }, | |
| { label: 'Skills', tier: :soft, dir: '__triage-skills', extensions: SKILL_EXTENSIONS }, | |
| { label: 'Videos', tier: :heavy, dir: '__triage-videos', extensions: VIDEO_EXTENSIONS }, | |
| { label: 'Markdowns', tier: :soft, dir: '__triage-markdowns', extensions: MARKDOWN_EXTENSIONS }, | |
| { label: 'Zips', tier: :soft, dir: '__triage-zips', extensions: ZIP_EXTENSIONS }, | |
| { label: 'APKs', tier: :soft, dir: '__triage-apks', extensions: APK_EXTENSIONS }, | |
| { label: 'CSVs', tier: :soft, dir: '__triage-csvs', extensions: CSV_EXTENSIONS }, | |
| { label: 'Codes', tier: :soft, dir: '__triage-codes', extensions: CODE_EXTENSIONS }, | |
| { label: 'Certs', tier: :soft, dir: '__triage-certs', extensions: CERT_EXTENSIONS } | |
| ].freeze | |
| 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 that is redrawn in place (one row per category) instead of a | |
| # stream of per-file log lines. On a non-tty it is printed once, at the end. | |
| class SummaryTable | |
| RESET = "\e[0m".freeze | |
| DIM = "\e[2m".freeze | |
| BOLD = "\e[1m".freeze | |
| HEADERS = ['Category', 'Files', 'Size', 'Destination'].freeze | |
| DESTINATION_MAX = 48 | |
| SKIPPED_KINDS_MAX = 6 | |
| def initialize(categories, dry_run:) | |
| @dry_run = dry_run | |
| @tty = $stdout.tty? | |
| @drawn_lines = 0 | |
| @skipped = Hash.new(0) # kind (".dmg", "no extension", "folders") => count | |
| @rows = categories.map do |category| | |
| { label: category[:label], destination: shorten(tilde(category[:destination])), files: 0, bytes: 0 } | |
| end | |
| @label_width = (@rows.map { |row| row[:label].length } << HEADERS[0].length).max | |
| @destination_width = (@rows.map { |row| row[:destination].length } << HEADERS[3].length).max | |
| @files_width = HEADERS[1].length | |
| @size_width = 9 | |
| @table_width = column_widths.sum { |width| width + 2 } + 5 | |
| end | |
| def record(index, bytes) | |
| @rows[index][:files] += 1 | |
| @rows[index][:bytes] += bytes | |
| end | |
| # extension is a downcased ".ext", '' for an extensionless file, or :directory | |
| def skip(extension) | |
| kind = case extension | |
| when :directory then 'folders' | |
| when '' then 'no extension' | |
| else extension | |
| end | |
| @skipped[kind] += 1 | |
| 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 | |
| # Keep the table narrow enough for a normal terminal: the tail of a long | |
| # destination is the part worth reading. | |
| def shorten(path) | |
| return path if path.length <= DESTINATION_MAX | |
| "…#{path[-(DESTINATION_MAX - 1)..]}" | |
| end | |
| def render | |
| lines = [title] | |
| lines << border('┌', '┬', '┐') | |
| lines << cells(*HEADERS, style: BOLD) | |
| lines << border('├', '┼', '┤') | |
| @rows.each do |row| | |
| size = row[:files].zero? ? '—' : human_size(row[:bytes]) | |
| lines << cells(row[:label], row[:files].to_s, size, row[:destination], | |
| style: row[:files].zero? ? DIM : nil) | |
| end | |
| lines << border('├', '┼', '┤') | |
| lines << cells('Total', total_files.to_s, human_size(total_bytes), '', style: BOLD) | |
| lines << border('└', '┴', '┘') | |
| lines << footer | |
| lines | |
| end | |
| def title | |
| mode = @dry_run ? "#{DIM}(dry run)#{RESET} " : '' | |
| "#{BOLD}Triage#{RESET} #{mode}#{DIM}#{tilde(ORIGIN_FOLDER)}#{RESET}" | |
| end | |
| def footer | |
| total = @skipped.values.sum | |
| return "#{DIM}nothing left behind#{RESET}" if total.zero? | |
| noun = total == 1 ? 'entry' : 'entries' | |
| prefix = "#{total} #{noun} left in place: " | |
| "#{DIM}#{prefix}#{skipped_kinds(@table_width - prefix.length)}#{RESET}" | |
| end | |
| # Busiest kinds first, so a pile of one unhandled extension is obvious. Only | |
| # whole kinds are listed — whatever does not fit the line becomes "+n more". | |
| def skipped_kinds(budget) | |
| ranked = @skipped.sort_by { |kind, count| [-count, kind] } | |
| labels = ranked.map { |kind, count| count > 1 ? "#{kind} (#{count})" : kind } | |
| listed = [] | |
| labels.take(SKIPPED_KINDS_MAX).each do |label| | |
| candidate = listed + [label] | |
| break if candidate.join(', ').length + tail(ranked, candidate.size).length > budget | |
| listed = candidate | |
| end | |
| listed << "+#{remaining(ranked, listed.size)} more" if remaining(ranked, listed.size).positive? | |
| listed.join(', ') | |
| end | |
| def tail(ranked, listed_count) | |
| left = remaining(ranked, listed_count) | |
| left.positive? ? ", +#{left} more" : '' | |
| end | |
| def remaining(ranked, listed_count) | |
| ranked.drop(listed_count).sum { |_kind, count| count } | |
| end | |
| def column_widths | |
| [@label_width, @files_width, @size_width, @destination_width] | |
| end | |
| def border(left, middle, right) | |
| "#{DIM}#{left}#{column_widths.map { |width| '─' * (width + 2) }.join(middle)}#{right}#{RESET}" | |
| end | |
| def cells(label, files, size, destination, style: nil) | |
| columns = [ | |
| label.ljust(@label_width), | |
| files.rjust(@files_width), | |
| size.rjust(@size_width), | |
| destination.ljust(@destination_width) | |
| ] | |
| pipe = "#{DIM}│#{RESET}" | |
| row = columns.map { |column| "#{style}#{column}#{RESET if style}" }.join(" #{pipe} ") | |
| "#{pipe} #{row} #{pipe}" | |
| end | |
| def total_files | |
| @rows.sum { |row| row[:files] } | |
| end | |
| def total_bytes | |
| @rows.sum { |row| row[:bytes] } | |
| end | |
| end | |
| # Function to move files based on type | |
| def organize_files(dry_run = false) | |
| categories = CATEGORIES.map do |category| | |
| category.merge(destination: File.join(DESTINATION_FOLDERS[category[:tier]], category[:dir])) | |
| end | |
| table = SummaryTable.new(categories, dry_run: dry_run) | |
| table.draw | |
| begin | |
| # Iterate over files in the origin folder | |
| Dir.children(ORIGIN_FOLDER).sort.each do |file| | |
| file_path = File.join(ORIGIN_FOLDER, file) | |
| next table.skip(:directory) if File.directory?(file_path) # Skip directories | |
| file_extension = File.extname(file).downcase | |
| index = categories.find_index { |category| category[:extensions].include?(file_extension) } | |
| next table.skip(file_extension) if index.nil? | |
| bytes = File.size?(file_path).to_i | |
| unless dry_run | |
| FileUtils.mkdir_p(categories[index][:destination]) | |
| FileUtils.mv(file_path, categories[index][:destination]) | |
| end | |
| table.record(index, bytes) | |
| table.draw | |
| end | |
| table.draw(final: true) | |
| ensure | |
| table.restore_cursor | |
| end | |
| end | |
| dry_run = ARGV[0] | |
| if !Dir.exist?(ORIGIN_FOLDER) | |
| puts "The origin folder does not exist." | |
| FileUtils.mkdir_p(ORIGIN_FOLDER) | |
| end | |
| if !Dir.exist?(DESTINATION_FOLDERS[:soft]) | |
| puts "The destination soft foles folder does not exist. Creating it..." | |
| FileUtils.mkdir_p(DESTINATION_FOLDERS[:soft]) | |
| end | |
| if !Dir.exist?(DESTINATION_FOLDERS[:heavy]) | |
| puts "The destination heavy foles folder does not exist. Creating it..." | |
| FileUtils.mkdir_p(DESTINATION_FOLDERS[:heavy]) | |
| end | |
| organize_files(dry_run == "--dry-run") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment