require 'csv' # Export uses the exact same population and grouped cells as the interactive table. class CountsTableExport EXTRA = ['format', 'columns', 'headers', 'labels', 'children', 'child_sort', 'order', 'nested_json', 'summary_columns', 'basis', 'axis', 'latest'].freeze attr_reader :format, :resource def initialize(params) raise ArgumentError if (params.keys - CountsTableQuery::KEYS - EXTRA).any? raise ArgumentError unless params.values.all? { _1.is_a?(String) } @params = params @labels = JSON.parse(params.fetch('labels', '{}')) raise ArgumentError unless @labels.is_a?(Hash) @selected = params.key?('children') ? params['children'].split(',') : nil @order = params.fetch('order', '').split(',') @child_sort = params.fetch('child_sort', '').split(',').map { _1.split(':', -1) } @child_sort.each do |path, direction, *extra| raise ArgumentError if extra.any? || !['asc', 'desc'].include?(direction) raise ArgumentError unless path&.match?(/\A[a-zA-Z_]+(?:\.[a-zA-Z]+)*\/[a-zA-Z]+\z/) end @format = params.fetch('format', 'json') @resource = params.fetch('resource', 'posts') raise ArgumentError unless ['json', 'csv'].include?(format) @query = CountsTableQuery.new(params.slice(*CountsTableQuery::KEYS) .merge('page' => '1', 'limit' => '100')) @summary = @query.mode == 'summary' @columns = params.fetch('columns', '').split(',') @headers = JSON.parse(params.fetch('headers', '[]')) raise ArgumentError unless @headers.is_a?(Array) && @headers.all? { _1.is_a?(String) } if @summary && @headers.any? && @headers.length != @query.axes.all.length + 1 raise ArgumentError end unless @summary if ['posts', 'tags'].include?(resource) @projection = CountsExportProjection.new(params, resource:, summary: false, tags: false) else allowed = @query.population.definition.fields.keys + ['is_latest'] raise ArgumentError if (@columns - allowed).any? raise ArgumentError unless @columns.length == @headers.length && @columns.uniq == @columns end end end def body Enumerator.new do |output| ApplicationRecord.transaction(isolation: :repeatable_read) do @language_names = Language.pluck(:code, :name).to_h started = false first = true @query.each_page do |page| unless started metadata = page.except(:versions, :cells, :axis_labels, :column_values).merge( resource:, filters: @params.slice(*CountsTableQuery::KEYS), fields: @query.population.definition.metadata, columns: @columns, column_labels: @headers, order: @params['order'], children: @params['children'], sort: @params.fetch('sort', ''), child_sort: @params['child_sort']) output << (format == 'json' ? "{\"metadata\":#{ metadata.to_json },\"data\":[" : "\uFEFF") if format == 'csv' headers = @summary ? summary_headers : @headers output << CSV.generate_line(headers.map { safe(_1) }, row_sep: "\r\n") end started = true end (page[:cells] || page[:versions]).each do |row| record = project(row) if format == 'json' output << ',' unless first output << record.to_json else values = if @summary row[:values].each_with_index.map do |value, index| summary_value(value, index, page) end + [row[:count]] elsif @projection @projection.csv_record(record, row) else record.map { |key, value| csv_logical(key, value) } end output << CSV.generate_line(values.map { safe(_1) }, row_sep: "\r\n") end first = false end end output << ']}' if format == 'json' end end end private def summary_headers return @headers if @headers.any? labels = @labels @query.axes.all.map { labels.fetch(_1['field'], _1['field']) } + [labels.fetch('count', 'count')] end def summary_value(value, index, page) field = @query.axes.all.fetch(index).fetch('field') return @labels.fetch('bot', '') if field == 'user' && value.nil? names = page[:axis_labels].fetch("a#{ index }", {}) name = field == 'user' ? names[value.to_s] : names.fetch(value.to_s, value) if field == 'user' name = @labels.fetch('notRegistered', '') if name.nil? || name == '' pattern = @labels.fetch('actorFormat', '{{name}} (#{{id}})') return pattern.gsub(/\{\{(name|id)\}\}/) do Regexp.last_match(1) == 'name' ? name.to_s : value.to_s end end @labels.fetch("#{ field }:#{ value }", name) end def project(row) return { row: row[:values].first(@query.axes.rows.length), column: row[:values].drop(@query.axes.rows.length), count: row[:count] } if @summary return @projection.project(row) if @projection @columns.to_h do |column| key = column == 'user' ? 'created_by_user' : column value = row[key] value = project_nested(value, key) if ['members', 'placements', 'export_paths_json'].include?(key) [key, value] end end def camel(key) key.gsub(/_([a-z])/) { Regexp.last_match(1).upcase } end def project_nested(value, path) rows = path == 'export_paths_json' ? (value || {}).map { |profile, target| { 'profile' => profile, 'path' => target } } : Array(value) sorts = @child_sort.filter_map do |key, direction| parent, field = key.split('/', 2) [field, direction] if parent == path end rows = rows.each_with_index.sort do |(first, a), (last, b)| compared = 0 sorts.each do |field, direction| left = first.find { |key, _| camel(key) == field }&.last right = last.find { |key, _| camel(key) == field }&.last compared = if left.nil? || right.nil? left.nil? == right.nil? ? 0 : (left.nil? ? 1 : -1) else (left.to_s <=> right.to_s) * (direction == 'asc' ? 1 : -1) end break unless compared.zero? end compared.zero? ? a <=> b : compared end.map(&:first) rows.map do |row| fields = row.keys.select { !@selected || @selected.include?("#{ path }.#{ camel(_1) }") } ordered = (@order + fields.map { "#{ path }.#{ camel(_1) }" }).uniq .filter_map { |item| fields.find { "#{ path }.#{ camel(_1) }" == item } } ordered.to_h do |key| item = row[key] item = project_nested(item, "#{ path }.#{ camel(key) }") if item.is_a?(Array) [key, item] end end end def csv_logical(key, value) return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil? return value ? @labels.fetch('latest', '') : '' if key == 'is_latest' if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone) return @query.population.timezone.utc_to_local(value.utc).iso8601 end if key == 'language_code' || key == 'source_language_code' return @language_names[value] end if value.is_a?(Hash) return value.to_h { |field, item| [@labels.fetch(camel(field.to_s), ''), csv_logical(field, item)] } end return value.map { csv_logical(key, _1) } if value.is_a?(Array) @labels.fetch("#{ key }:#{ value }", value) end def safe(value) return value if value.is_a?(Numeric) text = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s text.match?(/\A\s*[=+@-]/) ? "'#{ text }" : text end end