Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/text.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJulius Härtl <jus@bitgrid.net>2019-04-13 21:28:28 +0300
committerJulius Härtl <jus@bitgrid.net>2019-04-13 21:28:28 +0300
commit1bdb0ff3eaa0f8a874bbf306ca5fe8a45f8e6ff4 (patch)
tree69363d95e38dc7b02a55c4e2cf3a9149f75cbb46
parent66adbbbe655ef62f1545a39c2bf31630455af8da (diff)
Add first working version of collaborative editing
Signed-off-by: Julius Härtl <jus@bitgrid.net>
-rw-r--r--.drone.yml143
-rw-r--r--appinfo/app.php12
-rw-r--r--appinfo/database.xml148
-rw-r--r--appinfo/info.xml4
-rw-r--r--appinfo/routes.php13
-rw-r--r--css/style.scss20
-rw-r--r--img/app.pngbin0 -> 231 bytes
-rw-r--r--img/app.svg4
-rw-r--r--lib/AppInfo/Application.php6
-rw-r--r--lib/Controller/PublicSessionController.php51
-rw-r--r--lib/Controller/SessionController.php79
-rw-r--r--lib/Db/Document.php43
-rw-r--r--lib/Db/DocumentMapper.php53
-rw-r--r--lib/Db/Session.php (renamed from src/authority.js)56
-rw-r--r--lib/Db/SessionMapper.php85
-rw-r--r--lib/Db/Step.php52
-rw-r--r--lib/Db/StepMapper.php48
-rw-r--r--lib/EventSource.php102
-rw-r--r--lib/Service/DocumentService.php167
-rw-r--r--lib/Service/SessionService.php107
-rw-r--r--src/collab.js192
-rw-r--r--src/components/Editor.vue84
-rw-r--r--src/files.js60
-rw-r--r--src/main.js87
-rw-r--r--templates/main.php22
-rw-r--r--webpack.common.js1
26 files changed, 1496 insertions, 143 deletions
diff --git a/.drone.yml b/.drone.yml
new file mode 100644
index 000000000..be0b311df
--- /dev/null
+++ b/.drone.yml
@@ -0,0 +1,143 @@
+clone:
+ git:
+ image: plugins/git
+ depth: 1
+
+pipeline:
+ check-app-compatbility:
+ image: nextcloudci/php7.0:php7.0-17
+ environment:
+ - APP_NAME=text
+ - CORE_BRANCH=stable15
+ - DB=sqlite
+ commands:
+ # Pre-setup steps
+ - wget https://raw.githubusercontent.com/nextcloud/travis_ci/master/before_install.sh
+ - bash ./before_install.sh $APP_NAME $CORE_BRANCH $DB
+ - cd ../server
+ # Code checker
+ - ./occ app:check-code $APP_NAME -c strong-comparison
+ - ./occ app:check-code $APP_NAME -c deprecation
+ when:
+ matrix:
+ TESTS: check-app-compatbility
+ syntax-php7.0:
+ image: nextcloudci/php7.0:php7.0-17
+ environment:
+ - APP_NAME=text
+ - CORE_BRANCH=stable15
+ - DB=sqlite
+ commands:
+ - composer install
+ - ./vendor/bin/parallel-lint --exclude ./vendor/ .
+ when:
+ matrix:
+ TESTS: syntax-php7.0
+ syntax-php7.1:
+ image: nextcloudci/php7.1:php7.1-15
+ environment:
+ - APP_NAME=text
+ - CORE_BRANCH=stable15
+ - DB=sqlite
+ commands:
+ - composer install
+ - ./vendor/bin/parallel-lint --exclude ./vendor/ .
+ when:
+ matrix:
+ TESTS: syntax-php7.1
+ syntax-php7.2:
+ image: nextcloudci/php7.2:php7.2-9
+ environment:
+ - APP_NAME=text
+ - CORE_BRANCH=stable15
+ - DB=sqlite
+ commands:
+ - composer install
+ - ./vendor/bin/parallel-lint --exclude ./vendor/ .
+ when:
+ matrix:
+ TESTS: syntax-php7.2
+ syntax-php7.3:
+ image: nextcloudci/php7.3:php7.3-2
+ environment:
+ - APP_NAME=text
+ - CORE_BRANCH=stable15
+ - DB=sqlite
+ commands:
+ - composer install
+ - ./vendor/bin/parallel-lint --exclude ./vendor/ .
+ when:
+ matrix:
+ TESTS: syntax-php7.3
+ php7.1:
+ image: nextcloudci/php7.1:php7.1-16
+ environment:
+ - APP_NAME=text
+ - CORE_BRANCH=stable15
+ commands:
+ - bash ./tests/drone-server-setup.sh $APP_NAME $CORE_BRANCH ${DB}
+ - cd ../server/apps/$APP_NAME
+ - composer install
+ - phpunit -c tests/phpunit.xml --coverage-clover build/php-unit.coverage.xml
+ when:
+ matrix:
+ TESTS: php7.1
+
+ eslint:
+ image: node:lts-alpine
+ commands:
+ - npm install
+ - npm run lint
+ when:
+ matrix:
+ TESTS: eslint
+ vue-build:
+ image: node:lts-alpine
+ commands:
+ - npm install
+ - npm run build
+ when:
+ matrix:
+ TESTS: vue-build
+services:
+ mysql:
+ image: mysql:5.7.22
+ environment:
+ - MYSQL_ROOT_PASSWORD=owncloud
+ - MYSQL_USER=oc_autotest
+ - MYSQL_PASSWORD=owncloud
+ - MYSQL_DATABASE=oc_autotest
+ command: [ "--innodb_large_prefix=true", "--innodb_file_format=barracuda", "--innodb_file_per_table=true" ]
+ when:
+ matrix:
+ DB: mysql
+ postgres:
+ image: postgres:10
+ environment:
+ - POSTGRES_USER=oc_autotest
+ # This is required as nextcloud will create a separte user since the oc_autotest user can create roles
+ - POSTGRES_DB=oc_autotest_dummy
+ - POSTGRES_PASSWORD=owncloud
+ when:
+ matrix:
+ DB: postgres
+
+matrix:
+ include:
+ - TESTS: check-app-compatbility
+ - TESTS: syntax-php7.0
+ - TESTS: syntax-php7.1
+ - TESTS: syntax-php7.2
+ - TESTS: syntax-php7.3
+ - TESTS: php7.1
+ DB: sqlite
+ - TESTS: php7.1
+ DB: mysql
+ # Removed temporary until we migrated notes to a new table
+ # - TESTS: php7.1
+ # DB: postgres
+ - TESTS: eslint
+ - TESTS: vue-build
+
+
+branches: [ master, stable*, alpha1 ]
diff --git a/appinfo/app.php b/appinfo/app.php
index e29799f72..7c15d6096 100644
--- a/appinfo/app.php
+++ b/appinfo/app.php
@@ -4,3 +4,15 @@ declare(strict_types=1);
namespace OCA\Text\AppInfo;
+$eventDispatcher = \OC::$server->getEventDispatcher();
+
+// only load text editor if the user is logged in
+if (\OC::$server->getUserSession()->isLoggedIn()) {
+ $eventDispatcher->addListener('OCA\Files::loadAdditionalScripts', function () {
+ \OCP\Util::addScript('text', 'files');
+ });
+}
+
+$eventDispatcher->addListener('OCA\Files_Sharing::loadAdditionalScripts', function () {
+ // load public stuff
+});
diff --git a/appinfo/database.xml b/appinfo/database.xml
new file mode 100644
index 000000000..cbb2019c7
--- /dev/null
+++ b/appinfo/database.xml
@@ -0,0 +1,148 @@
+<?xml version="1.0" encoding="ISO-8859-1" ?>
+<database>
+ <name>*dbname*</name>
+ <create>true</create>
+ <overwrite>false</overwrite>
+ <charset>utf8</charset>
+ <table>
+ <name>*dbprefix*text_documents</name>
+ <declaration>
+ <field>
+ <name>id</name>
+ <type>integer</type>
+ <notnull>true</notnull>
+ <autoincrement>1</autoincrement>
+ <unsigned>true</unsigned>
+ <length>4</length>
+ </field>
+ <field>
+ <name>current_version</name>
+ <type>integer</type>
+ <notnull>true</notnull>
+ <unsigned>true</unsigned>
+ <length>4</length>
+ <default>0</default>
+ </field>
+ <field>
+ <name>last_saved_version</name>
+ <type>integer</type>
+ <notnull>true</notnull>
+ <unsigned>true</unsigned>
+ <length>4</length>
+ <default>0</default>
+ </field>
+ </declaration>
+ </table>
+
+ <table>
+ <name>*dbprefix*text_sessions</name>
+ <declaration>
+ <field>
+ <name>id</name>
+ <type>integer</type>
+ <notnull>true</notnull>
+ <autoincrement>1</autoincrement>
+ <unsigned>true</unsigned>
+ <length>4</length>
+ </field>
+ <field>
+ <name>user_id</name>
+ <type>text</type>
+ <length>64</length>
+ </field>
+ <field>
+ <name>guest_name</name>
+ <type>text</type>
+ <length>64</length>
+ </field>
+ <field>
+ <name>color</name>
+ <type>text</type>
+ <length>64</length>
+ </field>
+ <field>
+ <name>token</name>
+ <type>text</type>
+ <length>64</length>
+ </field>
+ <field>
+ <name>document_id</name>
+ <type>integer</type>
+ <notnull>true</notnull>
+ <length>4</length>
+ </field>
+ <field>
+ <name>last_contact</name>
+ <type>integer</type>
+ <unsigned>true</unsigned>
+ <length>4</length>
+ </field>
+
+ <index>
+ <name>rd_session_token_idx</name>
+ <unique>false</unique>
+ <field>
+ <name>token</name>
+ <sorting>ascending</sorting>
+ </field>
+ </index>
+ </declaration>
+ </table>
+
+ <table>
+ <name>*dbprefix*text_steps</name>
+ <declaration>
+ <field>
+ <name>id</name>
+ <type>integer</type>
+ <notnull>true</notnull>
+ <autoincrement>1</autoincrement>
+ <unsigned>true</unsigned>
+ <length>4</length>
+ </field>
+ <field>
+ <name>document_id</name>
+ <type>integer</type>
+ <notnull>true</notnull>
+ <unsigned>true</unsigned>
+ <length>4</length>
+ </field>
+ <field>
+ <name>session_id</name>
+ <type>integer</type>
+ <notnull>true</notnull>
+ <unsigned>true</unsigned>
+ <length>4</length>
+ </field>
+ <field>
+ <name>data</name>
+ <type>clob</type>
+ </field>
+ <field>
+ <name>version</name>
+ <type>integer</type>
+ <notnull>true</notnull>
+ <unsigned>true</unsigned>
+ <length>4</length>
+ <default>0</default>
+ </field>
+
+ <index>
+ <name>rd_steps_did_idx</name>
+ <unique>false</unique>
+ <field>
+ <name>document_id</name>
+ <sorting>ascending</sorting>
+ </field>
+ </index>
+ <index>
+ <name>rd_steps_version_idx</name>
+ <unique>false</unique>
+ <field>
+ <name>version</name>
+ <sorting>ascending</sorting>
+ </field>
+ </index>
+ </declaration>
+ </table>
+</database>
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 68674d1f0..c7d6db71f 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -5,11 +5,11 @@
<name>Text</name>
<summary>Type together</summary>
<description>Collaborative text editor</description>
- <version>0.1.0-dev1</version>
+ <version>0.1.0-dev12</version>
<licence>agpl</licence>
<author mail="jus@bitgrid.net">Julius Härtl</author>
<namespace>Text</namespace>
- <category>type</category>
+ <category>office</category>
<website>https://github.com/nextcloud/text</website>
<bugs>https://github.com/nextcloud/text/issues</bugs>
<repository type="git">https://github.com/nextcloud/text.git</repository>
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 468b47616..ff8413314 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -7,11 +7,18 @@ return [
'routes' => [
['name' => 'Navigation#navigate', 'url' => '/', 'verb' => 'GET'],
+ // Setup a new session
['name' => 'Session#create', 'url' => '/session/create', 'verb' => 'GET'],
- ['name' => 'Sync#sync', 'url' => '/session/sync', 'verb' => 'GET'],
- ['name' => 'Session#push', 'url' => '/session/push', 'verb' => 'GET'],
- ['name' => 'Session#get', 'url' => '/session/get', 'verb' => 'GET'],
+ // Load initial document
+ ['name' => 'Session#fetch', 'url' => '/session/fetch', 'verb' => 'GET'],
+ // Load steps
+ ['name' => 'Session#sync', 'url' => '/session/sync', 'verb' => 'GET'],
+ // Push own steps
+ ['name' => 'Session#push', 'url' => '/session/push', 'verb' => 'POST'],
+ // Close session
['name' => 'Session#close', 'url' => '/session/close', 'verb' => 'GET'],
+ //['name' => 'Session#get', 'url' => '/session/get', 'verb' => 'GET'],
+
]
];
diff --git a/css/style.scss b/css/style.scss
index 3e8be7259..18b8f125d 100644
--- a/css/style.scss
+++ b/css/style.scss
@@ -62,7 +62,6 @@ li.ProseMirror-selectednode:after {
}
.ProseMirror-tooltip .ProseMirror-menu {
- width: -webkit-fit-content;
width: fit-content;
white-space: pre;
}
@@ -79,7 +78,6 @@ li.ProseMirror-selectednode:after {
}
.ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu {
- font-size: 90%;
white-space: nowrap;
}
@@ -111,7 +109,7 @@ li.ProseMirror-selectednode:after {
position: absolute;
background: white;
color: #666;
- border: 1px solid #aaa;
+ box-shadow: 0 0 3px var(--color-box-shadow);
padding: 2px;
}
@@ -193,7 +191,7 @@ li.ProseMirror-selectednode:after {
position: relative;
}
-.ProseMirror-icon {
+.ProseMirror-icon, .ProseMirror-menu-dropdown-wrap {
display: inline-block;
line-height: .8;
vertical-align: -2px; /* Compensate for padding */
@@ -205,6 +203,15 @@ li.ProseMirror-selectednode:after {
}
}
+.ProseMirror-menu-dropdown-menu {
+ padding: 8px;
+ border-radius: 2px;
+ cursor: pointer;
+ .ProseMirror-menu-dropdown-item {
+ padding: 8px;
+ }
+}
+
.ProseMirror-menu-disabled.ProseMirror-icon {
cursor: default;
}
@@ -248,6 +255,7 @@ li.ProseMirror-selectednode:after {
padding: 2px 10px;
border: none;
margin: 1em 0;
+ width: 100%;
}
.ProseMirror-example-setup-style hr:after {
@@ -261,7 +269,9 @@ li.ProseMirror-selectednode:after {
.ProseMirror ul, .ProseMirror ol {
padding-left: 30px;
}
-
+.ProseMirror ul li {
+ list-style-type: disc;
+}
.ProseMirror blockquote {
padding-left: 1em;
border-left: 3px solid #eee;
diff --git a/img/app.png b/img/app.png
new file mode 100644
index 000000000..3a68deb8a
--- /dev/null
+++ b/img/app.png
Binary files differ
diff --git a/img/app.svg b/img/app.svg
new file mode 100644
index 000000000..7e7da1036
--- /dev/null
+++ b/img/app.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" height="32" width="32" version="1.0" viewBox="0 0 32 32">
+ <path style="color:#000000;block-progression:tb;text-transform:none;text-indent:0" fill="#fff" d="m4.6992 2.004c-0.395 0.0764-0.7062 0.4666-0.6992 0.875v26.244c0 0.46 0.4122 0.876 0.8632 0.876h22.276c0.452 0 0.864-0.416 0.864-0.876v-20.284c-0.008-0.1338-0.046-0.266-0.11-0.383l-6.624-6.3984c-0.086-0.0328-0.178-0.051-0.27-0.0546h-16.137c-0.0532-0.0053-0.11-0.0053-0.1636 0zm3.3008 3.996h12v2h-12v-2zm0 6h10v2h-10v-2zm0 6h16v2h-16v-2zm0 6h8v2h-8v-2z"/>
+</svg>
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index acb422b35..9281a67cf 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -19,6 +19,12 @@ class Application extends App {
*/
public function __construct(array $params = []) {
parent::__construct(self::APP_NAME, $params);
+
+ // register hook for files
+ \OC::$server->getRootFolder()->listen('\OC\Files', 'preWrite', function() {});
+ \OC::$server->getRootFolder()->listen('\OC\Files', 'postWrite', function() {});
+ \OC::$server->getRootFolder()->listen('\OC\Files', 'preDelete', function() {});
+ \OC::$server->getRootFolder()->listen('\OC\Files', 'postDelete', function() {});
}
}
diff --git a/lib/Controller/PublicSessionController.php b/lib/Controller/PublicSessionController.php
new file mode 100644
index 000000000..446b02fde
--- /dev/null
+++ b/lib/Controller/PublicSessionController.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+declare(strict_types=1);
+
+
+namespace OCA\Text\Controller;
+
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\Http\FileDisplayResponse;
+use OCP\Files\IRootFolder;
+use OCP\ICacheFactory;
+use OCP\IRequest;
+use OCP\ITempManager;
+use OCP\Security\ISecureRandom;
+
+class PublicSessionController extends SessionController {
+
+ /**
+ * TODO: maybe set guestUserId in middleware
+ */
+
+ /**
+ * @PublicPage
+ */
+ public function push($transaction): DataResponse {
+ parent::push($transaction);
+ }
+
+}
diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php
index 0ce4c1e03..7416bd5c3 100644
--- a/lib/Controller/SessionController.php
+++ b/lib/Controller/SessionController.php
@@ -4,10 +4,14 @@ declare(strict_types=1);
namespace OCA\Text\Controller;
+use OCA\Text\Service\DocumentService;
+use OCA\Text\Service\SessionService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
+use OCP\AppFramework\Http\NotFoundResponse;
use OCP\Files\IRootFolder;
+use OCP\Files\NotFoundException;
use OCP\ICacheFactory;
use OCP\IRequest;
use OCP\ITempManager;
@@ -15,51 +19,92 @@ use OCP\Security\ISecureRandom;
class SessionController extends Controller {
+ private $cache;
+ private $sessionService;
+ private $documentService;
- private $userId;
- private $secureRandom;
-
- public function __construct(string $appName, IRequest $request, ICacheFactory $cacheFactory, ITempManager $tempManager, $userId, IRootFolder $rootFolder, ISecureRandom $secureRandom) {
+ public function __construct(string $appName, IRequest $request, ICacheFactory $cacheFactory, SessionService $sessionService, DocumentService $documentService) {
parent::__construct($appName, $request);
- $this->userId = $userId;
- $this->file = $rootFolder->get('example.md');
- $this->secureRandom = $secureRandom;
+ $this->cache = $cacheFactory->createDistributed('textSession');
+ $this->sessionService = $sessionService;
+ $this->documentService = $documentService;
+ }
+
+ /**
+ * Initialize the session as a client so it can use the other methods
+ *
+ * @NoCSRFRequired
+ * @NoAdminRequired
+ */
+ public function create($file) {
+ $document = $this->documentService->createDocumentByPath($file);
+ $session = $this->sessionService->initSession($document->getId());
+ return new DataResponse([
+ 'document' => $document,
+ 'session' => $session
+ ]);
}
/**
+ *
+ *
* @NoCSRFRequired
* @NoAdminRequired
*/
- public function init() {
- $sessionId = $this->secureRandom->generate(32, ISecureRandom::CHAR_DIGITS);
- $token = $this->secureRandom->generate(32);
- // save session to database
- // return session details
+ public function fetch($documentId, $sessionId, $token) {
+ if ($this->sessionService->isValidSession($documentId, $sessionId, $token)) {
+ $this->sessionService->removeInactiveSessions($documentId);
+ $file = $this->documentService->getBaseFile($documentId);
+ return new FileDisplayResponse($file);
+ }
+ return new NotFoundResponse();
}
/**
+ * Close existing session when quiting the client gracefully
+ * This reduces some cleanup work if used by the client
+ *
* @NoCSRFRequired
* @NoAdminRequired
*/
- public function push($transaction): DataResponse {
+ public function close($documentId, $sessionId, $token): DataResponse {
+ // TODO: To implement
return new DataResponse([]);
}
/**
+ * Client tries to commit a set of transactions to the document
+ *
* @NoCSRFRequired
* @NoAdminRequired
*/
- public function get() {
- return new FileDisplayResponse($this->file);
+ public function push($documentId, $sessionId, $token, $version, $steps): DataResponse {
+ if ($this->sessionService->isValidSession($documentId, $sessionId, $token)) {
+ try {
+ $steps = $this->documentService->addStep($documentId, $sessionId, $steps, $version);
+ } catch (\Exception $e) {
+ return new DataResponse($e->getMessage(), 500);
+ }
+ return new DataResponse($steps);
+ }
+ return new DataResponse([], 500);
}
/**
+ * Eventsource based handler
+ *
* @NoCSRFRequired
* @NoAdminRequired
*/
- public function sync($transaction): DataResponse {
- return new DataResponse([]);
+ public function sync($documentId, $version = 0): DataResponse {
+ if ($version === $this->cache->get('document-version-'.$documentId)) {
+ return new DataResponse(['steps' => []]);
+ }
+ return new DataResponse([
+ 'steps' => $this->documentService->getSteps($documentId, $version),
+ 'sessions' => $this->sessionService->getActiveSessions($documentId)
+ ]);
}
}
diff --git a/lib/Db/Document.php b/lib/Db/Document.php
new file mode 100644
index 000000000..3155eac73
--- /dev/null
+++ b/lib/Db/Document.php
@@ -0,0 +1,43 @@
+<?php
+/**
+ * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Text\Db;
+
+
+use OCP\AppFramework\Db\Entity;
+
+class Document extends Entity {
+
+ public $id;
+ protected $currentVersion = 0;
+ protected $lastSavedVersion = 0;
+ protected $initialVersion = 0;
+
+ public function __construct() {
+ $this->addType('id', 'integer');
+ $this->addType('currentVersion', 'integer');
+ $this->addType('lastSavedVersion', 'integer');
+ $this->addType('initialVersion', 'integer');
+ }
+
+}
diff --git a/lib/Db/DocumentMapper.php b/lib/Db/DocumentMapper.php
new file mode 100644
index 000000000..9f4113a9d
--- /dev/null
+++ b/lib/Db/DocumentMapper.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Text\Db;
+
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+
+class DocumentMapper extends QBMapper {
+
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'text_documents', Document::class);
+ }
+
+ public function find($documentId) {
+ /* @var $qb IQueryBuilder */
+ $qb = $this->db->getQueryBuilder();
+ $result = $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('id', $qb->createNamedParameter($documentId)))
+ ->execute();
+
+ $data = $result->fetch();
+ $result->closeCursor();
+ if ($data === false) {
+ throw new DoesNotExistException('Document doesn\'t exist');
+ }
+ return Document::fromRow($data);
+ }
+}
diff --git a/src/authority.js b/lib/Db/Session.php
index 0967a9cb3..5801ea0ab 100644
--- a/src/authority.js
+++ b/lib/Db/Session.php
@@ -1,4 +1,5 @@
-/*
+<?php
+/**
* @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
@@ -20,34 +21,35 @@
*
*/
-class Authority {
- constructor(doc) {
- this.doc = doc
- this.steps = []
- this.stepClientIDs = []
- this.onNewSteps = []
- }
+namespace OCA\Text\Db;
+
+
+use OCP\AppFramework\Db\Entity;
+
+class Session extends Entity implements \JsonSerializable {
+
+ public $id;
+ protected $userId;
+ protected $token;
+ protected $color;
+ protected $guestName;
+ protected $lastContact;
+ protected $documentId;
+
+ public function __construct() {
+ $this->addType('id', 'integer');
+ $this->addType('documentId', 'integer');
+ $this->addType('lastContact', 'integer');
- receiveSteps(version, steps, clientID) {
- if (version !== this.steps.length) return
-
- // Apply and accumulate new steps
- steps.forEach(step => {
- this.doc = step.apply(this.doc).doc
- const stepSerialized = JSON.parse(JSON.stringify(steps))
- this.steps.push(stepSerialized)
- this.stepClientIDs.push(clientID)
- })
- // Signal listeners
- this.onNewSteps.forEach(function(f) { f() })
}
- stepsSince(version) {
- return {
- steps: this.steps.slice(version),
- clientIDs: this.stepClientIDs.slice(version)
- }
+ public function jsonSerialize() {
+ return [
+ 'id' => $this->id,
+ 'userId' => $this->userId,
+ 'token' => $this->token,
+ 'color' => $this->color,
+ 'lastContact' => $this->lastContact
+ ];
}
}
-
-export default Authority;
diff --git a/lib/Db/SessionMapper.php b/lib/Db/SessionMapper.php
new file mode 100644
index 000000000..aa8b081a0
--- /dev/null
+++ b/lib/Db/SessionMapper.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Text\Db;
+
+
+use OCA\Text\Service\SessionService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+
+class SessionMapper extends QBMapper {
+
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'text_sessions', Session::class);
+ }
+
+ /**
+ * @param $documentId
+ * @param $sessionId
+ * @param $token
+ * @return Session
+ * @throws DoesNotExistException
+ */
+ public function find($documentId, $sessionId, $token) {
+ /* @var $qb IQueryBuilder */
+ $qb = $this->db->getQueryBuilder();
+ $result = $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId)))
+ ->andWhere($qb->expr()->eq('id', $qb->createNamedParameter($sessionId)))
+ ->andWhere($qb->expr()->eq('token', $qb->createNamedParameter($token)))
+ ->execute();
+
+ $data = $result->fetch();
+ $result->closeCursor();
+ if ($data === false) {
+ throw new DoesNotExistException('Session is invalid');
+ }
+ return Session::fromRow($data);
+ }
+
+ public function findAllActive($documentId) {
+ /* @var $qb IQueryBuilder */
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('id','color','document_id', 'last_contact','user_id')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId)))
+ ->andWhere($qb->expr()->gt('last_contact', $qb->createNamedParameter(time()-SessionService::SESSION_VALID_TIME)))
+ ->execute();
+
+ return $this->findEntities($qb);
+ }
+
+ public function deleteInactive($documentId) {
+ /* @var $qb IQueryBuilder */
+ $qb = $this->db->getQueryBuilder();
+ return $qb->delete($this->getTableName())
+ ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId)))
+ ->andWhere($qb->expr()->lt('last_contact', $qb->createNamedParameter(time()-SessionService::SESSION_VALID_TIME)))
+ ->execute();
+ }
+
+}
diff --git a/lib/Db/Step.php b/lib/Db/Step.php
new file mode 100644
index 000000000..db8d6a40c
--- /dev/null
+++ b/lib/Db/Step.php
@@ -0,0 +1,52 @@
+<?php
+/**
+ * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Text\Db;
+
+
+use OCP\AppFramework\Db\Entity;
+
+class Step extends Entity implements \JsonSerializable {
+
+ public $id;
+ protected $data;
+ protected $version;
+ protected $sessionId;
+ protected $documentId;
+
+ public function __construct() {
+ $this->addType('id', 'integer');
+ $this->addType('version', 'integer');
+ $this->addType('documentId', 'integer');
+ $this->addType('sessionId', 'integer');
+ }
+
+ public function jsonSerialize() {
+ return [
+ 'id' => $this->id,
+ 'data' => json_decode($this->data),
+ 'version' => $this->version,
+ 'sessionId' => $this->sessionId
+ ];
+ }
+}
diff --git a/lib/Db/StepMapper.php b/lib/Db/StepMapper.php
new file mode 100644
index 000000000..d8b370592
--- /dev/null
+++ b/lib/Db/StepMapper.php
@@ -0,0 +1,48 @@
+<?php
+/**
+ * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Text\Db;
+
+
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+
+class StepMapper extends QBMapper {
+
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'text_steps', Step::class);
+ }
+
+ public function find($documentId, $fromVersion) {
+ /* @var $qb IQueryBuilder */
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId)))
+ ->andWhere($qb->expr()->gt('version', $qb->createNamedParameter($fromVersion)))
+ ->execute();
+
+ return $this->findEntities($qb);
+ }
+}
diff --git a/lib/EventSource.php b/lib/EventSource.php
new file mode 100644
index 000000000..4fd531803
--- /dev/null
+++ b/lib/EventSource.php
@@ -0,0 +1,102 @@
+<?php
+/**
+ * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+
+namespace OCA\Text\Controller;
+
+use OCP\IEventSource;
+
+
+class EventSource implements IEventSource {
+
+ /**
+ * @var bool
+ */
+ private $started = false;
+
+ protected function init(): void {
+ if ($this->started) {
+ return;
+ }
+ $this->started = true;
+
+ // prevent php output buffering, caching and nginx buffering
+ while (ob_get_level()) {
+ ob_end_clean();
+ }
+ header('Cache-Control: no-cache');
+ header('X-Accel-Buffering: no');
+ header("Content-Type: text/event-stream");
+ flush();
+ }
+
+ /**
+ * Sends a message to the client
+ *
+ * If only one parameter is given, a typeless message will be sent with that parameter as data
+ *
+ * @param string $type
+ * @param mixed $data
+ *
+ * @throws \BadMethodCallException
+ */
+ public function send($type, $data = null) {
+ $this->validateMessage($type, $data);
+ $this->init();
+ if (is_null($data)) {
+ $data = $type;
+ $type = null;
+ }
+
+ if (!empty($type)) {
+ echo 'event: ' . $type . PHP_EOL;
+ }
+ echo 'data: ' . json_encode($data) . PHP_EOL;
+
+ echo PHP_EOL;
+ flush();
+ }
+
+ /**
+ * Closes the connection of the event source
+ *
+ * It's best to let the client close the stream
+ */
+ public function close() {
+ $this->send(
+ '__internal__', 'close'
+ );
+ }
+
+ /**
+ * Makes sure we have a message we can use
+ *
+ * @param string $type
+ * @param mixed $data
+ */
+ private function validateMessage($type, $data) {
+ if ($data && !preg_match('/^[A-Za-z0-9_]+$/', $type)) {
+ throw new \BadMethodCallException('Type needs to be alphanumeric (' . $type . ')');
+ }
+ }
+}
diff --git a/lib/Service/DocumentService.php b/lib/Service/DocumentService.php
new file mode 100644
index 000000000..77c67557a
--- /dev/null
+++ b/lib/Service/DocumentService.php
@@ -0,0 +1,167 @@
+<?php
+/**
+ * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Text\Service;
+
+use OC\Files\Node\File;
+use OCA\Text\Db\Document;
+use OCA\Text\Db\DocumentMapper;
+use OCA\Text\Db\SessionMapper;
+use OCA\Text\Db\Step;
+use OCA\Text\Db\StepMapper;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\Files\IAppData;
+use OCP\Files\InvalidPathException;
+use OCP\Files\IRootFolder;
+use OCP\Files\NotFoundException;
+use OCP\ICacheFactory;
+
+class DocumentService {
+
+
+ private $sessionMapper;
+ private $userId;
+ private $documentMapper;
+
+ public function __construct(SessionMapper $sessionMapper, DocumentMapper $documentMapper, StepMapper $stepMapper, IAppData $appData, $userId, IRootFolder $rootFolder, ICacheFactory $cacheFactory) {
+ $this->sessionMapper = $sessionMapper;
+ $this->documentMapper = $documentMapper;
+ $this->stepMapper = $stepMapper;
+ $this->userId = $userId;
+ $this->appData = $appData;
+ $this->rootFolder = $rootFolder;
+ $this->cache = $cacheFactory->createDistributed('text');
+
+ try {
+ $this->appData->getFolder('documents');
+ } catch (NotFoundException $e) {
+ $this->appData->newFolder('documents');
+ }
+ }
+
+ /**
+ * @param $path
+ * @return \OCP\AppFramework\Db\Entity
+ * @throws NotFoundException
+ * @throws \OCP\Files\InvalidPathException
+ * @throws \OCP\Files\NotPermittedException
+ */
+ public function createDocumentByPath($path) {
+ /** @var File $file */
+ $file = $this->rootFolder->getUserFolder($this->userId)->get($path);
+ return $this->createDocument($file);
+ }
+
+ /**
+ * @param $fileId
+ * @return \OCP\AppFramework\Db\Entity
+ * @throws NotFoundException
+ * @throws \OCP\Files\InvalidPathException
+ * @throws \OCP\Files\NotPermittedException
+ */
+ public function getDocumentById($fileId) {
+ /** @var File $file */
+ $file = $this->rootFolder->getUserFolder($this->userId)->getById($fileId);
+ return $this->createDocument($file);
+ }
+
+ /**
+ * @param File $file
+ * @return \OCP\AppFramework\Db\Entity
+ * @throws NotFoundException
+ * @throws \OCP\Files\InvalidPathException
+ * @throws \OCP\Files\NotPermittedException
+ */
+ protected function createDocument(File $file) {
+ /* remove this after debugging */
+ try {
+ $documentBaseFile = $this->appData->getFolder('documents')->getFile($file->getFileInfo()->getId());
+ } catch (NotFoundException $e) {
+ $documentBaseFile = $this->appData->getFolder('documents')->newFile($file->getFileInfo()->getId());
+ }
+ $documentBaseFile->putContent($file->fopen('r'));
+ /** endremove */
+
+ try {
+ $document = $this->documentMapper->find($file->getFileInfo()->getId());
+ return $document;
+ } catch (DoesNotExistException $e) {
+ } catch (InvalidPathException $e) {
+ } catch (NotFoundException $e) {
+ }
+ // TODO: lock file
+ // TODO: unlock after saving
+ try {
+ $documentBaseFile = $this->appData->getFolder('documents')->getFile($file->getFileInfo()->getId());
+ } catch (NotFoundException $e) {
+ $documentBaseFile = $this->appData->getFolder('documents')->newFile($file->getFileInfo()->getId());
+ }
+ $documentBaseFile->putContent($file->fopen('r'));
+
+ $document = new Document();
+ $document->setId($file->getFileInfo()->getId());
+ $document->setCurrentVersion(0);
+ $document->setLastSavedVersion(0);
+ $document = $this->documentMapper->insert($document);
+ $this->cache->set('document-version-'.$document->getId(), 0);
+ return $document;
+ }
+
+ public function getBaseFile($document) {
+ return $this->appData->getFolder('documents')->getFile($document);
+ }
+
+ /**
+ * @param Document $document
+ * @param $steps
+ * @param $version
+ * @return array
+ * @throws \Exception
+ */
+ public function addStep($documentId, $sessionId, $steps, $version) {
+ // TODO lock for other step adding
+ // TODO check cache
+ $document = $this->documentMapper->find($documentId);
+ if ($version !== $document->getCurrentVersion()) {
+ throw new \Exception('Version does not match');
+ }
+ $step = new Step();
+ $step->setData(\json_encode($steps));
+ $step->setSessionId($sessionId);
+ $step->setDocumentId($documentId);
+ $step->setVersion($version+1);
+ $this->stepMapper->insert($step);
+ $newVersion = $document->getCurrentVersion() + count($steps);
+ $document->setCurrentVersion($newVersion);
+ $this->documentMapper->update($document);
+ $this->cache->set('document-version-'.$document->getId(), $newVersion);
+ // TODO write version to cache for quicker checking
+ // TODO write steps to cache for quicker reading
+ return $steps;
+ }
+
+ public function getSteps($documentId, $lastVersion) {
+ return $this->stepMapper->find($documentId, $lastVersion);
+ }
+
+}
diff --git a/lib/Service/SessionService.php b/lib/Service/SessionService.php
new file mode 100644
index 000000000..748baf2f5
--- /dev/null
+++ b/lib/Service/SessionService.php
@@ -0,0 +1,107 @@
+<?php
+/**
+ * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Text\Service;
+
+
+use OC\Avatar\Avatar;
+use OCA\Text\Db\Session;
+use OCA\Text\Db\SessionMapper;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\IAvatar;
+use OCP\IAvatarManager;
+use OCP\Security\ISecureRandom;
+
+class SessionService {
+
+ public const SESSION_VALID_TIME = 60*5;
+
+ private $sessionMapper;
+ private $secureRandom;
+ private $timeFactory;
+ private $userId;
+
+ public function __construct(SessionMapper $sessionMapper, ISecureRandom $secureRandom, ITimeFactory $timeFactory, $userId) {
+ $this->sessionMapper = $sessionMapper;
+ $this->secureRandom = $secureRandom;
+ $this->timeFactory = $timeFactory;
+ $this->userId = $userId;
+ }
+
+ public function initSession($documentId): Session {
+ $session = new Session();
+ $session->setDocumentId($documentId);
+ $session->setUserId($this->userId);
+ $session->setToken($this->secureRandom->generate(64));
+ /** @var IAvatarManager $avatarGenerator */
+ $avatarGenerator = \OC::$server->query(IAvatarManager::class);
+ $color = $avatarGenerator->getGuestAvatar($this->userId)->avatarBackgroundColor($this->userId);
+ $color = sprintf("#%02x%02x%02x", $color->r, $color->g, $color->b);
+ $session->setColor($color);
+ $session->setGuestName(null);
+ $session->setLastContact($this->timeFactory->getTime());
+ return $this->sessionMapper->insert($session);
+ }
+
+ /**
+ * @throws DoesNotExistException
+ */
+ public function closeSession($documentId, $sessionId, $token): void {
+ $session = $this->sessionMapper->find($documentId, $sessionId, $token);
+ // TODO: check for unpersisited changes from session?
+ $this->sessionMapper->delete($session);
+ }
+
+ public function getActiveSessions($documentId): array {
+ $sessions = $this->sessionMapper->findAllActive($documentId);
+ return array_map(function(Session $session) {
+ $result = $session->jsonSerialize();
+ $userManager = \OC::$server->getUserManager();
+ $user = $userManager->get($session->getUserId());
+ if ($user) {
+ $result['displayName'] = $user->getDisplayName();
+ }
+ return $result;
+ }, $sessions);
+ }
+
+ public function removeInactiveSessions($documentId) {
+ return $this->sessionMapper->deleteInactive($documentId);
+ }
+ public function isValidSession($documentId, $sessionId, $token) {
+ try {
+ $session = $this->sessionMapper->find($documentId, $sessionId, $token);
+ } catch (DoesNotExistException $e) {
+ return false;
+ }
+ $session->setLastContact($this->timeFactory->getTime());
+ $this->sessionMapper->update($session);
+ return true;
+ }
+
+ public function cleanupSession() {
+ // find expired sessions
+ // remove them
+ }
+}
diff --git a/src/collab.js b/src/collab.js
new file mode 100644
index 000000000..dd982d1a1
--- /dev/null
+++ b/src/collab.js
@@ -0,0 +1,192 @@
+/*
+ * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import axios from 'nextcloud-axios'
+import {EditorState} from "prosemirror-state"
+import {EditorView} from "prosemirror-view"
+import {exampleSetup} from "prosemirror-example-setup"
+import {schema, defaultMarkdownParser, defaultMarkdownSerializer} from "prosemirror-markdown"
+import {collab, receiveTransaction, sendableSteps, getVersion} from 'prosemirror-collab';
+import {Step} from 'prosemirror-transform';
+
+const FETCH_INTERVAL = 100;
+const MIN_PUSH_RETRY = 200;
+const MAX_PUSH_RETRY = 10000;
+const WARNING_PUSH_RETRY = 2000;
+
+// TODO to fetch changes more frequently while typing
+// we either need to have a state machine similar to the prosemirror example to fetch
+// changes inbetween push tries or return updates with the push error
+
+class EditorSync {
+ constructor(doc, data) {
+ this.view = null
+ this.doc = doc
+ this.session = data.session
+ this.document = data.document
+ this.steps = []
+ this.stepClientIDs = []
+ this.lock = false
+ this.retryTime = MIN_PUSH_RETRY
+
+ // example for polling
+ // TODO: dynamic fetch interval
+ // reduce fetch interval if no other user joined or no change since x sec
+ setInterval(() => this.fetchSteps(), FETCH_INTERVAL)
+
+ }
+
+ fetchSteps() {
+ if (this.lock) {
+ return;
+ }
+ this.lock = true;
+ const authority = this;
+ axios.get(OC.generateUrl('/apps/text/session/sync'), {params: {
+ documentId: this.document.id,
+ sessionId: this.session.id,
+ token: this.session.token,
+ version: authority.steps.length
+ }}).then((response) => {
+ if (response.data.steps.length === 0) {
+ this.lock = false;
+ return;
+ }
+ for (let i = 0; i < response.data.steps.length; i++) {
+ let steps = response.data.steps[i].data.map(j => Step.fromJSON(schema, j));
+ steps.forEach(step => {
+ authority.steps.push(step)
+ authority.stepClientIDs.push(response.data.steps[i].sessionId)
+ })
+ }
+ let newData = authority.stepsSince(getVersion(authority.view.state))
+ authority.view.dispatch(
+ receiveTransaction(authority.view.state, newData.steps, newData.clientIDs)
+ )
+ console.log(getVersion(authority.view.state))
+ this.lock = false;
+ }).catch((e) => {
+ this.lock = false;
+ })
+ }
+
+ stepsSince(version) {
+ return {
+ steps: this.steps.slice(version),
+ clientIDs: this.stepClientIDs.slice(version)
+ }
+ }
+
+ carefulRetry(callback) {
+ let newRetry = this.retryTime ? Math.min(this.retryTime * 2, MAX_PUSH_RETRY) : MIN_PUSH_RETRY
+ if (newRetry > WARNING_PUSH_RETRY && this.retryTime < WARNING_PUSH_RETRY) {
+ OC.Notification.showTemporary('Changes could not be sent yet');
+ }
+ this.retryTime = newRetry
+ setTimeout(callback, this.retryTime)
+ }
+
+ carefulRetryReset() {
+ this.retryTime = MIN_PUSH_RETRY
+ }
+
+ sendSteps() {
+ let sendable = sendableSteps(this.view.state)
+ if (!sendable) {
+ return;
+ }
+
+ if (this.lock) {
+ setTimeout(() => {
+ this.sendSteps()
+ }, 500)
+ return;
+ }
+ this.lock = true;
+ const authority = this;
+ let version = sendable.version;
+ let steps = sendable.steps;
+ axios.post(OC.generateUrl('/apps/text/session/push'), {
+ documentId: this.document.id,
+ sessionId: this.session.id,
+ token: this.session.token,
+ steps: steps.map(s => s.toJSON()) || [],
+ version: getVersion(authority.view.state)
+ }).then((response) => {
+ // sucessfully applied steps on the server
+ let newSteps = []
+ let newClientIDs = []
+ steps.forEach(step => {
+ authority.steps.push(step)
+ authority.stepClientIDs.push(this.session.id)
+ })
+ let newData = authority.stepsSince(getVersion(authority.view.state))
+ authority.view.dispatch(
+ receiveTransaction(authority.view.state, newData.steps, newData.clientIDs)
+ )
+ this.carefulRetryReset()
+ this.lock = false
+ }).catch((e) => {
+ console.log('failed to apply steps due to collission, retrying');
+ this.lock = false
+ // TODO: remove if we have state machine
+ this.fetchSteps()
+
+ this.carefulRetry(() => {
+ this.sendSteps()
+ })
+ })
+ }
+
+}
+
+const initEditor = (unusedauthority, tmpEditorId, data, fileContent) => {
+ const authority = new EditorSync(defaultMarkdownParser.parse(fileContent), data)
+
+ const view = new EditorView(document.querySelector("#editor" + tmpEditorId), {
+ state: EditorState.create({
+ doc: authority.doc,
+ plugins: [
+ ...exampleSetup({schema}),
+ collab({
+ version: authority.steps.length,
+ clientID: data.session.id
+ })
+ ]
+ }),
+ get content() {
+ return defaultMarkdownSerializer.serialize(this.view.state.doc)
+ },
+ focus() { this.view.focus() },
+ destroy() { this.view.destroy() },
+ dispatchTransaction: transaction => {
+ const state = view.state.apply(transaction);
+ view.updateState(state);
+ // TODO: might be good to debounce this a bit
+ authority.sendSteps()
+ }
+ })
+ authority.view = view;
+ authority.fetchSteps()
+}
+
+export { initEditor }
diff --git a/src/components/Editor.vue b/src/components/Editor.vue
index b7fa3928f..41f1e2958 100644
--- a/src/components/Editor.vue
+++ b/src/components/Editor.vue
@@ -21,15 +21,93 @@
-->
<template>
-
+ <div id="editor-container" v-if="session">
+ <div id="editor-session-list">
+ <avatar :user="session.userId" style="border: 2px solid #000;" :style="{'border-color': session.color}"></avatar>
+ <avatar :user="name"></avatar>
+ <input v-model="name" />
+ </div>
+ <div id="editor2"></div>
+ </div>
</template>
<script>
+ import axios from 'nextcloud-axios'
+ import { initEditor } from './../collab';
+ import { Avatar } from 'nextcloud-vue';
+
export default {
- name: 'Editor'
+ name: 'Editor',
+ components: {
+ Avatar
+ },
+ beforeMount () {
+ this.initSession()
+ },
+ props: {
+ path: {
+ default: '/example.md'
+ },
+ },
+ data() {
+ return {
+ document: null,
+ content: null,
+ session: null,
+ name: 'Guest'
+ }
+ },
+ methods: {
+ initSession() {
+ axios.get(OC.generateUrl('/apps/text/session/create'), {
+ // TODO: viewer should provide the file id so we can use it in all places (also for public pages)
+ params: {file: this.path}
+ }).then((response) => {
+ this.document = response.data.document;
+ this.session = response.data.session;
+ axios.get(OC.generateUrl('/apps/text/session/fetch',),
+ {
+ params: {
+ documentId: this.document.id,
+ sessionId: this.session.id,
+ token: this.session.token
+ }
+ }
+ ).then((fileContent) => {
+ initEditor(null, 2, response.data, fileContent.data);
+ this.$emit('loaded')
+ // TODO: resize viewer
+ });
+ });
+
+ },
+ }
}
</script>
-<style scoped>
+<style scoped lang="scss">
+
+ #editor-container {
+ display: block;
+ max-width: 900px;
+ width: 100vw;
+ margin: 0 auto;
+ position: relative;
+ }
+ #editor-session-list {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 100;
+ padding: 3px;
+
+ input, div {
+ vertical-align: middle;
+ }
+ }
+
+</style>
+<style lang="scss">
+ @import './../../css/style';
</style>
diff --git a/src/files.js b/src/files.js
new file mode 100644
index 000000000..d64cd4076
--- /dev/null
+++ b/src/files.js
@@ -0,0 +1,60 @@
+/*
+ * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+const newFileMenuPlugin = {
+
+ attach: function (menu) {
+ var fileList = menu.fileList;
+
+ // only attach to main file list, public view is not supported yet
+ if (fileList.id !== 'files') {
+ return;
+ }
+
+ // register the new menu entry
+ menu.addMenuEntry({
+ id: 'file',
+ displayName: t('files_texteditor', 'New text document'),
+ templateName: t('files_texteditor', 'New text document.md'),
+ iconClass: 'icon-filetype-text',
+ fileType: 'file',
+ actionHandler: function (name) {
+ var dir = fileList.getCurrentDirectory();
+ // first create the file
+ fileList.createFile(name).then(function () {
+ // TODO: open editor
+ });
+ }
+ });
+ }
+};
+OC.Plugins.register('OCA.Files.NewFileMenu', newFileMenuPlugin);
+
+import Editor from './components/Editor'
+$(document).ready(function() {
+OCA.Viewer.registerHandler({
+ id: 'text',
+ mimes: ['text/markdown'],
+ component: Editor,
+ group: null
+});
+});
diff --git a/src/main.js b/src/main.js
index 96b02a75f..4857083a8 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,69 +1,26 @@
-import axios from 'nextcloud-axios'
-
-import {EditorState} from "prosemirror-state"
-import {EditorView} from "prosemirror-view"
-import {Schema, DOMParser} from "prosemirror-model"
-import {addListNodes} from "prosemirror-schema-list"
-import {exampleSetup} from "prosemirror-example-setup"
-
-import {schema, defaultMarkdownParser, defaultMarkdownSerializer} from "prosemirror-markdown"
-import {Step} from 'prosemirror-transform'
-import {collab, receiveTransaction, sendableSteps, getVersion} from "prosemirror-collab"
-
-
-import Authority from './authority'
+import Vue from 'vue'
-/* This value allows to simulate longer RTT
- * DEBUG_TIMEOUT 100 will simulate a RTT of 100ms for each request
- * so 200ms on total for sending change and fetching change
- */
-const DEBUG_TIMEOUT = 100;
-const initEditor = (authority, tmpEditorId) => {
- const view = new EditorView(document.querySelector("#editor" + tmpEditorId), {
- state: EditorState.create({
- doc: authority.doc,
- plugins: [
- ...exampleSetup({schema}), collab({version: authority.steps.length})]
- }),
- get content() {
- return defaultMarkdownSerializer.serialize(this.view.state.doc)
- },
- focus() { this.view.focus() },
- destroy() { this.view.destroy() },
- dispatchTransaction: transaction => {
- const state = view.state.apply(transaction);
- view.updateState(state);
- let sendable = sendableSteps(state)
- if (sendable) {
- setTimeout(() => authority.receiveSteps(sendable.version, sendable.steps, sendable.clientID), DEBUG_TIMEOUT)
- }
- }
- })
+__webpack_nonce__ = btoa(OC.requestToken); // eslint-disable-line no-native-reassign
- authority.onNewSteps.push(function() {
- // https://github.com/scrumpy/tiptap/issues/74
- let newData = authority.stepsSince(getVersion(view.state))
- const steps = JSON.parse(JSON.stringify(newData.steps));
- const cleanSteps = steps[0].map(step => Step.fromJSON(schema, step));
- setTimeout(() => {
- view.dispatch(receiveTransaction(view.state, cleanSteps, newData.clientIDs))
- }, DEBUG_TIMEOUT)
- })
-
-
- window.view[tmpEditorId] = view;
-}
-
-window.view = {};
/*
- * Loading initial document state from server
- */
-axios.get('http://localhost:8000/remote.php/webdav/example.md').then((response) => {
- var contentDom = document.querySelector("#editor-content");
- contentDom.innerHTML = response.data;
- const authority = new Authority(defaultMarkdownParser.parse(document.querySelector("#editor-content").textContent))
- initEditor(authority, 1);
- initEditor(authority, 2);
- initEditor(authority, 3);
-})
+import { initEditor } from './collab';
+import axios from 'nextcloud-axios'
+axios.get(OC.generateUrl('/apps/text/session/create'), {params: {file: '/example.md'}})
+ .then((response) => {
+ console.log(response.data);
+ axios.get(OC.generateUrl('/apps/text/session/fetch',), {params:
+ {documentId: response.data.document.id, sessionId: response.data.session.id, token: response.data.session.token}
+ }).then((fileContent) => {
+ let contentDom = document.querySelector("#editor-content");
+ contentDom.innerHTML = fileContent.data;
+ initEditor(null, 1, response.data, fileContent.data);
+ });
+ });
+*/
+Vue.prototype.t = t
+Vue.prototype.OCA = OCA
+import Editor from './components/Editor'
+new Vue({
+ render: h => h(Editor),
+}).$mount('#maineditor')
diff --git a/templates/main.php b/templates/main.php
index 0aba4731d..77a116127 100644
--- a/templates/main.php
+++ b/templates/main.php
@@ -3,27 +3,7 @@ script('text', 'type');
style('text', 'style');
?>
<div id="app-content">
- <div id="editor1"></div>
- <div id="editor2"></div>
- <div id="editor3"></div>
+ <div id="maineditor"></div>
- <div style="display: none" id="editor-content">
- <h3>Hello ProseMirror</h3>
-
- <p>This is editable text. You can focus it and start typing.</p>
-
- <p>To apply styling, you can select a piece of text and manipulate
- its styling from the menu. The basic schema
- supports <em>emphasis</em>, <strong>strong
- text</strong>, <a href="http://marijnhaverbeke.nl/blog">links</a>, <code>code
- font</code>, and <img src="/img/smiley.png"> images.</p>
-
- <p>Block-level structure can be manipulated with key bindings (try
- ctrl-shift-2 to create a level 2 heading, or enter in an empty
- textblock to exit the parent block), or through the menu.</p>
-
- <p>Try using the “list” item in the menu to wrap this paragraph in
- a numbered list.</p>
- </div>
</div>
diff --git a/webpack.common.js b/webpack.common.js
index 93b1cd295..4b1a9dff1 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -5,6 +5,7 @@ const VueLoaderPlugin = require('vue-loader/lib/plugin');
module.exports = {
entry: {
type: path.join(__dirname, 'src', 'main.js'),
+ files: path.join(__dirname, 'src', 'files.js'),
},
output: {
path: path.resolve(__dirname, './js'),