diff --git a/.gitignore b/.gitignore index 88c80b4a1..b9c9c1acc 100755 --- a/.gitignore +++ b/.gitignore @@ -13,14 +13,20 @@ schema-dump-default.lock ### Saito ### /_attic -/config/.env -/webroot/useruploads/* /build/ +/config/.env +/docs/local /dist/ +/webroot/useruploads/* ### Assets ### plugins/Bota/webroot/css/* !plugins/Bota/webroot/css/src/ + +plugins/Bota/webroot/fonts/*.woff* + +plugins/SpectrumColorpicker/webroot/* + webroot/css/stylesheets/* !webroot/css/stylesheets/src/ webroot/js/* diff --git a/.travis.yml b/.travis.yml index 3ff3e3a39..7d1c975b5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,9 @@ env: - DB=mysql DATABASE_TEST_URL='mysql://root:password@127.0.0.1/cakephp_test' global: - DEFAULT=1 + - PHP=1 - PHPCS=0 + - JS=0 matrix: fast_finish: true @@ -20,6 +22,17 @@ matrix: - php: 7.2 env: PHPCS=1 DEFAULT=0 - php: 7.3 + - language: node_js + env: JS=1 DEFAULT=0 PHP=0 + node_js: + - node + addons: + apt: + sources: + - google-chrome + packages: + - google-chrome-stable + script: yarn travis dist: trusty @@ -34,22 +47,22 @@ cache: before_install: - phpenv config-rm xdebug.ini - - if [ $DB = 'mysql' ]; then mysql -u root -e 'CREATE DATABASE cakephp_test;'; fi - - if [ $DB = 'mysql' ]; then mysql -u root -e "USE mysql; UPDATE user SET password=PASSWORD('password') WHERE user='root'; FLUSH PRIVILEGES;"; fi + - if [ $PHP = 1 ] && [ $DB = 'mysql' ]; then mysql -u root -e 'CREATE DATABASE cakephp_test;'; fi + - if [ $PHP = 1 ] && [ $DB = 'mysql' ]; then mysql -u root -e "USE mysql; UPDATE user SET password=PASSWORD('password') WHERE user='root'; FLUSH PRIVILEGES;"; fi - - pecl channel-update pecl.php.net + - if [$PHP = 1 ]; then pecl channel-update pecl.php.net; fi - | - if [[ ${TRAVIS_PHP_VERSION} != "5.6" ]]; then + if [ $PHP = 1 ] && [[ ${TRAVIS_PHP_VERSION} != "5.6" ]]; then echo 'extension = memcached.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi - - echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini - - echo 'apc.enable_cli = 1' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini + - if [$PHP = 1 ]; then echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi + - if [$PHP = 1 ]; then echo 'apc.enable_cli = 1' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi - if [[ ${TRAVIS_PHP_VERSION:0:1} == "7" ]] ; then echo "yes" | pecl install channel://pecl.php.net/apcu-5.1.5 || true; fi - sudo locale-gen da_DK before_script: - - composer install --prefer-source --no-interaction; + - if [ $PHP = 1 ]; then composer install --prefer-source --no-interaction; fi - if [ $PHPCS = 1 ]; then vendor/bin/phpcs --config-set installed_paths vendor/cakephp/cakephp-codesniffer; fi script: diff --git a/Gruntfile.js b/Gruntfile.js index 0e8f7ba03..ebfb86a58 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -34,6 +34,35 @@ module.exports = function (grunt) { src: '*', dest: './webroot/css/stylesheets/fonts/' }, + /// Assets for plugins/SprectrumColorpicker + { + src: './node_modules/spectrum-colorpicker/spectrum.js', + dest: './plugins/SpectrumColorpicker/webroot/js/spectrum.js', + }, + { + src: './node_modules/spectrum-colorpicker/spectrum.css', + dest: './plugins/SpectrumColorpicker/webroot/css/spectrum.css', + }, + /// Assets Cabin font + { + expand: true, + flatten: true, + src: './node_modules/typeface-cabin/files/cabin-latin-[4|7]00.woff*', + dest: './plugins/Bota/webroot/fonts/', + }, + { + expand: true, + flatten: true, + src: './node_modules/typeface-cabin/files/cabin-latin-[4|7]00italic.woff*', + dest: './plugins/Bota/webroot/fonts/', + }, + /// Assets Fenix font + { + expand: true, + flatten: true, + src: './node_modules/typeface-fenix/files/fenix-latin-400.woff*', + dest: './plugins/Bota/webroot/fonts/', + }, ] }, }, @@ -86,7 +115,7 @@ module.exports = function (grunt) { } }, }, - sass: { + 'dart-sass': { options: { sourceComments: true, sourceMap: false, @@ -157,7 +186,7 @@ module.exports = function (grunt) { grunt.loadNpmTasks('grunt-contrib-clean'); grunt.loadNpmTasks('grunt-contrib-watch'); grunt.loadNpmTasks('grunt-shell'); - grunt.loadNpmTasks('grunt-sass'); + grunt.loadNpmTasks('grunt-dart-sass'); grunt.loadNpmTasks('grunt-postcss'); // dev-setup @@ -171,8 +200,8 @@ module.exports = function (grunt) { // cleanup 'clean:release', // CSS - 'sass:static', - 'sass:theme', + 'dart-sass:static', + 'dart-sass:theme', 'postcss:release', // webpack 'shell:webpack', diff --git a/README.md b/README.md index 0482d070c..9ffb8682b 100755 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ ## What is it? -Saito is a web forum. It is different from the majority of other solutions as it puts the emphasis on presenting threads and conversations in a classic tree style view. It is optimized to display hundreds of individual posts on a single page request while running on a modest shared-hoster. +Saito is a web-forum with [conversation threading][ConversationThreading]. It is different from the majority of other forums as it puts the emphasis on performance and presenting conversations in a classic tree-style threaded view. -[Test it here][SaitoSupport] (log-in: test/test). +A lot of optimization went into serving long existing, small- to mid-sized communities with moderate traffic but hundreds of thousands of existing postings. It is able to displays hundreds of individual postings on a single page while running on a inexpensive, shared hosting account. + +[Test it here][SaitoSupport] (login: test/test). ## Status @@ -13,37 +15,41 @@ Saito is a web forum. It is different from the majority of other solutions as it [cake]: http://cakephp.org/ [marionette]: https://marionettejs.com/ -[SaitoHomepage]: http://saito.siezi.com/ -[SaitoSupport]: http://saito-forum.de/ +[SaitoHomepage]: https://saito.siezi.com/ +[SaitoSupport]: https://saito-forum.de/ +[ConversationThreading]: https://en.wikipedia.org/wiki/Conversation_threading ## Requirements -- PHP 7.2 +- PHP 7.2+ - Database (MySQL/MariaDB tested, [others untested](https://book.cakephp.org/3.0/en/orm/database-basics.html#supported-databases)). ## Get Started -A full prepackaged zip if is available on the [release page](https://github.com/Schlaefer/Saito/releases). +A ready-to-use ZIP containing all necessary files is available on the [release page](https://github.com/Schlaefer/Saito/releases). Unzip it, upload it to your server, open it in a browser, and follow the instructions on the screen. ## Development -### Install Files +### Set-Up Environment -Checkout files from git. +You need a more or less generic environement providing: -Install the PHP packages (the backend is mainly build on [CakePHP][cake]): +- PHP with `composer` for the server-backend (mainly build on [CakePHP][cake]) +- node with `yarn` and `grunt-cli` for the browser-frontend (mainly build on [Marionette][marionette]) +- a database -```shell -composer install -``` +There's a docker file for *development* in `dev/docker/…` -Install Javascript packages (the frontend is mainly build on [Marionette][marionette]): +### Install Files + +Checkout the files from git-repository and install the dependencies: ```shell -yarn +composer install; +yarn install; ``` -Move files into places: +Move dependency-assets into the right places: ```shell grunt dev-setup @@ -55,18 +61,32 @@ Run all test cases: composer test-all ``` -See `Gruntfile`, `packages.json` and `composer.json` for additional scripts to run. +See the `Gruntfile`, `packages.json` and `composer.json` for additional devleopment-commands. ### Create Production Files -Create minimized assets with: +To generate all the minimized assets for production: ```shell grunt release ``` -Create a release-zip: +### Create A Release Zip + +To generate a zip-package as found on the release page for distribution: ```shell vendor/bin/phing ``` + +## FAQ + +### How does it compare to [mylittleforum] + +Actually this forum was written to replace a mylittleforum installation with a more modern approach. Mylittleforum is a noteworthy starting place if you want a threaded web-forum. There aren't that many out there. Mylittleforum exists for many years now and offers great features. + +*Disclaimer: Subjective opinion ahead…* + +But there are a shortcommings, mainly: performance and maintainability. If a mylittleforum installation reaches a few hundred thousand postings it is going to slow down. Also it was written when PHP was a much worse language: there are no test cases, which makes it more fragile to changes. + +[mylittleforum]: https://mylittleforum.net/ diff --git a/build.xml b/build.xml index 9d276be95..d4990abe4 100644 --- a/build.xml +++ b/build.xml @@ -20,6 +20,7 @@ + diff --git a/composer.json b/composer.json index 4d3f5dd2a..4381b7c82 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ }, "require": { "php": ">=7.2", - "cakephp/cakephp": "3.7.*", + "cakephp/cakephp": "3.8.*", "cakephp/migrations": "@stable", "cakephp/plugin-installer": "*", "josegonzalez/dotenv": "*", diff --git a/composer.lock b/composer.lock index e88272cb3..5591bc916 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "394bf5f5f93827d1698ea031ce3f80d4", + "content-hash": "d65dfdbacdba8ea525c34199b6f51d16", "packages": [ { "name": "admad/cakephp-jwt-auth", @@ -152,16 +152,16 @@ }, { "name": "cakephp/cakephp", - "version": "3.7.8", + "version": "3.8.2", "source": { "type": "git", - "url": "https://github.com/cakephp/cakephp.git", - "reference": "035458f3f4832b25acb685d6d2f47d4ca518cb8b" + "url": "git@github.com:cakephp/cakephp.git", + "reference": "d57a5193312a21e013a1f21191826933398c026f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/cakephp/zipball/035458f3f4832b25acb685d6d2f47d4ca518cb8b", - "reference": "035458f3f4832b25acb685d6d2f47d4ca518cb8b", + "url": "https://api.github.com/repos/cakephp/cakephp/zipball/d57a5193312a21e013a1f21191826933398c026f", + "reference": "d57a5193312a21e013a1f21191826933398c026f", "shasum": "" }, "require": { @@ -237,7 +237,7 @@ "rapid-development", "validation" ], - "time": "2019-05-30T02:33:00+00:00" + "time": "2019-08-09T02:42:41+00:00" }, { "name": "cakephp/chronos", @@ -298,23 +298,23 @@ }, { "name": "cakephp/migrations", - "version": "2.1.1", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/cakephp/migrations.git", - "reference": "96e3cc00ede11f28bb8bcefcab95f07c487177cf" + "reference": "38fbee62e7f387dbe0dc7ef492aa7dddb8e304fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/migrations/zipball/96e3cc00ede11f28bb8bcefcab95f07c487177cf", - "reference": "96e3cc00ede11f28bb8bcefcab95f07c487177cf", + "url": "https://api.github.com/repos/cakephp/migrations/zipball/38fbee62e7f387dbe0dc7ef492aa7dddb8e304fc", + "reference": "38fbee62e7f387dbe0dc7ef492aa7dddb8e304fc", "shasum": "" }, "require": { "cakephp/cache": "^3.6.0", "cakephp/orm": "^3.6.0", "php": ">=5.6.0", - "robmorgan/phinx": "~0.10.3" + "robmorgan/phinx": "^0.10.3" }, "require-dev": { "cakephp/bake": "^1.7.0", @@ -328,7 +328,7 @@ "type": "cakephp-plugin", "autoload": { "psr-4": { - "Migrations\\": "src" + "Migrations\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -347,20 +347,20 @@ "cakephp", "migrations" ], - "time": "2019-02-07T15:20:33+00:00" + "time": "2019-07-22T03:02:47+00:00" }, { "name": "cakephp/plugin-installer", - "version": "1.1.0", + "version": "1.1.1", "source": { "type": "git", "url": "https://github.com/cakephp/plugin-installer.git", - "reference": "41373d0678490502f45adc7be88aa22d24ac1843" + "reference": "af9711ee5dfbe62a76e8aa86cb348895fab23b50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/plugin-installer/zipball/41373d0678490502f45adc7be88aa22d24ac1843", - "reference": "41373d0678490502f45adc7be88aa22d24ac1843", + "url": "https://api.github.com/repos/cakephp/plugin-installer/zipball/af9711ee5dfbe62a76e8aa86cb348895fab23b50", + "reference": "af9711ee5dfbe62a76e8aa86cb348895fab23b50", "shasum": "" }, "require-dev": { @@ -388,7 +388,7 @@ } ], "description": "A composer installer for CakePHP 3.0+ plugins.", - "time": "2017-12-24T21:09:29+00:00" + "time": "2019-07-25T15:43:38+00:00" }, { "name": "claviska/simpleimage", @@ -431,25 +431,25 @@ }, { "name": "composer/ca-bundle", - "version": "1.1.4", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "558f321c52faeb4828c03e7dc0cfe39a09e09a2d" + "reference": "f26a67e397be0e5c00d7c52ec7b5010098e15ce5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/558f321c52faeb4828c03e7dc0cfe39a09e09a2d", - "reference": "558f321c52faeb4828c03e7dc0cfe39a09e09a2d", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/f26a67e397be0e5c00d7c52ec7b5010098e15ce5", + "reference": "f26a67e397be0e5c00d7c52ec7b5010098e15ce5", "shasum": "" }, "require": { "ext-openssl": "*", "ext-pcre": "*", - "php": "^5.3.2 || ^7.0" + "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5", + "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 8", "psr/log": "^1.0", "symfony/process": "^2.5 || ^3.0 || ^4.0" }, @@ -483,31 +483,31 @@ "ssl", "tls" ], - "time": "2019-01-28T09:30:10+00:00" + "time": "2019-08-02T09:05:43+00:00" }, { "name": "davidyell/proffer", - "version": "1.0.1", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/davidyell/CakePHP3-Proffer.git", - "reference": "b4a841e0c2dcd99454989a77b4395e4029c04a03" + "reference": "d0fb682d1fa73ce35605042ae1da8c680c5e9d29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/davidyell/CakePHP3-Proffer/zipball/b4a841e0c2dcd99454989a77b4395e4029c04a03", - "reference": "b4a841e0c2dcd99454989a77b4395e4029c04a03", + "url": "https://api.github.com/repos/davidyell/CakePHP3-Proffer/zipball/d0fb682d1fa73ce35605042ae1da8c680c5e9d29", + "reference": "d0fb682d1fa73ce35605042ae1da8c680c5e9d29", "shasum": "" }, "require": { "cakephp/orm": "3.*", "intervention/image": "^2.3", - "php": ">=5.6" + "php": ">=5.6.0" }, "require-dev": { - "cakephp/cakephp": "^3.4.0", - "cakephp/cakephp-codesniffer": "^2.0", - "phpunit/phpunit": "^5.5" + "cakephp/cakephp": "~3.4", + "cakephp/cakephp-codesniffer": "~3.0", + "phpunit/phpunit": "^5|^6" }, "type": "cakephp-plugin", "autoload": { @@ -535,27 +535,27 @@ "orm", "upload" ], - "time": "2019-01-08T11:23:42+00:00" + "time": "2019-08-02T08:26:16+00:00" }, { "name": "embed/embed", - "version": "v3.3.9", + "version": "v3.4.1", "source": { "type": "git", "url": "https://github.com/oscarotero/Embed.git", - "reference": "4f0d7c0ba8dce13228fd35b7a73cd4ef5ab0f02c" + "reference": "960bbd5a62c5697302bd5394d58efba2e998b787" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/oscarotero/Embed/zipball/4f0d7c0ba8dce13228fd35b7a73cd4ef5ab0f02c", - "reference": "4f0d7c0ba8dce13228fd35b7a73cd4ef5ab0f02c", + "url": "https://api.github.com/repos/oscarotero/Embed/zipball/960bbd5a62c5697302bd5394d58efba2e998b787", + "reference": "960bbd5a62c5697302bd5394d58efba2e998b787", "shasum": "" }, "require": { "composer/ca-bundle": "^1.0", "ext-curl": "*", "ext-mbstring": "*", - "php": "^5.5|^7.0" + "php": "^5.6|^7.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^2.0", @@ -574,9 +574,9 @@ "authors": [ { "name": "Oscar Otero", + "role": "Developer", "email": "oom@oscarotero.com", - "homepage": "http://oscarotero.com", - "role": "Developer" + "homepage": "http://oscarotero.com" } ], "description": "PHP library to retrieve page info using oembed, opengraph, etc", @@ -588,7 +588,7 @@ "opengraph", "twitter cards" ], - "time": "2019-02-25T15:07:28+00:00" + "time": "2019-07-20T17:05:41+00:00" }, { "name": "firebase/php-jwt", @@ -642,12 +642,12 @@ "source": { "type": "git", "url": "https://github.com/FriendsOfCake/bootstrap-ui.git", - "reference": "e61d89ecb2dae89626b94c4e5b71191b1afe34e4" + "reference": "958a5dddd90f37ac816188ec4ab44ded82ce6ce7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfCake/bootstrap-ui/zipball/e61d89ecb2dae89626b94c4e5b71191b1afe34e4", - "reference": "e61d89ecb2dae89626b94c4e5b71191b1afe34e4", + "url": "https://api.github.com/repos/FriendsOfCake/bootstrap-ui/zipball/958a5dddd90f37ac816188ec4ab44ded82ce6ce7", + "reference": "958a5dddd90f37ac816188ec4ab44ded82ce6ce7", "shasum": "" }, "require": { @@ -670,8 +670,8 @@ "authors": [ { "name": "Jad Bitar", - "homepage": "http://jadb.io", - "role": "Author" + "role": "Author", + "homepage": "http://jadb.io" }, { "name": "Others", @@ -685,7 +685,7 @@ "cakephp", "twitter" ], - "time": "2019-05-13T11:40:19+00:00" + "time": "2019-08-19T06:18:07+00:00" }, { "name": "friendsofcake/search", @@ -785,33 +785,37 @@ }, { "name": "guzzlehttp/psr7", - "version": "1.5.2", + "version": "1.6.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "9f83dded91781a01c63574e387eaa769be769115" + "reference": "239400de7a173fe9901b9ac7c06497751f00727a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/9f83dded91781a01c63574e387eaa769be769115", - "reference": "9f83dded91781a01c63574e387eaa769be769115", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/239400de7a173fe9901b9ac7c06497751f00727a", + "reference": "239400de7a173fe9901b9ac7c06497751f00727a", "shasum": "" }, "require": { "php": ">=5.4.0", "psr/http-message": "~1.0", - "ralouphie/getallheaders": "^2.0.5" + "ralouphie/getallheaders": "^2.0.5 || ^3.0.0" }, "provide": { "psr/http-message-implementation": "1.0" }, "require-dev": { + "ext-zlib": "*", "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8" }, + "suggest": { + "zendframework/zend-httphandlerrunner": "Emit PSR-7 responses" + }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.5-dev" + "dev-master": "1.6-dev" } }, "autoload": { @@ -848,20 +852,20 @@ "uri", "url" ], - "time": "2018-12-04T20:46:45+00:00" + "time": "2019-07-01T23:21:34+00:00" }, { "name": "intervention/image", - "version": "2.4.2", + "version": "2.5.0", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "e82d274f786e3d4b866a59b173f42e716f0783eb" + "reference": "39eaef720d082ecc54c64bf54541c55f10db546d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/e82d274f786e3d4b866a59b173f42e716f0783eb", - "reference": "e82d274f786e3d4b866a59b173f42e716f0783eb", + "url": "https://api.github.com/repos/Intervention/image/zipball/39eaef720d082ecc54c64bf54541c55f10db546d", + "reference": "39eaef720d082ecc54c64bf54541c55f10db546d", "shasum": "" }, "require": { @@ -918,20 +922,20 @@ "thumbnail", "watermark" ], - "time": "2018-05-29T14:19:03+00:00" + "time": "2019-06-24T14:06:31+00:00" }, { "name": "jbbcode/jbbcode", - "version": "v1.4.0", + "version": "v1.4.1", "source": { "type": "git", "url": "https://github.com/jbowens/jBBCode.git", - "reference": "740092873a687e61b980d2a094f6323b0d3b273c" + "reference": "d574cbf4cfb096abd09fafabfe5b3b42e521a396" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jbowens/jBBCode/zipball/740092873a687e61b980d2a094f6323b0d3b273c", - "reference": "740092873a687e61b980d2a094f6323b0d3b273c", + "url": "https://api.github.com/repos/jbowens/jBBCode/zipball/d574cbf4cfb096abd09fafabfe5b3b42e521a396", + "reference": "d574cbf4cfb096abd09fafabfe5b3b42e521a396", "shasum": "" }, "require": { @@ -955,9 +959,9 @@ "authors": [ { "name": "Jackson Owens", + "role": "Developer", "email": "jackson_owens@alumni.brown.edu", - "homepage": "http://jbowens.org/", - "role": "Developer" + "homepage": "http://jbowens.org/" } ], "description": "A lightweight but extensible BBCode parser written in PHP 5.3.", @@ -966,7 +970,7 @@ "BB", "bbcode" ], - "time": "2019-02-22T23:54:11+00:00" + "time": "2019-08-13T18:28:27+00:00" }, { "name": "josegonzalez/dotenv", @@ -1023,16 +1027,16 @@ }, { "name": "layershifter/tld-database", - "version": "1.0.68", + "version": "1.0.69", "source": { "type": "git", "url": "https://github.com/layershifter/TLDDatabase.git", - "reference": "50433a8705e3d7f3a584943c3aef09b5f561e2b7" + "reference": "7423c7acdc5147268fb856f532a5d8875f6fe41e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/layershifter/TLDDatabase/zipball/50433a8705e3d7f3a584943c3aef09b5f561e2b7", - "reference": "50433a8705e3d7f3a584943c3aef09b5f561e2b7", + "url": "https://api.github.com/repos/layershifter/TLDDatabase/zipball/7423c7acdc5147268fb856f532a5d8875f6fe41e", + "reference": "7423c7acdc5147268fb856f532a5d8875f6fe41e", "shasum": "" }, "require": { @@ -1070,7 +1074,7 @@ "domain database", "tld database" ], - "time": "2019-04-22T10:35:49+00:00" + "time": "2019-08-04T10:03:36+00:00" }, { "name": "layershifter/tld-extract", @@ -1448,6 +1452,55 @@ ], "time": "2018-09-01T15:05:15+00:00" }, + { + "name": "psr/container", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "time": "2017-02-14T16:28:37+00:00" + }, { "name": "psr/http-message", "version": "1.0.1", @@ -1595,24 +1648,24 @@ }, { "name": "ralouphie/getallheaders", - "version": "2.0.5", + "version": "3.0.3", "source": { "type": "git", "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "5601c8a83fbba7ef674a7369456d12f1e0d0eafa" + "reference": "120b605dfeb996808c31b6477290a714d356e822" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/5601c8a83fbba7ef674a7369456d12f1e0d0eafa", - "reference": "5601c8a83fbba7ef674a7369456d12f1e0d0eafa", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", "shasum": "" }, "require": { - "php": ">=5.3" + "php": ">=5.6" }, "require-dev": { - "phpunit/phpunit": "~3.7.0", - "satooshi/php-coveralls": ">=1.0" + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" }, "type": "library", "autoload": { @@ -1631,20 +1684,20 @@ } ], "description": "A polyfill for getallheaders.", - "time": "2016-02-11T07:05:27+00:00" + "time": "2019-03-08T08:55:37+00:00" }, { "name": "robmorgan/phinx", - "version": "0.10.7", + "version": "0.10.8", "source": { "type": "git", "url": "https://github.com/cakephp/phinx.git", - "reference": "ba2dae98bb69d39531311e8fd72dd51e8e06ff32" + "reference": "1960e93169707096fdfde04904a204970077f4be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/phinx/zipball/ba2dae98bb69d39531311e8fd72dd51e8e06ff32", - "reference": "ba2dae98bb69d39531311e8fd72dd51e8e06ff32", + "url": "https://api.github.com/repos/cakephp/phinx/zipball/1960e93169707096fdfde04904a204970077f4be", + "reference": "1960e93169707096fdfde04904a204970077f4be", "shasum": "" }, "require": { @@ -1676,20 +1729,20 @@ "authors": [ { "name": "Woody Gilk", + "role": "Developer", "email": "woody.gilk@gmail.com", - "homepage": "http://shadowhand.me", - "role": "Developer" + "homepage": "http://shadowhand.me" }, { "name": "Rob Morgan", + "role": "Lead Developer", "email": "robbym@gmail.com", - "homepage": "https://robmorgan.id.au", - "role": "Lead Developer" + "homepage": "https://robmorgan.id.au" }, { "name": "Richard Quadling", - "email": "rquadling@gmail.com", - "role": "Developer" + "role": "Developer", + "email": "rquadling@gmail.com" }, { "name": "CakePHP Community", @@ -1705,7 +1758,7 @@ "migrations", "phinx" ], - "time": "2019-04-25T09:12:16+00:00" + "time": "2019-07-08T16:59:55+00:00" }, { "name": "siezi/cakephp-simple-captcha", @@ -1799,16 +1852,16 @@ }, { "name": "symfony/config", - "version": "v4.3.1", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "6379ee07398643e09e6ed1e87d9c62dfcad7f4eb" + "reference": "a17a2aea43950ce83a0603ed301bac362eb86870" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/6379ee07398643e09e6ed1e87d9c62dfcad7f4eb", - "reference": "6379ee07398643e09e6ed1e87d9c62dfcad7f4eb", + "url": "https://api.github.com/repos/symfony/config/zipball/a17a2aea43950ce83a0603ed301bac362eb86870", + "reference": "a17a2aea43950ce83a0603ed301bac362eb86870", "shasum": "" }, "require": { @@ -1859,20 +1912,20 @@ ], "description": "Symfony Config Component", "homepage": "https://symfony.com", - "time": "2019-05-30T16:10:05+00:00" + "time": "2019-07-18T10:34:59+00:00" }, { "name": "symfony/console", - "version": "v4.3.1", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "d50bbeeb0e17e6dd4124ea391eff235e932cbf64" + "reference": "8b0ae5742ce9aaa8b0075665862c1ca397d1c1d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/d50bbeeb0e17e6dd4124ea391eff235e932cbf64", - "reference": "d50bbeeb0e17e6dd4124ea391eff235e932cbf64", + "url": "https://api.github.com/repos/symfony/console/zipball/8b0ae5742ce9aaa8b0075665862c1ca397d1c1d9", + "reference": "8b0ae5742ce9aaa8b0075665862c1ca397d1c1d9", "shasum": "" }, "require": { @@ -1934,20 +1987,20 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-06-05T13:25:51+00:00" + "time": "2019-07-24T17:13:59+00:00" }, { "name": "symfony/filesystem", - "version": "v4.3.1", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "bf2af40d738dec5e433faea7b00daa4431d0a4cf" + "reference": "b9896d034463ad6fd2bf17e2bf9418caecd6313d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/bf2af40d738dec5e433faea7b00daa4431d0a4cf", - "reference": "bf2af40d738dec5e433faea7b00daa4431d0a4cf", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/b9896d034463ad6fd2bf17e2bf9418caecd6313d", + "reference": "b9896d034463ad6fd2bf17e2bf9418caecd6313d", "shasum": "" }, "require": { @@ -1984,20 +2037,20 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-06-03T20:27:40+00:00" + "time": "2019-06-23T08:51:25+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "82ebae02209c21113908c229e9883c419720738a" + "reference": "550ebaac289296ce228a706d0867afc34687e3f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", - "reference": "82ebae02209c21113908c229e9883c419720738a", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/550ebaac289296ce228a706d0867afc34687e3f4", + "reference": "550ebaac289296ce228a706d0867afc34687e3f4", "shasum": "" }, "require": { @@ -2009,7 +2062,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.11-dev" + "dev-master": "1.12-dev" } }, "autoload": { @@ -2025,13 +2078,13 @@ "MIT" ], "authors": [ - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - }, { "name": "Gert de Pagter", "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], "description": "Symfony polyfill for ctype functions", @@ -2042,20 +2095,20 @@ "polyfill", "portable" ], - "time": "2019-02-06T07:57:58+00:00" + "time": "2019-08-06T08:03:45+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "c766e95bec706cdd89903b1eda8afab7d7a6b7af" + "reference": "6af626ae6fa37d396dc90a399c0ff08e5cfc45b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c766e95bec706cdd89903b1eda8afab7d7a6b7af", - "reference": "c766e95bec706cdd89903b1eda8afab7d7a6b7af", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/6af626ae6fa37d396dc90a399c0ff08e5cfc45b2", + "reference": "6af626ae6fa37d396dc90a399c0ff08e5cfc45b2", "shasum": "" }, "require": { @@ -2069,7 +2122,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.12-dev" } }, "autoload": { @@ -2085,13 +2138,13 @@ "MIT" ], "authors": [ - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - }, { "name": "Laurent Bassin", "email": "laurent@bassin.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", @@ -2104,20 +2157,20 @@ "portable", "shim" ], - "time": "2019-03-04T13:44:35+00:00" + "time": "2019-08-06T08:03:45+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "fe5e94c604826c35a32fa832f35bd036b6799609" + "reference": "b42a2f66e8f1b15ccf25652c3424265923eb4f17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fe5e94c604826c35a32fa832f35bd036b6799609", - "reference": "fe5e94c604826c35a32fa832f35bd036b6799609", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/b42a2f66e8f1b15ccf25652c3424265923eb4f17", + "reference": "b42a2f66e8f1b15ccf25652c3424265923eb4f17", "shasum": "" }, "require": { @@ -2129,7 +2182,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.11-dev" + "dev-master": "1.12-dev" } }, "autoload": { @@ -2163,20 +2216,20 @@ "portable", "shim" ], - "time": "2019-02-06T07:57:58+00:00" + "time": "2019-08-06T08:03:45+00:00" }, { "name": "symfony/polyfill-php72", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "ab50dcf166d5f577978419edd37aa2bb8eabce0c" + "reference": "04ce3335667451138df4307d6a9b61565560199e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/ab50dcf166d5f577978419edd37aa2bb8eabce0c", - "reference": "ab50dcf166d5f577978419edd37aa2bb8eabce0c", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/04ce3335667451138df4307d6a9b61565560199e", + "reference": "04ce3335667451138df4307d6a9b61565560199e", "shasum": "" }, "require": { @@ -2185,7 +2238,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.11-dev" + "dev-master": "1.12-dev" } }, "autoload": { @@ -2218,20 +2271,20 @@ "portable", "shim" ], - "time": "2019-02-06T07:57:58+00:00" + "time": "2019-08-06T08:03:45+00:00" }, { "name": "symfony/polyfill-php73", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd" + "reference": "2ceb49eaccb9352bff54d22570276bb75ba4a188" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", - "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/2ceb49eaccb9352bff54d22570276bb75ba4a188", + "reference": "2ceb49eaccb9352bff54d22570276bb75ba4a188", "shasum": "" }, "require": { @@ -2240,7 +2293,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.11-dev" + "dev-master": "1.12-dev" } }, "autoload": { @@ -2276,27 +2329,27 @@ "portable", "shim" ], - "time": "2019-02-06T07:57:58+00:00" + "time": "2019-08-06T08:03:45+00:00" }, { "name": "symfony/service-contracts", - "version": "v1.1.2", + "version": "v1.1.5", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "191afdcb5804db960d26d8566b7e9a2843cab3a0" + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/191afdcb5804db960d26d8566b7e9a2843cab3a0", - "reference": "191afdcb5804db960d26d8566b7e9a2843cab3a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", "shasum": "" }, "require": { - "php": "^7.1.3" + "php": "^7.1.3", + "psr/container": "^1.0" }, "suggest": { - "psr/container": "", "symfony/service-implementation": "" }, "type": "library", @@ -2334,20 +2387,20 @@ "interoperability", "standards" ], - "time": "2019-05-28T07:50:59+00:00" + "time": "2019-06-13T11:15:36+00:00" }, { "name": "symfony/yaml", - "version": "v4.3.1", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "c60ecf5ba842324433b46f58dc7afc4487dbab99" + "reference": "34d29c2acd1ad65688f58452fd48a46bd996d5a6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/c60ecf5ba842324433b46f58dc7afc4487dbab99", - "reference": "c60ecf5ba842324433b46f58dc7afc4487dbab99", + "url": "https://api.github.com/repos/symfony/yaml/zipball/34d29c2acd1ad65688f58452fd48a46bd996d5a6", + "reference": "34d29c2acd1ad65688f58452fd48a46bd996d5a6", "shasum": "" }, "require": { @@ -2393,7 +2446,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2019-04-06T14:04:46+00:00" + "time": "2019-07-24T14:47:54+00:00" }, { "name": "yzalis/identicon", @@ -2449,16 +2502,16 @@ }, { "name": "zendframework/zend-diactoros", - "version": "1.8.6", + "version": "1.8.7", "source": { "type": "git", "url": "https://github.com/zendframework/zend-diactoros.git", - "reference": "20da13beba0dde8fb648be3cc19765732790f46e" + "reference": "a85e67b86e9b8520d07e6415fcbcb8391b44a75b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/20da13beba0dde8fb648be3cc19765732790f46e", - "reference": "20da13beba0dde8fb648be3cc19765732790f46e", + "url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/a85e67b86e9b8520d07e6415fcbcb8391b44a75b", + "reference": "a85e67b86e9b8520d07e6415fcbcb8391b44a75b", "shasum": "" }, "require": { @@ -2478,9 +2531,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.8.x-dev", - "dev-develop": "1.9.x-dev", - "dev-release-2.0": "2.0.x-dev" + "dev-release-1.8": "1.8.x-dev" } }, "autoload": { @@ -2509,7 +2560,7 @@ "psr", "psr-7" ], - "time": "2018-09-05T19:29:37+00:00" + "time": "2019-08-06T17:53:53+00:00" } ], "packages-dev": [ @@ -2686,20 +2737,20 @@ }, { "name": "cakephp/bake", - "version": "1.9.6", + "version": "1.11.2", "source": { "type": "git", "url": "https://github.com/cakephp/bake.git", - "reference": "96651b0d4c4b4d29cf6faa87114f745b91130eec" + "reference": "8598c3326541a16aa7b003ce322c44a34f90ad85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/bake/zipball/96651b0d4c4b4d29cf6faa87114f745b91130eec", - "reference": "96651b0d4c4b4d29cf6faa87114f745b91130eec", + "url": "https://api.github.com/repos/cakephp/bake/zipball/8598c3326541a16aa7b003ce322c44a34f90ad85", + "reference": "8598c3326541a16aa7b003ce322c44a34f90ad85", "shasum": "" }, "require": { - "cakephp/cakephp": "^3.7.0", + "cakephp/cakephp": "^3.8.0", "cakephp/plugin-installer": "^1.0", "php": ">=5.6.0", "wyrihaximus/twig-view": "^4.3.7" @@ -2730,7 +2781,7 @@ "bake", "cakephp" ], - "time": "2019-04-24T21:12:31+00:00" + "time": "2019-07-30T02:08:16+00:00" }, { "name": "cakephp/cakephp-codesniffer", @@ -2779,16 +2830,16 @@ }, { "name": "cakephp/debug_kit", - "version": "3.19.0", + "version": "3.20.1", "source": { "type": "git", "url": "https://github.com/cakephp/debug_kit.git", - "reference": "e2d856ed7c504b80354038b22d6f342c13ac5145" + "reference": "2d2a9844ee7e8a08be8824e63f83b18699a134b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/debug_kit/zipball/e2d856ed7c504b80354038b22d6f342c13ac5145", - "reference": "e2d856ed7c504b80354038b22d6f342c13ac5145", + "url": "https://api.github.com/repos/cakephp/debug_kit/zipball/2d2a9844ee7e8a08be8824e63f83b18699a134b3", + "reference": "2d2a9844ee7e8a08be8824e63f83b18699a134b3", "shasum": "" }, "require": { @@ -2820,8 +2871,8 @@ "authors": [ { "name": "Mark Story", - "homepage": "http://mark-story.com", - "role": "Author" + "role": "Author", + "homepage": "http://mark-story.com" }, { "name": "CakePHP Community", @@ -2835,20 +2886,20 @@ "debug", "kit" ], - "time": "2019-05-31T13:45:08+00:00" + "time": "2019-08-12T01:28:07+00:00" }, { "name": "composer/composer", - "version": "1.8.6", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "19b5f66a0e233eb944f134df34091fe1c5dfcc11" + "reference": "314aa57fdcfc942065996f59fb73a8b3f74f3fa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/19b5f66a0e233eb944f134df34091fe1c5dfcc11", - "reference": "19b5f66a0e233eb944f134df34091fe1c5dfcc11", + "url": "https://api.github.com/repos/composer/composer/zipball/314aa57fdcfc942065996f59fb73a8b3f74f3fa5", + "reference": "314aa57fdcfc942065996f59fb73a8b3f74f3fa5", "shasum": "" }, "require": { @@ -2884,7 +2935,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.8-dev" + "dev-master": "1.9-dev" } }, "autoload": { @@ -2908,14 +2959,14 @@ "homepage": "http://seld.be" } ], - "description": "Composer helps you declare, manage and install dependencies of PHP projects, ensuring you have the right stack everywhere.", + "description": "Composer helps you declare, manage and install dependencies of PHP projects. It ensures you have the right stack everywhere.", "homepage": "https://getcomposer.org/", "keywords": [ "autoload", "dependency", "package" ], - "time": "2019-06-11T13:03:06+00:00" + "time": "2019-08-02T18:55:33+00:00" }, { "name": "composer/semver", @@ -2981,16 +3032,16 @@ }, { "name": "composer/spdx-licenses", - "version": "1.5.1", + "version": "1.5.2", "source": { "type": "git", "url": "https://github.com/composer/spdx-licenses.git", - "reference": "a1aa51cf3ab838b83b0867b14e56fc20fbd55b3d" + "reference": "7ac1e6aec371357df067f8a688c3d6974df68fa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/a1aa51cf3ab838b83b0867b14e56fc20fbd55b3d", - "reference": "a1aa51cf3ab838b83b0867b14e56fc20fbd55b3d", + "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/7ac1e6aec371357df067f8a688c3d6974df68fa5", + "reference": "7ac1e6aec371357df067f8a688c3d6974df68fa5", "shasum": "" }, "require": { @@ -3037,7 +3088,7 @@ "spdx", "validator" ], - "time": "2019-03-26T10:23:26+00:00" + "time": "2019-07-29T10:31:59+00:00" }, { "name": "composer/xdebug-handler", @@ -3489,16 +3540,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.9.1", + "version": "1.9.3", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72" + "reference": "007c053ae6f31bba39dfa19a7726f56e9763bbea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72", - "reference": "e6828efaba2c9b79f4499dae1d66ef8bfa7b2b72", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/007c053ae6f31bba39dfa19a7726f56e9763bbea", + "reference": "007c053ae6f31bba39dfa19a7726f56e9763bbea", "shasum": "" }, "require": { @@ -3533,7 +3584,7 @@ "object", "object graph" ], - "time": "2019-04-07T13:18:21+00:00" + "time": "2019-08-09T12:45:53+00:00" }, { "name": "nette/bootstrap", @@ -3610,16 +3661,16 @@ }, { "name": "nette/di", - "version": "v3.0.0", + "version": "v3.0.1", "source": { "type": "git", "url": "https://github.com/nette/di.git", - "reference": "19d83539245aaacb59470828919182411061841f" + "reference": "4aff517a1c6bb5c36fa09733d4cea089f529de6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/di/zipball/19d83539245aaacb59470828919182411061841f", - "reference": "19d83539245aaacb59470828919182411061841f", + "url": "https://api.github.com/repos/nette/di/zipball/4aff517a1c6bb5c36fa09733d4cea089f529de6d", + "reference": "4aff517a1c6bb5c36fa09733d4cea089f529de6d", "shasum": "" }, "require": { @@ -3679,7 +3730,7 @@ "nette", "static" ], - "time": "2019-04-03T19:35:46+00:00" + "time": "2019-08-07T12:11:33+00:00" }, { "name": "nette/finder", @@ -3806,16 +3857,16 @@ }, { "name": "nette/php-generator", - "version": "v3.2.2", + "version": "v3.2.3", "source": { "type": "git", "url": "https://github.com/nette/php-generator.git", - "reference": "acff8b136fad84b860a626d133e791f95781f9f5" + "reference": "aea6e81437bb238e5f0e5b5ce06337433908e63b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/php-generator/zipball/acff8b136fad84b860a626d133e791f95781f9f5", - "reference": "acff8b136fad84b860a626d133e791f95781f9f5", + "url": "https://api.github.com/repos/nette/php-generator/zipball/aea6e81437bb238e5f0e5b5ce06337433908e63b", + "reference": "aea6e81437bb238e5f0e5b5ce06337433908e63b", "shasum": "" }, "require": { @@ -3861,7 +3912,7 @@ "php", "scaffolding" ], - "time": "2019-03-15T03:41:13+00:00" + "time": "2019-07-05T13:01:56+00:00" }, { "name": "nette/robot-loader", @@ -4060,16 +4111,16 @@ }, { "name": "nikic/php-parser", - "version": "v4.2.2", + "version": "v4.2.3", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "1bd73cc04c3843ad8d6b0bfc0956026a151fc420" + "reference": "e612609022e935f3d0337c1295176505b41188c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/1bd73cc04c3843ad8d6b0bfc0956026a151fc420", - "reference": "1bd73cc04c3843ad8d6b0bfc0956026a151fc420", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/e612609022e935f3d0337c1295176505b41188c8", + "reference": "e612609022e935f3d0337c1295176505b41188c8", "shasum": "" }, "require": { @@ -4077,7 +4128,7 @@ "php": ">=7.0" }, "require-dev": { - "phpunit/phpunit": "^6.5 || ^7.0" + "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0" }, "bin": [ "bin/php-parse" @@ -4107,7 +4158,7 @@ "parser", "php" ], - "time": "2019-05-25T20:07:01+00:00" + "time": "2019-08-12T20:17:41+00:00" }, { "name": "ocramius/package-versions", @@ -4618,16 +4669,16 @@ }, { "name": "phpstan/phpstan", - "version": "0.11.8", + "version": "0.11.15", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "fcf0081bf3a254ddacffa03e78be87842d0c09c9" + "reference": "1be5b3a706db16ac472a4c40ec03cf4c810b118d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/fcf0081bf3a254ddacffa03e78be87842d0c09c9", - "reference": "fcf0081bf3a254ddacffa03e78be87842d0c09c9", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1be5b3a706db16ac472a4c40ec03cf4c810b118d", + "reference": "1be5b3a706db16ac472a4c40ec03cf4c810b118d", "shasum": "" }, "require": { @@ -4638,9 +4689,9 @@ "nette/robot-loader": "^3.0.1", "nette/schema": "^1.0", "nette/utils": "^2.4.5 || ^3.0", - "nikic/php-parser": "^4.0.2", + "nikic/php-parser": "^4.2.3", "php": "~7.1", - "phpstan/phpdoc-parser": "^0.3.4", + "phpstan/phpdoc-parser": "^0.3.5", "symfony/console": "~3.2 || ~4.0", "symfony/finder": "~3.2 || ~4.0" }, @@ -4648,11 +4699,12 @@ "symfony/console": "3.4.16 || 4.1.5" }, "require-dev": { - "brianium/paratest": "^2.0", + "brianium/paratest": "^2.0 || ^3.0", "consistence/coding-standard": "^3.5", "dealerdirect/phpcodesniffer-composer-installer": "^0.4.4", "ext-intl": "*", "ext-mysqli": "*", + "ext-simplexml": "*", "ext-soap": "*", "ext-zip": "*", "jakub-onderka/php-parallel-lint": "^1.0", @@ -4662,7 +4714,7 @@ "phpstan/phpstan-php-parser": "^0.11", "phpstan/phpstan-phpunit": "^0.11", "phpstan/phpstan-strict-rules": "^0.11", - "phpunit/phpunit": "^7.0", + "phpunit/phpunit": "^7.5.14 || ^8.0", "slevomat/coding-standard": "^4.7.2", "squizlabs/php_codesniffer": "^3.3.2" }, @@ -4688,7 +4740,7 @@ "MIT" ], "description": "PHPStan - PHP Static Analysis Tool", - "time": "2019-05-28T19:58:33+00:00" + "time": "2019-08-18T20:51:53+00:00" }, { "name": "phpunit/php-code-coverage", @@ -5370,16 +5422,16 @@ }, { "name": "sebastian/exporter", - "version": "3.1.0", + "version": "3.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "234199f4528de6d12aaa58b612e98f7d36adb937" + "reference": "06a9a5947f47b3029d76118eb5c22802e5869687" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/234199f4528de6d12aaa58b612e98f7d36adb937", - "reference": "234199f4528de6d12aaa58b612e98f7d36adb937", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/06a9a5947f47b3029d76118eb5c22802e5869687", + "reference": "06a9a5947f47b3029d76118eb5c22802e5869687", "shasum": "" }, "require": { @@ -5406,6 +5458,10 @@ "BSD-3-Clause" ], "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, { "name": "Jeff Welch", "email": "whatthejeff@gmail.com" @@ -5414,17 +5470,13 @@ "name": "Volker Dusch", "email": "github@wallbash.com" }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, { "name": "Adam Harvey", "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" } ], "description": "Provides the functionality to export PHP variables for visualization", @@ -5433,7 +5485,7 @@ "export", "exporter" ], - "time": "2017-04-03T13:19:02+00:00" + "time": "2019-08-11T12:43:14+00:00" }, { "name": "sebastian/global-state", @@ -5862,7 +5914,7 @@ }, { "name": "symfony/css-selector", - "version": "v4.3.1", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", @@ -5896,14 +5948,14 @@ "MIT" ], "authors": [ - { - "name": "Jean-François Simon", - "email": "jeanfrancois.simon@sensiolabs.com" - }, { "name": "Fabien Potencier", "email": "fabien@symfony.com" }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" @@ -5915,16 +5967,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v4.3.1", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "06ee58fbc9a8130f1d35b5280e15235a0515d457" + "reference": "291397232a2eefb3347eaab9170409981eaad0e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/06ee58fbc9a8130f1d35b5280e15235a0515d457", - "reference": "06ee58fbc9a8130f1d35b5280e15235a0515d457", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/291397232a2eefb3347eaab9170409981eaad0e2", + "reference": "291397232a2eefb3347eaab9170409981eaad0e2", "shasum": "" }, "require": { @@ -5972,20 +6024,20 @@ ], "description": "Symfony DomCrawler Component", "homepage": "https://symfony.com", - "time": "2019-05-31T18:55:30+00:00" + "time": "2019-06-13T11:03:18+00:00" }, { "name": "symfony/finder", - "version": "v4.3.1", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176" + "reference": "9638d41e3729459860bb96f6247ccb61faaa45f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176", - "reference": "b3d4f4c0e4eadfdd8b296af9ca637cfbf51d8176", + "url": "https://api.github.com/repos/symfony/finder/zipball/9638d41e3729459860bb96f6247ccb61faaa45f2", + "reference": "9638d41e3729459860bb96f6247ccb61faaa45f2", "shasum": "" }, "require": { @@ -6021,11 +6073,11 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-05-26T20:47:49+00:00" + "time": "2019-06-28T13:16:30+00:00" }, { "name": "symfony/process", - "version": "v4.3.1", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/process.git", @@ -6074,16 +6126,16 @@ }, { "name": "symfony/var-dumper", - "version": "v4.3.1", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "f974f448154928d2b5fb7c412bd23b81d063f34b" + "reference": "e4110b992d2cbe198d7d3b244d079c1c58761d07" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/f974f448154928d2b5fb7c412bd23b81d063f34b", - "reference": "f974f448154928d2b5fb7c412bd23b81d063f34b", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e4110b992d2cbe198d7d3b244d079c1c58761d07", + "reference": "e4110b992d2cbe198d7d3b244d079c1c58761d07", "shasum": "" }, "require": { @@ -6146,7 +6198,7 @@ "debug", "dump" ], - "time": "2019-06-05T02:08:12+00:00" + "time": "2019-07-27T06:42:46+00:00" }, { "name": "theseer/tokenizer", diff --git a/config/Migrations/20180620081553_Initial.php b/config/Migrations/20180620081553_Initial.php index 4b48c4096..40b92ebb2 100644 --- a/config/Migrations/20180620081553_Initial.php +++ b/config/Migrations/20180620081553_Initial.php @@ -695,7 +695,7 @@ public function up() ]) ->addColumn('username', 'string', [ 'default' => null, - // For MySQL 5.6- limit for indexed varchar columns on InnoDB is 191 + // For MySQL 5.6 - limit for indexed varchar columns on InnoDB is 191 'limit' => 191, 'null' => true, ]) diff --git a/config/Migrations/20190729142650_Saitox5x3x0.php b/config/Migrations/20190729142650_Saitox5x3x0.php new file mode 100755 index 000000000..aaeb5fa08 --- /dev/null +++ b/config/Migrations/20190729142650_Saitox5x3x0.php @@ -0,0 +1,104 @@ +table('uploads') + ->changeColumn('title', 'string', [ + 'default' => null, + // For MySQL 5.6 - limit for indexed varchar columns on InnoDB is 191 + 'length' => 191, + 'null' => true, + ]) + ->addIndex( + [ + 'user_id', + 'title', + ], + ['name' => 'userId_title', 'unique' => true] + ) + ->update(); + + $this->table('useronline') + ->changeColumn('logged_in', 'boolean', [ + 'default' => null, + 'limit' => null, + 'null' => false, + ]) + ->update(); + + $this->table('drafts') + ->addColumn('user_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => false, + ]) + ->addColumn('pid', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => true, + ]) + ->addColumn('subject', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('text', 'text', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => 'CURRENT_TIMESTAMP', + 'limit' => null, + 'null' => false, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => '0000-00-00 00:00:00', + 'limit' => null, + 'null' => false, + ]) + ->addIndex( + [ + 'user_id', + 'pid', + ], + ['unique' => true] + ) + ->addIndex(['modified']) + ->create(); + + $this->execute('ALTER TABLE entries ENGINE=InnoDB'); + } + + public function down() + { + $this->table('uploads') + ->removeIndexByName('userId_title') + ->update(); + + $this->table('uploads') + ->changeColumn('title', 'string', [ + 'default' => null, + 'length' => 200, + 'null' => true, + ]) + ->update(); + + $this->table('useronline') + ->changeColumn('logged_in', 'boolean', [ + 'default' => 0, + 'length' => null, + 'null' => false, + ]) + ->update(); + + $this->table('drafts')->drop()->save(); + + $this->execute('ALTER TABLE entries ENGINE=MyISAM'); + } +} + diff --git a/config/requirements.php b/config/requirements.php index d5b180f58..b99e40479 100755 --- a/config/requirements.php +++ b/config/requirements.php @@ -20,8 +20,8 @@ /* * You can remove this if you are confident that your PHP version is sufficient. */ -if (version_compare(PHP_VERSION, '5.6.0') < 0) { - trigger_error('Your PHP version must be equal or higher than 5.6.0 to use CakePHP.' . PHP_EOL, E_USER_ERROR); +if (version_compare(PHP_VERSION, '7.2.0') < 0) { + trigger_error('Your PHP version must be equal or higher than 7.2.0 to use CakePHP.' . PHP_EOL, E_USER_ERROR); } /* diff --git a/config/routes.php b/config/routes.php index 4f45fff33..a030a22b4 100755 --- a/config/routes.php +++ b/config/routes.php @@ -59,22 +59,22 @@ */ $routes->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']); - /** - * /users/login -> /login - */ - $routes->connect( - '/login', - ['controller' => 'Users', 'action' => 'login'], - ['_name' => 'login'] + /** + * /users/login -> /login + */ + $routes->connect( + '/login', + ['controller' => 'Users', 'action' => 'login'], + ['_name' => 'login'] ); - /** - * /users/login -> /login - */ - $routes->connect( - '/logout', - ['controller' => 'Users', 'action' => 'logout'], - ['_name' => 'logout'] + /** + * /users/login -> /login + */ + $routes->connect( + '/logout', + ['controller' => 'Users', 'action' => 'logout'], + ['_name' => 'logout'] ); /** @@ -103,3 +103,29 @@ ['controller' => 'Entries', 'action' => 'threadLine'] ); }); + +Router::scope('/api/v2/', function ($routes) { + $routes->setExtensions(['json']); + $routes->resources('Postings'); +}); + +Router::scope('/api/v2/postingmeta', function ($routes) { + $routes->setExtensions(['json']); + $routes->connect( + '/*', + ['controller' => 'Postings', 'action' => 'meta'] + ); +}); + +Router::scope('/api/v2/', function ($routes) { + $routes->setExtensions(['json']); + $routes->resources('Drafts'); +}); + +Router::scope('/api/v2/preview', function ($routes) { + $routes->setExtensions(['json']); + $routes->connect( + '/preview/*', + ['controller' => 'Preview', 'action' => 'preview'] + ); +}); diff --git a/config/saito_config.php b/config/saito_config.php index 8be305834..475a5bb2f 100644 --- a/config/saito_config.php +++ b/config/saito_config.php @@ -39,7 +39,14 @@ /** * Upload directory root with trailing slash */ - 'uploadDirectory' => WWW_ROOT . 'useruploads' . DIRECTORY_SEPARATOR + 'uploadDirectory' => WWW_ROOT . 'useruploads' . DIRECTORY_SEPARATOR, + /** + * Category-select in posting-form is prepopulated with a category + * + * - true - The first available category is preselected as default. + * - false - The User is forced to select a category. + */ + 'answeringAutoSelectCategory' => false, ], /** diff --git a/dev/docker/php7/apache/Dockerfile b/dev/docker/php7/apache/Dockerfile index b9f305450..4d208219d 100644 --- a/dev/docker/php7/apache/Dockerfile +++ b/dev/docker/php7/apache/Dockerfile @@ -11,7 +11,7 @@ RUN apt-get update && apt-get install -y \ libicu-dev \ libpq-dev \ libmcrypt-dev \ - mysql-client \ + mariadb-client \ git \ zlib1g-dev \ libzip-dev \ @@ -38,7 +38,9 @@ RUN docker-php-ext-enable xdebug RUN echo "xdebug.remote_enable=1" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ && echo "xdebug.default_enable=1" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ && echo "xdebug.remote_connect_back=1" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ - && echo "xdebug.remote_autostart=0" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini + && echo "xdebug.remote_autostart=0" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ + && echo "xdebug.profiler_enable_trigger=1" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ + && echo "xdebug.profiler_output_dir=/var/www/html/tmp" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini # Configure and install GD RUN docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ diff --git a/dev/docker/php7/docker-compose.yml b/dev/docker/php7/docker-compose.yml index a83b71fcf..ca6273c2e 100644 --- a/dev/docker/php7/docker-compose.yml +++ b/dev/docker/php7/docker-compose.yml @@ -8,6 +8,8 @@ services: - ./../../../:/var/www/html ports: - "8080:80" + # Karma test runner + - "9876:9876" mailhog: image: mailhog/mailhog:latest diff --git a/dev/gettextExtractor.js b/dev/gettextExtractor.js index 7a40ec8ce..678fc7e9c 100644 --- a/dev/gettextExtractor.js +++ b/dev/gettextExtractor.js @@ -12,6 +12,16 @@ extractor ]) .parseFilesGlob('./frontend/src/**/*.@(ts|js|tsx|jsx|html)'); +extractor + .createJsParser([ + JsExtractors.callExpression('$.i18n.__', { + arguments: { + text: 0, + } + }), + ]) + .parseFilesGlob('./src/Template/**/*.@(ctp)'); + extractor .createHtmlParser([ (node, fileName, addMessage) => { diff --git a/docs/help/de/9-drafts.md b/docs/help/de/9-drafts.md new file mode 100644 index 000000000..741c5819d --- /dev/null +++ b/docs/help/de/9-drafts.md @@ -0,0 +1,14 @@ +## Entwürfe + +Entwürfe sollen Datenverlust beim Verfassen eines neuen Beitrages vermeiden helfen. + +Der Inhalt des Beitrages wird regelmässig im Hintergrund gespeichert. Sollte es zu Problemen beim Nutzer kommen (bspw. unerwartetes Schließen des Browsers), kann der bis dahin gesicherte Inhalt später wieder hergestellt werden. Dazu muss nur ein Beitrag an der gleichen Stelle wie zuvor begonnen werden, der Inhalt eines Entwurfs wird automatisch eingefügt. + +Ein Ikon zeigt den aktuellen Status an: + +-   Änderungen ungesichert +-   Änderungen gesichert + +Nach erfolgreichen Eintragen eines Beitrags wird der entsprechende Entwurf gelöscht. Entwürfe werden nach 30 Tagen als aufgegeben angesehen automatisch entfernt. + +Beim Bearbeiten eines bestehenden Beitrages wird kein Entwurf gespeichert. diff --git a/docs/help/en/9-drafts.md b/docs/help/en/9-drafts.md new file mode 100644 index 000000000..37416af35 --- /dev/null +++ b/docs/help/en/9-drafts.md @@ -0,0 +1,13 @@ +## Drafts + +Drafts prevent the loss of content when composing a new posting. + +The content is regularly saved in the background. If there's a problem (e.g. browser-crash) the saved content can be restored later. To do so start a new posting at the same place and an available draft will be inserted automatically. + +The current status is indicated by an icon: + +-   unsaved changes +-   saved + +After a successful posting the draft is deleted. All drafts are considered abandoned after 30 days and deleted automatically. + diff --git a/docs/update.md b/docs/update.md new file mode 100644 index 000000000..1ce5a94be --- /dev/null +++ b/docs/update.md @@ -0,0 +1,11 @@ +# Manual Update + +Copy all new files on the server except for a few files and folders with custom data. Usually those are: + +- Every file in the `config/` folder that you changed, usually: + - `config/app.php` + - `config/saito_config.php` +- `webroot/useruploads` + + +Don't forget invisible files like `.htaccess` in the root folder. diff --git a/frontend/src/@types/index.d.ts b/frontend/src/@types/index.d.ts index 1ae0a626e..2f84f9695 100644 --- a/frontend/src/@types/index.d.ts +++ b/frontend/src/@types/index.d.ts @@ -55,3 +55,19 @@ declare module 'moment' { export default moment; } +/** + * Browser-vendor specific properties on the global document object + */ +interface Document { + msHidden: any; + webkitHidden: any; +} + +interface Window { + /** + * Redirects the browser to a new URL. + * + * @param url URL to redirect to. + */ + redirect: (url: string) => void; +} diff --git a/frontend/src/app/NavigationBreak.ts b/frontend/src/app/NavigationBreak.ts new file mode 100644 index 000000000..fa9e5e8c0 --- /dev/null +++ b/frontend/src/app/NavigationBreak.ts @@ -0,0 +1,36 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + + import Marionette from 'backbone.marionette'; + import App from 'models/app'; + + class NavigationBreak extends Marionette.Object { + /** + * Backbone initialize + */ + public initialize() { + App.eventBus.reply('app:navigation:allow', this.allow); + App.eventBus.reply('app:navigation:disallow', this.disallow); + } + + /** + * Allows navigation + */ + public allow() { + window.onbeforeunload = null; + } + + /** + * Breaks navigation request + */ + public disallow() { + window.onbeforeunload = () => true; + } + } + + export default NavigationBreak; diff --git a/frontend/src/app/app.js b/frontend/src/app/app.js index ab9f2340b..462bf1f8b 100644 --- a/frontend/src/app/app.js +++ b/frontend/src/app/app.js @@ -6,6 +6,8 @@ import EventBus from 'app/vent'; import Application from 'app/core'; import AppView from 'views/app'; import Html5NotificationModule from 'modules/notification/html5-notification'; +import 'app/faviconBadge.ts'; +import 'app/pageAutoreload.ts'; import 'lib/jquery.i18n/jquery.i18n.extend'; import 'lib/saito/backbone.initHelper'; diff --git a/frontend/src/app/faviconBadge.ts b/frontend/src/app/faviconBadge.ts new file mode 100644 index 000000000..c61590fbf --- /dev/null +++ b/frontend/src/app/faviconBadge.ts @@ -0,0 +1,73 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import Marionette from 'backbone.marionette'; +import App from 'models/app.js'; +// tslint:disable-next-line +const Favico = require('favico.js'); + +/** + * Shows content on favicon + */ +class Favicon extends Marionette.Object { + /** + * Bb initialize + */ + public initialize() { + App.eventBus.reply('app:favicon:badge', this.set, this); + } + + /** + * Shows a string + */ + private set(text: string) { + const favicon = new Favico({ + animation: 'fade', + type: 'rectangle', + }); + + /// checkup browser support for hidden tab + let hidden: string; + let visibilityChange: string; + if (typeof document.hidden !== 'undefined') { + hidden = 'hidden'; + visibilityChange = 'visibilitychange'; + } else if (typeof document.msHidden !== 'undefined') { + hidden = 'msHidden'; + visibilityChange = 'msvisibilitychange'; + } else if (typeof document.webkitHidden !== 'undefined') { + hidden = 'webkitHidden'; + visibilityChange = 'webkitvisibilitychange'; + } + + /// browser can't detect a hidden tab + if (hidden === undefined) { + return; + } + + /// tab isn't hidden + if (!document[hidden]) { + return; + } + + /// set badge + favicon.badge(text); + + /// remove badge on page activation + const handleVisibilityChange = () => { + if (!document[hidden]) { + favicon.reset(); + } + }; + if (typeof document.addEventListener !== 'undefined') { + document.addEventListener(visibilityChange, handleVisibilityChange, false); + } + } +} + +export default new Favicon(); diff --git a/frontend/src/app/pageAutoreload.ts b/frontend/src/app/pageAutoreload.ts new file mode 100644 index 000000000..db627ca09 --- /dev/null +++ b/frontend/src/app/pageAutoreload.ts @@ -0,0 +1,62 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import Marionette from 'backbone.marionette'; +import App from 'models/app.js'; + +/** + * Sets up and manages autoreloading the current page. + */ +class Autoreload extends Marionette.Object { + /** + * Autoreload timer + */ + private autoPageReloadTimer: number|null = null; + + /** + * Bb initialize + */ + public initialize() { + App.eventBus.reply('app:autoreload:start', this.initAutoreload, this); + App.eventBus.reply('app:autoreload:stop', this.breakAutoreload, this); + } + + /** + * Sets up autorelad-timer + * + * @param period Autoreload time in seconds + */ + private initAutoreload(period: number) { + if (typeof period !== 'number') { + return; + } + if (period < 60) { + period = 600; + } + const url = window.location.pathname; + const reload = () => { + window.location.href = url; + }; + + this.breakAutoreload(); + this.autoPageReloadTimer = window.setTimeout(reload, period * 1000); + } + + /** + * Breaks autoreload by clearing the autoreload-timer + */ + private breakAutoreload() { + if (!this.autoPageReloadTimer) { + return; + } + window.clearTimeout(this.autoPageReloadTimer); + this.autoPageReloadTimer = null; + } +} + +export default new Autoreload(); diff --git a/frontend/src/lib/saito/CakeFormErrorView.ts b/frontend/src/lib/saito/CakeFormErrorView.ts index e21f29308..afec0ac6e 100644 --- a/frontend/src/lib/saito/CakeFormErrorView.ts +++ b/frontend/src/lib/saito/CakeFormErrorView.ts @@ -1,11 +1,27 @@ -import { Model } from 'backbone'; +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import { Collection, Model } from 'backbone'; import { View } from 'backbone.marionette'; import * as _ from 'underscore'; -class CakeFormErrorView extends View { - +/** + * Renders Cake validation errors on a form + * + * Expects an collection with errors. Error model should contain: + * - source - The object with... + * - field - CSS-selector for the field. + * - title - The Error message. + */ +export default class CakeFormErrorView extends View { public constructor(options: any = {}) { _.defaults(options, { + collection: new Collection(), template: _.noop, }); @@ -16,26 +32,81 @@ class CakeFormErrorView extends View { this.reset(); this.collection.each((error) => { - const element = error.get('meta').field; + const element = error.get('source').field; const msg = error.get('title'); - this.form(element, msg); + this.renderErrors(element, msg); }); } /** - * Render notification as form field error. + * Removes all error messages */ - private form(element, msg) { - const tpl = _.template('
<%= message %>
'); - this.$(element).addClass('is-invalid') - .after(tpl({ message: msg })); - } - - private reset() { + private reset(): void { this.$('.invalid-feedback').remove(); this.$('.is-invalid').removeClass('is-invalid'); } -} -export { CakeFormErrorView }; + /** + * Attach error message to input elements + * + * @param selector Selector for input field with errors. + * @param msg Error message to display. + */ + private renderErrors(selector: string, msg: string): void { + /// Add bootstrap compatible invalid selector in input fields. + const input = this.$(selector); + input.addClass('is-invalid'); + + const tpl = _.template('
<%- message %>
'); + + /// Appends the error msg at a dedicated place. + const dedicatedElement = this.findDedicatedElement(input); + if (dedicatedElement) { + dedicatedElement.html(tpl({ message: msg })); + + return; + } + + /// Just appends the error msg after the input field. + input.after(tpl({ message: msg })); + } + + /** + * Tries to find a dedicated element where to put the error message. + * + * Searches the input-field surrounding for a .vld-msg element. + * + * @param element HTML input element the error message belongs to. + */ + private findDedicatedElement(element: JQuery): JQuery | false { + let dedicatedElement: JQuery; + let level: number = 0; + let parent = element; + // We assume that the dedicated element isn't miles up in the DOM tree. + const maxLevel: number = 5; + while (level < maxLevel) { + parent = parent.parent(); + if (parent.get(0) === this.$el.get(0)) { + // Don't leave the form. + break; + } + if (parent.find(':input').length > 1) { + // We left the HTML subtree of the input. + break; + } + dedicatedElement = parent.find('.vld-msg'); + if (dedicatedElement.length) { + // Element was found. + break; + } + level++; + } + + if (dedicatedElement && dedicatedElement.length) { + return dedicatedElement; + } + + return false; + } +} diff --git a/frontend/src/lib/saito/markup.media.ts b/frontend/src/lib/saito/markup.media.ts index bb71347db..d0c2fe962 100644 --- a/frontend/src/lib/saito/markup.media.ts +++ b/frontend/src/lib/saito/markup.media.ts @@ -1,3 +1,11 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + import * as _ from 'underscore'; interface IPreFilter { @@ -118,7 +126,6 @@ class MarkupMultimedia { const patternImage = new RegExp('\\.(png|gif|jpg|jpeg|webp|svg)' + patternEnd, 'i'); const patternHtml = new RegExp('\\.(mp4|webm|m4v)' + patternEnd, 'i'); const patternAudio = new RegExp('\\.(m4a|ogg|mp3|wav|opus)' + patternEnd, 'i'); - const patternFlash = /\n" "Language-Team: \n" @@ -18,6 +18,55 @@ msgstr "" "X-Poedit-SearchPath-0: .\n" "X-Poedit-SearchPathExcluded-0: *i18n*\n" +#: frontend/src/modules/answering/answering.ts:111 +msgid "answer.btn.preview" +msgstr "Vorschau" + +#: frontend/src/modules/answering/buttons/SubmitButtonView.ts:14 +msgid "answer.btn.sbmt" +msgstr "Eintragen" + +#: frontend/src/modules/answering/views/CategorySelectVw.ts:22 +msgid "answer.cat.l" +msgstr "Kategorie" + +#: frontend/src/modules/answering/buttons/CiteBtnVw.ts:23 +msgid "answer.cite.t" +msgstr "Zitieren" + +#: frontend/src/modules/answering/editor/DraftsView.ts:108 +msgid "answer.draft.saved.t" +msgstr "Antwort ist als Entwurf gespeichert." + +#: frontend/src/modules/answering/editor/DraftsView.ts:109 +msgid "answer.draft.unsaved.t" +msgstr "Nicht alle Änderungen sind als Entwurf gespeichert." + +#: frontend/src/modules/answering/answering.ts:96 +msgid "answer.reply.t" +msgstr "Antwort verfassen" + +#: frontend/src/modules/answering/answering.ts:129 +#: frontend/src/modules/answering/answering.ts:236 +msgid "answer.subject.t" +msgstr "Betreff" + +#: frontend/src/modules/answering/answering.ts:129 +#: frontend/src/modules/answering/answering.ts:315 +msgid "answer.submit.e.t" +msgstr "Fehler: Beitrag wurde nicht gesichert" + +#: frontend/src/modules/answering/answering.ts:129 +#: frontend/src/modules/answering/answering.ts:272 +#: frontend/src/modules/answering/answering.ts:314 +msgid "api.generic.e.exp" +msgstr "Es konnte keine Verbindung zum Internet-Server hergestellt werden." + +#: frontend/src/modules/answering/answering.ts:129 +#: frontend/src/modules/answering/answering.ts:273 +msgid "api.generic.e.t" +msgstr "Fehler: Keine Verbindung zum Server" + #: frontend/src/modules/bookmarks/views/bookmarkItemVw.ts:23 #: frontend/src/modules/bookmarks/views/bookmarkItemVw.ts:88 msgid "bkm.delete.failure" @@ -65,7 +114,7 @@ msgid "bmk.isBookmarked" msgstr "Eintrag ist in den Lesezeichen" #: frontend/src/views/categoryChooserVw.ts:6 -#: frontend/src/views/categoryChooserVw.ts:26 +#: frontend/src/views/categoryChooserVw.ts:24 msgid "category.title.pl" msgstr "Kategorien" @@ -122,12 +171,12 @@ msgstr "Beitrag löschen" msgid "posting.helpful" msgstr "Beitrag als hilfreich markieren." -#: frontend/src/modules/answering/answering.ts:31 -#: frontend/src/modules/answering/answering.ts:164 +#: frontend/src/modules/answering/views/PreviewVw.ts:45 +#: frontend/src/modules/answering/views/PreviewVw.ts:63 msgid "preview.e.generic" msgstr "Fehler: Vorschau konnte nicht geladen werden." -#: frontend/src/modules/answering/preview.ts:43 +#: frontend/src/modules/answering/views/PreviewVw.ts:39 msgid "preview.t" msgstr "Vorschau" @@ -140,7 +189,7 @@ msgstr "Gestern" msgid "tree.delete.confirm" msgstr "Sicher?" -#: frontend/src/modules/answering/editor/MenuButton/MenuButtonUploadView.ts:23 +#: frontend/src/modules/answering/editor/MenuButton/MenuButtonUploadView.ts:24 msgid "upl.btn.insert" msgstr "Einfügen" @@ -177,9 +226,9 @@ msgstr "Noch nichts hochgeladen." msgid "upl.new.title" msgstr "Datei hier hineinziehen" -#: frontend/src/modules/answering/editor/MenuButton/MenuButtonUploadView.ts:23 +#: frontend/src/modules/answering/editor/MenuButton/MenuButtonUploadView.ts:26 #: frontend/src/modules/uploader/templates/uploaderAddTpl.html:7 -#: frontend/src/modules/answering/editor/MenuButton/MenuButtonUploadView.ts:65 +#: frontend/src/modules/answering/editor/MenuButton/MenuButtonUploadView.ts:68 #: frontend/src/modules/uploader/templates/uploaderAddTpl.html:8 msgid "upl.title" msgstr "Hochladen" @@ -188,14 +237,14 @@ msgstr "Hochladen" msgid "upl.title.pl" msgstr "Uploads" +#: src/Template/Users/view.ctp:325 +msgid "user.block.hours" +msgstr "{hours} Stunden" + #: frontend/src/modules/user/userVw.ts:14 msgid "user.recentposts.t" msgstr "Letzte Beiträge" -#~ msgid "user.block.hours" -#~ msgstr "{hours} Stunden" - -#~ msgid "prq.storage.warning" -#~ msgstr "" -#~ "Diese Web-Anwendung benötigt Cookies und localStorage. Bitte stellen Sie " -#~ "sicher, dass beides gegeben ist." +#, fuzzy +#~ msgid "answer.load.e.exp" +#~ msgstr "Es konnte keine Verbindung zum Internet-Server hergestellt werden." diff --git a/frontend/src/locale/en.po b/frontend/src/locale/en.po index 6e998d5fc..48d344866 100644 --- a/frontend/src/locale/en.po +++ b/frontend/src/locale/en.po @@ -18,6 +18,56 @@ msgstr "" "X-Poedit-SearchPath-0: .\n" "X-Poedit-SearchPathExcluded-0: *i18n*\n" +#: frontend/src/modules/answering/answering.ts:111 +msgid "answer.btn.preview" +msgstr "Preview" + +#: frontend/src/modules/answering/buttons/SubmitButtonView.ts:14 +msgid "answer.btn.sbmt" +msgstr "Submit" + +#: frontend/src/modules/answering/views/CategorySelectVw.ts:22 +msgid "answer.cat.l" +msgstr "Category" + +#: frontend/src/modules/answering/buttons/CiteBtnVw.ts:23 +msgid "answer.cite.t" +msgstr "Cite" + +#: frontend/src/modules/answering/editor/DraftsView.ts:108 +msgid "answer.draft.saved.t" +msgstr "Answer is saved as draft." + +#: frontend/src/modules/answering/editor/DraftsView.ts:109 +msgid "answer.draft.unsaved.t" +msgstr "Not all changes are saved as draft." + +#: frontend/src/modules/answering/answering.ts:96 +msgid "answer.reply.t" +msgstr "Write a Reply" + +#: frontend/src/modules/answering/answering.ts:129 +#: frontend/src/modules/answering/answering.ts:236 +msgid "answer.subject.t" +msgstr "Subject" + +#: frontend/src/modules/answering/answering.ts:129 +#: frontend/src/modules/answering/answering.ts:315 +msgid "answer.submit.e.t" +msgstr "Error: Posting Could Not Be Saved" + +#: frontend/src/modules/answering/answering.ts:129 +#: frontend/src/modules/answering/answering.ts:272 +#: frontend/src/modules/answering/answering.ts:314 +msgid "api.generic.e.exp" +msgstr "" +"Couldn't reach the internet server. Check your connection and try again." + +#: frontend/src/modules/answering/answering.ts:129 +#: frontend/src/modules/answering/answering.ts:273 +msgid "api.generic.e.t" +msgstr "Error: Couldn't Connect to Server" + #: frontend/src/modules/bookmarks/views/bookmarkItemVw.ts:23 #: frontend/src/modules/bookmarks/views/bookmarkItemVw.ts:88 msgid "bkm.delete.failure" @@ -65,7 +115,7 @@ msgid "bmk.isBookmarked" msgstr "Entry is bookmarked" #: frontend/src/views/categoryChooserVw.ts:6 -#: frontend/src/views/categoryChooserVw.ts:26 +#: frontend/src/views/categoryChooserVw.ts:24 msgid "category.title.pl" msgstr "Categories" @@ -122,12 +172,12 @@ msgstr "Delete Posting" msgid "posting.helpful" msgstr "Mark entry as helpful." -#: frontend/src/modules/answering/answering.ts:31 -#: frontend/src/modules/answering/answering.ts:164 +#: frontend/src/modules/answering/views/PreviewVw.ts:45 +#: frontend/src/modules/answering/views/PreviewVw.ts:63 msgid "preview.e.generic" msgstr "Error: Couldn't load preview." -#: frontend/src/modules/answering/preview.ts:43 +#: frontend/src/modules/answering/views/PreviewVw.ts:39 msgid "preview.t" msgstr "Preview" @@ -140,7 +190,7 @@ msgstr "yesterday" msgid "tree.delete.confirm" msgstr "Are sure?" -#: frontend/src/modules/answering/editor/MenuButton/MenuButtonUploadView.ts:23 +#: frontend/src/modules/answering/editor/MenuButton/MenuButtonUploadView.ts:24 msgid "upl.btn.insert" msgstr "Insert" @@ -177,9 +227,9 @@ msgstr "No uploads yet." msgid "upl.new.title" msgstr "Drop File Here" -#: frontend/src/modules/answering/editor/MenuButton/MenuButtonUploadView.ts:23 +#: frontend/src/modules/answering/editor/MenuButton/MenuButtonUploadView.ts:26 #: frontend/src/modules/uploader/templates/uploaderAddTpl.html:7 -#: frontend/src/modules/answering/editor/MenuButton/MenuButtonUploadView.ts:65 +#: frontend/src/modules/answering/editor/MenuButton/MenuButtonUploadView.ts:68 #: frontend/src/modules/uploader/templates/uploaderAddTpl.html:8 msgid "upl.title" msgstr "Uploads" @@ -188,14 +238,16 @@ msgstr "Uploads" msgid "upl.title.pl" msgstr "Uploads" +#: src/Template/Users/view.ctp:325 +msgid "user.block.hours" +msgstr "{hours} hours" + #: frontend/src/modules/user/userVw.ts:14 msgid "user.recentposts.t" msgstr "Recent Postings" -#~ msgid "user.block.hours" -#~ msgstr "{hours} hours" - -#~ msgid "prq.storage.warning" +#, fuzzy +#~ msgid "answer.load.e.exp" #~ msgstr "" -#~ "This web-application depends on Cookies and localStorage. Please make " -#~ "those available in your browser." +#~ "Couldn't reach to the internet server. Check that you're connected and " +#~ "try again." diff --git a/frontend/src/models/PostingMdl.ts b/frontend/src/models/PostingMdl.ts new file mode 100644 index 000000000..404e56617 --- /dev/null +++ b/frontend/src/models/PostingMdl.ts @@ -0,0 +1,32 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import { JsonApiModel } from 'lib/backbone/jsonApi'; +import * as _ from 'underscore'; + +export default class PostingModel extends JsonApiModel { + /** + * Constructor + * + * @param options Bb options + */ + public constructor(defaults: any = {}, options: any = {}) { + _.defaults(defaults, { + category_id: undefined, + id: undefined, + pid: undefined, + subject: '', + text: '', + }); + super(defaults, options); + } + + public isRoot(): boolean { + return !this.get('pid'); + } +} diff --git a/frontend/src/models/appStatus.js b/frontend/src/models/appStatus.js index d4b6230dc..50e0245b1 100644 --- a/frontend/src/models/appStatus.js +++ b/frontend/src/models/appStatus.js @@ -1,3 +1,11 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + import _ from 'underscore'; import Backbone from 'backbone'; import cakeRest from 'lib/saito/backbone.cakeRest'; @@ -13,7 +21,7 @@ const AppStatusModel = Backbone.Model.extend({ this.methodToCakePhpUrl.read = 'status/'; }, - start: function() { + start: function(immediate = true) { this._setWebroot(this.settings.get('webroot')); // Don't use SSE by default on unknown server-configs /* @@ -22,7 +30,8 @@ const AppStatusModel = Backbone.Model.extend({ return; } */ - this._poll(); + // slow polling just to keep the user online + this._poll(90000, 180000, immediate); }, _setWebroot: function(webroot) { @@ -49,23 +58,31 @@ const AppStatusModel = Backbone.Model.extend({ }, /** - * Requests status by polling with classic http request + * Requests status by polling with classic HTTP request. + * + * Adjust to sane values taking UserOnlineTable::setOnline() into account, so + * that users wont get set offline. Default current default values were great + * for a shoutbox like feature with immediate and reasonbly fast polling. + * + * The time between requests increases if the data from the server is + * unchanged. * + * @param {int} refreshTimeBase - minimum and start time between request in ms + * @param {int} refreshTimeMax - maximum time between requests in ms + * @param {bool} immediate - first request immediately or after refreshTimeBase * @private */ - _poll: function() { + _poll: function(refreshTimeBase = 10000, refreshTimeMax = 90000, immediate = true) { var resetRefreshTime, updateAppStatus, setTimer, timerId, stopTimer, - refreshTimeAct, - refreshTimeBase = 10000, - refreshTimeMax = 90000; + refreshTimeAct; stopTimer = function() { if (timerId !== undefined) { - clearTimeout(timerId); + window.clearTimeout(timerId); } }; @@ -75,7 +92,7 @@ const AppStatusModel = Backbone.Model.extend({ }; setTimer = function() { - timerId = setTimeout( + timerId = window.setTimeout( updateAppStatus, refreshTimeAct ); @@ -102,7 +119,10 @@ const AppStatusModel = Backbone.Model.extend({ } ); - updateAppStatus(); + if (immediate) { + updateAppStatus(); + } + resetRefreshTime(); setTimer(); } diff --git a/frontend/src/modules/answering/Draft.ts b/frontend/src/modules/answering/Draft.ts new file mode 100644 index 000000000..6d074f9ce --- /dev/null +++ b/frontend/src/modules/answering/Draft.ts @@ -0,0 +1,202 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import { Model } from 'backbone'; +import { View } from 'backbone.marionette'; +import { debounce, defaults, template, throttle } from 'underscore'; +import AnswerModel from './models/AnswerModel'; + +interface ILongTimers { early: number; long: number; } + +/** + * Main timer for sending drafts to the server. + */ +class LongTimer { + protected fct: () => void; + + protected lastRun: number; + + protected running: boolean; + + protected timerId: number; + + protected timers: ILongTimers; + + public constructor(fct: () => void, timers: ILongTimers) { + this.fct = fct; + this.timers = timers; + } + + /** + * Starts the timer. + */ + public start() { + if (this.running) { + return; + } + this.running = true; + this.timerId = window.setTimeout(() => { this.run(); }, this.timers.long); + } + + /** + * Ends the timer early. + */ + public early() { + if ((this.lastRun !== null) && ((Date.now() - this.lastRun) < this.timers.early)) { + return; + } + + this.run(); + this.stop(); + } + + /** + * Stops the timer. + */ + public stop() { + if (!this.running) { + return; + } + + this.running = false; + window.clearTimeout(this.timerId); + } + + /** + * Runs the the callback if the timer rings. + */ + private run() { + this.fct(); + this.lastRun = Date.now(); + } +} + +/** + * Model for drafts + */ +class DraftModel extends AnswerModel { + /** + * Ma initializer + * + * @param options options + */ + public initialize(options) { + this.saitoUrl = 'drafts/'; + } +} + +type DraftTimers = { debounce: number } & ILongTimers; + +/** + * View for drafts + */ +export default class DraftView extends View { + /** Holds the main timer */ + public longTimer: LongTimer; + /** Short timer to debounce the main timer */ + public shortTimer: () => void; + /** + * Enables or disables the the sending of drafts. + * + * Default: true. + */ + private enabled: boolean; + + public constructor(options: object = {}) { + const deflt: { timers: DraftTimers } & any = { + attributes: { 'data-shpid': 9 }, + className: 'draft-status shp', + model: new DraftModel(), + modelEvents: { + change: 'handleModelChange', + }, + tagName: 'span', + template: template(` + + + `), + timers: { + debounce: 4000, + early: 5000, + long: 30000, + }, + ui: { + saved: '.js-saved', + unsaved: '.js-unsaved', + }, + }; + defaults(options, deflt); + + super(options); + + this.enabled = true; + } + + public onRender() { + this.showSaved(); + + const timers: DraftTimers = this.getOption('timers'); + this.longTimer = new LongTimer(() => { this.send(); }, timers); + this.shortTimer = debounce(() => { this.longTimer.early(); }, timers.debounce); + } + + /** + * Enables the sending of drafts. + */ + public enable(): void { + this.enabled = true; + } + + /** + * Disables the sending of drafts. + */ + public disable(): void { + this.enabled = false; + } + + private handleModelChange() { + this.showUnsaved(); + this.longTimer.start(); + this.shortTimer(); + } + + private send() { + if (!this.enabled) { + return; + } + if (!this.model.get('id')) { + // Do only when creating a new draft, not on an existing one. + if (!this.model.get('subject') && !this.model.get('text')) { + // Don't send empty data. + return; + } + } + this.model.save(null, { + success: (model, response, options) => { this.showSaved(); }, + }); + } + + private showUnsaved() { + this.getUI('saved').hide(); + this.getUI('unsaved').show(); + } + + private showSaved() { + this.getUI('unsaved').hide(); + this.getUI('saved').show(); + } +} + +export { DraftModel, DraftView }; diff --git a/frontend/src/modules/answering/Meta.ts b/frontend/src/modules/answering/Meta.ts new file mode 100644 index 000000000..41cd75d2c --- /dev/null +++ b/frontend/src/modules/answering/Meta.ts @@ -0,0 +1,48 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import { JsonApiModel } from 'lib/backbone/jsonApi'; + +interface IAnswerMetaData { + draft?: { + id: number, + subject: string|null, + text: string|null, + }; + editor: { + buttons: any[], + categories: any[], + smilies: any[], + }; + meta: { + autoselectCategory: boolean, + info: string, + isEdit: boolean, + last: string, + quoteSymbol: string, + subject?: string, + text?: string|null, + subjectMaxLength: number, + }; + posting: object; +} + +class MetaModel extends JsonApiModel { + public attributes: IAnswerMetaData; + + /** + * Ma initializer + * + * @param options options + */ + public initialize(options: object = {}) { + this.saitoUrl = 'postingmeta/'; + } +} + +export { MetaModel }; diff --git a/frontend/src/modules/answering/SubjectInputView.ts b/frontend/src/modules/answering/SubjectInputView.ts deleted file mode 100644 index afa18767e..000000000 --- a/frontend/src/modules/answering/SubjectInputView.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Model } from 'backbone'; -import { View } from 'backbone.marionette'; -import App from 'models/app'; -import * as _ from 'underscore'; - -class SubjectInputModel extends Model { - public defaults() { - return { - length: 0, - max: null, - remaining: null, - value: '', - }; - } - - public initialize() { - this.listenTo(this, 'change:value', this.updateMeta); - const max = App.settings.get('subject_maxlength'); - if (!max) { - throw new Error('No subject_maxlength in App settings.'); - } - this.set('max', App.settings.get('subject_maxlength')); - this.updateMeta(); - } - - private updateMeta() { - // Should be _.chars(subject) for counting multibyte chars as one char only, but - // maxlength attribute also counts all bytes in multibyte char. - // This shortends the allowed subject by one byte-char per multibyte char, - // but we can life with that. - this.set('length', this.get('value').length); - this.set('remaining', this.get('max') - this.get('length')); - this.set('percentage', this.get('length') === 0 ? 0 : this.get('length') / this.get('max') * 100); - } -} - -enum ProgressBarState { - notFull = 'bg-success', - soonFull = 'bg-warning', - full = 'bg-danger', -} - -class SubjectInputView extends View { - public constructor(options: any = {}) { - _.defaults(options, { - events: { - // 'input' doesnt catch a keypress when full and 'keypress' - // doesn't catch paste/delete - 'input @ui.input': 'handleInput', - 'keypress @ui.input': 'handleMax', - }, - modelEvents: { - 'change:value': 'update', - }, - ui: { - counter: '.postingform-subject-count', - input: 'input', - progressBar: '.js-progress', - }, - }); - super(options); - } - - public initialize() { - this.model = new SubjectInputModel(); - this.handleInput(); // initialize non-empty input field (edit posting) - this.update(); - } - - private handleInput() { - this.model.set('value', this.getUI('input').val()); - } - - private update() { - this.updateCounter(); - this.updateProgressBar(); - } - - private updateCounter() { - this.getUI('counter').html(this.model.get('remaining')); - } - - private updateProgressBar() { - const $progress = this.getUI('progressBar'); - $progress.css('width', this.model.get('percentage') + '%'); - - const remaining = this.model.get('remaining'); - if (remaining === 0) { - this.handleMax(); - return; - } - const cssClass = (remaining < 20) ? ProgressBarState.soonFull : ProgressBarState.notFull; - this.setProgress(cssClass); - } - - private handleMax() { - if (this.model.get('percentage') !== 100) { - return; - } - this.setProgress(ProgressBarState.full); - _.delay(_.bind(this.setProgress, this), 250, ProgressBarState.soonFull); - } - - private setProgress(cssClass: ProgressBarState) { - const $progress = this.getUI('progressBar'); - Object.keys(ProgressBarState).forEach((key) => { - $progress.removeClass(ProgressBarState[key]); - }); - $progress.addClass(cssClass); - } -} - -export { SubjectInputView }; diff --git a/frontend/src/modules/answering/answering.ts b/frontend/src/modules/answering/answering.ts index f59b5807e..7aceec626 100644 --- a/frontend/src/modules/answering/answering.ts +++ b/frontend/src/modules/answering/answering.ts @@ -1,309 +1,333 @@ -import { Collection, Model } from 'backbone'; +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import { Model } from 'backbone'; import { View } from 'backbone.marionette'; -import * as Radio from 'backbone.radio'; import * as $ from 'jquery'; import 'jquery-textrange'; -import { CakeFormErrorView } from 'lib/saito/CakeFormErrorView'; +import CakeFormErrorView from 'lib/saito/CakeFormErrorView'; import 'lib/saito/jquery.scrollIntoView'; import App from 'models/app'; -import { PreviewView } from 'modules/answering/preview'; -import { SubjectInputView } from 'modules/answering/SubjectInputView'; import * as _ from 'underscore'; -import { unescapeHTML } from 'underscore.string'; -import { PostingModel } from '../posting/models/PostingModel'; -import { EditCountdownView } from './editCountdown'; +import { SpinnerView } from 'views/SpinnerView'; +import { NotificationType } from '../notification/notification'; +import CiteBtnVw from './buttons/CiteBtnVw'; +import SubmitButtonVw from './buttons/SubmitButtonView'; +import { DraftModel, DraftView } from './Draft'; import { EditorView } from './editor/EditorView'; +import { MetaModel } from './Meta'; +import AnswerModel from './models/AnswerModel'; +import CategorySelect from './views/CategorySelectVw'; +import PreviewView from './views/PreviewVw'; +import SubjectInputVw from './views/SubjectInputVw'; -class AnsweringView extends View { - /** answering form was loaded via ajax request */ - private ajax: boolean; - - private requestUrl: string; - - private rendered: boolean; +export default class AnsweringView extends View { + private errorVw: View; - private answeringForm: any; + private loaded: boolean; private sendInProgress: boolean; - private errorVw: View; - - private subjectView: View; - - /** answering form is in posting which is inline-opened */ - private parentThreadline: Model; + private metaModel: MetaModel; public constructor(options: any = {}) { _.defaults(options, { + childViewEvents: { + 'answer:send:submit': 'onSubmit', + 'answer:validation:error': 'onAnswerValidationError', + }, events: { - 'click .btn-primary': '_send', 'click .js-btnCite': '_handleCite', - 'click .js-btnPreview': '_showPreview', - 'click .js-btnPreviewClose': '_closePreview', - 'keypress .js-subject': '_onKeyPressSubject', + 'click .js-btnPreview': 'showPreview', + 'click .js-btnPreviewClose': 'closePreview', + }, + meta: new MetaModel(), + model: new AnswerModel(), + modelEvents: { + change: 'onAnswerModelChange', }, - /** - * same model as the parent PostingView - */ - model: null, regions: { - preview: '.preview-wrapper', + category: '.js-category', + cite: '.js-cite', + drafts: '.js-draft', + editor: '.js-editor', + preview: '.js-preview', + spinner: '.js-spinner', + subject: '.js-subject', + submitBtn: '.js-btn-primary', + }, + template: _.template(` +
+
+
+ <% if (pid) { %> +
+
+
+ +
+

<%- $.i18n.__('answer.reply.t') %>

+
+
+
+ <% } %> +
+
+ +
+
+
+ `), + ui: { + draft: 'postingform-info-draft', + form: 'form', + info: '.postingform-info-editor', + last: '.last', }, - template: _.noop, }); super(options); } public initialize(options) { - this.ajax = _.isUndefined(options.ajax) ? true : false; - this.answeringForm = false; - this.parentThreadline = options.parentThreadline || null; - this.rendered = false; - this.requestUrl = null; + this.loaded = false; this.sendInProgress = false; + this.metaModel = this.getOption('meta'); - this.requestUrl = App.settings.get('webroot') + - 'entries/add/' + this.model.get('id'); + /// init Cake Form Error View + this.errorVw = new CakeFormErrorView({ el: this.$el }); + } - // focus can only be set after element is visible in page - this.listenTo(App.eventBus, 'isAppVisible', this._focusSubject); + public onBeforeDestroy() { + this.errorVw.destroy(); } public onRender() { - // create new thread on /entries/add - if (this.ajax === false) { - this._onFormReady(); - } else if (this.answeringForm === false) { - this._requestAnsweringForm(); - } else if (this.rendered === false) { - this.rendered = true; - this.$el.html(this.answeringForm); - _.defer(() => { - this._postRendering(); - }); - } else { - App.eventBus.trigger('change:DOM'); + if (this.loaded) { + return; } - return this; - } - public onBeforeDestroy() { - if (this.errorVw) { - this.errorVw.destroy(); - } - this.subjectView.destroy(); - } + this.showChildView('spinner', new SpinnerView()); - private _disable() { - this.$('.btn.btn-primary').attr('disabled', 'disabled'); - } + const success = (model: MetaModel) => this.triggerMethod('answering:load:success', model); + + if (this.metaModel.isEmpty()) { + // Send id to identify an edit of that posting. + this.metaModel.set('id', this.model.get('id')); + this.metaModel.fetch({ + // Send pid to find potential drafts. + data: { pid: this.model.get('pid') }, + error: () => this.triggerMethod('answering:load:error'), + success, + }); + + return; + } - private _enable() { - this.$('.btn.btn-primary').removeAttr('disabled'); + success(this.metaModel); } /** - * Quote parent posting + * Handles successful form data load and builds the form in regions * - * @private + * @param data request data */ - private _handleCite() { - // Without defering a click on a selection which deselects (and should therefore be empty) - // still holds the previously selected text. - _.defer(() => { - let text = window.getSelection().toString(); - if (text !== '') { - text = App.settings.get('quote_symbol') + ' ' + text; - } else { - text = unescapeHTML(this.$('.js-btnCite').data('text')); - } + private onAnsweringLoadSuccess(model: MetaModel) { + this.loaded = true; + const data = model.attributes; - Radio.channel('editor').request('insert:text', text); - }); - } + this.model.set(data.posting); - private _onKeyPressSubject(event) { - // intercepts sending to form's action url when inline answering - if (event.keyCode === 13) { - this._send(event); - } - } + /// init drafts (no drafts for edits) + const isEdit: boolean = !!this.model.get('id'); + if (!isEdit) { + const draftModel = new DraftModel({ pid: this.model.get('pid') }); - private _showPreview(event) { - const form = event.currentTarget.form; - if (!(this.checkFormValidity(form))) { - return; + // update draft when answering input changes + draftModel.listenTo(this.model, 'change', () => { + draftModel.set(this.model.pick('subject', 'text')); + }); + + if (data.draft) { + const { id, subject, text} = data.draft; + this.model.set({ subject, text }); + // draft model is going to update an existing draft + draftModel.set('id', id); + } + + this.showChildView('drafts', new DraftView({ model: draftModel })); } - this.$('.preview-wrapper').slideDown('fast'); + /// init submit-button + this.showChildView('submitBtn', new SubmitButtonVw({ model: this.model })); - if (!this.getChildView('preview')) { - this.showChildView('preview', new PreviewView()); + /// init category select + if (this.model.isRoot()) { + this.showChildView('category', new CategorySelect({ + autoselectCategory: data.meta.autoselectCategory, + categories: data.editor.categories, + model: this.model, + })); } - const preview = this.getChildView('preview'); - if (!this.errorVw) { - this.errorVw = new CakeFormErrorView({ - collection: new Collection(), - el: form, + /// init editor textfield + this.showChildView( + 'editor', + new EditorView({ + buttons: data.editor.buttons, + model: this.model, + smilies: data.editor.smilies, + }), + ); + + /// init preview + const previewView = new PreviewView(); + this.showChildView('preview', previewView); + previewView.listenTo(this, 'answer:preview:show', previewView.onShow); + previewView.listenTo(this, 'answer:preview:hide', previewView.onHide); + + /// init subject-field + const subjectView = new SubjectInputVw({ + max: data.meta.subjectMaxLength, + model: this.model, + placeholder: data.meta.subject || $.i18n.__('answer.subject.t'), + }); + this.showChildView('subject', subjectView); + subjectView.listenTo(this, 'answering:form:rendered', subjectView.focus); + + /// init cite button + if (!this.model.isRoot() && data.meta.text) { + const citeModel = new Model({ + quoteSymbol: data.meta.quoteSymbol, + text: data.meta.text, }); + this.showChildView('cite', new CiteBtnVw({ model: citeModel })); } - preview.model.save( - { - category_id: this.$('#category-id').val(), - html: null, - pid: this.$('input[name=pid]').val(), - subject: this.$('.js-subject').val(), - text: this.$('textarea').val(), - }, - { - error: (model, response, options) => { - if (!('errors' in response.responseJSON)) { - App.eventBus.trigger('notification', { - message: $.i18n.__('preview.e.generic'), - type: 'error', - }); - - return; - } - - this.errorVw.collection.reset(response.responseJSON.errors); - }, - success: (mode, response, options) => { - this.errorVw.collection.reset(); - }, - }, - ).always(() => { - this.errorVw.render(); - }); - } + /// set editor-info + if (data.meta.info) { + this.getUI('info').prepend(data.meta.info); + } - private _closePreview(event) { - event.preventDefault(); - this.$('.preview-wrapper').slideUp('fast'); - } + /// add additional elements to "last" column in footer + if (data.meta.last) { + this.getUI('last').prepend(data.meta.last); + } - private _requestAnsweringForm() { - $.ajax({ - // don't append timestamp to requestUrl or Cake's - // SecurityComponent will blackhole the ajax call in _sendInline() - cache: true, - success: (data) => { - this.answeringForm = data; - this.render(); - }, - url: this.requestUrl, - }); + this.detachChildView('spinner'); + this.getUI('form').show(); + + this.triggerMethod('answering:form:rendered', data); + App.eventBus.trigger('change:DOM'); } - private _postRendering() { - this._focusSubject(); - this._onFormReady(); - - // On a fast server the answering form might be inserted - // before the slide down animation from postingSlider is even - // finished. So we just wait for a little time here. - // @bogus, Fix/obsolete when implementing a new posting form. - _.delay(() => { - this.$el.scrollIntoView('bottom'); - }, 300); + /** + * Handles error if loading of metadata for form failes + */ + private onAnsweringLoadError() { + App.eventBus.trigger('notification', { + message: $.i18n.__('api.generic.e.exp'), + title: $.i18n.__('api.generic.e.t'), + type: NotificationType.error, + }); } /** - * Initialize editor - * - * @param selector selector for region with textarea + * Submit data to server */ - private initEditor(selector: string) { - this.addRegion('editor', selector); + private onSubmit() { + if (this.sendInProgress) { + return; + } - const el = this.$(selector); - el.prepend('
'); - el.prepend('
'); + this.disableAnswering(); - // @todo - // - change autosize on posting change - // - attach to answering itself - const model = new PostingModel(); - const editor = new EditorView({ el, model }); + if (!this.checkFormValidity()) { + this.enableAnswering(); - this.showChildView('editor', editor); - editor.render(); - } + return; + } - private _onFormReady() { - this.initEditor('.js-editor'); - this.subjectView = new SubjectInputView({ el: this.$('.postingform-subject-wrapper') }); + // @todo @sm more concreate timeout handling + this.model.save(null, { + error: () => this.triggerMethod('answering:send:error'), + success: (model, response, options) => { + /// handled errors + if ('errors' in response) { + this.triggerMethod('answer:validation:error', response.errors); - const data = this.$('.js-data').data(); - const action = _.property(['meta', 'action'])(data); - if (action === 'edit') { - this.model.set('time', data.entry.time); - this._addCountdown(); - } - App.eventBus.trigger('change:DOM'); - } + return; + } - /** - * Adds countdown to Submit button - * - * @private - */ - private _addCountdown() { - const $submitButton = this.$('.js-btn-primary'); - const editCountdown = new EditCountdownView({ - done: 'disable', - editPeriod: App.settings.get('editPeriod'), - el: $submitButton, - model: this.model, + /// success + App.eventBus.request('app:navigation:allow'); + this.trigger('answering:send:success', model); + }, }); } - private _focusSubject() { - // focus is broken in Mobile Safari iOS 8 - const iOS = window.navigator.userAgent.match('iPad|iPhone'); - if (iOS) { - return; - } + private onAnsweringSendError() { + App.eventBus.trigger('notification', { + message: $.i18n.__('api.generic.e.exp'), + title: $.i18n.__('answer.submit.e.t'), + type: NotificationType.error, + }); - this.$('.postingform input[type=text]:first').focus(); + this.enableAnswering(); } - private _send(event) { - if (this.sendInProgress) { - event.preventDefault(); - return; - } - this.sendInProgress = true; - if (this.parentThreadline) { - this._sendInline(event); - } else { - this._sendRedirect(event); - } - } + /** + * Display validation errors + * + * @param errors errors object with validation errors from server + */ + private onAnswerValidationError(errors?) { + this.errorVw.collection.reset(errors); + this.errorVw.render(); - private _sendRedirect(event) { - event.preventDefault(); - const button: HTMLButtonElement & any = this.$('.btn-primary')[0]; - if (!this.checkFormValidity(button.form)) { - this.sendInProgress = false; - return; - } - button.disabled = true; - button.form.submit(); + this.enableAnswering(); } /** * Check form validity and trigger error messages in browser */ - private checkFormValidity(form: HTMLFormElement): boolean { + private checkFormValidity(): boolean { + const form: HTMLFormElement & any = this.getUI('form')[0]; + if (form.checkValidity()) { return true; } - // we can't trigger JS validation messages via form.submit() - // so we create and click this hidden dummy submit button + /// trigger browser native validation messages to be displayed to the user const handle = 'js-checkValidityDummy'; let checkValidityDummy = this.$(handle); if (!checkValidityDummy.length) { @@ -317,44 +341,67 @@ class AnsweringView extends View { return false; } - private _sendInline(event) { - event.preventDefault(); + /** + * Called when the posting model changes + */ + private onAnswerModelChange() { + /// warn user on input when navigating away + const fields: string[] = ['subject', 'text']; + const found = fields.find((field) => !!this.model.get(field)); + const state: string = found ? 'disallow' : 'allow'; + App.eventBus.request('app:navigation:' + state); + } - const data = this.$('#EntryAddForm').serialize(); + /** + * Initiates the preview + */ + private showPreview() { + if (!(this.checkFormValidity())) { + return; + } - const success = (responseData) => { - this.model.set({ isAnsweringFormShown: false }); - if (this.parentThreadline !== null) { - this.parentThreadline.set('isInlineOpened', false); - } - App.eventBus.trigger('newEntry', { - id: responseData.id, - isNewToUser: true, - pid: this.model.get('id'), - tid: responseData.tid, - }); - }; - - const fail = _.bind(function(jqXHR, text) { - this.sendInProgress = false; - this._enable(); - App.eventBus.trigger('notification', { - message: jqXHR.responseText, - title: text, - type: 'error', - }); - }, this); + this.triggerMethod('answer:preview:show', this.model); + } + + /** + * Closes the preview + */ + private closePreview() { + this.triggerMethod('answer:preview:hide'); + } - const disable = _.bind(this._disable, this); + /** + * Enables the answering form for the user + * + * Usually after a form-submit failed due to validation-/request-error + */ + private enableAnswering() { + const submitBtn = this.getChildView('submitBtn') as SubmitButtonVw; + submitBtn.enable(); - $.ajax({ - beforeSend: disable, - data, - dataType: 'json', - type: 'POST', - url: this.requestUrl, - }).done(success).fail(fail); + const drafts = this.getChildView('drafts') as DraftView; + if (drafts) { + drafts.enable(); + } + + this.sendInProgress = false; } -} -export { AnsweringView }; + /** + * Disables answering form for the user + * + * Usually after the form is submitting and the request is in progress + */ + private disableAnswering() { + this.sendInProgress = true; + + const submitBtn = this.getChildView('submitBtn') as SubmitButtonVw; + submitBtn.disable(); + + const drafts = this.getChildView('drafts') as DraftView; + if (drafts) { + drafts.disable(); + } + } + +} diff --git a/frontend/src/modules/answering/buttons/CiteBtnVw.ts b/frontend/src/modules/answering/buttons/CiteBtnVw.ts new file mode 100644 index 000000000..ef0dd9415 --- /dev/null +++ b/frontend/src/modules/answering/buttons/CiteBtnVw.ts @@ -0,0 +1,50 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import { Model } from 'backbone'; +import { View } from 'backbone.marionette'; +import * as Radio from 'backbone.radio'; +import * as _ from 'underscore'; +import { unescapeHTML } from 'underscore.string'; + +export default class CiteBtn extends View { + public constructor(options: any = {}) { + _.defaults(options, { + className: 'form-group', + events: { + 'click button': 'onCite', + }, + template: _.template(` + + `), + }); + super(options); + } + + /** + * Quote parent posting + * + * @private + */ + private onCite() { + // Without defering a click on a selection which deselects (and should therefore be empty) + // still holds the previously selected text. + _.defer(() => { + let text = window.getSelection().toString(); + if (text !== '') { + text = this.model.get('quoteSymbol') + ' ' + text; + } else { + text = unescapeHTML(this.model.get('text')); + } + + Radio.channel('editor').request('insert:text', text); + }); + } +} diff --git a/frontend/src/modules/answering/editCountdown.ts b/frontend/src/modules/answering/buttons/EditCountdownBtnView.ts similarity index 81% rename from frontend/src/modules/answering/editCountdown.ts rename to frontend/src/modules/answering/buttons/EditCountdownBtnView.ts index 770e8f9a2..57ceb5daf 100644 --- a/frontend/src/modules/answering/editCountdown.ts +++ b/frontend/src/modules/answering/buttons/EditCountdownBtnView.ts @@ -1,11 +1,20 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + import { Model } from 'backbone'; import { View } from 'backbone.marionette'; import * as $ from 'jquery'; import 'jQuery-tinyTimer/jquery.tinytimer'; +import App from 'models/app'; import moment from 'moment'; import * as _ from 'underscore'; -class EditCountdownView extends View { +export default class EditCountdownView extends View { /** * time in seconds how long the timer should count down */ @@ -21,10 +30,16 @@ class EditCountdownView extends View { super(options); } + /** + * Bb initialize + * + * @param options + * - startTime: Date - start time + */ public initialize(options) { - this.editEnd = moment(this.model.get('time')).unix() + - (options.editPeriod * 60); + this.editEnd = moment(options.startTime).unix() + (App.settings.get('editPeriod') * 60); // this.editEnd = moment().unix() + 5 ; // debug + if (moment().unix() > this.editEnd) { return; } @@ -80,5 +95,3 @@ class EditCountdownView extends View { } } - -export { EditCountdownView }; diff --git a/frontend/src/modules/answering/buttons/SubmitButtonView.ts b/frontend/src/modules/answering/buttons/SubmitButtonView.ts new file mode 100644 index 000000000..235bd7150 --- /dev/null +++ b/frontend/src/modules/answering/buttons/SubmitButtonView.ts @@ -0,0 +1,53 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import { Model } from 'backbone'; +import { View } from 'backbone.marionette'; +import { defaults, template } from 'underscore'; +import EditCountdownView from './EditCountdownBtnView'; + +export default class SubmitButtonView extends View { + constructor(options: any = {}) { + options = defaults(options, { + attributes: { + tabindex: 4, + type: 'button', + }, + className: 'btn btn-primary', + id: 'btn-primary', + tagName: 'button', + template: template(` + <%- $.i18n.__('answer.btn.sbmt') %> + `), + triggers: { + click: 'answer:send:submit', + }, + }); + super(options); + } + + public onRender() { + if (this.model.get('time')) { + const cd = new EditCountdownView({ + done: 'disable', + el: this.$el, + startTime: this.model.get('time'), + }); + } + } + + public enable() { + this.$el.removeAttr('disabled'); + this.$('span.spinner-border').remove(); + } + + public disable() { + this.$el.attr('disabled', 'disabled'); + this.$el.prepend(''); + } +} diff --git a/frontend/src/modules/answering/editor/EditorView.ts b/frontend/src/modules/answering/editor/EditorView.ts index f3a535d77..eab994552 100644 --- a/frontend/src/modules/answering/editor/EditorView.ts +++ b/frontend/src/modules/answering/editor/EditorView.ts @@ -1,3 +1,11 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + import * as autosize from 'autosize'; import { Collection, Model } from 'backbone'; import { View } from 'backbone.marionette'; @@ -11,11 +19,20 @@ class EditorView extends View { public constructor(options: any = {}) { _.defaults(options, { channelName: 'editor', + className: 'form-group', + events: { + 'input @ui.text': 'handleInput', + 'keypress @ui.input': 'handleInput', + }, regions: { buttons: '.js-editor-buttons', smilies: '.js-rgSmilies', }, - template: _.noop, + template: _.template(` +
+
+ + `), ui: { text: 'textarea', }, @@ -32,6 +49,8 @@ class EditorView extends View { } public onRender() { + // insert text on edit + this.getUI('text').val(this.model.get('text')); this.addMenuButtons(); autosize(this.getUI('text')); this.postContentChanged(); @@ -65,6 +84,21 @@ class EditorView extends View { return this.getUI('text').textrange('get', 'text'); } + /** + * Called when the editor-text changes through user input + */ + private handleInput() { + this.model.set('text', this.getUI('text').val()); + } + + /** + * Called when the editor-text changes through an insert + */ + private postContentChanged() { + this.handleInput(); + autosize.update(this.getUI('text')); + } + /** * Inserts text at the current cursor position * @@ -91,8 +125,7 @@ class EditorView extends View { const region = this.getRegion('smilies'); if (!region.hasView()) { const view = new SmiliesCollectionView(); - const data = this.getUI('text').data('smilies'); - view.collection.add(data); + view.collection.add(this.getOption('smilies')); this.showChildView('smilies', view); this.listenTo(view, 'click:smiley', (smiley) => { // additional space to prevent smiley concatenation: @@ -104,13 +137,8 @@ class EditorView extends View { this.getChildView('smilies').$el.collapse('toggle'); } - private postContentChanged() { - autosize.update(this.getUI('text')); - } - private addMenuButtons() { - const markupSettings = this.getUI('text').data('buttons'); - const collection = new Collection(markupSettings); + const collection = new Collection(this.getOption('buttons')); const view = new MenuButtonBarView({ collection }); this.showChildView('buttons', view); } diff --git a/frontend/src/modules/answering/models/AnswerModel.ts b/frontend/src/modules/answering/models/AnswerModel.ts new file mode 100644 index 000000000..fe6f21609 --- /dev/null +++ b/frontend/src/modules/answering/models/AnswerModel.ts @@ -0,0 +1,25 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import { ModelSaveOptions } from 'backbone'; +import PostingModel from 'models/PostingMdl'; +import { defaults } from 'underscore'; + +/** + * Stores all data required to send a new posting to the server + */ +export default class AnswerModel extends PostingModel { + /** + * Ma initializer + * + * @param options options + */ + public initialize(options) { + this.saitoUrl = 'postings/'; + } +} diff --git a/frontend/src/modules/answering/models/PreviewModel.ts b/frontend/src/modules/answering/models/PreviewModel.ts new file mode 100644 index 000000000..dc61c51f8 --- /dev/null +++ b/frontend/src/modules/answering/models/PreviewModel.ts @@ -0,0 +1,31 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import { defaults as _defaults } from 'underscore'; +import AnswerModel from './AnswerModel'; + +/** + * Stores all data required to send a new posting to the server + */ +export default class PreviewModel extends AnswerModel { + /** + * Constructor + * + * @param options Bb options + */ + public constructor(defaults: any = {}, options: any = {}) { + _defaults(defaults, { + html: undefined, + }); + super(defaults, options); + } + + public initialize(options) { + this.saitoUrl = 'preview/preview'; + } +} diff --git a/frontend/src/modules/answering/preview.ts b/frontend/src/modules/answering/preview.ts deleted file mode 100644 index 2b3709a63..000000000 --- a/frontend/src/modules/answering/preview.ts +++ /dev/null @@ -1,72 +0,0 @@ -import EventBus from 'app/vent'; -import { JsonApiModel } from 'lib/backbone/jsonApi'; - -import { View } from 'backbone.marionette'; -import { PostingRichtextView } from 'modules/posting/postingRichtext'; -import * as _ from 'underscore'; -import { SpinnerView } from 'views/SpinnerView'; - -class PreviewModel extends JsonApiModel { - public defaults() { - return { - html: null, - }; - } - - public urlRoot = () => { - return EventBus.vent.request('webroot') + 'preview/preview'; - } -} - -class PreviewView extends View { - protected template; - - constructor(options: any = {}) { - options = _.extend(options, { - className: 'card mb-3', - modelEvents: { - error: 'render', - request: 'onRequest', - sync: 'render', - }, - regions: { - postingBody: '.postingBody-text', - preview: '.preview', - }, - template: _.template(` -
-
- -
-

<%= $.i18n.__('preview.t') %>

-
-
-
- <%= html %> -
- `), - }); - super(options); - } - - public initialize() { - this.model = new PreviewModel(); - } - - public onRender() { - if (!this.model.get('html')) { - return; - } - const a = new PostingRichtextView({ el: this.$('.richtext') }); - this.showChildView('postingBody', a); - a.render(); - } - - private onRequest() { - this.showChildView('preview', new SpinnerView()); - } -} - -export { PreviewView }; diff --git a/frontend/src/modules/answering/views/CategorySelectVw.ts b/frontend/src/modules/answering/views/CategorySelectVw.ts new file mode 100644 index 000000000..8833944d2 --- /dev/null +++ b/frontend/src/modules/answering/views/CategorySelectVw.ts @@ -0,0 +1,70 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import { Model } from 'backbone'; +import { View } from 'backbone.marionette'; +import * as _ from 'underscore'; + +export default class CategorySelectVw extends View { + public constructor(options: any = {}) { + _.defaults(options, { + autoselectCategory: false, + className: 'form-group', + events: { + 'change @ui.select': 'onChangeSelect', + }, + template: _.template(` +
+ + +
+
+ `), + ui: { + select: 'select', + }, + }); + super(options); + } + + public onRender() { + this.triggerMethod('change:select'); + } + + public templateContext() { + return { + autoselectCategory: this.getOption('autoselectCategory'), + categories: this.getOption('categories'), + }; + } + + private onChangeSelect() { + let categoryId = this.getUI('select').val(); + if (!categoryId || typeof categoryId !== 'string') { + this.model.set('category_id', undefined); + + return; + } + categoryId = parseInt(categoryId, 10); + this.model.set('category_id', categoryId); + } +} diff --git a/frontend/src/modules/answering/views/PreviewVw.ts b/frontend/src/modules/answering/views/PreviewVw.ts new file mode 100644 index 000000000..2b2a9a3a3 --- /dev/null +++ b/frontend/src/modules/answering/views/PreviewVw.ts @@ -0,0 +1,90 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import { View } from 'backbone.marionette'; +import App from 'models/app'; +import * as _ from 'underscore'; +import { SpinnerView } from 'views/SpinnerView'; +import AnswerModel from '../models/AnswerModel'; +import PreviewModel from '../models/PreviewModel'; + +export default class PreviewView extends View { + protected template; + + constructor(options: any = {}) { + options = _.extend(options, { + className: 'preview-wrapper', + model: new PreviewModel(), + modelEvents: { + error: 'render', + request: 'onRequest', + sync: 'render', + }, + regions: { + preview: '.preview', + }, + template: _.template(` +
+
+
+ +
+

<%= $.i18n.__('preview.t') %>

+
+
+
+ <%= html %> +
+
+ `), + }); + super(options); + } + + public onHide() { + this.$el.slideUp('fast'); + } + + public onShow(model: AnswerModel) { + this.$el.slideDown('fast'); + + this.model.save( + model, + { + error: () => { + App.eventBus.trigger('notification', { + message: $.i18n.__('preview.e.generic'), + type: 'error', + }); + }, + success: (mode, response, options) => { + if ('errors' in response) { + this.trigger('answer:validation:error', response.errors); + + return; + } + + this.trigger('answer:validation:error'); + }, + }, + ); + } + + public onRender() { + if (!this.model.get('html')) { + return; + } + } + + private onRequest() { + this.showChildView('preview', new SpinnerView()); + } + +} diff --git a/frontend/src/modules/answering/views/SubjectInputVw.ts b/frontend/src/modules/answering/views/SubjectInputVw.ts new file mode 100644 index 000000000..4fa85c50b --- /dev/null +++ b/frontend/src/modules/answering/views/SubjectInputVw.ts @@ -0,0 +1,185 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import { Model } from 'backbone'; +import { View } from 'backbone.marionette'; +import * as _ from 'underscore'; + +class SubjectInputModel extends Model { + public defaults() { + return { + length: 0, + max: 70, + remaining: undefined, + value: '', + }; + } + + /** + * Backbone initializer + */ + public initialize() { + this.listenTo(this, 'change:max', this.updateMeta); + this.listenTo(this, 'change:value', this.updateMeta); + this.updateMeta(); + } + + /** + * Recalculates the metadata if the subject textfield value changes + */ + private updateMeta() { + // Should be _.chars(subject) for counting multibyte chars as one char only, but + // maxlength attribute also counts all bytes in multibyte char. + // This shortends the allowed subject by one byte-char per multibyte char, + // but we can life with that. + this.set('length', this.get('value').length); + this.set('remaining', this.get('max') - this.get('length')); + this.set('percentage', this.get('length') === 0 ? 0 : this.get('length') / this.get('max') * 100); + } +} + +enum ProgressBarState { + notFull = 'bg-success', + soonFull = 'bg-warning', + full = 'bg-danger', +} + +export default class SubjectInputView extends View { + private stateModel: SubjectInputModel; + + public constructor(options: any = {}) { + _.defaults(options, { + className: 'postingform-subject-wrapper form-group', + events: { + // 'input' doesnt catch a keypress when full and 'keypress' + // doesn't catch paste/delete + 'input @ui.input': 'handleInput', + 'keypress @ui.input': 'handleKeypress', + }, + template: _.template(` +
+
+ required="required" <% } %> + value="<%- subject %>" + tabindex="2" + type="text" + > +
+
+
+
+
+
+
+
+ `), + ui: { + counter: '.postingform-subject-count', + input: 'input', + progressBar: '.js-progress', + }, + }); + super(options); + } + + public initialize(options) { + this.stateModel = new SubjectInputModel(); + if (options.max) { + this.stateModel.set('max', options.max); + } + } + + public focus() { + // focus is broken in Mobile Safari iOS 8 + const iOS = window.navigator.userAgent.match('iPad|iPhone'); + if (iOS) { + return; + } + + this.getUI('input').focus(); + } + + public onRender() { + this.handleInput(); // initialize non-empty input field (edit posting) + this.update(); + } + + public templateContext() { + return { + placeholder: this.getOption('placeholder'), + subjectMaxLength: this.stateModel.get('max'), + }; + } + + private handleInput() { + const subject = this.getUI('input').val(); + this.model.set('subject', subject); + this.stateModel.set('value', subject); + this.update(); + } + + private update() { + this.updateCounter(); + this.updateProgressBar(); + } + + private updateCounter() { + this.getUI('counter').html(this.stateModel.get('remaining')); + } + + private updateProgressBar() { + const $progress = this.getUI('progressBar'); + $progress.css('width', this.stateModel.get('percentage') + '%'); + + const remaining = this.stateModel.get('remaining'); + if (remaining === 0) { + this.handleMax(); + return; + } + const cssClass = (remaining < 20) ? ProgressBarState.soonFull : ProgressBarState.notFull; + this.setProgress(cssClass); + } + + private handleKeypress(event) { + if (event.keyCode === 13) { + event.preventDefault(); + this.trigger('answer:send:submit'); + + return; + } + + this.handleMax(); + } + + private handleMax() { + if (this.stateModel.get('percentage') !== 100) { + return; + } + this.setProgress(ProgressBarState.full); + _.delay(_.bind(this.setProgress, this), 250, ProgressBarState.soonFull); + } + + private setProgress(cssClass: ProgressBarState) { + const $progress = this.getUI('progressBar'); + Object.keys(ProgressBarState).forEach((key) => { + $progress.removeClass(ProgressBarState[key]); + }); + $progress.addClass(cssClass); + } +} + +export { SubjectInputModel, SubjectInputView }; diff --git a/frontend/src/modules/notification/notification.ts b/frontend/src/modules/notification/notification.ts index b7342c7ad..45036c730 100644 --- a/frontend/src/modules/notification/notification.ts +++ b/frontend/src/modules/notification/notification.ts @@ -77,7 +77,7 @@ class NotificationsCollectionView extends Mn.CollectionView { +export default class NotificationsView extends Mn.View { public constructor(options: any = {}) { _.defaults(options, { template: _.template(`
`), @@ -165,4 +165,4 @@ class NotificationsView extends Mn.View { } } -export default NotificationsView; +export { NotificationType }; diff --git a/frontend/src/modules/posting/postingLayout.ts b/frontend/src/modules/posting/postingLayout.ts index df09adced..6d4010eab 100644 --- a/frontend/src/modules/posting/postingLayout.ts +++ b/frontend/src/modules/posting/postingLayout.ts @@ -3,7 +3,7 @@ import { View, ViewOptions } from 'backbone.marionette'; import { PostingModel } from 'modules/posting/models/PostingModel'; import * as _ from 'underscore'; import ActionView from 'views/postingAction'; -import SliderView from 'views/postingSlider'; +import SliderView from 'views/PostingSliderView'; import { PostingContentView } from './postingContent'; interface IPostingLayoutViewOptions extends ViewOptions { diff --git a/frontend/src/views/PostingSliderView.ts b/frontend/src/views/PostingSliderView.ts new file mode 100644 index 000000000..83484742e --- /dev/null +++ b/frontend/src/views/PostingSliderView.ts @@ -0,0 +1,145 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import { Model } from 'backbone'; +import { View } from 'backbone.marionette'; +import App from 'models/app'; +import AnsweringView from 'modules/answering/answering'; +import AnswerModel from 'modules/answering/models/AnswerModel'; +import * as _ from 'underscore'; +import { SpinnerView } from 'views/SpinnerView'; + +/** + * Slider beneath a posting which holds the answering form + */ +export default class Marionette extends View { + public answeringForm: boolean; + + public parentThreadline; + + public constructor(options: any = {}) { + _.defaults(options, { + childViewEvents: { + 'answering:form:rendered': 'onAnsweringFormRendered', + 'answering:load:error': 'onChildviewAnsweringLoadError', + 'answering:send:success': 'onChildviewAnsweringSendSuccess', + }, + events: { + 'click @ui.btnClose': 'onBtnClose', + }, + regions: { + answerRg: '.js-answer-wrapper', + }, + template: _.template('
'), + ui: { + btnClose: '.js-btnAnsweringClose', + }, + }); + super(options); + } + + public initialize(options) { + this.answeringForm = false; + this.parentThreadline = options.parentThreadline || null; + + this.listenTo(this.model, 'change:isAnsweringFormShown', this.toggleAnsweringForm); + } + + private onBtnClose() { + this.model.set('isAnsweringFormShown', false); + } + + private toggleAnsweringForm() { + if (this.model.get('isAnsweringFormShown')) { + this.hideAllAnsweringForms(); + this.loadAnsweringForm(); + } else { + this.hideAnsweringForm(); + } + } + + private onChildviewAnsweringSendSuccess(model: AnswerModel) { + const id = model.get('id'); + + /// Inline answer + if (this.parentThreadline !== null) { + this.model.set({ isAnsweringFormShown: false }); + + this.parentThreadline.set('isInlineOpened', false); + App.eventBus.trigger('newEntry', { + id, + isNewToUser: true, + pid: model.get('pid'), + tid: model.get('tid'), + }); + + return; + } + + /// redirect + let action: string = App.request.action; + + switch (action) { + case ('mix'): + break; + default: + action = 'view'; + } + + const root: string = App.settings.get('webroot'); + window.redirect(root + 'entries/' + action + '/' + id); + } + + private onChildviewAnsweringLoadError() { + this.model.set({ isAnsweringFormShown: false }); + const answerRg = this.getRegion('answerRg'); + answerRg.empty(); + this.answeringForm = false; + } + + private onAnsweringFormRendered() { + // wait for the slide-down to finish if still in progress + _.delay(() => { + this.$el.scrollIntoView('bottom'); + }, 350); + } + + /** + * Loads the answering form + */ + private loadAnsweringForm() { + App.eventBus.request('app:autoreload:stop'); + // slide down + this.$el.slideDown('fast'); + + if (this.answeringForm !== false) { + return; + } + + const model = new AnswerModel({ pid: this.model.get('id') }); + const answeringForm = new AnsweringView({ model }); + + this.showChildView('answerRg', answeringForm); + this.answeringForm = true; + } + + private hideAnsweringForm() { + this.$el.slideUp('fast', () => { + App.eventBus.trigger('change:DOM'); + }); + } + + private hideAllAnsweringForms() { + // we have #id problems with more than one markItUp on a page + this.collection.forEach(function(posting) { + if (posting.get('id') !== this.model.get('id')) { + posting.set('isAnsweringFormShown', false); + } + }, this); + } +} diff --git a/frontend/src/views/app.js b/frontend/src/views/app.js index accc2db3d..4f7b63d52 100644 --- a/frontend/src/views/app.js +++ b/frontend/src/views/app.js @@ -2,7 +2,8 @@ import $ from 'jquery'; import _ from 'underscore'; import Bb from 'backbone'; import Marionette from 'backbone.marionette'; -import { AnsweringView } from 'modules/answering/answering.ts'; +import AnsweringView from 'modules/answering/answering.ts'; +import AnswerModel from 'modules/answering/models/AnswerModel.ts'; import App from 'models/app'; import CategoryChooserVw from 'views/categoryChooserVw.ts'; import { SaitoHelpView } from 'views/helps.ts'; @@ -18,7 +19,8 @@ import ThreadLineCollection from 'collections/threadlines'; import { ThreadLineView } from 'views/ThreadLineView.ts'; import ThreadView from 'views/thread'; import UserVw from 'modules/user/userVw.ts'; -import 'lib/jquery-ui/jquery-ui.custom.min' +import 'lib/jquery-ui/jquery-ui.custom.min'; +import NavigationBreak from 'app/NavigationBreak'; export default Marionette.View.extend({ regions: { @@ -26,12 +28,10 @@ export default Marionette.View.extend({ slidetabs: '#slidetabs', }, - autoPageReloadTimer: false, - template: _.noop, _domInitializers: { - '.entry.add-not-inline': '_initAnsweringNotInlined', + '.js-answer-wrapper': '_initAnsweringNotInlined', '#slidetabs': '_initSlideTabs', '.js-entry-view-core': '_initPostings', '.threadBox': '_initThreadBoxes', @@ -54,6 +54,7 @@ export default Marionette.View.extend({ initialize: function () { this._initNotifications(); + const nv = new NavigationBreak(); this.threads = new ThreadCollection(); if (App.request.controller === 'Entries' && App.request.action === 'index') { @@ -63,9 +64,6 @@ export default Marionette.View.extend({ // collection of threadlines not bound to thread (bookmarks, search results …) this.threadLines = new ThreadLineCollection(); - - this.listenTo(App.eventBus, 'initAutoreload', this.initAutoreload); - this.listenTo(App.eventBus, 'breakAutoreload', this.breakAutoreload); }, initFromDom: function (options) { @@ -78,12 +76,21 @@ export default Marionette.View.extend({ } }); - this.initAutoreload(); this.initHelp(); + const autoPageReload = App.settings.get('autoPageReload'); + if (autoPageReload) { + App.eventBus.request('app:autoreload:start', autoPageReload); + const unread = $('.et-new').length; + if (unread) { + App.eventBus.request('app:favicon:badge', unread); + } + + } + /*** All elements initialized, show page ***/ - App.status.start(); + App.status.start(false); this._showPage(options.SaitoApp.timeAppStart, options.contentTimer); App.eventBus.trigger('notification', options.SaitoApp.msg); @@ -130,11 +137,22 @@ export default Marionette.View.extend({ * @private */ _initAnsweringNotInlined: function (element) { - this.answeringForm = new AnsweringView({ + const data = {}; + const id = element.data('edit'); + if (id) { + data.id = parseInt(id, 10); + } + const answeringForm = new AnsweringView({ el: element, - model: new PostingModel({ id: 'foo' }), - ajax: false + model: new AnswerModel(data), }).render(); + + this.listenTo(answeringForm, 'answering:send:success', (model) => { + const root = App.settings.get('webroot'); + window.redirect(root + 'entries/view/' + model.get('id')); + }); + + return answeringForm; // testing }, /** @@ -261,36 +279,6 @@ export default Marionette.View.extend({ $('.threadBox[data-id=' + tid + ']')[0].scrollIntoView('top'); }, - /** - * initialize page autoreload - */ - initAutoreload: function () { - var period, reload, url; - - url = window.location.pathname; - reload = (function () { - window.location = url; - }); - - if (!App.settings.get('autoPageReload')) { - return; - } - this.breakAutoreload(); - period = App.settings.get('autoPageReload') * 1000; - this.autoPageReloadTimer = setTimeout(reload, period); - }, - - /** - * break autoreload by clearing timer - */ - breakAutoreload: function () { - if (this.autoPageReloadTimer === false) { - return; - } - clearTimeout(this.autoPageReloadTimer); - this.autoPageReloadTimer = false; - }, - showLoginForm: function (event) { event.preventDefault(); const title = event.currentTarget.title; diff --git a/frontend/src/views/postingAction.js b/frontend/src/views/postingAction.js index 27dede43c..822ce78b5 100644 --- a/frontend/src/views/postingAction.js +++ b/frontend/src/views/postingAction.js @@ -5,7 +5,7 @@ import App from 'models/app'; import BmBtn from 'views/postingActionBookmark'; import DelModal from 'views/postingActionDelete'; import SolvesBtn from 'views/postingActionSolves'; -import { EditCountdownView } from 'modules/answering/editCountdown'; +import EditCountdownView from 'modules/answering/buttons/EditCountdownBtnView'; export default Marionette.View.extend({ @@ -36,9 +36,8 @@ export default Marionette.View.extend({ var _$editButton = this.$('.js-btn-edit'); if (_$editButton.length > 0) { var editCountdown = new EditCountdownView({ + startTime: this.model.get('time'), el: _$editButton, - model: this.model, - editPeriod: App.settings.get('editPeriod') }); } }, diff --git a/frontend/src/views/postingSlider.js b/frontend/src/views/postingSlider.js deleted file mode 100644 index c6c51e7c4..000000000 --- a/frontend/src/views/postingSlider.js +++ /dev/null @@ -1,73 +0,0 @@ -import Marionette from 'backbone.marionette'; -import App from 'models/app'; -import { AnsweringView } from 'modules/answering/answering.ts'; -import { SpinnerView } from 'views/SpinnerView'; -import * as _ from 'underscore'; - -export default Marionette.View.extend({ - - answeringForm: false, - - ui: { - btnClose: '.js-btnAnsweringClose', - }, - - events: { - 'click @ui.btnClose': 'onBtnClose' - }, - - template: _.noop, - - initialize: function (options) { - this.parentThreadline = options.parentThreadline || null; - - this.listenTo(this.model, 'change:isAnsweringFormShown', this.toggleAnsweringForm); - }, - - onBtnClose: function (event) { - event.preventDefault(); - this.model.set('isAnsweringFormShown', false); - }, - - toggleAnsweringForm: function () { - if (this.model.get('isAnsweringFormShown')) { - this._hideAllAnsweringForms(); - this._showAnsweringForm(); - } else { - this._hideAnsweringForm(); - } - }, - - _showAnsweringForm: function () { - App.eventBus.trigger('breakAutoreload'); - if (this.answeringForm === false) { - const spinner = (new SpinnerView()).render(); - this.$el.html(spinner.$el); - } - this.$el.slideDown('fast'); - if (this.answeringForm === false) { - this.answeringForm = new AnsweringView({ - el: this.$el, - model: this.model, - parentThreadline: this.parentThreadline - }); - this.answeringForm.render(); - } - }, - - _hideAnsweringForm: function () { - this.$el.slideUp('fast', function () { - App.eventBus.trigger('change:DOM'); - }); - }, - - _hideAllAnsweringForms: function () { - // we have #id problems with more than one markItUp on a page - this.collection.forEach(function (posting) { - if (posting.get('id') !== this.model.get('id')) { - posting.set('isAnsweringFormShown', false); - } - }, this); - } - -}); diff --git a/frontend/test/lib/MarkItUpSpec.js b/frontend/test/lib/MarkItUpSpec.js index 4850fc2a9..40cf76c68 100644 --- a/frontend/test/lib/MarkItUpSpec.js +++ b/frontend/test/lib/MarkItUpSpec.js @@ -1,3 +1,11 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + import $ from 'jquery'; import { MarkupMultimedia } from 'lib/saito/markup.media.ts'; @@ -60,13 +68,6 @@ describe("markup library", function () { expect(result).toEqual(expected); }); - it("outputs an [flash_video] tag for tags", function () { - input = ' { + const selectorIds = ['a', 'b', 'c', 'd']; + const fixture = ` +
+ +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ `; + + let view; + + beforeEach(() => { + setFixtures(fixture); + view = new CakeFormErrorView({ el: 'form'}); + }); + + afterEach(() => { + view.destroy(); + }); + + describe('input field', () => { + it('error indicator class is set and removed', () => { + view.render(); + expect(view.$('#a')).not.toHaveClass('is-invalid'); + + const model = new Model({source: {field: '#a'}, title: 'error-a'}); + view.collection.add(model); + view.render(); + expect(view.$('#a')).toHaveClass('is-invalid'); + + view.collection.remove(model); + view.render(); + expect(view.$('#a')).not.toHaveClass('is-invalid'); + }) + }); + + describe('error message', () => { + it('is is set directly next to input', () => { + const a = new Model({source: {field: '#a'}, title: 'error-a'}); + const b = new Model({source: {field: '#b'}, title: 'error-b'}); + view.collection.add([a, b]); + view.render(); + + expect(view.$('#a').siblings().get(0)).toHaveClass('invalid-feedback'); + expect(view.$('#b').siblings().get(0)).toHaveClass('invalid-feedback'); + }) + + it('is set directly next to input', () => { + const a = new Model({source: {field: '#a'}, title: 'error-a'}); + const b = new Model({source: {field: '#b'}, title: 'error-b'}); + view.collection.add([a, b]); + view.render(); + + const aMsg = view.$('#a').siblings().get(0); + expect(aMsg).toHaveClass('invalid-feedback'); + expect(aMsg).toHaveHtml('error-a'); + + const bMsg = view.$('#b').siblings().get(0); + expect(bMsg).toHaveClass('invalid-feedback'); + expect(bMsg).toHaveHtml('error-b'); + }) + + it('is set in dedicated tag', () => { + const c = new Model({source: {field: '#c'}, title: 'error-c'}); + const d = new Model({source: {field: '#d'}, title: 'error-d'}); + view.collection.add([c, d]); + view.render(); + + const cMsg = view.$('#test-vld-msg-c').children().get(0); + expect(cMsg).toHaveClass('invalid-feedback'); + expect(cMsg).toHaveHtml('error-c'); + + const dMsg = view.$('#test-vld-msg-d').children().get(0); + expect(dMsg).toHaveClass('invalid-feedback'); + expect(dMsg).toHaveHtml('error-d'); + }) + }); +}); diff --git a/frontend/test/modules/answering/AnsweringVwSpec.js b/frontend/test/modules/answering/AnsweringVwSpec.js new file mode 100644 index 000000000..1fbae409d --- /dev/null +++ b/frontend/test/modules/answering/AnsweringVwSpec.js @@ -0,0 +1,234 @@ +import _ from 'underscore'; +import $ from 'jquery'; +import AnsweringView from 'modules/answering/answering.ts'; +import AnswerModel from 'modules/answering/models/AnswerModel'; +import {MetaModel} from 'modules/answering/Meta'; + +describe('answering form', () => { + let metaFixture = { + draft: { + id: 5, + subject: 'Draft Subject', + text: 'Draft Text', + }, + editor: { + buttons: [], + categories: {1: 'Ontopic'}, + smilies: [], + }, + meta: { + autoselectCategory: true, + info: '', + isEdit: false, + last: '', + quoteSymbol: '>', + subject: 'Subject Placeholder', + text: '> Cited Parent Text', + subjectMaxLength: 75, + }, + posting: {}, + }; + + describe('loads meta data', () => { + it('sends request with id', () => { + const meta = new MetaModel(); + const model = new AnswerModel({ id: 99 }); + const view = new AnsweringView({meta, model}); + + spyOn(meta, 'set'); + spyOn(meta, 'fetch'); + + view.render(); + + expect(meta.set).toHaveBeenCalledWith('id', 99); + expect(meta.fetch).toHaveBeenCalled(); + }) + + it('sends request with pid', () => { + const meta = new MetaModel(); + const model = new AnswerModel({ pid: 815 }); + const view = new AnsweringView({meta, model}); + + spyOn($, 'ajax'); + view.render(); + + expect($.ajax.calls.mostRecent().args[0]['data']).toEqual({ pid: 815 }); + }) + + describe('fails', () => { + it('and calls error handler', () => { + const meta = new MetaModel(); + spyOn(meta, 'fetch').and.callFake((params) => { params.error() }); + const view = new AnsweringView({meta}); + spyOn(view, 'onAnsweringLoadError'); + + view.render(); + + expect(view.onAnsweringLoadError).toHaveBeenCalled(); + }); + }); + + describe('and succeeds', () => { + it('calling the main build routine', () => { + const meta = new MetaModel(); + spyOn(meta, 'fetch').and.callFake((params) => { params.success() }); + + const view = new AnsweringView({meta}); + spyOn(view, 'onAnsweringLoadSuccess'); + view.render(); + + expect(view.onAnsweringLoadSuccess).toHaveBeenCalled(); + }); + + describe('if posting is an answer', () => { + /** + * Not fully implemented yet. Only drafts so far. + * @todo + */ + it ('initializes all the subregions from the meta data', () => { + const meta = new MetaModel(metaFixture); + const model = new AnswerModel({ pid: 99 }); + const view = new AnsweringView({model, meta}); + view.render(); + + /// Check drafts view + expect(view.model.get('subject')).toEqual('Draft Subject'); + expect(view.model.get('text')).toEqual('Draft Text'); + + const draftView = view.getChildView('drafts'); + expect(draftView.model.get('id')).toEqual(5); + expect(draftView.model.get('pid')).toEqual(99); + expect(draftView.model.get('subject')).toEqual('Draft Subject'); + expect(draftView.model.get('text')).toEqual('Draft Text'); + + /// Check cite view + const citeView = view.getChildView('cite'); + expect(citeView.model.get('quoteSymbol')).toEqual('>'); + expect(citeView.model.get('text')).toEqual('> Cited Parent Text'); + }); + }); + + describe('if posting is an edit', () => { + it('populates the subject', () => { + const fixture = _.clone(metaFixture) + delete(fixture.draft), + fixture.posting = { + id: 99, + subject: 'The edit subject', + text: 'The edit text', + } + + const meta = new MetaModel(fixture); + const model = new AnswerModel({ pid: 99 }); + const view = new AnsweringView({model, meta}); + view.render(); + + expect(view.model.get('subject')).toEqual('The edit subject'); + expect(view.model.get('text')).toEqual('The edit text'); + }); + }); + }); + }); + + describe('submit', () => { + let view; + + beforeEach(() => { + const meta = new MetaModel(metaFixture); + const model = new AnswerModel({subject: 'foo', text: 'bar'}); + view = new AnsweringView({meta, model}); + }); + + describe('does not validate', () => { + it('fails', () => { + view.render(); + spyOn(view, 'checkFormValidity').and.returnValue(false); + spyOn(view, 'enableAnswering'); + spyOn(view, 'disableAnswering'); + + view.triggerMethod('submit'); + + expect(view.checkFormValidity).toHaveBeenCalled(); + expect(view.enableAnswering).toHaveBeenCalled(); + expect(view.disableAnswering).toHaveBeenCalled(); + }); + }); + + describe('does validate', () => { + let ajax; + + beforeEach(() => { + ajax = spyOn($, 'ajax'); + }); + + afterEach(() => { + expect($.ajax.calls.mostRecent().args[0]['url']).toEqual('/test/root/api/v2/postings/'); + }); + + it('disables and enables answering', () => { + ajax.and.callFake((params) => { params.error() }) + view.render(); + + const submitBtn = view.getChildView('submitBtn'); + spyOn(submitBtn, 'enable'); + spyOn(submitBtn, 'disable'); + const drafts = view.getChildView('drafts'); + spyOn(drafts, 'enable'); + spyOn(drafts, 'disable'); + + submitBtn.trigger('answer:send:submit'); + + /// on form start + expect(drafts.disable).toHaveBeenCalled(); + expect(submitBtn.disable).toHaveBeenCalled(); + + /// after ajax error + expect(drafts.enable).toHaveBeenCalled(); + expect(submitBtn.enable).toHaveBeenCalled(); + }); + + describe('connecting to server succeeds', () => { + describe('server responses with validation errors', () => { + beforeEach(() => { + ajax.and.callFake((params) => { params.success() }) + }); + it('shows the validation errors', () => { + view.render(); + + const errors = [ + {"source":{"field":"#category-id"}, "title":"error-category"}, + {"source":{"field":"#subject"}, "title":"error-subject"}, + ]; + ajax.and.callFake((params) => { params.success({ errors }); }); + spyOn(view.errorVw.collection, 'reset').and.callThrough(); + spyOn(view.errorVw, 'render').and.callThrough(); + + view.getChildView('submitBtn').trigger('answer:send:submit'); + + expect(view.errorVw.collection.reset).toHaveBeenCalledWith(errors); + expect(view.errorVw.render).toHaveBeenCalled(); + + // just in case rudimentary test + expect(view.$('.vld-msg').get(0)).toContainText('error-category'); + expect(view.$('.vld-msg').get(1)).toContainText('error-subject'); + }); + }); + }); + + describe('connecting to server fails', () => { + beforeEach(() => { + ajax.and.callFake((params) => { params.error() }) + }); + + it('handles the error', () => { + spyOn(view, 'triggerMethod').and.callThrough(); + view.render(); + + view.getChildView('submitBtn').trigger('answer:send:submit'); + + expect(view.triggerMethod).toHaveBeenCalledWith('answering:send:error'); + }); + }); + }); + }); +}); diff --git a/frontend/test/modules/answering/DraftSpec.js b/frontend/test/modules/answering/DraftSpec.js new file mode 100644 index 000000000..527ea9b41 --- /dev/null +++ b/frontend/test/modules/answering/DraftSpec.js @@ -0,0 +1,113 @@ +import $ from 'jquery'; +import { DraftModel, DraftView } from 'modules/answering/Draft.ts'; + +describe('answering form', () => { + describe ('with draft' , () => { + it('sends new draft', (done) => { + const model = new DraftModel(); + const view = new DraftView({ + model, + timers: { early: 1, debounce: 1, long: 1 } + }); + spyOn(view, 'send').and.callThrough(); + spyOn($, 'ajax'); + + view.render(); + const data = { subject: 'foo', text: 'bar' }; + model.set(data); + + setTimeout( + () => { + expect(view.send).toHaveBeenCalled(); + + const request = $.ajax.calls.mostRecent().args[0]; + expect(JSON.parse(request.data)).toEqual(data); + expect(request.type).toEqual('POST'); + expect(request.url).toEqual('/test/root/api/v2/drafts/'); + + done(); + }, + 4 // wait for long and short timer to fire + ); + }); + + it('does not send a new draft with empty fields', (done) => { + const model = new DraftModel(); + const view = new DraftView({ + model, + timers: { early: 1, debounce: 1, long: 1 } + }); + spyOn(view, 'send').and.callThrough(); + spyOn($, 'ajax'); + + view.render(); + const data = { subject: '', text: '' }; + model.set(data); + + setTimeout( + () => { + expect(view.send).not.toHaveBeenCalled(); + done(); + }, + 4 // wait for long and short timer to fire + ); + }); + + it('updates an existing draft', (done) => { + const model = new DraftModel(); + const view = new DraftView({ + model, + timers: { early: 1, debounce: 1, long: 1 } + }); + spyOn(view, 'send').and.callThrough(); + spyOn($, 'ajax'); + + view.render(); + const data = { 'id': 5, subject: 'foo', text: 'bar' }; + model.set(data); + + setTimeout( + () => { + expect(view.send).toHaveBeenCalled(); + + const request = $.ajax.calls.mostRecent().args[0]; + expect(JSON.parse(request.data)).toEqual(data); + expect(request.type).toEqual('PUT'); + expect(request.url).toEqual('/test/root/api/v2/drafts/5'); + + done(); + }, + 4 // wait for long and short timer to fire + ); + }); + + it('updates an existing draft with empty fields', (done) => { + const model = new DraftModel(); + const view = new DraftView({ + model, + timers: { early: 1, debounce: 1, long: 1 } + }); + spyOn(view, 'send').and.callThrough(); + spyOn($, 'ajax'); + + view.render(); + const data = { 'id': 5, subject: '', text: '' }; + model.set(data); + + setTimeout( + () => { + expect(view.send).toHaveBeenCalled(); + + const request = $.ajax.calls.mostRecent().args[0]; + expect(JSON.parse(request.data)).toEqual(data); + expect(request.type).toEqual('PUT'); + expect(request.url).toEqual('/test/root/api/v2/drafts/5'); + + done(); + }, + 4 // wait for long and short timer to fire + ); + }); + + }); +}); diff --git a/frontend/test/modules/answering/MetaSpec.js b/frontend/test/modules/answering/MetaSpec.js new file mode 100644 index 000000000..d184b54b5 --- /dev/null +++ b/frontend/test/modules/answering/MetaSpec.js @@ -0,0 +1,15 @@ +import $ from 'jquery'; +import {MetaModel} from 'modules/answering/Meta'; + +describe('answering', () => { + describe('meta', () => { + it('fetches from the correct URL', () => { + spyOn($, 'ajax'); + const model = new MetaModel(); + + model.fetch(); + + expect($.ajax.calls.mostRecent().args[0]['url']).toEqual('/test/root/api/v2/postingmeta/'); + }); + }); +}); diff --git a/frontend/test/modules/answering/SubjectInputViewSpec.js b/frontend/test/modules/answering/SubjectInputViewSpec.js deleted file mode 100644 index 62895a700..000000000 --- a/frontend/test/modules/answering/SubjectInputViewSpec.js +++ /dev/null @@ -1,46 +0,0 @@ -import { SubjectInputView as View } from 'modules/answering/SubjectInputView'; -import $ from 'jquery'; -import _ from 'underscore'; -import App from 'models/app'; - -describe('answering form', function () { - describe('subject input field', function () { - const fixture = ` -
- -
-
-
-
-
- `; - - const maxlength = 75; - - let view; - beforeEach(function () { - App.settings.set('subject_maxlength', maxlength); - setFixtures(fixture); - }); - - afterEach(function () { - if (view) view.destroy(); - }); - - it('initializes from non-empty field value', function () { - const content = 'existing content'; - $('input').attr('value', content); - view = new View({ el: '.postingform-subject-wrapper' }); - - const expected = (maxlength - content.length) + ''; - const result = view.getUI('counter').html(); - expect(result).toEqual(expected); - }); - - it('throws error if max length is not set', function () { - App.settings.set('subject_maxlength', null); - expect(() => { new View({ el: '.postingform-subject-wrapper' }) }) - .toThrowError(Error, 'No subject_maxlength in App settings.'); - }); - }); -}); diff --git a/frontend/test/modules/answering/models/AnswerModelSpec.js b/frontend/test/modules/answering/models/AnswerModelSpec.js new file mode 100644 index 000000000..92d13e295 --- /dev/null +++ b/frontend/test/modules/answering/models/AnswerModelSpec.js @@ -0,0 +1,27 @@ +import AnswerModel from 'modules/answering/models/AnswerModel'; + +describe('answer model', function () { + describe('default value', function () { + let model; + + beforeEach(function () { + model = new AnswerModel(); + }); + + afterEach(function () { + model.destroy() + }); + + it('subject', () => { + expect(model.get('subject')).toEqual(''); + }); + + it('text', () => { + expect(model.get('text')).toEqual(''); + }); + + it('pid', () => { + expect(model.get('pid')).toBeUndefined(); + }); + }) +}); diff --git a/frontend/test/modules/answering/views/CategorySelectVwSpec.js b/frontend/test/modules/answering/views/CategorySelectVwSpec.js new file mode 100644 index 000000000..284068295 --- /dev/null +++ b/frontend/test/modules/answering/views/CategorySelectVwSpec.js @@ -0,0 +1,67 @@ +import AnswerModel from 'modules/answering/models/AnswerModel'; +import CategorySelect from 'modules/answering/views/CategorySelectVw'; +import { SubjectInputView as View } from 'modules/answering/views/SubjectInputVw'; +import _ from 'underscore'; + +describe('answering form', function () { + const categories = { 1: 'Ontopic', 2: 'Offtopic'}; + let model; + + beforeEach(() => { + model = new AnswerModel(); + }) + + afterEach(() => { + model.destroy(); + }) + + + describe('category select', function () { + it('shows with autoselect category false', function () { + const view = new CategorySelect({ + categories, + autoselectCategory: false, + model, + }).render(); + + const html = view.getUI('select'); + + expect(html).toContainHtml(''); + }); + + it('shows with autoselect category true', function () { + const view = new CategorySelect({ + categories, + autoselectCategory: true, + model, + }).render(); + + const html = view.getUI('select'); + + expect(html).not.toContainHtml(''); + }); + + it('shows categories', function () { + const view = new CategorySelect({ + categories, + model, + }).render(); + + const html = view.getUI('select'); + + expect(html).toContainElement('option[value=1]:contains("Ontopic")'); + expect(html).toContainElement('option[value=2]:contains("Offtopic")'); + }); + + it('selects the category', () => { + model.set('category_id', 2); + const view = new CategorySelect({ + categories, + model, + }).render(); + + const html = view.getUI('select'); + expect(html).toContainElement('option[value=2][selected=selected]'); + }); + }); +}); diff --git a/frontend/test/modules/answering/views/SubjectInputViewSpec.js b/frontend/test/modules/answering/views/SubjectInputViewSpec.js new file mode 100644 index 000000000..de514bddd --- /dev/null +++ b/frontend/test/modules/answering/views/SubjectInputViewSpec.js @@ -0,0 +1,91 @@ +import AnswerModel from 'modules/answering/models/AnswerModel'; +import { SubjectInputView as View } from 'modules/answering/views/SubjectInputVw'; +import _ from 'underscore'; + +describe('answering form', function () { + describe('subject input field', function () { + it('initializes from existing value', function () { + const subject = 'existing content'; + + const view = new View({ + model: new AnswerModel({ subject }), + }); + view.render(); + + const result = view.getUI('input').val(); + expect(result).toEqual(subject); + }); + + it('is not required on answers', function () { + const subject = 'existing content'; + + const view = new View({ model: new AnswerModel() }).render(); + + const result = view.getUI('input'); + expect(result).toHaveAttr('required'); + }); + + it('is required on new threads', function () { + const subject = 'existing content'; + + const view = new View({ model: new AnswerModel({ 'pid': 1 }) }).render(); + + const result = view.getUI('input'); + expect(result).not.toHaveAttr('required'); + }); + + it('initializes char counter', function () { + const subject = '12345'; + const max = 100; + + const view = new View({ + max, + model: new AnswerModel({ subject }), + }); + view.render(); + + const result = view.getUI('counter').html(); + expect(result).toEqual(max - subject.length + ''); + + const progressBar = view.getUI('progressBar'); + expect(progressBar).toHaveCss({ width: (100 * subject.length / max) + '%' }); + }); + + it('updates char counter number', function () { + const max = 50; + const model = new AnswerModel(); + const view = new View({ max, model }); + view.render(); + + /// start out with empty subject + let result = view.getUI('counter').html(); + expect(result).toEqual(max + ''); + + const progressBar = view.getUI('progressBar'); + expect(progressBar).toHaveCss({ width: '0%' }); + + /// change subject + const subject = 'funky'; + const input = view.getUI('input'); + input.val(subject); + input.trigger('input'); + + result = view.getUI('counter').html(); + expect(result).toEqual(max - subject.length + ''); + + expect(progressBar).toHaveCss({ width: (100 * subject.length / max) + '%' }); + }); + + it('has a maxlength attribute', function () { + const max = 99; + const model = new AnswerModel(); + const view = new View({ max, model }); + view.render(); + + const result = view.getUI('input'); + + expect(result).toHaveAttr('maxlength', '99'); + }); + + }); +}); diff --git a/frontend/test/runner.js b/frontend/test/runner.js index 21b834f26..9c660cee2 100644 --- a/frontend/test/runner.js +++ b/frontend/test/runner.js @@ -3,6 +3,8 @@ import 'lib/jquery.i18n/jquery.i18n.extend.js'; import Bootstrap from 'bootstrap'; import 'lib/saito/backbone.modelHelper'; import 'lib/saito/underscore.extend'; +import App from 'models/app'; +import EventBus from 'app/vent'; $.fx.off = true; window.$ = $; @@ -19,3 +21,9 @@ window.redirect = function (destination) { const testsContext = require.context(".", true, /Spec$/); testsContext.keys().forEach(testsContext); + +App.settings.set('webroot', '/test/root/'); +App.settings.set('apiroot', '/test/root/api/v2/'); +EventBus.vent.reply('apiroot', function () { + return App.settings.get('apiroot'); +}); diff --git a/frontend/test/views/AppViewSpec.js b/frontend/test/views/AppViewSpec.js index 04c7e2ddf..e1dfe7630 100644 --- a/frontend/test/views/AppViewSpec.js +++ b/frontend/test/views/AppViewSpec.js @@ -1,5 +1,6 @@ import App from 'models/app'; import View from 'views/app'; +import AnswerModel from 'modules/answering/models/AnswerModel.ts'; describe("App", function () { @@ -17,7 +18,6 @@ describe("App", function () { } }; - App.settings.set('webroot', '/web/redirect/'); App.request = SaitoApp.request; this.view = new View(); done(); @@ -30,8 +30,30 @@ describe("App", function () { this.view.manuallyMarkAsRead(); expect(window.redirect).toHaveBeenCalledWith( - '/web/redirect/entries/update' + '/test/root/entries/update' ); }); }); + + describe('initialize answer view from DOM', () => { + it('redirects on successful answer', () => { + spyOn($, 'ajax'); // suppress ajax calls + spyOn(window, 'redirect'); + + const view = new View(); + const answeringView = view._initAnsweringNotInlined($('
')); + + const model = new AnswerModel({'id': 9}); + answeringView.trigger('answering:send:success', model); + + expect(window.redirect).toHaveBeenCalledWith('/test/root/entries/view/9'); + }); + + it('inits model-ID from DOM on edit', () => { + spyOn($, 'ajax'); // suppress ajax calls + const view = new View(); + const answeringView = view._initAnsweringNotInlined($('
')); + expect(answeringView.model.get('id')).toBe(9); + }) + }); }); diff --git a/frontend/test/views/PostingLiserViewSpec.js b/frontend/test/views/PostingLiserViewSpec.js new file mode 100644 index 000000000..5cc5741b0 --- /dev/null +++ b/frontend/test/views/PostingLiserViewSpec.js @@ -0,0 +1,36 @@ +import PostingSliderView from 'views/PostingSliderView'; +import { PostingModel } from 'modules/posting/models/PostingModel'; +import AnswerModel from 'modules/answering/models/AnswerModel'; +import App from 'models/app'; + +describe('posting slider', () => { + describe('childview sent a new answer', () => { + describe('on non-inline form', () => { + it('redirects to new posting', () => { + const model = new PostingModel(); + const view = new PostingSliderView({model}); + const answerModel = new AnswerModel({id: 20}); + + App.request.action = 'non-specific-action-triggering-default-route'; + spyOn(window, 'redirect'); + + view.triggerMethod('childview:answering:send:success', answerModel); + + expect(window.redirect).toHaveBeenCalledWith('/test/root/entries/view/20'); + }); + + it('redirects to new mix posting', () => { + const model = new PostingModel(); + const view = new PostingSliderView({model}); + const answerModel = new AnswerModel({id: 20}); + + App.request.action = 'mix'; + spyOn(window, 'redirect'); + + view.triggerMethod('childview:answering:send:success', answerModel); + + expect(window.redirect).toHaveBeenCalledWith('/test/root/entries/mix/20'); + }); + }); + }) +}); diff --git a/karma.conf.js b/karma.conf.js index df78300d2..195bb8fb3 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -77,11 +77,15 @@ module.exports = function (config) { // start these browsers // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher //browsers: ['Chrome'], - browsers: ['ChromiumHeadlessCustom'], + browsers: ['ChromeHeadlessCustom', 'ChromiumHeadlessCustom'], customLaunchers: { ChromiumHeadlessCustom: { base: 'ChromiumHeadless', flags: ['--no-sandbox'] + }, + ChromeHeadlessCustom: { + base: 'ChromeHeadless', + flags: ['--no-sandbox'] } }, diff --git a/package.json b/package.json index bba3f5ea1..d0939639b 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "Saito", "private": true, "dependencies": { + "@types/favico.js": "^0.0.28", "@types/underscore.string": "^0.0.32", "autosize": "^4.0.2", "backbone": "^1.4.0", @@ -12,6 +13,7 @@ "bootstrap": "^4.3.0", "datatables.net": "^1.10.19", "datatables.net-bs4": "^1.10.16", + "favico.js": "^0.3.10", "font-awesome": "^4.7.0", "gettext-extractor": "^3.4.3", "grunt": "^1.0.4", @@ -22,7 +24,10 @@ "lodash": "^4.17.10", "moment": "^2.22.2", "popper.js": "^1.14.3", + "spectrum-colorpicker": "^1.8.0", "string-template": "^1.0.0", + "typeface-cabin": "^0.0.72", + "typeface-fenix": "^0.0.72", "underscore": "~1.9.0", "underscore.string": "^3.3.4", "yarn": "^1.15.2" @@ -40,17 +45,17 @@ "babel-core": "^6.26.3", "babel-loader": "^7.1.4", "babel-preset-env": "^1.7.0", - "cssnano": "^3.10.0", + "cssnano": "^4.0.0", "grunt": "*", "grunt-concurrent": "*", "grunt-contrib-clean": "*", - "grunt-contrib-concat": "^0.5.0", + "grunt-contrib-concat": "*", "grunt-contrib-copy": "*", - "grunt-contrib-uglify-es": "^3.3.0", - "grunt-contrib-watch": "^1.0.1", - "grunt-phpcs": "^0.2.2", - "grunt-postcss": "^0.9.0", - "grunt-sass": "^2.1.0", + "grunt-contrib-uglify-es": "*", + "grunt-contrib-watch": "*", + "grunt-phpcs": "*", + "grunt-postcss": "*", + "grunt-dart-sass": "*", "grunt-shell": "*", "jasmine": "^3.1.0", "jasmine-ajax": "^3.4.0", @@ -71,7 +76,7 @@ "stylelint-config-standard": "^18.2.0", "ts-loader": "^4.3.0", "tslint": "^5.10.0", - "typescript": "^2.8.3", + "typescript": "^3.5.0", "underscore-template-loader": "^1.0.0", "webpack": "^4.20.2", "webpack-cli": "^3.1.1" @@ -80,10 +85,10 @@ "yarn": ">= 1.0.0" }, "scripts": { - "postinstall": "node -e \"try { require('fs').symlinkSync(require('path').resolve('node_modules/@bower_components'), 'bower_components', 'junction') } catch (e) { }\"", + "travis": "tslint -c tslint.json \"frontend/src/**/*.ts\"; karma start --single-run --browsers ChromeHeadlessCustom;", "pretest": "tslint --fix -c tslint.json \"frontend/src/**/*.ts\"", "test": "karma start --single-run --browsers ChromiumHeadlessCustom", - "karma": "karma start", + "karma": "karma start --browsers ChromiumHeadlessCustom", "webpack": "webpack --watch" } } diff --git a/phpstan.neon b/phpstan.neon index 844bbf991..0ff4ae05a 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,6 @@ parameters: - level: 1 + level: 3 + inferPrivatePropertyTypeFromConstructor: true paths: - plugins/ - src/ @@ -11,4 +12,9 @@ parameters: autoload_files: - tests/bootstrap.php ignoreErrors: + # CakePHP magic find functions - '#Call to an undefined method.*Table::findBy.*\(\)#' + # 3rd party search plugin + - + message: '#.*searchManager.*#' + path: %currentWorkingDirectory%/src/Model/Table/EntriesTable.php diff --git a/plugins/Admin/config/routes.php b/plugins/Admin/config/routes.php index 1c326cc55..44214d440 100644 --- a/plugins/Admin/config/routes.php +++ b/plugins/Admin/config/routes.php @@ -1,5 +1,15 @@ */ - $targetCategories = $this->CurrentUser->Categories->getAll( + $targetCategories = $this->CurrentUser->getCategories()->getAll( 'read', 'list' ); diff --git a/plugins/Admin/src/Controller/SettingsController.php b/plugins/Admin/src/Controller/SettingsController.php index 0ace03bd2..fbd7677da 100755 --- a/plugins/Admin/src/Controller/SettingsController.php +++ b/plugins/Admin/src/Controller/SettingsController.php @@ -1,11 +1,11 @@ Settings->patchEntity( $setting, $this->request->getData(), - ['fields' => 'value'] + ['fields' => ['value']] ); if ($this->Settings->save($setting)) { - $this->Flash->set('Saved. @lo', ['element' => 'notice']); + // @lo + $this->Flash->set('Saved.', ['element' => 'notice']); return $this->redirect(['action' => 'index', '#' => $id]); } - $this->Flash->set('Something went wrong @lo', ['element' => 'error']); + + $errors = $setting->getErrors(); + // @lo + $msg = !empty($errors) ? current(current($errors)) : 'Something went wrong'; + $this->Flash->set($msg, ['element' => 'error']); } $type = $this->settingsShownInAdminIndex[$id]['type'] ?? null; diff --git a/plugins/Admin/src/Controller/SmileyCodesController.php b/plugins/Admin/src/Controller/SmileyCodesController.php index e9e0e9e3b..eeb6fdbb9 100644 --- a/plugins/Admin/src/Controller/SmileyCodesController.php +++ b/plugins/Admin/src/Controller/SmileyCodesController.php @@ -1,5 +1,15 @@ ]http://localhost/img/macnemo.png[/img] - ### HTML5-Audio ### @@ -136,17 +124,15 @@ Choose an [appropriate file-format][Video] for your audience. [Video]: http://en.wikipedia.org/wiki/HTML5_video#Browser_support -### Iframe & Flash ### - -Please use the provided GUI-features. +### Uploads ### -### Upload ### +The BBCode-tag for uploads is generated automatically when a file is inserted through the uploader. - [upload]filename.ext[/upload] +### Other External Content ### -### Embed.ly ### + [embed]http://example.com/content.something[/embed] -If activated `[embed][/embed]` tries to embed the URL via [embed.ly](http://embed.ly/). +Tries to embed the content, a short extract of the content or a apppropriate link. ## Layout ## @@ -154,4 +140,4 @@ If activated `[embed][/embed]` tries to embed the URL via [embed.ly](http:/ [float]content[/float] -Floats the content to the side. \ No newline at end of file +Floats the content to the side. diff --git a/plugins/BbcodeParser/src/Lib/Editor.php b/plugins/BbcodeParser/src/Lib/Editor.php index 1aeb2e916..ea6f8236f 100644 --- a/plugins/BbcodeParser/src/Lib/Editor.php +++ b/plugins/BbcodeParser/src/Lib/Editor.php @@ -1,11 +1,11 @@ getParent()->getTagName() === 'url') { return $string; } - $string = $this->_hashLink($string); - $string = $this->_atUserLink($string); + $string = $this->hashLink($string); + $string = $this->atUserLink($string); - return $this->_autolink($string); + return $this->autolink($string); } /** - * {@inheritDoc} + * Links @ to the user's profile. + * + * @param string $string The Text to be parsed. + * @return string The text with usernames linked. */ - protected function _atUserLink($string) + protected function atUserLink(string $string): string { $tags = []; @@ -84,15 +100,19 @@ protected function _atUserLink($string) } /** - * autolink + * Autolinks URLs not surrounded by explicit URL-tags for user-convenience. * - * @param string $string string - * - * @return string + * @param string $string The text to be parsed for URLs. + * @return string The text with URLs linked. */ - protected function _autolink($string) + protected function autolink(string $string): string { - $replace = function ($matches) { + $replace = function (array $matches): string { + // don't link locally + if (strpos($matches['element'], 'file://') !== false) { + return $matches['element']; + } + // exclude punctuation at end of sentence from URLs $ignoredEndChars = implode('|', [',', '\?', ',', '\.', '\)', '!']); preg_match( @@ -100,8 +120,8 @@ protected function _autolink($string) $matches['element'], $m ); - // keep ['element'] and ['suffix'] and include ['prefix'] - $matches = $m + $matches; + // keep ['element'] and ['suffix'] and include ['prefix']; (array) for phpstan + $matches = (array)($m + $matches); if (strpos($matches['element'], '://') === false) { $matches['element'] = 'http://' . $matches['element']; @@ -148,18 +168,17 @@ function ($matches) { } /** - * Hash link - * - * @param string $string string + * Links # to that posting. * - * @return string + * @param string $string Text to be parsed for #. + * @return string Text containing hash-links. */ - protected function _hashLink($string) + protected function hashLink(string $string): string { $baseUrl = $this->_sOptions->get('webroot') . $this->_sOptions->get('hashBaseUrl'); $string = preg_replace_callback( - '/(?<=\s|^|])(?#)(?\d+)(?!\w)/', - function ($m) use ($baseUrl) { + '/(?<=\s|^|]|\()(?#)(?\d+)(?!\w)/', + function (array $m) use ($baseUrl): string { $hash = $m['element']; return $this->_url($baseUrl . $hash, '#' . $hash); diff --git a/plugins/BbcodeParser/src/Lib/jBBCode/Visitors/JbbCodeNl2BrVisitor.php b/plugins/BbcodeParser/src/Lib/jBBCode/Visitors/JbbCodeNl2BrVisitor.php index deebdb376..81397b71a 100644 --- a/plugins/BbcodeParser/src/Lib/jBBCode/Visitors/JbbCodeNl2BrVisitor.php +++ b/plugins/BbcodeParser/src/Lib/jBBCode/Visitors/JbbCodeNl2BrVisitor.php @@ -1,5 +1,15 @@ _Parser->parse($input); $this->assertHtml($expected, $result); + + /// in paranthesis + $input = "foo (#2234) bar"; + $expected = [ + 'foo (', + 'a' => [ + 'href' => '/hash/2234' + ], + '#2234', + '/a', + ') bar' + ]; + $result = $this->_Parser->parse($input); + $this->assertHtml($expected, $result); } public function testHashLinkFailure() @@ -606,6 +630,13 @@ public function testLinkAutoSurroundingChars() $this->assertHtml($expected, $result); } + public function testLinkAutoIgnoreLocalFiles() + { + $input = 'a file:///foo.bar b file://foo c file:// d file:///'; + $result = $this->_Parser->parse($input); + $this->assertEquals($input, $result); + } + public function testReturnText() { $in = 'test [b]test[b] test'; diff --git a/plugins/BbcodeParser/tests/TestCase/Lib/MarkupTest.php b/plugins/BbcodeParser/tests/TestCase/Lib/MarkupTest.php index 45b416be3..bd2a26355 100644 --- a/plugins/BbcodeParser/tests/TestCase/Lib/MarkupTest.php +++ b/plugins/BbcodeParser/tests/TestCase/Lib/MarkupTest.php @@ -1,5 +1,15 @@ CurrentUser->Categories->getAll('read'); + $categories = $this->CurrentUser->getCategories()->getAll('read'); $bookmarks = $this->Bookmarks->find( 'all', [ @@ -137,9 +135,9 @@ public function beforeFilter(Event $event) * @param int $bookmarkId bookmark-ID * @throws NotFoundException * @throws SaitoForbiddenException - * @return Entity + * @return EntityInterface */ - private function getBookmark(int $bookmarkId): Entity + private function getBookmark(int $bookmarkId): EntityInterface { $bookmark = $this->Bookmarks->find() ->where(['id' => $bookmarkId]) diff --git a/plugins/Bookmarks/src/Lib/Bookmarks.php b/plugins/Bookmarks/src/Lib/Bookmarks.php index ea3662b3f..a33e2e8fa 100644 --- a/plugins/Bookmarks/src/Lib/Bookmarks.php +++ b/plugins/Bookmarks/src/Lib/Bookmarks.php @@ -1,18 +1,18 @@ id, …] + * @var array format: [entry_id => id, …] */ protected $_bookmarks; @@ -69,7 +69,9 @@ protected function _load() return; } - $this->_bookmarks = TableRegistry::get('Bookmarks.Bookmarks') + /** @var BookmarksTable */ + $BookmarksTable = TableRegistry::get('Bookmarks.Bookmarks'); + $this->_bookmarks = $BookmarksTable ->find('list', ['keyField' => 'entry_id', 'valueField' => 'id']) ->where(['user_id' => $this->_CurrentUser->getId()]) ->toArray(); diff --git a/plugins/Bookmarks/src/Model/Table/BookmarksTable.php b/plugins/Bookmarks/src/Model/Table/BookmarksTable.php index 3a8017cfc..e942a8849 100644 --- a/plugins/Bookmarks/src/Model/Table/BookmarksTable.php +++ b/plugins/Bookmarks/src/Model/Table/BookmarksTable.php @@ -1,11 +1,11 @@ extend('_default'); $this->start('theme_head'); ?> - - +