intranet-pictures 0.0.0 → 1.0.0

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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/lib/intranet/pictures/json_db_provider.rb +196 -0
  3. data/lib/intranet/pictures/responder.rb +247 -0
  4. data/lib/intranet/pictures/version.rb +1 -1
  5. data/lib/intranet/resources/haml/pictures_browse.haml +14 -0
  6. data/lib/intranet/resources/haml/pictures_home.haml +36 -0
  7. data/lib/intranet/resources/haml/pictures_photoswipe.haml +23 -0
  8. data/lib/intranet/resources/locales/en.yml +28 -0
  9. data/lib/intranet/resources/locales/fr.yml +28 -0
  10. data/lib/intranet/resources/www/group_thumbnail.jpg +0 -0
  11. data/lib/intranet/resources/www/jpictures.js +42 -0
  12. data/lib/intranet/resources/www/photoswipe/LICENSE +21 -0
  13. data/lib/intranet/resources/www/photoswipe/default-skin/default-skin.css +484 -0
  14. data/lib/intranet/resources/www/photoswipe/default-skin/default-skin.png +0 -0
  15. data/lib/intranet/resources/www/photoswipe/default-skin/default-skin.svg +1 -0
  16. data/lib/intranet/resources/www/photoswipe/default-skin/preloader.gif +0 -0
  17. data/lib/intranet/resources/www/photoswipe/photoswipe-ui-default.js +861 -0
  18. data/lib/intranet/resources/www/photoswipe/photoswipe-ui-default.min.js +4 -0
  19. data/lib/intranet/resources/www/photoswipe/photoswipe.css +179 -0
  20. data/lib/intranet/resources/www/photoswipe/photoswipe.js +3734 -0
  21. data/lib/intranet/resources/www/photoswipe/photoswipe.min.js +4 -0
  22. data/lib/intranet/resources/www/style.css +81 -0
  23. data/spec/intranet/pictures/alpha.png +0 -0
  24. data/spec/intranet/pictures/json_db_provider_spec.rb +273 -0
  25. data/spec/intranet/pictures/responder_spec.rb +499 -0
  26. data/spec/intranet/pictures/sample-db.json +71 -0
  27. data/spec/intranet/pictures/white.jpg +0 -0
  28. data/spec/spec_helper.rb +6 -2
  29. metadata +52 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ab4f949290d03cad1457928b191456ffd1a7ce7cb2469332ce025978d9a4187
4
- data.tar.gz: 046e7c70a389c5b999b1817dd0056a28103c18de56ec888a8248e89d39415ad5
3
+ metadata.gz: a9d501ee87ce3a72f55276eaed283010fe6290149e51815a52e9f7814921f6ec
4
+ data.tar.gz: b22160bb395f4a7b1676f4fa844b8e0954f0eb5292152370f6c5b5cd1c60f9ca
5
5
  SHA512:
