davideisinger.com

My personal website
Log | Files | Refs | README

refresh-dither-alt (3332B)


      1 #!/usr/bin/env ruby
      2 
      3 require "optparse"
      4 require "shellwords"
      5 require "tempfile"
      6 require_relative "lib/image_alt_text"
      7 
      8 ROOT = File.expand_path("..", __dir__)
      9 # Match paired shortcodes, including quoted filenames and multiline alt text.
     10 PAIRED_SHORTCODE = /(?<opening>\{\{(?<kind>[<%])\s*dither\s+(?![^{}]*\/\s*[>%]\}\})(?<args>(?:"[^"]*"|'[^']*'|`[^`]*`|[^"'`{}])*?)\s*[>%]\}\})(?<alt>.*?)(?<closing>\{\{\k<kind>\s*\/dither\s*[>%]\}\})/m
     11 
     12 dry_run = false
     13 options = OptionParser.new do |parser|
     14   parser.banner = "Usage: bin/refresh-dither-alt [--dry-run] [POST_OR_DIRECTORY ...]"
     15   parser.on("--dry-run", "List images without decrypting, calling Codex, or editing posts") { dry_run = true }
     16   parser.on("-h", "--help", "Show this help") { puts parser; exit }
     17 end
     18 
     19 begin
     20   options.parse!
     21   inputs = ARGV.empty? ? [File.join(ROOT, "content")] : ARGV
     22   files = inputs.flat_map do |path|
     23     raise "Path not found: #{path}" unless File.exist?(path)
     24     File.directory?(path) ? Dir.glob(File.join(path, "**", "*.{md,markdown,html}")) : [path]
     25   end.map { |path| File.expand_path(path) }.uniq.sort
     26 
     27   count = 0
     28   failures = 0
     29   files.each do |post|
     30     original = File.read(post)
     31     post_count = 0
     32     begin
     33       updated = original.gsub(PAIRED_SHORTCODE) do |shortcode|
     34         match = Regexp.last_match
     35         next shortcode if match[:alt].strip.empty?
     36         next shortcode if match[:args].rstrip.end_with?("/")
     37 
     38         arguments = match[:args].gsub(/`([^`]*)`/) { Shellwords.escape(Regexp.last_match(1)) }
     39         filename = Shellwords.split(arguments).first
     40         raise "Missing image filename" if filename.nil? || filename.empty?
     41         encrypted = File.expand_path("#{filename}.enc", File.dirname(post))
     42         raise "Encrypted image not found: #{encrypted}" unless File.file?(encrypted)
     43 
     44         puts "#{dry_run ? 'Would update' : 'Updating'} #{post}: #{filename}"
     45         post_count += 1
     46         next shortcode if dry_run
     47 
     48         alt = nil
     49         Dir.mktmpdir("dither-alt-") do |directory|
     50           decrypted = File.join(directory, "image#{File.extname(filename)}")
     51           output, status = Open3.capture2e(
     52             "openssl", "aes-256-cbc", "-d", "-in", encrypted, "-out", decrypted,
     53             "-pass", "file:#{File.join(ROOT, 'secret.key')}", "-iter", "1000000"
     54           )
     55           raise "Decryption failed for #{filename}: #{output}" unless status.success?
     56           alt = ImageAltText.generate(decrypted)
     57         end
     58         "#{match[:opening]}#{alt}#{match[:closing]}"
     59       end
     60 
     61       if updated != original
     62         # Finish each post before replacing it; failed generations leave it intact.
     63         Tempfile.create([".dither-alt-", ".tmp"], File.dirname(post)) do |temp|
     64           temp.write(updated)
     65           temp.flush
     66           File.chmod(File.stat(post).mode & 0o777, temp.path)
     67           raise "Post changed during generation: #{post}" unless File.read(post) == original
     68           File.rename(temp.path, post)
     69         end
     70       end
     71       count += post_count
     72     rescue StandardError => error
     73       warn "#{post}: #{error.message} (post left unchanged)"
     74       failures += 1
     75     end
     76   end
     77   puts "#{dry_run ? 'Found' : 'Processed'} #{count} shortcode(s); #{failures} failed post(s)."
     78   exit(failures.zero? ? 0 : 1)
     79 rescue OptionParser::ParseError, StandardError => error
     80   abort error.message
     81 end