piccle 0.1.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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