piccle 0.1.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +5 -0
  5. data/Gemfile +4 -0
  6. data/NOTES.md +69 -0
  7. data/README.md +175 -0
  8. data/Rakefile +8 -0
  9. data/agpl-3.0.md +660 -0
  10. data/assets/css/default.css +397 -0
  11. data/assets/css/normalize.css +427 -0
  12. data/assets/icons/android-chrome-192x192.png +0 -0
  13. data/assets/icons/android-chrome-512x512.png +0 -0
  14. data/assets/icons/apple-touch-icon.png +0 -0
  15. data/assets/icons/favicon-16x16.png +0 -0
  16. data/assets/icons/favicon-32x32.png +0 -0
  17. data/assets/icons/favicon.ico +0 -0
  18. data/bin/console +14 -0
  19. data/bin/piccle +355 -0
  20. data/bin/setup +8 -0
  21. data/db/migrations/001_create_photos.rb +15 -0
  22. data/db/migrations/002_update_photos.rb +14 -0
  23. data/db/migrations/003_create_keywords_and_join_table.rb +14 -0
  24. data/db/migrations/004_add_focal_length.rb +7 -0
  25. data/db/migrations/005_create_locations.rb +20 -0
  26. data/js-renderer/handlebars.min-v4.7.6.js +29 -0
  27. data/js-renderer/renderer.js +93 -0
  28. data/lib/piccle.rb +52 -0
  29. data/lib/piccle/config.rb +136 -0
  30. data/lib/piccle/database.rb +33 -0
  31. data/lib/piccle/dstk_service.rb +64 -0
  32. data/lib/piccle/extractor.rb +128 -0
  33. data/lib/piccle/js_renderer.rb +37 -0
  34. data/lib/piccle/models/keyword.rb +6 -0
  35. data/lib/piccle/models/location.rb +11 -0
  36. data/lib/piccle/models/photo.rb +211 -0
  37. data/lib/piccle/parser.rb +230 -0
  38. data/lib/piccle/quilt_generator.rb +30 -0
  39. data/lib/piccle/renderer.rb +175 -0
  40. data/lib/piccle/streams.rb +2 -0
  41. data/lib/piccle/streams/base_stream.rb +56 -0
  42. data/lib/piccle/streams/camera_stream.rb +35 -0
  43. data/lib/piccle/streams/date_stream.rb +95 -0
  44. data/lib/piccle/streams/event_stream.rb +73 -0
  45. data/lib/piccle/streams/keyword_stream.rb +24 -0
  46. data/lib/piccle/streams/location_stream.rb +57 -0
  47. data/lib/piccle/template_helpers.rb +79 -0
  48. data/lib/piccle/version.rb +3 -0
  49. data/lib/tasks/development.rake +38 -0
  50. data/piccle.gemspec +43 -0
  51. data/templates/_breadcrumbs.handlebars.slim +16 -0
  52. data/templates/_footer.handlebars.slim +2 -0
  53. data/templates/_header.handlebars.slim +36 -0
  54. data/templates/_navigation.handlebars.slim +16 -0
  55. data/templates/_substream.handlebars.slim +17 -0
  56. data/templates/feed.atom.slim +29 -0
  57. data/templates/index.html.handlebars.slim +36 -0
  58. data/templates/show.html.handlebars.slim +64 -0
  59. metadata +340 -0
