Skip to content

Instantly share code, notes, and snippets.

@alex-quiterio
Last active September 10, 2026 21:55
Show Gist options
  • Select an option

  • Save alex-quiterio/a6a98ff58d827c63514e83b4e2911875 to your computer and use it in GitHub Desktop.

Select an option

Save alex-quiterio/a6a98ff58d827c63514e83b4e2911875 to your computer and use it in GitHub Desktop.
# 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