6
- metadata.gz: 32bf9cc089a52bc37e47ff5695136ae9ae9ed0d17cabe73966724f38716f504fca3aab26966848aac8ddf028476ad30bf6eee4451ca0d394c1293f642c2ff4ce
7
- data.tar.gz: a5910897b5b78444caa328559ca5e594b1fa2d30ef41c8da346177c433069a8749309cca515fbf56ed7adbb177afbdbacf80cae2a1f4fea78576cbd74b06b5cf
6
+ metadata.gz: 2d82750698f54a3328135679a1adbfeaa3f38cdac2c1e9fe5d870441bf162a35758c942aa131d4c191bea69fb7a8fc01f94a3bfc3f33d2e212a4a581e552adbf
7
+ data.tar.gz: 31cf32b5245d7b808bcc8714883feede4ef20a8af956bfd0c3cdca95ac9f5e461a32452df4e2f8b6da4a33bf0e54af4c3354157fbe92efd27c2ee059d77c77f3
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'mimemagic'
5
+
6
+ module Intranet
7
+ module Pictures
8
+ # Provides pictures data and pictures groups listings from a database file in JSON format.
9
+ #
10
+ # === Structure of the JSON database
11
+ # See the example below.
12
+ #
13
+ # The title of the pictures gallery is indicated in +title+
14
+ #
15
+ # Pictures are described individually as a hash in the +pictures+ array. The mandatory keys in
16
+ # this hash are:
17
+ # * +id+ : unique identifier of the picture
18
+ # * +uri+ : location of the picture, relative to the JSON file
19
+ # * +title+ : description of the picture
20
+ # * +datetime+ : date and time of the picture, as a string in ISO8601 format
21
+ # * +height+ and +width+ : size in pixels of the picture
22
+ #
23
+ # Pictures are meant to be grouped in various group types (event, city, region/country, ...).
24
+ # Each group type is defined as an entry in the +groups+ hash and consists of a set of groups,
25
+ # each of them described by a hash with the following keys:
26
+ # * +id+ : unique identifier of the group, mandatory
27
+ # * +title+ : human-readable group name, mandatory
28
+ # * +brief+ : optional short text associated to the group name
29
+ # * +uri+ : optional group thumbnail, relative to the JSON file
30
+ #
31
+ # Each image is associated to at most one group in each group type by by a a "foreign key"
32
+ # constraint of the type *picture*.+group_type+ = *group*.+id+.
33
+ # @example Structure of the JSON database (not all mandatory are present for readability)
34
+ # {
35
+ # "title": "gallery title",
36
+ # "groups": {
37
+ # "event": [
38
+ # { "id": "party", "title": "...", "brief": "...", "uri": "party.jpg", ... },
39
+ # { ... }
40
+ # ],
41
+ # "city": [
42
+ # { "id": "houston", "title": "...", "uri": "houston.png", ... },
43
+ # { ... }
44
+ # ]
45
+ # },
46
+ # "pictures": [
47
+ # { "uri": "dir/pic0.jpg", "datetime": "...", "event": "party", "city": "houston", ... },
48
+ # { ... }
49
+ # ]
50
+ # }
51
+ class JsonDbProvider
52
+ # Initializes a new pictures data provider.
53
+ # @param json_file [String] The path to the JSON database file.
54
+ def initialize(json_file)
55
+ @json_dir = File.dirname(json_file) # also works for URLs
56
+ @json_file = json_file
57
+ @json_time = Time.at(0)
58
+ load_json
59
+ end
60
+
61
+ # Returns the pictures gallery title.
62
+ # @return [String] The gallery title.
63
+ # @raise KeyError If no title is defined in JSON file.
64
+ def title
65
+ load_json
66
+ @json.fetch('title').to_s
67
+ end
68
+
69
+ # Returns the list of available group types.
70
+ # @return [Array<String>] The available group types.
71
+ # @raise KeyError If no group type is defined in JSON file.
72
+ def group_types
73
+ load_json
74
+ @json.fetch('groups').keys
75
+ end
76
+
77
+ # Returns the list of the groups satisfying the following conditions:
78
+ # * the group is of the given +type+, and
79
+ # * at least one picture belonging to that group matches the +selector+.
80
+ # Results are returned ordered by *group*.+sort_by+ in ascending order if +asc+, and in
81
+ # descending order otherwise.
82
+ # @param type [String] The group type.
83
+ # @param selector [Hash<String,String>] The pictures selector, interpreted as a logical AND
84
+ # combination of all key/value pairs provided.
85
+ # @param sort_by [String] The group field to sort the results by, or nil if results should be
86
+ # returned without particular sorting.
87
+ # @param asc [Boolean] True to sort returned groups in ascending order, False to sort in
88
+ # descending order.
89
+ # @return [Array<Hash{'id'=>String, 'title'=>String, ...}>] The selected groups.
90
+ # @raise KeyError If JSON file is malformed, if +type+ does not match an existing group type,
91
+ # or if +sort_by+ is not an existing group field.
92
+ def list_groups(type, selector = {}, sort_by = nil, asc = true)
93
+ load_json
94
+ groups = select_groups(type, selector).map { |g| g.except('uri') }
95
+ groups.sort_by! { |g| g.fetch(sort_by) } unless sort_by.nil?
96
+ groups.reverse! unless asc
97
+ groups
98
+ end
99
+
100
+ # Returns the list of the pictures matching +selector+.
101
+ # Results are returned ordered by *picture*.+sort_by+ in ascending order if +asc+, and in
102
+ # descending order otherwise.
103
+ # @param selector [Hash<String,String>] The pictures selector, interpreted as a logical AND
104
+ # combination of all key/value pairs provided.
105
+ # @param sort_by [String] The picture field to sort the results by, or nil if results should
106
+ # be returned without particular sorting.
107
+ # @param asc [Boolean] True to sort returned pictures in ascending order, False to sort in
108
+ # descending order.
109
+ # @return [Array<Hash{'id'=>String, 'title'=>String, 'datetime'=>String, 'height'=>Integer,
110
+ # 'width'=>Integer, ...}>] The selected pictures.
111
+ # @raise KeyError If JSON file is malformed, or if +sort_by+ is not an existing picture field.
112
+ def list_pictures(selector = {}, sort_by = nil, asc = true)
113
+ load_json
114
+ pics = select_pictures(selector).map { |p| p.except('uri') }
115
+ pics.sort_by! { |p| p.fetch(sort_by) } unless sort_by.nil?
116
+ pics.reverse! unless asc
117
+ pics
118
+ end
119
+
120
+ # Returns the picture file matching +selector+.
121
+ # @param selector [Hash<String,String>] The picture selector, which should return exactly one
122
+ # picture.
123
+ # @return [String, Blob] The MIME type of the picture, and the picture file content.
124
+ # @raise KeyError If JSON file is malformed, if selector does not match exactly one picture,
125
+ # or if the *picture*.+uri+ does not refer to an existing image file.
126
+ def picture(selector = {})
127
+ load_json
128
+ pic = select_pictures(selector)
129
+ raise KeyError unless pic.size == 1
130
+
131
+ path = File.join(@json_dir, pic.first.fetch('uri'))
132
+ open_image_file(path)
133
+ end
134
+
135
+ # Returns the thumbnail picture of the group satisfying the following conditions:
136
+ # * the group is of the given +type+, and
137
+ # * at least one picture belonging to that group matches the +selector+.
138
+ # @param type [String] The group type.
139
+ # @param selector [Hash<String,String>] The pictures selector, interpreted as a logical AND
140
+ # combination of all key/value pairs provided. The
141
+ # selector should return exactly one group.
142
+ # @return [String, Blob] The MIME type of the picture, and the picture file content. Nil may
143
+ # be returned if no thumbnail is available for that group.
144
+ # @raise KeyError If JSON file is malformed, if selector does not match exactly one group,
145
+ # or if the *group*.+uri+ does not refer to an existing image file.
146
+ def group_thumbnail(type, selector = {})
147
+ load_json
148
+ group = select_groups(type, selector)
149
+ raise KeyError unless group.size == 1
150
+ return nil if group.first['uri'].nil?
151
+
152
+ path = File.join(@json_dir, group.first.fetch('uri'))
153
+ open_image_file(path)
154
+ end
155
+
156
+ private
157
+
158
+ # (Re)load the JSON file if modified on disk
159
+ def load_json
160
+ mtime = File.mtime(@json_file)
161
+ return unless @json_time < File.mtime(@json_file)
162
+
163
+ @json = JSON.parse(File.read(@json_file))
164
+ @json_time = mtime
165
+ end
166
+
167
+ def open_image_file(path)
168
+ mime_type = MimeMagic.by_path(path).type
169
+ raise KeyError unless mime_type.start_with?('image/')
170
+
171
+ [MimeMagic.by_path(path).type, File.read(path)]
172
+ rescue Errno::ENOENT
173
+ raise KeyError
174
+ end
175
+
176
+ def picture_match?(picture, selector)
177
+ selector.all? { |k, v| picture.fetch(k) == v }
178
+ rescue KeyError
179
+ false
180
+ end
181
+
182
+ def select_groups(type, selector)
183
+ return @json.fetch('groups').fetch(type) if selector.empty? # optimization
184
+
185
+ groups_id = @json.fetch('pictures').map do |p|
186
+ p.fetch(type) if picture_match?(p, selector)
187
+ end.uniq.compact
188
+ @json.fetch('groups').fetch(type).select { |g| groups_id.include?(g.fetch('id')) }
189
+ end
190
+
191
+ def select_pictures(selector)
192
+ @json.fetch('pictures').select { |p| picture_match?(p, selector) }
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'intranet/abstract_responder'
4
+ require 'intranet/core/haml_wrapper'
5
+ require 'intranet/core/locales'
6
+ require 'json'
7
+ require_relative 'version'
8
+
9
+ module Intranet
10
+ module Pictures
11
+ # The responder for the Pictures monitor module of the Intranet.
12
+ class Responder < AbstractResponder
13
+ include Core::HamlWrapper # 'inherits' from methods of HamlWrapper
14
+
15
+ # Returns the name of the module.
16
+ # @return [String] The name of the module.
17
+ def self.module_name
18
+ NAME
19
+ end
20
+
21
+ # The version of the module, according to semantic versionning.
22
+ # @return [String] The version of the module.
23
+ def self.module_version
24
+ VERSION
25
+ end
26
+
27
+ # The homepage of the module.
28
+ # @return [String] The homepage URL of the module.
29
+ def self.module_homepage
30
+ HOMEPAGE_URL
31
+ end
32
+
33
+ # Initializes a new Pictures responder instance.
34
+ # @param provider [#title,#group_types,#list_groups,#group_thumbnail,#list_pictures,#picture]
35
+ # The pictures provider.
36
+ # @see Intranet::Pictures::JsonDbProvider The specification of the provider, and in particular
37
+ # the minimal mandatory elements that must be returned by the operations.
38
+ # @param recents [Array<Hash{group_type:String, sort_by:String, asc:Boolean, limit:Integer}>]
39
+ # The description of the recent pictures album to be displayed on the module home page (all
40
+ # keys except +group_type+ may be omitted).
41
+ # @param home_groups [Array<Hash{group_type:String, sort_by:String, asc:Boolean,
42
+ # browse:String, browse_sort_by:String, browse_asc:Boolean}>]
43
+ # The description of the groups to be displayed on the module home page. All groups of the
44
+ # +group_type+ will be displayed and link to a page showing all groups of the +browse+ type.
45
+ # @param in_menu [Boolean] Whether the module instance should be displayed in the main
46
+ # navigation menu or not.
47
+ def initialize(provider, recents = [], home_groups = [], in_menu = true)
48
+ @provider = provider
49
+ @recents = recents
50
+ @in_menu = in_menu
51
+ @home_groups = home_groups
52
+ end
53
+
54
+ # Specifies if the responder instance should be displayed in the main navigation menu or not.
55
+ # @return [Boolean] True if the responder instance should be added to the main navigation
56
+ # menu, False otherwise.
57
+ def in_menu?
58
+ @in_menu
59
+ end
60
+
61
+ # Specifies the absolute path to the resources directory for that module.
62
+ # @return [String] The absolute path to the resources directory for the module.
63
+ def resources_dir
64
+ File.absolute_path(File.join('..', 'resources'), __dir__)
65
+ end
66
+
67
+ # Generates the HTML content associated to the given +path+ and +query+.
68
+ # === REST API Description:
69
+ # * Read-only access to pictures listings under */api/pictures* using +GET+ method, response
70
+ # is in JSON format with the following structure:
71
+ # [
72
+ # { "id": "...", "height": 480, "width": 640, "title": "...", "datetime": "...", ... },
73
+ # { ... }
74
+ # ]
75
+ # * Read-only access to groups listings under */api/groups/*+group_type+ using +GET+ method,
76
+ # response is in JSON format with the following structure:
77
+ # [
78
+ # { "id": "...", "title": "...", ... },
79
+ # { ... }
80
+ # ]
81
+ # @param path [String] The requested URI, relative to that module root URI.
82
+ # @param query [Hash<String,String>] The URI variable/value pairs, if any.
83
+ # @return [Integer, String, String] The HTTP return code, the MIME type and the answer body.
84
+ def generate_page(path, query)
85
+ case path
86
+ when %r{^/index\.html$} then serve_home
87
+ when %r{^/browse_\w+\.html$}
88
+ serve_groups(path.gsub(%r{^/browse_(\w+)\.html$}, '\\1'), query)
89
+ when %r{^/api/} then serve_api(path.gsub(%r{^/api}, ''), query)
90
+ else super(path, query)
91
+ end
92
+ end
93
+
94
+ # The title of the Pictures module, as displayed on the web page.
95
+ # @return [String] The title of the Pictures module web page.
96
+ def title
97
+ @provider.title
98
+ end
99
+
100
+ # Provides the list of Cascade Style Sheets (CSS) dependencies for this module.
101
+ # @return [Array<String>] The list of CSS dependencies.
102
+ def css_dependencies
103
+ super + ['design/style.css',
104
+ 'design/photoswipe/photoswipe.css',
105
+ 'design/photoswipe/default-skin/default-skin.css']
106
+ end
107
+
108
+ # Provides the list of Javascript files (JS) dependencies for this module.
109
+ # @return [Array<String>] The list of JS dependencies.
110
+ def js_dependencies
111
+ super + ['design/jpictures.js',
112
+ 'design/photoswipe/photoswipe.min.js',
113
+ 'design/photoswipe/photoswipe-ui-default.min.js']
114
+ end
115
+
116
+ private
117
+
118
+ # Extract a selector from the given +query+.
119
+ # @return [Hash<String,String>] The picture or group selector.
120
+ def selector(query)
121
+ query.except('sort_by', 'sort_order')
122
+ end
123
+
124
+ # Extract the sort criteria from the given +query+.
125
+ # @return [String] The key to use to sort pictures or groups, or nil if no sort order is
126
+ # specified in +query+.
127
+ def sort_by(query)
128
+ query.fetch('sort_by')
129
+ rescue KeyError
130
+ nil
131
+ end
132
+
133
+ # Extract the sort order from the given +query+.
134
+ # @return [Boolean] False if the pictures or groups should be sorted in descending order,
135
+ # True otherwise.
136
+ # @raise KeyError If the query requests an invalid sort order.
137
+ def sort_order(query)
138
+ return false if query['sort_order'] == 'desc'
139
+ return true if query['sort_order'].nil? || query['sort_order'] == 'asc'
140
+
141
+ raise KeyError # incorrect value for 'sort_order'
142
+ end
143
+
144
+ ##########################################################################
145
+ ### Servicing of the HTML "display-able" content ###
146
+ ##########################################################################
147
+
148
+ def active_filters(query)
149
+ selector(query).map do |k, v|
150
+ { k => @provider.list_groups(k, { k => v }).first.fetch('title') }
151
+ rescue KeyError
152
+ { k => v }
153
+ end.reduce({}, :merge)
154
+ end
155
+
156
+ def recent_groups
157
+ @recents.map do |recent|
158
+ groups = @provider.list_groups(recent[:group_type], {}, recent[:sort_by], recent[:asc])
159
+ see_more_url = ''
160
+ if recent[:limit].to_i.positive?
161
+ groups = groups.first(recent[:limit])
162
+ see_more_url = "browse_#{recent[:group_type]}.html?sort_by=#{recent[:sort_by]}"
163
+ see_more_url += '&sort_order=desc' unless recent[:asc]
164
+ end
165
+ { group_type: recent[:group_type], groups: groups, see_more_url: see_more_url }
166
+ end
167
+ end
168
+
169
+ def all_groups
170
+ @home_groups.map do |section|
171
+ groups = @provider.list_groups(section[:group_type], {}, section[:sort_by], section[:asc])
172
+ url_prefix = "browse_#{section[:browse]}.html?sort_by=#{section[:browse_sort_by]}"
173
+ url_prefix += '&sort_order=desc' unless section[:browse_asc]
174
+ { group_type: section[:group_type], groups: groups, url_prefix: url_prefix }
175
+ end
176
+ end
177
+
178
+ def make_nav(group_type = nil, query = {})
179
+ h = { I18n.t('nav.home') => '/index.html', I18n.t('pictures.menu') => nil, title => nil }
180
+ unless group_type.nil?
181
+ h[title] = 'index.html'
182
+ extra_key = I18n.t("pictures.nav.#{group_type}")
183
+ filters = active_filters(query).values
184
+ extra_key += " (#{filters.join(', ')})" unless filters.empty?
185
+ h.store(extra_key, nil)
186
+ end
187
+ h
188
+ end
189
+
190
+ def gallery_url(group_type, group_id, filters = {})
191
+ filters.store(group_type, group_id)
192
+ filters.store('sort_by', 'datetime')
193
+ filters.map { |k, v| [k, v].join('=') }.join('&')
194
+ end
195
+
196
+ def serve_home
197
+ content = to_markup('pictures_home', nav: make_nav)
198
+ [206, 'text/html', { content: content, title: title }]
199
+ rescue KeyError
200
+ [404, '', '']
201
+ end
202
+
203
+ def serve_groups(type, query)
204
+ groups = @provider.list_groups(type, selector(query), sort_by(query), sort_order(query))
205
+ content = to_markup('pictures_browse', nav: make_nav(type, query), group_type: type,
206
+ filters: selector(query), groups: groups)
207
+ [206, 'text/html', { content: content, title: title }]
208
+ rescue KeyError
209
+ [404, '', '']
210
+ end
211
+
212
+ ##########################################################################
213
+ ### Servicing of the REST API (raw JSON data & pictures) ###
214
+ ##########################################################################
215
+
216
+ def api_list_groups(path, query)
217
+ group_type = path.split('/')[2].to_s
218
+ @provider.list_groups(group_type, selector(query), sort_by(query), sort_order(query))
219
+ end
220
+
221
+ def api_group_thumbnail(path, query)
222
+ group_type = path.split('/')[2].to_s
223
+ pic = @provider.group_thumbnail(group_type, selector(query))
224
+ if pic.nil?
225
+ pic = ['image/jpeg', File.read(File.join(resources_dir, 'www', 'group_thumbnail.jpg'))]
226
+ end
227
+ pic
228
+ end
229
+
230
+ def api_list_pictures(query)
231
+ @provider.list_pictures(selector(query), sort_by(query), sort_order(query))
232
+ end
233
+
234
+ def serve_api(path, query)
235
+ case path
236
+ when %r{^/groups/} then [200, 'application/json', api_list_groups(path, query).to_json]
237
+ when %r{^/group/} then [200, api_group_thumbnail(path, query)].flatten
238
+ when %r{^/pictures$} then [200, 'application/json', api_list_pictures(query).to_json]
239
+ when %r{^/picture$} then [200, @provider.picture(query)].flatten
240
+ else [404, '', '']
241
+ end
242
+ rescue KeyError
243
+ [404, '', '']
244
+ end
245
+ end
246
+ end
247
+ end