diff options
author | Julius Härtl <jus@bitgrid.net> | 2019-04-13 21:28:28 +0300 |
---|---|---|
committer | Julius Härtl <jus@bitgrid.net> | 2019-04-13 21:28:28 +0300 |
commit | 1bdb0ff3eaa0f8a874bbf306ca5fe8a45f8e6ff4 (patch) | |
tree | 69363d95e38dc7b02a55c4e2cf3a9149f75cbb46 | |
parent | 66adbbbe655ef62f1545a39c2bf31630455af8da (diff) |
Add first working version of collaborative editing
Signed-off-by: Julius Härtl <jus@bitgrid.net>
-rw-r--r-- | .drone.yml | 143 | ||||
-rw-r--r-- | appinfo/app.php | 12 | ||||
-rw-r--r-- | appinfo/database.xml | 148 | ||||
-rw-r--r-- | appinfo/info.xml | 4 | ||||
-rw-r--r-- | appinfo/routes.php | 13 | ||||
-rw-r--r-- | css/style.scss | 20 | ||||
-rw-r--r-- | img/app.png | bin | 0 -> 231 bytes | |||
-rw-r--r-- | img/app.svg | 4 | ||||
-rw-r--r-- | lib/AppInfo/Application.php | 6 | ||||
-rw-r--r-- | lib/Controller/PublicSessionController.php | 51 | ||||
-rw-r--r-- | lib/Controller/SessionController.php | 79 | ||||
-rw-r--r-- | lib/Db/Document.php | 43 | ||||
-rw-r--r-- | lib/Db/DocumentMapper.php | 53 | ||||
-rw-r--r-- | lib/Db/Session.php (renamed from src/authority.js) | 56 | ||||
-rw-r--r-- | lib/Db/SessionMapper.php | 85 | ||||
-rw-r--r-- | lib/Db/Step.php | 52 | ||||
-rw-r--r-- | lib/Db/StepMapper.php | 48 | ||||
-rw-r--r-- | lib/EventSource.php | 102 | ||||
-rw-r--r-- | lib/Service/DocumentService.php | 167 | ||||
-rw-r--r-- | lib/Service/SessionService.php | 107 | ||||
-rw-r--r-- | src/collab.js | 192 | ||||
-rw-r--r-- | src/components/Editor.vue | 84 | ||||
-rw-r--r-- | src/files.js | 60 | ||||
-rw-r--r-- | src/main.js | 87 | ||||
-rw-r--r-- | templates/main.php | 22 | ||||
-rw-r--r-- | webpack.common.js | 1 |
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 Binary files differnew file mode 100644 index 000000000..3a68deb8a --- /dev/null +++ b/img/app.png 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'), |