Binary file
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "piccle"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'fileutils'
4
+ require 'handlebars'
5
+ require 'json'
6
+ require 'slim'
7
+ require 'thor'
8
+ require 'piccle'
9
+
10
+ class CLI < Thor
11
+ default_task :generate
12
+ class_option :"image-dir", desc: "Input image directory. Defaults to $CWD/images.", aliases: "-i"
13
+ class_option :database, desc: "The location of the database to use. Defaults to $CWD/piccle_data.db.", aliases: "-d"
14
+ class_option :config, desc: "Config file to use. Defaults to $CWD/piccle.config.yaml, then ~/.piccle.config.yaml.", aliases: "-c"
15
+ class_option :debug, desc: "Enable debug mode.", type: :boolean, default: false
16
+
17
+ # Prints a banner describing Piccle. qv. https://github.com/erikhuda/thor/issues/612
18
+ def help(*)
19
+ print_wrapped <<~INTRO
20
+ Piccle #{Piccle::VERSION}: a static website generator for photographers.
21
+
22
+ Piccle reads the metadata from your photos and uses it to generate a gallery that lets people explore
23
+ your photos by date, keyword, location, etc. Run "piccle help generate" for more details, or see
24
+ usage instructions at https://piccle.alexpounds.com/.
25
+ INTRO
26
+ puts
27
+ super
28
+ end
29
+
30
+ desc "generate", "Generates a web photo gallery based on image metadata."
31
+ option :"output-dir", desc: "Output directory. Defaults to $CWD/generated.", aliases: "-o"
32
+ option :events, desc: "The location of the events file to use, if any. Defaults to $CWD/events.yaml", aliases: "-e"
33
+ option :"author-name", desc: "Author name.", aliases: "-n"
34
+ option :url, desc: "The URL where you'll deploy your gallery, if any. Used to generate Atom feeds and OpenGraph tags.", aliases: "-u"
35
+ 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
36
+ def generate
37
+ if options.key?("config") && !File.exist?(options["config"])
38
+ puts "Specified config file #{options["config"]} not found."
39
+ exit 1
40
+ end
41
+
42
+ Piccle.config = piccle_config(options)
43
+ report_options
44
+ check_image_dir_exists
45
+
46
+ update_db
47
+ generate_everything
48
+ end
49
+
50
+ desc "geocode", "Retrieve locations from photos, and convert lat/longs to named locations."
51
+ def geocode
52
+ Piccle.config = piccle_config(options)
53
+ report_image_and_config_options
54
+ check_image_dir_exists
55
+
56
+ Piccle::Photo.where(path: Piccle.config.images_dir).each do |photo|
57
+ if File.exist?(File.join(Piccle.config.images_dir, photo.file_name))
58
+ print "Updating location data for #{photo.file_name}... "
59
+
60
+ # Is this photo fully geocoded? That is, does it have lat/long/city/state/country?
61
+ if photo.geocoded?
62
+ puts " Already geocoded, no changes made."
63
+ save_location_data(photo)
64
+
65
+ # Does it have just a lat/long? Either retrieve a cached location record or look it up.
66
+ elsif photo.latitude && photo.longitude
67
+ puts "\n Photo has lat/long, looking for place data... "
68
+ dstk_service = Piccle::DstkService.new
69
+ if location = dstk_service.location_for(photo)
70
+ if photo.update(city: location.city, state: location.state, country: location.country)
71
+ puts " Done."
72
+ else
73
+ puts " Couldn't save data: #{photo.errors.inspect}"
74
+ end
75
+ end
76
+
77
+ # Maybe it's got city/state/country in the DB already.
78
+ else
79
+ places = [photo.city, photo.state, photo.country].compact
80
+ if places.any?
81
+ puts " Photo has metadata labels for #{places.join(", ")}, no changes made."
82
+ else
83
+ puts " No geo information in this photo's metadata, no changes made."
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ protected
91
+
92
+ # Merge our supplied options with a couple of required details, and return a config object.
93
+ def piccle_config(options)
94
+ Piccle::Config.new(options.merge("working_directory" => Dir.pwd, "home_directory" => Dir.home))
95
+ end
96
+
97
+ # How are we going to generate this gallery - based on which options?
98
+ def report_options
99
+ report_image_and_config_options
100
+ option_message("Writing gallery to #{Piccle.config.output_dir}", "output-dir")
101
+ option_message("Photos will be credited to #{Piccle.config.author_name}", "author-name")
102
+
103
+ if Piccle.config.using_default?("events") && !File.exist?(Piccle.config.events_file)
104
+ puts "No events file found."
105
+ elsif File.exist?(Piccle.config.events_file)
106
+ option_message("Events read from #{Piccle.config.events_file}", "events")
107
+ else
108
+ option_message("⚠️ Events file #{Piccle.config.events_file}", "events")
109
+ end
110
+
111
+ puts "⚠️ Not generating an Atom feed, because URL is unset." unless Piccle.config.atom?
112
+ puts "⚠️ Not generating OpenGraph tags, because URL is unset." unless Piccle.config.open_graph?
113
+ puts ""
114
+ end
115
+
116
+ # Geocoding has fewer relevant options, so we can output this little summary instead.
117
+ def report_image_and_config_options
118
+ option_message("Reading images from #{Piccle.config.images_dir}", "image-dir")
119
+
120
+ if Piccle.config.database_exists?
121
+ option_message("Using existing database #{Piccle.config.database_file}", "database")
122
+ else
123
+ option_message("Creating new database #{Piccle.config.database_file}", "database")
124
+ end
125
+ end
126
+
127
+ # Given a message and a parameter name, generate one of our standard report strings.
128
+ def option_message(message, config_param)
129
+ puts "#{message} (#{Piccle.config.source_for(config_param)})"
130
+ end
131
+
132
+ # Read all the images in the images directory, and load their data into the DB.
133
+ def update_db
134
+ Dir.glob(File.join(Piccle.config.images_dir, "**")).each do |filename|
135
+ print "Examining #{filename}..."
136
+ photo = Piccle::Photo.from_file(filename)
137
+ if photo.changed_hash?
138
+ print " updating..."
139
+ photo.update_from_file
140
+ puts " done."
141
+ elsif photo.freshly_created?
142
+ puts " created."
143
+ else
144
+ puts " done."
145
+ end
146
+ end
147
+ end
148
+
149
+ # Ensure we have some images to work with; if not, output an error and quit.
150
+ def check_image_dir_exists
151
+ unless File.exist?(Piccle.config.images_dir)
152
+ 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."
153
+ exit 1
154
+ end
155
+ if Dir.empty?(Piccle.config.images_dir)
156
+ STDERR.puts "\n⚠️ There are no images in #{Piccle.config.images_dir}, so we cannot continue."
157
+ exit 1
158
+ end
159
+ end
160
+
161
+ # Generates an entire site. Atom feeds, HTML templates, smaller images, JSON data, copied assets, the whole enchilada.
162
+ def generate_everything
163
+ start_time = Time.now
164
+ puts "Generating website..."
165
+
166
+ parser = new_parser_with_streams
167
+ parse_photos(parser)
168
+ renderer = if Piccle.config.ruby_renderer?
169
+ Piccle::Renderer.new(parser)
170
+ else
171
+ Piccle::JsRenderer.new(parser)
172
+ end
173
+
174
+ FileUtils.mkdir_p(Piccle.config.output_dir)
175
+ generate_templates(renderer)
176
+ generate_atom_feeds(parser, renderer)
177
+ generate_html_indexes(parser, renderer)
178
+ generate_html_photos(parser, renderer)
179
+ generate_json(parser, renderer)
180
+ generate_thumbnails
181
+ generate_quilts(parser)
182
+ copy_assets
183
+ puts "Website generated in #{(Time.now - start_time)} seconds."
184
+ end
185
+
186
+ # Get a parser, with streams (metadata filters and extractors) registered.
187
+ def new_parser_with_streams
188
+ Piccle::Parser.new.tap do |p|
189
+ p.add_stream(Piccle::Streams::DateStream)
190
+ p.add_stream(Piccle::Streams::LocationStream)
191
+ p.add_stream(Piccle::Streams::EventStream)
192
+ p.add_stream(Piccle::Streams::CameraStream)
193
+ p.add_stream(Piccle::Streams::KeywordStream)
194
+ end
195
+ end
196
+
197
+ # Load all the photos, and parse them all.
198
+ def parse_photos(parser)
199
+ Piccle::Photo.where(path: Piccle.config.images_dir).each do |p|
200
+ parser.parse(p)
201
+ end
202
+ parser.load_events
203
+ parser.order
204
+ end
205
+
206
+ # Given a parser object, generate some HTML index pages from the data it contains.
207
+ def generate_html_indexes(parser, renderer)
208
+ puts " ... generating HTML indexes ..."
209
+ print " ... generating main index ... "
210
+ File.write(File.join(Piccle.config.output_dir, "index.html"), renderer.render_main_index)
211
+ puts "Done."
212
+
213
+ parser.subsections.each do |subsection|
214
+ if parser.subsection_photo_hashes(subsection).any?
215
+ subdir = File.join(Piccle.config.output_dir, *subsection)
216
+ print " ... generating #{subdir} index ... "
217
+ FileUtils.mkdir_p(subdir)
218
+ File.write(File.join(subdir, "index.html"), renderer.render_index(subsection))
219
+ puts "Done."
220
+ end
221
+ end
222
+ end
223
+
224
+ # Given a parser object, generate Atom feeds for everything, and all substreams.
225
+ def generate_atom_feeds(parser, renderer)
226
+ if Piccle.config.atom?
227
+ puts " ... generating Atom feeds ..."
228
+ print " ... generating main Atom feed ... "
229
+ File.write(File.join(Piccle.config.output_dir, "feed.atom"), renderer.render_feed)
230
+ puts "Done."
231
+
232
+ parser.subsections.each do |subsection|
233
+ if parser.subsection_photo_hashes(subsection).any?
234
+ subdir = File.join(Piccle.config.output_dir, *subsection)
235
+ print " ... generating #{subdir} feed ... "
236
+ FileUtils.mkdir_p(subdir)
237
+ File.write(File.join(subdir, "feed.atom"), renderer.render_feed(subsection))
238
+ puts "Done."
239
+ end
240
+ end
241
+ else
242
+ puts " Not generating Atom feeds, because no home URL is set."
243
+ end
244
+ end
245
+
246
+ # Given a parser object, generate photo pages from the data it contains.
247
+ def generate_html_photos(parser, renderer)
248
+ puts " ... generating HTML photo pages ..."
249
+ parser.photo_hashes.each do |hash|
250
+ print " ... generating canonical page for #{hash}... "
251
+ File.write(File.join(Piccle.config.output_dir, "#{hash}.html"), renderer.render_photo(hash))
252
+ puts "Done."
253
+
254
+ parser.links_for(hash).each do |selector|
255
+ destination_page = File.join(Piccle.config.output_dir, *selector, "#{hash}.html")
256
+ print " ... generating stream page #{destination_page}..."
257
+ File.write(destination_page, renderer.render_photo(hash, selector))
258
+ puts "Done."
259
+ end
260
+ end
261
+ end
262
+
263
+ def generate_json(parser, _renderer)
264
+ puts " ... generating JSON files..."
265
+ FileUtils.mkdir_p(File.join(Piccle.config.output_dir, "json"))
266
+ File.write(File.join(Piccle.config.output_dir, "json", "all.json"), parser.data.to_json)
267
+ end
268
+
269
+
270
+ # Stubby, hacky method that demos generating thumbnails.
271
+ def generate_thumbnails
272
+ puts " ... generating thumbnails..."
273
+ FileUtils.mkdir_p(File.join(Piccle.config.output_dir, "images", "thumbnails"))
274
+ FileUtils.mkdir_p(File.join(Piccle.config.output_dir, "images", "photos"))
275
+
276
+ Piccle::Photo.where(path: Piccle.config.images_dir).each do |photo|
277
+ print " ... generating #{photo.thumbnail_path}... "
278
+ if photo.thumbnail_exists?
279
+ puts "Already exists, skipping."
280
+ else
281
+ photo.generate_thumbnail!
282
+ puts "Done."
283
+ end
284
+
285
+ print " ... generating #{photo.full_image_path}... "
286
+ if photo.full_image_exists?
287
+ puts "Already exists, skipping."
288
+ else
289
+ photo.generate_full_image!
290
+ puts "Done."
291
+ end
292
+ end
293
+ end
294
+
295
+ # Generates "quilts" - stitched together images for each section, which we use in OpenGraph tags.
296
+ def generate_quilts(parser)
297
+ puts "Generating gallery quilts (preview images for sharing galleries on social media)..."
298
+ if Piccle.config.open_graph?
299
+ thumbnail_path_proc = Proc.new { |k, v| File.join(Piccle.config.output_dir, "images", "thumbnails", "#{v[:hash]}.#{v[:file_name]}") }
300
+
301
+ print " ... Creating main index quilt..."
302
+ main_thumbnails = parser.data[:photos].first(9).map(&thumbnail_path_proc)
303
+ main_quilt = Piccle::QuiltGenerator.generate_for(main_thumbnails)
304
+ main_quilt.write(File.join(Piccle.config.output_dir, "quilt.jpg"))
305
+ puts " Done."
306
+
307
+ parser.subsections.each do |subsection|
308
+ thumbnails = parser.subsection_photos(subsection).map(&thumbnail_path_proc)
309
+ if thumbnails.any?
310
+ output_path = File.join(Piccle.config.output_dir, *subsection, "quilt.jpg")
311
+ print " ... Creating gallery quilt #{output_path}..."
312
+ quilt = Piccle::QuiltGenerator.generate_for(thumbnails.first(9))
313
+ quilt.write(output_path)
314
+ puts " Done."
315
+ end
316
+ end
317
+ else
318
+ puts " Not generating gallery quilt images, because no home URL is set."
319
+ end
320
+ end
321
+
322
+ def generate_templates(_renderer)
323
+ puts " ... generating templates..."
324
+ FileUtils.mkdir_p(File.join(Piccle.config.output_dir, "js"))
325
+ File.write(File.join(Piccle.config.output_dir, "js", "index.handlebars"), Piccle::TemplateHelpers.compile_template("index"))
326
+ File.write(File.join(Piccle.config.output_dir, "js", "show.handlebars"), Piccle::TemplateHelpers.compile_template("show"))
327
+ end
328
+
329
+ # Copy our static assets into the expected location.
330
+ def copy_assets
331
+ puts " ... copying static assets..."
332
+ puts " ... copying CSS..."
333
+ copy_asset_type("css")
334
+ puts " ... copying icons..."
335
+ copy_asset_type("icons")
336
+ end
337
+
338
+ def copy_asset_type(type)
339
+ FileUtils.mkdir_p(File.join(Piccle.config.output_dir, type))
340
+ Dir.glob("#{Piccle.config.gem_root_join("assets", type)}/**").each do |f|
341
+ FileUtils.cp(f, File.join(Piccle.config.output_dir, type, File.basename(f)))
342
+ end
343
+ end
344
+
345
+ # Take geo information from a photo, and save it to our Piccle database.
346
+ def save_location_data(photo)
347
+ unless Piccle::Location.find(latitude: photo.latitude, longitude: photo.longitude)
348
+ Piccle::Location.create(latitude: photo.latitude, longitude: photo.longitude, city: photo.city,
349
+ state: photo.state, country: photo.country)
350
+ end
351
+ end
352
+
353
+ end
354
+
355
+ CLI.start(ARGV)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,15 @@
1
+ Sequel.migration do
2
+ change do
3
+ create_table(:photos) do
4
+ primary_key :id
5
+ String :file_name, null: false, text: true
6
+ String :path, null: false, text: true
7
+ String :md5, null: false
8
+ Integer :width, null: false
9
+ Integer :height, null: false
10
+ String :camera_name, null: false, text: true
11
+ DateTime :taken_at
12
+ DateTime :created_at
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ Sequel.migration do
2
+ change do
3
+ alter_table(:photos) do
4
+ add_column :title, String, text: true
5
+ add_column :description, String, text: true
6
+ add_column :aperture, Float
7
+ add_column :shutter_speed_numerator, Integer
8
+ add_column :shutter_speed_denominator, Integer
9
+ add_column :iso, Integer
10
+ add_column :latitude, Float
11
+ add_column :longitude, Float
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ Sequel.migration do
2
+ change do
3
+ create_table(:keywords) do
4
+ primary_key :id
5
+ String :name, null: false, unique: true
6
+ end
7
+
8
+ create_table(:keywords_photos) do
9
+ Integer :photo_id, null: false
10
+ Integer :keyword_id, null: false
11
+ primary_key [:photo_id, :keyword_id], name: :keywords_photos_pk
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,7 @@
1
+ Sequel.migration do
2
+ change do
3
+ alter_table(:photos) do
4
+ add_column :focal_length, Float
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,20 @@
1
+ Sequel.migration do
2
+ change do
3
+ create_table(:locations) do
4
+ primary_key :id
5
+ Float :latitude, null: false
6
+ Float :longitude, null: false
7
+ String :city, text: true
8
+ String :state, text: true
9
+ String :country, text: true
10
+ DateTime :created_at
11
+ check { (city !~ nil) | (state !~ nil) | (country !~ nil) }
12
+ end
13
+
14
+ alter_table(:photos) do
15
+ add_column :city, String, text: true
16
+ add_column :state, String, text: true
17
+ add_column :country, String, text: true
18
+ end
19
+ end
20
+ end