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
@@ -0,0 +1,30 @@
1
+ require 'rmagick'
2
+
3
+ # Generates an image quilt, ideal for OpenGraph previews.
4
+ module Piccle
5
+ class QuiltGenerator
6
+ # Generates a quilt of the given images - up to 9.
7
+ def self.generate_for(photo_paths)
8
+ photo_paths = photo_paths.first(9)
9
+ image_list = Magick::ImageList.new(*photo_paths)
10
+ image_list.montage do |conf|
11
+ conf.border_width = 0
12
+ conf.geometry = "200x200+0+0"
13
+ end
14
+ end
15
+
16
+ # Returns a tuple of [width, height] output dimensions. When we stitch a quilt together each square is 200px,
17
+ # but variable numbers of images can lead to quilts of different sizes. OpenGraph tags should include this
18
+ # size information.
19
+ def self.dimensions_for(image_count)
20
+ case image_count
21
+ when 1 then [200, 200]
22
+ when 2 then [400, 200]
23
+ when 3 then [600, 200]
24
+ when 4 then [400, 400]
25
+ when 5..6 then [600, 400]
26
+ else [600, 600]
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,175 @@
1
+ module Piccle
2
+ # Renders a bunch of pages, based on the hash data loaded by the given parser.
3
+ class Renderer
4
+ def initialize(parser)
5
+ @parser = parser
6
+ @extractor = Piccle::Extractor.new(parser)
7
+ end
8
+
9
+ # Given an array that contains a path (not including a :photos key), get that photo
10
+ # data and render an index page using the :photos data found at that location.
11
+ #
12
+ # For instance, if selector was ["by-date", "2015"] you'd get an index page of photos
13
+ # for 2015 based on the data held by the parser.
14
+ def render_index(selector)
15
+ Piccle::TemplateHelpers.render("index", render_index_template_vars(selector))
16
+ end
17
+
18
+ # Renders the "main" index – the front page of our site.
19
+ def render_main_index
20
+ Piccle::TemplateHelpers.render("index", render_main_index_template_vars)
21
+ end
22
+
23
+ # Renders an Atom feed of the given subsection.
24
+ def render_feed(selector = [])
25
+ photos = @parser.subsection_photos(selector).sort_by { |k, p| p[:created_at] }.reverse
26
+ escaped_selector = selector.map { |s| CGI::escape(s) }
27
+
28
+ template_vars = {
29
+ photos: photos,
30
+ joined_selector: "/#{escaped_selector.join("/")}/",
31
+ feed_update_time: photos.map { |k, v| v[:created_at] }.max,
32
+ selector: selector,
33
+ site_metadata: site_metadata
34
+ }
35
+
36
+ Piccle::TemplateHelpers.render_rss("feed", template_vars)
37
+ end
38
+
39
+ # Render a page for a specific photo.
40
+ def render_photo(hash, selector=[])
41
+ Piccle::TemplateHelpers.render("show", render_photo_template_vars(hash, selector))
42
+ end
43
+
44
+ protected
45
+
46
+ # Returns all the data we pass into the main index to render.
47
+ def render_main_index_template_vars
48
+ template_vars = {
49
+ photos: @parser.data[:photos],
50
+ event_starts: @parser.data[:event_starts],
51
+ event_ends: @parser.data[:event_ends],
52
+ nav_items: @extractor.navigation,
53
+ site_metadata: site_metadata
54
+ }
55
+
56
+ if Piccle.config.open_graph?
57
+ width, height = Piccle::QuiltGenerator.dimensions_for(@parser.data[:photos].length)
58
+ template_vars[:open_graph] = open_graph_for(title: site_title(),
59
+ description: "A gallery of photos by #{Piccle.config.author_name}",
60
+ image_url: "#{Piccle.config.home_url}quilt.jpg",
61
+ image_alt: "A quilt of the most recent images in this gallery.",
62
+ width: width,
63
+ height: height,
64
+ page_url: Piccle.config.home_url)
65
+ end
66
+ template_vars
67
+ end
68
+
69
+ # Returns all the data we pass into a template for rendering an index page, as a hash.
70
+ def render_index_template_vars(selector)
71
+ breadcrumbs = @extractor.breadcrumbs_for(selector)
72
+ selector_path = "#{selector.join('/')}/"
73
+ template_vars = {
74
+ photos: @parser.subsection_photos(selector),
75
+ event_starts: [],
76
+ event_ends: [],
77
+ nav_items: @extractor.navigation,
78
+ selector: selector,
79
+ selector_path: selector_path,
80
+ breadcrumbs: breadcrumbs,
81
+ site_url: Piccle.config.home_url,
82
+ include_prefix: Piccle::TemplateHelpers.include_prefix(selector),
83
+ site_metadata: site_metadata
84
+ }
85
+
86
+ if Piccle.config.open_graph?
87
+ width, height = Piccle::QuiltGenerator.dimensions_for(@parser.subsection_photo_hashes(selector).length)
88
+ template_vars[:open_graph] = open_graph_for(title: site_title(breadcrumbs),
89
+ description: "A gallery of photos by #{Piccle.config.author_name}",
90
+ image_url: "#{Piccle.config.home_url}#{selector_path}quilt.jpg",
91
+ image_alt: "A quilt of the most recent images in this gallery.",
92
+ width: width,
93
+ height: height,
94
+ page_url: "#{Piccle.config.home_url}#{selector_path}")
95
+ end
96
+
97
+ template_vars
98
+ end
99
+
100
+ # Returns all the template vars we use to render a photo page.
101
+ def render_photo_template_vars(hash, selector)
102
+ photo_data = @parser.data[:photos][hash]
103
+ substreams = [@parser.substream_for(hash)] + @parser.links_for(hash).map { |selector| @parser.interesting_substream_for(hash, selector) }.compact
104
+
105
+ template_vars = {
106
+ photo: photo_data,
107
+ selector: selector,
108
+ selector_path: selector.any? ? "#{selector.join('/')}/" : "",
109
+ breadcrumbs: @extractor.breadcrumbs_for(selector),
110
+ substreams: substreams.select { |stream| stream[:photos].length > 1 },
111
+ camera_link: @extractor.camera_link(hash),
112
+ keywords: @extractor.keywords(hash),
113
+ day_link: @extractor.day_link(hash),
114
+ month_link: @extractor.month_link(hash),
115
+ year_link: @extractor.year_link(hash),
116
+ city_link: @extractor.city_link(hash),
117
+ state_link: @extractor.state_link(hash),
118
+ country_link: @extractor.country_link(hash),
119
+ prev_link: @extractor.prev_link(hash, selector),
120
+ next_link: @extractor.next_link(hash, selector),
121
+ include_prefix: Piccle::TemplateHelpers.include_prefix(selector),
122
+ canonical: "photos/#{hash}.html", # TODO: Other paths live in piccle.rake. Why's this one here?
123
+ site_metadata: site_metadata
124
+ }
125
+
126
+ photo_title = photo_data[:title] || ""
127
+ photo_title = "Photo" if photo_title.empty?
128
+ template_vars[:breadcrumbs] << { friendly_name: photo_title } if selector.any?
129
+
130
+ if Piccle.config.open_graph?
131
+ template_vars[:open_graph] = open_graph_for(title: photo_data[:title] || "A photo by #{Piccle.config.author_name}",
132
+ description: photo_data[:description],
133
+ image_url: "#{Piccle.config.home_url}images/photos/#{hash}.#{photo_data[:file_name]}",
134
+ width: photo_data[:width],
135
+ height: photo_data[:height],
136
+ page_url: "#{Piccle.config.home_url}/#{hash}.html")
137
+ end
138
+
139
+ template_vars
140
+ end
141
+
142
+ # Gets information about our site, used on pretty much every page.
143
+ def site_metadata
144
+ unless @cached_site_metadata
145
+ min_year = Piccle::Photo.earliest_photo_year
146
+ max_year = Piccle::Photo.latest_photo_year
147
+ copyright_year = if min_year == max_year
148
+ max_year
149
+ else
150
+ "#{min_year} – #{max_year}"
151
+ end
152
+
153
+ @cached_site_metadata = { author_name: Piccle.config.author_name, copyright_year: copyright_year }
154
+ end
155
+ @cached_site_metadata
156
+ end
157
+
158
+ # Returns a hash of open graph data based on the parameters passed in.
159
+ def open_graph_for(title: nil, description: nil, image_url: nil, image_alt: nil, width: nil, height: nil, page_url: nil)
160
+ open_graph = { title: title, url: page_url, image: { width: width, height: height, url: image_url } }
161
+ open_graph[:image][:image_alt] = image_alt if image_alt
162
+ open_graph[:description] = description if description
163
+ open_graph
164
+ end
165
+
166
+ # Returns a human-readable title for this site.
167
+ def site_title(breadcrumbs = [])
168
+ title = "Photography by #{Piccle.config.author_name}"
169
+ if breadcrumbs.any?
170
+ title += " - " + breadcrumbs.map { |b| b[:friendly_name] }.join(" - ")
171
+ end
172
+ title
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,2 @@
1
+ class Piccle::Streams
2
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Streams are a self-contained way of faceting data for a photo.
4
+
5
+ class Piccle::Streams::BaseStream
6
+ #
7
+ def namespace
8
+ "by-foo"
9
+ end
10
+
11
+ # Returns a hash that contains data to merge for the given photo.
12
+ def data_for(photo)
13
+ {}
14
+ end
15
+
16
+ # def metadata_for(photo)
17
+ # [{}]
18
+ # end
19
+
20
+ # Reorder the data within this stream.
21
+ #
22
+ # The parser calls #order on every stream once it's loaded all the photos. You can reorder your data as you see fit.
23
+ # For instance, most general streams will want to order their subphotos by date - but if you're a keyword stream,
24
+ # you might also put the most popular keywords first.
25
+ #
26
+ # The default implementation organises facets with the most popular items first, and reorders the photos by date.
27
+ #
28
+ # Each stream is expected to only meddle within its own namespace, but this is not enforced.
29
+ def order(data)
30
+ if data.key?(namespace)
31
+ data[namespace] = data[namespace].sort_by(&length_sort_proc(data)).reverse.to_h
32
+ data[namespace].each do |k, v|
33
+ data[namespace][k][:photos] = data[namespace][k][:photos].sort_by(&date_sort_proc(data)).reverse if k.is_a?(String)
34
+ end
35
+ end
36
+
37
+ data
38
+ end
39
+
40
+ protected
41
+
42
+ # A sort proc designed for hashes. Sorts all string keys in order of how many photos they contain.
43
+ def length_sort_proc(data)
44
+ Proc.new { |k, v| k.is_a?(String) ? data.dig(namespace, k, :photos)&.length : 0 }
45
+ end
46
+
47
+ # A date sort designed for arrays. Sorts all photo hashes in order of the date they were taken.
48
+ def date_sort_proc(data)
49
+ Proc.new { |hash| data.dig(:photos, hash, :taken_at) || Time.new(1970, 1, 1) }
50
+ end
51
+
52
+ # Converts a sentence into something suitable for use in a URL slug.
53
+ def slugify(name)
54
+ name.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/^-+/, '').gsub(/-+$/, '')
55
+ end
56
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Browse photos by camera.
4
+ class Piccle::Streams::CameraStream < Piccle::Streams::BaseStream
5
+ def namespace
6
+ "by-camera"
7
+ end
8
+
9
+ def data_for(photo)
10
+ {
11
+ namespace => {
12
+ :friendly_name => "By Camera",
13
+ :interesting => false,
14
+ slugify(camera_name(photo)) => {
15
+ friendly_name: camera_name(photo),
16
+ photos: [photo.md5]
17
+ },
18
+ }
19
+ }
20
+ end
21
+
22
+ def metadata_for(photo)
23
+ [{
24
+ friendly_name: camera_name(photo),
25
+ type: :camera,
26
+ selector: [namespace, slugify(camera_name(photo))]
27
+ }]
28
+ end
29
+
30
+ protected
31
+
32
+ def camera_name(photo)
33
+ photo.camera_name || "unknown"
34
+ end
35
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Enables browsing photos by date.
4
+
5
+ class Piccle::Streams::DateStream < Piccle::Streams::BaseStream
6
+ def namespace
7
+ "by-date"
8
+ end
9
+
10
+ # Standard method called by the parser object. This should return a hash that contains sub-categories (optionally)
11
+ # and a list of :photos for each tier.
12
+ def data_for(photo)
13
+ year, month, day = photo.taken_at&.year, photo.taken_at&.month, photo.taken_at&.day
14
+ if year && month && day
15
+ { namespace => {
16
+ :friendly_name => "By Date",
17
+ :interesting => false,
18
+ year.to_s => {
19
+ :friendly_name => "#{year}",
20
+ :interesting => false,
21
+ month.to_s => {
22
+ :friendly_name => "#{Date::MONTHNAMES[month]}",
23
+ :interesting => false,
24
+ day.to_s => {
25
+ :friendly_name => "#{day}#{ordinal_for(day)}",
26
+ :interesting => false,
27
+ :photos => [photo.md5]
28
+ },
29
+ :photos => [photo.md5]
30
+ },
31
+ photos: [photo.md5]
32
+ }
33
+ }}
34
+ else
35
+ {}
36
+ end
37
+ end
38
+
39
+ def metadata_for(photo)
40
+ year, month, day = photo.taken_at&.year, photo.taken_at&.month, photo.taken_at&.day
41
+ if year && month && day
42
+ [{ friendly_name: "#{day}#{ordinal_for(day)}",
43
+ type: :date_day,
44
+ selector: [namespace, year, month, day]
45
+ }, {
46
+ friendly_name: "#{Date::MONTHNAMES[month]}",
47
+ type: :date_month,
48
+ selector: [namespace, year, month]
49
+ }, {
50
+ friendly_name: year.to_s,
51
+ type: :date_year,
52
+ selector: [namespace, year]
53
+ }]
54
+ else
55
+ []
56
+ end
57
+ end
58
+
59
+ # Standard method called by the parser object. Gives this stream an option to re-order its data. The stream is on
60
+ # its honour to only meddle within its own namespace.
61
+ def order(data)
62
+ sort_proc = Proc.new { |k, v| k.is_a?(String) ? k : "" }
63
+
64
+ data[namespace] = data[namespace].sort_by(&sort_proc).to_h # Sort years
65
+
66
+ data[namespace].each do |year_k, v|
67
+ # Sort photos in each year, and then the month keys
68
+ if year_k.is_a?(String)
69
+ if data[namespace][year_k].key?(:photos)
70
+ data[namespace][year_k][:photos] = data[namespace][year_k][:photos].sort_by(&date_sort_proc(data)).reverse
71
+ end
72
+ data[namespace][year_k] = data[namespace][year_k].sort_by(&sort_proc).to_h
73
+
74
+ data[namespace][year_k].each do |month_k, v|
75
+ # Sort photos in each month, and then the days. TODO.
76
+ end
77
+ end
78
+ end
79
+ data
80
+ end
81
+
82
+ protected
83
+
84
+ def ordinal_for(num)
85
+ if num % 10 == 1 && num != 11
86
+ "st"
87
+ elsif num % 10 == 2 && num != 12
88
+ "nd"
89
+ elsif num % 10 == 3 && num != 13
90
+ "rd"
91
+ else
92
+ "th"
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,73 @@
1
+ require 'yaml'
2
+
3
+ # A special-case stream that handles named "events". You can define details in events.yaml - with things like a name,
4
+ # dates, and whether it should be collapsed or not on the front page.
5
+
6
+ class Piccle::Streams::EventStream < Piccle::Streams::BaseStream
7
+ attr_accessor :events
8
+
9
+ def namespace
10
+ "by-event"
11
+ end
12
+
13
+ def initialize
14
+ @events = if File.exist?(Piccle.config.events_file)
15
+ YAML.load_file(Piccle.config.events_file).map do |event| # Convert keys to symbols; bring dates to life.
16
+ event = event.map { |k, v| [k.to_sym, v] }.to_h
17
+ if event[:from].is_a? Date
18
+ event[:from] = DateTime.new(event[:from].year, event[:from].month, event[:from].day, 0, 0, 0)
19
+ end
20
+ if event[:to].is_a? Date
21
+ event[:to] = DateTime.new(event[:to].year, event[:to].month, event[:to].day, 23, 59, 59)
22
+ end
23
+ event
24
+ end
25
+ else
26
+ []
27
+ end
28
+ end
29
+
30
+ def data_for(photo)
31
+ if photo.taken_at
32
+ relevant_events = @events.select { |ev| photo.taken_at.to_datetime >= ev[:from] && photo.taken_at.to_datetime <= ev[:to] }
33
+ result = { namespace => { friendly_name: "By Event", interesting: true }}
34
+ relevant_events.each do |ev|
35
+ result[namespace][slugify(ev[:name])] = { friendly_name: ev[:name], interesting: true, photos: [photo.md5] }
36
+ end
37
+
38
+ result
39
+ else
40
+ {}
41
+ end
42
+ end
43
+
44
+ # Sorts most recent events first; then organises photos by date. TODO
45
+ def order(data)
46
+ super(data)
47
+ end
48
+
49
+ # Given an event name, get a selector hash for this event.
50
+ def selector_for(name)
51
+ [namespace, slugify(name)]
52
+ end
53
+
54
+ # "Sentinels" are data hashes that mark the start and end of an event. We use them to render tiles in the overall
55
+ # index page, if we have photos for the declared event. Each event has an event_start and an event_end marker.
56
+ #
57
+ # Returns an array of 2 items: [event_starts, event_ends]
58
+ def sentinels_for(data)
59
+ event_starts = {}
60
+ event_ends = {}
61
+ @events.each do |event|
62
+ slug = slugify(event[:name])
63
+ if data.dig(namespace, slug, :photos)&.any?
64
+ most_recent_hash = data[namespace][slug][:photos].first
65
+ oldest_hash = data[namespace][slug][:photos].last # Event starts are the furthest back in time!
66
+ event_starts[oldest_hash] = { name: event[:name], selector: selector_for(event[:name]) }
67
+ event_ends[most_recent_hash] = { name: event[:name], selector: selector_for(event[:name]) }
68
+ end
69
+ end
70
+
71
+ [event_starts, event_ends]
72
+ end
73
+ end