piccle 0.1.0.rc1 → 0.1.1.pre

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,14 @@
1
+ Sequel.migration do
2
+ change do
3
+ create_table(:people) do
4
+ primary_key :id
5
+ String :name, null: false, unique: true
6
+ end
7
+
8
+ create_table(:people_photos) do
9
+ Integer :person_id, null: false
10
+ Integer :photo_id, null: false
11
+ primary_key [:person_id, :photo_id], name: :people_photos_pk
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ Sequel.migration do
2
+ change do
3
+ alter_table(:photos) do
4
+ add_index :md5, unique: true
5
+ add_index :file_name
6
+ add_index :path
7
+ add_index [:path, :file_name], unique: true
8
+ end
9
+
10
+ # Keywords and people are case-insensitive, enforce that in the DB
11
+ alter_table(:keywords) { add_index Sequel.function(:lower, :name), unique: true }
12
+ alter_table(:people) { add_index Sequel.function(:lower, :name), unique: true }
13
+ end
14
+ end
data/lib/piccle.rb CHANGED
@@ -13,6 +13,7 @@ require "piccle/streams/date_stream"
13
13
  require "piccle/streams/event_stream"
14
14
  require "piccle/streams/keyword_stream"
15
15
  require "piccle/streams/location_stream"
16
+ require "piccle/streams/person_stream"
16
17
  require "piccle/template_helpers"
17
18
  require "piccle/version"
18
19
 
@@ -38,9 +39,9 @@ module Piccle
38
39
  def Piccle.const_missing(name)
39
40
  Sequel::Model.db = Piccle.config.db
40
41
 
41
- if %i[Photo Keyword Location].include?(name)
42
+ if %i[Keyword Location Person Photo].include?(name)
42
43
  Dir[Piccle.config.gem_root_join("lib", "piccle", "models", "*.rb")].each { |f| require f.delete_prefix("lib/").delete_suffix(".rb") }
43
- models = [Piccle::Photo, Piccle::Keyword, Piccle::Location]
44
+ models = [Piccle::Keyword, Piccle::Location, Piccle::Person, Piccle::Photo]
44
45
  models.each(&:finalize_associations)
45
46
  models.each(&:freeze)
46
47
  end
@@ -51,7 +51,7 @@ module Piccle
51
51
 
52
52
  index = hashes.index(photo_hash)
53
53
  if index && index > 0
54
- "#{(selector + [hashes[index-1]]).join("/")}.html"
54
+ "#{hashes[index-1]}.html"
55
55
  else
56
56
  nil
57
57
  end
@@ -64,22 +64,31 @@ module Piccle
64
64
 
65
65
  index = hashes.index(photo_hash)
66
66
  if index && index < (hashes.length - 1)
67
- "#{(selector + [hashes[index + 1]]).join("/")}.html"
67
+ "#{hashes[index + 1]}.html"
68
68
  else
69
69
  nil
70
70
  end
71
71
  end
72
72
 
73
+ # Gets the photos to show on the main index page. This is everything, except for photos within a collapsed event.
74
+ # We keep the first photo, but this is typically rendererd as a quilt tile rather than the photo itself.
75
+ def main_index_photos
76
+ all_photos = @parser.data[:photos]
77
+ collapsed_events = @parser.data[:events].select { |ev| ev[:collapsed] }
78
+ removable_hashes = collapsed_events.flat_map { |ev| @parser.subsection_photo_hashes(ev[:selector]).drop(1) }
79
+ all_photos.reject { |k, _v| removable_hashes.include?(k) }
80
+ end
81
+
73
82
  # Gets a (currently top-level only) navigation structure. All entries have at least one photo.
74
83
  def navigation
75
84
  navigation_entries = @parser.faceted_data.map do |k, v|
76
85
  { friendly_name: v[:friendly_name],
77
- entries: entries_for(v, k)
86
+ min_for_nav: v[:min_for_nav],
87
+ entries: entries_for(v, k),
78
88
  }
79
89
  end
80
- # A hack, to only show keywords that have more than one image.
81
90
  navigation_entries.each do |nav_entry|
82
- nav_entry[:entries].keep_if { |entry| entry[:photo_count] > 1 } if "By Topic" == nav_entry[:friendly_name]
91
+ nav_entry[:entries].keep_if { |entry| entry[:photo_count] >= nav_entry[:min_for_nav] } if nav_entry[:min_for_nav]
83
92
  end
84
93
  end
85
94
 
@@ -9,11 +9,13 @@ module Piccle
9
9
  end
10
10
 
11
11
  def render_main_index
