docsmith 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/.rspec +3 -0
- data/.rspec_status +212 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +66 -0
- data/Rakefile +8 -0
- data/USAGE.md +510 -0
- data/docs/superpowers/plans/2026-04-01-docsmith-full-plan.md +6459 -0
- data/docs/superpowers/plans/2026-04-08-parsers-remove-branches-docs.md +2112 -0
- data/docs/superpowers/specs/2026-04-01-docsmith-phase1-design.md +340 -0
- data/docsmith_spec.md +630 -0
- data/lib/docsmith/auto_save.rb +29 -0
- data/lib/docsmith/comments/anchor.rb +68 -0
- data/lib/docsmith/comments/comment.rb +44 -0
- data/lib/docsmith/comments/manager.rb +73 -0
- data/lib/docsmith/comments/migrator.rb +64 -0
- data/lib/docsmith/configuration.rb +95 -0
- data/lib/docsmith/diff/engine.rb +39 -0
- data/lib/docsmith/diff/parsers/html.rb +64 -0
- data/lib/docsmith/diff/parsers/markdown.rb +60 -0
- data/lib/docsmith/diff/renderers/base.rb +62 -0
- data/lib/docsmith/diff/renderers/registry.rb +41 -0
- data/lib/docsmith/diff/renderers.rb +10 -0
- data/lib/docsmith/diff/result.rb +77 -0
- data/lib/docsmith/diff.rb +6 -0
- data/lib/docsmith/document.rb +44 -0
- data/lib/docsmith/document_version.rb +50 -0
- data/lib/docsmith/errors.rb +18 -0
- data/lib/docsmith/events/event.rb +19 -0
- data/lib/docsmith/events/hook_registry.rb +14 -0
- data/lib/docsmith/events/notifier.rb +22 -0
- data/lib/docsmith/rendering/html_renderer.rb +36 -0
- data/lib/docsmith/rendering/json_renderer.rb +29 -0
- data/lib/docsmith/version.rb +5 -0
- data/lib/docsmith/version_manager.rb +143 -0
- data/lib/docsmith/version_tag.rb +25 -0
- data/lib/docsmith/versionable.rb +252 -0
- data/lib/docsmith.rb +52 -0
- data/lib/generators/docsmith/install/install_generator.rb +27 -0
- data/lib/generators/docsmith/install/templates/create_docsmith_tables.rb.erb +64 -0
- data/lib/generators/docsmith/install/templates/docsmith_initializer.rb.erb +19 -0
- data/sig/docsmith.rbs +4 -0
- metadata +196 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Docsmith
|
|
6
|
+
module Diff
|
|
7
|
+
# Holds the computed diff between two DocumentVersion records.
|
|
8
|
+
# Produced by Diff::Engine.between; consumed by callers for stats and rendering.
|
|
9
|
+
class Result
|
|
10
|
+
# @return [String] content type of the diffed document ("markdown", "html", "json")
|
|
11
|
+
attr_reader :content_type
|
|
12
|
+
# @return [Integer] version_number of the from (older) version
|
|
13
|
+
attr_reader :from_version
|
|
14
|
+
# @return [Integer] version_number of the to (newer) version
|
|
15
|
+
attr_reader :to_version
|
|
16
|
+
# @return [Array<Hash>] change hashes produced by Renderers::Base#compute
|
|
17
|
+
attr_reader :changes
|
|
18
|
+
|
|
19
|
+
# @param content_type [String]
|
|
20
|
+
# @param from_version [Integer]
|
|
21
|
+
# @param to_version [Integer]
|
|
22
|
+
# @param changes [Array<Hash>]
|
|
23
|
+
def initialize(content_type:, from_version:, to_version:, changes:)
|
|
24
|
+
@content_type = content_type
|
|
25
|
+
@from_version = from_version
|
|
26
|
+
@to_version = to_version
|
|
27
|
+
@changes = changes
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Integer] number of added lines
|
|
31
|
+
def additions
|
|
32
|
+
changes.count { |c| c[:type] == :addition }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Integer] number of deleted lines
|
|
36
|
+
def deletions
|
|
37
|
+
changes.count { |c| c[:type] == :deletion }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [String] HTML diff representation
|
|
41
|
+
def to_html
|
|
42
|
+
Renderers::Registry.for(content_type).new.render_html(changes)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [String] JSON diff representation matching the documented schema
|
|
46
|
+
def to_json(*)
|
|
47
|
+
{
|
|
48
|
+
content_type: content_type,
|
|
49
|
+
from_version: from_version,
|
|
50
|
+
to_version: to_version,
|
|
51
|
+
stats: { additions: additions, deletions: deletions },
|
|
52
|
+
changes: changes.map { |c| serialize_change(c) }
|
|
53
|
+
}.to_json
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def serialize_change(change)
|
|
59
|
+
case change[:type]
|
|
60
|
+
when :addition
|
|
61
|
+
{ type: "addition", position: { line: change[:line] }, content: change[:content] }
|
|
62
|
+
when :deletion
|
|
63
|
+
{ type: "deletion", position: { line: change[:line] }, content: change[:content] }
|
|
64
|
+
when :modification
|
|
65
|
+
{
|
|
66
|
+
type: "modification",
|
|
67
|
+
position: { line: change[:line] },
|
|
68
|
+
old_content: change[:old_content],
|
|
69
|
+
new_content: change[:new_content]
|
|
70
|
+
}
|
|
71
|
+
else
|
|
72
|
+
change.transform_keys(&:to_s)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docsmith
|
|
4
|
+
# AR model backed by docsmith_documents.
|
|
5
|
+
# Serves as both a standalone versioned document and the shadow record
|
|
6
|
+
# auto-created when Docsmith::Versionable is included on any AR model.
|
|
7
|
+
#
|
|
8
|
+
# Shadow record lifecycle:
|
|
9
|
+
# include Docsmith::Versionable on Article → first save_version! call does:
|
|
10
|
+
# Docsmith::Document.find_or_create_by!(subject: article_instance)
|
|
11
|
+
# subject_type / subject_id link back to the originating record.
|
|
12
|
+
class Document < ActiveRecord::Base
|
|
13
|
+
self.table_name = "docsmith_documents"
|
|
14
|
+
|
|
15
|
+
belongs_to :subject, polymorphic: true, optional: true
|
|
16
|
+
has_many :document_versions,
|
|
17
|
+
-> { order(:version_number) },
|
|
18
|
+
foreign_key: :document_id,
|
|
19
|
+
class_name: "Docsmith::DocumentVersion",
|
|
20
|
+
dependent: :destroy
|
|
21
|
+
has_many :version_tags,
|
|
22
|
+
through: :document_versions,
|
|
23
|
+
class_name: "Docsmith::VersionTag"
|
|
24
|
+
|
|
25
|
+
validates :content_type, presence: true,
|
|
26
|
+
inclusion: { in: %w[html markdown json] }
|
|
27
|
+
|
|
28
|
+
# @return [Docsmith::DocumentVersion, nil] latest version by version_number
|
|
29
|
+
def current_version
|
|
30
|
+
document_versions.last
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Find or create the shadow Document for an existing AR record.
|
|
34
|
+
# @param record [ActiveRecord::Base]
|
|
35
|
+
# @param field [Symbol, nil] ignored — content_field comes from class config
|
|
36
|
+
# @return [Docsmith::Document]
|
|
37
|
+
def self.from_record(record, field: nil)
|
|
38
|
+
find_or_create_by!(subject: record) do |doc|
|
|
39
|
+
doc.content_type = "markdown"
|
|
40
|
+
doc.title = record.respond_to?(:title) ? record.title.to_s : record.class.name
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docsmith
|
|
4
|
+
# Immutable content snapshot. Table is docsmith_versions.
|
|
5
|
+
# Class name is DocumentVersion (not Version) to avoid colliding with
|
|
6
|
+
# lib/docsmith/version.rb which holds the Docsmith::VERSION constant.
|
|
7
|
+
class DocumentVersion < ActiveRecord::Base
|
|
8
|
+
self.table_name = "docsmith_versions"
|
|
9
|
+
|
|
10
|
+
belongs_to :document,
|
|
11
|
+
class_name: "Docsmith::Document",
|
|
12
|
+
foreign_key: :document_id
|
|
13
|
+
belongs_to :author, polymorphic: true, optional: true
|
|
14
|
+
has_many :version_tags,
|
|
15
|
+
class_name: "Docsmith::VersionTag",
|
|
16
|
+
foreign_key: :version_id,
|
|
17
|
+
dependent: :destroy
|
|
18
|
+
has_many :comments,
|
|
19
|
+
class_name: "Docsmith::Comments::Comment",
|
|
20
|
+
foreign_key: :version_id,
|
|
21
|
+
dependent: :destroy
|
|
22
|
+
|
|
23
|
+
validates :version_number, presence: true,
|
|
24
|
+
uniqueness: { scope: :document_id }
|
|
25
|
+
validates :content, presence: true
|
|
26
|
+
validates :content_type, presence: true,
|
|
27
|
+
inclusion: { in: %w[html markdown json] }
|
|
28
|
+
|
|
29
|
+
# @return [Docsmith::DocumentVersion, nil]
|
|
30
|
+
def previous_version
|
|
31
|
+
document.document_versions
|
|
32
|
+
.where("version_number < ?", version_number)
|
|
33
|
+
.last
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Renders this version's content in the given output format.
|
|
37
|
+
#
|
|
38
|
+
# @param format [Symbol] :html or :json
|
|
39
|
+
# @param options [Hash] passed through to the renderer
|
|
40
|
+
# @return [String]
|
|
41
|
+
# @raise [ArgumentError] for unknown formats
|
|
42
|
+
def render(format, **options)
|
|
43
|
+
case format.to_sym
|
|
44
|
+
when :html then Rendering::HtmlRenderer.new.render(self, **options)
|
|
45
|
+
when :json then Rendering::JsonRenderer.new.render(self, **options)
|
|
46
|
+
else raise ArgumentError, "Unknown render format: #{format}. Supported: :html, :json"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docsmith
|
|
4
|
+
# Base class for all Docsmith errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when content_field returns a non-String and no content_extractor is configured.
|
|
8
|
+
class InvalidContentField < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when max_versions is set, all versions are tagged, and a new version would exceed the limit.
|
|
11
|
+
class MaxVersionsExceeded < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when a requested version_number does not exist on the document.
|
|
14
|
+
class VersionNotFound < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when tag_version! is called with a name already used on this document.
|
|
17
|
+
class TagAlreadyExists < Error; end
|
|
18
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docsmith
|
|
4
|
+
module Events
|
|
5
|
+
# Immutable payload for all Docsmith events.
|
|
6
|
+
# Fields on every event: record, document, version, author.
|
|
7
|
+
# Extra fields by event:
|
|
8
|
+
# version_restored → from_version
|
|
9
|
+
# version_tagged → tag_name
|
|
10
|
+
# comment_added/orphaned → comment (Phase 3)
|
|
11
|
+
# branch_created/merged → branch (Phase 4)
|
|
12
|
+
Event = Struct.new(
|
|
13
|
+
:record, :document, :version, :author,
|
|
14
|
+
:from_version, :tag_name,
|
|
15
|
+
:comment, :branch, :conflicts, :merge_result,
|
|
16
|
+
keyword_init: true
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docsmith
|
|
4
|
+
module Events
|
|
5
|
+
# Calls synchronous hooks registered via Docsmith.configure { |c| c.on(:event) { } }.
|
|
6
|
+
module HookRegistry
|
|
7
|
+
# @param event_name [Symbol]
|
|
8
|
+
# @param event [Docsmith::Events::Event]
|
|
9
|
+
def self.call(event_name, event)
|
|
10
|
+
Docsmith.configuration.hooks_for(event_name).each { |hook| hook.call(event) }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/notifications"
|
|
4
|
+
|
|
5
|
+
module Docsmith
|
|
6
|
+
module Events
|
|
7
|
+
# Fires both AS::Notifications and callback hooks for every action.
|
|
8
|
+
# Instrument name format: "#{event_name}.docsmith" (e.g. "version_created.docsmith").
|
|
9
|
+
module Notifier
|
|
10
|
+
# @param event_name [Symbol]
|
|
11
|
+
# @param payload [Hash] keyword args forwarded to Event.new
|
|
12
|
+
# @return [Docsmith::Events::Event]
|
|
13
|
+
def self.instrument(event_name, **payload)
|
|
14
|
+
event = Event.new(**payload)
|
|
15
|
+
ActiveSupport::Notifications.instrument("#{event_name}.docsmith", payload) do
|
|
16
|
+
HookRegistry.call(event_name, event)
|
|
17
|
+
end
|
|
18
|
+
event
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Docsmith
|
|
7
|
+
module Rendering
|
|
8
|
+
# Renders a DocumentVersion's content as an HTML string.
|
|
9
|
+
# Markdown is shown pre-formatted (no external gem dependency).
|
|
10
|
+
# JSON is pretty-printed inside a pre block.
|
|
11
|
+
# Subclass and override #render to plug in a markdown gem (e.g. redcarpet).
|
|
12
|
+
class HtmlRenderer
|
|
13
|
+
# @param version [Docsmith::DocumentVersion]
|
|
14
|
+
# @param options [Hash] unused in Phase 2; available for subclasses
|
|
15
|
+
# @return [String] HTML representation of the version content
|
|
16
|
+
def render(version, **options)
|
|
17
|
+
content = version.content.to_s
|
|
18
|
+
content_type = version.content_type.to_s
|
|
19
|
+
|
|
20
|
+
case content_type
|
|
21
|
+
when "html"
|
|
22
|
+
content
|
|
23
|
+
when "markdown"
|
|
24
|
+
"<pre class=\"docsmith-markdown\">#{CGI.escapeHTML(content)}</pre>"
|
|
25
|
+
when "json"
|
|
26
|
+
pretty = JSON.pretty_generate(JSON.parse(content))
|
|
27
|
+
"<pre class=\"docsmith-json\">#{CGI.escapeHTML(pretty)}</pre>"
|
|
28
|
+
else
|
|
29
|
+
"<pre>#{CGI.escapeHTML(content)}</pre>"
|
|
30
|
+
end
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
"<pre>#{CGI.escapeHTML(content)}</pre>"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Docsmith
|
|
6
|
+
module Rendering
|
|
7
|
+
# Renders a DocumentVersion's content as a JSON string.
|
|
8
|
+
# For json content_type: re-parses and pretty-prints.
|
|
9
|
+
# For other types: wraps content in a JSON envelope.
|
|
10
|
+
class JsonRenderer
|
|
11
|
+
# @param version [Docsmith::DocumentVersion]
|
|
12
|
+
# @param options [Hash] unused in Phase 2
|
|
13
|
+
# @return [String] JSON representation of the version content
|
|
14
|
+
def render(version, **options)
|
|
15
|
+
content = version.content.to_s
|
|
16
|
+
content_type = version.content_type.to_s
|
|
17
|
+
|
|
18
|
+
case content_type
|
|
19
|
+
when "json"
|
|
20
|
+
JSON.pretty_generate(JSON.parse(content))
|
|
21
|
+
else
|
|
22
|
+
{ content_type: content_type, content: content }.to_json
|
|
23
|
+
end
|
|
24
|
+
rescue JSON::ParserError
|
|
25
|
+
{ content_type: content_type, content: content, error: "invalid_json" }.to_json
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docsmith
|
|
4
|
+
# Service object for all version lifecycle operations.
|
|
5
|
+
# The Versionable mixin delegates here after resolving the shadow document.
|
|
6
|
+
# Always receives a Docsmith::Document instance.
|
|
7
|
+
module VersionManager
|
|
8
|
+
# Create a new DocumentVersion snapshot.
|
|
9
|
+
# Returns nil if content is identical to the latest version (string == check).
|
|
10
|
+
#
|
|
11
|
+
# @param document [Docsmith::Document]
|
|
12
|
+
# @param author [Object, nil]
|
|
13
|
+
# @param summary [String, nil]
|
|
14
|
+
# @param config [Hash] resolved config
|
|
15
|
+
# @return [Docsmith::DocumentVersion, nil]
|
|
16
|
+
def self.save!(document, author:, summary: nil, config: nil)
|
|
17
|
+
config ||= Configuration.resolve({}, Docsmith.configuration)
|
|
18
|
+
current = document.content.to_s
|
|
19
|
+
latest = document.document_versions.last
|
|
20
|
+
|
|
21
|
+
return nil if latest && latest.content == current
|
|
22
|
+
|
|
23
|
+
next_num = document.versions_count + 1
|
|
24
|
+
|
|
25
|
+
version = DocumentVersion.create!(
|
|
26
|
+
document: document,
|
|
27
|
+
version_number: next_num,
|
|
28
|
+
content: current,
|
|
29
|
+
content_type: document.content_type,
|
|
30
|
+
author: author,
|
|
31
|
+
change_summary: summary,
|
|
32
|
+
metadata: {}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
document.update_columns(
|
|
36
|
+
versions_count: next_num,
|
|
37
|
+
last_versioned_at: Time.current
|
|
38
|
+
)
|
|
39
|
+
document.versions_count = next_num
|
|
40
|
+
|
|
41
|
+
prune_if_needed!(document, version, config) if config[:max_versions]
|
|
42
|
+
|
|
43
|
+
record = document.subject || document
|
|
44
|
+
Events::Notifier.instrument(:version_created,
|
|
45
|
+
record: record, document: document, version: version, author: author)
|
|
46
|
+
|
|
47
|
+
version
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Restore a previous version by creating a new version with its content.
|
|
51
|
+
# Fires :version_restored (not :version_created). Never mutates existing versions.
|
|
52
|
+
#
|
|
53
|
+
# @param document [Docsmith::Document]
|
|
54
|
+
# @param version [Integer] version_number to restore from
|
|
55
|
+
# @param author [Object, nil]
|
|
56
|
+
# @param config [Hash] resolved config
|
|
57
|
+
# @return [Docsmith::DocumentVersion] the new version
|
|
58
|
+
# @raise [Docsmith::VersionNotFound]
|
|
59
|
+
def self.restore!(document, version:, author:, config: nil)
|
|
60
|
+
config ||= Configuration.resolve({}, Docsmith.configuration)
|
|
61
|
+
from_version = document.document_versions.find_by(version_number: version)
|
|
62
|
+
raise VersionNotFound, "Version #{version} not found on this document" unless from_version
|
|
63
|
+
|
|
64
|
+
next_num = document.versions_count + 1
|
|
65
|
+
|
|
66
|
+
new_version = DocumentVersion.create!(
|
|
67
|
+
document: document,
|
|
68
|
+
version_number: next_num,
|
|
69
|
+
content: from_version.content,
|
|
70
|
+
content_type: document.content_type,
|
|
71
|
+
author: author,
|
|
72
|
+
change_summary: "Restored from v#{version}",
|
|
73
|
+
metadata: {}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
document.update_columns(
|
|
77
|
+
content: from_version.content,
|
|
78
|
+
versions_count: next_num,
|
|
79
|
+
last_versioned_at: Time.current
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
record = document.subject || document
|
|
83
|
+
Events::Notifier.instrument(:version_restored,
|
|
84
|
+
record: record, document: document, version: new_version,
|
|
85
|
+
author: author, from_version: from_version)
|
|
86
|
+
|
|
87
|
+
new_version
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Tag a specific version with a name unique to this document.
|
|
91
|
+
#
|
|
92
|
+
# @param document [Docsmith::Document]
|
|
93
|
+
# @param version [Integer] version_number to tag
|
|
94
|
+
# @param name [String] unique per document
|
|
95
|
+
# @param author [Object, nil]
|
|
96
|
+
# @return [Docsmith::VersionTag]
|
|
97
|
+
# @raise [Docsmith::VersionNotFound]
|
|
98
|
+
# @raise [Docsmith::TagAlreadyExists]
|
|
99
|
+
def self.tag!(document, version:, name:, author:)
|
|
100
|
+
version_record = document.document_versions.find_by(version_number: version)
|
|
101
|
+
raise VersionNotFound, "Version #{version} not found on this document" unless version_record
|
|
102
|
+
|
|
103
|
+
if VersionTag.exists?(document_id: document.id, name: name)
|
|
104
|
+
raise TagAlreadyExists, "Tag '#{name}' already exists on this document"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
tag = VersionTag.create!(
|
|
108
|
+
document: document,
|
|
109
|
+
version: version_record,
|
|
110
|
+
name: name,
|
|
111
|
+
author: author
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
record = document.subject || document
|
|
115
|
+
Events::Notifier.instrument(:version_tagged,
|
|
116
|
+
record: record, document: document, version: version_record,
|
|
117
|
+
author: author, tag_name: name)
|
|
118
|
+
|
|
119
|
+
tag
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.prune_if_needed!(document, new_version, config)
|
|
123
|
+
max = config[:max_versions]
|
|
124
|
+
return unless max && document.versions_count > max
|
|
125
|
+
|
|
126
|
+
tagged_ids = VersionTag.where(document_id: document.id).select(:version_id)
|
|
127
|
+
oldest_untagged = document.document_versions
|
|
128
|
+
.where.not(id: tagged_ids)
|
|
129
|
+
.where.not(id: new_version.id)
|
|
130
|
+
.first
|
|
131
|
+
|
|
132
|
+
unless oldest_untagged
|
|
133
|
+
raise MaxVersionsExceeded,
|
|
134
|
+
"All #{document.versions_count} versions are tagged. Cannot prune to stay within " \
|
|
135
|
+
"max_versions: #{max}. Remove a tag or increase max_versions."
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
oldest_untagged.destroy!
|
|
139
|
+
document.update_column(:versions_count, document.versions_count - 1)
|
|
140
|
+
end
|
|
141
|
+
private_class_method :prune_if_needed!
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docsmith
|
|
4
|
+
# Named label on a DocumentVersion.
|
|
5
|
+
# Tag names are unique per document (not per version) — enforced at DB level
|
|
6
|
+
# via the unique index on [document_id, name] in docsmith_version_tags.
|
|
7
|
+
# document_id is denormalized on this table to enable that DB-level constraint.
|
|
8
|
+
class VersionTag < ActiveRecord::Base
|
|
9
|
+
self.table_name = "docsmith_version_tags"
|
|
10
|
+
|
|
11
|
+
belongs_to :document,
|
|
12
|
+
class_name: "Docsmith::Document",
|
|
13
|
+
foreign_key: :document_id
|
|
14
|
+
belongs_to :version,
|
|
15
|
+
class_name: "Docsmith::DocumentVersion",
|
|
16
|
+
foreign_key: :version_id
|
|
17
|
+
belongs_to :author, polymorphic: true, optional: true
|
|
18
|
+
|
|
19
|
+
validates :name, presence: true
|
|
20
|
+
validates :document_id, presence: true
|
|
21
|
+
validates :version_id, presence: true
|
|
22
|
+
validates :name, uniqueness: { scope: :document_id,
|
|
23
|
+
message: "already exists on this document" }
|
|
24
|
+
end
|
|
25
|
+
end
|