collab 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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d67b7d2a30faff8c19e2ef8ebbacf175bc716c8eec5b4a3672bc088554067759
4
+ data.tar.gz: 413ba76d5f6d46f390ff213b85369be667801a140151d91b166c5bc4dd1b0eff
5
+ SHA512:
6
+ metadata.gz: e11fada08ea6e80d32d3d02ead738495ad0c89d044065563c543fbd68c4c0425c4e04f7475306dd69751de6f081637f99e6919b9d42b35ffd021882c3dc4d814
7
+ data.tar.gz: e7dab34c2530ba90fe8dee20e10cca6db76bea46d37205c703186c40c93cd6c7d9d0b92ef1a149e85060c1057f61f3a08d055d8b2f0389bdcd6f60445b646f01
@@ -0,0 +1,27 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'Collab'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.md')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ require 'bundler/gem_tasks'
18
+
19
+ require 'rake/testtask'
20
+
21
+ Rake::TestTask.new(:test) do |t|
22
+ t.libs << 'test'
23
+ t.pattern = 'test/**/*_test.rb'
24
+ t.verbose = false
25
+ end
26
+
27
+ task default: :test
@@ -0,0 +1,28 @@
1
+ class Collab::DocumentChannel < ApplicationCable::Channel
2
+ def subscribed
3
+ @document = instance_exec(::Collab.config.find_document_for_subscribe)
4
+
5
+ starting_version = params[:startingVersion]&.to_i
6
+ raise "missing startingVersion" if starting_version.nil?
7
+
8
+ stream_for @document
9
+
10
+ transactions = @document.transactions
11
+ .where("document_version > ?", starting_version)
12
+ .order(document_version: :asc)
13
+ .load
14
+
15
+ raise "invalid version" unless transactions.first.document_version == (starting_version + 1) unless transactions.empty?
16
+
17
+ transactions.lazy.map(&:as_json).each(method(:transmit))
18
+ end
19
+
20
+ def submit(data)
21
+ instance_exec(@document, data, &::Collab.config.authorize_update_document)
22
+ @document.perform_transaction_later(data)
23
+ end
24
+
25
+ def unsubscribed
26
+ stop_all_streams # this may not be needed
27
+ end
28
+ end
@@ -0,0 +1,7 @@
1
+ class Collab::DocumentTransactionJob < ::Collab.config.application_job.constantize
2
+ queue_as ::Collab.config.queue_document_transaction_job_as
3
+
4
+ def perform(document, data)
5
+ document.apply_transaction_now(data)
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ require "collab/config"
2
+ require "collab/railtie"
3
+
4
+ module Collab
5
+ def config
6
+ @config ||= Collab::Config.new
7
+ return @config unless block_given?
8
+ yield @config
9
+ end
10
+
11
+ autoload "Bridge", "collab/bridge"
12
+ autoload "HasCollaborativeDocument", "collab/has_collaborative_document"
13
+
14
+ module Models
15
+ autoload "Base", "collab/models/base"
16
+ autoload "Document", "collab/models/document"
17
+ autoload "DocumentTransaction", "collab/models/document_transaction"
18
+ end
19
+ end
@@ -0,0 +1,54 @@
1
+ require "collab/config"
2
+ require "json"
3
+
4
+ module Collab
5
+ class Bridge
6
+ class JSRuntimeError < StandardError
7
+ def initialize(data)
8
+ @js_backtrace = data["stack"].split("\n").map{|f| "JavaScript #{f.strip}"} if data["stack"]
9
+
10
+ super(data["name"] + ": " + data["message"])
11
+ end
12
+
13
+ def backtrace
14
+ return unless val = super
15
+
16
+ if @js_backtrace
17
+ @js_backtrace + val
18
+ else
19
+ val
20
+ end
21
+ end
22
+ end
23
+
24
+ def initialize
25
+ @node = Dir.chdir(Rails.root) do
26
+ IO.popen(["node", "-e", %q{"require('rails-collab-server')"}], "r+")
27
+ end
28
+ end
29
+
30
+ def self.current
31
+ @current ||= new
32
+ end
33
+
34
+ def call(name, data = nil, schemaName:)
35
+ req = {name: name, data: data, schemaPackage: ::Collab.config.schema_package, schemaName: schemaName}
36
+ @node.puts(JSON.generate(req))
37
+ res = JSON.parse(@node.gets)
38
+ raise ::Collab::Bridge::JSRuntimeError.new(res["error"]) if res["error"]
39
+ res["result"]
40
+ end
41
+
42
+ def apply_transaction(document, transaction, schemaName:)
43
+ call("applyTransaction", {doc: document, data: transaction}, schemaName: schemaName)
44
+ end
45
+
46
+ def html_to_document(html, schemaName:)
47
+ call("htmlToDoc", html, schemaName: schemaName)
48
+ end
49
+
50
+ def document_to_html(document, schemaName:)
51
+ call("docToHtml", document, schemaName: schemaName)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,15 @@
1
+ module Collab
2
+ class Config
3
+ attr_accessor :max_transactions, :application_job, :queue_document_transaction_job_as, :schema_package, :application_record, :document_transaction_model, :document_model
4
+
5
+ def find_document_for_subscribe(&block)
6
+ @find_document_for_subscribe unless block
7
+ @find_document_for_subscribe = block
8
+ end
9
+
10
+ def authorize_update_document(&block)
11
+ @authorize_update_document unless block
12
+ @authorize_update_document = block
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ module Collab
2
+ module HasCollaborativeDocument
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ def has_collaborative_document(attach_as, schema:, blank_document:)
7
+ has_one attach_as, -> { where(attached_as: attach_as) }, class_name: ::Collab.config.document_model, as: :attached
8
+
9
+ define_method attach_as do
10
+ super() || begin
11
+ document = self.__send__("build_#{attach_as}", schema_name: schema, document: blank_document.dup)
12
+ document.save!
13
+ document
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,6 @@
1
+ module Collab
2
+ class Models::Base < ::Collab.config.application_record.constantize
3
+ self.abstract_class = true
4
+ self.table_name_prefix = 'collab_'
5
+ end
6
+ end
@@ -0,0 +1,75 @@
1
+ module Collab
2
+ class Models::Document < ::Collab::Models::Base
3
+ belongs_to :attached, polymorphic: true
4
+ has_many :transactions, class_name: ::Collab.config.document_transaction_model, foreign_key: :document_id
5
+
6
+ validates :document, presence: true
7
+ validates :document_version, presence: true, numericality: {only_integer: true, greater_than_or_equal_to: 0}
8
+ validates :schema_name, presence: true
9
+
10
+ before_save :nullify_serialized_html, unless: :serialized_html_fresh?
11
+ after_save :delete_old_transactions
12
+
13
+ # The already-serialized html version of this document
14
+ def serialized_html
15
+ return super if serialized_html_fresh?
16
+ end
17
+
18
+ def serialized_html_fresh?
19
+ serialized_html_version == document_version
20
+ end
21
+
22
+ # Serialize the document to html - will be cached if possible (somewhat expensive)
23
+ def to_html
24
+ return serialized_html if serialized_html
25
+
26
+ serialized_html = ::Collab::Bridge.current.document_to_html(self.document, schema_name: schema_name)
27
+ self.update! serialized_html: serialized_html, serialized_html_version: document_version
28
+ serialized_html
29
+ end
30
+
31
+ def from_html(html)
32
+ raise "cannot override a persisted document" if self.persisted?
33
+
34
+ self.document = ::Collab::Bridge.current.html_to_document(html, schema_name: schema_name)
35
+ end
36
+
37
+ def perform_transaction_later(data)
38
+ ::Collab::DocumentTransactionJob.perform_later(@document, data)
39
+ end
40
+
41
+ def apply_transaction_now(data)
42
+ return unless data["v"].is_a?(Integer)
43
+
44
+ return if (data["v"] - 1) != self.document_version # if expired between queue and perform, there is no need to aquire a lock. this is an optimization - not a guarantee
45
+ with_lock do
46
+ return if (data["v"] - 1) != self.document_version # now that we've aquired a lock to the record, ensure that we're still accessing the correct version
47
+
48
+ transaction_result = ::Collab::Bridge.current.apply_transaction(self.document, data, schema_name: self.schema_name)
49
+ return unless transaction_result # check to make sure the transaction succeeded
50
+
51
+ self.document = transaction_result["doc"]
52
+ self.document_version = data["v"]
53
+ save!
54
+
55
+ transactions.create! document_version: self.document_version, steps: data["steps"], ref: data["ref"]
56
+ end
57
+ end
58
+
59
+ def as_json
60
+ {id: id, document: document, version: self.document_version}
61
+ end
62
+
63
+ private
64
+
65
+ def nullify_serialized_html
66
+ self.serialized_html = nil
67
+ end
68
+
69
+ def delete_old_transactions
70
+ cutoff = document_version - ::Collab.config.max_transactions
71
+ return if cutoff <= 0
72
+ transactions.where("document_version < ?", cutoff).delete_all
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,22 @@
1
+ module Collab
2
+ class Models::DocumentTransaction < ::Collab::Models::Base
3
+ belongs_to :document, class_name: ::Collab.config.document_model
4
+
5
+ validates :steps, presence: true
6
+ validates :document_version, presence: true
7
+
8
+ after_create_commit :broadcast
9
+
10
+ def as_json
11
+ {
12
+ v: document_version,
13
+ steps: steps,
14
+ ref: ref
15
+ }
16
+ end
17
+
18
+ def broadcast
19
+ ::Collab::DocumentChannel.broadcast_to(document, as_json)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,4 @@
1
+ module Collab
2
+ class Railtie < ::Rails::Railtie
3
+ end
4
+ end
@@ -0,0 +1,3 @@
1
+ module Collab
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,21 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Collab
5
+ class Install < Rails::Generators::NamedBase
6
+ include ::Rails::Generators::Migration
7
+
8
+ desc "Creates the necessary migrations and initializer for the gem"
9
+
10
+ source_root File.expand_path('templates', __dir__)
11
+
12
+ def copy_initializer_file
13
+ migration_template(
14
+ "create_collab_tables.rb",
15
+ "db/migrate/create_collab_tables.rb",
16
+ )
17
+
18
+ copy_file "initializer.rb", "config/initializers/collab.rb"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,32 @@
1
+ class CreateCollabTables < ActiveRecord::Migration[6.0]
2
+ def change
3
+ create_table :collab_documents, id: :uuid do |t|
4
+ t.references :attached, null: false, index: false, type: :uuid, polymorphic: true
5
+ t.string :attached_as
6
+ t.index [:attached_type, :attached_id, :attached_as], name: "index_collaborative_documents_on_attached"
7
+
8
+ t.string :schema_name, null: false
9
+
10
+ t.jsonb :document, null: false
11
+ t.integer :document_version, null: false, default: 0
12
+
13
+ t.text :serialized_html
14
+ t.integer :serialized_html_version
15
+
16
+ t.timestamps
17
+ end
18
+
19
+ create_table :collab_document_transactions, id: false do |t|
20
+ t.references :document, null: false, foreign_key: {to_table: :collaborative_documents}, type: :uuid, index: false
21
+
22
+ t.jsonb :steps, array: true, null: false
23
+ t.integer :document_version, null: false
24
+
25
+ t.string :ref
26
+
27
+ t.index [:document_id, :document_version], unique: true, order: {document_version: :asc}, name: "index_collaborative_document_transactions"
28
+
29
+ t.timestamps
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,39 @@
1
+ # Configuration for the collab gem
2
+ Collab.config do |c|
3
+ # The NPM package containing the document schema. This will be require()d from your Rails directory
4
+ # To use a Git repo, see https://docs.npmjs.com/files/package.json#git-urls-as-dependencies
5
+ c.schema_package = "prosemirror-schema-basic"
6
+ # How many old transactions to keep per document
7
+ c.max_transactions = 250
8
+
9
+ # Handlers
10
+ # ========
11
+ # Find a the document to subscribe to based on the params passed to the channel
12
+ # Authorization may also be performed here (raise an error)
13
+ # The block is executed in the scope of the ActionCable channel within #subscribe
14
+ c.find_document_for_subscribe do
15
+ Collab::Models::Document.find params[:document_id]
16
+ end
17
+ # Called when a client submits a transaction in order to update a document
18
+ # You should throw an error if unauthorized
19
+ # The block is executed in the instance of the channel
20
+ c.authorize_update_document do |document, transaction_data|
21
+ # raise "authorization failed"
22
+ end
23
+
24
+ # ActionJob settings
25
+ # ==================
26
+ # The base job class to use
27
+ c.application_job = "::ApplicationJob"
28
+ # The job queue to use for DocumentTransaction jobs
29
+ c.queue_document_transaction_job_as = :default
30
+
31
+
32
+ # ActiveRecord settings
33
+ # =====================
34
+ # The class which models in the gem should inherit from
35
+ c.application_record = "::ApplicationRecord"
36
+ # If you want to use your own document model or document transaction model,
37
+ c.document_model = "::Collab::Models::Document"
38
+ c.document_transaction_model = "::Collab::Models::DocumentTransaction"
39
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :collab do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: collab
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ben Aubin
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-07-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 6.0.3
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 6.0.3.1
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: 6.0.3
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 6.0.3.1
33
+ - !ruby/object:Gem::Dependency
34
+ name: sqlite3
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ description:
48
+ email:
49
+ - ben@benaubin.com
50
+ executables: []
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - Rakefile
55
+ - app/channels/collab/document_channel.rb
56
+ - app/jobs/collab/document_transaction_job.rb
57
+ - lib/collab.rb
58
+ - lib/collab/bridge.rb
59
+ - lib/collab/config.rb
60
+ - lib/collab/has_collaborative_document.rb
61
+ - lib/collab/models/base.rb
62
+ - lib/collab/models/document.rb
63
+ - lib/collab/models/document_transaction.rb
64
+ - lib/collab/railtie.rb
65
+ - lib/collab/version.rb
66
+ - lib/generators/collab/install/install_generator.rb
67
+ - lib/generators/collab/install/templates/create_collab_tables.rb
68
+ - lib/generators/collab/install/templates/initializer.rb
69
+ - lib/tasks/collab_tasks.rake
70
+ homepage: https://github.com/benaubin/rails-collab
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ homepage_uri: https://github.com/benaubin/rails-collab
75
+ source_code_uri: https://github.com/benaubin/rails-collab
76
+ changelog_uri: https://github.com/benaubin/rails-collab/blob/master/CHANGELOG.md
77
+ post_install_message:
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: 2.3.0
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 3.1.4
93
+ signing_key:
94
+ specification_version: 4
95
+ summary: Collaborative editing on Rails.
96
+ test_files: []