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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +54 -0
- data/app/controllers/paper_view/application_controller.rb +22 -0
- data/app/controllers/paper_view/versions_controller.rb +12 -0
- data/app/helpers/paper_view/application_helper.rb +37 -0
- data/app/models/paper_view/attribute_change.rb +127 -0
- data/app/models/paper_view/change_set.rb +48 -0
- data/app/models/paper_view/leaf_change.rb +29 -0
- data/app/models/paper_view/paginator.rb +61 -0
- data/app/models/paper_view/payload.rb +95 -0
- data/app/models/paper_view/timeline.rb +62 -0
- data/app/models/paper_view/version_query.rb +50 -0
- data/app/models/paper_view/version_row.rb +98 -0
- data/app/models/paper_view/versions_table.rb +57 -0
- data/app/views/layouts/paper_view/application.html.erb +36 -0
- data/app/views/paper_view/shared/_flash.html.erb +5 -0
- data/app/views/paper_view/shared/_script.html.erb +94 -0
- data/app/views/paper_view/shared/_styles.html.erb +388 -0
- data/app/views/paper_view/versions/_filters.html.erb +51 -0
- data/app/views/paper_view/versions/_footer.html.erb +41 -0
- data/app/views/paper_view/versions/_panel.html.erb +126 -0
- data/app/views/paper_view/versions/_timeline_row.html.erb +50 -0
- data/app/views/paper_view/versions/index.html.erb +77 -0
- data/config/routes.rb +5 -0
- data/lib/paper_view/configuration.rb +24 -0
- data/lib/paper_view/engine.rb +9 -0
- data/lib/paper_view/version.rb +3 -0
- data/lib/paper_view.rb +37 -0
- metadata +86 -0
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
|