12
- call_nodejs("index", render_main_index_template_vars)
12
+ template_vars = paginate(render_main_index_template_vars)
13
+ template_vars.map { |page_template_vars| call_nodejs("index", page_template_vars) }
13
14
  end
14
15
 
15
16
  def render_index(selector)
16
- call_nodejs("index", render_index_template_vars(selector))
17
+ template_vars = paginate(render_index_template_vars(selector))
18
+ template_vars.map { |page_template_vars| call_nodejs("index", page_template_vars) }
17
19
  end
18
20
 
19
21
  def render_photo(hash, selector = [])
@@ -0,0 +1,6 @@
1
+ require 'sequel'
2
+
3
+ # Represents a real-life person. Uses the IPTC4 "PersonInImage" field in XMP data.
4
+ class Piccle::Person < Sequel::Model
5
+ many_to_many :photos
6
+ end
@@ -8,6 +8,7 @@ require 'json'
8
8
  # Represents an image in the system. Reading info from an image? Inferring something based on the data? Put it here.
9
9
  class Piccle::Photo < Sequel::Model
10
10
  many_to_many :keywords
11
+ many_to_many :people
11
12
  attr_accessor :changed_hash # Has this file been modified?
12
13
  attr_accessor :freshly_created # Have we just generated this file?
13
14
 
@@ -29,7 +30,10 @@ class Piccle::Photo < Sequel::Model
29
30
  photo.freshly_created = freshly_created
30
31
 
31
32
  # Pull out keywords for this file, if it's new or changed.
32
- photo.generate_keywords if freshly_created || photo.changed_hash?
33
+ if freshly_created || photo.changed_hash?
34
+ photo.sync_keywords
35
+ photo.sync_people
36
+ end
33
37
 
34
38
  photo
35
39
  end
@@ -183,16 +187,36 @@ class Piccle::Photo < Sequel::Model
183
187
  end
184
188
 
185
189
  # Read the keywords from the photo file, and ensure they're included in the DB.
186
- # TODO: remove any keywords that aren't currently in the file.
187
- def generate_keywords
190
+ def sync_keywords
188
191
  exif_info = EXIFR::JPEG.new(original_photo_path)
189
192
  xmp = XMP.parse(exif_info)
190
193
 
191
194
  if xmp && xmp.namespaces && xmp.namespaces.include?("dc") && xmp.dc.attributes.include?("subject")
192
- xmp.dc.subject.each do |keyword|
195
+ # Add new keywords
196
+ downcased_keywords = xmp.dc.subject.map(&:downcase)
197
+ downcased_keywords.each do |keyword|
193
198
  keyword = Piccle::Keyword.find_or_create(name: keyword)
194
199
  add_keyword(keyword) unless keywords.include?(keyword)
195
200
  end
201
+
202
+ # Remove old keywords
203
+ keywords.each { |keyword| remove_keyword(keyword) unless downcased_keywords.include?(keyword.name.downcase) }
204
+ end
205
+ end
206
+
207
+ def sync_people
208
+ exif_info = EXIFR::JPEG.new(original_photo_path)
209
+ xmp = XMP.parse(exif_info)
210
+
211
+ if xmp && xmp.namespaces && xmp.namespaces.include?("Iptc4xmpExt") && xmp.Iptc4xmpExt.attributes.include?("PersonInImage")
212
+ people_names = xmp.Iptc4xmpExt.PersonInImage
213
+ people_names.each do |name|
214
+ person = Piccle::Person.find(Sequel.ilike(:name, name)) || Piccle::Person.create(name: name)
215
+ add_person(person) unless people.include?(person)
216
+ end
217
+
218
+ downcased_names = people_names.map(&:downcase)
219
+ people.each { |person| remove_person(person) unless downcased_names.include?(person.name.downcase) }
196
220
  end
197
221
  end
198
222
 
data/lib/piccle/parser.rb CHANGED
@@ -120,7 +120,8 @@ module Piccle
120
120
 
121
121
  # Get photo hashes in a given subsection, given a diggable path.
122
122
  def subsection_photo_hashes(subsection_path)
123
- @data.dig(*subsection_path).fetch(:photos, [])
123
+ subsection = @data.dig(*subsection_path)
124
+ subsection ? subsection.fetch(:photos, []) : []
124
125
  end
125
126
 
126
127
  # Gets the actual photo objects for a given subsection.
@@ -132,6 +133,11 @@ module Piccle
132
133
  end
133
134
  end
134
135
 
