require 'set' # Export identifiers are stable; UI translations are supplied separately as column labels. class CountsExportProjection ROOT_KEYS = { 'resource_id' => 'resource_id', 'version_no' => 'version_no', 'is_latest' => 'is_latest', 'thumbnail' => 'thumbnail_url', 'thumbnail_base' => 'thumbnail_base', 'title' => 'title', 'url' => 'url', 'tags' => 'tags', 'parents' => 'parents', 'video_ms' => 'video_ms', 'original_created_at' => 'original_created_at', 'original_created_from' => 'original_created_from', 'original_created_before' => 'original_created_before', 'user' => 'created_by_user', 'created_at' => 'created_at', 'name' => 'name', 'names' => 'names', 'category' => 'category', 'aliases' => 'aliases', 'deprecated' => 'deprecated' }.freeze CHILD_KEYS = { 'id' => 'id', 'versionNo' => 'version_no', 'kind' => 'kind', 'name' => 'name', 'category' => 'category', 'sections' => 'sections', 'names' => 'names', 'aliases' => 'aliases', 'languageCode' => 'language', 'scriptCode' => 'script', 'autoGenerated' => 'auto_generated' }.freeze NAME_FIELDS = ['languageCode', 'scriptCode', 'name', 'aliases', 'autoGenerated'].freeze CHILDREN = { 'tags' => ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'names'], 'names' => NAME_FIELDS, 'aliases' => ['languageCode', 'name', 'scriptCode'], 'parents' => ['id'] }.freeze attr_reader :columns, :headers def initialize(params, resource:, summary:, tags:) @params = params @labels = JSON.parse(params.fetch('labels', '{}')) raise ArgumentError unless @labels.is_a?(Hash) && @labels.length <= 100 raise ArgumentError unless @labels.all? { |key, value| valid_label?(key, value) } @summary = summary allowed = if summary tags ? ['kind', 'tag_id', 'name', 'deprecated', 'count'] : ['group', 'count'] else CountsQuery::COLUMNS.fetch(resource) end @columns = params.fetch('columns', allowed.join(',')).split(',') raise ArgumentError if (@columns - allowed).any? || @columns.uniq != @columns @headers = JSON.parse(params.fetch('headers', JSON.generate(@columns))) unless (@headers.is_a?(Array) && @headers.length == @columns.length && @headers.all? { _1.is_a?(String) && _1.length <= 200 }) raise ArgumentError end @order = params.fetch('order', '').split(',') @sorts = params.fetch('child_sort', '').split(',').map do |entry| parts = entry.split(':', -1) raise ArgumentError unless parts.length == 2 path, direction = parts raise ArgumentError unless ['asc', 'desc'].include?(direction) [path, direction] end @selected = params.key?('children') ? params['children'].split(',').to_set : nil valid = CHILDREN.flat_map do |key, fields| paths(fields, key) end raise ArgumentError if @selected && (@selected.to_a - valid).any? allowed_sorts = valid.map do |path| parts = path.split('.') field = parts.pop "#{ parts.join('.') }/#{ field }" end if ((@sorts.map(&:first) - allowed_sorts).any? || @sorts.map(&:first).uniq.length != @sorts.length) raise ArgumentError end end def metadata @columns.each_with_index.map do |key, index| { key: @summary ? key : ROOT_KEYS.fetch(key), label: @headers[index] } end end def project(row) @columns.to_h do |key| if @summary value = key == 'group' ? { key: row[:key], name: row[:name] } : row[key.to_sym] [key, value] else value = case key when 'thumbnail' then row[:thumbnail_base] when 'original_created_at' { from: row[:original_created_from], before: row[:original_created_before] } else row[ROOT_KEYS.fetch(key).to_sym] end value = nested(value || [], CHILDREN.fetch(key), key) if CHILDREN.key?(key) [ROOT_KEYS.fetch(key), value] end end end def csv_record(record, row) record.map do |key, value| key == 'group' ? row[:name] || row[:key] : csv_display(value, key) end end private def valid_label?(key, value) key.is_a?(String) && value.is_a?(String) && key.length <= 100 && value.length <= 300 end def csv_display(value, key) return value ? @labels.fetch('latest', 'true') : '' if key == 'is_latest' return @labels.fetch("kind:#{ value }", value) if key == 'kind' return @labels.fetch("category:#{ value }", value) if key == 'category' if ['language', 'script'].include?(key) && value.is_a?(Hash) return @labels.fetch("#{ key }:#{ value[:code] }", value[:name] || @labels.fetch('notRegistered', '')) end return @labels.fetch("boolean:#{ value }", value) if value == true || value == false return value.map { csv_display(_1, key) } if value.is_a?(Array) if value.is_a?(Hash) return value.to_h do |field, item| [@labels.fetch(field.to_s, field), csv_display(item, field.to_s)] end end value end def fields_for(key) return NAME_FIELDS if key == 'names' return ['name', 'scriptCode'] if key == 'aliases' [] end def paths(fields, prefix) fields.flat_map do |key| path = "#{ prefix }.#{ key }" [path, *paths(fields_for(key), path)] end end def fetch(row, key) = row.key?(key) ? row[key] : row[key.to_sym] def named_code(row, kind) code = fetch(row, "#{ kind }_code") name = fetch(row, "#{ kind }_name") { code:, name: name.present? && name.to_s.downcase != code.to_s.downcase ? name : nil } end def stable(value) case value when Hash value.stringify_keys.sort.to_h.transform_values { stable(_1) } when Array value.map { stable(_1) } else value end end def sort_value(row, key) if key == 'languageCode' language = named_code(row, 'language') return @labels.fetch("language:#{ language[:code] }", language[:name]) end return named_code(row, 'script')[:name] if key == 'scriptCode' fetch(row, CHILD_KEYS.fetch(key)) end def comparison(left, right) if left.is_a?(Array) && right.is_a?(Array) && left.length != right.length return left.length <=> right.length end return left <=> right if left.is_a?(Numeric) && right.is_a?(Numeric) if [true, false].include?(left) && [true, false].include?(right) return (left ? 1 : 0) <=> (right ? 1 : 0) end a = left.is_a?(Hash) || left.is_a?(Array) ? JSON.generate(stable(left)) : left.to_s b = right.is_a?(Hash) || right.is_a?(Array) ? JSON.generate(stable(right)) : right.to_s a <=> b end def sorted(rows, prefix, fields) sorts = @sorts.filter_map do |path, direction| parent, key = path.split('/', 2) next unless parent == prefix raise ArgumentError unless fields.include?(key) [key, direction] end rows.each_with_index.sort do |(left, a), (right, b)| result = 0 sorts.each do |key, direction| first = sort_value(left, key) last = sort_value(right, key) result = if first.nil? || last.nil? first.nil? == last.nil? ? 0 : (first.nil? ? 1 : -1) else comparison(first, last) * (direction == 'asc' ? 1 : -1) end break unless result.zero? end result.zero? ? a <=> b : result end.map(&:first) end def nested(rows, fields, prefix) keys = fields.select { !@selected || @selected.include?("#{ prefix }.#{ _1 }") } ordered = (@order + keys.map { "#{ prefix }.#{ _1 }" }).uniq .filter_map { |path| keys.find { "#{ prefix }.#{ _1 }" == path } } sorted(rows, prefix, fields).map do |row| ordered.to_h do |key| stable = CHILD_KEYS.fetch(key) value = case key when 'languageCode' then named_code(row, 'language') when 'scriptCode' then named_code(row, 'script') else fetch(row, stable) end if fields_for(key).any? value = nested(value || [], fields_for(key), "#{ prefix }.#{ key }") end [stable, value] end end end end