card-mod-history 0.11.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.
@@ -0,0 +1,70 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require 'activerecord-import'
3
+
4
+ class Card
5
+ # A _change_ is an alteration to a card's name, type, content, or trash state.
6
+ # Together, {Act acts}, {Action actions}, and {Change changes} comprise a
7
+ # comprehensive {Card card} history tracking system.
8
+ #
9
+ # For example, if a given web submission changes both the name and type of
10
+ # card, that would be recorded as one {Action action} with two
11
+ # {Change changes}.
12
+ #
13
+ # A {Change} records:
14
+ #
15
+ # * the _field_ changed
16
+ # * the new _value_ of that field
17
+ # * the {Action action} of which the change is part
18
+ #
19
+ class Change < ApplicationRecord
20
+ belongs_to :action, foreign_key: :card_action_id,
21
+ inverse_of: :card_changes
22
+
23
+ # lists the database fields for which changes are recorded
24
+ TRACKED_FIELDS = %w[name type_id db_content trash left_id right_id].freeze
25
+
26
+ class << self
27
+ # delete all {Change changes} not associated with an {Action action}
28
+ # (janitorial)
29
+ def delete_actionless
30
+ joins(
31
+ "LEFT JOIN card_actions "\
32
+ "ON card_changes.card_action_id = card_actions.id "
33
+ ).where(
34
+ "card_actions.id is null"
35
+ ).pluck_in_batches(:id) do |group_ids|
36
+ # used to be .delete_all here, but that was failing on large dbs
37
+ Rails.logger.info "deleting batch of changes"
38
+ where("id in (#{group_ids.join ','})").delete_all
39
+ end
40
+ end
41
+
42
+ # Change fields are recorded as integers. #field_index looks up the
43
+ # integer associated with a given field name.
44
+ # @param value [String, Symbol]
45
+ # @return [Integer]
46
+ def field_index value
47
+ value.is_a?(Integer) ? value : TRACKED_FIELDS.index(value.to_s)
48
+ end
49
+
50
+ # look up changes based on field name
51
+ # @param value [String, Symbol]
52
+ # @return [Change]
53
+ def find_by_field_name value
54
+ find_by_field field_index(value)
55
+ end
56
+ end
57
+
58
+ # set field value (integer)
59
+ # @param value [String, Symbol]
60
+ def field= value
61
+ write_attribute(:field, TRACKED_FIELDS.index(value.to_s))
62
+ end
63
+
64
+ # retrieve field name
65
+ # @return [String]
66
+ def field
67
+ TRACKED_FIELDS[read_attribute(:field)]
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,109 @@
1
+ event :update_ancestor_timestamps, :integrate do
2
+ ids = history_ancestor_ids
3
+ return unless ids.present?
4
+ Card.where(id: ids).update_all(updater_id: Auth.current_id, updated_at: Time.now)
5
+ ids.map { |anc_id| Card.expire anc_id.cardname }
6
+ end
7
+
8
+ # track history (acts, actions, changes) on this card
9
+ def history?
10
+ true
11
+ end
12
+
13
+ # all cards whose acts are considered part of this card's history
14
+ def history_card_ids
15
+ nestee_ids << id
16
+ end
17
+
18
+ # all cards who are considered updated if this card's was updated
19
+ def history_parent_ids
20
+ nester_ids
21
+ end
22
+
23
+ def history_ancestor_ids recursion_level=0
24
+ return [] if recursion_level > 5
25
+
26
+ ids = history_parent_ids +
27
+ history_parent_ids.map { |id| Card[id].history_ancestor_ids(recursion_level + 1) }
28
+ ids.flatten
29
+ end
30
+
31
+ # ~~FIXME~~: optimize (no need to instantiate all actions and changes!)
32
+ # Nothing is instantiated here. ActiveRecord is much smarter than you think.
33
+ # Methods like #empty? and #size make sql queries if their receivers are not already
34
+ # loaded -pk
35
+ def first_change?
36
+ # = update or delete
37
+ @current_action.action_type != :create && action_count == 2 &&
38
+ create_action.card_changes.empty?
39
+ end
40
+
41
+ def first_create?
42
+ @current_action.action_type == :create && action_count == 1
43
+ end
44
+
45
+ def action_count
46
+ Card::Action.where(card_id: @current_action.card_id).count
47
+ end
48
+
49
+ # card has account that is responsible for prior acts
50
+ def has_edits?
51
+ Card::Act.where(actor_id: id).where("card_id IS NOT NULL").present?
52
+ end
53
+
54
+ def changed_fields
55
+ Card::Change::TRACKED_FIELDS & (changed_attribute_names_to_save | saved_changes.keys)
56
+ end
57
+
58
+ def nestee_ids
59
+ requiring_id { @nestee_ids ||= nesting_ids(:referee_id, :referer_id) }
60
+ end
61
+
62
+ def nester_ids
63
+ requiring_id { @nester_ids ||= nesting_ids(:referer_id, :referee_id) }
64
+ end
65
+
66
+ def diff_args
67
+ { diff_format: :text }
68
+ end
69
+
70
+ # Delete all changes and old actions and make the last action the create action
71
+ # (that way the changes for that action will be created with the first update)
72
+ def make_last_action_the_initial_action
73
+ delete_all_changes
74
+ old_actions.delete_all
75
+ last_action.update! action_type: :create
76
+ end
77
+
78
+ def clear_history
79
+ delete_all_changes
80
+ delete_old_actions
81
+ end
82
+
83
+ def delete_old_actions
84
+ old_actions.delete_all
85
+ end
86
+
87
+ def delete_all_changes
88
+ Card::Change.where(card_action_id: all_action_ids).delete_all
89
+ end
90
+
91
+ def save_content_draft content
92
+ super
93
+ acts.create do |act|
94
+ act.ar_actions.build(draft: true, card_id: id, action_type: :update)
95
+ .card_changes.build(field: :db_content, value: content)
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def nesting_ids return_field, where_field
102
+ Card::Reference.select(return_field).distinct.where(
103
+ ref_type: "I", where_field => id
104
+ ).pluck(return_field).compact
105
+ end
106
+
107
+ def requiring_id
108
+ id ? yield : (return [])
109
+ end
@@ -0,0 +1,124 @@
1
+ ACTS_PER_PAGE = Card.config.acts_per_page
2
+
3
+ format :html do
4
+ def act_from_context
5
+ if (act_id = params["act_id"])
6
+ Act.find(act_id) || raise(Card::NotFound, "act not found")
7
+ else
8
+ card.last_action.act
9
+ end
10
+ end
11
+
12
+ # used (by history and recent)for rendering act lists with legend and paging
13
+ #
14
+ # @param acts [ActiveRecord::Relation] relation that will return acts objects
15
+ # @param context [Symbol] :relative or :absolute
16
+ # @param draft_legend [Symbol] :show or :hide
17
+ def acts_layout acts, context, draft_legend=:hide
18
+ bs_layout container: false, fluid: false do
19
+ html _render_act_legend(draft_legend => :draft_legend)
20
+ row(12) { act_list acts, context }
21
+ row(12) { act_paging acts, context }
22
+ end
23
+ end
24
+
25
+ def act_list acts, context
26
+ act_accordion acts, context do |act, seq|
27
+ fmt = context == :relative ? self : act.card.format(:html)
28
+ fmt.act_listing act, seq, context
29
+ end
30
+ end
31
+
32
+ def act_listing act, seq=nil, context=nil
33
+ opts = act_listing_opts_from_params(seq)
34
+ opts[:slot_class] = "revision-#{act.id} history-slot list-group-item"
35
+ context ||= (params[:act_context] || :absolute).to_sym
36
+ act_renderer(context).new(self, act, opts).render
37
+ end
38
+
39
+ # TODO: consider putting all these under one top-level param, eg:
40
+ # act: { seq: X, diff: [show/hide], action_view: Y }
41
+ def act_listing_opts_from_params seq
42
+ { act_seq: (seq || params["act_seq"]),
43
+ action_view: (params["action_view"] || "summary").to_sym,
44
+ hide_diff: params["hide_diff"].to_s.strip == "true" }
45
+ end
46
+
47
+ def act_accordion acts, context, &block
48
+ accordion_group acts_for_accordion(acts, context, &block), nil, class: "clear-both"
49
+ end
50
+
51
+ def acts_for_accordion acts, context
52
+ clean_acts(current_page_acts(acts)).map do |act|
53
+ with_act_seq(context, acts) do |seq|
54
+ yield act, seq
55
+ end
56
+ end
57
+ end
58
+
59
+ def with_act_seq context, acts
60
+ yield(context == :absolute ? nil : current_act_seq(acts))
61
+ end
62
+
63
+ def current_act_seq acts
64
+ @act_seq = @act_seq ? (@act_seq -= 1) : act_list_starting_seq(acts)
65
+ end
66
+
67
+ def clean_acts acts
68
+ # FIXME: if we get rid of bad act data, this will not be necessary
69
+ # The current
70
+ acts.select(&:card)
71
+ end
72
+
73
+ def current_page_acts acts
74
+ acts.page(acts_page_from_params).per acts_per_page
75
+ end
76
+
77
+ def act_list_starting_seq acts
78
+ acts.size - (acts_page_from_params - 1) * acts_per_page
79
+ end
80
+
81
+ def acts_per_page
82
+ @acts_per_page || ACTS_PER_PAGE
83
+ end
84
+
85
+ def acts_page_from_params
86
+ @acts_page_from_params ||= params["page"].present? ? params["page"].to_i : 1
87
+ end
88
+
89
+ def act_paging acts, context
90
+ wrap_with :div, class: "slotter btn-sm" do
91
+ acts = current_page_acts acts
92
+ opts = { remote: true, theme: "twitter-bootstrap-4" }
93
+ opts[:total_pages] = 10 if limited_paging? context
94
+ paginate acts, opts
95
+ end
96
+ end
97
+
98
+ def limited_paging? context
99
+ context == :absolute && Act.count > 1000
100
+ end
101
+
102
+ def action_icon action_type, extra_class=nil
103
+ icon = case action_type
104
+ when :create then :add_circle
105
+ when :update then :pencil
106
+ when :delete then :remove_circle
107
+ when :draft then :wrench
108
+ end
109
+ icon_tag icon, extra_class
110
+ end
111
+
112
+ private
113
+
114
+ def act_renderer context
115
+ case context
116
+ when :absolute
117
+ Act::ActRenderer::AbsoluteActRenderer
118
+ when :bridge
119
+ Act::ActRenderer::BridgeActRenderer
120
+ else # relative
121
+ Act::ActRenderer::RelativeActRenderer
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,124 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ def all_action_ids
4
+ Card::Action.where(card_id: id).pluck :id
5
+ end
6
+
7
+ def action_from_id action_id
8
+ return unless action_id.is_a?(Integer) || action_id =~ /^\d+$/
9
+
10
+ # if not an integer revision id is probably a mod (e.g. if you request
11
+ # files/:logo/standard.png)
12
+ action = Action.fetch action_id
13
+ return unless action.card_id == id
14
+
15
+ action
16
+ end
17
+
18
+ def old_actions
19
+ actions.where("id != ?", last_action_id)
20
+ end
21
+
22
+ def create_action
23
+ @create_action ||= actions.first
24
+ end
25
+
26
+ def nth_action index
27
+ index = index.to_i
28
+ return unless id && index.positive?
29
+
30
+ Action.where("draft is not true AND card_id = #{id}")
31
+ .order(:id).limit(1).offset(index - 1).first
32
+ end
33
+
34
+ def new_content_action_id
35
+ return unless @current_action && current_action_changes_content?
36
+
37
+ @current_action.id
38
+ end
39
+
40
+ def current_action_changes_content?
41
+ new_card? || @current_action.new_content? || db_content_is_changing?
42
+ end
43
+
44
+ format :html do
45
+ def action_from_context
46
+ if (action_id = voo.action_id || params[:action_id])
47
+ Action.fetch action_id
48
+ else
49
+ card.last_action
50
+ end
51
+ end
52
+
53
+ def action_content action, view_type
54
+ return "" unless action.present?
55
+
56
+ wrap do
57
+ [action_content_toggle(action, view_type),
58
+ content_diff(action, view_type)]
59
+ end
60
+ end
61
+
62
+ def content_diff action, view_type
63
+ diff = action.new_content? && content_changes(action, view_type)
64
+ return "<i>empty</i>" unless diff.present?
65
+
66
+ diff
67
+ end
68
+
69
+ def action_content_toggle action, view_type
70
+ return unless show_action_content_toggle?(action, view_type)
71
+
72
+ toggle_action_content_link action, view_type
73
+ end
74
+
75
+ def show_action_content_toggle? action, view_type
76
+ view_type == :expanded || action.summary_diff_omits_content?
77
+ end
78
+
79
+ def toggle_action_content_link action, view_type
80
+ other_view_type = view_type == :expanded ? :summary : :expanded
81
+ css_class = "revision-#{action.card_act_id} float-right"
82
+ link_to_view "action_#{other_view_type}",
83
+ icon_tag(action_arrow_dir(view_type), class: "md-24"),
84
+ class: css_class,
85
+ path: { action_id: action.id, look_in_trash: true }
86
+ end
87
+
88
+ def action_arrow_dir view_type
89
+ view_type == :expanded ? :triangle_left : :triangle_right
90
+ end
91
+
92
+ def revert_actions_link link_text, path_args, html_args={}
93
+ return unless card.ok? :update
94
+
95
+ path_args.reverse_merge! action: :update, look_in_trash: true, assign: true,
96
+ card: { skip: :validate_renaming }
97
+ html_args.reverse_merge! remote: true, method: :post, rel: "nofollow", path: path_args
98
+ add_class html_args, "slotter"
99
+ link_to link_text, html_args
100
+ end
101
+
102
+ def action_legend
103
+ types = %i[create update delete]
104
+ legend = types.map do |action_type|
105
+ "#{action_icon(action_type)} #{action_type}d"
106
+ end
107
+ legend << _render_draft_legend if voo.show?(:draft_legend)
108
+ "<small>Actions: #{legend.join ' | '}</small>"
109
+ end
110
+
111
+ def content_legend
112
+ legend = [Card::Content::Diff.render_added_chunk("Additions"),
113
+ Card::Content::Diff.render_deleted_chunk("Subtractions")]
114
+ "<small>Content changes: #{legend.join ' | '}</small>"
115
+ end
116
+
117
+ def content_changes action, diff_type, hide_diff=false
118
+ if hide_diff
119
+ action.raw_view
120
+ else
121
+ action.content_diff diff_type
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,8 @@
1
+ # all acts with actions on self and on cards included in self (ie, acts shown in history)
2
+ def history_acts
3
+ @history_acts ||= Act.all_with_actions_on(history_card_ids, true).order id: :desc
4
+ end
5
+
6
+ def draft_acts
7
+ drafts.created_by(Card::Auth.current_id).map(&:act)
8
+ end
@@ -0,0 +1,100 @@
1
+ # must be called on all actions and before :set_name, :process_subcards and
2
+ # :validate_delete_children
3
+ event :assign_action, :initialize, when: :actionable? do
4
+ act = director.need_act
5
+ @current_action = Card::Action.create(
6
+ card_act_id: act.id,
7
+ action_type: action,
8
+ draft: (Env.params["draft"] == "true")
9
+ )
10
+ if @supercard && @supercard != self
11
+ @current_action.super_action = @supercard.current_action
12
+ end
13
+ end
14
+
15
+ # can we store an action? (can be overridden, eg in files)
16
+ def actionable?
17
+ history?
18
+ end
19
+
20
+ event :detect_conflict, :validate, on: :update, when: :edit_conflict? do
21
+ errors.add :conflict, tr(:error_not_latest_revision)
22
+ end
23
+
24
+ def edit_conflict?
25
+ last_action_id_before_edit &&
26
+ last_action_id_before_edit.to_i != last_action_id &&
27
+ (la = last_action) &&
28
+ la.act.actor_id != Auth.current_id
29
+ end
30
+
31
+ # stores changes in the changes table and assigns them to the current action
32
+ # removes the action if there are no changes
33
+ event :finalize_action, :finalize, when: :finalize_action? do
34
+ if changed_fields.present?
35
+ @current_action.update! card_id: id
36
+
37
+ # Note: #last_change_on uses the id to sort by date
38
+ # so the changes for the create changes have to be created before the first change
39
+ store_card_changes_for_create_action if first_change?
40
+ store_card_changes unless first_create?
41
+ # FIXME: a `@current_action.card` call here breaks specs in solid_cache_spec.rb
42
+ elsif @current_action.card_changes.reload.empty?
43
+ @current_action.delete
44
+ @current_action = nil
45
+ end
46
+ end
47
+
48
+ # changes for the create action are stored after the first update
49
+ def store_card_changes_for_create_action
50
+ Card::Action.cache.delete "#{create_action.id}-changes"
51
+ store_each_history_field create_action.id do |field|
52
+ attribute_before_act field
53
+ end
54
+ end
55
+
56
+ def store_card_changes
57
+ store_each_history_field @current_action.id, changed_fields do |field|
58
+ self[field]
59
+ end
60
+ end
61
+
62
+ def store_each_history_field action_id, fields=nil
63
+ fields ||= Card::Change::TRACKED_FIELDS
64
+ if false # Card::Change.supports_import?
65
+ # attach.feature fails with this
66
+ values = fields.map.with_index { |field, index| [index, yield(field), action_id] }
67
+ Card::Change.import [:field, :value, :card_action_id], values #, validate: false
68
+ else
69
+ fields.each do |field|
70
+ Card::Change.create field: field,
71
+ value: yield(field),
72
+ card_action_id: action_id
73
+ end
74
+ end
75
+ end
76
+
77
+ def finalize_action?
78
+ actionable? && current_action
79
+ end
80
+
81
+ event :rollback_actions, :prepare_to_validate, on: :update, when: :rollback_request? do
82
+ update_args = process_revert_actions
83
+ Env.params["revert_actions"] = nil
84
+ update! update_args
85
+ clear_drafts
86
+ abort :success
87
+ end
88
+
89
+ event :finalize_act, after: :finalize_action, when: :act_card? do
90
+ Card::Director.act.update! card_id: id
91
+ end
92
+
93
+ event :remove_empty_act, :integrate_with_delay_final, when: :remove_empty_act? do
94
+ # Card::Director.act.delete
95
+ # Card::Director.act = nil
96
+ end
97
+
98
+ def remove_empty_act?
99
+ act_card? && Director.act&.ar_actions&.reload&.empty?
100
+ end