136
+ # Determines whether the given subsection is marked as "collapsed". This is currently specific to event streams.
137
+ def subsection_collapsed?(subsection_path)
138
+ @data.dig(*subsection_path).fetch(:collapsed, false)
139
+ end
140
+
135
141
  # Given an MD5 hash, returns an array of arrays. Each array is a set of strings that, combined with the MD5, gives a link to the photo.
136
142
  # So for instance, with a date stream parser, if a photo was taken on 2016-04-19 you'll get back:
137
143
  # [["by-date", "2016"], ["by-date", "2016", "4"], ["by-date", "2016", "4", "19"]]
@@ -12,12 +12,14 @@ module Piccle
12
12
  # For instance, if selector was ["by-date", "2015"] you'd get an index page of photos
13
13
  # for 2015 based on the data held by the parser.
14
14
  def render_index(selector)
15
- Piccle::TemplateHelpers.render("index", render_index_template_vars(selector))
15
+ template_vars = paginate(render_index_template_vars(selector))
16
+ template_vars.map { |page_template_vars| Piccle::TemplateHelpers.render("index", page_template_vars) }
16
17
  end
17
18
 
18
19
  # Renders the "main" index – the front page of our site.
19
20
  def render_main_index
20
- Piccle::TemplateHelpers.render("index", render_main_index_template_vars)
21
+ template_vars = paginate(render_main_index_template_vars)
22
+ template_vars.map { |page_template_vars| Piccle::TemplateHelpers.render("index", page_template_vars) }
21
23
  end
22
24
 
23
25
  # Renders an Atom feed of the given subsection.
@@ -41,22 +43,30 @@ module Piccle
41
43
  Piccle::TemplateHelpers.render("show", render_photo_template_vars(hash, selector))
42
44
  end
43
45
 
46
+ # What should we call this index page? The first page is "index" (ie. index.html), later ones have a number
47
+ # (eg. "index-02"). NB! Our indexes are 0 based, but our pages are 1 based. (0 → index, 1 → index-02, 2 → index-03).
48
+ def index_page_name_for(index)
49
+ index.zero? ? "index" : "index-#{sprintf("%02d", index + 1)}"
50
+ end
51
+
44
52
  protected
45
53
 
46
54
  # Returns all the data we pass into the main index to render.
47
55
  def render_main_index_template_vars
56
+ page_description = "A gallery of photos by #{Piccle.config.author_name}"
48
57
  template_vars = {
49
- photos: @parser.data[:photos],
58
+ photos: @extractor.main_index_photos,
50
59
  event_starts: @parser.data[:event_starts],
51
60
  event_ends: @parser.data[:event_ends],
52
61
  nav_items: @extractor.navigation,
53
- site_metadata: site_metadata
62
+ site_metadata: site_metadata,
63
+ page_description: page_description
54
64
  }
55
65
 
56
66
  if Piccle.config.open_graph?
57
67
  width, height = Piccle::QuiltGenerator.dimensions_for(@parser.data[:photos].length)
