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
@@ -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