# frozen_string_literal: true require 'json' require 'mimemagic' module Intranet module Pictures # Provides pictures data and pictures groups listings from a database file in JSON format. # # === Structure of the JSON database # See the example below. # # The title of the pictures gallery is indicated in +title+ # # Pictures are described individually as a hash in the +pictures+ array. The mandatory keys in # this hash are: # * +id+ : unique identifier of the picture # * +uri+ : location of the picture, relative to the JSON file # * +title+ : description of the picture # * +datetime+ : date and time of the picture, as a string in ISO8601 format # * +height+ and +width+ : size in pixels of the picture # # Pictures are meant to be grouped in various group types (event, city, region/country, ...). # Each group type is defined as an entry in the +groups+ hash and consists of a set of groups, # each of them described by a hash with the following keys: # * +id+ : unique identifier of the group, mandatory # * +title+ : human-readable group name, mandatory # * +brief+ : optional short text associated to the group name # * +uri+ : optional group thumbnail, relative to the JSON file # # Each image is associated to at most one group in each group type by by a a "foreign key" # constraint of the type *picture*.+group_type+ = *group*.+id+. # @example Structure of the JSON database (not all mandatory are present for readability) # { # "title": "gallery title", # "groups": { # "event": [ # { "id": "party", "title": "...", "brief": "...", "uri": "party.jpg", ... }, # { ... } # ], # "city": [ # { "id": "houston", "title": "...", "uri": "houston.png", ... }, # { ... } # ] # }, # "pictures": [ # { "uri": "dir/pic0.jpg", "datetime": "...", "event": "party", "city": "houston", ... }, # { ... } # ] # } class JsonDbProvider # Initializes a new pictures data provider. # @param json_file [String] The path to the JSON database file. def initialize(json_file) @json_dir = File.dirname(json_file) # also works for URLs @json_file = json_file @json_time = Time.at(0) load_json end # Returns the pictures gallery title. # @return [String] The gallery title. # @raise KeyError If no title is defined in JSON file. def title load_json @json.fetch('title').to_s end # Returns the list of available group types. # @return [Array] The available group types. # @raise KeyError If no group type is defined in JSON file. def group_types load_json @json.fetch('groups').keys end # Returns the list of the groups satisfying the following conditions: # * the group is of the given +type+, and # * at least one picture belonging to that group matches the +selector+. # Results are returned ordered by *group*.+sort_by+ in ascending order if +asc+, and in # descending order otherwise. # @param type [String] The group type. # @param selector [Hash] The pictures selector, interpreted as a logical AND # combination of all key/value pairs provided. # @param sort_by [String] The group field to sort the results by, or nil if results should be # returned without particular sorting. # @param asc [Boolean] True to sort returned groups in ascending order, False to sort in # descending order. # @return [ArrayString, 'title'=>String, ...}>] The selected groups. # @raise KeyError If JSON file is malformed, if +type+ does not match an existing group type, # or if +sort_by+ is not an existing group field. def list_groups(type, selector = {}, sort_by = nil, asc = true) load_json groups = select_groups(type, selector).map { |g| g.except('uri') } groups.sort_by! { |g| g.fetch(sort_by) } unless sort_by.nil? groups.reverse! unless asc groups end # Returns the list of the pictures matching +selector+. # Results are returned ordered by *picture*.+sort_by+ in ascending order if +asc+, and in # descending order otherwise. # @param selector [Hash] The pictures selector, interpreted as a logical AND # combination of all key/value pairs provided. # @param sort_by [String] The picture field to sort the results by, or nil if results should # be returned without particular sorting. # @param asc [Boolean] True to sort returned pictures in ascending order, False to sort in # descending order. # @return [ArrayString, 'title'=>String, 'datetime'=>String, 'height'=>Integer, # 'width'=>Integer, ...}>] The selected pictures. # @raise KeyError If JSON file is malformed, or if +sort_by+ is not an existing picture field. def list_pictures(selector = {}, sort_by = nil, asc = true) load_json pics = select_pictures(selector).map { |p| p.except('uri') } pics.sort_by! { |p| p.fetch(sort_by) } unless sort_by.nil? pics.reverse! unless asc pics end # Returns the picture file matching +selector+. # @param selector [Hash] The picture selector, which should return exactly one # picture. # @return [String, Blob] The MIME type of the picture, and the picture file content. # @raise KeyError If JSON file is malformed, if selector does not match exactly one picture, # or if the *picture*.+uri+ does not refer to an existing image file. def picture(selector = {}) load_json pic = select_pictures(selector) raise KeyError unless pic.size == 1 path = File.join(@json_dir, pic.first.fetch('uri')) open_image_file(path) end # Returns the thumbnail picture of the group satisfying the following conditions: # * the group is of the given +type+, and # * at least one picture belonging to that group matches the +selector+. # @param type [String] The group type. # @param selector [Hash] The pictures selector, interpreted as a logical AND # combination of all key/value pairs provided. The # selector should return exactly one group. # @return [String, Blob] The MIME type of the picture, and the picture file content. Nil may # be returned if no thumbnail is available for that group. # @raise KeyError If JSON file is malformed, if selector does not match exactly one group, # or if the *group*.+uri+ does not refer to an existing image file. def group_thumbnail(type, selector = {}) load_json group = select_groups(type, selector) raise KeyError unless group.size == 1 return nil if group.first['uri'].nil? path = File.join(@json_dir, group.first.fetch('uri')) open_image_file(path) end private # (Re)load the JSON file if modified on disk def load_json mtime = File.mtime(@json_file) return unless @json_time < File.mtime(@json_file) @json = JSON.parse(File.read(@json_file)) @json_time = mtime end def open_image_file(path) mime_type = MimeMagic.by_path(path).type raise KeyError unless mime_type.start_with?('image/') [MimeMagic.by_path(path).type, File.read(path)] rescue Errno::ENOENT raise KeyError end def picture_match?(picture, selector) selector.all? { |k, v| picture.fetch(k) == v } rescue KeyError false end def select_groups(type, selector) return @json.fetch('groups').fetch(type) if selector.empty? # optimization groups_id = select_pictures_having(type, selector).map { |p| p.fetch(type) }.uniq.compact @json.fetch('groups').fetch(type).select { |g| groups_id.include?(g.fetch('id')) } end def select_pictures(selector) @json.fetch('pictures').select { |p| picture_match?(p, selector) } end def select_pictures_having(group_type, selector) @json.fetch('pictures').select { |p| p.key?(group_type) && picture_match?(p, selector) } end end end end