58
68
  template_vars[:open_graph] = open_graph_for(title: site_title(),
59
- description: "A gallery of photos by #{Piccle.config.author_name}",
69
+ description: page_description,
60
70
  image_url: "#{Piccle.config.home_url}quilt.jpg",
61
71
  image_alt: "A quilt of the most recent images in this gallery.",
62
72
  width: width,
@@ -70,6 +80,7 @@ module Piccle
70
80
  def render_index_template_vars(selector)
71
81
  breadcrumbs = @extractor.breadcrumbs_for(selector)
72
82
  selector_path = "#{selector.join('/')}/"
83
+ page_description = "A gallery of photos by #{Piccle.config.author_name}"
73
84
  template_vars = {
74
85
  photos: @parser.subsection_photos(selector),
75
86
  event_starts: [],
@@ -80,13 +91,14 @@ module Piccle
80
91
  breadcrumbs: breadcrumbs,
81
92
  site_url: Piccle.config.home_url,
82
93
  include_prefix: Piccle::TemplateHelpers.include_prefix(selector),
83
- site_metadata: site_metadata
94
+ site_metadata: site_metadata,
95
+ page_description: page_description
84
96
  }
85
97
 
86
98
  if Piccle.config.open_graph?
87
99
  width, height = Piccle::QuiltGenerator.dimensions_for(@parser.subsection_photo_hashes(selector).length)
88
100
  template_vars[:open_graph] = open_graph_for(title: site_title(breadcrumbs),
89
- description: "A gallery of photos by #{Piccle.config.author_name}",
101
+ description: page_description,
90
102
  image_url: "#{Piccle.config.home_url}#{selector_path}quilt.jpg",
91
103
  image_alt: "A quilt of the most recent images in this gallery.",
92
104
  width: width,
@@ -97,10 +109,48 @@ module Piccle
97
109
  template_vars
98
110
  end
99
111
 
112
+ # Given a set of template variables, paginate them. Specifically:
113
+ # - Take the :photos key, and break it into chunks
114
+ # - Add a :pagination key with details of all the pages
115
+ # - Returns an array of template vars.
116
+ def paginate(template_vars)
117
+ slices = template_vars[:photos].each_slice(100)
118
+ if slices.count > 1
119
+ pages = (0...slices.count).map { |i| { label: i + 1, page_name: index_page_name_for(i) } }
120
+
121
+ slices.map.with_index do |slice, i|
122
+ pages_with_current = pages.map.with_index { |page, index| page.merge(is_current: (i == index)) }
123
+ is_first = i.zero?
124
+ is_last = i == slices.count - 1
125
+
126
+ new_vars = { photos: slice.to_h }
127
+ new_vars[:prev_link] = "#{index_page_name_for(i-1)}.html" unless is_first
128
+ new_vars[:next_link] = "#{index_page_name_for(i+1)}.html" unless is_last
129
+
130
+ pagination = { pagination: { page_name: index_page_name_for(i),
131
+ pages: pages_with_current,
132
+ is_first_page: is_first,
133
+ is_last_page: is_last,
134
+ previous_page_name: index_page_name_for(i-1),
135
+ next_page_name: index_page_name_for(i+1),
136
+ total_pages: slices.count }}
137
+
138
+ template_vars.merge(new_vars, pagination)
139
+ end
140
+ else # Just the one page, no point in adding any pagination.
141
+ [template_vars]
142
+ end
143
+ end
144
+
100
145
  # Returns all the template vars we use to render a photo page.
101
146
  def render_photo_template_vars(hash, selector)
102
147
  photo_data = @parser.data[:photos][hash]
103
148
  substreams = [@parser.substream_for(hash)] + @parser.links_for(hash).map { |selector| @parser.interesting_substream_for(hash, selector) }.compact
149
+ page_description = if photo_data[:description] && photo_data[:description].strip.length > 0
150
+ photo_data[:description]
151
+ else
152
+ "A photo by #{Piccle.config.author_name}"
153
+ end
104
154
 
105
155
  template_vars = {
106
156
  photo: photo_data,
@@ -119,8 +169,9 @@ module Piccle
119
169
  prev_link: @extractor.prev_link(hash, selector),
120
170
  next_link: @extractor.next_link(hash, selector),
121
171
  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
172
+ canonical: "#{hash}.html",
173
+ site_metadata: site_metadata,
174
+ page_description: page_description
124
175
  }
125
176
 
126
177
  photo_title = photo_data[:title] || ""
@@ -129,7 +180,7 @@ module Piccle
129
180
 
130
181
  if Piccle.config.open_graph?
131
182
  template_vars[:open_graph] = open_graph_for(title: photo_data[:title] || "A photo by #{Piccle.config.author_name}",
132
- description: photo_data[:description],
183
+ description: page_description,
133
184
  image_url: "#{Piccle.config.home_url}images/photos/#{hash}.#{photo_data[:file_name]}",
134
185
  width: photo_data[:width],
135
186
  height: photo_data[:height],
@@ -28,7 +28,7 @@ class Piccle::Streams::BaseStream
28
28
  # Each stream is expected to only meddle within its own namespace, but this is not enforced.
29
29
  def order(data)
30
30
  if data.key?(namespace)
31
- data[namespace] = data[namespace].sort_by(&length_sort_proc(data)).reverse.to_h
31
+ data[namespace] = data[namespace].sort_by(&length_sort_proc(data)).to_h
32
32
  data[namespace].each do |k, v|
33
33
  data[namespace][k][:photos] = data[namespace][k][:photos].sort_by(&date_sort_proc(data)).reverse if k.is_a?(String)
34
34
  end
@@ -39,9 +39,13 @@ class Piccle::Streams::BaseStream
39
39
 
40
40
  protected
41
41
 
42
- # A sort proc designed for hashes. Sorts all string keys in order of how many photos they contain.
42
+ # A sort proc designed for hashes. Sorts all string keys in order of how many photos they contain, then alphabetically.
43
43
  def length_sort_proc(data)
44
- Proc.new { |k, v| k.is_a?(String) ? data.dig(namespace, k, :photos)&.length : 0 }
44
+ Proc.new do |k, v|
45
+ length = k.is_a?(String) ? data.dig(namespace, k, :photos)&.length || 0 : 0
46
+ name = k.is_a?(String) ? k.downcase : ""
47
+ [length * -1, name]
48
+ end
45
49
  end
46
50
 
47
51
  # A date sort designed for arrays. Sorts all photo hashes in order of the date they were taken.
@@ -61,7 +61,7 @@ class Piccle::Streams::DateStream < Piccle::Streams::BaseStream
61
61
  def order(data)
62
62
  sort_proc = Proc.new { |k, v| k.is_a?(String) ? k : "" }
63
63
 
64
- data[namespace] = data[namespace].sort_by(&sort_proc).to_h # Sort years
64
+ data[namespace] = data[namespace].sort_by(&sort_proc).reverse.to_h # Sort years
65
65
 
66
66
  data[namespace].each do |year_k, v|
67
67
  # Sort photos in each year, and then the month keys
@@ -14,13 +14,8 @@ class Piccle::Streams::EventStream < Piccle::Streams::BaseStream
14
14
  @events = if File.exist?(Piccle.config.events_file)
15
15
  YAML.load_file(Piccle.config.events_file).map do |event| # Convert keys to symbols; bring dates to life.
16
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
17
+ event[:selector] = selector_for(event[:name])
18
+ transform_dates(event)
24
19
  end
25
20
  else
26
21
  []
@@ -32,7 +27,8 @@ class Piccle::Streams::EventStream < Piccle::Streams::BaseStream
32
27
  relevant_events = @events.select { |ev| photo.taken_at.to_datetime >= ev[:from] && photo.taken_at.to_datetime <= ev[:to] }
33
28
  result = { namespace => { friendly_name: "By Event", interesting: true }}
34
29
  relevant_events.each do |ev|
35
- result[namespace][slugify(ev[:name])] = { friendly_name: ev[:name], interesting: true, photos: [photo.md5] }
30
+ result[namespace][slugify(ev[:name])] = { friendly_name: ev[:name], interesting: true, photos: [photo.md5],
31
+ collapsed: ev[:collapsed], sort_date: ev[:from] }
36
32
  end
37
33
 
38
34
  result
@@ -43,7 +39,14 @@ class Piccle::Streams::EventStream < Piccle::Streams::BaseStream
43
39
 
44
40
  # Sorts most recent events first; then organises photos by date. TODO
45
41
  def order(data)
46
- super(data)
42
+ if data.key?(namespace)
43
+ data[namespace] = data[namespace].sort_by { |k, v| k.is_a?(String) ? v[:sort_date] : DateTime.new(1826, 1, 1) }.reverse.to_h
44
+ data[namespace].each do |k, v|
45
+ data[namespace][k][:photos] = data[namespace][k][:photos].sort_by(&date_sort_proc(data)).reverse if k.is_a?(String)
46
+ end
47
+ end
48
+
49
+ data
47
50
  end
48
51
 
49
52
  # Given an event name, get a selector hash for this event.
@@ -63,11 +66,31 @@ class Piccle::Streams::EventStream < Piccle::Streams::BaseStream
63
66
  if data.dig(namespace, slug, :photos)&.any?
64
67
  most_recent_hash = data[namespace][slug][:photos].first
65
68
  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]) }
