piccle 0.1.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/Gemfile +4 -0
- data/NOTES.md +69 -0
- data/README.md +175 -0
- data/Rakefile +8 -0
- data/agpl-3.0.md +660 -0
- data/assets/css/default.css +397 -0
- data/assets/css/normalize.css +427 -0
- data/assets/icons/android-chrome-192x192.png +0 -0
- data/assets/icons/android-chrome-512x512.png +0 -0
- data/assets/icons/apple-touch-icon.png +0 -0
- data/assets/icons/favicon-16x16.png +0 -0
- data/assets/icons/favicon-32x32.png +0 -0
- data/assets/icons/favicon.ico +0 -0
- data/bin/console +14 -0
- data/bin/piccle +355 -0
- data/bin/setup +8 -0
- data/db/migrations/001_create_photos.rb +15 -0
- data/db/migrations/002_update_photos.rb +14 -0
- data/db/migrations/003_create_keywords_and_join_table.rb +14 -0
- data/db/migrations/004_add_focal_length.rb +7 -0
- data/db/migrations/005_create_locations.rb +20 -0
- data/js-renderer/handlebars.min-v4.7.6.js +29 -0
- data/js-renderer/renderer.js +93 -0
- data/lib/piccle.rb +52 -0
- data/lib/piccle/config.rb +136 -0
- data/lib/piccle/database.rb +33 -0
- data/lib/piccle/dstk_service.rb +64 -0
- data/lib/piccle/extractor.rb +128 -0
- data/lib/piccle/js_renderer.rb +37 -0
- data/lib/piccle/models/keyword.rb +6 -0
- data/lib/piccle/models/location.rb +11 -0
- data/lib/piccle/models/photo.rb +211 -0
- data/lib/piccle/parser.rb +230 -0
- data/lib/piccle/quilt_generator.rb +30 -0
- data/lib/piccle/renderer.rb +175 -0
- data/lib/piccle/streams.rb +2 -0
- data/lib/piccle/streams/base_stream.rb +56 -0
- data/lib/piccle/streams/camera_stream.rb +35 -0
- data/lib/piccle/streams/date_stream.rb +95 -0
- data/lib/piccle/streams/event_stream.rb +73 -0
- data/lib/piccle/streams/keyword_stream.rb +24 -0
- data/lib/piccle/streams/location_stream.rb +57 -0
- data/lib/piccle/template_helpers.rb +79 -0
- data/lib/piccle/version.rb +3 -0
- data/lib/tasks/development.rake +38 -0
- data/piccle.gemspec +43 -0
- data/templates/_breadcrumbs.handlebars.slim +16 -0
- data/templates/_footer.handlebars.slim +2 -0
- data/templates/_header.handlebars.slim +36 -0
- data/templates/_navigation.handlebars.slim +16 -0
- data/templates/_substream.handlebars.slim +17 -0
- data/templates/feed.atom.slim +29 -0
- data/templates/index.html.handlebars.slim +36 -0
- data/templates/show.html.handlebars.slim +64 -0
- metadata +340 -0
@@ -0,0 +1,37 @@
|
|
1
|
+
# Render using a NodeJS helper program. The Handlebars.rb bindings are tied to an old version of libv8; they render
|
2
|
+
# REALLY slowly as a result.
|
3
|
+
# This renderer calls out to a NodeJS helper program instead - so all the templating is handled in JavaScript.
|
4
|
+
module Piccle
|
5
|
+
class JsRenderer < Renderer
|
6
|
+
def initialize(*args)
|
7
|
+
@renderer = IO.popen(["node", "js-renderer/renderer.js", Piccle.config.output_dir], "r+")
|
8
|
+
super(*args)
|
9
|
+
end
|
10
|
+
|
11
|
+
def render_main_index
|
12
|
+
call_nodejs("index", render_main_index_template_vars)
|
13
|
+
end
|
14
|
+
|
15
|
+
def render_index(selector)
|
16
|
+
call_nodejs("index", render_index_template_vars(selector))
|
17
|
+
end
|
18
|
+
|
19
|
+
def render_photo(hash, selector = [])
|
20
|
+
call_nodejs("show", render_photo_template_vars(hash, selector))
|
21
|
+
end
|
22
|
+
|
23
|
+
protected
|
24
|
+
|
25
|
+
def call_nodejs(template, template_vars)
|
26
|
+
@renderer.write("render_#{template}\n")
|
27
|
+
@renderer.write("#{JSON.dump(template_vars)}\n")
|
28
|
+
buffer = ""
|
29
|
+
loop do
|
30
|
+
line = @renderer.readline
|
31
|
+
break if line.strip == "\x1C"
|
32
|
+
buffer += line
|
33
|
+
end
|
34
|
+
buffer
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
require 'sequel'
|
2
|
+
|
3
|
+
# Represents a location in the system - either just a lat/long point (to be geocoded later) or a lat/long named
|
4
|
+
# with "city", "state", "country". Countries are normally countries, but overall it's more like "small area", "wider
|
5
|
+
# geographic area", "big geographic area".
|
6
|
+
class Piccle::Location < Sequel::Model
|
7
|
+
def before_create
|
8
|
+
self.created_at ||= Time.now
|
9
|
+
super
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,211 @@
|
|
1
|
+
require 'exifr/jpeg'
|
2
|
+
require 'xmp'
|
3
|
+
require 'digest'
|
4
|
+
require 'sequel'
|
5
|
+
require 'rmagick'
|
6
|
+
require 'json'
|
7
|
+
|
8
|
+
# Represents an image in the system. Reading info from an image? Inferring something based on the data? Put it here.
|
9
|
+
class Piccle::Photo < Sequel::Model
|
10
|
+
many_to_many :keywords
|
11
|
+
attr_accessor :changed_hash # Has this file been modified?
|
12
|
+
attr_accessor :freshly_created # Have we just generated this file?
|
13
|
+
|
14
|
+
def before_create
|
15
|
+
self.created_at ||= Time.now
|
16
|
+
super
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.from_file(path_to_file)
|
20
|
+
freshly_created = false
|
21
|
+
md5 = Digest::MD5.file(path_to_file).to_s
|
22
|
+
|
23
|
+
photo = self.find_or_create(file_name: File.basename(path_to_file), path: File.dirname(path_to_file)) do |p|
|
24
|
+
# Block executes when creating a new record.
|
25
|
+
freshly_created = true
|
26
|
+
p.set(data_hash(path_to_file))
|
27
|
+
end
|
28
|
+
photo.changed_hash = md5 != photo.md5
|
29
|
+
photo.freshly_created = freshly_created
|
30
|
+
|
31
|
+
# Pull out keywords for this file, if it's new or changed.
|
32
|
+
photo.generate_keywords if freshly_created || photo.changed_hash?
|
33
|
+
|
34
|
+
photo
|
35
|
+
end
|
36
|
+
|
37
|
+
# Gets a dataset of properties to save about this file. We reuse this between from_file (above) and update_from_file
|
38
|
+
# (below).
|
39
|
+
def self.data_hash(path_to_file)
|
40
|
+
exif_info = EXIFR::JPEG.new(path_to_file)
|
41
|
+
xmp = XMP.parse(exif_info)
|
42
|
+
p = {}
|
43
|
+
|
44
|
+
p[:md5] = Digest::MD5.file(path_to_file).to_s
|
45
|
+
p[:width] = exif_info.width
|
46
|
+
p[:height] = exif_info.height
|
47
|
+
p[:camera_name] = exif_info.model || "Unknown camera"
|
48
|
+
p[:description] = exif_info.image_description
|
49
|
+
p[:aperture] = exif_info.aperture_value
|
50
|
+
p[:iso] = exif_info.iso_speed_ratings
|
51
|
+
p[:iso] = p[:iso].first if p[:iso].is_a? Array
|
52
|
+
p[:shutter_speed_numerator] = exif_info.exposure_time&.numerator
|
53
|
+
p[:shutter_speed_denominator] = exif_info.exposure_time&.denominator
|
54
|
+
p[:focal_length] = exif_info.focal_length.to_f
|
55
|
+
p[:taken_at] = exif_info.date_time_original&.to_datetime
|
56
|
+
|
57
|
+
p[:latitude] = if exif_info.gps_latitude && exif_info.gps_latitude_ref
|
58
|
+
exif_info.gps_latitude_ref == "S" ? (exif_info.gps_latitude.to_f * -1) : exif_info.gps_latitude.to_f
|
59
|
+
end
|
60
|
+
|
61
|
+
p[:longitude] = if exif_info.gps_longitude && exif_info.gps_longitude_ref
|
62
|
+
exif_info.gps_longitude_ref == "W" ? (exif_info.gps_longitude.to_f * -1) : exif_info.gps_longitude.to_f
|
63
|
+
end
|
64
|
+
|
65
|
+
p[:title] = if xmp && xmp.namespaces && xmp.namespaces.include?("dc") && xmp.dc.attributes.include?("title")
|
66
|
+
xmp.dc.title
|
67
|
+
end
|
68
|
+
%w[City State Country].each do |location|
|
69
|
+
p[location.downcase.to_sym] = if xmp && xmp.namespaces && xmp.namespaces.include?("photoshop") &&
|
70
|
+
xmp.photoshop.attributes.include?(location)
|
71
|
+
xmp.photoshop.send(location)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Tweak encoding of potential non-UTF-8 strings
|
76
|
+
%i[description title city state country].each do |attr|
|
77
|
+
p[attr].force_encoding("UTF-8") if p[attr].respond_to?(:force_encoding)
|
78
|
+
end
|
79
|
+
|
80
|
+
p
|
81
|
+
end
|
82
|
+
|
83
|
+
# The year our earliest photo was taken. Used by our copyright footer.
|
84
|
+
def self.earliest_photo_year
|
85
|
+
Date.parse(self.min(:taken_at)).year
|
86
|
+
end
|
87
|
+
|
88
|
+
# The year the last photo was taken. Used by the copyright footer.
|
89
|
+
def self.latest_photo_year
|
90
|
+
Date.parse(self.max(:taken_at)).year
|
91
|
+
end
|
92
|
+
|
93
|
+
# ---- Image attributes (inferred from data) ----
|
94
|
+
|
95
|
+
def portrait?
|
96
|
+
height > width
|
97
|
+
end
|
98
|
+
|
99
|
+
# Is this image landscape?
|
100
|
+
def landscape?
|
101
|
+
width > height
|
102
|
+
end
|
103
|
+
|
104
|
+
# Is this image square?
|
105
|
+
def square?
|
106
|
+
width == height
|
107
|
+
end
|
108
|
+
|
109
|
+
# Have we already generated a thumbnail for this image?
|
110
|
+
def thumbnail_exists?
|
111
|
+
File.exist?(thumbnail_path)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Gets the full path to the thumbnail for this photo.
|
115
|
+
def thumbnail_path
|
116
|
+
File.join(Piccle.config.output_dir, template_thumbnail_path)
|
117
|
+
end
|
118
|
+
|
119
|
+
# Gets the path to use in our generated HTML.
|
120
|
+
def template_thumbnail_path
|
121
|
+
File.join("images", "thumbnails", "#{md5}.#{file_name}")
|
122
|
+
end
|
123
|
+
|
124
|
+
# Does a "full-size" image exist?
|
125
|
+
def full_image_exists?
|
126
|
+
File.exist?(full_image_path)
|
127
|
+
end
|
128
|
+
|
129
|
+
# Gets the full path to the "full" image for this photo.
|
130
|
+
def full_image_path
|
131
|
+
File.join(Piccle.config.output_dir, template_full_image_path)
|
132
|
+
end
|
133
|
+
|
134
|
+
# Gets the path to use in our generated HTML."
|
135
|
+
def template_full_image_path
|
136
|
+
File.join("images", "photos", "#{md5}.#{file_name}")
|
137
|
+
end
|
138
|
+
|
139
|
+
# Gets the path to the photo page.
|
140
|
+
def photo_show_path
|
141
|
+
"#{md5}.html"
|
142
|
+
end
|
143
|
+
|
144
|
+
def original_photo_path
|
145
|
+
File.join(path, file_name)
|
146
|
+
end
|
147
|
+
|
148
|
+
# Munge the shutter speed data into a human-readable string.
|
149
|
+
def friendly_shutter_speed
|
150
|
+
if shutter_speed_numerator && shutter_speed_denominator
|
151
|
+
if shutter_speed_denominator > 1
|
152
|
+
"#{shutter_speed_numerator}/#{shutter_speed_denominator}s"
|
153
|
+
else
|
154
|
+
"#{shutter_speed_numerator}s"
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def friendly_focal_length
|
160
|
+
"#{focal_length.round(1)} mm" if focal_length.positive?
|
161
|
+
end
|
162
|
+
|
163
|
+
# Does this image have both a lat-long pair, AND at least one of (city, state, country)?
|
164
|
+
def geocoded?
|
165
|
+
(latitude && longitude) && (city || state || country)
|
166
|
+
end
|
167
|
+
|
168
|
+
# ---- Piccle internals ----
|
169
|
+
|
170
|
+
# Has this file changed hash? You probably want to call update if so.
|
171
|
+
def changed_hash?
|
172
|
+
changed_hash
|
173
|
+
end
|
174
|
+
|
175
|
+
# Have we just created this file?
|
176
|
+
def freshly_created?
|
177
|
+
freshly_created
|
178
|
+
end
|
179
|
+
|
180
|
+
# Re-read the photo data, and save it to the DB.
|
181
|
+
def update_from_file
|
182
|
+
update(Piccle::Photo.data_hash(original_photo_path))
|
183
|
+
end
|
184
|
+
|
185
|
+
# 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
|
188
|
+
exif_info = EXIFR::JPEG.new(original_photo_path)
|
189
|
+
xmp = XMP.parse(exif_info)
|
190
|
+
|
191
|
+
if xmp && xmp.namespaces && xmp.namespaces.include?("dc") && xmp.dc.attributes.include?("subject")
|
192
|
+
xmp.dc.subject.each do |keyword|
|
193
|
+
keyword = Piccle::Keyword.find_or_create(name: keyword)
|
194
|
+
add_keyword(keyword) unless keywords.include?(keyword)
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
# Generate a thumbnail for this image.
|
200
|
+
def generate_thumbnail!
|
201
|
+
img = Magick::Image.read(original_photo_path).first
|
202
|
+
img.resize_to_fill!(Piccle::THUMBNAIL_SIZE)
|
203
|
+
img.write(thumbnail_path)
|
204
|
+
end
|
205
|
+
|
206
|
+
def generate_full_image!
|
207
|
+
img = Magick::Image.read(original_photo_path).first
|
208
|
+
img.resize_to_fit!(Piccle::FULL_SIZE, Piccle::FULL_SIZE)
|
209
|
+
img.write(full_image_path)
|
210
|
+
end
|
211
|
+
end
|
@@ -0,0 +1,230 @@
|
|
1
|
+
# The "base parser" for Piccle. Repeatedly call parser.parse(Photo), and it pulls out the metadata necessary to generate pages.
|
2
|
+
# It'll figure out which details to pull out, links between individual photos, details like ordering, etc.
|
3
|
+
#
|
4
|
+
# Essentially, we end up building a big @data array that's got all the photo metadata, and the streams populate the various
|
5
|
+
# facets of the data. And then another module can render our site from this big specially-structured hash.
|
6
|
+
#
|
7
|
+
# Our hash looks like this:
|
8
|
+
# {
|
9
|
+
# title: "Foo", # The title of this section
|
10
|
+
# photos: { md5_string => Hash[photo_data] }, # Data needed to display
|
11
|
+
# events: [ Hash[event_data] ] # Details about named events. These get special tiles on
|
12
|
+
# # the front page, but are implemented via a stream.
|
13
|
+
#
|
14
|
+
|
15
|
+
module Piccle
|
16
|
+
class Parser
|
17
|
+
attr_accessor :data
|
18
|
+
attr_accessor :streams
|
19
|
+
|
20
|
+
def initialize
|
21
|
+
@data = { friendly_name: "All Photos" } # The extracted metadata that we'll use to generate our photo gallery.
|
22
|
+
@photos = {} # An array of MD5 -> Photo object, in case we want to get back to them easily at some point.
|
23
|
+
@streams = [] # Any extra processors that we want to use.
|
24
|
+
end
|
25
|
+
|
26
|
+
# Register a "stream", a thing that can extract extra data from a photo and add it to our data array, for later generation.
|
27
|
+
def add_stream(stream)
|
28
|
+
@streams << stream.new
|
29
|
+
end
|
30
|
+
|
31
|
+
# Do we have any photos in this parsed data yet?
|
32
|
+
def empty?
|
33
|
+
@photos.empty?
|
34
|
+
end
|
35
|
+
|
36
|
+
# Parse a photo. Also passes it to any registered streams, which can subcategorise each photo into sections under its own namespace.
|
37
|
+
# Streams can also return a metadata element, that we can display specially in the photo page.
|
38
|
+
def parse(photo)
|
39
|
+
@photos[photo.md5] = photo
|
40
|
+
@data[:photos] ||= {}
|
41
|
+
|
42
|
+
@data[:photos][photo.md5] = { hash: photo.md5,
|
43
|
+
file_name: photo.file_name,
|
44
|
+
title: photo.title,
|
45
|
+
photo_show_path: photo.photo_show_path,
|
46
|
+
description: photo.description,
|
47
|
+
width: photo.width,
|
48
|
+
height: photo.height,
|
49
|
+
taken_at: photo.taken_at,
|
50
|
+
created_at: photo.created_at,
|
51
|
+
aperture: photo.aperture,
|
52
|
+
shutter_speed: photo.friendly_shutter_speed,
|
53
|
+
focal_length: photo.friendly_focal_length,
|
54
|
+
iso: photo.iso,
|
55
|
+
city: photo.city,
|
56
|
+
state: photo.state,
|
57
|
+
country: photo.country,
|
58
|
+
has_location: photo.geocoded?,
|
59
|
+
metadata: []
|
60
|
+
}
|
61
|
+
|
62
|
+
@streams.each do |stream|
|
63
|
+
@data = merge_into(@data, stream.data_for(photo)) if stream.respond_to?(:data_for)
|
64
|
+
@data[:photos][photo.md5][:metadata] += stream.metadata_for(photo) if stream.respond_to?(:metadata_for)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# You can iterate over this list to display things.
|
69
|
+
def order
|
70
|
+
@data[:photos] = @data[:photos].sort_by { |k, v| v[:taken_at] || Time.new(1970, 1, 1) }.reverse.to_h
|
71
|
+
|
72
|
+
@streams.each do |stream|
|
73
|
+
@data = stream.order(@data) if stream.respond_to?(:order)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Loads the event data from the EventStream. It also finds "sentinels", which are photos where we should display a special
|
78
|
+
# tile beforehand to indicate the start/end of the event.
|
79
|
+
def load_events
|
80
|
+
event_stream = Piccle::Streams::EventStream.new
|
81
|
+
order
|
82
|
+
@data[:events] = event_stream.events
|
83
|
+
@data[:event_starts], @data[:event_ends] = event_stream.sentinels_for(@data)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Gets the metadata for a given photo hash.
|
87
|
+
def metadata_for(photo_hash)
|
88
|
+
@data.dig(:photos, photo_hash, :metadata)
|
89
|
+
end
|
90
|
+
|
91
|
+
# Get the friendly name for the given selector.
|
92
|
+
def friendly_name_for(selector)
|
93
|
+
@data.dig(*selector, :friendly_name)
|
94
|
+
end
|
95
|
+
|
96
|
+
# Gets a list of all subsections (ie. all the subindexes that we should render).
|
97
|
+
# It's an array of hash keys, suitable for passing via @data.dig(*keys).
|
98
|
+
# TODO: This could probably be sped up.
|
99
|
+
def subsections
|
100
|
+
previous_size = 0
|
101
|
+
subsection_list = faceted_data.keys.map { |el| [el] }
|
102
|
+
size = subsection_list.count
|
103
|
+
|
104
|
+
# Find all the string keys in our data.
|
105
|
+
loop do
|
106
|
+
subsection_list.each do |key_path|
|
107
|
+
new_keys = string_keys_only(@data.dig(*key_path)).keys
|
108
|
+
new_keys.each { |k| subsection_list << key_path + [k] }
|
109
|
+
end
|
110
|
+
|
111
|
+
# Clean up our state - remove dupes, update counts.
|
112
|
+
subsection_list.uniq!
|
113
|
+
previous_size = size
|
114
|
+
size = subsection_list.count
|
115
|
+
break if previous_size == size
|
116
|
+
end
|
117
|
+
|
118
|
+
subsection_list
|
119
|
+
end
|
120
|
+
|
121
|
+
# Get photo hashes in a given subsection, given a diggable path.
|
122
|
+
def subsection_photo_hashes(subsection_path)
|
123
|
+
@data.dig(*subsection_path).fetch(:photos, [])
|
124
|
+
end
|
125
|
+
|
126
|
+
# Gets the actual photo objects for a given subsection.
|
127
|
+
def subsection_photos(subsection_path)
|
128
|
+
if subsection_path.any?
|
129
|
+
subsection_photo_hashes(subsection_path).map { |hash| [hash, @data[:photos][hash]] }.to_h
|
130
|
+
else
|
131
|
+
@data[:photos]
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
# 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
|
+
# So for instance, with a date stream parser, if a photo was taken on 2016-04-19 you'll get back:
|
137
|
+
# [["by-date", "2016"], ["by-date", "2016", "4"], ["by-date", "2016", "4", "19"]]
|
138
|
+
# And you could use that to generate a links akin to /by-date/2016/4/19/abcdef1234567890.html.
|
139
|
+
def links_for(md5)
|
140
|
+
# Return each key that includes the photos.
|
141
|
+
subsections.select { |path| @data.dig(*path).fetch(:photos, []).include?(md5) }
|
142
|
+
end
|
143
|
+
|
144
|
+
# Given a photo hash, and a substream selector (which may be omitted, for the main list of photos),
|
145
|
+
# returns an array with *up to* 5 previous/next photos, as well as this image. It's ideal for rendering small
|
146
|
+
# strips of neighbouring images.
|
147
|
+
def substream_hashes_for(hash, selector = [])
|
148
|
+
relevant_hashes = (@data.dig(*selector, :photos) || {})
|
149
|
+
relevant_hashes = relevant_hashes.keys if relevant_hashes.respond_to?(:keys)
|
150
|
+
if photo_index = relevant_hashes.find_index(hash)
|
151
|
+
before_index = [0, photo_index-5].max
|
152
|
+
after_index = [photo_index + 5, relevant_hashes.length - 1].min
|
153
|
+
relevant_hashes[before_index..after_index]
|
154
|
+
else
|
155
|
+
[]
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
# Same as the above, but only returns hashes for "interesting" substreams.
|
160
|
+
def interesting_substream_hashes_for(hash, selector = [])
|
161
|
+
if @data.dig(*selector, :interesting)
|
162
|
+
substream_hashes_for(hash, selector)
|
163
|
+
else
|
164
|
+
[]
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Returns a substream hash. This is a bundle of data suitable for rendering a navigation strip within this stream.
|
169
|
+
# It includes a title for the substream, previous/next photos where applicable (ie. for nav arrows), and a set of
|
170
|
+
# photos including the current photo.
|
171
|
+
def substream_for(hash, selector = [])
|
172
|
+
photo_hashes = substream_hashes_for(hash, selector)
|
173
|
+
if photo_hashes.any?
|
174
|
+
substream = {}
|
175
|
+
photo_i = photo_hashes.find_index(hash)
|
176
|
+
substream[:title] = friendly_name_for(selector)
|
177
|
+
substream[:photos] = photo_hashes.map { |h| @data[:photos][h] }
|
178
|
+
substream[:selector_path] = "#{selector.join('/')}/" if selector.any?
|
179
|
+
substream[:previous] = @data[:photos][photo_hashes[photo_i - 1]] if photo_i > 0
|
180
|
+
substream[:next] = @data[:photos][photo_hashes[photo_i + 1]] if photo_i < photo_hashes.length - 1
|
181
|
+
substream
|
182
|
+
else
|
183
|
+
nil
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# Same as the above, but only if this stream is flagged as interesting.
|
188
|
+
def interesting_substream_for(hash, selector = [])
|
189
|
+
substream_for(hash, selector) if @data.dig(*selector, :interesting)
|
190
|
+
end
|
191
|
+
|
192
|
+
# Accessor for the photo hashes.
|
193
|
+
def photo_hashes
|
194
|
+
@photos.keys
|
195
|
+
end
|
196
|
+
|
197
|
+
|
198
|
+
# Gets the data that we faceted - the things broken down by stream.
|
199
|
+
def faceted_data
|
200
|
+
@faceted_data ||= string_keys_only(@data)
|
201
|
+
end
|
202
|
+
|
203
|
+
protected
|
204
|
+
|
205
|
+
def string_keys_only(data)
|
206
|
+
data.select { |k, _| k.is_a? String }
|
207
|
+
end
|
208
|
+
|
209
|
+
def merge_into(destination, source)
|
210
|
+
# If the source has a photos key, make sure one exists in the destination, and then append the source's contents.
|
211
|
+
if source.key?(:photos)
|
212
|
+
destination[:photos] ||= []
|
213
|
+
destination[:photos] += source[:photos]
|
214
|
+
end
|
215
|
+
|
216
|
+
# For all the other keys, see if the destination has them. If it does, combine them using OURSELF. Otherwise, just set it to our version.
|
217
|
+
source.keys.each do |key|
|
218
|
+
next if :photos == key
|
219
|
+
if destination.key?(key) && destination[key].is_a?(Hash)
|
220
|
+
destination[key] = merge_into(destination[key], source[key])
|
221
|
+
|
222
|
+
else
|
223
|
+
destination[key] = source[key]
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
destination
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|