#!/usr/bin/env ruby require 'fileutils' require 'handlebars' require 'json' require 'slim' require 'thor' require 'piccle' class CLI < Thor default_task :generate class_option :"image-dir", desc: "Input image directory. Defaults to $CWD/images.", aliases: "-i" class_option :database, desc: "The location of the database to use. Defaults to $CWD/piccle_data.db.", aliases: "-d" class_option :config, desc: "Config file to use. Defaults to $CWD/piccle.config.yaml, then ~/.piccle.config.yaml.", aliases: "-c" class_option :debug, desc: "Enable debug mode.", type: :boolean, default: false # Prints a banner describing Piccle. qv. https://github.com/erikhuda/thor/issues/612 def help(*) print_wrapped <<~INTRO Piccle #{Piccle::VERSION}: a static website generator for photographers. Piccle reads the metadata from your photos and uses it to generate a gallery that lets people explore your photos by date, keyword, location, etc. Run "piccle help generate" for more details, or see usage instructions at https://piccle.alexpounds.com/. INTRO puts super end desc "generate", "Generates a web photo gallery based on image metadata." option :"output-dir", desc: "Output directory. Defaults to $CWD/generated.", aliases: "-o" option :events, desc: "The location of the events file to use, if any. Defaults to $CWD/events.yaml", aliases: "-e" option :"author-name", desc: "Author name.", aliases: "-n" option :url, desc: "The URL where you'll deploy your gallery, if any. Used to generate Atom feeds and OpenGraph tags.", aliases: "-u" option :"ruby-renderer", desc: "Render templates with a Ruby codepath, rather than a JavaScript helper app. You don't need node in your path, but it is 10x slower.", type: :boolean, default: false def generate if options.key?("config") && !File.exist?(options["config"]) puts "Specified config file #{options["config"]} not found." exit 1 end Piccle.config = piccle_config(options) report_options check_image_dir_exists update_db generate_everything end desc "geocode", "Retrieve locations from photos, and convert lat/longs to named locations." def geocode Piccle.config = piccle_config(options) report_image_and_config_options check_image_dir_exists Piccle::Photo.where(path: Piccle.config.images_dir).each do |photo| if File.exist?(File.join(Piccle.config.images_dir, photo.file_name)) print "Updating location data for #{photo.file_name}... " # Is this photo fully geocoded? That is, does it have lat/long/city/state/country? if photo.geocoded? puts " Already geocoded, no changes made." save_location_data(photo) # Does it have just a lat/long? Either retrieve a cached location record or look it up. elsif photo.latitude && photo.longitude puts "\n Photo has lat/long, looking for place data... " dstk_service = Piccle::DstkService.new if location = dstk_service.location_for(photo) if photo.update(city: location.city, state: location.state, country: location.country) puts " Done." else puts " Couldn't save data: #{photo.errors.inspect}" end end # Maybe it's got city/state/country in the DB already. else places = [photo.city, photo.state, photo.country].compact if places.any? puts " Photo has metadata labels for #{places.join(", ")}, no changes made." else puts " No geo information in this photo's metadata, no changes made." end end end end end protected # Merge our supplied options with a couple of required details, and return a config object. def piccle_config(options) Piccle::Config.new(options.merge("working_directory" => Dir.pwd, "home_directory" => Dir.home)) end # How are we going to generate this gallery - based on which options? def report_options report_image_and_config_options option_message("Writing gallery to #{Piccle.config.output_dir}", "output-dir") option_message("Photos will be credited to #{Piccle.config.author_name}", "author-name") if Piccle.config.using_default?("events") && !File.exist?(Piccle.config.events_file) puts "No events file found." elsif File.exist?(Piccle.config.events_file) option_message("Events read from #{Piccle.config.events_file}", "events") else option_message("⚠️ Events file #{Piccle.config.events_file}", "events") end puts "⚠️ Not generating an Atom feed, because URL is unset." unless Piccle.config.atom? puts "⚠️ Not generating OpenGraph tags, because URL is unset." unless Piccle.config.open_graph? puts "" end # Geocoding has fewer relevant options, so we can output this little summary instead. def report_image_and_config_options option_message("Reading images from #{Piccle.config.images_dir}", "image-dir") if Piccle.config.database_exists? option_message("Using existing database #{Piccle.config.database_file}", "database") else option_message("Creating new database #{Piccle.config.database_file}", "database") end end # Given a message and a parameter name, generate one of our standard report strings. def option_message(message, config_param) puts "#{message} (#{Piccle.config.source_for(config_param)})" end # Read all the images in the images directory, and load their data into the DB. def update_db Dir.glob(File.join(Piccle.config.images_dir, "**")).each do |filename| print "Examining #{filename}..." photo = Piccle::Photo.from_file(filename) if photo.changed_hash? print " updating..." photo.update_from_file puts " done." elsif photo.freshly_created? puts " created." else puts " done." end end end # Ensure we have some images to work with; if not, output an error and quit. def check_image_dir_exists unless File.exist?(Piccle.config.images_dir) STDERR.puts "\n⚠️ The images directory, #{Piccle.config.images_dir}, does not exist. You can specify it using the -i option; run 'piccle help generate' for more info." exit 1 end if Dir.empty?(Piccle.config.images_dir) STDERR.puts "\n⚠️ There are no images in #{Piccle.config.images_dir}, so we cannot continue." exit 1 end end # Generates an entire site. Atom feeds, HTML templates, smaller images, JSON data, copied assets, the whole enchilada. def generate_everything start_time = Time.now puts "Generating website..." parser = new_parser_with_streams parse_photos(parser) renderer = if Piccle.config.ruby_renderer? Piccle::Renderer.new(parser) else Piccle::JsRenderer.new(parser) end FileUtils.mkdir_p(Piccle.config.output_dir) generate_templates(renderer) generate_atom_feeds(parser, renderer) generate_html_indexes(parser, renderer) generate_html_photos(parser, renderer) generate_json(parser, renderer) generate_thumbnails generate_quilts(parser) copy_assets puts "Website generated in #{(Time.now - start_time)} seconds." end # Get a parser, with streams (metadata filters and extractors) registered. def new_parser_with_streams Piccle::Parser.new.tap do |p| p.add_stream(Piccle::Streams::DateStream) p.add_stream(Piccle::Streams::LocationStream) p.add_stream(Piccle::Streams::EventStream) p.add_stream(Piccle::Streams::CameraStream) p.add_stream(Piccle::Streams::KeywordStream) end end # Load all the photos, and parse them all. def parse_photos(parser) Piccle::Photo.where(path: Piccle.config.images_dir).each do |p| parser.parse(p) end parser.load_events parser.order end # Given a parser object, generate some HTML index pages from the data it contains. def generate_html_indexes(parser, renderer) puts " ... generating HTML indexes ..." print " ... generating main index ... " File.write(File.join(Piccle.config.output_dir, "index.html"), renderer.render_main_index) puts "Done." parser.subsections.each do |subsection| if parser.subsection_photo_hashes(subsection).any? subdir = File.join(Piccle.config.output_dir, *subsection) print " ... generating #{subdir} index ... " FileUtils.mkdir_p(subdir) File.write(File.join(subdir, "index.html"), renderer.render_index(subsection)) puts "Done." end end end # Given a parser object, generate Atom feeds for everything, and all substreams. def generate_atom_feeds(parser, renderer) if Piccle.config.atom? puts " ... generating Atom feeds ..." print " ... generating main Atom feed ... " File.write(File.join(Piccle.config.output_dir, "feed.atom"), renderer.render_feed) puts "Done." parser.subsections.each do |subsection| if parser.subsection_photo_hashes(subsection).any? subdir = File.join(Piccle.config.output_dir, *subsection) print " ... generating #{subdir} feed ... " FileUtils.mkdir_p(subdir) File.write(File.join(subdir, "feed.atom"), renderer.render_feed(subsection)) puts "Done." end end else puts " Not generating Atom feeds, because no home URL is set." end end # Given a parser object, generate photo pages from the data it contains. def generate_html_photos(parser, renderer) puts " ... generating HTML photo pages ..." parser.photo_hashes.each do |hash| print " ... generating canonical page for #{hash}... " File.write(File.join(Piccle.config.output_dir, "#{hash}.html"), renderer.render_photo(hash)) puts "Done." parser.links_for(hash).each do |selector| destination_page = File.join(Piccle.config.output_dir, *selector, "#{hash}.html") print " ... generating stream page #{destination_page}..." File.write(destination_page, renderer.render_photo(hash, selector)) puts "Done." end end end def generate_json(parser, _renderer) puts " ... generating JSON files..." FileUtils.mkdir_p(File.join(Piccle.config.output_dir, "json")) File.write(File.join(Piccle.config.output_dir, "json", "all.json"), parser.data.to_json) end # Stubby, hacky method that demos generating thumbnails. def generate_thumbnails puts " ... generating thumbnails..." FileUtils.mkdir_p(File.join(Piccle.config.output_dir, "images", "thumbnails")) FileUtils.mkdir_p(File.join(Piccle.config.output_dir, "images", "photos")) Piccle::Photo.where(path: Piccle.config.images_dir).each do |photo| print " ... generating #{photo.thumbnail_path}... " if photo.thumbnail_exists? puts "Already exists, skipping." else photo.generate_thumbnail! puts "Done." end print " ... generating #{photo.full_image_path}... " if photo.full_image_exists? puts "Already exists, skipping." else photo.generate_full_image! puts "Done." end end end # Generates "quilts" - stitched together images for each section, which we use in OpenGraph tags. def generate_quilts(parser) puts "Generating gallery quilts (preview images for sharing galleries on social media)..." if Piccle.config.open_graph? thumbnail_path_proc = Proc.new { |k, v| File.join(Piccle.config.output_dir, "images", "thumbnails", "#{v[:hash]}.#{v[:file_name]}") } print " ... Creating main index quilt..." main_thumbnails = parser.data[:photos].first(9).map(&thumbnail_path_proc) main_quilt = Piccle::QuiltGenerator.generate_for(main_thumbnails) main_quilt.write(File.join(Piccle.config.output_dir, "quilt.jpg")) puts " Done." parser.subsections.each do |subsection| thumbnails = parser.subsection_photos(subsection).map(&thumbnail_path_proc) if thumbnails.any? output_path = File.join(Piccle.config.output_dir, *subsection, "quilt.jpg") print " ... Creating gallery quilt #{output_path}..." quilt = Piccle::QuiltGenerator.generate_for(thumbnails.first(9)) quilt.write(output_path) puts " Done." end end else puts " Not generating gallery quilt images, because no home URL is set." end end def generate_templates(_renderer) puts " ... generating templates..." FileUtils.mkdir_p(File.join(Piccle.config.output_dir, "js")) File.write(File.join(Piccle.config.output_dir, "js", "index.handlebars"), Piccle::TemplateHelpers.compile_template("index")) File.write(File.join(Piccle.config.output_dir, "js", "show.handlebars"), Piccle::TemplateHelpers.compile_template("show")) end # Copy our static assets into the expected location. def copy_assets puts " ... copying static assets..." puts " ... copying CSS..." copy_asset_type("css") puts " ... copying icons..." copy_asset_type("icons") end def copy_asset_type(type) FileUtils.mkdir_p(File.join(Piccle.config.output_dir, type)) Dir.glob("#{Piccle.config.gem_root_join("assets", type)}/**").each do |f| FileUtils.cp(f, File.join(Piccle.config.output_dir, type, File.basename(f))) end end # Take geo information from a photo, and save it to our Piccle database. def save_location_data(photo) unless Piccle::Location.find(latitude: photo.latitude, longitude: photo.longitude) Piccle::Location.create(latitude: photo.latitude, longitude: photo.longitude, city: photo.city, state: photo.state, country: photo.country) end end end CLI.start(ARGV)