paper_view 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0b12547320bb6d8dd78354092beee8c80f6fe2936e7476ef766791c7fdd2caa2
4
+ data.tar.gz: 35aef5e07fbf35d80559c57e961ad7b388aaee2de8ca7d16e0f6c8279f48d096
5
+ SHA512:
6
+ metadata.gz: 5771770579c64ff7442fbbfdea10a9b071917e45c2e0ecda1cd8fa8e30d8c461eaa21ffa85bf81fa389a9e507ac7bebcf831cdc3a41990224aa69ae45eb201ef
7
+ data.tar.gz: e4129deda604f38f7c862ebf8afa45e1b4b2bc18930ed747f647170e76b3dda48080562940041990939bc056e67b3bf8400d71cc7cac2325710dadbebfe75f45
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Leon
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # PaperView
2
+
3
+ A lightweight, mountable dashboard for [paper_trail](https://github.com/paper-trail-gem/paper_trail).
4
+ Browse the `versions` table and read, in one chronological timeline, what actually changed.
5
+
6
+ ## Installation
7
+
8
+ ### 1. Add the gem
9
+
10
+ ```ruby
11
+ # Gemfile
12
+ gem "paper_view"
13
+ ```
14
+
15
+ ### 2. Mount the engine
16
+
17
+ ```ruby
18
+ # config/routes.rb
19
+ Rails.application.routes.draw do
20
+ authenticate :user, ->(user) { user.admin? } do
21
+ mount PaperView::Engine => "/paper_view"
22
+ end
23
+ end
24
+ ```
25
+
26
+ ### 3. Configure it (optional)
27
+
28
+ ```ruby
29
+ # config/initializers/paper_view.rb
30
+ PaperView.setup do |config|
31
+ # Inherit from your own controller so that Devise/Pundit/layout helpers are available.
32
+ config.parent_controller = "ApplicationController"
33
+
34
+ # Runs as a before_action, evaluated inside the controller instance.
35
+ config.authenticate_with { redirect_to main_app.root_path unless current_user&.admin? }
36
+
37
+ # Must return a truthy value, otherwise the request is answered with 403.
38
+ config.authorize_with { current_user.admin? }
39
+
40
+ # Turn `whodunnit` into something readable.
41
+ config.whodunnit_label = ->(whodunnit) { User.find_by(id: whodunnit)&.email || whodunnit }
42
+
43
+ # Any strftime format.
44
+ config.time_format = "%Y-%m-%d %H:%M:%S"
45
+
46
+ config.per_page = 25
47
+
48
+ # Any model with the paper_trail column layout works here.
49
+ # Required interface: id, item_type, item_id, event, whodunnit, created_at, object_changes
50
+ config.version_class_name = "PaperTrail::Version"
51
+ end
52
+ ```
53
+
54
+ Visit `/paper_view`.
@@ -0,0 +1,22 @@
1
+ module PaperView
2
+ class ApplicationController < PaperView.config.parent_controller.constantize
3
+ layout "paper_view/application"
4
+
5
+ before_action :authenticate_paper_view!
6
+ before_action :authorize_paper_view!
7
+
8
+ private
9
+
10
+ def authenticate_paper_view!
11
+ block = PaperView.config.authentication_block
12
+ instance_exec(&block) if block
13
+ end
14
+
15
+ def authorize_paper_view!
16
+ block = PaperView.config.authorization_block
17
+ return if block.nil?
18
+
19
+ head :forbidden unless instance_exec(&block)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,12 @@
1
+ module PaperView
2
+ class VersionsController < ApplicationController
3
+ def index
4
+ @query = VersionQuery.new(params)
5
+ @item_types = @query.item_types
6
+ return unless @query.submitted?
7
+
8
+ @paginator = Paginator.new(@query.relation, page: params[:page], per_page: params[:per_page].presence || PaperView.config.per_page)
9
+ @timeline = Timeline.new(@paginator.records, selected_id: params[:version])
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,37 @@
1
+ module PaperView
2
+ module ApplicationHelper
3
+ EMPTY = "—".freeze
4
+
5
+ def paper_view_whodunnit(version)
6
+ label = PaperView.config.whodunnit_label
7
+ value = label ? label.call(version.whodunnit) : version.whodunnit
8
+ value.presence || EMPTY
9
+ end
10
+
11
+ def paper_view_time(time)
12
+ return EMPTY if time.nil?
13
+
14
+ tag.time(time.strftime(PaperView.config.time_format), datetime: time.iso8601, title: time.iso8601)
15
+ end
16
+
17
+ def paper_view_item_path(version)
18
+ versions_path(item_type: version.item_type, item_id: version.item_id)
19
+ end
20
+
21
+ def paper_view_filter_params(overrides = {})
22
+ request.query_parameters.except("page", "version").merge(overrides)
23
+ end
24
+
25
+ def paper_view_hidden_filters(except: [])
26
+ excluded = Array(except).map(&:to_s)
27
+ safe_join(paper_view_filter_params.except(*excluded).map { |name, value| hidden_field_tag(name, value, id: nil) })
28
+ end
29
+
30
+ def paper_view_nonce_attribute
31
+ nonce = content_security_policy_nonce
32
+ return unless nonce
33
+
34
+ raw(%( nonce="#{h(nonce)}"))
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,127 @@
1
+ require "json"
2
+
3
+ module PaperView
4
+ class AttributeChange
5
+ JSON_START = /\A\s*[\[{]/
6
+
7
+ attr_reader :name, :old_value, :new_value
8
+
9
+ def self.display(value, pretty: false)
10
+ case value
11
+ when nil then EMPTY
12
+ when String then value.empty? ? '""' : value
13
+ when Hash, Array then json(value, pretty: pretty)
14
+ when Time then value.in_time_zone.strftime(PaperView.config.time_format)
15
+ else value.to_s
16
+ end
17
+ end
18
+
19
+ def self.json(value, pretty: false)
20
+ pretty ? JSON.pretty_generate(value) : JSON.generate(value)
21
+ rescue
22
+ value.inspect
23
+ end
24
+
25
+ def self.structure(value)
26
+ case value
27
+ when Hash, Array then value
28
+ when String then parse(value)
29
+ end
30
+ end
31
+
32
+ def self.parse(text)
33
+ return unless text.match?(JSON_START)
34
+
35
+ structure = JSON.parse(text)
36
+ structure if structure.is_a?(Hash) || structure.is_a?(Array)
37
+ rescue JSON::ParserError
38
+ nil
39
+ end
40
+
41
+ # Walks both sides at once, collecting every leaf as a [path, old, new] triple.
42
+ # Arrays line up by index while their length holds. Once it changes they compare by
43
+ # membership instead, each gone or gained element labelled with its own position.
44
+ def self.flatten_pair(old_value, new_value, path = nil, target = [])
45
+ if both(Hash, old_value, new_value)
46
+ (old_value.keys | new_value.keys).each do |key|
47
+ flatten_pair(old_value[key], new_value[key], [path, key].compact.join("."), target)
48
+ end
49
+ elsif both(Array, old_value, new_value) && old_value.size == new_value.size
50
+ old_value.each_index { |index| flatten_pair(old_value[index], new_value[index], "#{path}[#{index}]", target) }
51
+ elsif both(Array, old_value, new_value)
52
+ old_value.each_with_index { |item, index| target << ["#{path}[#{index}]", item, new_value.include?(item) ? item : nil] }
53
+ new_value.each_with_index { |item, index| target << ["#{path}[#{index}]", nil, item] unless old_value.include?(item) }
54
+ else
55
+ target << [path, old_value, new_value]
56
+ end
57
+ target
58
+ end
59
+
60
+ def self.both(kind, old_value, new_value)
61
+ old_value.is_a?(kind) && new_value.is_a?(kind) && (old_value.any? || new_value.any?)
62
+ end
63
+
64
+ def initialize(name, old_value, new_value)
65
+ @name = name.to_s
66
+ @old_value = old_value
67
+ @new_value = new_value
68
+ end
69
+
70
+ def nested?
71
+ !structures.nil?
72
+ end
73
+
74
+ def old_text
75
+ @old_text ||= text(old_value)
76
+ end
77
+
78
+ def new_text
79
+ @new_text ||= text(new_value)
80
+ end
81
+
82
+ def leaf_changes
83
+ leaf_pairs.filter_map { |path, before, after| LeafChange.new(path, before, after) if before != after }
84
+ end
85
+
86
+ def unchanged_count
87
+ leaf_pairs.count { |_, before, after| before == after }
88
+ end
89
+
90
+ def leaves
91
+ return [LeafChange.new(name, old_value, new_value)] unless nested?
92
+
93
+ leaf_changes.map { |change| change.under(name) }
94
+ end
95
+
96
+ private
97
+
98
+ def text(value)
99
+ self.class.display(self.class.structure(value) || value, pretty: true)
100
+ end
101
+
102
+ def structures
103
+ return @structures if defined?(@structures)
104
+
105
+ @structures = structure_pair
106
+ end
107
+
108
+ # Both sides have to be the same kind of structure. A blank side counts as an empty one,
109
+ # so a structure that was just set or cleared still diffs.
110
+ def structure_pair
111
+ pair = [old_value, new_value].map { |value| self.class.structure(value) }
112
+ kind = pair.compact.first&.class
113
+ return unless kind && pair.any? { |structure| structure&.any? }
114
+
115
+ pair = pair.zip([old_value, new_value]).map { |structure, value| structure || (kind.new if blank?(value)) }
116
+ pair if pair.all? { |structure| structure.is_a?(kind) }
117
+ end
118
+
119
+ def blank?(value)
120
+ value.nil? || value == ""
121
+ end
122
+
123
+ def leaf_pairs
124
+ @leaf_pairs ||= nested? ? self.class.flatten_pair(*structures) : []
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,48 @@
1
+ module PaperView
2
+ class ChangeSet
3
+ include Enumerable
4
+
5
+ def self.for(version)
6
+ payload = raw_payload(version)
7
+ changes = Payload.parse(payload)
8
+ new(changes || {}, raw: payload, unreadable: changes.nil? && payload.present?)
9
+ end
10
+
11
+ def self.raw_payload(version)
12
+ version.object_changes if version.respond_to?(:object_changes)
13
+ rescue
14
+ nil
15
+ end
16
+
17
+ attr_reader :entries, :raw
18
+
19
+ def initialize(raw_changes, raw: nil, unreadable: false)
20
+ @raw = raw
21
+ @unreadable = unreadable
22
+ @entries = raw_changes.filter_map do |name, pair|
23
+ next unless pair.is_a?(Array) && pair.size == 2
24
+ AttributeChange.new(name, pair.first, pair.last)
25
+ end
26
+ end
27
+
28
+ def unreadable?
29
+ @unreadable
30
+ end
31
+
32
+ def each(&block)
33
+ entries.each(&block)
34
+ end
35
+
36
+ def empty?
37
+ entries.empty?
38
+ end
39
+
40
+ def size
41
+ entries.size
42
+ end
43
+
44
+ def raw_text
45
+ @raw_text ||= raw.is_a?(Hash) ? AttributeChange.json(raw, pretty: true) : raw.to_s
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,29 @@
1
+ module PaperView
2
+ class LeafChange
3
+ attr_reader :path, :old_value, :new_value
4
+
5
+ def initialize(path, old_value, new_value)
6
+ @path = path.to_s
7
+ @old_value = old_value
8
+ @new_value = new_value
9
+ end
10
+
11
+ def under(prefix)
12
+ self.class.new(prefixed(prefix), old_value, new_value)
13
+ end
14
+
15
+ def prefixed(prefix)
16
+ return prefix if path.empty?
17
+
18
+ path.start_with?("[") ? "#{prefix}#{path}" : "#{prefix}.#{path}"
19
+ end
20
+
21
+ def old_text
22
+ AttributeChange.display(old_value)
23
+ end
24
+
25
+ def new_text
26
+ AttributeChange.display(new_value)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,61 @@
1
+ module PaperView
2
+ class Paginator
3
+ WINDOW = 2
4
+ PER_PAGE_OPTIONS = [25, 50, 100, 500].freeze
5
+
6
+ attr_reader :page, :per_page, :total_count
7
+
8
+ def initialize(relation, page:, per_page:)
9
+ @relation = relation
10
+ @per_page = per_page.to_i.clamp(1, 200)
11
+ @total_count = relation.count(:all)
12
+ @page = page.to_i.clamp(1, [total_pages, 1].max)
13
+ end
14
+
15
+ def per_page_options
16
+ (PER_PAGE_OPTIONS + [per_page]).uniq.sort
17
+ end
18
+
19
+ def records
20
+ @records ||= @relation.offset(offset).limit(per_page).to_a
21
+ end
22
+
23
+ def total_pages
24
+ (total_count / per_page.to_f).ceil
25
+ end
26
+
27
+ def offset
28
+ (page - 1) * per_page
29
+ end
30
+
31
+ def first_page?
32
+ page <= 1
33
+ end
34
+
35
+ def last_page?
36
+ page >= total_pages
37
+ end
38
+
39
+ def previous_page
40
+ first_page? ? nil : page - 1
41
+ end
42
+
43
+ def next_page
44
+ last_page? ? nil : page + 1
45
+ end
46
+
47
+ def page_numbers
48
+ return [] if total_pages <= 1
49
+
50
+ candidates = [1, total_pages]
51
+ ((page - WINDOW)..(page + WINDOW)).each do |number|
52
+ candidates << number if number.between?(1, total_pages)
53
+ end
54
+
55
+ candidates.uniq.sort.each_with_object([]) do |number, list|
56
+ list << :gap if list.last.is_a?(Integer) && number > list.last + 1
57
+ list << number
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,95 @@
1
+ require "json"
2
+ require "psych"
3
+
4
+ module PaperView
5
+ class Payload
6
+ SYMBOL_TAGS = ["!ruby/symbol", "!ruby/sym", "tag:yaml.org,2002:symbol"].freeze
7
+ BIG_DECIMAL_TAG = "!ruby/object:BigDecimal".freeze
8
+ TIME_WITH_ZONE_TAG = "!ruby/object:ActiveSupport::TimeWithZone".freeze
9
+
10
+ def self.parse(raw)
11
+ return raw if raw.is_a?(Hash)
12
+ return nil if raw.blank?
13
+
14
+ text = raw.to_s
15
+ json(text) || new(text).to_h
16
+ end
17
+
18
+ def self.json(text)
19
+ parsed = JSON.parse(text)
20
+ parsed if parsed.is_a?(Hash)
21
+ rescue JSON::ParserError, TypeError
22
+ nil
23
+ end
24
+
25
+ def initialize(text)
26
+ @text = text
27
+ @anchors = {}
28
+ @scanner = Psych::ScalarScanner.new(Psych::ClassLoader.new)
29
+ end
30
+
31
+ def to_h
32
+ document = Psych.parse(@text)
33
+ value = document && visit(document.root)
34
+ value if value.is_a?(Hash)
35
+ rescue Psych::Exception
36
+ nil
37
+ end
38
+
39
+ private
40
+
41
+ def visit(node)
42
+ case node
43
+ when Psych::Nodes::Mapping then mapping(node)
44
+ when Psych::Nodes::Sequence then sequence(node)
45
+ when Psych::Nodes::Scalar then register(node, scalar(node))
46
+ when Psych::Nodes::Alias then @anchors[node.anchor]
47
+ end
48
+ end
49
+
50
+ def mapping(node)
51
+ pairs = {}
52
+ register(node, pairs)
53
+ node.children.each_slice(2) { |key, value| pairs[visit(key).to_s] = visit(value) }
54
+ (node.tag == TIME_WITH_ZONE_TAG) ? time_with_zone(pairs) : pairs
55
+ end
56
+
57
+ def time_with_zone(pairs)
58
+ utc = pairs["utc"]
59
+ return pairs unless utc.is_a?(Time)
60
+
61
+ zone = pairs.dig("zone", "name")
62
+ (zone && Time.find_zone(zone)&.at(utc)) || utc
63
+ end
64
+
65
+ def sequence(node)
66
+ items = []
67
+ register(node, items)
68
+ node.children.each { |child| items << visit(child) }
69
+ items
70
+ end
71
+
72
+ def scalar(node)
73
+ return node.value if node.quoted
74
+ return @scanner.tokenize(node.value) if node.tag.nil?
75
+
76
+ case node.tag
77
+ when *SYMBOL_TAGS then node.value.to_sym
78
+ when BIG_DECIMAL_TAG then big_decimal(node.value)
79
+ else node.value
80
+ end
81
+ end
82
+
83
+ def big_decimal(value)
84
+ number = value.split(":", 2).last
85
+ defined?(BigDecimal) ? BigDecimal(number) : number
86
+ rescue ArgumentError, TypeError
87
+ number
88
+ end
89
+
90
+ def register(node, value)
91
+ @anchors[node.anchor] = value if node.anchor
92
+ value
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,62 @@
1
+ module PaperView
2
+ class Timeline
3
+ Group = Struct.new(:label, :rows)
4
+
5
+ attr_reader :rows
6
+
7
+ def initialize(versions, selected_id: nil)
8
+ bursts = burst_sizes(versions)
9
+ @rows = versions.map { |version| VersionRow.new(version, burst_size: bursts[version.id]) }
10
+ @selected_id = selected_id
11
+ end
12
+
13
+ def empty?
14
+ rows.empty?
15
+ end
16
+
17
+ def groups
18
+ rows.chunk_while { |one, other| one.date == other.date }
19
+ .map { |chunk| Group.new(day_label(chunk.first.date), chunk) }
20
+ end
21
+
22
+ def items
23
+ @items ||= rows.map(&:item_key).uniq
24
+ end
25
+
26
+ def stream?
27
+ items.size != 1
28
+ end
29
+
30
+ def title
31
+ stream? ? "All changes" : rows.first.item_label
32
+ end
33
+
34
+ def selected
35
+ @selected ||= rows.find { |row| row.id.to_s == @selected_id.to_s } || newest
36
+ end
37
+
38
+ private
39
+
40
+ def newest
41
+ rows.max_by { |row| [row.version.created_at || Time.at(0), row.id] }
42
+ end
43
+
44
+ def burst_sizes(versions)
45
+ versions.group_by { |version| [version.whodunnit, version.created_at&.to_i] }
46
+ .each_with_object({}) { |(_, group), sizes| group.each { |version| sizes[version.id] = group.size } }
47
+ end
48
+
49
+ def day_label(date)
50
+ return "Unknown date" if date.nil?
51
+
52
+ today = Date.current
53
+ full = date.strftime("%-d %B %Y")
54
+
55
+ case date
56
+ when today then "Today · #{full}"
57
+ when today - 1 then "Yesterday · #{full}"
58
+ else full
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,50 @@
1
+ module PaperView
2
+ class VersionQuery
3
+ EVENTS = %w[create update destroy].freeze
4
+ FILTER_KEYS = %i[item_type item_id event].freeze
5
+ SORTS = %w[desc asc].freeze
6
+
7
+ attr_reader :item_type, :item_id, :event, :sort
8
+
9
+ def initialize(params = {})
10
+ @submitted = FILTER_KEYS.any? { |key| params.key?(key) }
11
+ @item_type = params[:item_type].presence
12
+ @item_id = params[:item_id].presence
13
+ @event = params[:event].presence_in(EVENTS)
14
+ @sort = params[:sort].presence_in(SORTS) || SORTS.first
15
+ end
16
+
17
+ def submitted?
18
+ @submitted
19
+ end
20
+
21
+ def item_types
22
+ @item_types ||= versions_table.item_types
23
+ end
24
+
25
+ def relation
26
+ scope = PaperView.version_class.all
27
+ scope = scope.where(item_type: searched_item_types) if searched_item_types
28
+ scope = scope.where(item_id: item_id) if item_id
29
+ scope = scope.where(event: event) if event
30
+ scope.order(created_at: sort.to_sym, id: sort.to_sym)
31
+ end
32
+
33
+ def label
34
+ return "All versions" unless item_type
35
+ item_id ? "#{item_type} ##{item_id}" : item_type
36
+ end
37
+
38
+ private
39
+
40
+ def versions_table
41
+ @versions_table ||= VersionsTable.new
42
+ end
43
+
44
+ def searched_item_types
45
+ return @searched_item_types if defined?(@searched_item_types)
46
+
47
+ @searched_item_types = item_type || (item_types if item_id && versions_table.item_type_indexed?)
48
+ end
49
+ end
50
+ end