69
+ event_starts[oldest_hash] = { name: event[:name], selector: selector_for(event[:name]), collapsed: event[:collapsed] }
70
+ event_ends[most_recent_hash] = { name: event[:name], selector: selector_for(event[:name]), collapsed: event[:collapsed] }
68
71
  end
69
72
  end
70
73
 
71
74
  [event_starts, event_ends]
72
75
  end
76
+
77
+ protected
78
+
79
+ # Given an event, munge the dates appropriately.
80
+ # - If we have an "at" date specified, make the from/to fields match the start and end of the day.
81
+ # - If we have "from"/"to" specified as dates, line them up with the start/end of the day.
82
+ def transform_dates(event)
83
+ if event[:at]
84
+ event[:from] = DateTime.new(event[:at].year, event[:at].month, event[:at].day, 0, 0, 0)
85
+ event[:to] = DateTime.new(event[:at].year, event[:at].month, event[:at].day, 23, 59, 59)
86
+ elsif event[:from] && event[:to]
87
+ if event[:from].is_a? Date
88
+ event[:from] = DateTime.new(event[:from].year, event[:from].month, event[:from].day, 0, 0, 0)
89
+ end
90
+ if event[:to].is_a? Date
91
+ event[:to] = DateTime.new(event[:to].year, event[:to].month, event[:to].day, 23, 59, 59)
92
+ end
93
+ end
94
+ event
95
+ end
73
96
  end