piccle 0.1.0.rc1 → 0.1.1.pre

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