commit 1218570914ffc7982955cfcfaf83085617a6eae5 Author: Mo Elzubeir Date: Fri Dec 9 08:36:26 2022 -0600 at the end of the day, it was inevitable diff --git a/.build/build.xml b/.build/build.xml new file mode 100644 index 0000000..a3e1715 --- /dev/null +++ b/.build/build.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.build/tests/screenshots/.gitkeep b/.build/tests/screenshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/pipline.yml b/.github/workflows/pipline.yml new file mode 100644 index 0000000..635bfd1 --- /dev/null +++ b/.github/workflows/pipline.yml @@ -0,0 +1,76 @@ +# This is a basic workflow to help you get started with Actions + +name: Deploy-Pipline-To-AWS-EC2 + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the symfony_3.4 branch +on: + push: + branches: [ symfony_3.4 ] + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + deploy: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + + # Runs a single command using the runners shell + # - name: Test script + # run: cp app/config/parameters.yml.production app/config/parameters.yml + + # - name: Frontend install + # run: cp frontend/app/appConfig.js.production frontend/app/appConfig.js + # - run: cd frontend && npm install + # - run: cd frontend && npm run build + + - name: ssh scp ssh pipelines + uses: cross-the-world/ssh-scp-ssh-pipelines@latest + env: + WELCOME: "ssh scp ssh pipelines" + LASTSSH: "Doing something after copying" + with: + host: 3.138.54.84 + user: deploy + pass: eivuz6Ai + connect_timeout: 10s + first_ssh: | + cd /var/www/html + echo eivuz6Ai | sudo -S rm -r app + echo eivuz6Ai | sudo -S rm -r behat + echo eivuz6Ai | sudo -S rm -r behat.yml + echo eivuz6Ai | sudo -S rm -r bin + echo eivuz6Ai | sudo -S rm -r Capfile + echo eivuz6Ai | sudo -S rm -r composer.json + echo eivuz6Ai | sudo -S rm -r composer.lock + echo eivuz6Ai | sudo -S rm -r configuration + echo eivuz6Ai | sudo -S rm -r deploy-aws.sh + echo eivuz6Ai | sudo -S rm -r docker + echo eivuz6Ai | sudo -S rm -r docker-compose.yml + echo eivuz6Ai | sudo -S rm -r frontend + echo eivuz6Ai | sudo -S rm -r phpunit.xml.dist + echo eivuz6Ai | sudo -S rm -r README.md + echo eivuz6Ai | sudo -S rm -r hose_external_schema.json + echo eivuz6Ai | sudo -S rm -r src + echo eivuz6Ai | sudo -S rm -r supervisor-start.sh + echo eivuz6Ai | sudo -S rm -r tests + echo eivuz6Ai | sudo -S rm -r update.sh + echo eivuz6Ai | sudo -S rm -r web + scp: | + '*' => /var/www/html + last_ssh: | + cp /var/www/html/app/config/parameters.yml.production /var/www/html/app/config/parameters.yml + cd /var/www/html && composer install + cd /var/www/html && php bin/console doctrine:migrations:migrate --no-interaction + cp /var/www/html/frontend/app/appConfig.js.production /var/www/html/frontend/app/appConfig.js + cd /var/www/html/frontend && npm install + cd /var/www/html/frontend && npm run build + echo eivuz6Ai | sudo -S chown -R apache:apache /var/www/html/web + cd /var/www/html && php bin/console cache:clear --env=prod --no-debug + rm -r /var/www/html/var/cache/stage/* + . /var/www/deploy-aws.sh \ No newline at end of file diff --git a/.github/workflows/staging_pipeline.yml b/.github/workflows/staging_pipeline.yml new file mode 100644 index 0000000..717d09b --- /dev/null +++ b/.github/workflows/staging_pipeline.yml @@ -0,0 +1,76 @@ +# This is a basic workflow to help you get started with Actions + +name: Deploy-Staging-To-AWS-EC2 + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the symfony_3.4 branch +on: + push: + branches: [ staging ] + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + deploy: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + + # Runs a single command using the runners shell + # - name: Test script + # run: cp app/config/parameters.yml.production app/config/parameters.yml + + # - name: Frontend install + # run: cp frontend/app/appConfig.js.production frontend/app/appConfig.js + # - run: cd frontend && npm install + # - run: cd frontend && npm run build + + - name: ssh scp ssh pipelines + uses: cross-the-world/ssh-scp-ssh-pipelines@latest + env: + WELCOME: "ssh scp ssh pipelines" + LASTSSH: "Doing something after copying" + with: + host: 3.133.134.95 + user: deploy + pass: eivuz6Ai + connect_timeout: 10s + first_ssh: | + cd /var/www/html + echo eivuz6Ai | sudo -S rm -r app + echo eivuz6Ai | sudo -S rm -r behat + echo eivuz6Ai | sudo -S rm -r behat.yml + echo eivuz6Ai | sudo -S rm -r bin + echo eivuz6Ai | sudo -S rm -r Capfile + echo eivuz6Ai | sudo -S rm -r composer.json + echo eivuz6Ai | sudo -S rm -r composer.lock + echo eivuz6Ai | sudo -S rm -r configuration + echo eivuz6Ai | sudo -S rm -r deploy-aws.sh + echo eivuz6Ai | sudo -S rm -r docker + echo eivuz6Ai | sudo -S rm -r docker-compose.yml + echo eivuz6Ai | sudo -S rm -r frontend + echo eivuz6Ai | sudo -S rm -r phpunit.xml.dist + echo eivuz6Ai | sudo -S rm -r README.md + echo eivuz6Ai | sudo -S rm -r hose_external_schema.json + echo eivuz6Ai | sudo -S rm -r src + echo eivuz6Ai | sudo -S rm -r supervisor-start.sh + echo eivuz6Ai | sudo -S rm -r tests + echo eivuz6Ai | sudo -S rm -r update.sh + echo eivuz6Ai | sudo -S rm -r web + scp: | + '*' => /var/www/html + last_ssh: | + cp /var/www/html/app/config/parameters.yml.staging /var/www/html/app/config/parameters.yml + cd /var/www/html && composer install + cd /var/www/html && php bin/console doctrine:migrations:migrate --no-interaction + cp /var/www/html/frontend/app/appConfig.js.staging /var/www/html/frontend/app/appConfig.js + cd /var/www/html/frontend && npm install + cd /var/www/html/frontend && npm run build + echo eivuz6Ai | sudo -S chown -R apache:apache /var/www/html/web + cd /var/www/html && php bin/console cache:clear --env=prod --no-debug + rm -r /var/www/html/var/cache/stage/* + . /var/www/deploy-aws.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1caeab8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +/app/config/parameters.yml +/app/config/parameters.yml.* +/frontend/app/appConfig.js.production +/app/config/cert/* +/build/ +/phpunit.xml +/var/* +!/var/cache +/var/cache/* +!var/cache/.gitkeep +!/var/logs +/var/logs/* +!var/logs/.gitkeep +!/var/sessions +/var/sessions/* +!var/sessions/.gitkeep +!var/SymfonyRequirements.php +/vendor/ +/web/bundles/ +/composer.phar +.idea +/.build/tests/screenshots/* +!.build/tests/screenshots/.gitkeep + +/web/assets/* +!/web/assets/.gitkeep + +/web/dist/* + +/frontend/node_modules +/frontend/app/appConfig.js + +!/web/dist/.gitkeep +npm-debug.log +/.project +src/QueueBundle/Resources/config/parameters.ini +*~ +!/src/CacheBundle/Feed/Formatter/Strategy/FeedFormatterStrategyInterface.php + +docker/mysql/data/* +!docker/mysql/data/.gitkeep +.env diff --git a/Capfile b/Capfile new file mode 100644 index 0000000..462a878 --- /dev/null +++ b/Capfile @@ -0,0 +1,4 @@ +load 'deploy' if respond_to?(:namespace) # cap2 differentiator + +require 'capifony_symfony2' +load 'app/config/deploy' \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9591e2c --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# Setup + +### Requirement + +- php + + Version: greater or equal to 7.2 + + Extensions: + + - bcmath + - libxml + - mbstring + - openssl + - xml + - curl + +- ElasticSearch 5.x +- RabbitMQ +- Apache2 + +### Install + +1. Run + + composer install + ./bin/console doctrine:database:create + ./bin/console doctrine:migrations:migrate --no-interaction + ./bin/console socialhose:fixture:load --force + setfacl -dR -m u:"www-data":rwX -m u:$(whoami):rwX var + setfacl -R -m u:"www-data":rwX -m u:$(whoami):rwX var + +2. Use configuration from `configuration` directory + +3. Regenerate public and private keys in **app/config/cert** and update options in **app/config/parameters.yml** + - jwt_private_key_path + - jwt_public_key_path + - jwt_key_pass_phrase + +# Tests + + composer test + +or separately + + composer unit-test + composer behat-test + +Also you can run test by hand, like this: + +- Run unit test + + phpunit + +- Run all functional test + + behat -s api + behat -s command + + or for group + + behat -s api behat/features/Security + + or for concrete feature + + behat -s api behat/features/Security/token/create.feature + + or run in debug mode (behat will print out all request options and responses) + + DEBUG=true behat -s api + + or for fast test, without recreating database and cache clear + + WITHOUT_CLEAR=true behat -s api + +### Docker + +- Add UID to environment file: `echo "UID=$UID" >> .env` +- docker-compose up --build -d + - make sure all containers build and run successfully (`docker-compose ps`): + - socialhose-elastic + - socialhose-elastic-hq + - socialhose-mysql + - socialhose-php + - socialhose-rabbit +- `docker-compose exec socialhose-php bash` + Go into container. All next commands should be running in it. +- install \ update backend + - `sudo setfacl -dR -m u:"www-data":rwX -m u:$(whoami):rwX var` + - `sudo setfacl -R -m u:"www-data":rwX -m u:$(whoami):rwX var` + - `cp app/config/parameters.yml.docker app/config/parameters.yml` + - `composer1 install` +- database migration + - `./bin/console doctrine:migrations:migrate --no-interaction` + Migration is broken and can temporarily be bypassed by commenting out the up commands in VersionVersion20210212114326 + - `./bin/console socialhose:fixture:load --force` +- install \ update frontend + - `cp frontend/app/appConfig.js.docker frontend/app/appConfig.js` + - `cd frontend` + - `npm install` + - `npm run build` + +## Add twitter, instagram fixtures + +- `docker-compose exec socialhose-php bash` +- `cd socialhosefixtures` +- `./add-fixtures.sh` + +## Services are available under following urls: + +- http://localhost:8081/app_dev.php - main site +- http://localhost:5000 - elastic hq (use http://socialhose-elastic:9200 for connect to elastic service) +- http://localhost:15672 - rabbitMQ UI +- MySQL external port is 33066 +- http://localhost:8025/ - mail UI diff --git a/app/.htaccess b/app/.htaccess new file mode 100644 index 0000000..fb1de45 --- /dev/null +++ b/app/.htaccess @@ -0,0 +1,7 @@ + + Require all denied + + + Order deny,allow + Deny from all + diff --git a/app/AppCache.php b/app/AppCache.php new file mode 100644 index 0000000..639ec2c --- /dev/null +++ b/app/AppCache.php @@ -0,0 +1,7 @@ +getEnvironment(), [ 'dev', 'stage', 'test' ], true)) { + $bundles[] = new Nelmio\ApiDocBundle\NelmioApiDocBundle(); + $bundles[] = new ApiDocBundle\ApiDocBundle(); + $bundles[] = + new Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle(); + } + + if (in_array($this->getEnvironment(), [ 'dev', 'test' ], true)) { + $bundles[] = new Symfony\Bundle\DebugBundle\DebugBundle(); + $bundles[] = new Symfony\Bundle\WebProfilerBundle\WebProfilerBundle(); + $bundles[] = + new Sensio\Bundle\DistributionBundle\SensioDistributionBundle(); + $bundles[] = new Sensio\Bundle\GeneratorBundle\SensioGeneratorBundle(); + } + + return $bundles; + } + + public function getRootDir() + { + return __DIR__; + } + + public function getCacheDir() + { + return dirname(__DIR__).'/var/cache/'.$this->getEnvironment(); + } + + public function getLogDir() + { + return dirname(__DIR__).'/var/logs'; + } + + public function registerContainerConfiguration(LoaderInterface $loader) + { + $loader->load($this->getRootDir().'/config/config_'.$this->getEnvironment().'.yml'); + } +} diff --git a/app/DoctrineMigrations/Version20161221180933.php b/app/DoctrineMigrations/Version20161221180933.php new file mode 100644 index 0000000..57bdfa3 --- /dev/null +++ b/app/DoctrineMigrations/Version20161221180933.php @@ -0,0 +1,163 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + CREATE TABLE pages ( + id INT AUTO_INCREMENT NOT NULL, + query_id INT DEFAULT NULL, + document_id VARCHAR(255) DEFAULT NULL, + number INT NOT NULL, + INDEX IDX_2074E575EF946F99 (query_id), + INDEX IDX_2074E575C33F7837 (document_id), + PRIMARY KEY(id) + ) + DEFAULT CHARACTER SET utf8 + COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + $this->addSql(' + CREATE TABLE queries ( + id INT AUTO_INCREMENT NOT NULL, + string LONGTEXT NOT NULL, + types LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', + unique_name VARCHAR(255) NOT NULL, + date DATETIME NOT NULL, + expiration_date DATETIME NOT NULL, + total_count INT NOT NULL, + PRIMARY KEY(id) + ) + DEFAULT CHARACTER SET utf8 + COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql(' + CREATE TABLE documents ( + sequence VARCHAR(255) NOT NULL, + bucket BIGINT DEFAULT NULL, + sequence_range BIGINT DEFAULT NULL, + hashcode LONGTEXT DEFAULT NULL, + resource LONGTEXT DEFAULT NULL, + section VARCHAR(255) DEFAULT NULL, + date_found DATETIME DEFAULT NULL, + index_method VARCHAR(255) DEFAULT NULL, + detection_method VARCHAR(255) DEFAULT NULL, + version VARCHAR(255) DEFAULT NULL, + source_hashcode VARCHAR(255) DEFAULT NULL, + source_resource VARCHAR(255) DEFAULT NULL, + source_link VARCHAR(255) DEFAULT NULL, + source_publisher_type VARCHAR(255) DEFAULT NULL, + source_date_found DATETIME DEFAULT NULL, + source_last_updated DATETIME DEFAULT NULL, + source_last_published DATETIME DEFAULT NULL, + source_last_posted DATETIME DEFAULT NULL, + source_update_interval BIGINT DEFAULT NULL, + source_http_status INT DEFAULT NULL, + source_content_length INT DEFAULT NULL, + source_content_checksum VARCHAR(255) DEFAULT NULL, + source_assigned_tags VARCHAR(255) DEFAULT NULL, + strategy_source_setting_update VARCHAR(255) DEFAULT NULL, + strategy_source_setting_index VARCHAR(255) DEFAULT NULL, + source_title VARCHAR(255) DEFAULT NULL, + source_description VARCHAR(255) DEFAULT NULL, + source_feed_href VARCHAR(255) DEFAULT NULL, + source_feed_title VARCHAR(255) DEFAULT NULL, + source_feed_format VARCHAR(255) DEFAULT NULL, + permalink VARCHAR(255) DEFAULT NULL, + permalink_redirect VARCHAR(255) DEFAULT NULL, + permalink_redirect_domain VARCHAR(255) DEFAULT NULL, + permalink_redirect_site VARCHAR(255) DEFAULT NULL, + canonical VARCHAR(255) DEFAULT NULL, + domain VARCHAR(255) DEFAULT NULL, + site VARCHAR(255) DEFAULT NULL, + main LONGTEXT DEFAULT NULL, + main_length INT DEFAULT NULL, + main_checksum VARCHAR(255) DEFAULT NULL, + main_format VARCHAR(255) DEFAULT NULL, + extract LONGTEXT DEFAULT NULL, + extract_length INT DEFAULT NULL, + extract_checksum VARCHAR(255) DEFAULT NULL, + summary_text LONGTEXT DEFAULT NULL, + title VARCHAR(255) DEFAULT NULL, + published DATETIME DEFAULT NULL, + publisher VARCHAR(255) DEFAULT NULL, + description VARCHAR(255) DEFAULT NULL, + html LONGTEXT DEFAULT NULL, + html_length INT DEFAULT NULL, + links VARCHAR(255) DEFAULT NULL, + author_name VARCHAR(255) DEFAULT NULL, + author_link VARCHAR(255) DEFAULT NULL, + author_gender VARCHAR(255) DEFAULT NULL, + image_src VARCHAR(255) DEFAULT NULL, + card VARCHAR(255) DEFAULT NULL, + type VARCHAR(255) DEFAULT NULL, + sentiment VARCHAR(255) DEFAULT NULL, + lang VARCHAR(255) DEFAULT NULL, + metadata_score INT DEFAULT NULL, + PRIMARY KEY(sequence) + ) + DEFAULT CHARACTER SET utf8 + COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql(' + CREATE TABLE users ( + id INT AUTO_INCREMENT NOT NULL, + username VARCHAR(180) NOT NULL, + username_canonical VARCHAR(180) NOT NULL, + email VARCHAR(180) NOT NULL, + email_canonical VARCHAR(180) NOT NULL, + enabled TINYINT(1) NOT NULL, + salt VARCHAR(255) DEFAULT NULL, + password VARCHAR(255) NOT NULL, + last_login DATETIME DEFAULT NULL, + confirmation_token VARCHAR(180) DEFAULT NULL, + password_requested_at DATETIME DEFAULT NULL, + roles LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', + UNIQUE INDEX UNIQ_1483A5E992FC23A8 (username_canonical), + UNIQUE INDEX UNIQ_1483A5E9A0D96FBF (email_canonical), + UNIQUE INDEX UNIQ_1483A5E9C05FB297 (confirmation_token), + PRIMARY KEY(id) + ) + DEFAULT CHARACTER SET utf8 + COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + $this->addSql(' + ALTER TABLE pages + ADD CONSTRAINT FK_2074E575EF946F99 + FOREIGN KEY (query_id) REFERENCES queries (id) + '); + $this->addSql(' + ALTER TABLE pages + ADD CONSTRAINT FK_2074E575C33F7837 + FOREIGN KEY (document_id) REFERENCES documents (sequence) + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE pages DROP FOREIGN KEY FK_2074E575EF946F99'); + $this->addSql('ALTER TABLE pages DROP FOREIGN KEY FK_2074E575C33F7837'); + $this->addSql('DROP TABLE pages'); + $this->addSql('DROP TABLE queries'); + $this->addSql('DROP TABLE documents'); + $this->addSql('DROP TABLE users'); + } +} diff --git a/app/DoctrineMigrations/Version20161222151030.php b/app/DoctrineMigrations/Version20161222151030.php new file mode 100644 index 0000000..c998996 --- /dev/null +++ b/app/DoctrineMigrations/Version20161222151030.php @@ -0,0 +1,42 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE documents + CHANGE source_assigned_tags source_assigned_tags LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:array)\', + CHANGE links links LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:array)\' + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE documents + CHANGE source_assigned_tags source_assigned_tags VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, + CHANGE links links VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci + '); + } +} diff --git a/app/DoctrineMigrations/Version20161223144722.php b/app/DoctrineMigrations/Version20161223144722.php new file mode 100644 index 0000000..3429ee6 --- /dev/null +++ b/app/DoctrineMigrations/Version20161223144722.php @@ -0,0 +1,61 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE queries + ADD user_id INT DEFAULT NULL, + ADD type VARCHAR(255) NOT NULL, + ADD limit_exceed TINYINT(1) DEFAULT NULL, + ADD last_update_at DATETIME DEFAULT NULL, + CHANGE expiration_date expiration_date DATETIME DEFAULT NULL + '); + $this->addSql(' + ALTER TABLE queries + ADD CONSTRAINT FK_8AF84772A76ED395 + FOREIGN KEY (user_id) REFERENCES users (id) + '); + $this->addSql('CREATE INDEX IDX_8AF84772A76ED395 ON queries (user_id)'); + + $this->addSql(" + UPDATE queries + SET type = 'simple' + "); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE queries DROP FOREIGN KEY FK_8AF84772A76ED395'); + $this->addSql('DROP INDEX IDX_8AF84772A76ED395 ON queries'); + $this->addSql(' + ALTER TABLE queries + DROP user_id, + DROP type, + DROP limit_exceed, + DROP last_update_at, + CHANGE expiration_date expiration_date DATETIME NOT NULL + '); + } +} diff --git a/app/DoctrineMigrations/Version20170105131820.php b/app/DoctrineMigrations/Version20170105131820.php new file mode 100644 index 0000000..e50942b --- /dev/null +++ b/app/DoctrineMigrations/Version20170105131820.php @@ -0,0 +1,38 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE pages ADD score DOUBLE PRECISION NOT NULL'); + $this->addSql(' + UPDATE pages + SET score = 1.0 + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE pages DROP score'); + } +} diff --git a/app/DoctrineMigrations/Version20170106150012.php b/app/DoctrineMigrations/Version20170106150012.php new file mode 100644 index 0000000..21c1d9c --- /dev/null +++ b/app/DoctrineMigrations/Version20170106150012.php @@ -0,0 +1,42 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE queries + CHANGE unique_name normalized LONGTEXT NOT NULL COLLATE utf8_unicode_ci, + CHANGE string raw LONGTEXT NOT NULL COLLATE utf8_unicode_ci + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE queries + CHANGE raw string LONGTEXT NOT NULL COLLATE utf8_unicode_ci, + CHANGE normalized unique_name VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci + '); + } +} diff --git a/app/DoctrineMigrations/Version20170110184359.php b/app/DoctrineMigrations/Version20170110184359.php new file mode 100644 index 0000000..375cbbf --- /dev/null +++ b/app/DoctrineMigrations/Version20170110184359.php @@ -0,0 +1,36 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE queries ADD hash VARCHAR(255) NOT NULL'); + $this->addSql('CREATE INDEX hash_idx ON queries (hash)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP INDEX hash_idx ON queries'); + $this->addSql('ALTER TABLE queries DROP hash'); + } +} diff --git a/app/DoctrineMigrations/Version20170113190543.php b/app/DoctrineMigrations/Version20170113190543.php new file mode 100644 index 0000000..a6dad62 --- /dev/null +++ b/app/DoctrineMigrations/Version20170113190543.php @@ -0,0 +1,39 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE documents + ADD country VARCHAR(255) DEFAULT NULL, + ADD state VARCHAR(255) DEFAULT NULL, + ADD city VARCHAR(255) DEFAULT NULL + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE documents DROP country, DROP state, DROP city'); + } +} diff --git a/app/DoctrineMigrations/Version20170116173457.php b/app/DoctrineMigrations/Version20170116173457.php new file mode 100644 index 0000000..9ea63fa --- /dev/null +++ b/app/DoctrineMigrations/Version20170116173457.php @@ -0,0 +1,52 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + CREATE TABLE jobs ( + id INT AUTO_INCREMENT NOT NULL, + query_id INT DEFAULT NULL, + INDEX IDX_A8936DC5EF946F99 (query_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + $this->addSql(' + ALTER TABLE jobs + ADD CONSTRAINT FK_A8936DC5EF946F99 FOREIGN KEY (query_id) + REFERENCES queries (id) + '); + $this->addSql('ALTER TABLE queries ADD status VARCHAR(255) DEFAULT NULL'); + $this->addSql(" + UPDATE queries + SET status = 'synced' + "); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE jobs'); + $this->addSql('ALTER TABLE queries DROP status'); + } +} diff --git a/app/DoctrineMigrations/Version20170127122322.php b/app/DoctrineMigrations/Version20170127122322.php new file mode 100644 index 0000000..f908cf7 --- /dev/null +++ b/app/DoctrineMigrations/Version20170127122322.php @@ -0,0 +1,40 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE queries + CHANGE types filters LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\' + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE queries + CHANGE filters types LONGTEXT NOT NULL COLLATE utf8_unicode_ci COMMENT \'(DC2Type:array)\' + '); + } +} diff --git a/app/DoctrineMigrations/Version20170130165430.php b/app/DoctrineMigrations/Version20170130165430.php new file mode 100644 index 0000000..f4512f2 --- /dev/null +++ b/app/DoctrineMigrations/Version20170130165430.php @@ -0,0 +1,52 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + CREATE TABLE sessions ( + sess_id INT AUTO_INCREMENT NOT NULL, + sess_data LONGTEXT NOT NULL COLLATE utf8_unicode_ci, + sess_time DATETIME NOT NULL, + PRIMARY KEY(sess_id) + ) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + $this->addSql(' + CREATE TABLE refresh_tokens ( + id INT AUTO_INCREMENT NOT NULL, + refresh_token VARCHAR(128) NOT NULL, + username VARCHAR(255) NOT NULL, + valid DATETIME NOT NULL, + UNIQUE INDEX UNIQ_9BACE7E1C74F2195 (refresh_token), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE sessions'); + $this->addSql('DROP TABLE refresh_tokens'); + } +} diff --git a/app/DoctrineMigrations/Version20170131112539.php b/app/DoctrineMigrations/Version20170131112539.php new file mode 100644 index 0000000..e9b0672 --- /dev/null +++ b/app/DoctrineMigrations/Version20170131112539.php @@ -0,0 +1,39 @@ +abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE documents + ADD shares INT DEFAULT NULL, + ADD views INT DEFAULT NULL, + ADD source_location VARCHAR(255) DEFAULT NULL + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE documents DROP shares, DROP views, DROP source_location'); + } +} diff --git a/app/DoctrineMigrations/Version20170131112706.php b/app/DoctrineMigrations/Version20170131112706.php new file mode 100644 index 0000000..7355def --- /dev/null +++ b/app/DoctrineMigrations/Version20170131112706.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE documents ADD shared_identifier VARCHAR(255) DEFAULT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE documents DROP shared_identifier'); + } +} diff --git a/app/DoctrineMigrations/Version20170131163118.php b/app/DoctrineMigrations/Version20170131163118.php new file mode 100644 index 0000000..c449b0a --- /dev/null +++ b/app/DoctrineMigrations/Version20170131163118.php @@ -0,0 +1,64 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + CREATE TABLE categories ( + id INT AUTO_INCREMENT NOT NULL, + user_id INT DEFAULT NULL, + INDEX IDX_3AF34668A76ED395 (user_id), + name VARCHAR(255) NOT NULL, + internal TINYINT(1) NOT NULL, + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + $this->addSql(' + ALTER TABLE categories + ADD CONSTRAINT FK_3AF34668A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) + '); + $this->addSql(' + ALTER TABLE queries + ADD category_id INT DEFAULT NULL, + ADD name VARCHAR(255) DEFAULT NULL + '); + $this->addSql(' + ALTER TABLE queries + ADD CONSTRAINT FK_8AF8477212469DE2 FOREIGN KEY (category_id) REFERENCES categories (id) + '); + $this->addSql('CREATE INDEX IDX_8AF8477212469DE2 ON queries (category_id)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE queries DROP FOREIGN KEY FK_8AF8477212469DE2'); + $this->addSql('DROP TABLE categories'); + $this->addSql('DROP INDEX IDX_8AF8477212469DE2 ON queries'); + $this->addSql(' + ALTER TABLE queries + DROP category_id, + DROP name + '); + } +} diff --git a/app/DoctrineMigrations/Version20170205175114.php b/app/DoctrineMigrations/Version20170205175114.php new file mode 100644 index 0000000..4365303 --- /dev/null +++ b/app/DoctrineMigrations/Version20170205175114.php @@ -0,0 +1,41 @@ +abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE IF EXISTS sessions'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + CREATE TABLE sessions ( + sess_id INT AUTO_INCREMENT NOT NULL, + sess_data LONGTEXT NOT NULL COLLATE utf8_unicode_ci, + sess_time DATETIME NOT NULL, + PRIMARY KEY(sess_id) + ) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + } +} diff --git a/app/DoctrineMigrations/Version20170206154846.php b/app/DoctrineMigrations/Version20170206154846.php new file mode 100644 index 0000000..c5923ce --- /dev/null +++ b/app/DoctrineMigrations/Version20170206154846.php @@ -0,0 +1,46 @@ +abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + CREATE TABLE sources ( + id INT AUTO_INCREMENT NOT NULL, + title VARCHAR(255) NOT NULL, + media_type VARCHAR(255) NOT NULL, + license VARCHAR(255) NOT NULL, + country VARCHAR(255) NOT NULL, + rank INT NOT NULL, + url VARCHAR(255) NOT NULL, + PRIMARY KEY(id) + ) + DEFAULT CHARACTER SET utf8 + COLLATE utf8_unicode_ci ENGINE = InnoDB'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE sources'); + } +} diff --git a/app/DoctrineMigrations/Version20170207160549.php b/app/DoctrineMigrations/Version20170207160549.php new file mode 100644 index 0000000..425c543 --- /dev/null +++ b/app/DoctrineMigrations/Version20170207160549.php @@ -0,0 +1,63 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE sources + ADD user_id INT DEFAULT NULL, + ADD source_publisher_type VARCHAR(255) NOT NULL, + ADD state VARCHAR(255) NOT NULL, + ADD city VARCHAR(255) NOT NULL, + ADD lang VARCHAR(255) NOT NULL, + ADD section VARCHAR(255) NOT NULL, + ADD deleted TINYINT(1) NOT NULL, + CHANGE media_type type VARCHAR(255) NOT NULL + '); + $this->addSql(' + ALTER TABLE sources + ADD CONSTRAINT FK_D25D65F2A76ED395 FOREIGN KEY (user_id) + REFERENCES users (id) ON DELETE CASCADE + '); + $this->addSql('CREATE INDEX IDX_D25D65F2A76ED395 ON sources (user_id)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE sources DROP FOREIGN KEY FK_D25D65F2A76ED395'); + $this->addSql('DROP INDEX IDX_D25D65F2A76ED395 ON sources'); + $this->addSql(' + ALTER TABLE sources + ADD media_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, + DROP user_id, + DROP type, + DROP source_publisher_type, + DROP state, + DROP city, + DROP lang, + DROP section, + DROP deleted + '); + } +} diff --git a/app/DoctrineMigrations/Version20170207162507.php b/app/DoctrineMigrations/Version20170207162507.php new file mode 100644 index 0000000..e4b6c3f --- /dev/null +++ b/app/DoctrineMigrations/Version20170207162507.php @@ -0,0 +1,42 @@ +abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE sources DROP FOREIGN KEY FK_D25D65F2A76ED395'); + $this->addSql('DROP INDEX IDX_D25D65F2A76ED395 ON sources'); + $this->addSql('ALTER TABLE sources DROP user_id'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE sources ADD user_id INT DEFAULT NULL'); + $this->addSql(' + ALTER TABLE sources + ADD CONSTRAINT FK_D25D65F2A76ED395 FOREIGN KEY (user_id) + REFERENCES users (id) ON DELETE CASCADE + '); + $this->addSql('CREATE INDEX IDX_D25D65F2A76ED395 ON sources (user_id)'); + } +} diff --git a/app/DoctrineMigrations/Version20170207170330.php b/app/DoctrineMigrations/Version20170207170330.php new file mode 100644 index 0000000..1e60f73 --- /dev/null +++ b/app/DoctrineMigrations/Version20170207170330.php @@ -0,0 +1,70 @@ +abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + CREATE TABLE source_list ( + id INT AUTO_INCREMENT NOT NULL, + user_id INT DEFAULT NULL, + title VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted TINYINT(1) NOT NULL, + INDEX IDX_45427D1BA76ED395 (user_id), + PRIMARY KEY(id) + ) + DEFAULT CHARACTER SET utf8 + COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql(' + CREATE TABLE sources_to_sources_lists ( + source_id INT NOT NULL, + source_list_id INT NOT NULL, + INDEX IDX_6411C8FE953C1C61 (source_id), + INDEX IDX_6411C8FE7471AEE3 (source_list_id), + PRIMARY KEY(source_id, source_list_id) + ) + DEFAULT CHARACTER SET utf8 + COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql(' + ALTER TABLE source_list + ADD CONSTRAINT FK_45427D1BA76ED395 + FOREIGN KEY (user_id) REFERENCES users (id)'); + $this->addSql(' + ALTER TABLE sources_to_sources_lists + ADD CONSTRAINT FK_6411C8FE953C1C61 + FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE'); + $this->addSql(' + ALTER TABLE sources_to_sources_lists + ADD CONSTRAINT FK_6411C8FE7471AEE3 + FOREIGN KEY (source_list_id) REFERENCES source_list (id) ON DELETE CASCADE'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE sources_to_sources_lists DROP FOREIGN KEY FK_6411C8FE7471AEE3'); + $this->addSql('DROP TABLE source_list'); + $this->addSql('DROP TABLE sources_to_sources_lists'); + } +} diff --git a/app/DoctrineMigrations/Version20170208110737.php b/app/DoctrineMigrations/Version20170208110737.php new file mode 100644 index 0000000..c1e9daa --- /dev/null +++ b/app/DoctrineMigrations/Version20170208110737.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE source_list ADD is_global TINYINT(1) NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE source_list DROP is_global'); + } +} diff --git a/app/DoctrineMigrations/Version20170209075009.php b/app/DoctrineMigrations/Version20170209075009.php new file mode 100644 index 0000000..83e04cf --- /dev/null +++ b/app/DoctrineMigrations/Version20170209075009.php @@ -0,0 +1,38 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE users + ADD first_name VARCHAR(255) NOT NULL, + ADD last_name VARCHAR(255) NOT NULL + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users DROP first_name, DROP last_name'); + } +} diff --git a/app/DoctrineMigrations/Version20170210123519.php b/app/DoctrineMigrations/Version20170210123519.php new file mode 100644 index 0000000..267a8cc --- /dev/null +++ b/app/DoctrineMigrations/Version20170210123519.php @@ -0,0 +1,93 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + CREATE TABLE cross_users_stored_queries ( + id INT AUTO_INCREMENT NOT NULL, + query_id INT DEFAULT NULL, + user_id INT DEFAULT NULL, + category_id INT DEFAULT NULL, + name VARCHAR(255) NOT NULL, + INDEX IDX_A2E1F665EF946F99 (query_id), + INDEX IDX_A2E1F665A76ED395 (user_id), + INDEX IDX_A2E1F66512469DE2 (category_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + $this->addSql(' + ALTER TABLE cross_users_stored_queries + ADD CONSTRAINT FK_A2E1F665EF946F99 + FOREIGN KEY (query_id) + REFERENCES queries (id) + '); + $this->addSql(' + ALTER TABLE cross_users_stored_queries + ADD CONSTRAINT FK_A2E1F665A76ED395 + FOREIGN KEY (user_id) + REFERENCES users (id) + '); + $this->addSql(' + ALTER TABLE cross_users_stored_queries + ADD CONSTRAINT FK_A2E1F66512469DE2 + FOREIGN KEY (category_id) + REFERENCES categories (id) + '); + $this->addSql('ALTER TABLE queries DROP FOREIGN KEY FK_8AF84772A76ED395'); + $this->addSql('ALTER TABLE queries DROP FOREIGN KEY FK_8AF8477212469DE2'); + $this->addSql('DROP INDEX IDX_8AF84772A76ED395 ON queries'); + $this->addSql('DROP INDEX IDX_8AF8477212469DE2 ON queries'); + $this->addSql('ALTER TABLE queries DROP user_id, DROP category_id, DROP name'); + $this->addSql('ALTER TABLE source_list CHANGE title name VARCHAR(255) NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE cross_users_stored_queries'); + $this->addSql(' + ALTER TABLE queries ADD user_id INT DEFAULT NULL, + ADD category_id INT DEFAULT NULL, + ADD name VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci + '); + $this->addSql(' + ALTER TABLE queries + ADD CONSTRAINT FK_8AF84772A76ED395 + FOREIGN KEY (user_id) + REFERENCES users (id) + '); + $this->addSql(' + ALTER TABLE queries + ADD CONSTRAINT FK_8AF8477212469DE2 + FOREIGN KEY (category_id) + REFERENCES categories (id) + '); + $this->addSql('CREATE INDEX IDX_8AF84772A76ED395 ON queries (user_id)'); + $this->addSql('CREATE INDEX IDX_8AF8477212469DE2 ON queries (category_id)'); + $this->addSql(' + ALTER TABLE source_list + CHANGE name title VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci + '); + } +} diff --git a/app/DoctrineMigrations/Version20170213113612.php b/app/DoctrineMigrations/Version20170213113612.php new file mode 100644 index 0000000..295d93a --- /dev/null +++ b/app/DoctrineMigrations/Version20170213113612.php @@ -0,0 +1,37 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE queries + ADD fields LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\' + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE queries DROP fields'); + } +} diff --git a/app/DoctrineMigrations/Version20170214044904.php b/app/DoctrineMigrations/Version20170214044904.php new file mode 100644 index 0000000..86a4e44 --- /dev/null +++ b/app/DoctrineMigrations/Version20170214044904.php @@ -0,0 +1,50 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE users + ADD organization VARCHAR(255) NOT NULL, + ADD expiration_day DATETIME DEFAULT NULL, + ADD number_of_subscriber INT NOT NULL, + ADD number_of_saved_fields_allowed INT NOT NULL, + ADD number_of_newsletters_allowed INT NOT NULL, + ADD number_of_searches_allowed INT NOT NULL + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE users + DROP organization, + DROP expiration_day, + DROP number_of_subscriber, + DROP number_of_saved_fields_allowed, + DROP number_of_newsletters_allowed, + DROP number_of_searches_allowed + '); + } +} diff --git a/app/DoctrineMigrations/Version20170214050359.php b/app/DoctrineMigrations/Version20170214050359.php new file mode 100644 index 0000000..89b5f92 --- /dev/null +++ b/app/DoctrineMigrations/Version20170214050359.php @@ -0,0 +1,40 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE users + CHANGE number_of_searches_allowed number_of_searches_per_day_allowed INT NOT NULL + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE users + CHANGE number_of_searches_per_day_allowed number_of_searches_allowed INT NOT NULL + '); + } +} diff --git a/app/DoctrineMigrations/Version20170215051338.php b/app/DoctrineMigrations/Version20170215051338.php new file mode 100644 index 0000000..41175e2 --- /dev/null +++ b/app/DoctrineMigrations/Version20170215051338.php @@ -0,0 +1,39 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE sources_to_sections (source_id INT NOT NULL, section_id INT NOT NULL, INDEX IDX_475C417E953C1C61 (source_id), INDEX IDX_475C417ED823E37A (section_id), PRIMARY KEY(source_id, section_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE sections (id INT AUTO_INCREMENT NOT NULL, title VARCHAR(255) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE sources_to_sections ADD CONSTRAINT FK_475C417E953C1C61 FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE sources_to_sections ADD CONSTRAINT FK_475C417ED823E37A FOREIGN KEY (section_id) REFERENCES sections (id) ON DELETE CASCADE'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE sources_to_sections DROP FOREIGN KEY FK_475C417ED823E37A'); + $this->addSql('DROP TABLE sources_to_sections'); + $this->addSql('DROP TABLE sections'); + } +} diff --git a/app/DoctrineMigrations/Version20170215051526.php b/app/DoctrineMigrations/Version20170215051526.php new file mode 100644 index 0000000..470a992 --- /dev/null +++ b/app/DoctrineMigrations/Version20170215051526.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE sources DROP section'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE sources ADD section VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + } +} diff --git a/app/DoctrineMigrations/Version20170217121217.php b/app/DoctrineMigrations/Version20170217121217.php new file mode 100644 index 0000000..e33f54d --- /dev/null +++ b/app/DoctrineMigrations/Version20170217121217.php @@ -0,0 +1,42 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE categories ADD parent_id INT DEFAULT NULL'); + $this->addSql(' + ALTER TABLE categories + ADD CONSTRAINT FK_3AF34668727ACA70 + FOREIGN KEY (parent_id) REFERENCES categories (id) + '); + $this->addSql('CREATE INDEX IDX_3AF34668727ACA70 ON categories (parent_id)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE categories DROP FOREIGN KEY FK_3AF34668727ACA70'); + $this->addSql('DROP INDEX IDX_3AF34668727ACA70 ON categories'); + $this->addSql('ALTER TABLE categories DROP parent_id'); + } +} diff --git a/app/DoctrineMigrations/Version20170219142028.php b/app/DoctrineMigrations/Version20170219142028.php new file mode 100644 index 0000000..9dce2fb --- /dev/null +++ b/app/DoctrineMigrations/Version20170219142028.php @@ -0,0 +1,94 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + CREATE TABLE feeds ( + id INT AUTO_INCREMENT NOT NULL, + user_id INT DEFAULT NULL, + category_id INT DEFAULT NULL, + query_id INT DEFAULT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(255) NOT NULL, + INDEX IDX_5A29F52FA76ED395 (user_id), + INDEX IDX_5A29F52F12469DE2 (category_id), + INDEX IDX_5A29F52FEF946F99 (query_id), PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql(' + ALTER TABLE feeds + ADD CONSTRAINT FK_5A29F52FA76ED395 + FOREIGN KEY (user_id) + REFERENCES users (id) + '); + $this->addSql(' + ALTER TABLE feeds + ADD CONSTRAINT FK_5A29F52F12469DE2 + FOREIGN KEY (category_id) + REFERENCES categories (id) + '); + $this->addSql(' + ALTER TABLE feeds + ADD CONSTRAINT FK_5A29F52FEF946F99 + FOREIGN KEY (query_id) + REFERENCES queries (id) + '); + $this->addSql('DROP TABLE cross_users_stored_queries'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + CREATE TABLE cross_users_stored_queries ( + id INT AUTO_INCREMENT NOT NULL, + category_id INT DEFAULT NULL, + user_id INT DEFAULT NULL, + query_id INT DEFAULT NULL, + name VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, + INDEX IDX_A2E1F665EF946F99 (query_id), + INDEX IDX_A2E1F665A76ED395 (user_id), + INDEX IDX_A2E1F66512469DE2 (category_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql(' + ALTER TABLE cross_users_stored_queries + ADD CONSTRAINT FK_A2E1F66512469DE2 + FOREIGN KEY (category_id) + REFERENCES categories (id) + '); + $this->addSql(' + ALTER TABLE cross_users_stored_queries + ADD CONSTRAINT FK_A2E1F665A76ED395 + FOREIGN KEY (user_id) + REFERENCES users (id) + '); + $this->addSql(' + ALTER TABLE cross_users_stored_queries + ADD CONSTRAINT FK_A2E1F665EF946F99 + FOREIGN KEY (query_id) + REFERENCES queries (id) + '); + $this->addSql('DROP TABLE feeds'); + } +} diff --git a/app/DoctrineMigrations/Version20170220073111.php b/app/DoctrineMigrations/Version20170220073111.php new file mode 100644 index 0000000..ca5503e --- /dev/null +++ b/app/DoctrineMigrations/Version20170220073111.php @@ -0,0 +1,37 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE feeds + ADD publisher_types LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:array)\' + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE feeds DROP publisher_types'); + } +} diff --git a/app/DoctrineMigrations/Version20170220142446.php b/app/DoctrineMigrations/Version20170220142446.php new file mode 100644 index 0000000..ff0a390 --- /dev/null +++ b/app/DoctrineMigrations/Version20170220142446.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE categories ADD type VARCHAR(255) NOT NULL, DROP internal'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE categories ADD internal TINYINT(1) NOT NULL, DROP type'); + } +} diff --git a/app/DoctrineMigrations/Version20170222065333.php b/app/DoctrineMigrations/Version20170222065333.php new file mode 100644 index 0000000..e0580a1 --- /dev/null +++ b/app/DoctrineMigrations/Version20170222065333.php @@ -0,0 +1,42 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users ADD master_user INT DEFAULT NULL'); + $this->addSql(' + ALTER TABLE users + ADD CONSTRAINT FK_1483A5E9A626FB20 + FOREIGN KEY (master_user) REFERENCES users (id)' + ); + $this->addSql('CREATE INDEX IDX_1483A5E9A626FB20 ON users (master_user)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users DROP FOREIGN KEY FK_1483A5E9A626FB20'); + $this->addSql('DROP INDEX IDX_1483A5E9A626FB20 ON users'); + $this->addSql('ALTER TABLE users DROP master_user'); + } +} diff --git a/app/DoctrineMigrations/Version20170223051007.php b/app/DoctrineMigrations/Version20170223051007.php new file mode 100644 index 0000000..1e9255e --- /dev/null +++ b/app/DoctrineMigrations/Version20170223051007.php @@ -0,0 +1,58 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE source_list DROP FOREIGN KEY FK_45427D1BA76ED395'); + $this->addSql(' + ALTER TABLE source_list + ADD CONSTRAINT FK_45427D1BA76ED395 + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + '); + $this->addSql('CREATE INDEX source_search_idx ON sources (title, type)'); + $this->addSql('ALTER TABLE users DROP FOREIGN KEY FK_1483A5E9A626FB20'); + $this->addSql(' + ALTER TABLE users + ADD CONSTRAINT FK_1483A5E9A626FB20 + FOREIGN KEY (master_user) REFERENCES users (id) ON DELETE CASCADE + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE source_list DROP FOREIGN KEY FK_45427D1BA76ED395'); + $this->addSql(' + ALTER TABLE source_list + ADD CONSTRAINT FK_45427D1BA76ED395 + FOREIGN KEY (user_id) REFERENCES users (id) + '); + $this->addSql('DROP INDEX source_search_idx ON sources'); + $this->addSql('ALTER TABLE users DROP FOREIGN KEY FK_1483A5E9A626FB20'); + $this->addSql(' + ALTER TABLE users + ADD CONSTRAINT FK_1483A5E9A626FB20 + FOREIGN KEY (master_user) REFERENCES users (id) + '); + } +} diff --git a/app/DoctrineMigrations/Version20170223075915.php b/app/DoctrineMigrations/Version20170223075915.php new file mode 100644 index 0000000..57d1922 --- /dev/null +++ b/app/DoctrineMigrations/Version20170223075915.php @@ -0,0 +1,45 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + CREATE TABLE site_settings + ( + id INT AUTO_INCREMENT NOT NULL, + title VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + value VARCHAR(255) NOT NULL, + PRIMARY KEY(id) + ) + DEFAULT CHARACTER SET utf8 + COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE site_settings'); + } +} diff --git a/app/DoctrineMigrations/Version20170228093313.php b/app/DoctrineMigrations/Version20170228093313.php new file mode 100644 index 0000000..a2d2016 --- /dev/null +++ b/app/DoctrineMigrations/Version20170228093313.php @@ -0,0 +1,39 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE users + ADD position VARCHAR(255) NOT NULL, + ADD phone_number VARCHAR(255) NOT NULL, + ADD allow_to_create_saved_feeds TINYINT(1) NOT NULL + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users DROP position, DROP phone_number, DROP allow_to_create_saved_feeds'); + } +} diff --git a/app/DoctrineMigrations/Version20170302050031.php b/app/DoctrineMigrations/Version20170302050031.php new file mode 100644 index 0000000..0d188ab --- /dev/null +++ b/app/DoctrineMigrations/Version20170302050031.php @@ -0,0 +1,103 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + CREATE TABLE saved_analyse ( + id INT AUTO_INCREMENT NOT NULL, + name VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY(id) + ) + DEFAULT CHARACTER SET utf8 + COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + $this->addSql(' + CREATE TABLE analytic_reports_to_charts ( + analytic_report_id INT NOT NULL, + chart_id INT NOT NULL, + INDEX IDX_EEB700806ADC7F69 (analytic_report_id), + INDEX IDX_EEB70080BEF83E0A (chart_id), + PRIMARY KEY(analytic_report_id, chart_id) + ) + DEFAULT CHARACTER SET utf8 + COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + $this->addSql(' + CREATE TABLE analytics_reports_to_queries ( + analytic_report_id INT NOT NULL, + stored_query_id INT NOT NULL, + INDEX IDX_1D5B43456ADC7F69 (analytic_report_id), + INDEX IDX_1D5B4345C113F4B (stored_query_id), + PRIMARY KEY(analytic_report_id, stored_query_id) + ) + DEFAULT CHARACTER SET utf8 + COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + $this->addSql(' + CREATE TABLE chart ( + id INT AUTO_INCREMENT NOT NULL, + name VARCHAR(255) NOT NULL, + identifier VARCHAR(255) NOT NULL, + deleted TINYINT(1) NOT NULL, + PRIMARY KEY(id) + ) + DEFAULT CHARACTER SET utf8 + COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + $this->addSql(' + ALTER TABLE analytic_reports_to_charts + ADD CONSTRAINT FK_EEB700806ADC7F69 + FOREIGN KEY (analytic_report_id) REFERENCES saved_analyse (id) + '); + $this->addSql(' + ALTER TABLE analytic_reports_to_charts + ADD CONSTRAINT FK_EEB70080BEF83E0A + FOREIGN KEY (chart_id) REFERENCES chart (id) + '); + $this->addSql(' + ALTER TABLE analytics_reports_to_queries + ADD CONSTRAINT FK_1D5B43456ADC7F69 + FOREIGN KEY (analytic_report_id) REFERENCES saved_analyse (id) + '); + $this->addSql(' + ALTER TABLE analytics_reports_to_queries + ADD CONSTRAINT FK_1D5B4345C113F4B + FOREIGN KEY (stored_query_id) REFERENCES queries (id) + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE analytic_reports_to_charts DROP FOREIGN KEY FK_EEB700806ADC7F69'); + $this->addSql('ALTER TABLE analytics_reports_to_queries DROP FOREIGN KEY FK_1D5B43456ADC7F69'); + $this->addSql('ALTER TABLE analytic_reports_to_charts DROP FOREIGN KEY FK_EEB70080BEF83E0A'); + $this->addSql('DROP TABLE saved_analyse'); + $this->addSql('DROP TABLE analytic_reports_to_charts'); + $this->addSql('DROP TABLE analytics_reports_to_queries'); + $this->addSql('DROP TABLE chart'); + } +} diff --git a/app/DoctrineMigrations/Version20170302072240.php b/app/DoctrineMigrations/Version20170302072240.php new file mode 100644 index 0000000..f4f204f --- /dev/null +++ b/app/DoctrineMigrations/Version20170302072240.php @@ -0,0 +1,57 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE template (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, deleted TINYINT(1) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql(' + CREATE TABLE charts_templates ( + chart_id INT NOT NULL, + template_id INT NOT NULL, + INDEX IDX_3B4D6648BEF83E0A (chart_id), + INDEX IDX_3B4D66485DA0FB8 (template_id), + PRIMARY KEY(chart_id, template_id) + ) + DEFAULT CHARACTER SET utf8 + COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + $this->addSql('CREATE TABLE chart_category (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, deleted TINYINT(1) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE charts_templates ADD CONSTRAINT FK_3B4D6648BEF83E0A FOREIGN KEY (chart_id) REFERENCES chart (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE charts_templates ADD CONSTRAINT FK_3B4D66485DA0FB8 FOREIGN KEY (template_id) REFERENCES template (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE chart ADD chart_category_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chart ADD CONSTRAINT FK_E5562A2A1E65F97D FOREIGN KEY (chart_category_id) REFERENCES chart_category (id)'); + $this->addSql('CREATE INDEX IDX_E5562A2A1E65F97D ON chart (chart_category_id)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE charts_templates DROP FOREIGN KEY FK_3B4D66485DA0FB8'); + $this->addSql('ALTER TABLE chart DROP FOREIGN KEY FK_E5562A2A1E65F97D'); + $this->addSql('DROP TABLE template'); + $this->addSql('DROP TABLE charts_templates'); + $this->addSql('DROP TABLE chart_category'); + $this->addSql('DROP INDEX IDX_E5562A2A1E65F97D ON chart'); + $this->addSql('ALTER TABLE chart DROP chart_category_id'); + } +} diff --git a/app/DoctrineMigrations/Version20170302090715.php b/app/DoctrineMigrations/Version20170302090715.php new file mode 100644 index 0000000..a509068 --- /dev/null +++ b/app/DoctrineMigrations/Version20170302090715.php @@ -0,0 +1,61 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE charts_templates DROP FOREIGN KEY FK_3B4D66485DA0FB8'); + $this->addSql('CREATE TABLE chart_template (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, deleted TINYINT(1) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql(' + CREATE TABLE charts_to_chart_templates ( + chart_id INT NOT NULL, + chart_template_id INT NOT NULL, + INDEX IDX_8A840E3BBEF83E0A (chart_id), + INDEX IDX_8A840E3B5DA0FB8 (chart_template_id), + PRIMARY KEY(chart_id, chart_template_id) + ) + DEFAULT CHARACTER SET utf8 + COLLATE utf8_unicode_ci ENGINE = InnoDB + '); + $this->addSql('ALTER TABLE charts_to_chart_templates ADD CONSTRAINT FK_8A840E3BBEF83E0A FOREIGN KEY (chart_id) REFERENCES chart (id) ON DELETE CASCADE'); + $this->addSql(' + ALTER TABLE charts_to_chart_templates + ADD CONSTRAINT FK_8A840E3B5DA0FB8 + FOREIGN KEY (chart_template_id) + REFERENCES chart_template (id) ON DELETE CASCADE + '); + $this->addSql('DROP TABLE charts_templates'); + $this->addSql('DROP TABLE template'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE charts_to_chart_templates DROP FOREIGN KEY FK_8A840E3B5DA0FB8'); + $this->addSql('CREATE TABLE charts_templates (chart_id INT NOT NULL, template_id INT NOT NULL, INDEX IDX_3B4D6648BEF83E0A (chart_id), INDEX IDX_3B4D66485DA0FB8 (template_id), PRIMARY KEY(chart_id, template_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE template (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, deleted TINYINT(1) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE charts_templates ADD CONSTRAINT FK_3B4D66485DA0FB8 FOREIGN KEY (template_id) REFERENCES template (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE charts_templates ADD CONSTRAINT FK_3B4D6648BEF83E0A FOREIGN KEY (chart_id) REFERENCES chart (id) ON DELETE CASCADE'); + $this->addSql('DROP TABLE chart_template'); + $this->addSql('DROP TABLE charts_to_chart_templates'); + } +} diff --git a/app/DoctrineMigrations/Version20170306085650.php b/app/DoctrineMigrations/Version20170306085650.php new file mode 100644 index 0000000..674d11a --- /dev/null +++ b/app/DoctrineMigrations/Version20170306085650.php @@ -0,0 +1,50 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + CREATE INDEX IDX_8A840E3B9F96B27 ON charts_to_chart_templates (chart_template_id) + '); + $this->addSql(' + DROP INDEX idx_8a840e3b5da0fb8 ON charts_to_chart_templates + '); + +// $this->addSql('ALTER TABLE charts_to_chart_templates RENAME INDEX idx_8a840e3b5da0fb8 TO IDX_8A840E3B9F96B27'); + $this->addSql('ALTER TABLE users CHANGE number_of_subscriber number_of_subscribers INT NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + CREATE INDEX IDX_8A840E3B5DA0FB8 ON charts_to_chart_templates (chart_template_id) + '); + $this->addSql(' + DROP INDEX idx_8a840e3b9f96b27 ON charts_to_chart_templates + '); + +// $this->addSql('ALTER TABLE charts_to_chart_templates RENAME INDEX idx_8a840e3b9f96b27 TO IDX_8A840E3B5DA0FB8'); + $this->addSql('ALTER TABLE users CHANGE number_of_subscribers number_of_subscriber INT NOT NULL'); + } +} diff --git a/app/DoctrineMigrations/Version20170306121643.php b/app/DoctrineMigrations/Version20170306121643.php new file mode 100644 index 0000000..a3721a4 --- /dev/null +++ b/app/DoctrineMigrations/Version20170306121643.php @@ -0,0 +1,65 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE documents + ADD point VARCHAR(255) DEFAULT NULL, + ADD duplicates_count INT DEFAULT NULL, + DROP sequence_range, + DROP hashcode, + DROP index_method, + DROP detection_method, + DROP version, + DROP source_resource, + DROP source_last_updated, + DROP source_update_interval, + DROP source_http_status, + DROP strategy_source_setting_update, + DROP strategy_source_setting_index, + DROP source_feed_href, + DROP source_feed_title, + DROP source_feed_format, + DROP permalink_redirect, + DROP permalink_redirect_domain, + DROP permalink_redirect_site, + DROP canonical, + DROP domain, + DROP site, + DROP extract, + DROP extract_length, + DROP extract_checksum, + DROP card, + DROP metadata_score, + CHANGE section section LONGTEXT DEFAULT NULL, + CHANGE source_assigned_tags tags LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:array)\' + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE documents ADD sequence_range BIGINT DEFAULT NULL, ADD hashcode LONGTEXT DEFAULT NULL COLLATE utf8_unicode_ci, ADD detection_method VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD version VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD source_resource VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD source_last_updated DATETIME DEFAULT NULL, ADD source_update_interval BIGINT DEFAULT NULL, ADD strategy_source_setting_update VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD strategy_source_setting_index VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD source_feed_href VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD source_feed_title VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD source_feed_format VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD permalink_redirect VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD permalink_redirect_domain VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD permalink_redirect_site VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD canonical VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD domain VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD site VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD extract LONGTEXT DEFAULT NULL COLLATE utf8_unicode_ci, ADD extract_length INT DEFAULT NULL, ADD extract_checksum VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD card VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD metadata_score INT DEFAULT NULL, CHANGE section section VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, CHANGE point index_method VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, CHANGE duplicates_count source_http_status INT DEFAULT NULL, CHANGE tags source_assigned_tags LONGTEXT DEFAULT NULL COLLATE utf8_unicode_ci COMMENT \'(DC2Type:array)\''); + } +} diff --git a/app/DoctrineMigrations/Version20170309070704.php b/app/DoctrineMigrations/Version20170309070704.php new file mode 100644 index 0000000..6427ae6 --- /dev/null +++ b/app/DoctrineMigrations/Version20170309070704.php @@ -0,0 +1,40 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE analytics_reports_to_feeds (analytic_report_id INT NOT NULL, feed_id INT NOT NULL, INDEX IDX_3613776B6ADC7F69 (analytic_report_id), INDEX IDX_3613776B51A5BC03 (feed_id), PRIMARY KEY(analytic_report_id, feed_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE analytics_reports_to_feeds ADD CONSTRAINT FK_3613776B6ADC7F69 FOREIGN KEY (analytic_report_id) REFERENCES saved_analyse (id)'); + $this->addSql('ALTER TABLE analytics_reports_to_feeds ADD CONSTRAINT FK_3613776B51A5BC03 FOREIGN KEY (feed_id) REFERENCES feeds (id)'); + $this->addSql('DROP TABLE analytics_reports_to_queries'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE analytics_reports_to_queries (analytic_report_id INT NOT NULL, stored_query_id INT NOT NULL, INDEX IDX_1D5B43456ADC7F69 (analytic_report_id), INDEX IDX_1D5B4345C113F4B (stored_query_id), PRIMARY KEY(analytic_report_id, stored_query_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE analytics_reports_to_queries ADD CONSTRAINT FK_1D5B43456ADC7F69 FOREIGN KEY (analytic_report_id) REFERENCES saved_analyse (id)'); + $this->addSql('ALTER TABLE analytics_reports_to_queries ADD CONSTRAINT FK_1D5B4345C113F4B FOREIGN KEY (stored_query_id) REFERENCES queries (id)'); + $this->addSql('DROP TABLE analytics_reports_to_feeds'); + } +} diff --git a/app/DoctrineMigrations/Version20170309093604.php b/app/DoctrineMigrations/Version20170309093604.php new file mode 100644 index 0000000..0276e87 --- /dev/null +++ b/app/DoctrineMigrations/Version20170309093604.php @@ -0,0 +1,48 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE analytic_reports_to_charts DROP FOREIGN KEY FK_EEB700806ADC7F69'); + $this->addSql('ALTER TABLE analytic_reports_to_charts DROP FOREIGN KEY FK_EEB70080BEF83E0A'); + $this->addSql('ALTER TABLE analytic_reports_to_charts ADD CONSTRAINT FK_EEB700806ADC7F69 FOREIGN KEY (analytic_report_id) REFERENCES saved_analyse (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE analytic_reports_to_charts ADD CONSTRAINT FK_EEB70080BEF83E0A FOREIGN KEY (chart_id) REFERENCES chart (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE analytics_reports_to_feeds DROP FOREIGN KEY FK_3613776B51A5BC03'); + $this->addSql('ALTER TABLE analytics_reports_to_feeds DROP FOREIGN KEY FK_3613776B6ADC7F69'); + $this->addSql('ALTER TABLE analytics_reports_to_feeds ADD CONSTRAINT FK_3613776B51A5BC03 FOREIGN KEY (feed_id) REFERENCES feeds (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE analytics_reports_to_feeds ADD CONSTRAINT FK_3613776B6ADC7F69 FOREIGN KEY (analytic_report_id) REFERENCES saved_analyse (id) ON DELETE CASCADE'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE analytic_reports_to_charts DROP FOREIGN KEY FK_EEB700806ADC7F69'); + $this->addSql('ALTER TABLE analytic_reports_to_charts DROP FOREIGN KEY FK_EEB70080BEF83E0A'); + $this->addSql('ALTER TABLE analytic_reports_to_charts ADD CONSTRAINT FK_EEB700806ADC7F69 FOREIGN KEY (analytic_report_id) REFERENCES saved_analyse (id)'); + $this->addSql('ALTER TABLE analytic_reports_to_charts ADD CONSTRAINT FK_EEB70080BEF83E0A FOREIGN KEY (chart_id) REFERENCES chart (id)'); + $this->addSql('ALTER TABLE analytics_reports_to_feeds DROP FOREIGN KEY FK_3613776B6ADC7F69'); + $this->addSql('ALTER TABLE analytics_reports_to_feeds DROP FOREIGN KEY FK_3613776B51A5BC03'); + $this->addSql('ALTER TABLE analytics_reports_to_feeds ADD CONSTRAINT FK_3613776B6ADC7F69 FOREIGN KEY (analytic_report_id) REFERENCES saved_analyse (id)'); + $this->addSql('ALTER TABLE analytics_reports_to_feeds ADD CONSTRAINT FK_3613776B51A5BC03 FOREIGN KEY (feed_id) REFERENCES feeds (id)'); + } +} diff --git a/app/DoctrineMigrations/Version20170309100456.php b/app/DoctrineMigrations/Version20170309100456.php new file mode 100644 index 0000000..eb7e728 --- /dev/null +++ b/app/DoctrineMigrations/Version20170309100456.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE documents DROP bucket, DROP resource, DROP source_date_found, DROP source_last_published, DROP source_last_posted'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE documents ADD bucket BIGINT DEFAULT NULL, ADD resource LONGTEXT DEFAULT NULL COLLATE utf8_unicode_ci, ADD source_date_found DATETIME DEFAULT NULL, ADD source_last_published DATETIME DEFAULT NULL, ADD source_last_posted DATETIME DEFAULT NULL'); + } +} diff --git a/app/DoctrineMigrations/Version20170309101227.php b/app/DoctrineMigrations/Version20170309101227.php new file mode 100644 index 0000000..807718a --- /dev/null +++ b/app/DoctrineMigrations/Version20170309101227.php @@ -0,0 +1,38 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE saved_analyse ADD user_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE saved_analyse ADD CONSTRAINT FK_BF1AC3E9A76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); + $this->addSql('CREATE INDEX IDX_BF1AC3E9A76ED395 ON saved_analyse (user_id)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE saved_analyse DROP FOREIGN KEY FK_BF1AC3E9A76ED395'); + $this->addSql('DROP INDEX IDX_BF1AC3E9A76ED395 ON saved_analyse'); + $this->addSql('ALTER TABLE saved_analyse DROP user_id'); + } +} diff --git a/app/DoctrineMigrations/Version20170310101811.php b/app/DoctrineMigrations/Version20170310101811.php new file mode 100644 index 0000000..24558ed --- /dev/null +++ b/app/DoctrineMigrations/Version20170310101811.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE documents ADD source_publisher_subtype VARCHAR(255) DEFAULT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE documents DROP source_publisher_subtype'); + } +} diff --git a/app/DoctrineMigrations/Version20170315135446.php b/app/DoctrineMigrations/Version20170315135446.php new file mode 100644 index 0000000..6725df2 --- /dev/null +++ b/app/DoctrineMigrations/Version20170315135446.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE filters_values (hash VARCHAR(255) NOT NULL, data LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', PRIMARY KEY(hash)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE filters_values'); + } +} diff --git a/app/DoctrineMigrations/Version20170323122000.php b/app/DoctrineMigrations/Version20170323122000.php new file mode 100644 index 0000000..8ab20e5 --- /dev/null +++ b/app/DoctrineMigrations/Version20170323122000.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE documents ADD source_date_found DATETIME DEFAULT NULL, DROP source_hashcode, DROP source_content_length, DROP source_content_checksum, DROP main_checksum, DROP main_format, DROP description, DROP type'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE documents ADD source_hashcode VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD source_content_length INT DEFAULT NULL, ADD source_content_checksum VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD main_checksum VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD main_format VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD description VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD type VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, DROP source_date_found'); + } +} diff --git a/app/DoctrineMigrations/Version20170328120620.php b/app/DoctrineMigrations/Version20170328120620.php new file mode 100644 index 0000000..8c2f026 --- /dev/null +++ b/app/DoctrineMigrations/Version20170328120620.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE source_list ADD number_sources INT NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE source_list DROP number_sources'); + } +} diff --git a/app/DoctrineMigrations/Version20170328130638.php b/app/DoctrineMigrations/Version20170328130638.php new file mode 100644 index 0000000..e85c6d5 --- /dev/null +++ b/app/DoctrineMigrations/Version20170328130638.php @@ -0,0 +1,36 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE sources_to_sources_lists DROP PRIMARY KEY'); + $this->addSql('ALTER TABLE sources_to_sources_lists ADD PRIMARY KEY (source_list_id, source_id)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE sources_to_sources_lists DROP PRIMARY KEY'); + $this->addSql('ALTER TABLE sources_to_sources_lists ADD PRIMARY KEY (source_id, source_list_id)'); + } +} diff --git a/app/DoctrineMigrations/Version20170331135621.php b/app/DoctrineMigrations/Version20170331135621.php new file mode 100644 index 0000000..4ce303f --- /dev/null +++ b/app/DoctrineMigrations/Version20170331135621.php @@ -0,0 +1,52 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE sources_to_sections DROP FOREIGN KEY FK_475C417ED823E37A'); + $this->addSql('ALTER TABLE sources_to_sections DROP FOREIGN KEY FK_475C417E953C1C61'); + $this->addSql('ALTER TABLE sources_to_sources_lists DROP FOREIGN KEY FK_6411C8FE953C1C61'); + $this->addSql('CREATE TABLE cross_sources_source_lists (source VARCHAR(255) NOT NULL, list_id INT NOT NULL, INDEX IDX_5972F68E3DAE168B (list_id), PRIMARY KEY(source, list_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE cross_sources_source_lists ADD CONSTRAINT FK_5972F68E3DAE168B FOREIGN KEY (list_id) REFERENCES source_list (id)'); + $this->addSql('DROP TABLE sections'); + $this->addSql('DROP TABLE sources'); + $this->addSql('DROP TABLE sources_to_sections'); + $this->addSql('DROP TABLE sources_to_sources_lists'); + $this->addSql('ALTER TABLE source_list ADD source_number INT NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE sections (id INT AUTO_INCREMENT NOT NULL, title VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE sources (id INT AUTO_INCREMENT NOT NULL, title VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, license VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, country VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, rank INT NOT NULL, url VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, source_publisher_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, state VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, city VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, lang VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, deleted TINYINT(1) NOT NULL, INDEX source_search_idx (title, type), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE sources_to_sections (source_id INT NOT NULL, section_id INT NOT NULL, INDEX IDX_475C417E953C1C61 (source_id), INDEX IDX_475C417ED823E37A (section_id), PRIMARY KEY(source_id, section_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE sources_to_sources_lists (source_list_id INT NOT NULL, source_id INT NOT NULL, INDEX IDX_6411C8FE953C1C61 (source_id), INDEX IDX_6411C8FE7471AEE3 (source_list_id), PRIMARY KEY(source_list_id, source_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE sources_to_sections ADD CONSTRAINT FK_475C417E953C1C61 FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE sources_to_sections ADD CONSTRAINT FK_475C417ED823E37A FOREIGN KEY (section_id) REFERENCES sections (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE sources_to_sources_lists ADD CONSTRAINT FK_6411C8FE7471AEE3 FOREIGN KEY (source_list_id) REFERENCES source_list (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE sources_to_sources_lists ADD CONSTRAINT FK_6411C8FE953C1C61 FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE'); + $this->addSql('DROP TABLE cross_sources_source_lists'); + $this->addSql('ALTER TABLE source_list DROP source_number'); + } +} diff --git a/app/DoctrineMigrations/Version20170411131958.php b/app/DoctrineMigrations/Version20170411131958.php new file mode 100644 index 0000000..f808831 --- /dev/null +++ b/app/DoctrineMigrations/Version20170411131958.php @@ -0,0 +1,38 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE source_list ADD updated_by_id INT DEFAULT NULL, CHANGE updated_at updated_at DATETIME DEFAULT NULL'); + $this->addSql('ALTER TABLE source_list ADD CONSTRAINT FK_45427D1B896DBBDE FOREIGN KEY (updated_by_id) REFERENCES users (id)'); + $this->addSql('CREATE INDEX IDX_45427D1B896DBBDE ON source_list (updated_by_id)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE source_list DROP FOREIGN KEY FK_45427D1B896DBBDE'); + $this->addSql('DROP INDEX IDX_45427D1B896DBBDE ON source_list'); + $this->addSql('ALTER TABLE source_list DROP updated_by_id, CHANGE updated_at updated_at DATETIME NOT NULL'); + } +} diff --git a/app/DoctrineMigrations/Version20170414125354.php b/app/DoctrineMigrations/Version20170414125354.php new file mode 100644 index 0000000..4b957a8 --- /dev/null +++ b/app/DoctrineMigrations/Version20170414125354.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE cross_sources_source_lists CHANGE source source VARBINARY(255) NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE cross_sources_source_lists CHANGE source source VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + } +} diff --git a/app/DoctrineMigrations/Version20170418123149.php b/app/DoctrineMigrations/Version20170418123149.php new file mode 100644 index 0000000..1e104c9 --- /dev/null +++ b/app/DoctrineMigrations/Version20170418123149.php @@ -0,0 +1,54 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE notifications (id INT AUTO_INCREMENT NOT NULL, owner_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, subject VARCHAR(255) DEFAULT NULL, automated_subject TINYINT(1) NOT NULL, published TINYINT(1) NOT NULL, allow_unsubscribe TINYINT(1) NOT NULL, unsubscribe_notification TINYINT(1) NOT NULL, enhanced_html TINYINT(1) NOT NULL, send_when_empty TINYINT(1) NOT NULL, timezone VARCHAR(255) NOT NULL, send_until DATE DEFAULT NULL, active TINYINT(1) NOT NULL, type VARCHAR(255) NOT NULL, article_extracts VARCHAR(11) DEFAULT NULL, highlight_keywords TINYINT(1) DEFAULT NULL, show_source_country TINYINT(1) DEFAULT NULL, show_user_comments TINYINT(1) DEFAULT NULL, show_paragraph_breaks TINYINT(1) DEFAULT NULL, INDEX IDX_6000B0D37E3C61F9 (owner_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE abstract_notification_user (abstract_notification_id INT NOT NULL, user_id INT NOT NULL, INDEX IDX_D77AF04D25D4BF91 (abstract_notification_id), INDEX IDX_D77AF04DA76ED395 (user_id), PRIMARY KEY(abstract_notification_id, user_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE abstract_notification_abstract_feed (abstract_notification_id INT NOT NULL, abstract_feed_id INT NOT NULL, INDEX IDX_897CC52225D4BF91 (abstract_notification_id), INDEX IDX_897CC522F312DA93 (abstract_feed_id), PRIMARY KEY(abstract_notification_id, abstract_feed_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE abstract_notification_chart (abstract_notification_id INT NOT NULL, chart_id INT NOT NULL, INDEX IDX_E261071525D4BF91 (abstract_notification_id), INDEX IDX_E2610715BEF83E0A (chart_id), PRIMARY KEY(abstract_notification_id, chart_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE notification_schedule (id INT AUTO_INCREMENT NOT NULL, notification_id INT DEFAULT NULL, day VARCHAR(10) NOT NULL, time INT NOT NULL, periodically TINYINT(1) NOT NULL, INDEX IDX_F28295EEF1A9D84 (notification_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D37E3C61F9 FOREIGN KEY (owner_id) REFERENCES users (id)'); + $this->addSql('ALTER TABLE abstract_notification_user ADD CONSTRAINT FK_D77AF04D25D4BF91 FOREIGN KEY (abstract_notification_id) REFERENCES notifications (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE abstract_notification_user ADD CONSTRAINT FK_D77AF04DA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE abstract_notification_abstract_feed ADD CONSTRAINT FK_897CC52225D4BF91 FOREIGN KEY (abstract_notification_id) REFERENCES notifications (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE abstract_notification_abstract_feed ADD CONSTRAINT FK_897CC522F312DA93 FOREIGN KEY (abstract_feed_id) REFERENCES feeds (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE abstract_notification_chart ADD CONSTRAINT FK_E261071525D4BF91 FOREIGN KEY (abstract_notification_id) REFERENCES notifications (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE abstract_notification_chart ADD CONSTRAINT FK_E2610715BEF83E0A FOREIGN KEY (chart_id) REFERENCES chart (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE notification_schedule ADD CONSTRAINT FK_F28295EEF1A9D84 FOREIGN KEY (notification_id) REFERENCES notifications (id)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE abstract_notification_user DROP FOREIGN KEY FK_D77AF04D25D4BF91'); + $this->addSql('ALTER TABLE abstract_notification_abstract_feed DROP FOREIGN KEY FK_897CC52225D4BF91'); + $this->addSql('ALTER TABLE abstract_notification_chart DROP FOREIGN KEY FK_E261071525D4BF91'); + $this->addSql('ALTER TABLE notification_schedule DROP FOREIGN KEY FK_F28295EEF1A9D84'); + $this->addSql('DROP TABLE notifications'); + $this->addSql('DROP TABLE abstract_notification_user'); + $this->addSql('DROP TABLE abstract_notification_abstract_feed'); + $this->addSql('DROP TABLE abstract_notification_chart'); + $this->addSql('DROP TABLE notification_schedule'); + } +} diff --git a/app/DoctrineMigrations/Version20170418140509.php b/app/DoctrineMigrations/Version20170418140509.php new file mode 100644 index 0000000..ac947c0 --- /dev/null +++ b/app/DoctrineMigrations/Version20170418140509.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notifications ADD sources_count INT NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notifications DROP sources_count'); + } +} diff --git a/app/DoctrineMigrations/Version20170421093942.php b/app/DoctrineMigrations/Version20170421093942.php new file mode 100644 index 0000000..c95f1a7 --- /dev/null +++ b/app/DoctrineMigrations/Version20170421093942.php @@ -0,0 +1,62 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + // Drop primary key and foreign key indexes. + $this->addSql('ALTER TABLE pages DROP FOREIGN KEY FK_2074E575C33F7837'); + $this->addSql('ALTER TABLE documents DROP PRIMARY KEY'); + + $this->addSql('ALTER TABLE documents CHANGE sequence sequence BIGINT NOT NULL'); + $this->addSql('ALTER TABLE pages CHANGE document_id document_id BIGINT DEFAULT NULL'); + + // Return indexes back. + $this->addSql('ALTER TABLE documents ADD PRIMARY KEY(sequence)'); + $this->addSql(' + ALTER TABLE pages + ADD CONSTRAINT FK_2074E575C33F7837 + FOREIGN KEY (document_id) REFERENCES documents (sequence) + '); + $this->addSql('ALTER TABLE filters_values ADD expires_at DATETIME NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + // Drop primary key and foreign key indexes. + $this->addSql('ALTER TABLE pages DROP FOREIGN KEY FK_2074E575C33F7837'); + $this->addSql('ALTER TABLE documents DROP PRIMARY KEY'); + + $this->addSql('ALTER TABLE documents CHANGE sequence sequence VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('ALTER TABLE pages CHANGE document_id document_id VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci'); + + // Return indexes back. + $this->addSql('ALTER TABLE documents ADD PRIMARY KEY(sequence)'); + $this->addSql(' + ALTER TABLE pages + ADD CONSTRAINT FK_2074E575C33F7837 + FOREIGN KEY (document_id) REFERENCES documents (sequence) + '); + $this->addSql('ALTER TABLE filters_values DROP expires_at'); + } +} diff --git a/app/DoctrineMigrations/Version20170421133622.php b/app/DoctrineMigrations/Version20170421133622.php new file mode 100644 index 0000000..43fd732 --- /dev/null +++ b/app/DoctrineMigrations/Version20170421133622.php @@ -0,0 +1,42 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE queries + ADD raw_filters LONGTEXT NOT NULL COMMENT \'(DC2Type:json_array)\', + ADD raw_advanced_filters LONGTEXT NOT NULL COMMENT \'(DC2Type:json_array)\' + '); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql(' + ALTER TABLE queries + DROP raw_filters, + DROP raw_advanced_filters + '); + } +} diff --git a/app/DoctrineMigrations/Version20170424141108.php b/app/DoctrineMigrations/Version20170424141108.php new file mode 100644 index 0000000..12d8292 --- /dev/null +++ b/app/DoctrineMigrations/Version20170424141108.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE feeds ADD normalized_filters LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:array)\''); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE feeds DROP normalized_filters'); + } +} diff --git a/app/DoctrineMigrations/Version20170525100045.php b/app/DoctrineMigrations/Version20170525100045.php new file mode 100644 index 0000000..9f9e6bc --- /dev/null +++ b/app/DoctrineMigrations/Version20170525100045.php @@ -0,0 +1,36 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE queries ADD external_filters LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:array)\''); + $this->addSql('ALTER TABLE feeds DROP normalized_filters'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE feeds ADD normalized_filters LONGTEXT DEFAULT NULL COLLATE utf8_unicode_ci COMMENT \'(DC2Type:array)\''); + $this->addSql('ALTER TABLE queries DROP external_filters'); + } +} diff --git a/app/DoctrineMigrations/Version20170601052151.php b/app/DoctrineMigrations/Version20170601052151.php new file mode 100644 index 0000000..54bd482 --- /dev/null +++ b/app/DoctrineMigrations/Version20170601052151.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE documents ADD source_hashcode VARCHAR(255) DEFAULT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE documents DROP source_hashcode'); + } +} diff --git a/app/DoctrineMigrations/Version20170621133416.php b/app/DoctrineMigrations/Version20170621133416.php new file mode 100644 index 0000000..da17ca5 --- /dev/null +++ b/app/DoctrineMigrations/Version20170621133416.php @@ -0,0 +1,50 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE notifications_history (id INT AUTO_INCREMENT NOT NULL, notification_id INT DEFAULT NULL, date DATETIME NOT NULL, INDEX IDX_6227D824EF1A9D84 (notification_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE notifications_history ADD CONSTRAINT FK_6227D824EF1A9D84 FOREIGN KEY (notification_id) REFERENCES notifications (id)'); + $this->addSql('ALTER TABLE site_settings ADD section VARCHAR(255) NOT NULL, CHANGE value value LONGTEXT NOT NULL'); + $this->addSql('ALTER TABLE notifications ADD created_at DATETIME NOT NULL, ADD last_sent_at DATETIME NOT NULL, CHANGE timezone timezone VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE notification_schedule ADD type VARCHAR(255) NOT NULL, ADD days VARCHAR(9) DEFAULT NULL, ADD period VARCHAR(7) DEFAULT NULL, ADD hour SMALLINT DEFAULT NULL, ADD minute SMALLINT DEFAULT NULL, DROP periodically, CHANGE day day VARCHAR(10) DEFAULT NULL, CHANGE time time VARCHAR(5) DEFAULT NULL'); + + // + // Create table for string scheduling. This table don't have ORM mapping. + // + $table = $schema->createTable('internal_notification_scheduling'); + $table->addColumn('notification_id', 'integer'); + $table->addColumn('date', 'datetime'); + $table->addForeignKeyConstraint('notifications', [ 'notification_id' ], [ 'id' ]); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE notifications_history'); + $this->addSql('DROP TABLE internal_notification_scheduling'); + $this->addSql('ALTER TABLE notification_schedule ADD periodically TINYINT(1) NOT NULL, DROP type, DROP days, DROP period, DROP hour, DROP minute, CHANGE time time INT NOT NULL, CHANGE day day VARCHAR(10) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('ALTER TABLE notifications DROP created_at, DROP last_sent_at, CHANGE timezone timezone VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('ALTER TABLE site_settings DROP section, CHANGE value value VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + } +} diff --git a/app/DoctrineMigrations/Version20170622142328.php b/app/DoctrineMigrations/Version20170622142328.php new file mode 100644 index 0000000..92d6b39 --- /dev/null +++ b/app/DoctrineMigrations/Version20170622142328.php @@ -0,0 +1,36 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE comments (id INT AUTO_INCREMENT NOT NULL, document_id BIGINT DEFAULT NULL, author_id INT DEFAULT NULL, title VARCHAR(255) NOT NULL, content LONGTEXT NOT NULL, create_at DATETIME NOT NULL, INDEX IDX_5F9E962AC33F7837 (document_id), INDEX IDX_5F9E962AF675F31B (author_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE comments ADD CONSTRAINT FK_5F9E962AC33F7837 FOREIGN KEY (document_id) REFERENCES documents (sequence)'); + $this->addSql('ALTER TABLE comments ADD CONSTRAINT FK_5F9E962AF675F31B FOREIGN KEY (author_id) REFERENCES users (id)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE comments'); + } +} diff --git a/app/DoctrineMigrations/Version20170623141928.php b/app/DoctrineMigrations/Version20170623141928.php new file mode 100644 index 0000000..9513133 --- /dev/null +++ b/app/DoctrineMigrations/Version20170623141928.php @@ -0,0 +1,38 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE comments ADD new TINYINT(1) NOT NULL'); + $this->addSql('DELETE FROM comments'); + $this->addSql('ALTER TABLE documents ADD comments_count INT NOT NULL'); + $this->addSql('UPDATE documents SET comments_count = 0'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE comments DROP new'); + $this->addSql('ALTER TABLE documents DROP comments_count'); + } +} diff --git a/app/DoctrineMigrations/Version20170626123406.php b/app/DoctrineMigrations/Version20170626123406.php new file mode 100644 index 0000000..a46d18a --- /dev/null +++ b/app/DoctrineMigrations/Version20170626123406.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE comments CHANGE create_at created_at DATETIME NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE comments CHANGE created_at create_at DATETIME NOT NULL'); + } +} diff --git a/app/DoctrineMigrations/Version20170627072351.php b/app/DoctrineMigrations/Version20170627072351.php new file mode 100644 index 0000000..cdc8100 --- /dev/null +++ b/app/DoctrineMigrations/Version20170627072351.php @@ -0,0 +1,40 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE pages ADD clip_feed_id INT DEFAULT NULL, DROP score'); + $this->addSql('ALTER TABLE pages ADD CONSTRAINT FK_2074E575E3491210 FOREIGN KEY (clip_feed_id) REFERENCES feeds (id)'); + $this->addSql('CREATE INDEX IDX_2074E575E3491210 ON pages (clip_feed_id)'); + $this->addSql('ALTER TABLE feeds ADD filters LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:array)\', ADD total_count INT DEFAULT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE feeds DROP filters, DROP total_count'); + $this->addSql('ALTER TABLE pages DROP FOREIGN KEY FK_2074E575E3491210'); + $this->addSql('DROP INDEX IDX_2074E575E3491210 ON pages'); + $this->addSql('ALTER TABLE pages ADD score DOUBLE PRECISION NOT NULL, DROP clip_feed_id'); + } +} diff --git a/app/DoctrineMigrations/Version20170627094317.php b/app/DoctrineMigrations/Version20170627094317.php new file mode 100644 index 0000000..74698f9 --- /dev/null +++ b/app/DoctrineMigrations/Version20170627094317.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE feeds ADD raw_filters LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:json_array)\''); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE feeds DROP raw_filters'); + } +} diff --git a/app/DoctrineMigrations/Version20170628113424.php b/app/DoctrineMigrations/Version20170628113424.php new file mode 100644 index 0000000..b70dcc0 --- /dev/null +++ b/app/DoctrineMigrations/Version20170628113424.php @@ -0,0 +1,36 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE recently_used_feeds (id INT AUTO_INCREMENT NOT NULL, feed_id INT DEFAULT NULL, user_id INT DEFAULT NULL, used_at DATETIME NOT NULL, INDEX IDX_7329C41B51A5BC03 (feed_id), INDEX IDX_7329C41BA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE recently_used_feeds ADD CONSTRAINT FK_7329C41B51A5BC03 FOREIGN KEY (feed_id) REFERENCES feeds (id)'); + $this->addSql('ALTER TABLE recently_used_feeds ADD CONSTRAINT FK_7329C41BA76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE recently_used_feeds'); + } +} diff --git a/app/DoctrineMigrations/Version20170628132350.php b/app/DoctrineMigrations/Version20170628132350.php new file mode 100644 index 0000000..99325e8 --- /dev/null +++ b/app/DoctrineMigrations/Version20170628132350.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE emailed_documents (id INT AUTO_INCREMENT NOT NULL, email_to LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', email_reply_to VARCHAR(255) NOT NULL, subject VARCHAR(255) DEFAULT NULL, content LONGTEXT NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE emailed_documents'); + } +} diff --git a/app/DoctrineMigrations/Version20170710081331.php b/app/DoctrineMigrations/Version20170710081331.php new file mode 100644 index 0000000..f81a071 --- /dev/null +++ b/app/DoctrineMigrations/Version20170710081331.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + $this->addSql('ALTER TABLE feeds ADD exported TINYINT(1) NOT NULL'); + $this->addSql('UPDATE feeds SET exported = false'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE feeds DROP exported'); + } +} diff --git a/app/DoctrineMigrations/Version20170718143600.php b/app/DoctrineMigrations/Version20170718143600.php new file mode 100644 index 0000000..e01bb94 --- /dev/null +++ b/app/DoctrineMigrations/Version20170718143600.php @@ -0,0 +1,80 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE cross_notifications_feeds (notification_id INT NOT NULL, abstract_feed_id INT NOT NULL, INDEX IDX_36DBBC7CEF1A9D84 (notification_id), INDEX IDX_36DBBC7CF312DA93 (abstract_feed_id), PRIMARY KEY(notification_id, abstract_feed_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE cross_notifications_charts (notification_id INT NOT NULL, chart_id INT NOT NULL, INDEX IDX_325EA2F0EF1A9D84 (notification_id), INDEX IDX_325EA2F0BEF83E0A (chart_id), PRIMARY KEY(notification_id, chart_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE notification_themes (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, `default` TINYINT(1) NOT NULL, enhanced_summary LONGTEXT NOT NULL, enhanced_conclusion LONGTEXT NOT NULL, enhanced_header LONGTEXT NOT NULL COMMENT \'(DC2Type:object)\', enhanced_fonts LONGTEXT NOT NULL COMMENT \'(DC2Type:object)\', enhanced_content LONGTEXT NOT NULL COMMENT \'(DC2Type:object)\', enhanced_colors LONGTEXT NOT NULL COMMENT \'(DC2Type:object)\', plain_summary LONGTEXT NOT NULL, plain_conclusion LONGTEXT NOT NULL, plain_header LONGTEXT NOT NULL COMMENT \'(DC2Type:object)\', plain_fonts LONGTEXT NOT NULL COMMENT \'(DC2Type:object)\', plain_content LONGTEXT NOT NULL COMMENT \'(DC2Type:object)\', plain_colors LONGTEXT NOT NULL COMMENT \'(DC2Type:object)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE recipients (id INT AUTO_INCREMENT NOT NULL, owner_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, subscribed_count LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', created_at DATETIME NOT NULL, active TINYINT(1) NOT NULL, type VARCHAR(255) NOT NULL, first_name VARCHAR(255) DEFAULT NULL, last_name VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, description LONGTEXT DEFAULT NULL, persons_count INT DEFAULT NULL, INDEX IDX_146632C47E3C61F9 (owner_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE cross_recipient_notifications (abstract_recipient_id INT NOT NULL, notification_id INT NOT NULL, INDEX IDX_CAECF31E7A443649 (abstract_recipient_id), INDEX IDX_CAECF31EEF1A9D84 (notification_id), PRIMARY KEY(abstract_recipient_id, notification_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE cross_groups_persons (group_recipient_id INT NOT NULL, person_recipient_id INT NOT NULL, INDEX IDX_E37D3AB7569C541 (group_recipient_id), INDEX IDX_E37D3AB7A216F35 (person_recipient_id), PRIMARY KEY(group_recipient_id, person_recipient_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE cross_notifications_feeds ADD CONSTRAINT FK_36DBBC7CEF1A9D84 FOREIGN KEY (notification_id) REFERENCES notifications (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE cross_notifications_feeds ADD CONSTRAINT FK_36DBBC7CF312DA93 FOREIGN KEY (abstract_feed_id) REFERENCES feeds (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE cross_notifications_charts ADD CONSTRAINT FK_325EA2F0EF1A9D84 FOREIGN KEY (notification_id) REFERENCES notifications (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE cross_notifications_charts ADD CONSTRAINT FK_325EA2F0BEF83E0A FOREIGN KEY (chart_id) REFERENCES chart (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE recipients ADD CONSTRAINT FK_146632C47E3C61F9 FOREIGN KEY (owner_id) REFERENCES users (id)'); + $this->addSql('ALTER TABLE cross_recipient_notifications ADD CONSTRAINT FK_CAECF31E7A443649 FOREIGN KEY (abstract_recipient_id) REFERENCES recipients (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE cross_recipient_notifications ADD CONSTRAINT FK_CAECF31EEF1A9D84 FOREIGN KEY (notification_id) REFERENCES notifications (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE cross_groups_persons ADD CONSTRAINT FK_E37D3AB7569C541 FOREIGN KEY (group_recipient_id) REFERENCES recipients (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE cross_groups_persons ADD CONSTRAINT FK_E37D3AB7A216F35 FOREIGN KEY (person_recipient_id) REFERENCES recipients (id) ON DELETE CASCADE'); + $this->addSql('DROP TABLE abstract_notification_abstract_feed'); + $this->addSql('DROP TABLE abstract_notification_chart'); + $this->addSql('DROP TABLE abstract_notification_user'); + $this->addSql('ALTER TABLE notifications ADD theme_id INT DEFAULT NULL, ADD notification_type VARCHAR(255) NOT NULL, ADD theme_type VARCHAR(255) NOT NULL, ADD enhanced_theme_options_diff LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', ADD plain_theme_options_diff LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', DROP enhanced_html, DROP type, DROP article_extracts, DROP highlight_keywords, DROP show_source_country, DROP show_user_comments, DROP show_paragraph_breaks, CHANGE timezone timezone VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D359027487 FOREIGN KEY (theme_id) REFERENCES notification_themes (id)'); + $this->addSql('CREATE INDEX IDX_6000B0D359027487 ON notifications (theme_id)'); + $this->addSql('ALTER TABLE users ADD recipient_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E9E92F8F78 FOREIGN KEY (recipient_id) REFERENCES recipients (id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9E92F8F78 ON users (recipient_id)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notifications DROP FOREIGN KEY FK_6000B0D359027487'); + $this->addSql('ALTER TABLE cross_recipient_notifications DROP FOREIGN KEY FK_CAECF31E7A443649'); + $this->addSql('ALTER TABLE cross_groups_persons DROP FOREIGN KEY FK_E37D3AB7569C541'); + $this->addSql('ALTER TABLE cross_groups_persons DROP FOREIGN KEY FK_E37D3AB7A216F35'); + $this->addSql('ALTER TABLE users DROP FOREIGN KEY FK_1483A5E9E92F8F78'); + $this->addSql('CREATE TABLE abstract_notification_abstract_feed (abstract_notification_id INT NOT NULL, abstract_feed_id INT NOT NULL, INDEX IDX_897CC52225D4BF91 (abstract_notification_id), INDEX IDX_897CC522F312DA93 (abstract_feed_id), PRIMARY KEY(abstract_notification_id, abstract_feed_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE abstract_notification_chart (abstract_notification_id INT NOT NULL, chart_id INT NOT NULL, INDEX IDX_E261071525D4BF91 (abstract_notification_id), INDEX IDX_E2610715BEF83E0A (chart_id), PRIMARY KEY(abstract_notification_id, chart_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE abstract_notification_user (abstract_notification_id INT NOT NULL, user_id INT NOT NULL, INDEX IDX_D77AF04D25D4BF91 (abstract_notification_id), INDEX IDX_D77AF04DA76ED395 (user_id), PRIMARY KEY(abstract_notification_id, user_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE abstract_notification_abstract_feed ADD CONSTRAINT FK_897CC52225D4BF91 FOREIGN KEY (abstract_notification_id) REFERENCES notifications (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE abstract_notification_abstract_feed ADD CONSTRAINT FK_897CC522F312DA93 FOREIGN KEY (abstract_feed_id) REFERENCES feeds (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE abstract_notification_chart ADD CONSTRAINT FK_E261071525D4BF91 FOREIGN KEY (abstract_notification_id) REFERENCES notifications (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE abstract_notification_chart ADD CONSTRAINT FK_E2610715BEF83E0A FOREIGN KEY (chart_id) REFERENCES chart (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE abstract_notification_user ADD CONSTRAINT FK_D77AF04D25D4BF91 FOREIGN KEY (abstract_notification_id) REFERENCES notifications (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE abstract_notification_user ADD CONSTRAINT FK_D77AF04DA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE'); + $this->addSql('DROP TABLE cross_notifications_feeds'); + $this->addSql('DROP TABLE cross_notifications_charts'); + $this->addSql('DROP TABLE notification_themes'); + $this->addSql('DROP TABLE recipients'); + $this->addSql('DROP TABLE cross_recipient_notifications'); + $this->addSql('DROP TABLE cross_groups_persons'); + $this->addSql('DROP INDEX IDX_6000B0D359027487 ON notifications'); + $this->addSql('ALTER TABLE notifications ADD enhanced_html TINYINT(1) NOT NULL, ADD type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, ADD article_extracts VARCHAR(11) DEFAULT NULL COLLATE utf8_unicode_ci, ADD highlight_keywords TINYINT(1) DEFAULT NULL, ADD show_source_country TINYINT(1) DEFAULT NULL, ADD show_user_comments TINYINT(1) DEFAULT NULL, ADD show_paragraph_breaks TINYINT(1) DEFAULT NULL, DROP theme_id, DROP notification_type, DROP theme_type, DROP enhanced_theme_options_diff, DROP plain_theme_options_diff, CHANGE timezone timezone VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('DROP INDEX UNIQ_1483A5E9E92F8F78 ON users'); + $this->addSql('ALTER TABLE users DROP recipient_id'); + } +} diff --git a/app/DoctrineMigrations/Version20170720105420.php b/app/DoctrineMigrations/Version20170720105420.php new file mode 100644 index 0000000..6e31ff3 --- /dev/null +++ b/app/DoctrineMigrations/Version20170720105420.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notification_themes ADD published TINYINT(1) NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notification_themes DROP published'); + } +} diff --git a/app/DoctrineMigrations/Version20170726083859.php b/app/DoctrineMigrations/Version20170726083859.php new file mode 100644 index 0000000..1c95018 --- /dev/null +++ b/app/DoctrineMigrations/Version20170726083859.php @@ -0,0 +1,36 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE user_organization (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, organization_id INT NOT NULL, roles VARCHAR(255) NOT NULL, billing_plan VARCHAR(255) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE organizations (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_427C1C7F5E237E06 (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE user_organization'); + $this->addSql('DROP TABLE organizations'); + } +} diff --git a/app/DoctrineMigrations/Version20170728104833.php b/app/DoctrineMigrations/Version20170728104833.php new file mode 100644 index 0000000..eeb69d0 --- /dev/null +++ b/app/DoctrineMigrations/Version20170728104833.php @@ -0,0 +1,35 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + $this->addSql('ALTER TABLE organizations ADD organization_address VARCHAR(255) NOT NULL, ADD organization_email VARCHAR(255) NOT NULL, ADD organization_phone VARCHAR(255) NOT NULL, CHANGE name organization_name VARCHAR(255) NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notifications CHANGE notification_type notification_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE theme_type theme_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE timezone timezone VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('ALTER TABLE organizations ADD name VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, DROP organization_name, DROP organization_address, DROP organization_email, DROP organization_phone'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_427C1C7F5E237E06 ON organizations (name)'); + } +} diff --git a/app/DoctrineMigrations/Version20170731082044.php b/app/DoctrineMigrations/Version20170731082044.php new file mode 100644 index 0000000..4d1467d --- /dev/null +++ b/app/DoctrineMigrations/Version20170731082044.php @@ -0,0 +1,36 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users ADD private_person TINYINT(1) NOT NULL, ADD billing_plan_id INT NOT NULL'); + + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notifications CHANGE notification_type notification_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE theme_type theme_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE timezone timezone VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('ALTER TABLE users DROP private_person, DROP billing_plan_id'); + } +} diff --git a/app/DoctrineMigrations/Version20170801062855.php b/app/DoctrineMigrations/Version20170801062855.php new file mode 100644 index 0000000..69a60ca --- /dev/null +++ b/app/DoctrineMigrations/Version20170801062855.php @@ -0,0 +1,42 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE subscription (id INT AUTO_INCREMENT NOT NULL, plan_id INT NOT NULL, type INT NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE plan (id INT AUTO_INCREMENT NOT NULL, limitPlan INT NOT NULL, price DOUBLE PRECISION NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('DROP TABLE user_organization'); + $this->addSql('ALTER TABLE users DROP billing_plan_id'); + + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE user_organization (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, organization_id INT NOT NULL, roles VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, billing_plan VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('DROP TABLE subscription'); + $this->addSql('DROP TABLE plan'); + $this->addSql('ALTER TABLE notifications CHANGE notification_type notification_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE theme_type theme_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE timezone timezone VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('ALTER TABLE users ADD billing_plan_id INT NOT NULL'); + } +} diff --git a/app/DoctrineMigrations/Version20170801070731.php b/app/DoctrineMigrations/Version20170801070731.php new file mode 100644 index 0000000..19654c5 --- /dev/null +++ b/app/DoctrineMigrations/Version20170801070731.php @@ -0,0 +1,40 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE subscription CHANGE plan_id plan_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE subscription ADD CONSTRAINT FK_A3C664D3E899029B FOREIGN KEY (plan_id) REFERENCES plan (id)'); + $this->addSql('CREATE INDEX IDX_A3C664D3E899029B ON subscription (plan_id)'); + $this->addSql('ALTER TABLE notifications CHANGE timezone timezone VARCHAR(255) NOT NULL, CHANGE notification_type notification_type VARCHAR(255) NOT NULL, CHANGE theme_type theme_type VARCHAR(255) NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notifications CHANGE notification_type notification_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE theme_type theme_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE timezone timezone VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('ALTER TABLE subscription DROP FOREIGN KEY FK_A3C664D3E899029B'); + $this->addSql('DROP INDEX IDX_A3C664D3E899029B ON subscription'); + $this->addSql('ALTER TABLE subscription CHANGE plan_id plan_id INT NOT NULL'); + } +} diff --git a/app/DoctrineMigrations/Version20170801074039.php b/app/DoctrineMigrations/Version20170801074039.php new file mode 100644 index 0000000..d612ae9 --- /dev/null +++ b/app/DoctrineMigrations/Version20170801074039.php @@ -0,0 +1,44 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE subscription ADD organization_id INT DEFAULT NULL, ADD user_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE subscription ADD CONSTRAINT FK_A3C664D332C8A3DE FOREIGN KEY (organization_id) REFERENCES organizations (id)'); + $this->addSql('ALTER TABLE subscription ADD CONSTRAINT FK_A3C664D3A76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); + $this->addSql('CREATE INDEX IDX_A3C664D332C8A3DE ON subscription (organization_id)'); + $this->addSql('CREATE INDEX IDX_A3C664D3A76ED395 ON subscription (user_id)'); + + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notifications CHANGE notification_type notification_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE theme_type theme_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE timezone timezone VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('ALTER TABLE subscription DROP FOREIGN KEY FK_A3C664D332C8A3DE'); + $this->addSql('ALTER TABLE subscription DROP FOREIGN KEY FK_A3C664D3A76ED395'); + $this->addSql('DROP INDEX IDX_A3C664D332C8A3DE ON subscription'); + $this->addSql('DROP INDEX IDX_A3C664D3A76ED395 ON subscription'); + $this->addSql('ALTER TABLE subscription DROP organization_id, DROP user_id'); + } +} diff --git a/app/DoctrineMigrations/Version20170801102300.php b/app/DoctrineMigrations/Version20170801102300.php new file mode 100644 index 0000000..4e1ddd1 --- /dev/null +++ b/app/DoctrineMigrations/Version20170801102300.php @@ -0,0 +1,42 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users DROP private_person'); + $this->addSql('ALTER TABLE subscription ADD organization_address VARCHAR(255) NOT NULL, ADD organization_email VARCHAR(255) NOT NULL, ADD organization_phone VARCHAR(255) NOT NULL, DROP type'); + $this->addSql('ALTER TABLE notifications CHANGE timezone timezone VARCHAR(255) NOT NULL, CHANGE notification_type notification_type VARCHAR(255) NOT NULL, CHANGE theme_type theme_type VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE plan DROP limitPlan'); + $this->addSql('ALTER TABLE organizations DROP organization_address, DROP organization_email, DROP organization_phone'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notifications CHANGE notification_type notification_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE theme_type theme_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE timezone timezone VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('ALTER TABLE organizations ADD organization_address VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, ADD organization_email VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, ADD organization_phone VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('ALTER TABLE plan ADD limitPlan INT NOT NULL'); + $this->addSql('ALTER TABLE subscription ADD type INT NOT NULL, DROP organization_address, DROP organization_email, DROP organization_phone'); + $this->addSql('ALTER TABLE users ADD private_person TINYINT(1) NOT NULL'); + } +} diff --git a/app/DoctrineMigrations/Version20170801113442.php b/app/DoctrineMigrations/Version20170801113442.php new file mode 100644 index 0000000..aa78595 --- /dev/null +++ b/app/DoctrineMigrations/Version20170801113442.php @@ -0,0 +1,44 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE subscriptions (id INT AUTO_INCREMENT NOT NULL, plan_id INT DEFAULT NULL, organization_id INT DEFAULT NULL, user_id INT DEFAULT NULL, type VARCHAR(255) NOT NULL, organization_address VARCHAR(255) DEFAULT NULL, organization_email VARCHAR(255) DEFAULT NULL, organization_phone VARCHAR(255) DEFAULT NULL, INDEX IDX_4778A01E899029B (plan_id), INDEX IDX_4778A0132C8A3DE (organization_id), INDEX IDX_4778A01A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE subscriptions ADD CONSTRAINT FK_4778A01E899029B FOREIGN KEY (plan_id) REFERENCES plan (id)'); + $this->addSql('ALTER TABLE subscriptions ADD CONSTRAINT FK_4778A0132C8A3DE FOREIGN KEY (organization_id) REFERENCES organizations (id)'); + $this->addSql('ALTER TABLE subscriptions ADD CONSTRAINT FK_4778A01A76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); + $this->addSql('DROP TABLE subscription'); + + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE subscription (id INT AUTO_INCREMENT NOT NULL, organization_id INT DEFAULT NULL, user_id INT DEFAULT NULL, plan_id INT DEFAULT NULL, organization_address VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, organization_email VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, organization_phone VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, INDEX IDX_A3C664D3E899029B (plan_id), INDEX IDX_A3C664D332C8A3DE (organization_id), INDEX IDX_A3C664D3A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE subscription ADD CONSTRAINT FK_A3C664D332C8A3DE FOREIGN KEY (organization_id) REFERENCES organizations (id)'); + $this->addSql('ALTER TABLE subscription ADD CONSTRAINT FK_A3C664D3A76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); + $this->addSql('ALTER TABLE subscription ADD CONSTRAINT FK_A3C664D3E899029B FOREIGN KEY (plan_id) REFERENCES plan (id)'); + $this->addSql('DROP TABLE subscriptions'); + $this->addSql('ALTER TABLE notifications CHANGE notification_type notification_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE theme_type theme_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE timezone timezone VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + } +} diff --git a/app/DoctrineMigrations/Version20170801125743.php b/app/DoctrineMigrations/Version20170801125743.php new file mode 100644 index 0000000..f86de61 --- /dev/null +++ b/app/DoctrineMigrations/Version20170801125743.php @@ -0,0 +1,52 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE source_list DROP number_sources, CHANGE source_number source_number INT NOT NULL'); + $this->addSql('ALTER TABLE plan ADD name VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE notifications ADD billing_subscription_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D3CF9564CB FOREIGN KEY (billing_subscription_id) REFERENCES subscriptions (id)'); + $this->addSql('CREATE INDEX IDX_6000B0D3CF9564CB ON notifications (billing_subscription_id)'); + $this->addSql('DROP INDEX UNIQ_427C1C7F5E237E06 ON organizations'); + $this->addSql('ALTER TABLE organizations CHANGE organization_name name VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE users ADD billing_subscription_id INT DEFAULT NULL, DROP organization'); + $this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E9CF9564CB FOREIGN KEY (billing_subscription_id) REFERENCES subscriptions (id)'); + $this->addSql('CREATE INDEX IDX_1483A5E9CF9564CB ON users (billing_subscription_id)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notifications DROP FOREIGN KEY FK_6000B0D3CF9564CB'); + $this->addSql('DROP INDEX IDX_6000B0D3CF9564CB ON notifications'); + $this->addSql('ALTER TABLE notifications DROP billing_subscription_id'); + $this->addSql('ALTER TABLE organizations CHANGE name organization_name VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_427C1C7F5E237E06 ON organizations (organization_name)'); + $this->addSql('ALTER TABLE plan DROP name'); + $this->addSql('ALTER TABLE source_list ADD number_sources INT DEFAULT NULL, CHANGE source_number source_number INT DEFAULT NULL'); + $this->addSql('ALTER TABLE users DROP FOREIGN KEY FK_1483A5E9CF9564CB'); + $this->addSql('DROP INDEX IDX_1483A5E9CF9564CB ON users'); + $this->addSql('ALTER TABLE users ADD organization VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, DROP billing_subscription_id'); + } +} diff --git a/app/DoctrineMigrations/Version20170802062009.php b/app/DoctrineMigrations/Version20170802062009.php new file mode 100644 index 0000000..dfd66ab --- /dev/null +++ b/app/DoctrineMigrations/Version20170802062009.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE recipients CHANGE persons_count recipients_number INT DEFAULT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE recipients CHANGE recipients_number persons_count INT DEFAULT NULL'); + } +} diff --git a/app/DoctrineMigrations/Version20170803125221.php b/app/DoctrineMigrations/Version20170803125221.php new file mode 100644 index 0000000..7bae360 --- /dev/null +++ b/app/DoctrineMigrations/Version20170803125221.php @@ -0,0 +1,41 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notification_schedule ADD history_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE notification_schedule ADD CONSTRAINT FK_F28295E1E058452 FOREIGN KEY (history_id) REFERENCES notifications_history (id)'); + $this->addSql('CREATE INDEX IDX_F28295E1E058452 ON notification_schedule (history_id)'); + $this->addSql('ALTER TABLE internal_notification_scheduling ADD schedules LONGTEXT NOT NULL'); + $this->addSql('UPDATE internal_notification_scheduling SET schedules = \'\''); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notification_schedule DROP FOREIGN KEY FK_F28295E1E058452'); + $this->addSql('DROP INDEX IDX_F28295E1E058452 ON notification_schedule'); + $this->addSql('ALTER TABLE notification_schedule DROP history_id'); + $this->addSql('ALTER TABLE internal_notification_scheduling DROP schedules'); + } +} diff --git a/app/DoctrineMigrations/Version20170807041246.php b/app/DoctrineMigrations/Version20170807041246.php new file mode 100644 index 0000000..f28453c --- /dev/null +++ b/app/DoctrineMigrations/Version20170807041246.php @@ -0,0 +1,35 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE plan ADD searches_per_day INT NOT NULL, ADD saved_feeds INT NOT NULL, ADD master_accounts INT NOT NULL, ADD subscriber_accounts INT NOT NULL, ADD alerts INT NOT NULL, ADD newsletters INT NOT NULL, ADD analytics TINYINT(1) NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notifications CHANGE notification_type notification_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE theme_type theme_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE timezone timezone VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('ALTER TABLE plan DROP searches_per_day, DROP saved_feeds, DROP master_accounts, DROP subscriber_accounts, DROP alerts, DROP newsletters, DROP analytics'); + } +} diff --git a/app/DoctrineMigrations/Version20170809114318.php b/app/DoctrineMigrations/Version20170809114318.php new file mode 100644 index 0000000..dc1d3a2 --- /dev/null +++ b/app/DoctrineMigrations/Version20170809114318.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE subscriptions ADD searches_per_day INT NOT NULL, ADD saved_feeds INT NOT NULL, ADD master_accounts INT NOT NULL, ADD subscriber_accounts INT NOT NULL, ADD alerts INT NOT NULL, ADD newsletters INT NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE subscriptions DROP searches_per_day, DROP saved_feeds, DROP master_accounts, DROP subscriber_accounts, DROP alerts, DROP newsletters'); + } +} diff --git a/app/DoctrineMigrations/Version20170810054937.php b/app/DoctrineMigrations/Version20170810054937.php new file mode 100644 index 0000000..d87fd93 --- /dev/null +++ b/app/DoctrineMigrations/Version20170810054937.php @@ -0,0 +1,36 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users ADD verifyed TINYINT(1) NOT NULL'); + + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notifications CHANGE notification_type notification_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE theme_type theme_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE timezone timezone VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('ALTER TABLE users DROP verifyed'); + } +} diff --git a/app/DoctrineMigrations/Version20170810055624.php b/app/DoctrineMigrations/Version20170810055624.php new file mode 100644 index 0000000..9efb49e --- /dev/null +++ b/app/DoctrineMigrations/Version20170810055624.php @@ -0,0 +1,35 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users CHANGE verifyed verified TINYINT(1) NOT NULL'); + $this->addSql('UPDATE users SET verified = true'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users CHANGE verified verifyed TINYINT(1) NOT NULL'); + } +} diff --git a/app/DoctrineMigrations/Version20170810065300.php b/app/DoctrineMigrations/Version20170810065300.php new file mode 100644 index 0000000..09eb24a --- /dev/null +++ b/app/DoctrineMigrations/Version20170810065300.php @@ -0,0 +1,38 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE payment_tokens (hash VARCHAR(255) NOT NULL, details LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:object)\', after_url LONGTEXT DEFAULT NULL, target_url LONGTEXT NOT NULL, gateway_name VARCHAR(255) NOT NULL, PRIMARY KEY(hash)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE payments (id INT AUTO_INCREMENT NOT NULL, number VARCHAR(255) DEFAULT NULL, description VARCHAR(255) DEFAULT NULL, client_email VARCHAR(255) DEFAULT NULL, client_id VARCHAR(255) DEFAULT NULL, total_amount INT DEFAULT NULL, currency_code VARCHAR(255) DEFAULT NULL, details LONGTEXT NOT NULL COMMENT \'(DC2Type:json_array)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE payment_tokens'); + $this->addSql('DROP TABLE payments'); + + } +} diff --git a/app/DoctrineMigrations/Version20170810124714.php b/app/DoctrineMigrations/Version20170810124714.php new file mode 100644 index 0000000..277c748 --- /dev/null +++ b/app/DoctrineMigrations/Version20170810124714.php @@ -0,0 +1,53 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE jobs'); + $this->addSql('ALTER TABLE subscriptions DROP FOREIGN KEY FK_4778A01A76ED395'); + $this->addSql('DROP INDEX IDX_4778A01A76ED395 ON subscriptions'); + $this->addSql('ALTER TABLE subscriptions CHANGE user_id owner_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE subscriptions ADD CONSTRAINT FK_4778A017E3C61F9 FOREIGN KEY (owner_id) REFERENCES users (id)'); + $this->addSql('CREATE INDEX IDX_4778A017E3C61F9 ON subscriptions (owner_id)'); + $this->addSql('ALTER TABLE recipients DROP FOREIGN KEY FK_146632C47E3C61F9'); + $this->addSql('ALTER TABLE recipients ADD CONSTRAINT FK_146632C47E3C61F9 FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE users DROP FOREIGN KEY FK_1483A5E9CF9564CB'); + $this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E9CF9564CB FOREIGN KEY (billing_subscription_id) REFERENCES subscriptions (id) ON DELETE SET NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE jobs (id INT AUTO_INCREMENT NOT NULL, query_id INT DEFAULT NULL, INDEX IDX_A8936DC5EF946F99 (query_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE jobs ADD CONSTRAINT FK_A8936DC5EF946F99 FOREIGN KEY (query_id) REFERENCES queries (id)'); + $this->addSql('ALTER TABLE recipients DROP FOREIGN KEY FK_146632C47E3C61F9'); + $this->addSql('ALTER TABLE recipients ADD CONSTRAINT FK_146632C47E3C61F9 FOREIGN KEY (owner_id) REFERENCES users (id)'); + $this->addSql('ALTER TABLE subscriptions DROP FOREIGN KEY FK_4778A017E3C61F9'); + $this->addSql('DROP INDEX IDX_4778A017E3C61F9 ON subscriptions'); + $this->addSql('ALTER TABLE subscriptions CHANGE owner_id user_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE subscriptions ADD CONSTRAINT FK_4778A01A76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); + $this->addSql('CREATE INDEX IDX_4778A01A76ED395 ON subscriptions (user_id)'); + $this->addSql('ALTER TABLE users DROP FOREIGN KEY FK_1483A5E9CF9564CB'); + $this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E9CF9564CB FOREIGN KEY (billing_subscription_id) REFERENCES subscriptions (id)'); + } +} diff --git a/app/DoctrineMigrations/Version20170816121934.php b/app/DoctrineMigrations/Version20170816121934.php new file mode 100644 index 0000000..9184807 --- /dev/null +++ b/app/DoctrineMigrations/Version20170816121934.php @@ -0,0 +1,46 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notifications DROP FOREIGN KEY FK_6000B0D37E3C61F9'); + $this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D37E3C61F9 FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE subscriptions DROP FOREIGN KEY FK_4778A017E3C61F9'); + $this->addSql('ALTER TABLE subscriptions ADD CONSTRAINT FK_4778A017E3C61F9 FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE users DROP FOREIGN KEY FK_1483A5E9E92F8F78'); + $this->addSql('ALTER TABLE users DROP number_of_subscribers, DROP number_of_saved_fields_allowed, DROP number_of_newsletters_allowed, DROP number_of_searches_per_day_allowed, DROP allow_to_create_saved_feeds'); + $this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E9E92F8F78 FOREIGN KEY (recipient_id) REFERENCES recipients (id) ON DELETE SET NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notifications DROP FOREIGN KEY FK_6000B0D37E3C61F9'); + $this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D37E3C61F9 FOREIGN KEY (owner_id) REFERENCES users (id)'); + $this->addSql('ALTER TABLE subscriptions DROP FOREIGN KEY FK_4778A017E3C61F9'); + $this->addSql('ALTER TABLE subscriptions ADD CONSTRAINT FK_4778A017E3C61F9 FOREIGN KEY (owner_id) REFERENCES users (id)'); + $this->addSql('ALTER TABLE users DROP FOREIGN KEY FK_1483A5E9E92F8F78'); + $this->addSql('ALTER TABLE users ADD number_of_subscribers INT NOT NULL, ADD number_of_saved_fields_allowed INT NOT NULL, ADD number_of_newsletters_allowed INT NOT NULL, ADD number_of_searches_per_day_allowed INT NOT NULL, ADD allow_to_create_saved_feeds TINYINT(1) NOT NULL'); + $this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E9E92F8F78 FOREIGN KEY (recipient_id) REFERENCES recipients (id)'); + } +} diff --git a/app/DoctrineMigrations/Version20170830113930.php b/app/DoctrineMigrations/Version20170830113930.php new file mode 100644 index 0000000..e34b70c --- /dev/null +++ b/app/DoctrineMigrations/Version20170830113930.php @@ -0,0 +1,46 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE billing_subscription_agreement (id INT AUTO_INCREMENT NOT NULL, subscription_id INT DEFAULT NULL, agreement_id VARCHAR(255) NOT NULL, INDEX IDX_9BD6D8479A1887DC (subscription_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE billing_subscription_agreement ADD CONSTRAINT FK_9BD6D8479A1887DC FOREIGN KEY (subscription_id) REFERENCES subscriptions (id)'); + $this->addSql('DROP TABLE payment_tokens'); + $this->addSql('ALTER TABLE subscriptions ADD payed TINYINT(1) NOT NULL'); + $this->addSql('UPDATE subscriptions SET payed = false'); + $this->addSql('ALTER TABLE payments ADD gateway VARCHAR(255) NOT NULL COMMENT \'(DC2Type:payment_gateway)\', ADD created_at DATETIME NOT NULL, ADD success TINYINT(1) NOT NULL, ADD amount_amount NUMERIC(10, 2) NOT NULL, ADD amount_currency VARCHAR(4) NOT NULL, DROP number, DROP description, DROP client_email, DROP client_id, DROP currency_code, DROP details, CHANGE total_amount subscription_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE payments ADD CONSTRAINT FK_65D29B329A1887DC FOREIGN KEY (subscription_id) REFERENCES subscriptions (id)'); + $this->addSql('CREATE INDEX IDX_65D29B329A1887DC ON payments (subscription_id)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE payment_tokens (hash VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, details LONGTEXT DEFAULT NULL COLLATE utf8_unicode_ci COMMENT \'(DC2Type:object)\', after_url LONGTEXT DEFAULT NULL COLLATE utf8_unicode_ci, target_url LONGTEXT NOT NULL COLLATE utf8_unicode_ci, gateway_name VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, PRIMARY KEY(hash)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('DROP TABLE billing_subscription_agreement'); + $this->addSql('ALTER TABLE payments DROP FOREIGN KEY FK_65D29B329A1887DC'); + $this->addSql('DROP INDEX IDX_65D29B329A1887DC ON payments'); + $this->addSql('ALTER TABLE payments ADD number VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD description VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD client_email VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD client_id VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD currency_code VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD details LONGTEXT NOT NULL COLLATE utf8_unicode_ci COMMENT \'(DC2Type:json_array)\', DROP gateway, DROP created_at, DROP success, DROP amount_amount, DROP amount_currency, CHANGE subscription_id total_amount INT DEFAULT NULL'); + $this->addSql('ALTER TABLE subscriptions DROP payed'); + } +} diff --git a/app/DoctrineMigrations/Version20170904125133.php b/app/DoctrineMigrations/Version20170904125133.php new file mode 100644 index 0000000..6f71ad4 --- /dev/null +++ b/app/DoctrineMigrations/Version20170904125133.php @@ -0,0 +1,38 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE subscriptions ADD gateway VARCHAR(255) NOT NULL COMMENT \'(DC2Type:payment_gateway)\''); + $this->addSql('ALTER TABLE billing_subscription_agreement ADD gateway VARCHAR(255) NOT NULL COMMENT \'(DC2Type:payment_gateway)\''); + $this->addSql('ALTER TABLE payments ADD transaction_id VARCHAR(255) NOT NULL, ADD status VARCHAR(255) NOT NULL COMMENT \'(DC2Type:payment_status)\', DROP success'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE billing_subscription_agreement DROP gateway'); + $this->addSql('ALTER TABLE payments ADD success TINYINT(1) NOT NULL, DROP transaction_id, DROP status'); + $this->addSql('ALTER TABLE subscriptions DROP gateway'); + } +} diff --git a/app/DoctrineMigrations/Version20170905141456.php b/app/DoctrineMigrations/Version20170905141456.php new file mode 100644 index 0000000..4dc1198 --- /dev/null +++ b/app/DoctrineMigrations/Version20170905141456.php @@ -0,0 +1,36 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE deleted_documents (abstract_feed_id INT NOT NULL, document_sequence BIGINT NOT NULL, INDEX IDX_A3B88FFDF312DA93 (abstract_feed_id), INDEX IDX_A3B88FFDDD472672 (document_sequence), PRIMARY KEY(abstract_feed_id, document_sequence)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE deleted_documents ADD CONSTRAINT FK_A3B88FFDF312DA93 FOREIGN KEY (abstract_feed_id) REFERENCES feeds (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE deleted_documents ADD CONSTRAINT FK_A3B88FFDDD472672 FOREIGN KEY (document_sequence) REFERENCES documents (sequence)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('DROP TABLE deleted_documents'); + } +} diff --git a/app/DoctrineMigrations/Version20171026130637.php b/app/DoctrineMigrations/Version20171026130637.php new file mode 100644 index 0000000..0ef7956 --- /dev/null +++ b/app/DoctrineMigrations/Version20171026130637.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE source_list DROP deleted'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE source_list ADD deleted TINYINT(1) NOT NULL'); + } +} diff --git a/app/DoctrineMigrations/Version20171119140842.php b/app/DoctrineMigrations/Version20171119140842.php new file mode 100644 index 0000000..d403170 --- /dev/null +++ b/app/DoctrineMigrations/Version20171119140842.php @@ -0,0 +1,71 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE analytic_reports_to_charts DROP FOREIGN KEY FK_EEB70080BEF83E0A'); + $this->addSql('ALTER TABLE charts_to_chart_templates DROP FOREIGN KEY FK_8A840E3BBEF83E0A'); + $this->addSql('ALTER TABLE cross_notifications_charts DROP FOREIGN KEY FK_325EA2F0BEF83E0A'); + $this->addSql('ALTER TABLE chart DROP FOREIGN KEY FK_E5562A2A1E65F97D'); + $this->addSql('ALTER TABLE charts_to_chart_templates DROP FOREIGN KEY FK_8A840E3B5DA0FB8'); + $this->addSql('ALTER TABLE analytic_reports_to_charts DROP FOREIGN KEY FK_EEB700806ADC7F69'); + $this->addSql('ALTER TABLE analytics_reports_to_feeds DROP FOREIGN KEY FK_3613776B6ADC7F69'); + $this->addSql('CREATE TABLE cache_items (`key` VARCHAR(255) NOT NULL, value LONGTEXT NOT NULL COMMENT \'(DC2Type:json_array)\', lifetime INT NOT NULL, expires_at BIGINT NOT NULL, PRIMARY KEY(`key`)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('DROP TABLE analytic_reports_to_charts'); + $this->addSql('DROP TABLE analytics_reports_to_feeds'); + $this->addSql('DROP TABLE chart'); + $this->addSql('DROP TABLE chart_category'); + $this->addSql('DROP TABLE chart_template'); + $this->addSql('DROP TABLE charts_to_chart_templates'); + $this->addSql('DROP TABLE cross_notifications_charts'); + $this->addSql('DROP TABLE filters_values'); + $this->addSql('DROP TABLE saved_analyse'); + $this->addSql('ALTER TABLE documents DROP html, DROP html_length'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE analytic_reports_to_charts (analytic_report_id INT NOT NULL, chart_id INT NOT NULL, INDEX IDX_EEB700806ADC7F69 (analytic_report_id), INDEX IDX_EEB70080BEF83E0A (chart_id), PRIMARY KEY(analytic_report_id, chart_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE analytics_reports_to_feeds (analytic_report_id INT NOT NULL, feed_id INT NOT NULL, INDEX IDX_3613776B6ADC7F69 (analytic_report_id), INDEX IDX_3613776B51A5BC03 (feed_id), PRIMARY KEY(analytic_report_id, feed_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE chart (id INT AUTO_INCREMENT NOT NULL, chart_category_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, identifier VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, deleted TINYINT(1) NOT NULL, INDEX IDX_E5562A2A1E65F97D (chart_category_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE chart_category (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, deleted TINYINT(1) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE chart_template (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, deleted TINYINT(1) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE charts_to_chart_templates (chart_id INT NOT NULL, chart_template_id INT NOT NULL, INDEX IDX_8A840E3BBEF83E0A (chart_id), INDEX IDX_8A840E3B9F96B27 (chart_template_id), PRIMARY KEY(chart_id, chart_template_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE cross_notifications_charts (notification_id INT NOT NULL, chart_id INT NOT NULL, INDEX IDX_325EA2F0EF1A9D84 (notification_id), INDEX IDX_325EA2F0BEF83E0A (chart_id), PRIMARY KEY(notification_id, chart_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE filters_values (hash VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, data LONGTEXT NOT NULL COLLATE utf8_unicode_ci COMMENT \'(DC2Type:array)\', expires_at DATETIME NOT NULL, PRIMARY KEY(hash)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE saved_analyse (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, INDEX IDX_BF1AC3E9A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE analytic_reports_to_charts ADD CONSTRAINT FK_EEB700806ADC7F69 FOREIGN KEY (analytic_report_id) REFERENCES saved_analyse (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE analytic_reports_to_charts ADD CONSTRAINT FK_EEB70080BEF83E0A FOREIGN KEY (chart_id) REFERENCES chart (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE analytics_reports_to_feeds ADD CONSTRAINT FK_3613776B51A5BC03 FOREIGN KEY (feed_id) REFERENCES feeds (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE analytics_reports_to_feeds ADD CONSTRAINT FK_3613776B6ADC7F69 FOREIGN KEY (analytic_report_id) REFERENCES saved_analyse (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE chart ADD CONSTRAINT FK_E5562A2A1E65F97D FOREIGN KEY (chart_category_id) REFERENCES chart_category (id)'); + $this->addSql('ALTER TABLE charts_to_chart_templates ADD CONSTRAINT FK_8A840E3B5DA0FB8 FOREIGN KEY (chart_template_id) REFERENCES chart_template (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE charts_to_chart_templates ADD CONSTRAINT FK_8A840E3BBEF83E0A FOREIGN KEY (chart_id) REFERENCES chart (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE cross_notifications_charts ADD CONSTRAINT FK_325EA2F0BEF83E0A FOREIGN KEY (chart_id) REFERENCES chart (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE cross_notifications_charts ADD CONSTRAINT FK_325EA2F0EF1A9D84 FOREIGN KEY (notification_id) REFERENCES notifications (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE saved_analyse ADD CONSTRAINT FK_BF1AC3E9A76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); + $this->addSql('DROP TABLE cache_items'); + $this->addSql('ALTER TABLE documents ADD html LONGTEXT DEFAULT NULL COLLATE utf8_unicode_ci, ADD html_length INT DEFAULT NULL'); + } +} diff --git a/app/DoctrineMigrations/Version20171122092350.php b/app/DoctrineMigrations/Version20171122092350.php new file mode 100644 index 0000000..ad3d2d3 --- /dev/null +++ b/app/DoctrineMigrations/Version20171122092350.php @@ -0,0 +1,90 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + // + // Drop foreign keys to `documents` table. + // + $this->addSql('ALTER TABLE comments DROP FOREIGN KEY FK_5F9E962AC33F7837'); + $this->addSql('ALTER TABLE comments CHANGE document_id document_id VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE pages DROP FOREIGN KEY FK_2074E575C33F7837'); + $this->addSql('ALTER TABLE pages CHANGE document_id document_id VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE deleted_documents DROP FOREIGN KEY FK_A3B88FFDDD472672'); + $this->addSql('DROP INDEX IDX_A3B88FFDDD472672 ON deleted_documents'); + $this->addSql('ALTER TABLE deleted_documents ADD document_id VARCHAR(255) NOT NULL, DROP document_sequence'); + + // + // Change `documents` table. + // + $this->addSql('ALTER TABLE documents DROP PRIMARY KEY'); + $this->addSql('ALTER TABLE documents ADD id VARCHAR(255) NOT NULL'); + $this->addSql('UPDATE documents SET id = sequence'); + $this->addSql('ALTER TABLE documents ADD platform VARCHAR(255) NOT NULL, ADD data LONGTEXT NOT NULL COMMENT \'(DC2Type:json_array)\', DROP sequence, DROP section, DROP date_found, DROP source_link, DROP source_publisher_type, DROP tags, DROP source_title, DROP source_description, DROP permalink, DROP main, DROP main_length, DROP summary_text, DROP title, DROP published, DROP publisher, DROP links, DROP author_name, DROP author_link, DROP author_gender, DROP image_src, DROP sentiment, DROP lang, DROP country, DROP state, DROP city, DROP shares, DROP views, DROP source_location, DROP shared_identifier, DROP point, DROP duplicates_count, DROP source_publisher_subtype, DROP source_date_found, DROP source_hashcode'); + $this->addSql('ALTER TABLE documents ADD PRIMARY KEY (id)'); + + // + // Create new foreign keys. + // + $this->addSql('ALTER TABLE comments ADD CONSTRAINT FK_5F9E962AC33F7837 FOREIGN KEY (document_id) REFERENCES documents (id)'); + $this->addSql('ALTER TABLE pages ADD CONSTRAINT FK_2074E575C33F7837 FOREIGN KEY (document_id) REFERENCES documents (id)'); + $this->addSql('ALTER TABLE deleted_documents DROP PRIMARY KEY'); + $this->addSql('ALTER TABLE deleted_documents ADD CONSTRAINT FK_A3B88FFDC33F7837 FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX IDX_A3B88FFDC33F7837 ON deleted_documents (document_id)'); + $this->addSql('ALTER TABLE deleted_documents ADD PRIMARY KEY (abstract_feed_id, document_id)'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + // + // Drop foreign keys to `documents` table. + // + $this->addSql('ALTER TABLE comments DROP FOREIGN KEY FK_5F9E962AC33F7837'); + $this->addSql('ALTER TABLE comments CHANGE document_id document_id BIGINT DEFAULT NULL'); + $this->addSql('ALTER TABLE pages DROP FOREIGN KEY FK_2074E575C33F7837'); + $this->addSql('ALTER TABLE pages CHANGE document_id document_id BIGINT DEFAULT NULL'); + $this->addSql('ALTER TABLE deleted_documents DROP FOREIGN KEY FK_A3B88FFDC33F7837'); + $this->addSql('DROP INDEX IDX_A3B88FFDC33F7837 ON deleted_documents'); + $this->addSql('ALTER TABLE deleted_documents DROP PRIMARY KEY'); + + // + // Change `documents` table. + // + $this->addSql('ALTER TABLE documents DROP PRIMARY KEY'); + $this->addSql('ALTER TABLE documents ADD sequence BIGINT NOT NULL'); + $this->addSql('UPDATE documents SET sequence = id'); + $this->addSql('ALTER TABLE documents ADD section LONGTEXT DEFAULT NULL COLLATE utf8_unicode_ci, ADD date_found DATETIME DEFAULT NULL, ADD source_link VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD source_publisher_type VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD tags LONGTEXT DEFAULT NULL COLLATE utf8_unicode_ci COMMENT \'(DC2Type:array)\', ADD source_title VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD source_description VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD permalink VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD main LONGTEXT DEFAULT NULL COLLATE utf8_unicode_ci, ADD main_length INT DEFAULT NULL, ADD summary_text LONGTEXT DEFAULT NULL COLLATE utf8_unicode_ci, ADD title VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD published DATETIME DEFAULT NULL, ADD publisher VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD links LONGTEXT DEFAULT NULL COLLATE utf8_unicode_ci COMMENT \'(DC2Type:array)\', ADD author_name VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD author_link VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD author_gender VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD image_src VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD sentiment VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD lang VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD country VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD state VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD city VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD shares INT DEFAULT NULL, ADD views INT DEFAULT NULL, ADD source_location VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD shared_identifier VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD point VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD duplicates_count INT DEFAULT NULL, ADD source_publisher_subtype VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, ADD source_date_found DATETIME DEFAULT NULL, ADD source_hashcode VARCHAR(255) DEFAULT NULL COLLATE utf8_unicode_ci, DROP id, DROP platform, DROP data'); + $this->addSql('ALTER TABLE documents ADD PRIMARY KEY (sequence)'); + + // + // Create new foreign keys. + // + $this->addSql('ALTER TABLE comments ADD CONSTRAINT FK_5F9E962AC33F7837 FOREIGN KEY (document_id) REFERENCES documents (sequence)'); + $this->addSql('ALTER TABLE pages ADD CONSTRAINT FK_2074E575C33F7837 FOREIGN KEY (document_id) REFERENCES documents (sequence)'); + $this->addSql('ALTER TABLE deleted_documents ADD document_sequence BIGINT NOT NULL, DROP document_id'); + $this->addSql('ALTER TABLE deleted_documents ADD CONSTRAINT FK_A3B88FFDDD472672 FOREIGN KEY (document_sequence) REFERENCES documents (sequence)'); + $this->addSql('CREATE INDEX IDX_A3B88FFDDD472672 ON deleted_documents (document_sequence)'); + $this->addSql('ALTER TABLE deleted_documents ADD PRIMARY KEY (abstract_feed_id, document_sequence)'); + } +} diff --git a/app/DoctrineMigrations/Version20171206121257.php b/app/DoctrineMigrations/Version20171206121257.php new file mode 100644 index 0000000..f2c81df --- /dev/null +++ b/app/DoctrineMigrations/Version20171206121257.php @@ -0,0 +1,35 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE plan ADD inner_name VARCHAR(255) NOT NULL, CHANGE name title VARCHAR(255) NOT NULL'); + $this->addSql('UPDATE plan SET inner_name = title'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE plan CHANGE inner_name name VARCHAR(255) NOT NULL, DROP title'); + } +} diff --git a/app/DoctrineMigrations/Version20180108134402.php b/app/DoctrineMigrations/Version20180108134402.php new file mode 100644 index 0000000..c6103e0 --- /dev/null +++ b/app/DoctrineMigrations/Version20180108134402.php @@ -0,0 +1,35 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE categories ADD exported TINYINT(1) NOT NULL'); + $this->addSql('UPDATE categories SET exported = false'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE categories DROP exported'); + } +} diff --git a/app/DoctrineMigrations/Version20180131062559.php b/app/DoctrineMigrations/Version20180131062559.php new file mode 100644 index 0000000..eba5302 --- /dev/null +++ b/app/DoctrineMigrations/Version20180131062559.php @@ -0,0 +1,46 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE analytics_context (hash VARCHAR(255) NOT NULL, filters LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', raw_filters LONGTEXT NOT NULL COMMENT \'(DC2Type:json_array)\', PRIMARY KEY(hash)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE cross_analytics_feeds (analytic_context_hash VARCHAR(255) NOT NULL, feed_id INT NOT NULL, INDEX IDX_CDBB0E14B097194 (analytic_context_hash), INDEX IDX_CDBB0E151A5BC03 (feed_id), PRIMARY KEY(analytic_context_hash, feed_id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('CREATE TABLE analytics (id INT AUTO_INCREMENT NOT NULL, owner_id INT DEFAULT NULL, context_id VARCHAR(255) DEFAULT NULL, name VARCHAR(255) NOT NULL, INDEX IDX_EAC2E6887E3C61F9 (owner_id), INDEX IDX_EAC2E6886B00C1CF (context_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); + $this->addSql('ALTER TABLE cross_analytics_feeds ADD CONSTRAINT FK_CDBB0E14B097194 FOREIGN KEY (analytic_context_hash) REFERENCES analytics_context (hash)'); + $this->addSql('ALTER TABLE cross_analytics_feeds ADD CONSTRAINT FK_CDBB0E151A5BC03 FOREIGN KEY (feed_id) REFERENCES feeds (id)'); + $this->addSql('ALTER TABLE analytics ADD CONSTRAINT FK_EAC2E6887E3C61F9 FOREIGN KEY (owner_id) REFERENCES users (id)'); + $this->addSql('ALTER TABLE analytics ADD CONSTRAINT FK_EAC2E6886B00C1CF FOREIGN KEY (context_id) REFERENCES analytics_context (hash)'); + $this->addSql('ALTER TABLE subscriptions ADD analytics TINYINT(1) NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE cross_analytics_feeds DROP FOREIGN KEY FK_CDBB0E14B097194'); + $this->addSql('ALTER TABLE analytics DROP FOREIGN KEY FK_EAC2E6886B00C1CF'); + $this->addSql('DROP TABLE analytics_context'); + $this->addSql('DROP TABLE cross_analytics_feeds'); + $this->addSql('DROP TABLE analytics'); + $this->addSql('ALTER TABLE subscriptions DROP analytics'); + } +} diff --git a/app/DoctrineMigrations/Version20180208075952.php b/app/DoctrineMigrations/Version20180208075952.php new file mode 100644 index 0000000..cbfb60a --- /dev/null +++ b/app/DoctrineMigrations/Version20180208075952.php @@ -0,0 +1,36 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE queries DROP external_filters'); + $this->addSql('ALTER TABLE subscriptions DROP analytics'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE queries ADD external_filters LONGTEXT DEFAULT NULL COLLATE utf8_unicode_ci COMMENT \'(DC2Type:array)\''); + $this->addSql('ALTER TABLE subscriptions ADD analytics TINYINT(1) NOT NULL'); + } +} diff --git a/app/DoctrineMigrations/Version20201129180056.php b/app/DoctrineMigrations/Version20201129180056.php new file mode 100644 index 0000000..95b8054 --- /dev/null +++ b/app/DoctrineMigrations/Version20201129180056.php @@ -0,0 +1,36 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE cross_analytics_feeds DROP FOREIGN KEY FK_CDBB0E14B097194'); + $this->addSql('ALTER TABLE cross_analytics_feeds ADD CONSTRAINT FK_CDBB0E14B097194 FOREIGN KEY (analytic_context_hash) REFERENCES analytics_context (hash) ON DELETE CASCADE'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE cross_analytics_feeds DROP FOREIGN KEY FK_CDBB0E14B097194'); + $this->addSql('ALTER TABLE cross_analytics_feeds ADD CONSTRAINT FK_CDBB0E14B097194 FOREIGN KEY (analytic_context_hash) REFERENCES analytics_context (hash)'); + } +} diff --git a/app/DoctrineMigrations/Version20201130111719.php b/app/DoctrineMigrations/Version20201130111719.php new file mode 100644 index 0000000..f4a8c7e --- /dev/null +++ b/app/DoctrineMigrations/Version20201130111719.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE analytics CHANGE name name VARCHAR(255) DEFAULT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE analytics CHANGE name name VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + } +} diff --git a/app/DoctrineMigrations/Version20201201055354.php b/app/DoctrineMigrations/Version20201201055354.php new file mode 100644 index 0000000..cd1e081 --- /dev/null +++ b/app/DoctrineMigrations/Version20201201055354.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE analytics ADD created_at DATETIME NOT NULL, ADD updated_at DATETIME DEFAULT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE analytics DROP created_at, DROP updated_at'); + } +} diff --git a/app/DoctrineMigrations/Version20210129120533.php b/app/DoctrineMigrations/Version20210129120533.php new file mode 100644 index 0000000..80de772 --- /dev/null +++ b/app/DoctrineMigrations/Version20210129120533.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users ADD company_name VARCHAR(255) DEFAULT NULL, ADD job_function VARCHAR(255) DEFAULT NULL, ADD number_of_employee VARCHAR(255) DEFAULT NULL, ADD industry VARCHAR(255) DEFAULT NULL, ADD website_url VARCHAR(255) DEFAULT NULL, ADD hub_spot TINYINT(1) NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users DROP company_name, DROP job_function, DROP number_of_employee, DROP industry, DROP website_url, DROP hub_spot'); + } +} diff --git a/app/DoctrineMigrations/Version20210212114326.php b/app/DoctrineMigrations/Version20210212114326.php new file mode 100644 index 0000000..743b9ee --- /dev/null +++ b/app/DoctrineMigrations/Version20210212114326.php @@ -0,0 +1,38 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE plan ADD news TINYINT(1) NOT NULL, ADD blog TINYINT(1) NOT NULL, ADD reddit TINYINT(1) NOT NULL, ADD instagram TINYINT(1) NOT NULL, ADD twitter TINYINT(1) NOT NULL'); + $this->addSql('ALTER TABLE subscriptions DROP searchLicence, DROP webFeedLicence, DROP newsletterLicences, DROP useAccounts, DROP newsletter, DROP news, DROP blog, DROP reddit, DROP instagram, DROP twitter, DROP analytics'); + */ + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE plan DROP news, DROP blog, DROP reddit, DROP instagram, DROP twitter'); + $this->addSql('ALTER TABLE subscriptions ADD searchLicence INT NOT NULL, ADD webFeedLicence INT NOT NULL, ADD newsletterLicences INT NOT NULL, ADD useAccounts INT NOT NULL, ADD newsletter INT NOT NULL, ADD news TINYINT(1) NOT NULL, ADD blog TINYINT(1) NOT NULL, ADD reddit TINYINT(1) NOT NULL, ADD instagram TINYINT(1) NOT NULL, ADD twitter TINYINT(1) NOT NULL, ADD analytics TINYINT(1) NOT NULL'); + } +} diff --git a/app/DoctrineMigrations/Version20210212135239.php b/app/DoctrineMigrations/Version20210212135239.php new file mode 100644 index 0000000..a1bb686 --- /dev/null +++ b/app/DoctrineMigrations/Version20210212135239.php @@ -0,0 +1,36 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE plan ADD is_default TINYINT(1) NOT NULL'); + $this->addSql('ALTER TABLE notifications CHANGE timezone timezone VARCHAR(255) NOT NULL COMMENT \'(DC2Type:datetimezone)\', CHANGE notification_type notification_type VARCHAR(255) NOT NULL COMMENT \'(DC2Type:notification_type)\', CHANGE theme_type theme_type VARCHAR(255) NOT NULL COMMENT \'(DC2Type:theme_type)\''); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE notifications CHANGE notification_type notification_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE theme_type theme_type VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci, CHANGE timezone timezone VARCHAR(255) NOT NULL COLLATE utf8_unicode_ci'); + $this->addSql('ALTER TABLE plan DROP is_default'); + } +} diff --git a/app/DoctrineMigrations/Version20210215093151.php b/app/DoctrineMigrations/Version20210215093151.php new file mode 100644 index 0000000..b16600d --- /dev/null +++ b/app/DoctrineMigrations/Version20210215093151.php @@ -0,0 +1,36 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE plan ADD web_feeds INT NOT NULL'); + $this->addSql('ALTER TABLE subscriptions ADD web_feeds INT NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE plan DROP web_feeds'); + $this->addSql('ALTER TABLE subscriptions DROP web_feeds'); + } +} diff --git a/app/DoctrineMigrations/Version20210219090823.php b/app/DoctrineMigrations/Version20210219090823.php new file mode 100644 index 0000000..0b43ede --- /dev/null +++ b/app/DoctrineMigrations/Version20210219090823.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users ADD stripe_user_id VARCHAR(255) DEFAULT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users DROP stripe_user_id'); + } +} diff --git a/app/DoctrineMigrations/Version20210315141150.php b/app/DoctrineMigrations/Version20210315141150.php new file mode 100644 index 0000000..d1bc3bf --- /dev/null +++ b/app/DoctrineMigrations/Version20210315141150.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users ADD is_plan_cancelled TINYINT(1) NOT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users DROP is_plan_cancelled'); + } +} diff --git a/app/DoctrineMigrations/Version20210317131419.php b/app/DoctrineMigrations/Version20210317131419.php new file mode 100644 index 0000000..9deb6ae --- /dev/null +++ b/app/DoctrineMigrations/Version20210317131419.php @@ -0,0 +1,40 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE plan ADD user_id INT DEFAULT NULL, ADD is_plan_downgrade TINYINT(1) NOT NULL'); + $this->addSql('ALTER TABLE plan ADD CONSTRAINT FK_DD5A5B7DA76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); + $this->addSql('CREATE INDEX IDX_DD5A5B7DA76ED395 ON plan (user_id)'); + $this->addSql('ALTER TABLE subscriptions ADD is_subscription_cancelled TINYINT(1) NOT NULL, ADD is_plan_downgrade TINYINT(1) NOT NULL, ADD start_date DATETIME DEFAULT NULL, ADD end_date DATETIME DEFAULT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE plan DROP FOREIGN KEY FK_DD5A5B7DA76ED395'); + $this->addSql('DROP INDEX IDX_DD5A5B7DA76ED395 ON plan'); + $this->addSql('ALTER TABLE plan DROP user_id, DROP is_plan_downgrade'); + $this->addSql('ALTER TABLE subscriptions DROP is_subscription_cancelled, DROP is_plan_downgrade, DROP start_date, DROP end_date'); + } +} diff --git a/app/DoctrineMigrations/Version20210319071842.php b/app/DoctrineMigrations/Version20210319071842.php new file mode 100644 index 0000000..54c60c5 --- /dev/null +++ b/app/DoctrineMigrations/Version20210319071842.php @@ -0,0 +1,33 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE users CHANGE is_plan_cancelled is_plan_cancelled TINYINT(1) DEFAULT NULL'); + $this->addSql('ALTER TABLE subscriptions CHANGE is_subscription_cancelled is_subscription_cancelled TINYINT(1) DEFAULT NULL, CHANGE is_plan_downgrade is_plan_downgrade TINYINT(1) DEFAULT NULL'); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + // this down() migration is auto-generated, please modify it to your needs + + } +} diff --git a/app/Resources/views/base.html.twig b/app/Resources/views/base.html.twig new file mode 100644 index 0000000..f1c5d5a --- /dev/null +++ b/app/Resources/views/base.html.twig @@ -0,0 +1,15 @@ + + + + + + + {%- block title -%}Welcome!{%- endblock -%} + {%- block stylesheets -%}{%- endblock -%} + + + + {%- block body -%}{%- endblock -%} + {%- block javascripts -%}{%- endblock -%} + + diff --git a/app/autoload.php b/app/autoload.php new file mode 100644 index 0000000..fa582ec --- /dev/null +++ b/app/autoload.php @@ -0,0 +1,13 @@ + Update site config" + run "#{try_sudo} sh -c 'cd #{latest_release} && #{php_bin} #{symfony_console} socialhose:site-settings:sync #{console_options}'" + capifony_puts_ok + end + + namespace :update_code do + + desc "Rewrite parameters" + task :rewrite_params do + capifony_pretty_print "--> Rewriting parameters.yml with app/config/parameters.yml.#{stage}" + run "sh -c 'cd #{latest_release} && cp app/config/parameters.yml.#{stage} app/config/parameters.yml'" + capifony_puts_ok + end + + desc "Rewrite .htaccess" + task :rewrite_htaccess do + capifony_pretty_print "--> Rewriting .htaccess with .htaccess.#{stage}" + run "sh -c 'cd #{latest_release} && cp web/.htaccess.#{stage} web/.htaccess'" + capifony_puts_ok + end + + end + + namespace :frontend do + + desc "Rewrite frontend/app/appConfig.js" + task :rewrite_config do + set :file, "frontend/app/appConfig.js" + capifony_pretty_print "--> Rewriting #{file} with #{file}.#{stage}" + run "sh -c 'cd #{latest_release} && cp #{file}.#{stage} #{file}'" + capifony_puts_ok + end + + desc "Install node modules" + task :install do + capifony_pretty_print "--> Install node modules" + run "sh -c 'cd #{latest_release}/frontend && npm install'" + capifony_puts_ok + end + + desc "Build" + task :build do + capifony_pretty_print "--> Build forntend" + run "sh -c 'cd #{latest_release}/frontend && npm run build'" + capifony_puts_ok + end + + end + +end + +# Dependences +before "symfony:composer:install", "deployment:update_code:rewrite_params" +before "symfony:composer:install", "deployment:update_code:rewrite_htaccess" +before "symfony:composer:install", "deployment:frontend:rewrite_config" +before "symfony:composer:update", "deployment:update_code:rewrite_params" +before "symfony:composer:update", "deployment:update_code:rewrite_htaccess" +before "symfony:composer:update", "deployment:frontend:rewrite_config" +before "symfony:cache:warmup", "symfony:doctrine:migrations:migrate" + +after "deployment:frontend:rewrite_config", "deployment:frontend:install" +after "deployment:frontend:install", "deployment:frontend:build" + +after "deploy", "deploy:cleanup" +after "deploy:cleanup", "deployment:update_site_config" + +# Logging +logger.level = Logger::MAX_LEVEL diff --git a/app/config/routing.yml b/app/config/routing.yml new file mode 100644 index 0000000..17b3b0f --- /dev/null +++ b/app/config/routing.yml @@ -0,0 +1,42 @@ +# +# Administrative panel +# +admin: + resource: '@AdminBundle/Controller' + prefix: /admin +# +# First versions of api. +# +api_v1: + resource: 'routing_api_v1.yml' + prefix: /api/v1 + +# +# Security routes for creating and refreshing JWT tokens and also for +# registration. +# +security: + resource: 'routing_api_security.yml' + prefix: /security + +# +# Main application. +# +app: + resource: '@AppBundle/Controller/IndexController.php' + type: annotation + +payment: + resource: '@PaymentBundle/Controller/IpnController.php' + type: annotation + prefix: '/billing' + + +register_finish: + path: '/auth/register-finish' + methods: [ 'GET' ] + defaults: + _controller: 'AppBundle:Index:index' + +miracode_stripe: + resource: '@MiracodeStripeBundle/Resources/config/routing.xml' \ No newline at end of file diff --git a/app/config/routing_api_security.yml b/app/config/routing_api_security.yml new file mode 100644 index 0000000..caa5f73 --- /dev/null +++ b/app/config/routing_api_security.yml @@ -0,0 +1,17 @@ +user_security: + resource: '@UserBundle/Resources/config/routing/security.yml' + +authentication: + resource: '@AuthenticationBundle/Controller/TokenController.php' + type: annotation + +fos_user: + resource: "@FOSUserBundle/Resources/config/routing/all.xml" + +fos_user_registration_confirm: + path: /registration/confirm/{token} + defaults: { _controller: user.controller.registration:confirmAction } + +fos_user_registration_confirmed: + path: /registration/confirmed + defaults: { _controller: UserBundle:Security:Registration:confirmed } diff --git a/app/config/routing_api_v1.yml b/app/config/routing_api_v1.yml new file mode 100644 index 0000000..577545d --- /dev/null +++ b/app/config/routing_api_v1.yml @@ -0,0 +1,5 @@ +app_v1: + resource: '@AppBundle/Resources/config/routing/v1.yml' + +user_v1: + resource: '@UserBundle/Resources/config/routing/v1.yml' diff --git a/app/config/routing_dev.yml b/app/config/routing_dev.yml new file mode 100644 index 0000000..9a0e705 --- /dev/null +++ b/app/config/routing_dev.yml @@ -0,0 +1,23 @@ +_wdt: + resource: "@WebProfilerBundle/Resources/config/routing/wdt.xml" + prefix: /_wdt + +_profiler: + resource: "@WebProfilerBundle/Resources/config/routing/profiler.xml" + prefix: /_profiler + +_errors: + resource: "@TwigBundle/Resources/config/routing/errors.xml" + prefix: /_error + +api_doc: + resource: "@NelmioApiDocBundle/Resources/config/routing.yml" + prefix: /doc + +_email: + type: annotation + resource: "@UserBundle/Controller/Developing" + prefix: /developing + +_main: + resource: routing.yml diff --git a/app/config/routing_stage.yml b/app/config/routing_stage.yml new file mode 100644 index 0000000..49661a2 --- /dev/null +++ b/app/config/routing_stage.yml @@ -0,0 +1,6 @@ +api_doc: + resource: "@NelmioApiDocBundle/Resources/config/routing.yml" + prefix: /doc + +_main: + resource: routing.yml diff --git a/app/config/security.yml b/app/config/security.yml new file mode 100644 index 0000000..40e7073 --- /dev/null +++ b/app/config/security.yml @@ -0,0 +1,68 @@ +security: + encoders: + FOS\UserBundle\Model\UserInterface: bcrypt + + role_hierarchy: + # Ordinary users hierarchy. + ROLE_SUBSCRIBER: [] + ROLE_MASTER_USER: [ ROLE_SUBSCRIBER ] + + # Admins hierarchy. + ROLE_ADMIN: [] + ROLE_SUPER_ADMIN: [ ROLE_ADMIN ] + + providers: + socialhose_user: + id: authentication_bundle.user_provider + + socialhose_admin: + id: fos_user.user_provider.username + + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + + registration: + pattern: ^/security/registration + stateless: true + anonymous: true + + token_create: + pattern: ^/security/token/create + stateless: true + anonymous: true + socialhose_auth: ~ + + token_refresh: + pattern: ^/security/token/refresh + stateless: true + anonymous: true + + api: + pattern: ^/api + stateless: true + provider: socialhose_user + guard: + authenticators: + - lexik_jwt_authentication.jwt_token_authenticator + + admin: + pattern: ^/admin + anonymous: ~ + provider: socialhose_admin + form_login: + login_path: admin_login + check_path: admin_login_check + target_path_parameter: admin_dashboard + default_target_path: admin_dashboard + logout: + path: admin_logging_out + target: admin_login + + access_control: + - { path: ^/security, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } + - { path: ^/admin/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/admin, roles: ROLE_ADMIN } + - { path: ^/, roles: IS_AUTHENTICATED_ANONYMOUSLY } \ No newline at end of file diff --git a/app/config/services.yml b/app/config/services.yml new file mode 100644 index 0000000..d95e342 --- /dev/null +++ b/app/config/services.yml @@ -0,0 +1,8 @@ +imports: + - { resource: '@ApiBundle/Resources/config/services.yml' } + - { resource: '@CacheBundle/Resources/config/services.yml' } + - { resource: '@UserBundle/Resources/config/services.yml' } + - { resource: '@AuthenticationBundle/Resources/config/services.yml' } + - { resource: '@AdminBundle/Resources/config/services.yml' } + - { resource: '@QueueBundle/Resources/config/services.yml' } + - { resource: '@PaymentBundle/Resources/config/services.yml' } diff --git a/app/config/stage/demo.rb b/app/config/stage/demo.rb new file mode 100644 index 0000000..dedeff8 --- /dev/null +++ b/app/config/stage/demo.rb @@ -0,0 +1,13 @@ +server "10.1.1.55", :web, :app, :db, :primary => true, :no_release => false +set :deploy_root, "/var/www/html/new/" #project root path on server +set :deploy_to, "#{deploy_root}/#{deploy_dir}" +set :user, "jenkins" + +after "deploy:create_symlink" do + capifony_pretty_print "--> run #{update_cmd}" + run "sh -c 'cd #{latest_release}; #{update_cmd}'" + + capifony_pretty_print "--> Creating symlimk for web folder" + run "sh -c 'rm -rf #{deploy_root}/web && ln -s #{latest_release}/web #{deploy_root}/web'" + capifony_puts_ok +end \ No newline at end of file diff --git a/app/config/stage/development.rb b/app/config/stage/development.rb new file mode 100644 index 0000000..d8b7f49 --- /dev/null +++ b/app/config/stage/development.rb @@ -0,0 +1,23 @@ +server "192.168.0.110", :web, :app, :db, :primary => true, :no_release => false +set :deploy_root, "/var/www/html/new" #project root path on server +set :deploy_to, "#{deploy_root}/#{deploy_dir}" +set :user, "socialhose" +ssh_options[:forward_agent] = true +ssh_options[:port] = "22" +set :deploy_via, :rsync_with_remote_cache +set :rsync_options, "--recursive --delete --delete-excluded --exclude .git* --exclude .build*" +set :controllers_to_clear, [ "app_dev.php", "app_test.php" ] +set :symfony_env_prod, "stage" + +after "deploy:create_symlink" do + capifony_pretty_print "--> Creating symlimk for web folder" + run "sh -c 'rm -rf #{deploy_root}/web && ln -s #{latest_release}/web #{deploy_root}/web'" + capifony_puts_ok + + capifony_pretty_print "--> Restart workers" + run "sh -c 'supervisorctl restart documents_email'" + run "sh -c 'supervisorctl restart documents_fetching'" + run "sh -c 'supervisorctl restart notification_fetching'" + run "sh -c 'supervisorctl restart notification_sending'" + capifony_puts_ok +end diff --git a/app/config/stage/production.rb b/app/config/stage/production.rb new file mode 100644 index 0000000..cae57a1 --- /dev/null +++ b/app/config/stage/production.rb @@ -0,0 +1,23 @@ +server "34.228.99.0", :web, :app, :db, :primary => true, :no_release => false +set :deploy_root, "/var/www/socialhose/" #project root path on server +set :deploy_to, "#{deploy_root}/#{deploy_dir}" +set :user, "deploy" +set :branch, "master" +set :webserver_user, "nginx" +set :controllers_to_clear, ['app_dev.php', 'app_test.php', 'app_stage.php'] +set :symfony_env_prod, "prod" +set :deploy_via, :rsync_with_remote_cache +set :rsync_options, "--recursive --delete --delete-excluded --exclude .git* --exclude .build*" + +after "deploy:create_symlink" do + capifony_pretty_print "--> Creating symlimk for web folder" + run "sh -c 'rm -rf #{deploy_root}/web && ln -s #{latest_release}/web #{deploy_root}/web'" + capifony_puts_ok + + capifony_pretty_print "--> Restart workers" + run "sh -c 'supervisorctl restart documents_email:*'" + run "sh -c 'supervisorctl restart documents_fetching:*'" + run "sh -c 'supervisorctl restart notification_fetching:*'" + run "sh -c 'supervisorctl restart notification_sending:*'" + capifony_puts_ok +end diff --git a/behat.yml b/behat.yml new file mode 100644 index 0000000..0fcd323 --- /dev/null +++ b/behat.yml @@ -0,0 +1,44 @@ +default: + # + # Auto load contexts + # + autoload: + '': %paths.base%/behat/ + suites: + + # + # Suite for commands. + # + command: + contexts: + - Command\Context\CommandContext: + - '@service_container' + - '%paths.base%/src/AppBundle/DataFixtures/' + + # + # Paths to command features. + # + paths: + - %paths.base%/behat/Command/features/ + + # + # Suite for first version of api. + # + api: + contexts: + - Api\Context\ApiContext: + - '@service_container' + - 'http://socialhose.local/' + - '%paths.base%/src/AppBundle/DataFixtures/' + + # + # Paths to show features for api v1 tests. + # + paths: + - %paths.base%/behat/Api/features/Security/ + - %paths.base%/behat/Api/features/V1/ + extensions: + Behat\Symfony2Extension: + kernel: + env: test + debug: true diff --git a/behat/Api/Context/ApiContext.php b/behat/Api/Context/ApiContext.php new file mode 100644 index 0000000..a34057c --- /dev/null +++ b/behat/Api/Context/ApiContext.php @@ -0,0 +1,232 @@ +apiConnection = new ApiConnection($baseUrl, $this->debug); + } + + /** + * Make request to api. + * + * @Given /^(?:|I )[Mm]ake (?PGET|POST|PUT|DELETE) request to (?P.+)$/ + * + * @param string $method HTTP method name. + * @param string $endpoint Relative to base url. + * @param mixed $payload Request parameters. + * + * @return void + */ + public function request($method, $endpoint, $payload = null) + { + switch (true) { + case $payload instanceof TableNode: + $table = $payload->getTable(); + $payload = []; + + foreach ($table as $row) { + $payload[current($row)] = next($row); + } + break; + + case $payload instanceof PyStringNode: + $payload = (array) json_decode($payload->getRaw(), true); + $payload = array_filter($payload); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \InvalidArgumentException( + 'Request error: ' . json_last_error_msg() + ); + } + break; + + default: + $payload = []; + } + + $payload = $this->processor->process($payload); + + $this->apiConnection->request($method, $endpoint, $payload, $this->authToken); + } + + /** + * @Then /^(?:|I )[Gg]ot response with code (?P\d+)$/ + * @Then /^(?:|I )[Gg]ot successful response$/ + * + * @param integer $expected Expected response. + * + * @return void + */ + public function checkStatus($expected = 200) + { + self::assertEquals( + $expected, + $this->apiConnection->getLastResponse()->getStatusCode() + ); + } + + /** + * @Then /^(?:|I )[Gg]ot response with content$/ + * @Then /^(?:|[Rr]esponse|[Ii]t's )[Cc]ontains$/ + * + * @param PyStringNode $pattern Pattern for coduo/PHPMatcher. + * + * @return void + */ + public function responseMatch(PyStringNode $pattern) + { + $error = ''; + + self::assertTrue( + $this->match( + $this->apiConnection->getLastResponseData(), + $pattern->getRaw(), + $error + ), + $error + ); + } + + /** + * @Then /^(?:|I )[Gg]ot empty response$/ + * @Then /^[Rr]esponse is empty$/ + * @Then /^[Ii]t's empty$/ + * + * @return void + */ + public function emptyResponse() + { + $error = ''; + + self::assertTrue( + PHPMatcher::match( + $this->apiConnection->getLastResponseData(), + '', + $error + ), + $error + ); + } + + /** + * @Given /^(?:|I )[Aa]uthenticated as (?P[\w@\.]+) with password (?P.+)$/ + * + * @param string $email User email. + * @param string $password User password. + * + * @return void + */ + public function authenticate($email, $password) + { + $this->apiConnection + ->request('POST', '/security/token/create', [ + 'email' => $email, + 'password' => $password, + ]); + + self::assertEquals( + 200, + $this->apiConnection->getLastResponse()->getStatusCode() + ); + $response = json_decode( + $this->apiConnection->getLastResponseData(), + true + ); + self::assertTrue(is_array($response), 'Invalid response from server'); + self::assertArrayHasKey('token', $response); + $this->authToken = $response['token']; + } + + /** + * @Then /^(?:[Oo]ne|(?P\d+)) [Ee]mail is sent$/ + * + * @param integer $count Expected emails count. + * + * @return void + */ + public function emailsCount($count = 1) + { + $mailer = $this->getMailCollector(); + + self::assertEquals($count, $mailer->getMessageCount()); + } + + /** + * @Then /^[Nn]o emails sent$/ + * + * @return void + */ + public function noEmailsSent() + { + $mailer = $this->getMailCollector(); + + self::assertEquals(0, $mailer->getMessageCount()); + } + + /** + * @Then /^(?:|[Ff]irst )[Ee]mail subject is "(?P[^"]+)"$/ + * @Then /^(?P\d+) email subject is "(?P[^"]+)"$/ + * + * @param string $subject Expected email subject. + * @param integer $index Email index. + * + * @return void + */ + public function emailSubject($subject, $index = 0) + { + $mailer = $this->getMailCollector(); + + /** @var \Swift_Message $message */ + $message = $mailer->getMessages()[$index]; + self::assertEquals($subject, $message->getSubject()); + } + + /** + * @return \Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface|\Symfony\Bundle\SwiftmailerBundle\DataCollector\MessageDataCollector + */ + protected function getMailCollector() + { + /** @var Profiler $profiler */ + $profiler = $this->container->get('profiler'); + $token = current($this->apiConnection->getLastResponse() + ->getHeader('X-Debug-Token')); + + return $profiler->loadProfile($token)->getCollector('swiftmailer'); + } +} diff --git a/behat/Api/Util/ApiConnection.php b/behat/Api/Util/ApiConnection.php new file mode 100644 index 0000000..b0e6269 --- /dev/null +++ b/behat/Api/Util/ApiConnection.php @@ -0,0 +1,162 @@ + 'PHPSTORM', + ], parse_url($baseUrl, PHP_URL_HOST)); + } + + $this->client = new Client([ + 'base_uri' => $baseUrl, + 'cookies' => $cookies, + ]); + $this->debug = $debug; + } + + /** + * Make request to api. + * + * @param string $method HTTP method name. + * @param string $endpoint Relative to base url. + * @param array $payload Request payload, may send as query string or as + * json relative to method name. + * @param string $authToken Application authentication token. + * + * @return mixed|\Psr\Http\Message\ResponseInterface + */ + public function request( + $method, + $endpoint, + array $payload = [], + $authToken = null + ) { + $method = strtoupper($method); + + // Prepare full endpoint url. + $endpoint = self::FRONT_CONTROLLER . '/' . ltrim($endpoint, '/'); + + // Prepare request options. + $options = []; + // Add authorization token if it provided. + if ($authToken !== null) { + $options['headers'] = [ 'Authorization' => 'Bearer '. $authToken ]; + } + + // Send payload as query string for 'GET' request. + // For other methods, send in content as json. + $options[($method === 'GET') ? 'query' : 'json'] = $payload; + + // Show debug information about request. + if ($this->debug) { + echo 'Request: ' . $this->client->getConfig('base_uri') + . $endpoint. PHP_EOL; + echo 'Request options: ' . PHP_EOL + . json_encode($options, JSON_PRETTY_PRINT) + . PHP_EOL; + } + + try { + // Make request to api and save response to class. + $this->lastResponseData = null; + $this->lastResponse = + $this->client->request($method, $endpoint, $options); + } catch (BadResponseException $e) { + $this->lastResponse = $e->getResponse(); + } + + if ($this->debug) { + $decodedResponse = json_decode($this->getLastResponseData(), true); + + if ($decodedResponse) { + // In debug mode dump data received from server to output. + print('Response data: ' . PHP_EOL + . json_encode( + $decodedResponse, + JSON_PRETTY_PRINT + ). PHP_EOL + ); + } else { + print $this->getLastResponseData().PHP_EOL; + } + } + + return $this->lastResponse; + } + + /** + * Get last response. + * + * @return Response + */ + public function getLastResponse() + { + return $this->lastResponse; + } + + /** + * Get data from last response. + * + * @return null|string + */ + public function getLastResponseData() + { + if (($this->lastResponseData === null) && $this->lastResponse) { + $this->lastResponseData = $this->lastResponse + ->getBody() + ->getContents(); + } + + return $this->lastResponseData; + } +} diff --git a/behat/Api/features/Security/token/create.feature b/behat/Api/features/Security/token/create.feature new file mode 100644 index 0000000..af6fab1 --- /dev/null +++ b/behat/Api/features/Security/token/create.feature @@ -0,0 +1,83 @@ +Feature: Create authentication token + As an anonymous user + I should be able to obtain authentication token in order to make request + to api + + @db-fixtures + Scenario: + I try to create token with proper data + + Given I make POST request to /security/token/create + """ + { + "email": "test@email.com", + "password": "test" + } + """ + And I got response with code 200 + And it's contains + """ + { + "user": "@object@ + .entity('UserBundle:User', 'user, id, recipient, restrictions') + .field('firstName', 'John') + .field('lastName', 'Smith') + ", + "token": "@string@", + "refreshToken": "@string@" + } + """ + + + Scenario Outline: + I try to create token without providing any data . + + Given I make POST request to /security/token/create + """ + { + + } + """ + And I got response with code 400 + And it's contains + """ + { + "errors": [ + "Credentials not provided." + ] + } + """ + + Examples: + | payload | + | "email": "test@email.com" | + | "password": "test" | + | | + + + @db-fixtures + Scenario Outline: + I try to create token with invalid data. + + Given I make POST request to /security/token/create + """ + { + "email": "", + "password": "" + } + """ + And I got response with code 401 + And it's contains + """ + { + "errors": [ + "Bad credentials." + ] + } + """ + + Examples: + | email | password | + | test@email.com | invalid | + | unknown@mail1.dev | test | + diff --git a/behat/Api/features/Security/token/refresh.feature b/behat/Api/features/Security/token/refresh.feature new file mode 100644 index 0000000..4d484b5 --- /dev/null +++ b/behat/Api/features/Security/token/refresh.feature @@ -0,0 +1,67 @@ +Feature: Refresh authentication token + As an authenticated user + I should be able to obtain authentication token by using my refresh token + + @db-fixtures + Scenario: + I try to refresh authentication token. + + Given I make POST request to /security/token/refresh + """ + { + "refreshToken": "user1_token" + } + """ + And I got response with code 200 + And it's contains + """ + { + "user": "@object@ + .entity('UserBundle:User', 'user, id, recipient, restrictions') + .field('firstName', 'John') + .field('lastName', 'Smith') + ", + "token": "@string@", + "refreshToken": "@string@" + } + """ + + + Scenario: + I try to refresh authentication token without refresh token provided. + + Given I make POST request to /security/token/refresh + """ + { + } + """ + And I got response with code 400 + And it's contains + """ + { + "errors": [ + "refreshToken: This value should not be null." + ] + } + """ + + @db-fixtures + Scenario: + I try to refresh authentication token by invalid refresh token. + + Given I make POST request to /security/token/refresh + """ + { + "refreshToken": "some token" + } + """ + And I got response with code 401 + And it's contains + """ + { + "errors": [ + "Refresh token \"some token\" does not exist." + ] + } + """ + diff --git a/behat/Api/features/V1/category/create.feature b/behat/Api/features/V1/category/create.feature new file mode 100644 index 0000000..fe16460 --- /dev/null +++ b/behat/Api/features/V1/category/create.feature @@ -0,0 +1,78 @@ +Feature: Create category + As an authenticated user + I should able to create new category + + @db-fixtures + Scenario: + I try to create new category. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/categories + """ + { + "name": "new category", + "parent": 4 + } + """ + Then I got successful response + And it's contains + """ + @object@ + .entity('CacheBundle:Category', 'category, feed_tree, id') + .field('name', 'new category') + """ + + Scenario: + I try to create new category but not provide name. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/categories + """ + { + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": [ + { + "message": "This value should not be blank.", + "transKey": "createCategoryNameEmpty", + "type": "error", + "parameters": { + "current": null + } + } + ] + } + """ + + @db-fixtures + Scenario: + I try to create new category with already exists name. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/categories + """ + { + "name": "My Content", + "parent": 4 + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": [ + { + "message": "User already have category with name \"My Content\".", + "transKey": "createCategoryNameNotUnique", + "type": "error", + "parameters": { + "current": "My Content" + } + } + ] + } + """ \ No newline at end of file diff --git a/behat/Api/features/V1/category/delete.feature b/behat/Api/features/V1/category/delete.feature new file mode 100644 index 0000000..65257fe --- /dev/null +++ b/behat/Api/features/V1/category/delete.feature @@ -0,0 +1,75 @@ +Feature: Delete category + As an authenticated user + I should be able to delete my category + + @db-fixtures + Scenario: + I try to delete 'Test' category. + + Given I authenticated as test@email.com with password test + When I make DELETE request to /api/v1/categories/6 + Then I got response with code 204 + And it's empty + And database don't has entity CacheBundle:Category + | id | 6 | + + @db-fixtures + Scenario: + I try to delete 'Sub main sub 3' category which have subdirectories. + + Given I authenticated as test@email.com with password test + When I make DELETE request to /api/v1/categories/5 + Then I got response with code 204 + And it's empty + And database don't has entity CacheBundle:Category + | id | 5 | + And don't has entity CacheBundle:Category + | id | 6 | + + @db-fixtures + Scenario: + I try to delete category with unknown id. + + Given I authenticated as test@email.com with password test + When I make DELETE request to /api/v1/categories/1000 + Then I got response with code 404 + And it's contains + """ + { + "errors": [ + "Can't find category with id 1000." + ] + } + """ + + @db-fixtures + Scenario: + I try to delete category 'My Content' category. + + Given I authenticated as test@email.com with password test + When I make DELETE request to /api/v1/categories/1 + Then I got response with code 403 + And it's contains + """ + { + "errors": [ + "Can't delete internal category." + ] + } + """ + + @db-fixtures + Scenario: + I try to delete category for another user. + + Given I authenticated as test@email.com with password test + When I make DELETE request to /api/v1/categories/10 + Then I got response with code 403 + And it's contains + """ + { + "errors": [ + "Can't delete category owned by other user." + ] + } + """ \ No newline at end of file diff --git a/behat/Api/features/V1/category/get.feature b/behat/Api/features/V1/category/get.feature new file mode 100644 index 0000000..1e4b4d8 --- /dev/null +++ b/behat/Api/features/V1/category/get.feature @@ -0,0 +1,50 @@ +Feature: Get category + As an authenticated user + I should be able get information about specified category + + @db-fixtures + Scenario: + I try to get 'My Content' category information. + + Given I authenticated as test@email.com with password test + When I make GET request to /api/v1/categories/1 + Then I got successful response + And it's contains + """ + @object@ + .entity('CacheBundle:Category', 'category, feed_tree, id') + .field('id', 1) + .field('name', 'My Content') + """ + + @db-fixtures + Scenario: + I try to get category information by unknown id. + + Given I authenticated as test@email.com with password test + When I make GET request to /api/v1/categories/1000 + Then I got response with code 404 + And it's contains + """ + { + "errors": [ + "Can't find Category with id 1000." + ] + } + """ + + @db-fixtures + Scenario: + I try to get category owned by other user. + + Given I authenticated as test@email.com with password test + When I make GET request to /api/v1/categories/9 + Then I got response with code 403 + And it's contains + """ + { + "errors": [ + "Can't read category owned by other user." + ] + } + """ \ No newline at end of file diff --git a/behat/Api/features/V1/category/list.feature b/behat/Api/features/V1/category/list.feature new file mode 100644 index 0000000..5b483ef --- /dev/null +++ b/behat/Api/features/V1/category/list.feature @@ -0,0 +1,25 @@ +Feature: Get list of categories + As an authenticated + I should be able to get list of my categories + + @db-fixtures + Scenario: + I try to get list of categories. + + Given I authenticated as test@email.com with password test + When I make GET request to /api/v1/categories + Then I got successful response + And it's contains + """ + { + "data": "@array@ + .every(entity('CacheBundle:Category', 'category_tree, feed_tree, id')) + .one(field('name', 'My Content')) + .one(field('name', 'Deleted Content')) + ", + "count": "@integer@.greaterThan(1)", + "totalCount": "@integer@.greaterThan(1)", + "page": 1, + "limit": "@integer@.greaterThan(1)" + } + """ diff --git a/behat/Api/features/V1/category/move.feature b/behat/Api/features/V1/category/move.feature new file mode 100644 index 0000000..1a36ae7 --- /dev/null +++ b/behat/Api/features/V1/category/move.feature @@ -0,0 +1,154 @@ +Feature: Move category + As an authenticated user + I should be able to move my category from one place to another + + @db-fixtures + Scenario: + I try to move 'Sub main sub 3' category to another category. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/categories/5/move_to/4 + Then I got successful response + And it's contains + """ + { + "data": "@array@ + .every(entity('CacheBundle:Category', 'category_tree, feed_tree, id')) + .one( + field('name', 'My Content'), + field('childes', + one( + field('id', 2), + field('childes', + one( + field('id', 4), + field('childes', one( + field('id', 5), + field('childes', one(field('id', 6))) + )) + ) + ) + ) + ) + ) + ", + "count": "@integer@.greaterThan(1)", + "totalCount": "@integer@.greaterThan(1)", + "page": 1, + "limit": "@integer@.greaterThan(1)" + } + """ + And database has entity CacheBundle:Category + | id | 5 | + | name | Sub main sub 3 | + | user | 1 | + | parent | 4 | + And database has entity CacheBundle:Category + | id | 6 | + | name | Test | + | user | 1 | + | parent | 5 | + + Scenario: + I try to move unknown category. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/categories/1000/move_to/7 + Then I got response with code 404 + And it's contains + """ + { + "errors": [ + "Can't find category with id 1000." + ] + } + """ + + Scenario: + I try to move my category into unknown. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/categories/5/move_to/1000 + Then I got response with code 404 + And it's contains + """ + { + "errors": [ + "Can't find category with id 1000." + ] + } + """ + + @db-fixtures + Scenario: + I try to move 'My Content' category which is internal. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/categories/1/move_to/7 + Then I got response with code 403 + And it's contains + """ + { + "errors": [ + "Can't move internal category." + ] + } + """ + + @db-fixtures + Scenario: + I try to move another user category. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/categories/10/move_to/1 + Then I got response with code 404 + And it's contains + """ + { + "errors": [ + "Can't find category with id 10." + ] + } + """ + + @db-fixtures + Scenario: + I try to move 'Sub main sub 3' category inside it self. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/categories/5/move_to/5 + Then I got response with code 400 + And it's contains + """ + { + "errors": [ + "Try to place category inside itself." + ] + } + """ + And database has entity CacheBundle:Category + | id | 5 | + | name | Sub main sub 3 | + | user | 1 | + | parent | 2 | + + @db-fixtures + Scenario: + I try to move 'Sub main sub 3' category inside one of child. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/categories/5/move_to/6 + Then I got response with code 400 + And it's contains + """ + { + "errors": [ + "Try to place category inside it child." + ] + } + """ + And database has entity CacheBundle:Category + | id | 5 | + | name | Sub main sub 3 | + | user | 1 | + | parent | 2 | \ No newline at end of file diff --git a/behat/Api/features/V1/category/update.feature b/behat/Api/features/V1/category/update.feature new file mode 100644 index 0000000..ae96258 --- /dev/null +++ b/behat/Api/features/V1/category/update.feature @@ -0,0 +1,274 @@ +Feature: Update category + As an authenticated user + I should be able to update any available properties of my category + + @db-fixtures + Scenario: + I try to rename 'Test' category. + + Given I authenticated as test@email.com with password test + When I make PUT request to /api/v1/categories/6 + """ + { + "name": "Awesome Category", + "parent": 5 + } + """ + Then I got response with code 200 + And it's contains + """ + @object@ + .entity('CacheBundle:Category', 'category, feed_tree, id') + .field('id', 6) + .field('name', 'Awesome Category') + """ + And database has entity CacheBundle:Category + | id | 6 | + | name | Awesome Category | + | user | 1 | + | parent | 5 | + + @db-fixtures + Scenario: + I try to move 'Test' category to another category. + + Given I authenticated as test@email.com with password test + When I make PUT request to /api/v1/categories/6 + """ + { + "name": "Test", + "parent": 4 + } + """ + Then I got response with code 200 + And it's contains + """ + @object@ + .entity('CacheBundle:Category', 'category, feed_tree, id') + .field('id', 6) + .field('name', 'Test') + """ + And database has entity CacheBundle:Category + | id | 6 | + | name | Test | + | user | 1 | + | parent | 4 | + + Scenario: + I try to update 'Test' but not provide necessary information. + + Given I authenticated as test@email.com with password test + When I make PUT request to /api/v1/categories/6 + """ + { + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": [ + { + "message": "This value should not be blank.", + "transKey": "updateCategoryNameEmpty", + "type": "error", + "parameters": { + "current": null + } + } + ] + } + """ + + @db-fixtures + Scenario: + I try to rename category and set already exists name + + Given I authenticated as test@email.com with password test + When I make PUT request to /api/v1/categories/6 + """ + { + "name": "My Content", + "parent": 5 + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": [ + { + "message": "User already have category with name \"My Content\".", + "transKey": "updateCategoryNameNotUnique", + "type": "error", + "parameters": { + "current": "My Content" + } + } + ] + } + """ + + @db-fixtures + Scenario: + I try to update category with unknown id. + + Given I authenticated as test@email.com with password test + When I make PUT request to /api/v1/categories/1000 + """ + { + "name": "Awesome Category", + "parent": 5 + } + """ + Then I got response with code 404 + And it's contains + """ + { + "errors": [ + "Can't find Category with id 1000." + ] + } + """ + + @db-fixtures + Scenario: + I try to update 'My Content' category which is internal. + + Given I authenticated as test@email.com with password test + When I make PUT request to /api/v1/categories/1 + """ + { + "name": "Awesome category" + } + """ + Then I got response with code 403 + And it's contains + """ + { + "errors": [ + "Can't update internal category." + ] + } + """ + + @db-fixtures + Scenario: + I try to update category for another user. + + Given I authenticated as test@email.com with password test + When I make PUT request to /api/v1/categories/10 + """ + { + "name": "Awesome category", + "parent": 4 + } + """ + Then I got response with code 403 + And it's contains + """ + { + "errors": [ + "Can't update category owned by other user." + ] + } + """ + + @db-fixtures + Scenario: + I try to move 'Test' category inside it self. + + Given I authenticated as test@email.com with password test + When I make PUT request to /api/v1/categories/6 + """ + { + "name": "Test", + "parent": 6 + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": [ + { + "message": "Try to place category inside itself.", + "transKey": "updateCategoryParent", + "type": "error", + "parameters": [] + } + ] + } + """ + And database has entity CacheBundle:Category + | id | 6 | + | name | Test | + | user | 1 | + | parent | 5 | + + @db-fixtures + Scenario: + I try to move 'Test' category inside unknown category. + + Given I authenticated as test@email.com with password test + When I make PUT request to /api/v1/categories/6 + """ + { + "name": "Test", + "parent": 1000 + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": [ + { + "message": "This value is not valid.", + "transKey": "updateCategoryParentInvalid", + "type": "error", + "parameters": { + "current": "1000", + "available": null + } + } + ] + } + """ + And database has entity CacheBundle:Category + | id | 6 | + | name | Test | + | user | 1 | + | parent | 5 | + + @db-fixtures + Scenario: + I try to move 'Sub main sub 3' category inside one of child. + + Given I authenticated as test@email.com with password test + When I make PUT request to /api/v1/categories/5 + """ + { + "name": "Sub main sub 3", + "parent": 6 + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": [ + { + "message": "Try to place category inside it child.", + "transKey": "updateCategoryParent", + "type": "error", + "parameters": [] + } + ] + } + """ + And database has entity CacheBundle:Category + | id | 5 | + | name | Sub main sub 3 | + | user | 1 | + | parent | 2 | \ No newline at end of file diff --git a/behat/Api/features/V1/notification/create_simple.feature b/behat/Api/features/V1/notification/create_simple.feature new file mode 100644 index 0000000..42c0137 --- /dev/null +++ b/behat/Api/features/V1/notification/create_simple.feature @@ -0,0 +1,266 @@ +Feature: Create simple notification + As an authenticated user + I should be able to create new simple notification + + @db-fixtures + Scenario: + I try to create new simple notification. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/notifications + """ + { + "name": "New notification", + "recipients": [ 1 ], + "notificationType": "alert", + "themeType": "plain", + "theme": 1, + "subject": "Some subject", + "published": true, + "automatedSubject": false, + "allowUnsubscribe": true, + "unsubscribeNotification": true, + "sources": [ + { + "type": "feed", + "id": 1 + } + ], + "sendWhenEmpty": false, + "timezone": "Asia/Novosibirsk", + "automatic": [ + { + "type": "daily", + "time": "15m", + "days": "all" + }, + { + "type": "weekly", + "period": "third", + "day": "monday", + "hour": 11, + "minute": 45 + }, + { + "type": "monthly", + "day": 3, + "hour": 11, + "minute": 0 + }, + { + "type": "monthly", + "day": "last", + "hour": 0, + "minute": 55 + } + ], + "sendUntil": "2017-10-01", + "plainDiff": {}, + "enhancedDiff": {} + } + """ + Then I got successful response + And it's contains + """ + @object@ + .entity('UserBundle:Notification', 'notification, schedule, id') + .field('type', 'alert') + .field('name', 'New notification') + .field('subject', 'Some subject') + .field('owner', field('id', 1)) + .field('sources', + count(1), + one(field('type', 'feed'), field('id', 1), field('name', 'test1')) + ) + """ + And database has entity UserBundle:Notification\Notification + | name | New notification | + | notificationType | alert | + | owner | 1 | + | subject | Some subject | + | automatedSubject | false | + | published | true | + | allowUnsubscribe | true | + | unsubscribeNotification | true | + | sendWhenEmpty | false | + | timezone | Asia/Novosibirsk | + | sendUntil | 2017-10-01 | + | active | true | + + @db-fixtures + Scenario: + I try to create new simple notification without sources, recipients and scheduling. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/notifications + """ + { + "name": "New notification", + "recipients": [], + "notificationType": "alert", + "themeType": "plain", + "theme": 1, + "subject": "Some subject", + "published": true, + "automatedSubject": false, + "allowUnsubscribe": true, + "unsubscribeNotification": true, + "sources": [], + "sendWhenEmpty": false, + "timezone": "Asia/Novosibirsk", + "automatic": [], + "sendUntil": "2017-10-01", + "plainDiff": {}, + "enhancedDiff": {} + } + """ + Then I got successful response + And it's contains + """ + @object@ + .entity('UserBundle:Notification', 'notification, schedule, id') + .field('type', 'alert') + .field('name', 'New notification') + .field('subject', 'Some subject') + .field('owner', field('id', 1)) + .field('recipients', count(0)) + .field('sources', count(0)) + .field('automatic', count(0)) + """ + And database has entity UserBundle:Notification\Notification + | name | New notification | + | notificationType | alert | + | owner | 1 | + | subject | Some subject | + | automatedSubject | false | + | published | true | + | allowUnsubscribe | true | + | unsubscribeNotification | true | + | sendWhenEmpty | false | + | timezone | Asia/Novosibirsk | + | sendUntil | 2017-10-01 | + | active | true | + + @db-fixtures + Scenario: + I try to create new simple notification with invalid recipient. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/notifications + """ + { + "name": "New notification", + "recipients": [ 1000 ], + "notificationType": "alert", + "themeType": "plain", + "theme": 1, + "subject": "Some subject", + "published": true, + "automatedSubject": false, + "allowUnsubscribe": true, + "unsubscribeNotification": true, + "sources": [], + "sendWhenEmpty": false, + "timezone": "Asia/Novosibirsk", + "automatic": [], + "sendUntil": "2017-10-01", + "plainDiff": {}, + "enhancedDiff": {} + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": [ + { + "message": "This value is not valid.", + "transKey": "createNotificationRecipientsInvalid", + "type": "error", + "parameters": { + "current": [ 1000 ], + "available": null + } + } + ] + } + """ + And database don't has entity UserBundle:Notification\Notification + | name | New notification | + | notificationType | alert | + | owner | 1 | + | subject | Some subject | + | automatedSubject | false | + | published | true | + | allowUnsubscribe | true | + | unsubscribeNotification | true | + | sendWhenEmpty | false | + | timezone | Asia/Novosibirsk | + | sendUntil | 2017-10-01 | + | active | true | + + @db-fixtures + Scenario Outline: + I try to create new simple notification with invalid source. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/notifications + """ + { + "name": "New notification", + "recipients": [], + "notificationType": "alert", + "themeType": "plain", + "theme": 1, + "subject": "Some subject", + "published": true, + "automatedSubject": false, + "allowUnsubscribe": true, + "unsubscribeNotification": true, + "sources": [ + { + + } + ], + "sendWhenEmpty": false, + "timezone": "Asia/Novosibirsk", + "automatic": [], + "sendUntil": "2017-10-01", + "plainDiff": {}, + "enhancedDiff": {} + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@ + .one( + field('message', ''), + field('transKey', ''), + field('type', 'error') + ) + " + } + """ + And database don't has entity UserBundle:Notification\Notification + | name | New notification | + | notificationType | alert | + | owner | 1 | + | subject | Some subject | + | automatedSubject | false | + | published | true | + | allowUnsubscribe | true | + | unsubscribeNotification | true | + | sendWhenEmpty | false | + | timezone | Asia/Novosibirsk | + | sendUntil | 2017-10-01 | + | active | true | + + Examples: + | payload | message | transKey | + | | Some of sources has invalid id. | createNotificationSources | + | "type": "feed" | This value should not be blank. | createNotificationSourcesIdEmpty | + | "id": 1 | This value should not be blank. | createNotificationSourcesTypeEmpty | + | "type": "some", "id": 1 | This value is not valid. | createNotificationSourcesTypeInvalid | + | "type": "feed", "id": 1000 | Some of sources has invalid id. | createNotificationSources | diff --git a/behat/Api/features/V1/notification/list.feature b/behat/Api/features/V1/notification/list.feature new file mode 100644 index 0000000..f5c517d --- /dev/null +++ b/behat/Api/features/V1/notification/list.feature @@ -0,0 +1,58 @@ +Feature: Get list of notifications + As an authenticated user + I should be able to get list of my notifications + + @db-fixtures + Scenario: + I get list of my notification's. + + Given I authenticated as test@email.com with password test + When I make GET request to /api/v1/notifications + Then I got successful response + And it's contains + """ + { + "notifications": { + "data": "@array@.every( + entity('UserBundle:Notification', 'notification_list, schedule, id'), + field('owner', field('id', 1)) + )", + "count": "@integer@", + "totalCount": "@integer@", + "page": "@integer@", + "limit": "@integer@" + }, + "meta": { + "sort": { + "field": "name", + "direction": "asc" + } + } + } + """ + + When I make GET request to /api/v1/notifications + | onlyPublished | true | + Then I got successful response + And it's contains + """ + { + "notifications": { + "data": "@array@.every( + entity('UserBundle:Notification', 'notification_list, schedule, id'), + field('owner', field('id', 1)), + field('published', true) + )", + "count": "@integer@", + "totalCount": "@integer@", + "page": "@integer@", + "limit": "@integer@" + }, + "meta": { + "sort": { + "field": "name", + "direction": "asc" + } + } + } + """ diff --git a/behat/Api/features/V1/query/advanced_filters/articleDate.feature b/behat/Api/features/V1/query/advanced_filters/articleDate.feature new file mode 100644 index 0000000..4af4af1 --- /dev/null +++ b/behat/Api/features/V1/query/advanced_filters/articleDate.feature @@ -0,0 +1,188 @@ +Feature: Use 'Article Date' advanced filter + As an authenticated user + I should be able to use 'Article Date' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents from US and use `Article Date` + advanced filters with value '31 Days'. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "articleDate": "31 Days" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('country', 'US')), + field('published', gte('#now().modify(\"- 31 Days\").format(\"c\")#')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "articleDate": "31 Days" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "articleDate": "31 Days" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('country', 'US')), + field('published', gte('#now().modify(\"- 31 Days\").format(\"c\")#')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "articleDate": "31 Days" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + And database don't has entity CacheBundle:Query\SimpleQuery + | id | 5 | + | raw | cat | + + @db-fixtures + Scenario: + I search 'cat' which appears in documents from US and also use `Article Date` + advanced filters with empty value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "articleDate": "" + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ + And database don't has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents from US and use `Article Date` + advanced filters with invalid value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "articleDate": "111" + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ + And database don't has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + diff --git a/behat/Api/features/V1/query/advanced_filters/articleLanguage.feature b/behat/Api/features/V1/query/advanced_filters/articleLanguage.feature new file mode 100644 index 0000000..266bc19 --- /dev/null +++ b/behat/Api/features/V1/query/advanced_filters/articleLanguage.feature @@ -0,0 +1,198 @@ +Feature: Use 'Article Language' advanced filter + As an authenticated user + I should be able to use 'Article Language' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' and use `Article Language` advanced filters with value + 'en'. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "articleLanguage": "en" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('language', 'en') + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "articleLanguage": "en" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "articleLanguage": "en" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('language', 'en') + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "articleLanguage": "en" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + And database don't has entity CacheBundle:Query\SimpleQuery + | id | 5 | + | raw | cat | + + @db-fixtures + Scenario: + I search 'cat' and use `Article Language` advanced filters with empty + value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "articleLanguage": "" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every(entity('CacheBundle:Document', 'document, id'))", + "count": "@integer@", + "totalCount": "@integer@", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "articleLanguage": "" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' and use `Article Language` advanced filters with invalid + value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "articleLanguage": "some" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": [], + "count": 0, + "totalCount": 0, + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "articleLanguage": "some" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | diff --git a/behat/Api/features/V1/query/advanced_filters/author.feature b/behat/Api/features/V1/query/advanced_filters/author.feature new file mode 100644 index 0000000..c0dd5be --- /dev/null +++ b/behat/Api/features/V1/query/advanced_filters/author.feature @@ -0,0 +1,240 @@ +Feature: Use 'Author' advanced filter + As an authenticated user + I should be able to use 'Author' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents from US and use `Author` + advanced filters with value 'Gracie Pfeffer'. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "author": "Gracie Pfeffer" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('country', 'US')), + field('author', field('name', 'Gracie Pfeffer')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "author": "Gracie Pfeffer" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "author": "Gracie Pfeffer" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('country', 'US')), + field('author', field('name', 'Gracie Pfeffer')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "author": "Gracie Pfeffer" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + And database don't has entity CacheBundle:Query\SimpleQuery + | id | 5 | + | raw | cat | + + @db-fixtures + Scenario: + I search 'cat' which appears in documents from US and use `Author` + advanced filters with empty value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "author": "" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('country', 'US')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "author": "" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents from US and use `Author` + advanced filters with invalid value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "author": "some" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": [], + "count": 0, + "totalCount": 0, + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "author": "some" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + diff --git a/behat/Api/features/V1/query/advanced_filters/publisher.feature b/behat/Api/features/V1/query/advanced_filters/publisher.feature new file mode 100644 index 0000000..f5dbe61 --- /dev/null +++ b/behat/Api/features/V1/query/advanced_filters/publisher.feature @@ -0,0 +1,198 @@ +Feature: Use 'Publisher' advanced filter + As an authenticated user + I should be able to use 'Publisher' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' and use `Publisher` advanced filters with value 'msnbc'. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "publisher": "msnbc" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('publisher', contains('msnbc', true)) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "publisher": "msnbc" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "publisher": "msnbc" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('publisher', contains('msnbc', true)) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "publisher": "msnbc" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + And database don't has entity CacheBundle:Query\SimpleQuery + | id | 5 | + | raw | cat | + + @db-fixtures + Scenario: + I search 'cat' and use `Publisher` advanced filters with empty value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "publisher": "" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id') + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "publisher": "" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' and use `Publisher` advanced filters with invalid value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "publisher": "some" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": [], + "count": 0, + "totalCount": 0, + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "publisher": "some" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + diff --git a/behat/Api/features/V1/query/advanced_filters/reach.feature b/behat/Api/features/V1/query/advanced_filters/reach.feature new file mode 100644 index 0000000..ca58d11 --- /dev/null +++ b/behat/Api/features/V1/query/advanced_filters/reach.feature @@ -0,0 +1,154 @@ +Feature: Use 'Reach' advanced filter + As an authenticated user + I should be able to use 'Reach' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' and use `Reach` advanced filters with value '10000+'. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "reach": "10000+" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('views', greaterThan(10000), lowerThan(25000)) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "reach": "10000+" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "reach": "10000+" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('views', greaterThan(10000), lowerThan(25000)) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "reach": "10000+" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + And database don't has entity CacheBundle:Query\SimpleQuery + | id | 5 | + | raw | cat | + + @db-fixtures + Scenario: + I search 'cat' and use `Reach` advanced filters with empty value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "reach": "" + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ + And database don't has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' and use `Reach` advanced filters with invalid value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "reach": "111" + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ + And database don't has entity CacheBundle:Query\SimpleQuery + | id | 3 | + | raw | cat | + diff --git a/behat/Api/features/V1/query/advanced_filters/source.feature b/behat/Api/features/V1/query/advanced_filters/source.feature new file mode 100644 index 0000000..673b2ed --- /dev/null +++ b/behat/Api/features/V1/query/advanced_filters/source.feature @@ -0,0 +1,246 @@ +Feature: Use 'Source' advanced filter + As an authenticated user + I should be able to use 'Source' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents from US and after it add `source` + advanced filters with value 'CNN'. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "source": "CNN" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', + field('country', 'US'), + field('title', 'CNN') + ) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "source": "CNN" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "source": "CNN" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', + field('country', 'US'), + field('title', 'CNN') + ) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "source": "CNN" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + And database don't has entity CacheBundle:Query\SimpleQuery + | id | 5 | + | raw | cat | + + @db-fixtures + Scenario: + I search 'cat' which appears in documents from US and after it add `source` + advanced filters with empty value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "source": "" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', + field('country', 'US') + ) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "source": "" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + @db-fixtures + Scenario: + I search 'cat' and use `Source` advanced filters with unknown value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "source": "some" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": [], + "count": 0, + "totalCount": 0, + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": { + "source": "some" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + diff --git a/behat/Api/features/V1/query/advanced_filters/sourceCity.feature b/behat/Api/features/V1/query/advanced_filters/sourceCity.feature new file mode 100644 index 0000000..90e46d8 --- /dev/null +++ b/behat/Api/features/V1/query/advanced_filters/sourceCity.feature @@ -0,0 +1,198 @@ +Feature: Use 'Source City' advanced filter + As an authenticated user + I should be able to use 'Source City' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' and use `Source City` advanced filters with value 'Arizona'. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceCity": "Amazing City" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('city', 'Amazing City')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceCity": "Amazing City" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceCity": "Amazing City" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('city', 'Amazing City')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceCity": "Amazing City" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + And database don't has entity CacheBundle:Query\SimpleQuery + | id | 5 | + | raw | cat | + + @db-fixtures + Scenario: + I search 'cat' and use `Source City` advanced filters with empty value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceCity": "" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id') + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceCity": "" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' and after it add `sourceCity` advanced filters with invalid + value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceCity": "some" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": [], + "count": 0, + "totalCount": 0, + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceCity": "some" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | diff --git a/behat/Api/features/V1/query/advanced_filters/sourceCountry.feature b/behat/Api/features/V1/query/advanced_filters/sourceCountry.feature new file mode 100644 index 0000000..4835285 --- /dev/null +++ b/behat/Api/features/V1/query/advanced_filters/sourceCountry.feature @@ -0,0 +1,197 @@ +Feature: Use 'Source Country' advanced filter + As an authenticated user + I should be able to use 'Source Country' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' and use `Source Country` advanced filters with value 'US'. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceCountry": "US" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('country', 'US')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceCountry": "US" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceCountry": "US" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('country', 'US')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceCountry": "US" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + And database don't has entity CacheBundle:Query\SimpleQuery + | id | 5 | + | raw | cat | + + @db-fixtures + Scenario: + I search 'cat' and use `Source Country` advanced filters with empty value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceCountry": "" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id') + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceCountry": "" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' use `Source Country` advanced filters with invalid value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceCountry": "some" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": [], + "count": 0, + "totalCount": 0, + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceCountry": "some" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | diff --git a/behat/Api/features/V1/query/advanced_filters/sourceSection.feature b/behat/Api/features/V1/query/advanced_filters/sourceSection.feature new file mode 100644 index 0000000..efa9437 --- /dev/null +++ b/behat/Api/features/V1/query/advanced_filters/sourceSection.feature @@ -0,0 +1,197 @@ +Feature: Use 'Source Section' advanced filter + As an authenticated user + I should be able to use 'Source Section' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' and use `Source Section` advanced filters with value 'Lifestyle'. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceSection": "Lifestyle" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('section', 'Lifestyle')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceSection": "Lifestyle" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceSection": "Lifestyle" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('section', 'Lifestyle')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceSection": "Lifestyle" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + And don't has entity CacheBundle:Query\SimpleQuery + | id | 5 | + | raw | cat | + + @db-fixtures + Scenario: + I search 'cat' and use `Source Section` advanced filters with empty value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceSection": "" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id') + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceSection": "" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' and use `Source Section` advanced filters with invalid value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceSection": "some" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": [], + "count": 0, + "totalCount": 0, + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceSection": "some" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | diff --git a/behat/Api/features/V1/query/advanced_filters/sourceState.feature b/behat/Api/features/V1/query/advanced_filters/sourceState.feature new file mode 100644 index 0000000..c7dc767 --- /dev/null +++ b/behat/Api/features/V1/query/advanced_filters/sourceState.feature @@ -0,0 +1,197 @@ +Feature: Use 'Source State' advanced filter + As an authenticated user + I should be able to use 'Source State' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' and use `Source State` advanced filters with value 'Arizona'. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceState": "Arizona" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('state', 'Arizona')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceState": "Arizona" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceState": "Arizona" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('state', 'Arizona')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceState": "Arizona" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + And don't has entity CacheBundle:Query\SimpleQuery + | id | 5 | + | raw | cat | + + @db-fixtures + Scenario: + I search 'cat' and use `Source State` advanced filters with empty value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceState": "" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id') + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceState": "" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' and use `Source State` advanced filters with invalid value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "advancedFilters": { + "sourceState": "some" + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": [], + "count": 0, + "totalCount": 0, + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": { + "sourceState": "some" + } + }, + "sources": [], + "sourceLists": [] + } + } + """ + And database has 1 entity CacheBundle:Query\SimpleQuery + | id | 4 | + | raw | cat | diff --git a/behat/Api/features/V1/query/filters/country.feature b/behat/Api/features/V1/query/filters/country.feature new file mode 100644 index 0000000..d91a5fc --- /dev/null +++ b/behat/Api/features/V1/query/filters/country.feature @@ -0,0 +1,207 @@ +Feature: Use 'Country' filter + As an authenticated user + I should be able to use 'Country' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents with US language. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "US" ] + } + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('country', 'US')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "country": { + "include": [ "US" ] + } + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents not with US language. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "exclude": [ "US" ] + } + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + not(field('source', field('country', 'US'))) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "country": { + "exclude": [ "US" ] + } + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents with US and RU languages. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "US", "RU" ] + } + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', oneOf( + field('country', 'US'), + field('country', 'RU') + )) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "country": { + "include": [ "US", "RU" ] + } + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + Scenario: + I search 'cat' which appears in documents with unknown language. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "include": [ "unknown" ] + } + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ + + Scenario: + I search 'cat' which appears in documents not with unknown language. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "country": { + "exclude": [ "unknown" ] + } + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ \ No newline at end of file diff --git a/behat/Api/features/V1/query/filters/date.feature b/behat/Api/features/V1/query/filters/date.feature new file mode 100644 index 0000000..703a86b --- /dev/null +++ b/behat/Api/features/V1/query/filters/date.feature @@ -0,0 +1,255 @@ +Feature: Use 'Date' filter + As an authenticated user + I should be able to use 'Date' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' in documents which found maximum 10 days ago. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "date": { + "type": "last" + "days": 10 + } + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + oneOf( + field('title', contains('cat', true)), + field('content', contains('cat', true)) + ), + field('published', gte('#now().modify(\"- 10 days\").format(\"c\")#')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@" + } + """ + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' in documents which found between some period. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "date": { + "type": "between", + "start": "#now().modify(\"- 30 days\").format(\"Y-m-d\")#", + "end": "#now().modify(\"- 1 days\").format(\"Y-m-d\")#" + } + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + oneOf( + field('title', contains('cat', true)), + field('content', contains('cat', true)) + ), + field('published', between( + '#now().modify(\"- 30 days\").format(\"c\")#', + '#now().modify(\"- 1 days\").format(\"c\")#' + )) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@" + } + """ + + Scenario: + I search 'cat' in documents which filtered by date filter with invalid type. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "date": { + "type": "invalid" + } + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ + + Scenario: + I search 'cat' in documents which filtered by date filter with 'last' type + but not provide 'days' field. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "date": { + "type": "last" + } + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ + + Scenario Outline: + I search 'cat' in documents which filtered by date filter with 'last' type + but provide invalid 'days' values. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "date": { + "type": "last", + "days": + } + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ + + Examples: + | value | + | "invalid" | + | 0 | + | -10 | + + Scenario: + I search 'cat' in documents which filtered by date filter with 'between' type + but not provide 'start' and 'end' values. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "date": { + "type": "between" + } + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ + + Scenario Outline: + I search 'cat' in documents which filtered by date filter with 'between' type + but provide invalid 'start' and 'end' values. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "date": { + "type": "between", + "start": "", + "end": "" + } + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ + + Examples: + | date | + | 2017-13-20 | + | some | + | 2017-01-40 | + + Scenario: + I search 'cat' in documents which filtered by date filter with 'between' type + but provide 'start' greater than 'end'. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "date": { + "type": "between", + "start": "#now().modify(\"- 15 days\").format(\"Y-m-d\")#", + "end": "#now().modify(\"- 30 days\").format(\"Y-m-d\")#" + } + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ \ No newline at end of file diff --git a/behat/Api/features/V1/query/filters/hasImage.feature b/behat/Api/features/V1/query/filters/hasImage.feature new file mode 100644 index 0000000..8ba82c6 --- /dev/null +++ b/behat/Api/features/V1/query/filters/hasImage.feature @@ -0,0 +1,129 @@ +Feature: Use 'Has Image' filter + As an authenticated user + I should be able to use 'Has Image' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents which have image. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "hasImage": true + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('image', isNotEmpty()) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "hasImage": true + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents which is may have images. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "hasImage": false + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + oneOf( + field('image', isEmpty()), + field('image', isNotEmpty()) + ) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "hasImage": false + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + Scenario Outline: + I search 'cat' which appears in documents which filtered by hasImage filters + with invalid value. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "hasImage": + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ + + Examples: + | value | + | "some" | + | 10 | + | "true" | \ No newline at end of file diff --git a/behat/Api/features/V1/query/filters/headline.feature b/behat/Api/features/V1/query/filters/headline.feature new file mode 100644 index 0000000..9be4016 --- /dev/null +++ b/behat/Api/features/V1/query/filters/headline.feature @@ -0,0 +1,344 @@ +Feature: Use 'Headline' filter + As an authenticated user + I should be able to use 'Headline' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat'. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": {} + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + oneOf( + field('title', contains('cat', true)), + field('title', contains('dog', true)), + field('title', contains('fish', true)), + field('content', contains('cat', true)), + field('content', contains('dog', true)), + field('content', contains('fish', true)) + ) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": {}, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat', and also include 'dog' in headline. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "headline": { + "include": "dog" + } + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + oneOf( + field('title', contains('cat', true)), + field('content', contains('cat', true)) + ), + field('title', + contains('cat', true), + contains('dog', true) + ) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "headline": { + "include": "dog" + } + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which not include 'cat' in headline. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "headline": { + "exclude": "cat" + } + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + oneOf( + field('title', contains('cat', true)), + field('content', contains('cat', true)) + ), + field('title', + not(contains('cat', true)), + contains('some', true) + ), + field('content', contains('cat', true)) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "headline": { + "exclude": "cat" + } + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' without 'dog' and 'fish' in headline. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "headline": { + "exclude": "dog, fish" + } + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + oneOf( + field('title', contains('cat', true)), + field('content', contains('cat', true)) + ), + field('title', + not(contains('dog', true)), + not(contains('fish', true)) + ) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "headline": { + "exclude": "dog, fish" + } + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' with 'dog' but without 'fish' in headline. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "headline": { + "include": "dog", + "exclude": "fish" + } + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + oneOf( + field('title', contains('cat', true)), + field('content', contains('cat', true)) + ), + field('title', + contains('cat', true), + contains('dog', true), + not(contains('fish', true)) + ) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "headline": { + "include": "dog", + "exclude": "fish" + } + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + @external-index-fixtures @db-fixtures + Scenario: + I search documents 'fish' without 'cat' in headline. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "fish", + "page": 1, + "filters": { + "headline": { + "exclude": "cat" + } + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + oneOf( + field('title', contains('fish', true)), + field('content', contains('fish', true)) + ), + field('title', not(contains('cat', true))) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "fish", + "filters": { + "headline": { + "exclude": "cat" + } + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ \ No newline at end of file diff --git a/behat/Api/features/V1/query/filters/language.feature b/behat/Api/features/V1/query/filters/language.feature new file mode 100644 index 0000000..178eb69 --- /dev/null +++ b/behat/Api/features/V1/query/filters/language.feature @@ -0,0 +1,168 @@ +Feature: Use 'Language' filter + As an authenticated user + I should be able to use 'Language' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents with english language. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "language": [ "en" ] + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('language', 'en') + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "language": [ "en" ] + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents with english and russian languages. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "language": [ "en", "ru" ] + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + oneOf( + field('language', 'en'), + field('language', 'ru') + ) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "language": [ "en", "ru" ] + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + Scenario: + I search 'cat' which appears in documents with unknown languages. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "language": [ "unknown" ] + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents with empty language filters. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "language": [ ] + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id') + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "language": [] + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ \ No newline at end of file diff --git a/behat/Api/features/V1/query/filters/state.feature b/behat/Api/features/V1/query/filters/state.feature new file mode 100644 index 0000000..5dbd4e0 --- /dev/null +++ b/behat/Api/features/V1/query/filters/state.feature @@ -0,0 +1,207 @@ +Feature: Use 'State' filter + As an authenticated user + I should be able to use 'State' for filtering search results + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents from state Arizona. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "state": { + "include": [ "AZ" ] + } + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', field('state', 'Arizona')) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "state": { + "include": [ "AZ" ] + } + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents not from state Arizona. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "state": { + "exclude": [ "AZ" ] + } + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + not(field('source', field('state', 'Arizona'))) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "state": { + "exclude": [ "AZ" ] + } + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + @external-index-fixtures @db-fixtures + Scenario: + I search 'cat' which appears in documents not from Louisiana and Maryland. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "state": { + "include": [ "LA", "MD" ] + } + } + } + """ + Then I got successful response + And it's contains + """ + { + "documents": { + "data": "@array@.every( + entity('CacheBundle:Document', 'document, id'), + field('source', oneOf( + field('state', 'Louisiana'), + field('state', 'Maryland') + )) + )", + "count": "@integer@.greaterThan(0)", + "totalCount": "@integer@.greaterThan(0)", + "page": 1, + "limit": 100 + }, + "advancedFilters": "@array@", + "stats": "@object@", + "meta": { + "type": "query", + "status": "synced", + "search": { + "query": "cat", + "filters": { + "state": { + "include": [ "LA", "MD" ] + } + }, + "advancedFilters": {} + }, + "sources": [], + "sourceLists": [] + } + } + """ + + Scenario: + I search 'cat' which appears in documents from unknown state. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "state": { + "include": [ "unknown" ] + } + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ + + Scenario: + I search 'cat' which appears in documents not from unknown state. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/query/search + """ + { + "query": "cat", + "page": 1, + "filters": { + "state": { + "exclude": [ "unknown" ] + } + } + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": "@array@" + } + """ \ No newline at end of file diff --git a/behat/Api/features/V1/recipient/group/create.feature b/behat/Api/features/V1/recipient/group/create.feature new file mode 100644 index 0000000..67fef07 --- /dev/null +++ b/behat/Api/features/V1/recipient/group/create.feature @@ -0,0 +1,34 @@ +Feature: Create recipient group + As an master + I should be able to create group of recipients + + @db-fixtures + Scenario: + I try to create recipient group. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/recipients/groups + """ + { + "name": "Test Group", + "description": "some group", + "active": true, + "recipients": [], + "notifications": [] + } + """ + Then I got successful response + And it's contains + """ + @object@ + .entity('UserBundle:GroupRecipient', 'recipient, id') + .field('name', 'Test Group') + .field('description', 'some group') + .field('active', true) + .field('recipients', []) + .field('notifications', []) + """ + And database has entity UserBundle:GroupRecipient + | name | Test Group | + | description | some group | + | active | true | diff --git a/behat/Api/features/V1/recipient/person/create.feature b/behat/Api/features/V1/recipient/person/create.feature new file mode 100644 index 0000000..0ded00d --- /dev/null +++ b/behat/Api/features/V1/recipient/person/create.feature @@ -0,0 +1,36 @@ +Feature: Create recipient + As an master + I must be able to create new person recipient + + @db-fixtures + Scenario: + I try to create new recipient. + + Given I authenticated as test@email.com with password test + When I make POST request to /api/v1/recipients + """ + { + "firstName": "Test", + "lastName": "User", + "email": "test.user@email.com", + "active": true, + "notifications": [], + "groups": [] + } + """ + Then I got successful response + And it's contains + """ + @object@ + .entity('UserBundle:PersonRecipient', 'recipient, id') + .field('firstName', 'Test') + .field('lastName', 'User') + .field('email', 'test.user@email.com') + .field('active', true) + .field('groups', []) + """ + And database has entity UserBundle:PersonRecipient + | firstName | Test | + | lastName | User | + | email | test.user@email.com | + | active | true | diff --git a/behat/Api/features/V1/source/add_to_list.feature b/behat/Api/features/V1/source/add_to_list.feature new file mode 100644 index 0000000..f5c46f7 --- /dev/null +++ b/behat/Api/features/V1/source/add_to_list.feature @@ -0,0 +1,63 @@ +Feature: Add sources to list + As an authenticated user + I should be able to place sources in my lists + + @db-fixtures @source-index-fixtures + Scenario: + I make empty request. + + Given I authenticated as test@email.com with password test + And I make POST request to /api/v1/source-index/add-to-sources-list + """ + { + "sources": [ 1, 2 ], + "sourceLists": [ 1, 3, 5, 7, 9, 11 ] + } + """ + Then I got response with code 204 + + # Check database. + And database has entity CacheBundle:SourceToSourceList + | source | 1 | + | list | 1 | + And has entity CacheBundle:SourceToSourceList + | source | 1 | + | list | 3 | + And has entity CacheBundle:SourceToSourceList + | source | 1 | + | list | 5 | + And has entity CacheBundle:SourceToSourceList + | source | 1 | + | list | 7 | + And has entity CacheBundle:SourceToSourceList + | source | 1 | + | list | 9 | + And has entity CacheBundle:SourceToSourceList + | source | 1 | + | list | 11 | + + And has entity CacheBundle:SourceToSourceList + | source | 2 | + | list | 1 | + And has entity CacheBundle:SourceToSourceList + | source | 2 | + | list | 3 | + And has entity CacheBundle:SourceToSourceList + | source | 2 | + | list | 5 | + And has entity CacheBundle:SourceToSourceList + | source | 2 | + | list | 7 | + And has entity CacheBundle:SourceToSourceList + | source | 2 | + | list | 9 | + And has entity CacheBundle:SourceToSourceList + | source | 2 | + | list | 11 | + + And I wait 1000 milliseconds + + # Check index. + And source index has 2 documents + | _id | in | 1, 2 | + | list_ids | in | 1, 3, 5, 7, 9, 11 | \ No newline at end of file diff --git a/behat/Api/features/V1/source/list.feature b/behat/Api/features/V1/source/list.feature new file mode 100644 index 0000000..55507ed --- /dev/null +++ b/behat/Api/features/V1/source/list.feature @@ -0,0 +1,120 @@ +Feature: Get list of sources + As an authenticated user + I should be able to get list of sources + + @db-fixtures @source-index-fixtures + Scenario: + I make empty request. + + Given I authenticated as test@email.com with password test + And I make POST request to /api/v1/source-index/ + """ + { + } + """ + Then I got successful response + And it's contains + """ + { + "sources": { + "data": "@array@", + "count": "@integer@", + "totalCount": "@integer@", + "page": 1, + "limit": 20 + }, + "filters": "@object@" + } + """ + + @db-fixtures @source-index-fixtures + Scenario: + I search for source with 'CNN' in title or url. + + Given I authenticated as test@email.com with password test + And I make POST request to /api/v1/source-index/ + """ + { + "query": "CNN" + } + """ + Then I got successful response + And it's contains + """ + { + "sources": { + "data": "@array@ + .every(field('title', contains('CNN'))) + ", + "count": "@integer@", + "totalCount": "@integer@", + "page": 1, + "limit": 20 + }, + "filters": "@object@" + } + """ + + @db-fixtures @source-index-fixtures + Scenario Outline: + I should be able to get more or less source per page. + + Given I authenticated as test@email.com with password test + And I make POST request to /api/v1/source-index/ + """ + { + "limit": + } + """ + Then I got successful response + And it's contains + """ + { + "sources": { + "data": "@array@", + "count": "@integer@", + "totalCount": "@integer@", + "page": 1, + "limit": + }, + "filters": "@object@" + } + """ + + Examples: + | limit | + | 10 | + | 1 | + | 200 | + + @db-fixtures @source-index-fixtures + Scenario Outline: + I should'nt be able change requested page. + + Given I authenticated as test@email.com with password test + And I make POST request to /api/v1/source-index/ + """ + { + "page": + } + """ + Then I got successful response + And it's contains + """ + { + "sources": { + "data": "@array@", + "count": "@integer@", + "totalCount": "@integer@", + "page": , + "limit": 20 + }, + "filters": "@object@" + } + """ + + Examples: + | page | + | 2 | + | 3 | + | 20 | diff --git a/behat/Api/features/V1/source/replace_list.feature b/behat/Api/features/V1/source/replace_list.feature new file mode 100644 index 0000000..c629f8c --- /dev/null +++ b/behat/Api/features/V1/source/replace_list.feature @@ -0,0 +1,133 @@ +Feature: Replace used lists for source + As an authenticated user + I should be able to replace used lists for specified source + + @db-fixtures @source-index-fixtures + Scenario: + I replace lists for one of source. + + Given I authenticated as test@email.com with password test + And I make POST request to /api/v1/source-index/1/list + """ + { + "sourceLists": [ 1, 3, 5, 7, 9, 11 ] + } + """ + Then I got response with code 204 + + # Check database. + And database has entity CacheBundle:SourceToSourceList + | source | 1 | + | list | 1 | + And has entity CacheBundle:SourceToSourceList + | source | 1 | + | list | 3 | + And has entity CacheBundle:SourceToSourceList + | source | 1 | + | list | 5 | + And has entity CacheBundle:SourceToSourceList + | source | 1 | + | list | 7 | + And has entity CacheBundle:SourceToSourceList + | source | 1 | + | list | 9 | + And has entity CacheBundle:SourceToSourceList + | source | 1 | + | list | 11 | + + And I wait 1000 milliseconds + + # Check index. + And source index has 1 documents + | _id | in | 1 | + | list_ids | in | 1, 3, 5, 7, 9, 11 | + + + @db-fixtures @source-index-fixtures + Scenario: + I replace lists for unknown source. + + Given I authenticated as test@email.com with password test + And I make POST request to /api/v1/source-index/10000/list + """ + { + "sourceLists": [ 1, 3, 5, 7, 9, 11 ] + } + """ + Then I got response with code 404 + And it's contains + """ + { + "errors": [ + { + "message": "Can't find source with id 10000", + "transKey": "replaceSourceUnknown", + "type": "error", + "parameters": { + "current": "10000" + } + } + ] + } + """ + + @db-fixtures @source-index-fixtures + Scenario: + I make empty request. + + Given I authenticated as test@email.com with password test + And I make POST request to /api/v1/source-index/1/list + """ + { + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": [ + { + "message": "sourceLists: This value should not be empty.", + "transKey": "replaceSourceListsEmpty", + "type": "error", + "parameters": { + "current": null + } + } + ] + } + """ + + @db-fixtures @source-index-fixtures + Scenario Outline: + I replace lists for unknown or not owned lists. + + Given I authenticated as test@email.com with password test + And I make POST request to /api/v1/source-index/1/list + """ + { + "sourceLists": [ ] + } + """ + Then I got response with code 400 + And it's contains + """ + { + "errors": [ + { + "message": "sourceLists: This value is invalid.", + "transKey": "replaceSourceListInvalid", + "type": "error", + "parameters": { + "current": [ ], + "invalid": [ ] + } + } + ] + } + """ + + Examples: + | list_id | + | 1000 | + | 2 | \ No newline at end of file diff --git a/behat/Command/Context/CommandContext.php b/behat/Command/Context/CommandContext.php new file mode 100644 index 0000000..da53f24 --- /dev/null +++ b/behat/Command/Context/CommandContext.php @@ -0,0 +1,73 @@ +get('kernel'); + $this->factory = new CommandTestFactory($kernel); + } + + /** + * @Given /^I run command (?P.+)$/ + * + * @param string $name Command name. + * @param TableNode $table Command parameters in table format. + * + * @return void + */ + public function runCommand($name, TableNode $table = null) + { + $params = []; + if ($table !== null) { + foreach ($table as $row) { + $params[current($row)] = next($row); + } + } + + $this->command = $this->factory->create($name, $params)->run(); + } + + /** + * @Then /^(?:|[Cc]ommand )[Rr]eturned (?P\d+) exit code$/ + * + * @param integer $code Command exit code. + * + * @return void + */ + public function checkExitCode($code = 0) + { + self::assertEquals($code, $this->command->getExitCode()); + } +} diff --git a/behat/Command/Util/CommandTest.php b/behat/Command/Util/CommandTest.php new file mode 100644 index 0000000..9022c0c --- /dev/null +++ b/behat/Command/Util/CommandTest.php @@ -0,0 +1,85 @@ +application = $application; + $this->input = new ArrayInput([ 'command' => $command ] + $params); + } + + /** + * Run this command. + * + * @return CommandTest + */ + public function run() + { + $this->output = new BufferedOutput(); + $this->exitCode = $this->application->run($this->input, $this->output); + + return $this; + } + + /** + * Get exit code. + * + * @return integer + */ + public function getExitCode() + { + return $this->exitCode; + } + + /** + * Get output. + * + * @return string + */ + public function getOutput() + { + return $this->output->fetch(); + } +} diff --git a/behat/Command/Util/CommandTestFactory.php b/behat/Command/Util/CommandTestFactory.php new file mode 100644 index 0000000..cb4255c --- /dev/null +++ b/behat/Command/Util/CommandTestFactory.php @@ -0,0 +1,45 @@ +application = new Application($kernel); + // If don't set to false, application will call 'exit' after command was + // executed. + $this->application->setAutoExit(false); + } + + /** + * Create new command instance. + * + * @param string $command Command name. + * @param array $params Command parameters. + * + * @return CommandTest + */ + public function create($command, array $params = []) + { + return new CommandTest($this->application, $command, $params); + } +} diff --git a/behat/Command/features/fetchAndUpdateStoredQuery.feature b/behat/Command/features/fetchAndUpdateStoredQuery.feature new file mode 100644 index 0000000..2b6fba3 --- /dev/null +++ b/behat/Command/features/fetchAndUpdateStoredQuery.feature @@ -0,0 +1,68 @@ +Feature: + Application should fetch document for stored queries in background. + So we must have command that do it. + + @db-fixtures @external-index-fixtures + Scenario: + Fetch document for stored queries. + + + Given I run command socialhose:stored_query:fetch + + Then command returned 0 exit code + + # Check entities in database. + And database has entity CacheBundle:Query\StoredQuery + | raw | cat | + | status | synced | + And has entity CacheBundle:Document + | title | About cat | + And don't has entity AppBundle:FetchJob + | id | 1 | + + When I wait 1000 milliseconds until documents was indexed + Then internal index has 1 document + | query | eq | #getStoredQuery({'raw': 'cat', 'status': 'synced'}).getId()# | + + # After add new document. + Given has new document in external index + | sequence | 2 | + | title | New cat article | + | date_found | #date().getTimestamp()# | + + When I run command socialhose:stored_query:update + Then command returned 0 exit code + + # Check entities in database. + And database has entity CacheBundle:Query\StoredQuery + | raw | cat | + | status | synced | + And has entity CacheBundle:Document + | title | About cat | + And has entity CacheBundle:Document + | title | New cat article | + + When I wait 1000 milliseconds until documents was indexed + Then internal index has 2 document + | query | eq | #getStoredQuery({'raw': 'cat', 'status': 'synced'}).getId()# | + + # Add third document. + Given has new document in external index + | sequence | 3 | + | title | About dogs | + | date_found | #date().getTimestamp()# | + + When I run command socialhose:stored_query:update + Then command returned 0 exit code + + # Check entities in database. + And has entity CacheBundle:Document + | title | About cat | + And has entity CacheBundle:Document + | title | New cat article | + But don't has entity CacheBundle:Document + | title | About dogs | + + When I wait 1000 milliseconds until documents was indexed + Then internal index has 2 document + | query | eq | #getStoredQuery({'raw': 'cat', 'status': 'synced'}).getId()# | \ No newline at end of file diff --git a/behat/Common/Context/AbstractContext.php b/behat/Common/Context/AbstractContext.php new file mode 100644 index 0000000..2e974eb --- /dev/null +++ b/behat/Common/Context/AbstractContext.php @@ -0,0 +1,338 @@ +createIndexes(); + + echo 'Purge external index ... '; + $this->externalIndex->purge(); + echo 'done'. PHP_EOL; + + echo 'Load indices fixtures: '. PHP_EOL; + + $loader = new IndexFixtureLoader($this->container); + $loader->loadFromDirectory($this->fixturesDir); + $this->indexExecutorFactory->external($this->externalIndex->getIndex()) + ->setLogger(function ($message) { + echo " > {$message}". PHP_EOL; + }) + ->execute($loader->getFixtures()); + + // Wait to insure that all fixtures was indexed. + sleep(2); + } + + /** + * Clear external index and load fixtures before scenario. + * + * @BeforeScenario @internal-index-fixtures + * + * @return void + */ + public function setupInternalIndexFixtures() + { + $this->createIndexes(); + + echo 'Purge internal index ... '; + $this->internalIndex->purge(); + echo 'done'. PHP_EOL; + + echo 'Load indices fixtures: '. PHP_EOL; + + $loader = new IndexFixtureLoader($this->container); + $loader->loadFromDirectory($this->fixturesDir); + $this->indexExecutorFactory->internal($this->internalIndex->getIndex()) + ->setLogger(function ($message) { + echo " > {$message}". PHP_EOL; + }) + ->execute($loader->getFixtures()); + + // Wait to insure that all fixtures was indexed. + sleep(2); + } + + /** + * Clear external index and load fixtures before scenario. + * + * @BeforeScenario @source-index-fixtures + * + * @return void + */ + public function setupSourceIndexFixtures() + { + // + // Remove source_update.date + // + // NOTICE: Insure that you don't run test in production :) + // + unlink(realpath(__DIR__ . '/../../../var/source_update.date')); + + $this->createIndexes(); + $this->setupExternalIndexFixtures(); + + echo 'Purge source index ... '; + $this->sourceIndex->purge(); + echo 'done'. PHP_EOL; + + echo 'Load indices fixtures: '. PHP_EOL; + + $loader = new IndexFixtureLoader($this->container); + $loader->loadFromDirectory($this->fixturesDir); + $this->indexExecutorFactory->source($this->sourceIndex->getIndex()) + ->setLogger(function ($message) { + echo " > {$message}". PHP_EOL; + }) + ->execute($loader->getFixtures()); + + // Wait to insure that all fixtures was indexed. + sleep(2); + } + + /** + * Create indexes if we want to upload index fixtures. + * + * @return void + */ + public function createIndexes() + { + if (! self::$indexInitialized + && (strtolower(trim(getenv('WITHOUT_CLEAR'))) !== 'true')) { + $this->externalIndex->setup(); + $this->internalIndex->setup(); + $this->sourceIndex->setup(); + + self::$indexInitialized = true; + } + } + + /** + * @param ContainerInterface $container A ContainerInterface instance. + * @param string $fixturesDir Path to fixtures directory. + */ + public function __construct(ContainerInterface $container, $fixturesDir) + { + $this->debug = getenv('DEBUG') !== false; + + $this->container = $container; + $this->fixturesDir = realpath($fixturesDir); + + if ($container->getParameter('kernel.environment') !== 'test') { + $message = 'You should run test in test environment /:|'; + throw new \InvalidArgumentException($message); + } + + // Create database helper. + // See Common\Context\DatabaseContextTrait + $this->dataBaseHelper = new DatabaseHelper($container->get('doctrine')); + + // Get serializer metadata for all entities. + // We make it for simplification testing process. With this information + // we can make more powerful expanders and matchers which help as to + // write less but make more. + /** @var EntityManagerInterface $em */ + $em = $this->container->get('doctrine.orm.default_entity_manager'); + + // Get all available entity fqcn's. + $fqcnList = array_map(function (ClassMetadata $metadata) { + return $metadata->getName(); + }, $em->getMetadataFactory()->getAllMetadata()); + + $entities = $this->processEntityMetadata($em, $fqcnList); + // Register all entities. + AppMatcher::registerEntities($entities); + + $this->processor = new DataProcessor($container); + + // Decorate indices connections. + $this->externalIndex = new ExternalIndexConnection( + $this->get('index.external') + ); + + $this->internalIndex = new InternalIndexConnection( + $this->get('index.articles') + ); + + $this->sourceIndex = new InternalSourceConnection( + $this->get('index.sources') + ); + + $this->indexExecutorFactory = new IndexFixtureExecutorFactory(); + } + + /** + * @When /^(?:|I )[Ww]ait (?P\d+) millisecond(?: until| for)?[\w\s]*$/ + * + * @param integer $milliseconds Seconds count. + * + * @return void + */ + public function wait($milliseconds) + { + usleep($milliseconds * 1000); + } + + /** + * Gets a service. + * + * @param string $id The service identifier. + * + * @return object The associated service. + */ + protected function get($id) + { + return $this->container->get($id); + } + + /** + * Gets a parameter. + * + * @param string $name The parameter name. + * + * @return mixed The parameter value. + */ + protected function getParameter($name) + { + return $this->container->getParameter($name); + } + + /** + * Match value against specified pattern. + * + * @param mixed $value Matched value. + * @param mixed $pattern Pattern. + * @param string $error Occurred error. + * + * @return boolean + */ + protected function match($value, $pattern, &$error) + { + $value = $this->processor->process($value); + + $pattern = preg_replace('/\\s{2,}/', '', $pattern); + + // Lint pattern only if it contains json. + if (($pattern[0] === '{') || ($pattern[0] === '[')) { + // Lint pattern. + $lint = new JsonParser(); + $exception = $lint->lint($pattern); + if ($exception !== null) { + throw new \RuntimeException('Pattern lint: ' . $exception->getMessage()); + } + } + + // Process expressions between ##. + $pattern = $this->processor->process($pattern); + + return AppMatcher::match($value, $pattern, $error); + } + + /** + * Lint json. + * + * @param PyStringNode|string $json Json to lint. + * + * @return void + */ + protected function lintJson($json) + { + if ($json instanceof PyStringNode) { + $json = $json->getRaw(); + } + + $linter = new JsonParser(); + $exception = $linter->lint($json); + + if ($exception !== null) { + throw $exception; + } + } +} diff --git a/behat/Common/Context/DatabaseContextTrait.php b/behat/Common/Context/DatabaseContextTrait.php new file mode 100644 index 0000000..4f02c38 --- /dev/null +++ b/behat/Common/Context/DatabaseContextTrait.php @@ -0,0 +1,231 @@ +get('doctrine.orm.entity_manager'); + + echo 'Purge database ... '; + $purger = new TruncateORMPurger(new ORMPurger($em)); + $purger->purge(); + echo 'done'. PHP_EOL; + + $loader = new ContainerAwareLoader($this->container); + $loader->loadFromDirectory($this->fixturesDir); + + echo 'Load database fixtures:'. PHP_EOL; + $executor = new ORMExecutor($em); + $executor + ->setLogger(function ($message) { + echo " > {$message}". PHP_EOL; + }); + $executor->execute($loader->getFixtures(), true); + } + + /** + * @Then /^(?:|[Dd]atabase )[Hh]as entity (?P.+)$/ + * + * @param string $name Entity short name like AppBundle:Entity or FQCN. + * @param TableNode $table Search parameters in table format. + * + * @return void + */ + public function hasEntity($name, TableNode $table) + { + $params = []; + + $tableData = $table->getTable(); + foreach ($tableData as $row) { + $params[current($row)] = next($row); + } + + $entity = $this->dataBaseHelper->getEntity($name, $params); + + self::assertNotNull( + $entity, + "Can't find entity {$name} with parameters " . PHP_EOL + . json_encode($params, JSON_PRETTY_PRINT) + ); + } + + /** + * @Then /^(?:|[Dd]atabase )[Hh]as (?P\d+) entity (?P.+)$/ + * @Then /?[Dd]on't has entity (?P.+)$? + * + * @param string $name Entity short name like AppBundle:Entity or FQCN. + * @param integer $count Expected entities count. + * @param TableNode $table Search parameters in table format. + * + * @return void + */ + public function hasEntities($name, $count, TableNode $table) + { + $params = []; + + $tableData = $table->getTable(); + foreach ($tableData as $row) { + $params[current($row)] = next($row); + } + + $entities = $this->dataBaseHelper->getEntities($name, $params); + + self::assertCount( + (int) $count, + $entities, + "Can't find {$count} entity {$name} with parameters " . PHP_EOL + . json_encode($params, JSON_PRETTY_PRINT) .PHP_EOL . + 'Actually found: '. count($entities) + ); + } + + /** + * @Then /^[Ii] want to delete entity (?P.+)$/ + * + * @param string $name Entity short name like AppBundle:Entity or FQCN. + * @param TableNode $table Search parameters in table format. + * + * @return void + */ + public function deleteEntity($name, TableNode $table) + { + $params = []; + + $tableData = $table->getTable(); + foreach ($tableData as $row) { + $params[current($row)] = next($row); + } + + $this->dataBaseHelper->deleteEntity($name, $params); + } + + /** + * @Then /^(?:|[Dd]atabase )[Dd]on't has entity (?P.+)$/ + * @Then /?[Hh]as entity (?P.+)$? + * + * @param string $name Entity short name like AppBundle:Entity or FQCN. + * @param TableNode $table Search parameters ikn table format. + * + * @return void + */ + public function entityNotExists($name, TableNode $table) + { + $params = []; + + $tableData = $table->getTable(); + foreach ($tableData as $row) { + $params[current($row)] = next($row); + } + + $entities = $this->dataBaseHelper->getEntities($name, $params); + + self::assertCount( + 0, + $entities, + "Entity {$name} with parameters " . PHP_EOL + . json_encode($params, JSON_PRETTY_PRINT) . PHP_EOL .'exists!' + ); + } + + /** + * @param EntityManagerInterface $em A EntityManagerInterface instance. + * @param array $fqcnList List of available fqcn's. + * + * @return array|mixed + */ + protected function processEntityMetadata(EntityManagerInterface $em, array $fqcnList) + { + $entities = []; + + foreach ($fqcnList as $fqcn) { + $reflection = new \ReflectionClass($fqcn); + if ($reflection->implementsInterface(NormalizableEntityInterface::class)) { + $name = \app\c\entityFqcnToShort($fqcn); + + if ($reflection->isAbstract()) { + $entities[$name] = new EntityMetadata($this->processAbstractMetadata($em, $reflection)); + } else { + /** @var NormalizableEntityInterface $entity */ + $entity = $reflection->newInstanceWithoutConstructor(); + $entities[$name] = new EntityMetadata($entity->getMetadata()); + } + } + } + + return $entities; + } + + /** + * @param EntityManagerInterface $em A EntityManagerInterface + * instance. + * @param \ReflectionClass $reflection A ReflectionClass instance. + * + * @return Metadata + */ + protected function processAbstractMetadata( + EntityManagerInterface $em, + \ReflectionClass $reflection + ) { + /** @var ClassMetadataInfo $doctrineMetadata */ + $doctrineMetadata = $em->getClassMetadata($reflection->getName()); + $map = $doctrineMetadata->discriminatorMap; + + if (! is_array($map) || (count($map) === 0)) { + // Parsed abstract class don't has discriminator column. + $message = 'Abstract class without discriminator column not allowed'; + throw new \InvalidArgumentException($message); + } + + $metadata = new Metadata($reflection->getName()); + + $metadataList = array_map(function ($fqcn) use ($em) { + $reflection = new \ReflectionClass($fqcn); + if ($reflection->isAbstract()) { + /** @var ClassMetadataInfo $doctrineMetadata */ + $doctrineMetadata = $em->getClassMetadata($fqcn); + $map = $doctrineMetadata->discriminatorMap; + return $this->processAbstractMetadata($em, $map); + } + + /** @var NormalizableEntityInterface $entity */ + $entity = $reflection->newInstanceWithoutConstructor(); + return $entity->getMetadata(); + }, $map); + + return $metadata->admixList($metadataList); + } +} diff --git a/behat/Common/Context/IndexContextTrait.php b/behat/Common/Context/IndexContextTrait.php new file mode 100644 index 0000000..73911f1 --- /dev/null +++ b/behat/Common/Context/IndexContextTrait.php @@ -0,0 +1,135 @@ +externalIndex; + + case 'internal': + return $this->internalIndex; + + case 'source': + return $this->sourceIndex; + } + + throw new \InvalidArgumentException("Unknown index '{$index}'"); + } + + /** + * Json parameters should have only necessary fields. Over will be auto + * generated. + * + * @Given /^(?:I add|[Hh]as) new document (?:in|to) (?P(external|internal|source)) index$/ + * + * @param TestIndexConnectionInterface $index A TestIndexConnectionInterface + * instance. + * @param TableNode $table A TableNode instance. + * + * @return void + */ + public function indexDocument( + TestIndexConnectionInterface $index, + TableNode $table + ) { + /** @var AbstractContext $this */ + $document = $index->createDocument(); + + $params = []; + $tableData = $table->getTable(); + foreach ($tableData as $row) { + $params[current($row)] = $this->processor->process(next($row)); + } + + foreach ($params as $name => $value) { + $document[$name] = $value; + } + + $index->index($document); + // Wait to insure that all fixtures was indexed. + usleep(100000); + } + + /** + * @Then /^(?P([Ee]xternal|[Ii]nternal|[Ss]ource)) index has (?P\d+) document[s]?$/ + * + * @param TestIndexConnectionInterface $connection A + * TestIndexConnectionInterface + * instance. + * @param integer $count A expected documents + * count. + * @param TableNode $table A TableNode instance. + * + * @return void + */ + public function hasDocuments( + TestIndexConnectionInterface $connection, + $count, + TableNode $table + ) { + /** @var AbstractContext $this */ + $tableData = $table->getTable(); + $factory = $connection->getFilterFactory(); + $filters = []; + foreach ($tableData as $row) { + $field = current($row); + $type = next($row); + $value = next($row); + + if ($type === 'in') { + $value = array_filter(array_map('trim', explode(',', $value))); + } + + $filters[] = + $factory->{$type}($field, $this->processor->process($value)); + } + + $results = $connection->createRequestBuilder() + ->setFilters($filters) + ->build() + ->execute(); + + self::assertCount((int) $count, $results); + } +} diff --git a/behat/Common/Util/Converter/DateConverter.php b/behat/Common/Util/Converter/DateConverter.php new file mode 100644 index 0000000..800a4cb --- /dev/null +++ b/behat/Common/Util/Converter/DateConverter.php @@ -0,0 +1,87 @@ + '\d{4}-\d{2}-\d{2}', + 'Y-m-d H:i:s' => '\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', + 'Y-m-d\TH:i:sP' => '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(:?(:?\+|\-)\d{2}:\d{2}|\w)', + ]; + + /** + * Cache of formats for specified dates. + * + * @var string + */ + private static $dateFormatCache = []; + + /** + * Check that specified string can be converted to \DateTime instance. + * + * @param string $date Date string. + * + * @return boolean + */ + public static function can($date) + { + return self::getFormat($date) !== null; + } + + /** + * @param string $date Date string. + * + * @return \DateTime|false + */ + public static function convert($date) + { + $format = self::getFormat($date); + + if ($format === null) { + throw new \InvalidArgumentException('Invalid date '. $date); + } + + return date_create_from_format($format, $date); + } + + /** + * Get proper date format for specified date string. + * + * @param string $date Date string. + * + * @return string|null + */ + private static function getFormat($date) + { + if (! is_string($date)) { + return false; + } + + if (! isset(self::$dateFormatCache[$date])) { + self::$dateFormatCache[$date] = null; + + // Check specified date against all available patterns and find + // proper format. + foreach (self::$datePatternsMap as $format => $pattern) { + if ((preg_match('/^' . $pattern . '$/', $date) === 1) + && (date_create_from_format($format, $date) !== false)) { + self::$dateFormatCache[$date] = $format; + break; + } + } + } + + return self::$dateFormatCache[$date]; + } +} diff --git a/behat/Common/Util/DatabaseHelper.php b/behat/Common/Util/DatabaseHelper.php new file mode 100644 index 0000000..c7058fd --- /dev/null +++ b/behat/Common/Util/DatabaseHelper.php @@ -0,0 +1,124 @@ +registry = $registry; + } + + /** + * Check that given entity with specified parameters are exists in our + * database. + * + * @param string $name Entity short name like AppBundle:Entity or FQCN. + * @param array $params Entity parameters. + * + * @return array + */ + public function getEntities($name, array $params) + { + // Process parameters. + $params = $this->parseParams($params); + + return $this->registry->getRepository($name)->findBy($params); + } + + /** + * Remove some entities from BD by parameters + * + * @param string $name Entity short name like AppBundle:Entity or FQCN. + * @param array $params Entity parameters. + * + * @return void + */ + public function deleteEntity($name, array $params) + { + // Process parameters. + $params = $this->parseParams($params); + + $entities = $this->registry->getRepository($name)->findBy($params); + $em = $this->registry->getEntityManager(); + foreach ($entities as $entity) { + $em->remove($entity); + } + $em->flush(); + } + + /** + * Check that given entity with specified parameters are exists in our + * database. + * + * @param string $name Entity short name like AppBundle:Entity or FQCN. + * @param array $params Entity parameters. + * + * @return object|null + */ + public function getEntity($name, array $params) + { + // Process parameters. + $params = $this->parseParams($params); + + return $this->registry->getRepository($name)->findOneBy($params); + } + + /** + * Parse raw parameters. + * + * @param array $params Entity parameters. + * + * @return array + */ + private function parseParams(array $params) + { + return array_map(function ($parameter) { + $origin = trim($parameter); + $buf = strtolower($origin); + + switch (true) { + // Parameter is valid numerical value convert it to float or + // integer. + case is_numeric($buf): + if (strpos($buf, '.') !== false) { + return (float) $buf; + } + + return (int) $buf; + + case DateConverter::can($buf): + return DateConverter::convert($buf); + + case in_array($origin, \DateTimeZone::listIdentifiers(), true): + return new \DateTimeZone($origin); + + // Parameter contains boolean value. + case ($buf === 'true') || ($buf === 'false'): + return $buf === 'true'; + } + + return $parameter; + }, $params); + } +} diff --git a/behat/Common/Util/Index/AbstractTestIndexConnection.php b/behat/Common/Util/Index/AbstractTestIndexConnection.php new file mode 100644 index 0000000..2a14ad8 --- /dev/null +++ b/behat/Common/Util/Index/AbstractTestIndexConnection.php @@ -0,0 +1,270 @@ +index = $index; + } + + /** + * Update specified document. + * + * Make partial update so in data must be placed only changed properties. + * + * @param string|integer $id Updated document id. + * @param array $data Array of changed data where key is property + * name and value is new property value. + * + * @return void + */ + public function update($id, array $data) + { + $this->index->update($id, $data); + } + + /** + * Update array of documents. + * + * Make partial update so for each document id we should place only changed + * property. + * + * @param array $config Array of arrays where key is updated document id and + * value is array of updated fields same as $data in + * `update` method. + * + * @return void + */ + public function updateBulk(array $config) + { + $this->index->updateBulk($config); + } + + /** + * Update array of documents with filtering. + * + * Make partial update so for each document id we should place only changed + * property. + * + * @param SearchRequestInterface $request A SearchRequestInterface instance. + * @param string $script Updating script. + * @param array $params Script parameters. + * + * @return void + */ + public function updateByQuery(SearchRequestInterface $request, $script, array $params = []) + { + $this->index->updateByQuery($request, $script, $params); + } + + /** + * Remove document by specified id or array of ids. + * + * @param string|string[] $id Document id or array of document ids. + * + * @return void + */ + public function remove($id) + { + $this->index->remove($id); + } + + /** + * Search information in index. + * + * @param SearchRequestInterface $request Internal representation of search + * request. + * + * @return SearchResponseInterface + */ + public function search(SearchRequestInterface $request) + { + return $this->index->search($request); + } + + /** + * Fetch all relevant documents. + * + * @param SearchRequestInterface $request Internal representation of search + * request. + * + * @return \Traversable + */ + public function fetchAll(SearchRequestInterface $request) + { + return $this->index->fetchAll($request); + } + + /** + * Create search request builder for this index connection. + * + * @return SearchRequestBuilderInterface + */ + public function createRequestBuilder() + { + return $this->index->createRequestBuilder(); + } + + /** + * Get filter factory instance. + * + * @return \IndexBundle\Filter\Factory\FilterFactoryInterface + */ + public function getFilterFactory() + { + return $this->index->getFilterFactory(); + } + + /** + * Get aggregation factory instance + * + * @return AggregationFactoryInterface + */ + public function getAggregationFactory() + { + return $this->index->getAggregationFactory(); + } + + /** + * Get aggregation instance + * + * @return AggregationFacadeInterface + */ + public function getAggregation() + { + return $this->index->getAggregation(); + } + + /** + * Return advanced filters aggregator. + * + * @return AFResolverInterface + */ + public function getAFResolver() + { + return $this->index->getAFResolver(); + } + + /** + * Get strategy used by this index. + * + * @return IndexStrategyInterface + */ + public function getStrategy() + { + return $this->index->getStrategy(); + } + + /** + * Create new index. + * + * @param array $mapping Index mapping. + * @param array $settings Index settings. + * + * @return void + */ + public function createIndex(array $mapping, array $settings = []) + { + if ($this->index instanceof InternalIndexInterface) { + $this->index->createIndex($mapping, $settings); + } else { + throw new \LogicException('Can\'t create index on '. get_class($this->index)); + } + } + + /** + * Index given document or array of documents. + * + * @param DocumentInterface|DocumentInterface[] $data DocumentInterface instance + * or array of instances. + * + * @return void + */ + public function index($data) + { + if ($this->index instanceof InternalIndexInterface) { + $this->index->index($data); + } else { + throw new \LogicException('Can\'t index documents on '. get_class($this->index)); + } + } + + /** + * Purge index. + * + * @return void + */ + public function purge() + { + if ($this->index instanceof InternalIndexInterface) { + $this->index->purge(); + } else { + throw new \LogicException('Can\'t purge index on '. get_class($this->index)); + } + } + + /** + * Get documents by it ids. + * + * @param integer|integer[] $ids Array of document ids or single id. + * @param string|string[] $fields Array of requested fields of single + * field. + * + * @return \IndexBundle\Model\DocumentInterface[] + */ + public function get($ids, $fields = []) + { + return $this->index->get($ids, $fields); + } + + /** + * Check that specified documents is exists. + * + * @param integer|array $ids Array of document ids or single id. + * + * @return array Contains all ids which not found in index. + */ + public function has($ids) + { + return $this->index->has($ids); + } + + /** + * @return IndexInterface|InternalIndexInterface|ExternalIndexInterface|SourceIndexInterface + */ + public function getIndex() + { + return $this->index; + } +} diff --git a/behat/Common/Util/Index/ExternalIndexConnection.php b/behat/Common/Util/Index/ExternalIndexConnection.php new file mode 100644 index 0000000..b33e39b --- /dev/null +++ b/behat/Common/Util/Index/ExternalIndexConnection.php @@ -0,0 +1,53 @@ +documentGenerator = new ExternalDocumentGenerator(); + } + + /** + * Setup external index. + * + * @return void + */ + public function setup() + { + ExternalIndexInitializer::initialize($this); + } + + /** + * Create new document for this index. + * + * @return DocumentInterface + */ + public function createDocument() + { + return $this->documentGenerator->generate(); + } +} diff --git a/behat/Common/Util/Index/InternalIndexConnection.php b/behat/Common/Util/Index/InternalIndexConnection.php new file mode 100644 index 0000000..f3297ac --- /dev/null +++ b/behat/Common/Util/Index/InternalIndexConnection.php @@ -0,0 +1,52 @@ +documentGenerator = new InternalDocumentGenerator(); + } + + /** + * Setup internal index. + * + * @return void + */ + public function setup() + { + InternalIndexInitializer::initialize($this); + } + + /** + * Create new document for this index. + * + * @return DocumentInterface + */ + public function createDocument() + { + return $this->documentGenerator->generate(); + } +} diff --git a/behat/Common/Util/Index/InternalSourceConnection.php b/behat/Common/Util/Index/InternalSourceConnection.php new file mode 100644 index 0000000..adf60f2 --- /dev/null +++ b/behat/Common/Util/Index/InternalSourceConnection.php @@ -0,0 +1,52 @@ +documentGenerator = new SourceDocumentGenerator(); + } + + /** + * Setup internal index. + * + * @return void + */ + public function setup() + { + SourceIndexInitializer::initialize($this); + } + + /** + * Create new document for this index. + * + * @return DocumentInterface + */ + public function createDocument() + { + return $this->documentGenerator->generate(); + } +} diff --git a/behat/Common/Util/Index/TestIndexConnectionInterface.php b/behat/Common/Util/Index/TestIndexConnectionInterface.php new file mode 100644 index 0000000..81f92fb --- /dev/null +++ b/behat/Common/Util/Index/TestIndexConnectionInterface.php @@ -0,0 +1,64 @@ +createMatcher(); + + if (! $matcher->match($value, $pattern)) { + $error = $matcher->getError(); + return false; + } + + return true; + } +} diff --git a/behat/Common/Util/Matcher/Expander/AbstractChainExpander.php b/behat/Common/Util/Matcher/Expander/AbstractChainExpander.php new file mode 100644 index 0000000..08063c2 --- /dev/null +++ b/behat/Common/Util/Matcher/Expander/AbstractChainExpander.php @@ -0,0 +1,60 @@ +expanders[] = $expander; + + if (func_num_args() > 1) { + $arguments = func_get_args(); + $length = count($arguments); + for ($i = 1; $i < $length; ++$i) { + if (!$arguments[$i] instanceof PatternExpander) { + throw new \InvalidArgumentException('Has invalid expander.'); + } + + $this->expanders[] = $arguments[$i]; + } + } + } + + /** + * @param mixed $value Value to match. + * + * @return boolean + */ + public function match($value) + { + foreach ($this->expanders as $expander) { + if (! $expander->match($value)) { + $className = get_class($expander); + $className = substr($className, strrpos($className, '\\')); + + $this->error = "Expander {$className} don't matches value: ". + $expander->getError(); + return false; + } + } + + return true; + } +} diff --git a/behat/Common/Util/Matcher/Expander/AbstractExpander.php b/behat/Common/Util/Matcher/Expander/AbstractExpander.php new file mode 100644 index 0000000..038d35c --- /dev/null +++ b/behat/Common/Util/Matcher/Expander/AbstractExpander.php @@ -0,0 +1,26 @@ +error; + } +} diff --git a/behat/Common/Util/Matcher/Expander/BetweenExpander.php b/behat/Common/Util/Matcher/Expander/BetweenExpander.php new file mode 100644 index 0000000..ca400e8 --- /dev/null +++ b/behat/Common/Util/Matcher/Expander/BetweenExpander.php @@ -0,0 +1,78 @@ +start = $start; + $this->end = $end; + } + + /** + * @param mixed $value Value to match. + * + * @return boolean + */ + public function match($value) + { + if (! is_numeric($value) && ! is_int($value) && ! is_float($value) + && ! is_string($value)) { + $this->error = 'Can match only integers, float and datetime values'; + return false; + } + + $start = $this->start; + $end = $this->end; + if (DateConverter::can($value)) { + // For string which represent date try to convert it into \DateTime + // instances. + try { + $value = DateConverter::convert($value); + $start = DateConverter::convert($start); + $end = DateConverter::convert($end); + + $value->setTimezone($start->getTimezone()); + } catch (\Exception $e) { + $this->error = $e->getMessage(); + return false; + } + } else { + // For scalar types convert all values to the same type. + $type = gettype($start); + settype($value, $type); + } + + return ($value >= $start) && ($value <= $end); + } +} diff --git a/behat/Common/Util/Matcher/Expander/EntityExpander.php b/behat/Common/Util/Matcher/Expander/EntityExpander.php new file mode 100644 index 0000000..db5b7a9 --- /dev/null +++ b/behat/Common/Util/Matcher/Expander/EntityExpander.php @@ -0,0 +1,69 @@ +entityName = $entityName; + + // Split serialization groups string into array. + $serializationGroups = explode(',', $serializationGroups); + // Trim values and remove empty. + $serializationGroups = array_filter( + array_map('trim', $serializationGroups) + ); + + $this->serializationGroups = $serializationGroups; + } + + /** + * @param mixed $value Value to match. + * + * @return boolean + */ + public function match($value) + { + // Get entity metadata for specified entity. + $metadata = AppMatcher::getEntityMetadata($this->entityName); + // Get patter fot specified entity with given serialization group. + $pattern = $metadata->getPattern($this->serializationGroups); + + if ($pattern && ! AppMatcher::match($value, $pattern, $this->error)) { + $this->error = + "Invalid entity {$this->entityName}: {$this->error}"; + + return false; + } + + return true; + } +} diff --git a/behat/Common/Util/Matcher/Expander/EveryExpander.php b/behat/Common/Util/Matcher/Expander/EveryExpander.php new file mode 100644 index 0000000..b86602b --- /dev/null +++ b/behat/Common/Util/Matcher/Expander/EveryExpander.php @@ -0,0 +1,43 @@ +error = 'Every expander require "array", got '. + new StringConverter($value) .'.'; + return false; + } + + foreach ($value as $row) { + if (! parent::match($row)) { + $this->error = 'Checked value '. new StringConverter($row) + .' is invalid: '. $this->error; + return false; + } + } + + return true; + } +} diff --git a/behat/Common/Util/Matcher/Expander/FieldExpander.php b/behat/Common/Util/Matcher/Expander/FieldExpander.php new file mode 100644 index 0000000..549c7be --- /dev/null +++ b/behat/Common/Util/Matcher/Expander/FieldExpander.php @@ -0,0 +1,88 @@ +fieldName = $fieldName; + $this->expander = $expander; + + if ($expander instanceof PatternExpander) { + $expander = func_get_args(); + $length = count($expander); + // Process all except first argument which contains field name. + $this->expander = []; + for ($i = 1; $i < $length; ++$i) { + if (!$expander[$i] instanceof PatternExpander) { + throw new \InvalidArgumentException('Has invalid expander.'); + } + + $this->expander[] = $expander[$i]; + } + } + } + + /** + * @param mixed $value Value to match. + * + * @return boolean + */ + public function match($value) + { + if (! is_array($value) && !isset($value[$this->fieldName])) { + return false; + } + + if (is_array($this->expander)) { + // Match all expanders. + foreach ($this->expander as $expander) { + if (! $expander->match($value[$this->fieldName])) { + $this->error = "Field {$this->fieldName}: expander don't matches value. ". + $expander->getError(); + return false; + } + } + + // All expanders successfully matches. + return true; + } + + if ($value[$this->fieldName] !== $this->expander) { + $this->error = "Field {$this->fieldName}: don't equal to {$this->expander}"; + return false; + } + + return true; + } +} diff --git a/behat/Common/Util/Matcher/Expander/GteExpander.php b/behat/Common/Util/Matcher/Expander/GteExpander.php new file mode 100644 index 0000000..035aede --- /dev/null +++ b/behat/Common/Util/Matcher/Expander/GteExpander.php @@ -0,0 +1,78 @@ +value = $value; + } + + /** + * @param mixed $value Value to match. + * + * @return boolean + */ + public function match($value) + { + if (! is_numeric($value) && ! is_int($value) && ! is_float($value) + && ! is_string($value)) { + $this->error = 'Can match only integers, float and datetime values'; + return false; + } + + $bound = $this->value; + if (DateConverter::can($value)) { + // For string which represent date try to convert it into \DateTime + // instances. + try { + $value = DateConverter::convert($value); + $bound = DateConverter::convert($bound); + + $bound->setTimezone($value->getTimezone()); + } catch (\Exception $e) { + $this->error = $e->getMessage(); + return false; + } + } else { + // For scalar types convert all values to the same type. + $type = gettype($bound); + settype($value, $type); + } + + if (! ($matched = $value >= $bound)) { + if ($value instanceof \DateTime) { + $value = $value->format('c'); + $bound = $bound->format('c'); + } + + $this->error = "Checked value {$value} less than {$bound}."; + } + + return $matched; + } +} diff --git a/behat/Common/Util/Matcher/Expander/LengthExpander.php b/behat/Common/Util/Matcher/Expander/LengthExpander.php new file mode 100644 index 0000000..8ae0732 --- /dev/null +++ b/behat/Common/Util/Matcher/Expander/LengthExpander.php @@ -0,0 +1,52 @@ +count = $count; + } + + /** + * @param mixed $value Value to match. + * + * @return boolean + */ + public function match($value) + { + if (! is_array($value)) { + return false; + } + + if ($this->count instanceof PatternExpander) { + return $this->count->match(count($value)); + } + + return count($value) === $this->count; + } +} diff --git a/behat/Common/Util/Matcher/Expander/NotExpander.php b/behat/Common/Util/Matcher/Expander/NotExpander.php new file mode 100644 index 0000000..dd39f66 --- /dev/null +++ b/behat/Common/Util/Matcher/Expander/NotExpander.php @@ -0,0 +1,44 @@ +expander = $expander; + } + + /** + * @param mixed $value Value to match. + * + * @return boolean + */ + public function match($value) + { + if ($this->expander->match($value)) { + $this->error = 'Expander match this value.'; + return false; + } + + return true; + } +} diff --git a/behat/Common/Util/Matcher/Expander/OneExpander.php b/behat/Common/Util/Matcher/Expander/OneExpander.php new file mode 100644 index 0000000..e6063a5 --- /dev/null +++ b/behat/Common/Util/Matcher/Expander/OneExpander.php @@ -0,0 +1,40 @@ +error = 'One expander require "array", got '. + new StringConverter($value) .'.'; + return false; + } + + foreach ($value as $row) { + if (parent::match($row)) { + return true; + } + } + + return false; + } +} diff --git a/behat/Common/Util/Matcher/Expander/SomeExpander.php b/behat/Common/Util/Matcher/Expander/SomeExpander.php new file mode 100644 index 0000000..ef3f758 --- /dev/null +++ b/behat/Common/Util/Matcher/Expander/SomeExpander.php @@ -0,0 +1,43 @@ +error = 'Some expander require "array", got '. + new StringConverter($value) .'.'; + return false; + } + + foreach ($value as $row) { + if (parent::match($row)) { + return true; + } + } + + + $this->error = 'No element does not match specified expanders'; + return false; + } +} diff --git a/behat/Common/Util/Matcher/Expander/TypeExpander.php b/behat/Common/Util/Matcher/Expander/TypeExpander.php new file mode 100644 index 0000000..1228fc8 --- /dev/null +++ b/behat/Common/Util/Matcher/Expander/TypeExpander.php @@ -0,0 +1,46 @@ +type = $type; + } + + /** + * @param mixed $value Value to match. + * + * @return boolean + */ + public function match($value) + { + $valueType = gettype($value); + + if ($valueType === 'float') { + $valueType = 'double'; + } + + return $valueType === $this->type; + } +} diff --git a/behat/Common/Util/Matcher/Matcher/JsonMatcher.php b/behat/Common/Util/Matcher/Matcher/JsonMatcher.php new file mode 100644 index 0000000..ccd00c3 --- /dev/null +++ b/behat/Common/Util/Matcher/Matcher/JsonMatcher.php @@ -0,0 +1,94 @@ +matcher = $matcher; + } + + /** + * Checks if matcher can match the pattern + * + * @param mixed $pattern Pattern. + * + * @return boolean + */ + public function canMatch($pattern) + { + return Json::isValidPattern($pattern); + } + + /** + * Matches value against the pattern + * + * @param mixed $value Checked value. + * @param mixed $pattern Pattern. + * + * @return boolean + */ + public function match($value, $pattern) + { + if (parent::match($value, $pattern)) { + return true; + } + + if (!Json::isValid($value)) { + $this->error = sprintf("Invalid given JSON of value. %s", $this->getErrorMessage()); + return false; + } + + if (!Json::isValidPattern($pattern) ) { + $this->error = sprintf("Invalid given JSON of pattern. %s", $this->getErrorMessage()); + return false; + } + + $transformedPattern = Json::transformPattern($pattern); + $match = $this->matcher->match(json_decode($value, true), json_decode($transformedPattern, true)); + if (!$match) { + $this->error = $this->matcher->getError(); + return false; + } + + return true; + } + + /** + * @return string + */ + private function getErrorMessage() + { + switch(json_last_error()) { + case JSON_ERROR_DEPTH: + return 'Maximum stack depth exceeded'; + case JSON_ERROR_STATE_MISMATCH: + return 'Underflow or the modes mismatch'; + case JSON_ERROR_CTRL_CHAR: + return 'Unexpected control character found'; + case JSON_ERROR_SYNTAX: + return 'Syntax error, malformed JSON'; + case JSON_ERROR_UTF8: + return 'Malformed UTF-8 characters, possibly incorrectly encoded'; + default: + return 'Unknown error'; + } + } +} diff --git a/behat/Common/Util/Matcher/Matcher/ObjectMatcher.php b/behat/Common/Util/Matcher/Matcher/ObjectMatcher.php new file mode 100644 index 0000000..6cfa9ca --- /dev/null +++ b/behat/Common/Util/Matcher/Matcher/ObjectMatcher.php @@ -0,0 +1,88 @@ +parser = $parser; + } + + /** + * Checks if matcher can match the pattern + * + * @param mixed $pattern Pattern. + * + * @return boolean + */ + public function canMatch($pattern) + { + if (! is_string($pattern)) { + return false; + } + + return $this->parser->hasValidSyntax($pattern) + && $this->parser->parse($pattern)->is('object'); + } + + /** + * Matches value against the pattern + * + * @param mixed $value Checked value. + * @param mixed $pattern Pattern. + * + * @return boolean + */ + public function match($value, $pattern) + { + if (parent::match($value, $pattern)) { + return true; + } + + // Add ability to match serialized json. + $lint = new JsonParser(); + if (is_string($value) && ($lint->lint($value) === null)) { + $value = json_decode($value, true); + } + + if (! is_array($value)) { + return false; + } + + // Check that given value is assoc array. + if (array_keys($value) === range(0, count($value) - 1)) { + return false; + } + + $typePattern = $this->parser->parse($pattern); + + // Match all expanders. + if (!$typePattern->matchExpanders($value)) { + $this->error = $typePattern->getError(); + return false; + } + + return true; + } +} diff --git a/behat/Common/Util/Matcher/Matcher/WildcardMatcher.php b/behat/Common/Util/Matcher/Matcher/WildcardMatcher.php new file mode 100644 index 0000000..e4ba68b --- /dev/null +++ b/behat/Common/Util/Matcher/Matcher/WildcardMatcher.php @@ -0,0 +1,66 @@ +parser = $parser; + } + + const MATCH_PATTERN = "@wildcard@"; + + /** + * Checks if matcher can match the pattern + * + * @param mixed $pattern Pattern. + * + * @return boolean + */ + public function canMatch($pattern) + { + return is_string($pattern) + && strpos($pattern, self::MATCH_PATTERN) !== false; + } + + /** + * Matches value against the pattern + * + * @param mixed $value Checked value. + * @param mixed $pattern Pattern. + * + * @return boolean + */ + public function match($value, $pattern) + { + $typePattern = $this->parser->parse($pattern); + + // Match all expanders. + if (!$typePattern->matchExpanders($value)) { + $this->error = $typePattern->getError(); + return false; + } + + return true; + } +} diff --git a/behat/Common/Util/Matcher/MatcherFactory.php b/behat/Common/Util/Matcher/MatcherFactory.php new file mode 100644 index 0000000..1abf452 --- /dev/null +++ b/behat/Common/Util/Matcher/MatcherFactory.php @@ -0,0 +1,103 @@ + AppExpanders\FieldExpander::class, + 'some' => AppExpanders\SomeExpander::class, + 'every' => AppExpanders\EveryExpander::class, + 'length' => AppExpanders\LengthExpander::class, + 'one' => AppExpanders\OneExpander::class, + 'type' => AppExpanders\TypeExpander::class, + 'entity' => AppExpanders\EntityExpander::class, + 'not' => AppExpanders\NotExpander::class, + 'gte' => AppExpanders\GteExpander::class, + 'between' => AppExpanders\BetweenExpander::class, + ]; + + /** + * @return ChainMatcher + */ + protected function buildScalarMatchers() + { + $parser = $this->buildParser(); + + return new Matcher\ChainMatcher([ + // Default matchers. + new Matcher\CallbackMatcher(), + new Matcher\ExpressionMatcher(), + new Matcher\NullMatcher(), + new Matcher\StringMatcher($parser), + new Matcher\IntegerMatcher($parser), + new Matcher\BooleanMatcher(), + new Matcher\DoubleMatcher($parser), + new Matcher\NumberMatcher(), + new Matcher\ScalarMatcher(), + + // Custom matchers. + new AppMatchers\ObjectMatcher($parser), + new AppMatchers\WildcardMatcher($parser), + ]); + } + + /** + * @return Parser + */ + protected function buildParser() + { + if (!self::$parser) { + // Register all expanders. + $expanderInitializer = new Parser\ExpanderInitializer(); + + foreach (self::$additionalExpanders as $name => $class) { + $expanderInitializer->setExpanderDefinition($name, $class); + } + + self::$parser = new Parser(new Lexer(), $expanderInitializer); + } + + return self::$parser; + } + + /** + * @return \Coduo\PHPMatcher\Matcher\ChainMatcher + */ + protected function buildMatchers() + { + $scalarMatchers = $this->buildScalarMatchers(); + $orMatcher = $this->buildOrMatcher(); + + $chainMatcher = new Matcher\ChainMatcher([ + $scalarMatchers, + $orMatcher, + new AppMatchers\JsonMatcher($orMatcher), + new Matcher\XmlMatcher($orMatcher), + new Matcher\TextMatcher($scalarMatchers, $this->buildParser()), + ]); + + return $chainMatcher; + } +} diff --git a/behat/Common/Util/Metadata/EntityMetadata.php b/behat/Common/Util/Metadata/EntityMetadata.php new file mode 100644 index 0000000..b46268b --- /dev/null +++ b/behat/Common/Util/Metadata/EntityMetadata.php @@ -0,0 +1,145 @@ +metadata = $metadata; + } + + /** + * Get pattern for specified groups. + * + * @param array $groups Serialization groups. + * + * @return array + */ + public function getPattern(array $groups) + { + sort($groups); + $key = serialize($groups); + if (! isset($this->cache[$key])) { + $properties = $this->metadata->getProperties($groups); + + $this->cache[$key] = []; + + if ($this->metadata->implementsInterface(EntityInterface::class)) { + $this->cache[$key]['type'] = '@string@'; + } + + foreach ($properties as $property) { + $this->cache[$key][$property->getName()] = + $this->generateProperty($property, $groups); + } + } + + return $this->cache[$key]; + } + + /** + * Convert from entity metadata type to PHPMatcher type. + * + * @param PropertyMetadata $property A PropertyMetadata instance. + * @param array $groups A serialization groups. + * + * @return string + */ + private function generateProperty(PropertyMetadata $property, array $groups) + { + $type = $property->getType(); + + switch ($type) { + // Associated object - another entity. + // Use entity expander with same serialization groups. + case PropertyMetadata::TYPE_ENTITY: + $type = \app\c\entityFqcnToShort($property->getActualType()); + $expander = "entity('{$type}', '". implode(',', $groups) ."')"; + $type = "@object@.{$expander}"; + if ($property->isNullable()) { + $type = "@wildcard@.oneOf(isEmpty(), {$expander})"; + } + break; + + // Enum type. + case PropertyMetadata::TYPE_ENUM: + $type = '@string@'; + break; + + // Collection of associated entities. + case PropertyMetadata::TYPE_COLLECTION: + $type = \app\c\entityFqcnToShort($property->getActualType()); + $reflection = new \ReflectionClass($property->getActualType()); + if ($reflection->isAbstract()) { + $type = '@array@'; + } else { + $expander = "entity('{$type}', '" . implode(',', $groups) . "')"; + $type = "@array@.every({$expander})"; + if ($property->isNullable()) { + $type = "@wildcard@.oneOf(isEmpty(), every({$expander}))"; + } + } + break; + + // For double type also use integer type checker. + case PropertyMetadata::TYPE_DOUBLE: + $type = "@wildcard@.oneOf(type('integer'), type('double'))"; + if ($property->isNullable()) { + $type = "@wildcard@.oneOf(isEmpty(), type('integer'), type('double'))"; + } + break; + + // For DateTime instances use 'string' matcher with 'isDateTime' + // expander. + case PropertyMetadata::TYPE_DATE: + $type = '@string@.isDateTime()'; + if ($property->isNullable()) { + $type = '@wildcard@.oneOf(isEmpty(), isDateTime())'; + } + break; + + // Process inline objects. + case PropertyMetadata::TYPE_GROUP: + $type = '@object@'; + if ($property->isNullable()) { + $type = '@wildcard@'; + } + break; + + // Other types like string, integer and etc. + default: + if ($property->isNullable()) { + $type = "@wildcard@.oneOf(isEmpty(), type('$type'))"; + } else { + $type = '@'. $type .'@'; + } + } + + return $type; + } +} diff --git a/behat/Common/Util/Processor/DataProcessor.php b/behat/Common/Util/Processor/DataProcessor.php new file mode 100644 index 0000000..94ff5d4 --- /dev/null +++ b/behat/Common/Util/Processor/DataProcessor.php @@ -0,0 +1,88 @@ +language = new TestExpressionLanguage($container); + $this->registerFunctions(); + } + + /** + * @param mixed $data Process data send to server and replace patterns. + * + * @return mixed + */ + public function process($data) + { + if (is_array($data)) { + // Recursively process arrays. + return array_map(function ($data) { + return $this->process($data); + }, $data); + } elseif (is_string($data)) { + // Process string data. + $replacer = function ($param) { + // Sanitize params. + $param = str_replace('\\"', '\'', trim(current($param), '#')); + + return $this->language->evaluate($param); + }; + + return preg_replace_callback("/#.+?#/", $replacer, $data); + } + + // Not change other variable types. + return $data; + } + + /** + * @param mixed $arguments Arguments specified by expression language. + * @param mixed $time Pass to DateTime constructor. + * + * @return \DateTime + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function createDate($arguments, $time = 'now') + { + return new \DateTime($time); + } + + /** + * Register custom expression language functions. + * + * @return void + */ + private function registerFunctions() + { + $dummy = function () { + // do nothing. + }; + + $this->language->register('date', $dummy, [ $this, 'createDate' ]); + } +} diff --git a/behat/Common/Util/Processor/ExpressionLanguage/Provider/EntityGetterProvider.php b/behat/Common/Util/Processor/ExpressionLanguage/Provider/EntityGetterProvider.php new file mode 100644 index 0000000..3628145 --- /dev/null +++ b/behat/Common/Util/Processor/ExpressionLanguage/Provider/EntityGetterProvider.php @@ -0,0 +1,75 @@ +em = $em; + } + + /** + * @return ExpressionFunction[] An array of Function instances. + */ + public function getFunctions() + { + /** + * Dummy compiler. + * We use this expression function only in runtime and not compile its. + */ + $compiler = function () { + }; + + $functions = []; + /** @var ClassMetadataInfo[] $metadataList */ + $metadataList = $this->em->getMetadataFactory()->getAllMetadata(); + foreach ($metadataList as $metadata) { + $name = $metadata->getName(); + $shortName = substr($name, strrpos($name, '\\') + 1); + $fnName = 'get'. $shortName; + + if (strpos($name, 'Entity\\'. $shortName) === false) { + // Process only entities inside 'Entity' directory. + continue; + } + + /** + * @param mixed $arguments Arguments specified by expression language + * @param array $criteria Search criteria. + * + * @return null|object Found entity or null. + */ + $evaluator = function ($arguments, array $criteria) use ($name) { + return $this->em->getRepository($name)->findOneBy($criteria); + }; + $functions[] = new ExpressionFunction($fnName, $compiler, $evaluator); + } + + return $functions; + } +} diff --git a/behat/Common/Util/Processor/ExpressionLanguage/TestExpressionLanguage.php b/behat/Common/Util/Processor/ExpressionLanguage/TestExpressionLanguage.php new file mode 100644 index 0000000..bd44202 --- /dev/null +++ b/behat/Common/Util/Processor/ExpressionLanguage/TestExpressionLanguage.php @@ -0,0 +1,41 @@ +get('doctrine'); + + $dummy = function () { + // Dummy function for compiler argument of ExpressionFunction. + }; + + parent::__construct(null, [ + new EntityGetterProvider($doctrine->getManager()), + ]); + + // Add 'now' function which return current date. + $this->addFunction(new ExpressionFunction('now', $dummy, function () { + return date_create(); + })); + } +} diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..49247c9 --- /dev/null +++ b/bin/console @@ -0,0 +1,29 @@ +#!/usr/bin/env php +getParameterOption(['--env', '-e'], getenv('SYMFONY_ENV') ?: 'dev'); +$debug = getenv('SYMFONY_DEBUG') !== '0' && !$input->hasParameterOption(['--no-debug', '']) && $env !== 'prod'; + +if ($debug) { + Debug::enable(); +} + +$kernel = new AppKernel($env, $debug); +$application = new Application($kernel); +$application->run($input); diff --git a/bin/symfony_requirements b/bin/symfony_requirements new file mode 100755 index 0000000..a7bf65a --- /dev/null +++ b/bin/symfony_requirements @@ -0,0 +1,146 @@ +#!/usr/bin/env php +getPhpIniConfigPath(); + +echo_title('Symfony Requirements Checker'); + +echo '> PHP is using the following php.ini file:'.PHP_EOL; +if ($iniPath) { + echo_style('green', ' '.$iniPath); +} else { + echo_style('yellow', ' WARNING: No configuration file (php.ini) used by PHP!'); +} + +echo PHP_EOL.PHP_EOL; + +echo '> Checking Symfony requirements:'.PHP_EOL.' '; + +$messages = array(); +foreach ($symfonyRequirements->getRequirements() as $req) { + if ($helpText = get_error_message($req, $lineSize)) { + echo_style('red', 'E'); + $messages['error'][] = $helpText; + } else { + echo_style('green', '.'); + } +} + +$checkPassed = empty($messages['error']); + +foreach ($symfonyRequirements->getRecommendations() as $req) { + if ($helpText = get_error_message($req, $lineSize)) { + echo_style('yellow', 'W'); + $messages['warning'][] = $helpText; + } else { + echo_style('green', '.'); + } +} + +if ($checkPassed) { + echo_block('success', 'OK', 'Your system is ready to run Symfony projects'); +} else { + echo_block('error', 'ERROR', 'Your system is not ready to run Symfony projects'); + + echo_title('Fix the following mandatory requirements', 'red'); + + foreach ($messages['error'] as $helpText) { + echo ' * '.$helpText.PHP_EOL; + } +} + +if (!empty($messages['warning'])) { + echo_title('Optional recommendations to improve your setup', 'yellow'); + + foreach ($messages['warning'] as $helpText) { + echo ' * '.$helpText.PHP_EOL; + } +} + +echo PHP_EOL; +echo_style('title', 'Note'); +echo ' The command console could use a different php.ini file'.PHP_EOL; +echo_style('title', '~~~~'); +echo ' than the one used with your web server. To be on the'.PHP_EOL; +echo ' safe side, please check the requirements from your web'.PHP_EOL; +echo ' server using the '; +echo_style('yellow', 'web/config.php'); +echo ' script.'.PHP_EOL; +echo PHP_EOL; + +exit($checkPassed ? 0 : 1); + +function get_error_message(Requirement $requirement, $lineSize) +{ + if ($requirement->isFulfilled()) { + return; + } + + $errorMessage = wordwrap($requirement->getTestMessage(), $lineSize - 3, PHP_EOL.' ').PHP_EOL; + $errorMessage .= ' > '.wordwrap($requirement->getHelpText(), $lineSize - 5, PHP_EOL.' > ').PHP_EOL; + + return $errorMessage; +} + +function echo_title($title, $style = null) +{ + $style = $style ?: 'title'; + + echo PHP_EOL; + echo_style($style, $title.PHP_EOL); + echo_style($style, str_repeat('~', strlen($title)).PHP_EOL); + echo PHP_EOL; +} + +function echo_style($style, $message) +{ + // ANSI color codes + $styles = array( + 'reset' => "\033[0m", + 'red' => "\033[31m", + 'green' => "\033[32m", + 'yellow' => "\033[33m", + 'error' => "\033[37;41m", + 'success' => "\033[37;42m", + 'title' => "\033[34m", + ); + $supports = has_color_support(); + + echo($supports ? $styles[$style] : '').$message.($supports ? $styles['reset'] : ''); +} + +function echo_block($style, $title, $message) +{ + $message = ' '.trim($message).' '; + $width = strlen($message); + + echo PHP_EOL.PHP_EOL; + + echo_style($style, str_repeat(' ', $width)); + echo PHP_EOL; + echo_style($style, str_pad(' ['.$title.']', $width, ' ', STR_PAD_RIGHT)); + echo PHP_EOL; + echo_style($style, $message); + echo PHP_EOL; + echo_style($style, str_repeat(' ', $width)); + echo PHP_EOL; +} + +function has_color_support() +{ + static $support; + + if (null === $support) { + if (DIRECTORY_SEPARATOR == '\\') { + $support = false !== getenv('ANSICON') || 'ON' === getenv('ConEmuANSI'); + } else { + $support = function_exists('posix_isatty') && @posix_isatty(STDOUT); + } + } + + return $support; +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..353ceab --- /dev/null +++ b/composer.json @@ -0,0 +1,113 @@ +{ + "name": "socialhose/backend", + "license": "proprietary", + "description": "social listening and analytics", + "type": "project", + "autoload": { + "psr-4": { + "": [ "src/", "behat/" ] + }, + "files": [ + "src/AppFunctional/import.php" + ], + "classmap": [ + "app/AppKernel.php", + "app/AppCache.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "config": { + "platform": { + "php": "7.2" + } + }, + "require": { + "php": ">=7.2", + "symfony/symfony": "3.4.*", + "doctrine/orm": "^2.5", + "doctrine/doctrine-bundle": "1.6.7", + "doctrine/doctrine-cache-bundle": "^1.2", + "symfony/swiftmailer-bundle": "^2.3", + "symfony/monolog-bundle": "^2.8", + "sensio/distribution-bundle": "^5.0", + "sensio/framework-extra-bundle": "^3.0.2", + "incenteev/composer-parameter-handler": "^2.0", + "friendsofsymfony/user-bundle": "~2.0@dev", + "doctrine/doctrine-migrations-bundle": "^1.2", + "symfony/assetic-bundle": "^2.8", + "guzzlehttp/guzzle": "~6.5", + "elasticsearch/elasticsearch": "~5.3", + "knplabs/knp-paginator-bundle": "~2.5.3", + "fzaninotto/faker": "~1.6", + "lexik/jwt-authentication-bundle": "~2.10.7", + "gesdinet/jwt-refresh-token-bundle": "0.1.8", + "nelmio/cors-bundle": "~1.5", + "nelmio/api-doc-bundle": "~2.13.0", + "seld/jsonlint": "1.5.*", + "php-amqplib/php-amqplib": "^2.6", + "egeloen/ckeditor-bundle": "^5.0", + "php-amqplib/rabbitmq-bundle": "^1.13", + "php-http/guzzle6-adapter": "^1.1", + "ihor/nspl": "^1.2", + "nochso/html-compress-twig": "^2.0", + "paypal/rest-api-sdk-php": "~1.14", + "prewk/option": "^0.0.5", + "slowprog/composer-copy-file": "^0.2.1", + "miracode/stripe-bundle": "^1.0" + }, + "require-dev": { + "sensio/generator-bundle": "^3.0", + "symfony/phpunit-bridge": "^3.0", + "phpunit/phpunit": "^5.6.1", + "behat/behat": "^3", + "behat/symfony2-extension": "^2.1.", + "doctrine/doctrine-fixtures-bundle": "~2.3", + "coduo/php-matcher": "2.1.x-dev" + }, + "scripts": { + "behat-test": [ + "./vendor/bin/behat -s api --format progress --colors", + "./vendor/bin/behat -s command --format progress --colors" + ], + "unit-test": [ + "./vendor/bin/phpunit --colors=always" + ], + "test": [ + "@unit-test", + "@behat-test" + ], + "copy-assets": [ + "SlowProg\\CopyFile\\ScriptHandler::copy" + ], + "scripts": [ + "Incenteev\\ParameterHandler\\ScriptHandler::buildParameters", + "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::buildBootstrap", + "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::clearCache", + "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installAssets", + "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installRequirementsFile", + "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::prepareDeploymentTarget", + "@copy-assets" + ], + "post-install-cmd": [ "@scripts" ], + "post-update-cmd": [ "@scripts" ] + }, + "extra": { + "symfony-app-dir": "app", + "symfony-bin-dir": "bin", + "symfony-var-dir": "var", + "symfony-web-dir": "web", + "symfony-tests-dir": "tests", + "symfony-assets-install": "relative", + "incenteev-parameters": { + "file": "app/config/parameters.yml", + "keep-outdated": true + }, + "copy-file": { + "src/UserBundle/Resources/views/Notification": "web/twig" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..18555d7 --- /dev/null +++ b/composer.lock @@ -0,0 +1,9274 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "8f2f3186ab8489014edc28377a074d0b", + "packages": [ + { + "name": "composer/package-versions-deprecated", + "version": "1.11.99.5", + "source": { + "type": "git", + "url": "https://github.com/composer/package-versions-deprecated.git", + "reference": "b4f54f74ef3453349c24a845d22392cd31e65f1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/package-versions-deprecated/zipball/b4f54f74ef3453349c24a845d22392cd31e65f1d", + "reference": "b4f54f74ef3453349c24a845d22392cd31e65f1d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.1.0 || ^2.0", + "php": "^7 || ^8" + }, + "replace": { + "ocramius/package-versions": "1.11.99" + }, + "require-dev": { + "composer/composer": "^1.9.3 || ^2.0@dev", + "ext-zip": "^1.13", + "phpunit/phpunit": "^6.5 || ^7" + }, + "type": "composer-plugin", + "extra": { + "class": "PackageVersions\\Installer", + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "PackageVersions\\": "src/PackageVersions" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be" + } + ], + "description": "Composer plugin that provides efficient querying for installed package versions (no runtime IO)", + "support": { + "issues": "https://github.com/composer/package-versions-deprecated/issues", + "source": "https://github.com/composer/package-versions-deprecated/tree/1.11.99.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-01-17T14:14:24+00:00" + }, + { + "name": "doctrine/annotations", + "version": "1.13.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/annotations.git", + "reference": "648b0343343565c4a056bfc8392201385e8d89f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/648b0343343565c4a056bfc8392201385e8d89f0", + "reference": "648b0343343565c4a056bfc8392201385e8d89f0", + "shasum": "" + }, + "require": { + "doctrine/lexer": "1.*", + "ext-tokenizer": "*", + "php": "^7.1 || ^8.0", + "psr/cache": "^1 || ^2 || ^3" + }, + "require-dev": { + "doctrine/cache": "^1.11 || ^2.0", + "doctrine/coding-standard": "^6.0 || ^8.1", + "phpstan/phpstan": "^1.4.10 || ^1.8.0", + "phpunit/phpunit": "^7.5 || ^8.0 || ^9.1.5", + "symfony/cache": "^4.4 || ^5.2", + "vimeo/psalm": "^4.10" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Annotations\\": "lib/Doctrine/Common/Annotations" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "Docblock Annotations Parser", + "homepage": "https://www.doctrine-project.org/projects/annotations.html", + "keywords": [ + "annotations", + "docblock", + "parser" + ], + "support": { + "issues": "https://github.com/doctrine/annotations/issues", + "source": "https://github.com/doctrine/annotations/tree/1.13.3" + }, + "time": "2022-07-02T10:48:51+00:00" + }, + { + "name": "doctrine/cache", + "version": "1.13.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/cache.git", + "reference": "56cd022adb5514472cb144c087393c1821911d09" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/cache/zipball/56cd022adb5514472cb144c087393c1821911d09", + "reference": "56cd022adb5514472cb144c087393c1821911d09", + "shasum": "" + }, + "require": { + "php": "~7.1 || ^8.0" + }, + "conflict": { + "doctrine/common": ">2.2,<2.4" + }, + "require-dev": { + "alcaeus/mongo-php-adapter": "^1.1", + "cache/integration-tests": "dev-master", + "doctrine/coding-standard": "^9", + "mongodb/mongodb": "^1.1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "predis/predis": "~1.0", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "symfony/cache": "^4.4 || ^5.4 || ^6", + "symfony/var-exporter": "^4.4 || ^5.4 || ^6" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "Required to use legacy MongoDB driver" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Cache library is a popular cache implementation that supports many different drivers such as redis, memcache, apc, mongodb and others.", + "homepage": "https://www.doctrine-project.org/projects/cache.html", + "keywords": [ + "abstraction", + "apcu", + "cache", + "caching", + "couchdb", + "memcached", + "php", + "redis", + "xcache" + ], + "support": { + "issues": "https://github.com/doctrine/cache/issues", + "source": "https://github.com/doctrine/cache/tree/1.13.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcache", + "type": "tidelift" + } + ], + "time": "2022-05-20T20:06:54+00:00" + }, + { + "name": "doctrine/collections", + "version": "1.6.8", + "source": { + "type": "git", + "url": "https://github.com/doctrine/collections.git", + "reference": "1958a744696c6bb3bb0d28db2611dc11610e78af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/collections/zipball/1958a744696c6bb3bb0d28db2611dc11610e78af", + "reference": "1958a744696c6bb3bb0d28db2611dc11610e78af", + "shasum": "" + }, + "require": { + "php": "^7.1.3 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9.0", + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.1.5", + "vimeo/psalm": "^4.2.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Collections\\": "lib/Doctrine/Common/Collections" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.", + "homepage": "https://www.doctrine-project.org/projects/collections.html", + "keywords": [ + "array", + "collections", + "iterators", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/collections/issues", + "source": "https://github.com/doctrine/collections/tree/1.6.8" + }, + "time": "2021-08-10T18:51:53+00:00" + }, + { + "name": "doctrine/common", + "version": "2.13.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/common.git", + "reference": "f3812c026e557892c34ef37f6ab808a6b567da7f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/common/zipball/f3812c026e557892c34ef37f6ab808a6b567da7f", + "reference": "f3812c026e557892c34ef37f6ab808a6b567da7f", + "shasum": "" + }, + "require": { + "doctrine/annotations": "^1.0", + "doctrine/cache": "^1.0", + "doctrine/collections": "^1.0", + "doctrine/event-manager": "^1.0", + "doctrine/inflector": "^1.0", + "doctrine/lexer": "^1.0", + "doctrine/persistence": "^1.3.3", + "doctrine/reflection": "^1.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^1.0", + "phpstan/phpstan": "^0.11", + "phpstan/phpstan-phpunit": "^0.11", + "phpunit/phpunit": "^7.0", + "squizlabs/php_codesniffer": "^3.0", + "symfony/phpunit-bridge": "^4.0.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "lib/Doctrine/Common" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "PHP Doctrine Common project is a library that provides additional functionality that other Doctrine projects depend on such as better reflection support, persistence interfaces, proxies, event system and much more.", + "homepage": "https://www.doctrine-project.org/projects/common.html", + "keywords": [ + "common", + "doctrine", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/common/issues", + "source": "https://github.com/doctrine/common/tree/2.13.x" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcommon", + "type": "tidelift" + } + ], + "time": "2020-06-05T16:46:05+00:00" + }, + { + "name": "doctrine/dbal", + "version": "2.13.9", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "c480849ca3ad6706a39c970cdfe6888fa8a058b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/c480849ca3ad6706a39c970cdfe6888fa8a058b8", + "reference": "c480849ca3ad6706a39c970cdfe6888fa8a058b8", + "shasum": "" + }, + "require": { + "doctrine/cache": "^1.0|^2.0", + "doctrine/deprecations": "^0.5.3|^1", + "doctrine/event-manager": "^1.0", + "ext-pdo": "*", + "php": "^7.1 || ^8" + }, + "require-dev": { + "doctrine/coding-standard": "9.0.0", + "jetbrains/phpstorm-stubs": "2021.1", + "phpstan/phpstan": "1.4.6", + "phpunit/phpunit": "^7.5.20|^8.5|9.5.16", + "psalm/plugin-phpunit": "0.16.1", + "squizlabs/php_codesniffer": "3.6.2", + "symfony/cache": "^4.4", + "symfony/console": "^2.0.5|^3.0|^4.0|^5.0", + "vimeo/psalm": "4.22.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "bin": [ + "bin/doctrine-dbal" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "lib/Doctrine/DBAL" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlanywhere", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/2.13.9" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2022-05-02T20:28:55+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "0e2a4f1f8cdfc7a92ec3b01c9334898c806b30de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/0e2a4f1f8cdfc7a92ec3b01c9334898c806b30de", + "reference": "0e2a4f1f8cdfc7a92ec3b01c9334898c806b30de", + "shasum": "" + }, + "require": { + "php": "^7.1|^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9", + "phpunit/phpunit": "^7.5|^8.5|^9.5", + "psr/log": "^1|^2|^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/v1.0.0" + }, + "time": "2022-05-02T15:47:09+00:00" + }, + { + "name": "doctrine/doctrine-bundle", + "version": "1.6.7", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineBundle.git", + "reference": "a01d99bc6c9a6c8a8ace0012690099dd957ce9b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/a01d99bc6c9a6c8a8ace0012690099dd957ce9b9", + "reference": "a01d99bc6c9a6c8a8ace0012690099dd957ce9b9", + "shasum": "" + }, + "require": { + "doctrine/dbal": "~2.3", + "doctrine/doctrine-cache-bundle": "~1.0", + "jdorn/sql-formatter": "~1.1", + "php": ">=5.5.9", + "symfony/console": "~2.7|~3.0", + "symfony/dependency-injection": "~2.7|~3.0", + "symfony/doctrine-bridge": "~2.7|~3.0", + "symfony/framework-bundle": "~2.7|~3.0" + }, + "require-dev": { + "doctrine/orm": "~2.3", + "phpunit/phpunit": "~4", + "satooshi/php-coveralls": "^1.0", + "symfony/phpunit-bridge": "~2.7|~3.0", + "symfony/property-info": "~2.8|~3.0", + "symfony/validator": "~2.7|~3.0", + "symfony/yaml": "~2.7|~3.0", + "twig/twig": "~1.10|~2.0" + }, + "suggest": { + "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", + "symfony/web-profiler-bundle": "To use the data collector." + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "1.6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\DoctrineBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Doctrine Project", + "homepage": "http://www.doctrine-project.org/" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + } + ], + "description": "Symfony DoctrineBundle", + "homepage": "http://www.doctrine-project.org", + "keywords": [ + "database", + "dbal", + "orm", + "persistence" + ], + "time": "2017-01-16T12:01:26+00:00" + }, + { + "name": "doctrine/doctrine-cache-bundle", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineCacheBundle.git", + "reference": "6bee2f9b339847e8a984427353670bad4e7bdccb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineCacheBundle/zipball/6bee2f9b339847e8a984427353670bad4e7bdccb", + "reference": "6bee2f9b339847e8a984427353670bad4e7bdccb", + "shasum": "" + }, + "require": { + "doctrine/cache": "^1.4.2", + "doctrine/inflector": "^1.0", + "php": "^7.1", + "symfony/doctrine-bridge": "^3.4|^4.0" + }, + "require-dev": { + "instaclick/coding-standard": "~1.1", + "instaclick/object-calisthenics-sniffs": "dev-master", + "instaclick/symfony2-coding-standard": "dev-remaster", + "phpunit/phpunit": "^7.0", + "predis/predis": "~0.8", + "satooshi/php-coveralls": "^1.0", + "squizlabs/php_codesniffer": "~1.5", + "symfony/console": "^3.4|^4.0", + "symfony/finder": "^3.4|^4.0", + "symfony/framework-bundle": "^3.4|^4.0", + "symfony/phpunit-bridge": "^3.4|^4.0", + "symfony/security-acl": "^2.8", + "symfony/validator": "^3.4|^4.0", + "symfony/yaml": "^3.4|^4.0" + }, + "suggest": { + "symfony/security-acl": "For using this bundle to cache ACLs" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "1.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\DoctrineCacheBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Fabio B. Silva", + "email": "fabio.bat.silva@gmail.com" + }, + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@hotmail.com" + }, + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + }, + { + "name": "Doctrine Project", + "homepage": "http://www.doctrine-project.org/" + } + ], + "description": "Symfony Bundle for Doctrine Cache", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "cache", + "caching" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineCacheBundle/issues", + "source": "https://github.com/doctrine/DoctrineCacheBundle/tree/1.4.0" + }, + "abandoned": true, + "time": "2019-11-29T11:22:01+00:00" + }, + { + "name": "doctrine/doctrine-migrations-bundle", + "version": "v1.3.2", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineMigrationsBundle.git", + "reference": "49fa399181db4bf4f9f725126bd1cb65c4398dce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/49fa399181db4bf4f9f725126bd1cb65c4398dce", + "reference": "49fa399181db4bf4f9f725126bd1cb65c4398dce", + "shasum": "" + }, + "require": { + "doctrine/doctrine-bundle": "~1.0", + "doctrine/migrations": "^1.1", + "php": ">=5.4.0", + "symfony/framework-bundle": "~2.7|~3.3|~4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^7.4" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\MigrationsBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + }, + { + "name": "Doctrine Project", + "homepage": "http://www.doctrine-project.org" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + } + ], + "description": "Symfony DoctrineMigrationsBundle", + "homepage": "http://www.doctrine-project.org", + "keywords": [ + "dbal", + "migrations", + "schema" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues", + "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/1.3" + }, + "time": "2018-12-03T11:55:33+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "41370af6a30faa9dc0368c4a6814d596e81aba7f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/41370af6a30faa9dc0368c4a6814d596e81aba7f", + "reference": "41370af6a30faa9dc0368c4a6814d596e81aba7f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/common": "<2.9@dev" + }, + "require-dev": { + "doctrine/coding-standard": "^6.0", + "phpunit/phpunit": "^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "lib/Doctrine/Common" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/1.1.x" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2020-05-29T18:28:51+00:00" + }, + { + "name": "doctrine/inflector", + "version": "1.4.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "4bd5c1cdfcd00e9e2d8c484f79150f67e5d355d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/4bd5c1cdfcd00e9e2d8c484f79150f67e5d355d9", + "reference": "4bd5c1cdfcd00e9e2d8c484f79150f67e5d355d9", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^8.0", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-phpunit": "^0.12", + "phpstan/phpstan-strict-rules": "^0.12", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "lib/Doctrine/Inflector", + "Doctrine\\Common\\Inflector\\": "lib/Doctrine/Common/Inflector" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/1.4.4" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2021-04-16T17:34:40+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/10dcfce151b967d20fde1b34ae6640712c3891bc", + "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^0.16 || ^1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.22" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/1.4.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-03-03T08:28:38+00:00" + }, + { + "name": "doctrine/lexer", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "1febd6c3ef84253d7c815bed85fc622ad207a9f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/1febd6c3ef84253d7c815bed85fc622ad207a9f8", + "reference": "1febd6c3ef84253d7c815bed85fc622ad207a9f8", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "^4.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "lib/Doctrine/Common/Lexer" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/1.0.2" + }, + "time": "2019-06-08T11:03:04+00:00" + }, + { + "name": "doctrine/migrations", + "version": "v1.8.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/migrations.git", + "reference": "215438c0eef3e5f9b7da7d09c6b90756071b43e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/215438c0eef3e5f9b7da7d09c6b90756071b43e6", + "reference": "215438c0eef3e5f9b7da7d09c6b90756071b43e6", + "shasum": "" + }, + "require": { + "doctrine/dbal": "~2.6", + "ocramius/proxy-manager": "^1.0|^2.0", + "php": "^7.1", + "symfony/console": "~3.3|^4.0" + }, + "require-dev": { + "doctrine/coding-standard": "^1.0", + "doctrine/orm": "~2.5", + "jdorn/sql-formatter": "~1.1", + "mikey179/vfsstream": "^1.6", + "phpunit/phpunit": "~7.0", + "squizlabs/php_codesniffer": "^3.0", + "symfony/yaml": "~3.3|^4.0" + }, + "suggest": { + "jdorn/sql-formatter": "Allows to generate formatted SQL with the diff command.", + "symfony/yaml": "Allows the use of yaml for migration configuration files." + }, + "bin": [ + "bin/doctrine-migrations" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "v1.8.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Migrations\\": "lib/Doctrine/Migrations", + "Doctrine\\DBAL\\Migrations\\": "lib/Doctrine/DBAL/Migrations" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Michael Simonson", + "email": "contact@mikesimonson.com" + } + ], + "description": "Database Schema migrations using Doctrine DBAL", + "homepage": "https://www.doctrine-project.org/projects/migrations.html", + "keywords": [ + "database", + "migrations" + ], + "support": { + "issues": "https://github.com/doctrine/migrations/issues", + "source": "https://github.com/doctrine/migrations/tree/1.8" + }, + "time": "2018-06-06T21:00:30+00:00" + }, + { + "name": "doctrine/orm", + "version": "2.7.5", + "source": { + "type": "git", + "url": "https://github.com/doctrine/orm.git", + "reference": "01187c9260cd085529ddd1273665217cae659640" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/orm/zipball/01187c9260cd085529ddd1273665217cae659640", + "reference": "01187c9260cd085529ddd1273665217cae659640", + "shasum": "" + }, + "require": { + "composer/package-versions-deprecated": "^1.8", + "doctrine/annotations": "^1.11.1", + "doctrine/cache": "^1.9.1", + "doctrine/collections": "^1.5", + "doctrine/common": "^2.11 || ^3.0", + "doctrine/dbal": "^2.9.3", + "doctrine/event-manager": "^1.1", + "doctrine/inflector": "^1.0", + "doctrine/instantiator": "^1.3", + "doctrine/lexer": "^1.0", + "doctrine/persistence": "^1.3.3 || ^2.0", + "ext-pdo": "*", + "php": "^7.1", + "symfony/console": "^3.0|^4.0|^5.0" + }, + "require-dev": { + "doctrine/coding-standard": "^6.0", + "phpstan/phpstan": "^0.12.18", + "phpunit/phpunit": "^8.0", + "symfony/yaml": "^3.4|^4.0|^5.0", + "vimeo/psalm": "^3.11" + }, + "suggest": { + "symfony/yaml": "If you want to use YAML Metadata Mapping Driver" + }, + "bin": [ + "bin/doctrine" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\ORM\\": "lib/Doctrine/ORM" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "Object-Relational-Mapper for PHP", + "homepage": "https://www.doctrine-project.org/projects/orm.html", + "keywords": [ + "database", + "orm" + ], + "support": { + "issues": "https://github.com/doctrine/orm/issues", + "source": "https://github.com/doctrine/orm/tree/2.7.5" + }, + "time": "2020-12-03T08:52:14+00:00" + }, + { + "name": "doctrine/persistence", + "version": "1.3.8", + "source": { + "type": "git", + "url": "https://github.com/doctrine/persistence.git", + "reference": "7a6eac9fb6f61bba91328f15aa7547f4806ca288" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/7a6eac9fb6f61bba91328f15aa7547f4806ca288", + "reference": "7a6eac9fb6f61bba91328f15aa7547f4806ca288", + "shasum": "" + }, + "require": { + "doctrine/annotations": "^1.0", + "doctrine/cache": "^1.0", + "doctrine/collections": "^1.0", + "doctrine/event-manager": "^1.0", + "doctrine/reflection": "^1.2", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/common": "<2.10@dev" + }, + "require-dev": { + "doctrine/coding-standard": "^6.0", + "phpstan/phpstan": "^0.11", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "vimeo/psalm": "^3.11" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "lib/Doctrine/Common", + "Doctrine\\Persistence\\": "lib/Doctrine/Persistence" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Persistence project is a set of shared interfaces and functionality that the different Doctrine object mappers share.", + "homepage": "https://doctrine-project.org/projects/persistence.html", + "keywords": [ + "mapper", + "object", + "odm", + "orm", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/persistence/issues", + "source": "https://github.com/doctrine/persistence/tree/1.3.x" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fpersistence", + "type": "tidelift" + } + ], + "time": "2020-06-20T12:56:16+00:00" + }, + { + "name": "doctrine/reflection", + "version": "1.2.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/reflection.git", + "reference": "1034e5e71f89978b80f9c1570e7226f6c3b9b6fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/reflection/zipball/1034e5e71f89978b80f9c1570e7226f6c3b9b6fb", + "reference": "1034e5e71f89978b80f9c1570e7226f6c3b9b6fb", + "shasum": "" + }, + "require": { + "doctrine/annotations": "^1.0", + "ext-tokenizer": "*", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^9", + "doctrine/common": "^3.3", + "phpstan/phpstan": "^1.4.10", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "lib/Doctrine/Common" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Reflection project is a simple library used by the various Doctrine projects which adds some additional functionality on top of the reflection functionality that comes with PHP. It allows you to get the reflection information about classes, methods and properties statically.", + "homepage": "https://www.doctrine-project.org/projects/reflection.html", + "keywords": [ + "reflection", + "static" + ], + "support": { + "issues": "https://github.com/doctrine/reflection/issues", + "source": "https://github.com/doctrine/reflection/tree/1.2.3" + }, + "abandoned": "roave/better-reflection", + "time": "2022-05-31T18:46:25+00:00" + }, + { + "name": "egeloen/ckeditor-bundle", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/egeloen/IvoryCKEditorBundle.git", + "reference": "433b4bf1fe0731d3ad88ed27f359b20cc2e30d28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egeloen/IvoryCKEditorBundle/zipball/433b4bf1fe0731d3ad88ed27f359b20cc2e30d28", + "reference": "433b4bf1fe0731d3ad88ed27f359b20cc2e30d28", + "shasum": "" + }, + "require": { + "egeloen/json-builder": "^2.0|^3.0", + "php": "^5.6|^7.0", + "symfony/dependency-injection": "^2.7|^3.0", + "symfony/form": "^2.7|^3.0", + "symfony/framework-bundle": "^2.7|^3.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.0", + "phpunit/phpunit": "^5.0", + "symfony/asset": "^2.7|^3.0", + "symfony/phpunit-bridge": "^2.7|^3.0", + "symfony/templating": "^2.7|^3.0", + "symfony/twig-bridge": "^2.7|^3.0", + "symfony/yaml": "^2.7|^3.0", + "twig/twig": "^1.12" + }, + "suggest": { + "egeloen/form-extra-bundle": "Allows to load CKEditor asynchronously", + "symfony/asset": "Allows to rewrite/version assets", + "symfony/templating": "Allows to use PHP templates", + "symfony/twig-bridge": "Allows to use Twig templates", + "twig/twig": "Allows to use Twig templates" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Ivory\\CKEditorBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eric GELOEN", + "email": "geloen.eric@gmail.com" + } + ], + "description": "Provides a CKEditor integration for your Symfony2 Project.", + "keywords": [ + "CKEditor" + ], + "time": "2017-06-05T12:35:35+00:00" + }, + { + "name": "egeloen/json-builder", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/egeloen/ivory-json-builder.git", + "reference": "3e70bc681891d8aca88dd72164caea659739f284" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egeloen/ivory-json-builder/zipball/3e70bc681891d8aca88dd72164caea659739f284", + "reference": "3e70bc681891d8aca88dd72164caea659739f284", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^5.6|^7.0", + "symfony/property-access": "^2.7|^3.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.0", + "phpunit/phpunit": "^5.0", + "symfony/phpunit-bridge": "^2.7|^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Ivory\\JsonBuilder\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eric GELOEN", + "email": "geloen.eric@gmail.com" + } + ], + "description": "JSON builder with escaping control for PHP 5.6+", + "keywords": [ + "Escape", + "builder", + "json" + ], + "time": "2017-02-27T20:18:54+00:00" + }, + { + "name": "elasticsearch/elasticsearch", + "version": "v5.5.0", + "source": { + "type": "git", + "url": "https://github.com/elastic/elasticsearch-php.git", + "reference": "48b8a90e2b97b4d69ce42851c1b9e59f8054661a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/elastic/elasticsearch-php/zipball/48b8a90e2b97b4d69ce42851c1b9e59f8054661a", + "reference": "48b8a90e2b97b4d69ce42851c1b9e59f8054661a", + "shasum": "" + }, + "require": { + "guzzlehttp/ringphp": "~1.0", + "php": "^5.6|^7.0", + "psr/log": "~1.0" + }, + "require-dev": { + "cpliakas/git-wrapper": "~1.0", + "doctrine/inflector": "^1.1", + "mockery/mockery": "0.9.4", + "phpunit/phpunit": "^4.7|^5.4", + "sami/sami": "~3.2", + "symfony/finder": "^2.8", + "symfony/yaml": "^2.8" + }, + "suggest": { + "ext-curl": "*", + "monolog/monolog": "Allows for client-level logging and tracing" + }, + "type": "library", + "autoload": { + "psr-4": { + "Elasticsearch\\": "src/Elasticsearch/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Zachary Tong" + } + ], + "description": "PHP Client for Elasticsearch", + "keywords": [ + "client", + "elasticsearch", + "search" + ], + "support": { + "issues": "https://github.com/elastic/elasticsearch-php/issues", + "source": "https://github.com/elastic/elasticsearch-php/tree/5.0" + }, + "time": "2019-07-18T15:11:30+00:00" + }, + { + "name": "fig/link-util", + "version": "1.1.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/link-util.git", + "reference": "5d7b8d04ed3393b4b59968ca1e906fb7186d81e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/link-util/zipball/5d7b8d04ed3393b4b59968ca1e906fb7186d81e8", + "reference": "5d7b8d04ed3393b4b59968ca1e906fb7186d81e8", + "shasum": "" + }, + "require": { + "php": ">=5.5.0", + "psr/link": "~1.0@dev" + }, + "provide": { + "psr/link-implementation": "1.0" + }, + "require-dev": { + "phpunit/phpunit": "^5.1", + "squizlabs/php_codesniffer": "^2.3.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Fig\\Link\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common utility implementations for HTTP links", + "keywords": [ + "http", + "http-link", + "link", + "psr", + "psr-13", + "rest" + ], + "support": { + "issues": "https://github.com/php-fig/link-util/issues", + "source": "https://github.com/php-fig/link-util/tree/1.1.2" + }, + "time": "2021-02-03T23:36:04+00:00" + }, + { + "name": "friendsofsymfony/user-bundle", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfSymfony/FOSUserBundle.git", + "reference": "1049935edd24ec305cc6cfde1875372fa9600446" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfSymfony/FOSUserBundle/zipball/1049935edd24ec305cc6cfde1875372fa9600446", + "reference": "1049935edd24ec305cc6cfde1875372fa9600446", + "shasum": "" + }, + "require": { + "paragonie/random_compat": "^1 || ^2", + "php": "^5.5.9 || ^7.0", + "symfony/form": "^2.8 || ^3.0 || ^4.0", + "symfony/framework-bundle": "^2.8 || ^3.0 || ^4.0", + "symfony/security-bundle": "^2.8 || ^3.0 || ^4.0", + "symfony/templating": "^2.8 || ^3.0 || ^4.0", + "symfony/twig-bundle": "^2.8 || ^3.0 || ^4.0", + "symfony/validator": "^2.8 || ^3.0 || ^4.0", + "twig/twig": "^1.28 || ^2.0" + }, + "conflict": { + "doctrine/doctrine-bundle": "<1.3", + "symfony/doctrine-bridge": "<2.7" + }, + "require-dev": { + "doctrine/doctrine-bundle": "^1.3", + "friendsofphp/php-cs-fixer": "^2.2", + "phpunit/phpunit": "^4.8.35|^5.7.11|^6.5", + "swiftmailer/swiftmailer": "^4.3 || ^5.0 || ^6.0", + "symfony/console": "^2.8 || ^3.0 || ^4.0", + "symfony/phpunit-bridge": "^2.8 || ^3.0 || ^4.0", + "symfony/yaml": "^2.8 || ^3.0 || ^4.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "FOS\\UserBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christophe Coevoet", + "email": "stof@notk.org" + }, + { + "name": "FriendsOfSymfony Community", + "homepage": "https://github.com/friendsofsymfony/FOSUserBundle/contributors" + }, + { + "name": "Thibault Duplessis" + } + ], + "description": "Symfony FOSUserBundle", + "homepage": "http://friendsofsymfony.github.com", + "keywords": [ + "User management" + ], + "support": { + "docs": "https://symfony.com/doc/master/bundles/FOSUserBundle/index.html", + "issues": "https://github.com/FriendsOfSymfony/FOSUserBundle/issues", + "source": "https://github.com/FriendsOfSymfony/FOSUserBundle/tree/v2.1.2" + }, + "time": "2018-03-08T08:59:27+00:00" + }, + { + "name": "fzaninotto/faker", + "version": "v1.9.2", + "source": { + "type": "git", + "url": "https://github.com/fzaninotto/Faker.git", + "reference": "848d8125239d7dbf8ab25cb7f054f1a630e68c2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fzaninotto/Faker/zipball/848d8125239d7dbf8ab25cb7f054f1a630e68c2e", + "reference": "848d8125239d7dbf8ab25cb7f054f1a630e68c2e", + "shasum": "" + }, + "require": { + "php": "^5.3.3 || ^7.0" + }, + "require-dev": { + "ext-intl": "*", + "phpunit/phpunit": "^4.8.35 || ^5.7", + "squizlabs/php_codesniffer": "^2.9.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/fzaninotto/Faker/issues", + "source": "https://github.com/fzaninotto/Faker/tree/v1.9.2" + }, + "abandoned": true, + "time": "2020-12-11T09:56:16+00:00" + }, + { + "name": "gesdinet/jwt-refresh-token-bundle", + "version": "v0.1.8", + "source": { + "type": "git", + "url": "https://github.com/markitosgv/JWTRefreshTokenBundle.git", + "reference": "a7d72c5e65ca016546d1ce4a1f1f77cc3051f33b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/markitosgv/JWTRefreshTokenBundle/zipball/a7d72c5e65ca016546d1ce4a1f1f77cc3051f33b", + "reference": "a7d72c5e65ca016546d1ce4a1f1f77cc3051f33b", + "shasum": "" + }, + "require": { + "doctrine/doctrine-bundle": "~1.4", + "doctrine/orm": "^2.4.8", + "lexik/jwt-authentication-bundle": "^1.1|^2.0@dev", + "php": ">=5.3.3", + "symfony/symfony": "~2.3|~3.0" + }, + "require-dev": { + "henrikbjorn/phpspec-code-coverage": "~1.0", + "phpspec/phpspec": "~2.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Gesdinet\\JWTRefreshTokenBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marcos Gómez Vilches", + "email": "marcos@gesdinet.com" + } + ], + "description": "Implements a refresh token system over Json Web Tokens in Symfony", + "keywords": [ + "jwt refresh token bundle symfony json web" + ], + "time": "2016-07-24T08:16:07+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "6.5.8", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "a52f0440530b54fa079ce76e8c5d196a42cad981" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/a52f0440530b54fa079ce76e8c5d196a42cad981", + "reference": "a52f0440530b54fa079ce76e8c5d196a42cad981", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.0", + "guzzlehttp/psr7": "^1.9", + "php": ">=5.5", + "symfony/polyfill-intl-idn": "^1.17" + }, + "require-dev": { + "ext-curl": "*", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0", + "psr/log": "^1.1" + }, + "suggest": { + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.5-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "homepage": "http://guzzlephp.org/", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/6.5.8" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2022-06-20T22:16:07+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "1.5.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "fe752aedc9fd8fcca3fe7ad05d419d32998a06da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/fe752aedc9fd8fcca3fe7ad05d419d32998a06da", + "reference": "fe752aedc9fd8fcca3fe7ad05d419d32998a06da", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "symfony/phpunit-bridge": "^4.4 || ^5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.5-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/1.5.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2021-10-22T20:56:57+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "1.9.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "e98e3e6d4f86621a9b75f623996e6bbdeb4b9318" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/e98e3e6d4f86621a9b75f623996e6bbdeb4b9318", + "reference": "e98e3e6d4f86621a9b75f623996e6bbdeb4b9318", + "shasum": "" + }, + "require": { + "php": ">=5.4.0", + "psr/http-message": "~1.0", + "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.14 || ^7.5.20 || ^8.5.8 || ^9.3.10" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/1.9.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2022-06-20T21:43:03+00:00" + }, + { + "name": "guzzlehttp/ringphp", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/RingPHP.git", + "reference": "5e2a174052995663dd68e6b5ad838afd47dd615b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/RingPHP/zipball/5e2a174052995663dd68e6b5ad838afd47dd615b", + "reference": "5e2a174052995663dd68e6b5ad838afd47dd615b", + "shasum": "" + }, + "require": { + "guzzlehttp/streams": "~3.0", + "php": ">=5.4.0", + "react/promise": "~2.0" + }, + "require-dev": { + "ext-curl": "*", + "phpunit/phpunit": "~4.0" + }, + "suggest": { + "ext-curl": "Guzzle will use specific adapters if cURL is present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Ring\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Provides a simple API and specification that abstracts away the details of HTTP into a single PHP function.", + "support": { + "issues": "https://github.com/guzzle/RingPHP/issues", + "source": "https://github.com/guzzle/RingPHP/tree/1.1.1" + }, + "abandoned": true, + "time": "2018-07-31T13:22:33+00:00" + }, + { + "name": "guzzlehttp/streams", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/streams.git", + "reference": "47aaa48e27dae43d39fc1cea0ccf0d84ac1a2ba5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/streams/zipball/47aaa48e27dae43d39fc1cea0ccf0d84ac1a2ba5", + "reference": "47aaa48e27dae43d39fc1cea0ccf0d84ac1a2ba5", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Provides a simple abstraction over streams of data", + "homepage": "http://guzzlephp.org/", + "keywords": [ + "Guzzle", + "stream" + ], + "abandoned": true, + "time": "2014-10-12T19:18:40+00:00" + }, + { + "name": "ihor/nspl", + "version": "1.3", + "source": { + "type": "git", + "url": "https://github.com/ihor/NSPL.git", + "reference": "906875858c2783ce7c7c73a4f5a102c544b6cced" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ihor/NSPL/zipball/906875858c2783ce7c7c73a4f5a102c544b6cced", + "reference": "906875858c2783ce7c7c73a4f5a102c544b6cced", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "files": [ + "autoload.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ihor Burlachenko", + "email": "ihor.burlachenko@gmail.com" + } + ], + "description": "Non-standard PHP library (NSPL) - functional primitives toolbox and more", + "keywords": [ + "curried", + "curry", + "defaultarray", + "flatten", + "functional", + "generator", + "higher-order", + "itemGetter", + "iterator", + "list", + "memoize", + "partial", + "partial application", + "programming", + "propertyGetter", + "python", + "random", + "spl", + "standard php library", + "weighted", + "weighted random", + "zip" + ], + "support": { + "issues": "https://github.com/ihor/NSPL/issues", + "source": "https://github.com/ihor/NSPL/tree/master" + }, + "time": "2019-03-21T20:38:30+00:00" + }, + { + "name": "incenteev/composer-parameter-handler", + "version": "v2.1.5", + "source": { + "type": "git", + "url": "https://github.com/Incenteev/ParameterHandler.git", + "reference": "e1dd118763503f7fd766f907013e1d76d525fcc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Incenteev/ParameterHandler/zipball/e1dd118763503f7fd766f907013e1d76d525fcc4", + "reference": "e1dd118763503f7fd766f907013e1d76d525fcc4", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "symfony/yaml": "^2.3 || ^3.0 || ^4.0 || ^5.0 || ^6.0" + }, + "require-dev": { + "composer/composer": "^1.0@dev", + "symfony/filesystem": "^2.3 || ^3 || ^4 || ^5 || ^6.0", + "symfony/phpunit-bridge": "^3.4.47 || ^4.4.41 || ^5.4.8 || ^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Incenteev\\ParameterHandler\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christophe Coevoet", + "email": "stof@notk.org" + } + ], + "description": "Composer script handling your ignored parameter file", + "homepage": "https://github.com/Incenteev/ParameterHandler", + "keywords": [ + "parameters management" + ], + "support": { + "issues": "https://github.com/Incenteev/ParameterHandler/issues", + "source": "https://github.com/Incenteev/ParameterHandler/tree/v2.1.5" + }, + "time": "2022-05-25T10:57:22+00:00" + }, + { + "name": "jalle19/php-yui-compressor", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/Jalle19/php-yui-compressor.git", + "reference": "eb88c4f7fd8d7c8bd15a1970611d67cca11f71a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jalle19/php-yui-compressor/zipball/eb88c4f7fd8d7c8bd15a1970611d67cca11f71a0", + "reference": "eb88c4f7fd8d7c8bd15a1970611d67cca11f71a0", + "shasum": "" + }, + "require": { + "nervo/yuicompressor": "2.4.*", + "php": ">=5.3.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "YUI": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "description": "A modern PHP wrapper for the YUI compressor", + "homepage": "https://github.com/Jalle19/php-yui-compressor", + "keywords": [ + "JS", + "css", + "minify", + "yui" + ], + "support": { + "issues": "https://github.com/Jalle19/php-yui-compressor/issues", + "source": "https://github.com/Jalle19/php-yui-compressor/tree/1.0.1" + }, + "time": "2014-06-03T12:02:11+00:00" + }, + { + "name": "jdorn/sql-formatter", + "version": "v1.2.17", + "source": { + "type": "git", + "url": "https://github.com/jdorn/sql-formatter.git", + "reference": "64990d96e0959dff8e059dfcdc1af130728d92bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jdorn/sql-formatter/zipball/64990d96e0959dff8e059dfcdc1af130728d92bc", + "reference": "64990d96e0959dff8e059dfcdc1af130728d92bc", + "shasum": "" + }, + "require": { + "php": ">=5.2.4" + }, + "require-dev": { + "phpunit/phpunit": "3.7.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "autoload": { + "classmap": [ + "lib" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Dorn", + "email": "jeremy@jeremydorn.com", + "homepage": "http://jeremydorn.com/" + } + ], + "description": "a PHP SQL highlighting library", + "homepage": "https://github.com/jdorn/sql-formatter/", + "keywords": [ + "highlight", + "sql" + ], + "time": "2014-01-12T16:20:24+00:00" + }, + { + "name": "knplabs/knp-components", + "version": "v1.3.10", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/knp-components.git", + "reference": "fc1755ba2b77f83a3d3c99e21f3026ba2a1429be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/knp-components/zipball/fc1755ba2b77f83a3d3c99e21f3026ba2a1429be", + "reference": "fc1755ba2b77f83a3d3c99e21f3026ba2a1429be", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "doctrine/mongodb-odm": "~1.0@beta", + "doctrine/orm": "~2.4", + "doctrine/phpcr-odm": "~1.2", + "jackalope/jackalope-doctrine-dbal": "~1.2", + "phpunit/phpunit": "~4.2", + "ruflin/elastica": "~1.0", + "symfony/event-dispatcher": "~2.5", + "symfony/property-access": ">=2.3" + }, + "suggest": { + "symfony/property-access": "To allow sorting arrays" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "autoload": { + "psr-0": { + "Knp\\Component": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KnpLabs Team", + "homepage": "http://knplabs.com" + }, + { + "name": "Symfony Community", + "homepage": "http://github.com/KnpLabs/knp-components/contributors" + } + ], + "description": "Knplabs component library", + "homepage": "http://github.com/KnpLabs/knp-components", + "keywords": [ + "components", + "knp", + "knplabs", + "pager", + "paginator" + ], + "support": { + "issues": "https://github.com/KnpLabs/knp-components/issues", + "source": "https://github.com/KnpLabs/knp-components/tree/master" + }, + "time": "2018-09-11T07:54:48+00:00" + }, + { + "name": "knplabs/knp-paginator-bundle", + "version": "2.5.4", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/KnpPaginatorBundle.git", + "reference": "1618a19a871ba1245dc68e462b35e7df07dcfcc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/KnpPaginatorBundle/zipball/1618a19a871ba1245dc68e462b35e7df07dcfcc3", + "reference": "1618a19a871ba1245dc68e462b35e7df07dcfcc3", + "shasum": "" + }, + "require": { + "knplabs/knp-components": "~1.2", + "php": ">=5.3.3", + "symfony/framework-bundle": "~2.3|~3.0", + "twig/twig": "~1.12|~2" + }, + "require-dev": { + "phpunit/phpunit": "~4.8", + "symfony/expression-language": "~2.4|~3.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Knp\\Bundle\\PaginatorBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KnpLabs Team", + "homepage": "http://knplabs.com" + }, + { + "name": "Symfony2 Community", + "homepage": "http://github.com/KnpLabs/KnpPaginatorBundle/contributors" + } + ], + "description": "Paginator bundle for Symfony2 to automate pagination and simplify sorting and other features", + "homepage": "http://github.com/KnpLabs/KnpPaginatorBundle", + "keywords": [ + "Symfony2", + "bundle", + "knp", + "knplabs", + "pager", + "pagination", + "paginator" + ], + "time": "2017-03-21T09:45:46+00:00" + }, + { + "name": "kriswallsmith/assetic", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/kriswallsmith/assetic.git", + "reference": "e911c437dbdf006a8f62c2f59b15b2d69a5e0aa1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kriswallsmith/assetic/zipball/e911c437dbdf006a8f62c2f59b15b2d69a5e0aa1", + "reference": "e911c437dbdf006a8f62c2f59b15b2d69a5e0aa1", + "shasum": "" + }, + "require": { + "php": ">=5.3.1", + "symfony/process": "~2.1|~3.0" + }, + "conflict": { + "twig/twig": "<1.27" + }, + "require-dev": { + "leafo/lessphp": "^0.3.7", + "leafo/scssphp": "~0.1", + "meenie/javascript-packer": "^1.1", + "mrclay/minify": "<2.3", + "natxet/cssmin": "3.0.4", + "patchwork/jsqueeze": "~1.0|~2.0", + "phpunit/phpunit": "~4.8 || ^5.6", + "psr/log": "~1.0", + "ptachoire/cssembed": "~1.0", + "symfony/phpunit-bridge": "~2.7|~3.0", + "twig/twig": "~1.23|~2.0", + "yfix/packager": "dev-master" + }, + "suggest": { + "leafo/lessphp": "Assetic provides the integration with the lessphp LESS compiler", + "leafo/scssphp": "Assetic provides the integration with the scssphp SCSS compiler", + "leafo/scssphp-compass": "Assetic provides the integration with the SCSS compass plugin", + "patchwork/jsqueeze": "Assetic provides the integration with the JSqueeze JavaScript compressor", + "ptachoire/cssembed": "Assetic provides the integration with phpcssembed to embed data uris", + "twig/twig": "Assetic provides the integration with the Twig templating engine" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "psr-0": { + "Assetic": "src/" + }, + "files": [ + "src/functions.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kris Wallsmith", + "email": "kris.wallsmith@gmail.com", + "homepage": "http://kriswallsmith.net/" + } + ], + "description": "Asset Management for PHP", + "homepage": "https://github.com/kriswallsmith/assetic", + "keywords": [ + "assets", + "compression", + "minification" + ], + "time": "2016-11-11T18:43:20+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "3.4.6", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "3ef8657a78278dfeae7707d51747251db4176240" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/3ef8657a78278dfeae7707d51747251db4176240", + "reference": "3ef8657a78278dfeae7707d51747251db4176240", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-openssl": "*", + "php": "^5.6 || ^7.0" + }, + "require-dev": { + "mikey179/vfsstream": "~1.5", + "phpmd/phpmd": "~2.2", + "phpunit/php-invoker": "~1.1", + "phpunit/phpunit": "^5.7 || ^7.3", + "squizlabs/php_codesniffer": "~2.3" + }, + "suggest": { + "lcobucci/clock": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "files": [ + "compat/class-aliases.php", + "compat/json-exception-polyfill.php", + "compat/lcobucci-clock-polyfill.php" + ], + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Otávio Cobucci Oblonczyk", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/3.4.6" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2021-09-28T19:18:28+00:00" + }, + { + "name": "lexik/jwt-authentication-bundle", + "version": "v2.10.7", + "source": { + "type": "git", + "url": "https://github.com/lexik/LexikJWTAuthenticationBundle.git", + "reference": "79ba5af396c4f4e64fe9c8b9af65f8441fdb44cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lexik/LexikJWTAuthenticationBundle/zipball/79ba5af396c4f4e64fe9c8b9af65f8441fdb44cf", + "reference": "79ba5af396c4f4e64fe9c8b9af65f8441fdb44cf", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "lcobucci/jwt": "^3.2|^4.0", + "namshi/jose": "^7.2", + "php": ">=5.6", + "symfony/framework-bundle": "^3.4|^4.0|^5.0", + "symfony/security-bundle": "^3.4|^4.0|^5.0" + }, + "require-dev": { + "symfony/browser-kit": "^3.4|^4.0|^5.0", + "symfony/console": "^3.4|^4.0|^5.0", + "symfony/dom-crawler": "^3.4|^4.0|^5.0", + "symfony/phpunit-bridge": "^3.4|^4.0|^5.0", + "symfony/var-dumper": "^3.4|^4.0|^5.0", + "symfony/yaml": "^3.4|^4.0|^5.0" + }, + "suggest": { + "gesdinet/jwt-refresh-token-bundle": "Implements a refresh token system over Json Web Tokens in Symfony", + "spomky-labs/lexik-jose-bridge": "Provides a JWT Token encoder with encryption support" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Lexik\\Bundle\\JWTAuthenticationBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Barthe", + "email": "j.barthe@lexik.fr", + "homepage": "https://github.com/jeremyb" + }, + { + "name": "Nicolas Cabot", + "email": "n.cabot@lexik.fr", + "homepage": "https://github.com/slashfan" + }, + { + "name": "Cedric Girard", + "email": "c.girard@lexik.fr", + "homepage": "https://github.com/cedric-g" + }, + { + "name": "Dev Lexik", + "email": "dev@lexik.fr", + "homepage": "https://github.com/lexik" + }, + { + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com", + "homepage": "https://github.com/chalasr" + }, + { + "name": "Lexik Community", + "homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle/graphs/contributors" + } + ], + "description": "This bundle provides JWT authentication for your Symfony REST API", + "homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle", + "keywords": [ + "Authentication", + "JWS", + "api", + "bundle", + "jwt", + "rest", + "symfony" + ], + "funding": [ + { + "url": "https://github.com/chalasr", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/lexik/jwt-authentication-bundle", + "type": "tidelift" + } + ], + "time": "2021-05-12T09:32:35+00:00" + }, + { + "name": "linkorb/jsmin-php", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/linkorb/jsmin-php.git", + "reference": "be85d87fc9c27730e7e9ced742b13010dafc1026" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/linkorb/jsmin-php/zipball/be85d87fc9c27730e7e9ced742b13010dafc1026", + "reference": "be85d87fc9c27730e7e9ced742b13010dafc1026", + "shasum": "" + }, + "require": { + "php": ">=5.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joost Faassen", + "email": "j.faassen@linkorb.com", + "role": "Packaging for Composer" + }, + { + "name": "Ryan Grove", + "email": "ryan@wonko.com", + "role": "PHP port" + }, + { + "name": "Adam Gofort", + "email": "aag@adamgoforth.com", + "role": "Updates to the PHP port" + }, + { + "name": "Douglas Crockford", + "email": "douglas@crockford.com" + } + ], + "description": "Unofficial package of jsmin-php", + "homepage": "http://www.github.com/linkorb/jsmin-php", + "keywords": [ + "javascript", + "jsmin", + "minify" + ], + "time": "2013-03-15T13:16:35+00:00" + }, + { + "name": "matthiasmullie/minify", + "version": "1.3.68", + "source": { + "type": "git", + "url": "https://github.com/matthiasmullie/minify.git", + "reference": "c00fb02f71b2ef0a5f53fe18c5a8b9aa30f48297" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/matthiasmullie/minify/zipball/c00fb02f71b2ef0a5f53fe18c5a8b9aa30f48297", + "reference": "c00fb02f71b2ef0a5f53fe18c5a8b9aa30f48297", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "matthiasmullie/path-converter": "~1.1", + "php": ">=5.3.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.0", + "matthiasmullie/scrapbook": "dev-master", + "phpunit/phpunit": ">=4.8" + }, + "suggest": { + "psr/cache-implementation": "Cache implementation to use with Minify::cache" + }, + "bin": [ + "bin/minifycss", + "bin/minifyjs" + ], + "type": "library", + "autoload": { + "psr-4": { + "MatthiasMullie\\Minify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matthias Mullie", + "email": "minify@mullie.eu", + "homepage": "http://www.mullie.eu", + "role": "Developer" + } + ], + "description": "CSS & JavaScript minifier, in PHP. Removes whitespace, strips comments, combines files (incl. @import statements and small assets in CSS files), and optimizes/shortens a few common programming patterns.", + "homepage": "http://www.minifier.org", + "keywords": [ + "JS", + "css", + "javascript", + "minifier", + "minify" + ], + "support": { + "issues": "https://github.com/matthiasmullie/minify/issues", + "source": "https://github.com/matthiasmullie/minify/tree/1.3.68" + }, + "funding": [ + { + "url": "https://github.com/matthiasmullie", + "type": "github" + } + ], + "time": "2022-04-19T08:28:56+00:00" + }, + { + "name": "matthiasmullie/path-converter", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/matthiasmullie/path-converter.git", + "reference": "e7d13b2c7e2f2268e1424aaed02085518afa02d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/matthiasmullie/path-converter/zipball/e7d13b2c7e2f2268e1424aaed02085518afa02d9", + "reference": "e7d13b2c7e2f2268e1424aaed02085518afa02d9", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "MatthiasMullie\\PathConverter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matthias Mullie", + "email": "pathconverter@mullie.eu", + "homepage": "http://www.mullie.eu", + "role": "Developer" + } + ], + "description": "Relative path converter", + "homepage": "http://github.com/matthiasmullie/path-converter", + "keywords": [ + "converter", + "path", + "paths", + "relative" + ], + "support": { + "issues": "https://github.com/matthiasmullie/path-converter/issues", + "source": "https://github.com/matthiasmullie/path-converter/tree/1.1.3" + }, + "time": "2019-02-05T23:41:09+00:00" + }, + { + "name": "meenie/javascript-packer", + "version": "1.1", + "source": { + "type": "git", + "url": "https://github.com/meenie/javascript-packer.git", + "reference": "dcab0159ae1ed9d7535c034fb8afe1e4c3495d22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/meenie/javascript-packer/zipball/dcab0159ae1ed9d7535c034fb8afe1e4c3495d22", + "reference": "dcab0159ae1ed9d7535c034fb8afe1e4c3495d22", + "shasum": "" + }, + "type": "library", + "autoload": { + "files": [ + "class.JavaScriptPacker.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL 2.1" + ], + "authors": [ + { + "name": "Nicolas Martin", + "role": "Developer" + } + ], + "description": "Composer hosted mirror of the PHP Version of Dean Edwards' JavaScript Packer", + "homepage": "http://joliclic.free.fr/php/javascript-packer/en/", + "keywords": [ + "javascript packer" + ], + "time": "2013-03-25T21:54:33+00:00" + }, + { + "name": "michelf/php-markdown", + "version": "1.9.1", + "source": { + "type": "git", + "url": "https://github.com/michelf/php-markdown.git", + "reference": "5024d623c1a057dcd2d076d25b7d270a1d0d55f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/michelf/php-markdown/zipball/5024d623c1a057dcd2d076d25b7d270a1d0d55f3", + "reference": "5024d623c1a057dcd2d076d25b7d270a1d0d55f3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": ">=4.3 <5.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Michelf\\": "Michelf/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Michel Fortin", + "email": "michel.fortin@michelf.ca", + "homepage": "https://michelf.ca/", + "role": "Developer" + }, + { + "name": "John Gruber", + "homepage": "https://daringfireball.net/" + } + ], + "description": "PHP Markdown", + "homepage": "https://michelf.ca/projects/php-markdown/", + "keywords": [ + "markdown" + ], + "support": { + "issues": "https://github.com/michelf/php-markdown/issues", + "source": "https://github.com/michelf/php-markdown/tree/1.9.1" + }, + "time": "2021-11-24T02:52:38+00:00" + }, + { + "name": "miracode/stripe-bundle", + "version": "v1.0.9", + "source": { + "type": "git", + "url": "https://github.com/mirovskyi/stripe-bundle.git", + "reference": "6031571633d3f3deece4d3199c98f2c67a5b980f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mirovskyi/stripe-bundle/zipball/6031571633d3f3deece4d3199c98f2c67a5b980f", + "reference": "6031571633d3f3deece4d3199c98f2c67a5b980f", + "shasum": "" + }, + "require": { + "doctrine/common": ">=2.2", + "php": ">=5.4.0", + "stripe/stripe-php": ">=3.0", + "symfony/config": ">=2.3", + "symfony/framework-bundle": ">=2.4" + }, + "require-dev": { + "doctrine/orm": ">=2.2", + "phpunit/phpunit": "~4.8|~5.0", + "symfony/yaml": "^2.8 || ^3.0 || ^4.0" + }, + "suggest": { + "doctrine/orm": "If you want to save stripe data in database" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Miracode\\StripeBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aleksey Mirovskyi", + "email": "mirovskyi@gmail.com" + } + ], + "description": "Symfony bundle to integrate Stripe PHP SDK. Ability to save Stripe objects in database using Doctrine.", + "keywords": [ + "bundle", + "payment", + "php", + "stripe", + "symfony", + "webpayment" + ], + "time": "2018-02-19T16:55:56+00:00" + }, + { + "name": "monolog/monolog", + "version": "1.27.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "904713c5929655dc9b97288b69cfeedad610c9a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/904713c5929655dc9b97288b69cfeedad610c9a1", + "reference": "904713c5929655dc9b97288b69cfeedad610c9a1", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "psr/log": "~1.0" + }, + "provide": { + "psr/log-implementation": "1.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^2.4.9 || ^3.0", + "doctrine/couchdb": "~1.0@dev", + "graylog2/gelf-php": "~1.0", + "php-amqplib/php-amqplib": "~2.4", + "php-console/php-console": "^3.1.3", + "phpstan/phpstan": "^0.12.59", + "phpunit/phpunit": "~4.5", + "ruflin/elastica": ">=0.90 <3.0", + "sentry/sentry": "^0.13", + "swiftmailer/swiftmailer": "^5.3|^6.0" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-mongo": "Allow sending log messages to a MongoDB server", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server via PHP Driver", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "php-console/php-console": "Allow sending log messages to Google Chrome", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server", + "sentry/sentry": "Allow sending log messages to a Sentry server" + }, + "type": "library", + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "http://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/1.27.1" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2022-06-09T08:53:42+00:00" + }, + { + "name": "namshi/jose", + "version": "7.2.3", + "source": { + "type": "git", + "url": "https://github.com/namshi/jose.git", + "reference": "89a24d7eb3040e285dd5925fcad992378b82bcff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/namshi/jose/zipball/89a24d7eb3040e285dd5925fcad992378b82bcff", + "reference": "89a24d7eb3040e285dd5925fcad992378b82bcff", + "shasum": "" + }, + "require": { + "ext-date": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-pcre": "*", + "ext-spl": "*", + "php": ">=5.5", + "symfony/polyfill-php56": "^1.0" + }, + "require-dev": { + "phpseclib/phpseclib": "^2.0", + "phpunit/phpunit": "^4.5|^5.0", + "satooshi/php-coveralls": "^1.0" + }, + "suggest": { + "ext-openssl": "Allows to use OpenSSL as crypto engine.", + "phpseclib/phpseclib": "Allows to use Phpseclib as crypto engine, use version ^2.0." + }, + "type": "library", + "autoload": { + "psr-4": { + "Namshi\\JOSE\\": "src/Namshi/JOSE/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Nadalin", + "email": "alessandro.nadalin@gmail.com" + }, + { + "name": "Alessandro Cinelli (cirpo)", + "email": "alessandro.cinelli@gmail.com" + } + ], + "description": "JSON Object Signing and Encryption library for PHP.", + "keywords": [ + "JSON Web Signature", + "JSON Web Token", + "JWS", + "json", + "jwt", + "token" + ], + "time": "2016-12-05T07:27:31+00:00" + }, + { + "name": "natxet/cssmin", + "version": "v3.0.6", + "source": { + "type": "git", + "url": "https://github.com/natxet/CssMin.git", + "reference": "d5d9f4c3e5cedb1ae96a95a21731f8790e38f1dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/natxet/CssMin/zipball/d5d9f4c3e5cedb1ae96a95a21731f8790e38f1dd", + "reference": "d5d9f4c3e5cedb1ae96a95a21731f8790e38f1dd", + "shasum": "" + }, + "require": { + "php": ">=5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joe Scylla", + "email": "joe.scylla@gmail.com", + "homepage": "https://profiles.google.com/joe.scylla" + } + ], + "description": "Minifying CSS", + "homepage": "http://code.google.com/p/cssmin/", + "keywords": [ + "css", + "minify" + ], + "support": { + "issues": "https://github.com/natxet/CssMin/issues", + "source": "https://github.com/natxet/CssMin/tree/master" + }, + "time": "2018-01-09T11:15:01+00:00" + }, + { + "name": "nelmio/api-doc-bundle", + "version": "2.13.5", + "target-dir": "Nelmio/ApiDocBundle", + "source": { + "type": "git", + "url": "https://github.com/nelmio/NelmioApiDocBundle.git", + "reference": "158149568863c688abfa3df94037257bbed628ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/158149568863c688abfa3df94037257bbed628ed", + "reference": "158149568863c688abfa3df94037257bbed628ed", + "shasum": "" + }, + "require": { + "michelf/php-markdown": "~1.4", + "php": ">=5.4", + "symfony/console": "~2.3|~3.0|~4.0", + "symfony/framework-bundle": "~2.3|~3.0|~4.0", + "symfony/twig-bundle": "~2.3|~3.0|~4.0" + }, + "conflict": { + "jms/serializer": "<0.12", + "jms/serializer-bundle": "<0.11", + "symfony/symfony": "~2.7.8", + "twig/twig": "<1.12" + }, + "require-dev": { + "doctrine/doctrine-bundle": "~1.5", + "doctrine/orm": "~2.3", + "dunglas/api-bundle": "~1.0", + "friendsofsymfony/rest-bundle": "~1.0|~2.0", + "jms/serializer-bundle": ">=0.11", + "sensio/framework-extra-bundle": "~3.0", + "symfony/browser-kit": "~2.3|~3.0|~4.0", + "symfony/css-selector": "~2.3|~3.0|~4.0", + "symfony/finder": "~2.3|~3.0|~4.0", + "symfony/form": "~2.3|~3.0|~4.0", + "symfony/phpunit-bridge": "~2.7|~3.0|~4.0", + "symfony/serializer": "~2.7|~3.0|~4.0", + "symfony/validator": "~2.3|~3.0|~4.0", + "symfony/yaml": "~2.3|~3.0|~4.0" + }, + "suggest": { + "dunglas/api-bundle": "For making use of resources definitions of DunglasApiBundle.", + "friendsofsymfony/rest-bundle": "For making use of REST information in the doc.", + "jms/serializer": "For making use of serializer information in the doc.", + "symfony/form": "For using form definitions as input.", + "symfony/validator": "For making use of validator information in the doc." + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-2.x": "2.13-dev" + } + }, + "autoload": { + "psr-0": { + "Nelmio\\ApiDocBundle": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nelmio", + "homepage": "http://nelm.io" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/nelmio/NelmioApiDocBundle/contributors" + } + ], + "description": "Generates documentation for your REST API from annotations", + "keywords": [ + "api", + "doc", + "documentation", + "rest" + ], + "support": { + "issues": "https://github.com/nelmio/NelmioApiDocBundle/issues", + "source": "https://github.com/nelmio/NelmioApiDocBundle/tree/2.13.5" + }, + "time": "2021-03-23T07:09:33+00:00" + }, + { + "name": "nelmio/cors-bundle", + "version": "1.5.6", + "source": { + "type": "git", + "url": "https://github.com/nelmio/NelmioCorsBundle.git", + "reference": "10a24c10f242440211ed31075e74f81661c690d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/10a24c10f242440211ed31075e74f81661c690d9", + "reference": "10a24c10f242440211ed31075e74f81661c690d9", + "shasum": "" + }, + "require": { + "symfony/framework-bundle": "^2.7 || ^3.0 || ^4.0" + }, + "require-dev": { + "matthiasnoback/symfony-dependency-injection-test": "^1.0 || ^2.0", + "mockery/mockery": "^0.9 || ^1.0", + "symfony/phpunit-bridge": "^2.7 || ^3.0 || ^4.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "1.5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Nelmio\\CorsBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nelmio", + "homepage": "http://nelm.io" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/nelmio/NelmioCorsBundle/contributors" + } + ], + "description": "Adds CORS (Cross-Origin Resource Sharing) headers support in your Symfony2 application", + "keywords": [ + "api", + "cors", + "crossdomain" + ], + "support": { + "issues": "https://github.com/nelmio/NelmioCorsBundle/issues", + "source": "https://github.com/nelmio/NelmioCorsBundle/tree/1.5.6" + }, + "time": "2019-06-17T08:53:14+00:00" + }, + { + "name": "nervo/yuicompressor", + "version": "2.4.8", + "source": { + "type": "git", + "url": "https://github.com/nervo/yuicompressor.git", + "reference": "e6e3b215c3998cf6092613c002ae533ae75d7ada" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nervo/yuicompressor/zipball/e6e3b215c3998cf6092613c002ae533ae75d7ada", + "reference": "e6e3b215c3998cf6092613c002ae533ae75d7ada", + "shasum": "" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD" + ], + "description": "YUI Compressor is an open source tool that supports the compression of both JavaScript and CSS files. The JavaScript compression removes comments and white-spaces as well as obfuscates local variables using the smallest possible variable name. CSS compression is done using a regular-expression-based CSS minifier.", + "homepage": "https://github.com/yui/yuicompressor", + "keywords": [ + "java", + "yui" + ], + "support": { + "issues": "https://github.com/nervo/yuicompressor/issues", + "source": "https://github.com/nervo/yuicompressor/tree/master" + }, + "time": "2013-09-13T10:26:14+00:00" + }, + { + "name": "nochso/html-compress-twig", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/nochso/html-compress-twig.git", + "reference": "56d658cebf2852ed455c5af0ee2196037acb2581" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nochso/html-compress-twig/zipball/56d658cebf2852ed455c5af0ee2196037acb2581", + "reference": "56d658cebf2852ed455c5af0ee2196037acb2581", + "shasum": "" + }, + "require": { + "twig/twig": "^1.26 || ^2.0", + "wyrihaximus/html-compress": "^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "nochso\\HtmlCompressTwig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Marcel Voigt", + "email": "mv@noch.so" + } + ], + "description": "Twig extension for compressing HTML and inline CSS/Javascript", + "keywords": [ + "compress", + "css", + "extension", + "html", + "javascript", + "minify", + "twig" + ], + "time": "2017-02-06T17:52:16+00:00" + }, + { + "name": "ocramius/proxy-manager", + "version": "2.2.4", + "source": { + "type": "git", + "url": "https://github.com/Ocramius/ProxyManager.git", + "reference": "2d7cd2a79cd3ade90c46211baae1b88d47683917" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Ocramius/ProxyManager/zipball/2d7cd2a79cd3ade90c46211baae1b88d47683917", + "reference": "2d7cd2a79cd3ade90c46211baae1b88d47683917", + "shasum": "" + }, + "require": { + "ocramius/package-versions": "^1.1.3", + "php": "^7.2.0", + "zendframework/zend-code": "^3.3.0" + }, + "require-dev": { + "couscous/couscous": "^1.6.1", + "ext-phar": "*", + "humbug/humbug": "1.0.0-RC.0@RC", + "nikic/php-parser": "^3.1.1", + "padraic/phpunit-accelerator": "dev-master@DEV", + "phpbench/phpbench": "^0.12.2", + "phpstan/phpstan": "dev-master#856eb10a81c1d27c701a83f167dc870fd8f4236a as 0.9.999", + "phpstan/phpstan-phpunit": "dev-master#5629c0a1f4a9c417cb1077cf6693ad9753895761", + "phpunit/phpunit": "^6.4.3", + "squizlabs/php_codesniffer": "^2.9.1" + }, + "suggest": { + "ocramius/generated-hydrator": "To have very fast object to array to object conversion for ghost objects", + "zendframework/zend-json": "To have the JsonRpc adapter (Remote Object feature)", + "zendframework/zend-soap": "To have the Soap adapter (Remote Object feature)", + "zendframework/zend-xmlrpc": "To have the XmlRpc adapter (Remote Object feature)" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-0": { + "ProxyManager\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "http://ocramius.github.io/" + } + ], + "description": "A library providing utilities to generate, instantiate and generally operate with Object Proxies", + "homepage": "https://github.com/Ocramius/ProxyManager", + "keywords": [ + "aop", + "lazy loading", + "proxy", + "proxy pattern", + "service proxies" + ], + "support": { + "issues": "https://github.com/Ocramius/ProxyManager/issues", + "source": "https://github.com/Ocramius/ProxyManager/tree/2.2.4" + }, + "funding": [ + { + "url": "https://github.com/Ocramius", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/ocramius/proxy-manager", + "type": "tidelift" + } + ], + "time": "2022-03-05T18:15:28+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v2.6.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "58c3f47f650c94ec05a151692652a868995d2938" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/58c3f47f650c94ec05a151692652a868995d2938", + "reference": "58c3f47f650c94ec05a151692652a868995d2938", + "shasum": "" + }, + "require": { + "php": "^7|^8" + }, + "require-dev": { + "phpunit/phpunit": "^6|^7|^8|^9", + "vimeo/psalm": "^1|^2|^3|^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2022-06-14T06:56:20+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v2.0.21", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "96c132c7f2f7bc3230723b66e89f8f150b29d5ae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/96c132c7f2f7bc3230723b66e89f8f150b29d5ae", + "reference": "96c132c7f2f7bc3230723b66e89f8f150b29d5ae", + "shasum": "" + }, + "require": { + "php": ">=5.2.0" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "autoload": { + "files": [ + "lib/random.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2022-02-16T17:07:03+00:00" + }, + { + "name": "patchwork/jsqueeze", + "version": "v2.0.5", + "source": { + "type": "git", + "url": "https://github.com/tchwork/jsqueeze.git", + "reference": "693d64850eab2ce6a7c8f7cf547e1ab46e69d542" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tchwork/jsqueeze/zipball/693d64850eab2ce6a7c8f7cf547e1ab46e69d542", + "reference": "693d64850eab2ce6a7c8f7cf547e1ab46e69d542", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Patchwork\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "(Apache-2.0 or GPL-2.0)" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + } + ], + "description": "Efficient JavaScript minification in PHP", + "homepage": "https://github.com/tchwork/jsqueeze", + "keywords": [ + "compression", + "javascript", + "minification" + ], + "abandoned": true, + "time": "2016-04-19T09:28:22+00:00" + }, + { + "name": "paypal/rest-api-sdk-php", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/paypal/PayPal-PHP-SDK.git", + "reference": "72e2f2466975bf128a31e02b15110180f059fc04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paypal/PayPal-PHP-SDK/zipball/72e2f2466975bf128a31e02b15110180f059fc04", + "reference": "72e2f2466975bf128a31e02b15110180f059fc04", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "php": ">=5.3.0", + "psr/log": "^1.0.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35" + }, + "type": "library", + "autoload": { + "psr-0": { + "PayPal": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "PayPal", + "homepage": "https://github.com/paypal/rest-api-sdk-php/contributors" + } + ], + "description": "PayPal's PHP SDK for REST APIs", + "homepage": "http://paypal.github.io/PayPal-PHP-SDK/", + "keywords": [ + "payments", + "paypal", + "rest", + "sdk" + ], + "abandoned": true, + "time": "2019-01-04T20:04:25+00:00" + }, + { + "name": "php-amqplib/php-amqplib", + "version": "v2.12.3", + "source": { + "type": "git", + "url": "https://github.com/php-amqplib/php-amqplib.git", + "reference": "f746eb44df6d8f838173729867dd1d20b0265faa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-amqplib/php-amqplib/zipball/f746eb44df6d8f838173729867dd1d20b0265faa", + "reference": "f746eb44df6d8f838173729867dd1d20b0265faa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-sockets": "*", + "php": ">=5.6.3,<8.0", + "phpseclib/phpseclib": "^2.0|^3.0" + }, + "conflict": { + "php": "7.4.0 - 7.4.1" + }, + "replace": { + "videlalvaro/php-amqplib": "self.version" + }, + "require-dev": { + "ext-curl": "*", + "nategood/httpful": "^0.2.20", + "phpunit/phpunit": "^5.7|^6.5|^7.0", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.12-dev" + } + }, + "autoload": { + "psr-4": { + "PhpAmqpLib\\": "PhpAmqpLib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Alvaro Videla", + "role": "Original Maintainer" + }, + { + "name": "Raúl Araya", + "email": "nubeiro@gmail.com", + "role": "Maintainer" + }, + { + "name": "Luke Bakken", + "email": "luke@bakken.io", + "role": "Maintainer" + }, + { + "name": "Ramūnas Dronga", + "email": "github@ramuno.lt", + "role": "Maintainer" + } + ], + "description": "Formerly videlalvaro/php-amqplib. This library is a pure PHP implementation of the AMQP protocol. It's been tested against RabbitMQ.", + "homepage": "https://github.com/php-amqplib/php-amqplib/", + "keywords": [ + "message", + "queue", + "rabbitmq" + ], + "support": { + "issues": "https://github.com/php-amqplib/php-amqplib/issues", + "source": "https://github.com/php-amqplib/php-amqplib/tree/v2.12.3" + }, + "time": "2021-03-01T12:21:31+00:00" + }, + { + "name": "php-amqplib/rabbitmq-bundle", + "version": "v1.15.1", + "source": { + "type": "git", + "url": "https://github.com/php-amqplib/RabbitMqBundle.git", + "reference": "5d46fd892e5f6ac0540195846fbcf32c8086bf74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-amqplib/RabbitMqBundle/zipball/5d46fd892e5f6ac0540195846fbcf32c8086bf74", + "reference": "5d46fd892e5f6ac0540195846fbcf32c8086bf74", + "shasum": "" + }, + "require": { + "php": "^5.3.9|^7.0", + "php-amqplib/php-amqplib": "^2.6", + "psr/log": "^1.0", + "symfony/config": "^2.7|^3.0|^4.0", + "symfony/console": "^2.7|^3.0|^4.0", + "symfony/dependency-injection": "^2.7|^3.0|^4.0", + "symfony/event-dispatcher": "^2.7|^3.0|^4.0", + "symfony/yaml": "^2.7|^3.0|^4.0" + }, + "replace": { + "oldsound/rabbitmq-bundle": "self.version" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|^5.4.3", + "symfony/debug": "^2.7|^3.0|^4.0", + "symfony/serializer": "^2.7|^3.0|^4.0" + }, + "suggest": { + "symfony/framework-bundle": "To use this lib as a full Symfony Bundle and to use the profiler data collector" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "1.10.x-dev" + } + }, + "autoload": { + "psr-4": { + "OldSound\\RabbitMqBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alvaro Videla" + } + ], + "description": "Integrates php-amqplib with Symfony & RabbitMq. Formerly oldsound/rabbitmq-bundle.", + "keywords": [ + "AMQP", + "Symfony2", + "message", + "queue", + "rabbitmq", + "symfony", + "symfony3", + "symfony4" + ], + "support": { + "issues": "https://github.com/php-amqplib/RabbitMqBundle/issues", + "source": "https://github.com/php-amqplib/RabbitMqBundle/tree/v1.15.1" + }, + "time": "2019-12-06T16:27:58+00:00" + }, + { + "name": "php-http/guzzle6-adapter", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/guzzle6-adapter.git", + "reference": "a56941f9dc6110409cfcddc91546ee97039277ab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/guzzle6-adapter/zipball/a56941f9dc6110409cfcddc91546ee97039277ab", + "reference": "a56941f9dc6110409cfcddc91546ee97039277ab", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0", + "php": ">=5.5.0", + "php-http/httplug": "^1.0" + }, + "provide": { + "php-http/async-client-implementation": "1.0", + "php-http/client-implementation": "1.0" + }, + "require-dev": { + "ext-curl": "*", + "php-http/adapter-integration-tests": "^0.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "Http\\Adapter\\Guzzle6\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + }, + { + "name": "David de Boer", + "email": "david@ddeboer.nl" + } + ], + "description": "Guzzle 6 HTTP Adapter", + "homepage": "http://httplug.io", + "keywords": [ + "Guzzle", + "http" + ], + "time": "2016-05-10T06:13:32+00:00" + }, + { + "name": "php-http/httplug", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/httplug.git", + "reference": "1c6381726c18579c4ca2ef1ec1498fdae8bdf018" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/httplug/zipball/1c6381726c18579c4ca2ef1ec1498fdae8bdf018", + "reference": "1c6381726c18579c4ca2ef1ec1498fdae8bdf018", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "php-http/promise": "^1.0", + "psr/http-message": "^1.0" + }, + "require-dev": { + "henrikbjorn/phpspec-code-coverage": "^1.0", + "phpspec/phpspec": "^2.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eric GELOEN", + "email": "geloen.eric@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "HTTPlug, the HTTP client abstraction for PHP", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "http" + ], + "time": "2016-08-31T08:30:17+00:00" + }, + { + "name": "php-http/promise", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/promise.git", + "reference": "4c4c1f9b7289a2ec57cde7f1e9762a5789506f88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/promise/zipball/4c4c1f9b7289a2ec57cde7f1e9762a5789506f88", + "reference": "4c4c1f9b7289a2ec57cde7f1e9762a5789506f88", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.3.2", + "phpspec/phpspec": "^5.1.2 || ^6.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Http\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Promise used for asynchronous HTTP requests", + "homepage": "http://httplug.io", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/php-http/promise/issues", + "source": "https://github.com/php-http/promise/tree/1.1.0" + }, + "time": "2020-07-07T09:29:14+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.14", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "2f0b7af658cbea265cbb4a791d6c29a6613f98ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2f0b7af658cbea265cbb4a791d6c29a6613f98ef", + "reference": "2f0b7af658cbea265cbb4a791d6c29a6613f98ef", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.14" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2022-04-04T05:15:45+00:00" + }, + { + "name": "prewk/option", + "version": "0.0.5", + "source": { + "type": "git", + "url": "https://github.com/prewk/option.git", + "reference": "bb48c619ce2fafd0c6b26433b7637c2de8f9248d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/prewk/option/zipball/bb48c619ce2fafd0c6b26433b7637c2de8f9248d", + "reference": "bb48c619ce2fafd0c6b26433b7637c2de8f9248d", + "shasum": "" + }, + "require": { + "prewk/result": "*" + }, + "require-dev": { + "leanphp/phpspec-code-coverage": "^3.1", + "phpspec/phpspec": "^3.2", + "satooshi/php-coveralls": "^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Prewk\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "(MIT and Apache-2.0)" + ], + "authors": [ + { + "name": "Oskar Thornblad", + "email": "oskar.thornblad@gmail.com" + } + ], + "description": "Option object for PHP inspired by Rust", + "time": "2017-04-28T17:13:50+00:00" + }, + { + "name": "prewk/result", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/prewk/result.git", + "reference": "70add2dabb799541f4cd23bccd27b0e61193a34e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/prewk/result/zipball/70add2dabb799541f4cd23bccd27b0e61193a34e", + "reference": "70add2dabb799541f4cd23bccd27b0e61193a34e", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "prewk/option": "*" + }, + "require-dev": { + "leanphp/phpspec-code-coverage": "^3.1", + "phpspec/phpspec": "^3.2", + "satooshi/php-coveralls": "^1.0", + "vimeo/psalm": "^3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Prewk\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "(MIT and Apache-2.0)" + ], + "authors": [ + { + "name": "Oskar Thornblad", + "email": "oskar.thornblad@gmail.com" + } + ], + "description": "Result object for PHP inspired by Rust", + "support": { + "issues": "https://github.com/prewk/result/issues", + "source": "https://github.com/prewk/result/tree/2.1.1" + }, + "time": "2019-05-12T09:12:49+00:00" + }, + { + "name": "psr/cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "time": "2016-08-06T20:24:11+00:00" + }, + { + "name": "psr/container", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf", + "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://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" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/1.1.1" + }, + "time": "2021-03-05T17:36:06+00:00" + }, + { + "name": "psr/http-message", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "time": "2016-08-06T14:39:51+00:00" + }, + { + "name": "psr/link", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/link.git", + "reference": "eea8e8662d5cd3ae4517c9b864493f59fca95562" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/link/zipball/eea8e8662d5cd3ae4517c9b864493f59fca95562", + "reference": "eea8e8662d5cd3ae4517c9b864493f59fca95562", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Link\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for HTTP links", + "keywords": [ + "http", + "http-link", + "link", + "psr", + "psr-13", + "rest" + ], + "time": "2016-10-28T16:06:13+00:00" + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.4" + }, + "time": "2021-05-03T11:20:27+00:00" + }, + { + "name": "psr/simple-cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "time": "2017-10-23T01:57:42+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "react/promise", + "version": "v2.9.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "234f8fd1023c9158e2314fa9d7d0e6a83db42910" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/234f8fd1023c9158e2314fa9d7d0e6a83db42910", + "reference": "234f8fd1023c9158e2314fa9d7d0e6a83db42910", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v2.9.0" + }, + "funding": [ + { + "url": "https://github.com/WyriHaximus", + "type": "github" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-02-11T10:27:51+00:00" + }, + { + "name": "seld/jsonlint", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/jsonlint.git", + "reference": "19495c181d6d53a0a13414154e52817e3b504189" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/19495c181d6d53a0a13414154e52817e3b504189", + "reference": "19495c181d6d53a0a13414154e52817e3b504189", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0" + }, + "bin": [ + "bin/jsonlint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Seld\\JsonLint\\": "src/Seld/JsonLint/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "JSON Linter", + "keywords": [ + "json", + "linter", + "parser", + "validator" + ], + "time": "2016-11-14T17:59:58+00:00" + }, + { + "name": "sensio/distribution-bundle", + "version": "v5.0.25", + "source": { + "type": "git", + "url": "https://github.com/sensiolabs/SensioDistributionBundle.git", + "reference": "80a38234bde8321fb92aa0b8c27978a272bb4baf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sensiolabs/SensioDistributionBundle/zipball/80a38234bde8321fb92aa0b8c27978a272bb4baf", + "reference": "80a38234bde8321fb92aa0b8c27978a272bb4baf", + "shasum": "" + }, + "require": { + "php": ">=5.3.9", + "sensiolabs/security-checker": "~5.0|~6.0", + "symfony/class-loader": "~2.3|~3.0", + "symfony/config": "~2.3|~3.0", + "symfony/dependency-injection": "~2.3|~3.0", + "symfony/filesystem": "~2.3|~3.0", + "symfony/http-kernel": "~2.3|~3.0", + "symfony/process": "~2.3|~3.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "5.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sensio\\Bundle\\DistributionBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + } + ], + "description": "Base bundle for Symfony Distributions", + "keywords": [ + "configuration", + "distribution" + ], + "support": { + "issues": "https://github.com/sensiolabs/SensioDistributionBundle/issues", + "source": "https://github.com/sensiolabs/SensioDistributionBundle/tree/master" + }, + "abandoned": true, + "time": "2019-06-18T15:43:58+00:00" + }, + { + "name": "sensio/framework-extra-bundle", + "version": "v3.0.29", + "source": { + "type": "git", + "url": "https://github.com/sensiolabs/SensioFrameworkExtraBundle.git", + "reference": "bb907234df776b68922eb4b25bfa061683597b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sensiolabs/SensioFrameworkExtraBundle/zipball/bb907234df776b68922eb4b25bfa061683597b6a", + "reference": "bb907234df776b68922eb4b25bfa061683597b6a", + "shasum": "" + }, + "require": { + "doctrine/common": "~2.2", + "symfony/dependency-injection": "~2.3|~3.0", + "symfony/framework-bundle": "~2.3|~3.0|~4.0" + }, + "require-dev": { + "doctrine/doctrine-bundle": "~1.5", + "doctrine/orm": "~2.4,>=2.4.5", + "symfony/asset": "~2.7|~3.0|~4.0", + "symfony/browser-kit": "~2.3|~3.0|~4.0", + "symfony/dom-crawler": "~2.3|~3.0|~4.0", + "symfony/expression-language": "~2.4|~3.0|~4.0", + "symfony/finder": "~2.3|~3.0|~4.0", + "symfony/phpunit-bridge": "~3.2|~4.0", + "symfony/psr-http-message-bridge": "^0.3|^1.0", + "symfony/security-bundle": "~2.4|~3.0|~4.0", + "symfony/templating": "~2.3|~3.0|~4.0", + "symfony/translation": "~2.3|~3.0|~4.0", + "symfony/twig-bundle": "~2.3|~3.0|~4.0", + "symfony/yaml": "~2.3|~3.0|~4.0", + "twig/twig": "~1.12|~2.0", + "zendframework/zend-diactoros": "^1.3" + }, + "suggest": { + "symfony/expression-language": "", + "symfony/psr-http-message-bridge": "To use the PSR-7 converters", + "symfony/security-bundle": "" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sensio\\Bundle\\FrameworkExtraBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + } + ], + "description": "This bundle provides a way to configure your controllers with annotations", + "keywords": [ + "annotations", + "controllers" + ], + "support": { + "issues": "https://github.com/sensiolabs/SensioFrameworkExtraBundle/issues", + "source": "https://github.com/sensiolabs/SensioFrameworkExtraBundle/tree/3.0" + }, + "time": "2017-12-14T19:03:23+00:00" + }, + { + "name": "sensiolabs/security-checker", + "version": "v6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sensiolabs/security-checker.git", + "reference": "a576c01520d9761901f269c4934ba55448be4a54" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sensiolabs/security-checker/zipball/a576c01520d9761901f269c4934ba55448be4a54", + "reference": "a576c01520d9761901f269c4934ba55448be4a54", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "symfony/console": "^2.8|^3.4|^4.2|^5.0", + "symfony/http-client": "^4.3|^5.0", + "symfony/mime": "^4.3|^5.0", + "symfony/polyfill-ctype": "^1.11" + }, + "bin": [ + "security-checker" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.0-dev" + } + }, + "autoload": { + "psr-4": { + "SensioLabs\\Security\\": "SensioLabs/Security" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien.potencier@gmail.com" + } + ], + "description": "A security checker for your composer.lock", + "support": { + "issues": "https://github.com/sensiolabs/security-checker/issues", + "source": "https://github.com/sensiolabs/security-checker/tree/master" + }, + "abandoned": "https://github.com/fabpot/local-php-security-checker", + "time": "2019-11-01T13:20:14+00:00" + }, + { + "name": "slowprog/composer-copy-file", + "version": "0.2.1", + "source": { + "type": "git", + "url": "https://github.com/slowprog/CopyFile.git", + "reference": "400c2b7c9f9f8ed8195217ae143ffbf3fec84c31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slowprog/CopyFile/zipball/400c2b7c9f9f8ed8195217ae143ffbf3fec84c31", + "reference": "400c2b7c9f9f8ed8195217ae143ffbf3fec84c31", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "composer/composer": "1.0.*@dev", + "mikey179/vfsstream": "~1", + "phpunit/phpunit": "~5.0", + "symfony/filesystem": "~2.7", + "symfony/finder": "~2.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "SlowProg\\CopyFile\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Tyshev", + "email": "slowprog@gmail.com" + } + ], + "description": "Composer script copying your files after install", + "homepage": "https://github.com/SlowProg/composer-copy-file", + "keywords": [ + "copy file" + ], + "time": "2017-12-07T13:18:47+00:00" + }, + { + "name": "stripe/stripe-php", + "version": "v8.10.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "ae44989a37e0785f325d696719afa46e3503dc7d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/ae44989a37e0785f325d696719afa46e3503dc7d", + "reference": "ae44989a37e0785f325d696719afa46e3503dc7d", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.5.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^5.7 || ^9.0", + "squizlabs/php_codesniffer": "^3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v8.10.0" + }, + "time": "2022-07-07T17:46:57+00:00" + }, + { + "name": "swiftmailer/swiftmailer", + "version": "v5.4.12", + "source": { + "type": "git", + "url": "https://github.com/swiftmailer/swiftmailer.git", + "reference": "181b89f18a90f8925ef805f950d47a7190e9b950" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/181b89f18a90f8925ef805f950d47a7190e9b950", + "reference": "181b89f18a90f8925ef805f950d47a7190e9b950", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "mockery/mockery": "~0.9.1", + "symfony/phpunit-bridge": "~3.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.4-dev" + } + }, + "autoload": { + "files": [ + "lib/swift_required.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Corbyn" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + } + ], + "description": "Swiftmailer, free feature-rich PHP mailer", + "homepage": "https://swiftmailer.symfony.com", + "keywords": [ + "email", + "mail", + "mailer" + ], + "support": { + "issues": "https://github.com/swiftmailer/swiftmailer/issues", + "source": "https://github.com/swiftmailer/swiftmailer/tree/v5.4.12" + }, + "abandoned": "symfony/mailer", + "time": "2018-07-31T09:26:32+00:00" + }, + { + "name": "symfony/assetic-bundle", + "version": "v2.8.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/assetic-bundle.git", + "reference": "2e0a23a4874838e26de6f025e02fc63328921a4c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/assetic-bundle/zipball/2e0a23a4874838e26de6f025e02fc63328921a4c", + "reference": "2e0a23a4874838e26de6f025e02fc63328921a4c", + "shasum": "" + }, + "require": { + "kriswallsmith/assetic": "~1.4", + "php": ">=5.3.0", + "symfony/console": "~2.3|~3.0", + "symfony/dependency-injection": "~2.3|~3.0", + "symfony/framework-bundle": "~2.3|~3.0", + "symfony/yaml": "~2.3|~3.0" + }, + "conflict": { + "kriswallsmith/spork": "<=0.2", + "twig/twig": "<1.27" + }, + "require-dev": { + "kriswallsmith/spork": "~0.3", + "patchwork/jsqueeze": "~1.0", + "symfony/class-loader": "~2.3|~3.0", + "symfony/css-selector": "~2.3|~3.0", + "symfony/dom-crawler": "~2.3|~3.0", + "symfony/phpunit-bridge": "~2.7|~3.0", + "symfony/twig-bundle": "~2.3|~3.0" + }, + "suggest": { + "kriswallsmith/spork": "to be able to dump assets in parallel", + "symfony/twig-bundle": "to use the Twig integration" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\AsseticBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kris Wallsmith", + "email": "kris.wallsmith@gmail.com", + "homepage": "http://kriswallsmith.net/" + } + ], + "description": "Integrates Assetic into Symfony2", + "homepage": "https://github.com/symfony/AsseticBundle", + "keywords": [ + "assets", + "compression", + "minification" + ], + "abandoned": "symfony/webpack-encore-pack", + "time": "2017-07-14T07:26:46+00:00" + }, + { + "name": "symfony/http-client", + "version": "v4.4.42", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "0366fe9d67709477e86b45e2e51a34ccf5018d04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/0366fe9d67709477e86b45e2e51a34ccf5018d04", + "reference": "0366fe9d67709477e86b45e2e51a34ccf5018d04", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "psr/log": "^1|^2|^3", + "symfony/http-client-contracts": "^1.1.10|^2", + "symfony/polyfill-php73": "^1.11", + "symfony/polyfill-php80": "^1.16", + "symfony/service-contracts": "^1.0|^2" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "1.1|2.0" + }, + "require-dev": { + "guzzlehttp/promises": "^1.4", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/dependency-injection": "^4.3|^5.0", + "symfony/http-kernel": "^4.4.13", + "symfony/process": "^4.2|^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-client/tree/v4.4.42" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-17T14:14:05+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v1.1.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "59f37624a82635962f04c98f31aed122e539a89e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/59f37624a82635962f04c98f31aed122e539a89e", + "reference": "59f37624a82635962f04c98f31aed122e539a89e", + "shasum": "" + }, + "require": { + "php": ">=7.1.3" + }, + "suggest": { + "symfony/http-client-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.1-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v1.1.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-04-11T14:52:04+00:00" + }, + { + "name": "symfony/mime", + "version": "v4.4.43", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "de46889e8844d8327677582950bd227273d8f2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/de46889e8844d8327677582950bd227273d8f2f3", + "reference": "de46889e8844d8327677582950bd227273d8f2f3", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php80": "^1.16" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "symfony/mailer": "<4.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1", + "symfony/dependency-injection": "^3.4|^4.1|^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v4.4.43" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-06-01T19:35:40+00:00" + }, + { + "name": "symfony/monolog-bundle", + "version": "v2.12.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bundle.git", + "reference": "b0146bdca7ba2a65f3bbe7010423c7393b29ec3f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/b0146bdca7ba2a65f3bbe7010423c7393b29ec3f", + "reference": "b0146bdca7ba2a65f3bbe7010423c7393b29ec3f", + "shasum": "" + }, + "require": { + "monolog/monolog": "~1.18", + "php": ">=5.3.2", + "symfony/config": "~2.3|~3.0", + "symfony/dependency-injection": "~2.3|~3.0", + "symfony/http-kernel": "~2.3|~3.0", + "symfony/monolog-bridge": "~2.3|~3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8", + "symfony/console": "~2.3|~3.0", + "symfony/yaml": "~2.3|~3.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MonologBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + } + ], + "description": "Symfony MonologBundle", + "homepage": "http://symfony.com", + "keywords": [ + "log", + "logging" + ], + "time": "2017-01-02T19:04:26+00:00" + }, + { + "name": "symfony/polyfill-apcu", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-apcu.git", + "reference": "43273a33c46f9d5a08dac76859f63d6814242e81" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-apcu/zipball/43273a33c46f9d5a08dac76859f63d6814242e81", + "reference": "43273a33c46f9d5a08dac76859f63d6814242e81", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Apcu\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting apcu_* functions to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "apcu", + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-apcu/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4", + "reference": "6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "e407643d610e5f2c8a4b14189150f68934bf5e48" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/e407643d610e5f2c8a4b14189150f68934bf5e48", + "reference": "e407643d610e5f2c8a4b14189150f68934bf5e48", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "59a8d271f00dd0e4c2e518104cc7963f655a1aa8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/59a8d271f00dd0e4c2e518104cc7963f655a1aa8", + "reference": "59a8d271f00dd0e4c2e518104cc7963f655a1aa8", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "symfony/polyfill-intl-normalizer": "^1.10", + "symfony/polyfill-php72": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "219aa369ceff116e673852dce47c3a41794c14bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/219aa369ceff116e673852dce47c3a41794c14bd", + "reference": "219aa369ceff116e673852dce47c3a41794c14bd", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e", + "reference": "9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-php56", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php56.git", + "reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php56/zipball/54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675", + "reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "metapackage", + "extra": { + "branch-alias": { + "dev-main": "1.20-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 5.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, + { + "name": "symfony/polyfill-php70", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php70.git", + "reference": "5f03a781d984aae42cebd18e7912fa80f02ee644" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/5f03a781d984aae42cebd18e7912fa80f02ee644", + "reference": "5f03a781d984aae42cebd18e7912fa80f02ee644", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "metapackage", + "extra": { + "branch-alias": { + "dev-main": "1.20-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php70/tree/v1.20.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, + { + "name": "symfony/polyfill-php72", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php72.git", + "reference": "bf44a9fd41feaac72b074de600314a93e2ae78e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/bf44a9fd41feaac72b074de600314a93e2ae78e2", + "reference": "bf44a9fd41feaac72b074de600314a93e2ae78e2", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php72\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php72/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "e440d35fa0286f77fb45b79a03fedbeda9307e85" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/e440d35fa0286f77fb45b79a03fedbeda9307e85", + "reference": "e440d35fa0286f77fb45b79a03fedbeda9307e85", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php73/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "cfa0ae98841b9e461207c13ab093d76b0fa7bace" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/cfa0ae98841b9e461207c13ab093d76b0fa7bace", + "reference": "cfa0ae98841b9e461207c13ab093d76b0fa7bace", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-10T07:21:04+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v1.1.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "afa00c500c2d6aea6e3b2f4862355f507bc5ebb4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/afa00c500c2d6aea6e3b2f4862355f507bc5ebb4", + "reference": "afa00c500c2d6aea6e3b2f4862355f507bc5ebb4", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.1-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v1.1.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-27T14:01:05+00:00" + }, + { + "name": "symfony/swiftmailer-bundle", + "version": "v2.6.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/swiftmailer-bundle.git", + "reference": "c4808f5169efc05567be983909d00f00521c53ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/swiftmailer-bundle/zipball/c4808f5169efc05567be983909d00f00521c53ec", + "reference": "c4808f5169efc05567be983909d00f00521c53ec", + "shasum": "" + }, + "require": { + "php": ">=5.3.2", + "swiftmailer/swiftmailer": "~4.2|~5.0", + "symfony/config": "~2.7|~3.0", + "symfony/dependency-injection": "~2.7|~3.0", + "symfony/http-kernel": "~2.7|~3.0" + }, + "require-dev": { + "symfony/console": "~2.7|~3.0", + "symfony/framework-bundle": "~2.7|~3.0", + "symfony/phpunit-bridge": "~3.3@dev", + "symfony/yaml": "~2.7|~3.0" + }, + "suggest": { + "psr/log": "Allows logging" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\SwiftmailerBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + } + ], + "description": "Symfony SwiftmailerBundle", + "homepage": "http://symfony.com", + "support": { + "issues": "https://github.com/symfony/swiftmailer-bundle/issues", + "source": "https://github.com/symfony/swiftmailer-bundle/tree/2.6" + }, + "abandoned": "symfony/mailer", + "time": "2017-10-19T01:06:41+00:00" + }, + { + "name": "symfony/symfony", + "version": "v3.4.49", + "source": { + "type": "git", + "url": "https://github.com/symfony/symfony.git", + "reference": "ba0e346e3ad11de4a307fe4fa2452a3656dcc17b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/symfony/zipball/ba0e346e3ad11de4a307fe4fa2452a3656dcc17b", + "reference": "ba0e346e3ad11de4a307fe4fa2452a3656dcc17b", + "shasum": "" + }, + "require": { + "doctrine/common": "~2.4", + "ext-xml": "*", + "fig/link-util": "^1.0", + "php": "^5.5.9|>=7.0.8", + "psr/cache": "~1.0", + "psr/container": "^1.0", + "psr/link": "^1.0", + "psr/log": "~1.0", + "psr/simple-cache": "^1.0", + "symfony/polyfill-apcu": "~1.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php56": "~1.0", + "symfony/polyfill-php70": "~1.6", + "twig/twig": "^1.41|^2.10" + }, + "conflict": { + "monolog/monolog": ">=2", + "phpdocumentor/reflection-docblock": "<3.0||>=3.2.0,<3.2.2", + "phpdocumentor/type-resolver": "<0.3.0", + "phpunit/phpunit": "<4.8.35|<5.4.3,>=5.0" + }, + "provide": { + "psr/cache-implementation": "1.0", + "psr/container-implementation": "1.0", + "psr/log-implementation": "1.0", + "psr/simple-cache-implementation": "1.0" + }, + "replace": { + "symfony/asset": "self.version", + "symfony/browser-kit": "self.version", + "symfony/cache": "self.version", + "symfony/class-loader": "self.version", + "symfony/config": "self.version", + "symfony/console": "self.version", + "symfony/css-selector": "self.version", + "symfony/debug": "self.version", + "symfony/debug-bundle": "self.version", + "symfony/dependency-injection": "self.version", + "symfony/doctrine-bridge": "self.version", + "symfony/dom-crawler": "self.version", + "symfony/dotenv": "self.version", + "symfony/event-dispatcher": "self.version", + "symfony/expression-language": "self.version", + "symfony/filesystem": "self.version", + "symfony/finder": "self.version", + "symfony/form": "self.version", + "symfony/framework-bundle": "self.version", + "symfony/http-foundation": "self.version", + "symfony/http-kernel": "self.version", + "symfony/inflector": "self.version", + "symfony/intl": "self.version", + "symfony/ldap": "self.version", + "symfony/lock": "self.version", + "symfony/monolog-bridge": "self.version", + "symfony/options-resolver": "self.version", + "symfony/process": "self.version", + "symfony/property-access": "self.version", + "symfony/property-info": "self.version", + "symfony/proxy-manager-bridge": "self.version", + "symfony/routing": "self.version", + "symfony/security": "self.version", + "symfony/security-bundle": "self.version", + "symfony/security-core": "self.version", + "symfony/security-csrf": "self.version", + "symfony/security-guard": "self.version", + "symfony/security-http": "self.version", + "symfony/serializer": "self.version", + "symfony/stopwatch": "self.version", + "symfony/templating": "self.version", + "symfony/translation": "self.version", + "symfony/twig-bridge": "self.version", + "symfony/twig-bundle": "self.version", + "symfony/validator": "self.version", + "symfony/var-dumper": "self.version", + "symfony/web-link": "self.version", + "symfony/web-profiler-bundle": "self.version", + "symfony/web-server-bundle": "self.version", + "symfony/workflow": "self.version", + "symfony/yaml": "self.version" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/annotations": "~1.0", + "doctrine/cache": "~1.6", + "doctrine/data-fixtures": "^1.1", + "doctrine/dbal": "~2.4", + "doctrine/doctrine-bundle": "~1.4", + "doctrine/orm": "~2.4,>=2.4.5", + "egulias/email-validator": "~1.2,>=1.2.8|~2.0", + "monolog/monolog": "~1.11", + "ocramius/proxy-manager": "~0.4|~1.0|~2.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0", + "predis/predis": "~1.0", + "symfony/phpunit-bridge": "^5.2", + "symfony/security-acl": "~2.8|~3.0" + }, + "type": "library", + "extra": { + "branch-version": "3.4" + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\": "src/Symfony/Bundle/", + "Symfony\\Component\\": "src/Symfony/Component/", + "Symfony\\Bridge\\Twig\\": "src/Symfony/Bridge/Twig/", + "Symfony\\Bridge\\Monolog\\": "src/Symfony/Bridge/Monolog/", + "Symfony\\Bridge\\Doctrine\\": "src/Symfony/Bridge/Doctrine/", + "Symfony\\Bridge\\ProxyManager\\": "src/Symfony/Bridge/ProxyManager/" + }, + "classmap": [ + "src/Symfony/Component/Intl/Resources/stubs" + ], + "exclude-from-classmap": [ + "**/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "The Symfony PHP framework", + "homepage": "https://symfony.com", + "keywords": [ + "framework" + ], + "support": { + "issues": "https://github.com/symfony/symfony/issues", + "source": "https://github.com/symfony/symfony/tree/v3.4.49" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-05-19T12:07:19+00:00" + }, + { + "name": "tedivm/jshrink", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/tedious/JShrink.git", + "reference": "0513ba1407b1f235518a939455855e6952a48bbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tedious/JShrink/zipball/0513ba1407b1f235518a939455855e6952a48bbc", + "reference": "0513ba1407b1f235518a939455855e6952a48bbc", + "shasum": "" + }, + "require": { + "php": "^5.6|^7.0|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.8", + "php-coveralls/php-coveralls": "^1.1.0", + "phpunit/phpunit": "^6" + }, + "type": "library", + "autoload": { + "psr-0": { + "JShrink": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Robert Hafner", + "email": "tedivm@tedivm.com" + } + ], + "description": "Javascript Minifier built in PHP", + "homepage": "http://github.com/tedious/JShrink", + "keywords": [ + "javascript", + "minifier" + ], + "support": { + "issues": "https://github.com/tedious/JShrink/issues", + "source": "https://github.com/tedious/JShrink/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/tedivm/jshrink", + "type": "tidelift" + } + ], + "time": "2020-11-30T18:10:21+00:00" + }, + { + "name": "twig/twig", + "version": "v2.15.1", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "3b7cedb2f736899a7dbd0ba3d6da335a015f5cc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/3b7cedb2f736899a7dbd0ba3d6da335a015f5cc4", + "reference": "3b7cedb2f736899a7dbd0ba3d6da335a015f5cc4", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3", + "symfony/polyfill-php72": "^1.8" + }, + "require-dev": { + "psr/container": "^1.0", + "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.15-dev" + } + }, + "autoload": { + "psr-0": { + "Twig_": "lib/" + }, + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v2.15.1" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2022-05-17T05:46:24+00:00" + }, + { + "name": "websharks/css-minifier", + "version": "150820", + "source": { + "type": "git", + "url": "https://github.com/websharks/css-minifier.git", + "reference": "da1d0254c41e1f59c7337aa444d743e6056046ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/websharks/css-minifier/zipball/da1d0254c41e1f59c7337aa444d743e6056046ff", + "reference": "da1d0254c41e1f59c7337aa444d743e6056046ff", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=5.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "WebSharks\\CssMinifier\\": "src/includes/classes" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0+" + ], + "authors": [ + { + "name": "websharks", + "homepage": "http://websharks-inc.com/", + "role": "company" + }, + { + "name": "jaswsinc", + "homepage": "http://jaswsinc.com/", + "role": "developer" + }, + { + "name": "raamdev", + "homepage": "http://raam.org/", + "role": "developer" + } + ], + "description": "Compresses CSS.", + "homepage": "https://github.com/websharks/css-minifier", + "keywords": [ + "compressor", + "css", + "websharks" + ], + "time": "2015-08-21T03:19:25+00:00" + }, + { + "name": "wyrihaximus/html-compress", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/WyriHaximus/HtmlCompress.git", + "reference": "0c2207ceaa711b93e1d33551cbb7b1ba6cd1e975" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WyriHaximus/HtmlCompress/zipball/0c2207ceaa711b93e1d33551cbb7b1ba6cd1e975", + "reference": "0c2207ceaa711b93e1d33551cbb7b1ba6cd1e975", + "shasum": "" + }, + "require": { + "jalle19/php-yui-compressor": "^1.0", + "linkorb/jsmin-php": "1.0.0", + "matthiasmullie/minify": "^1.3", + "meenie/javascript-packer": "1.1", + "natxet/cssmin": "^3.0", + "patchwork/jsqueeze": "~1.0|~2.0", + "php": "^7.0 || ^5.6", + "tedivm/jshrink": "^1.3", + "websharks/css-minifier": "150820" + }, + "require-dev": { + "phake/phake": "^1.0", + "phpunit/phpunit": "^4.0", + "squizlabs/php_codesniffer": "^1.0", + "vectorface/dunit": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "WyriHaximus\\HtmlCompress\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com", + "homepage": "http://wyrihaximus.net/" + } + ], + "description": "Compress/minify your HTML", + "keywords": [ + "compress", + "html" + ], + "support": { + "issues": "https://github.com/WyriHaximus/HtmlCompress/issues", + "source": "https://github.com/WyriHaximus/HtmlCompress/tree/master" + }, + "time": "2018-01-03T21:15:44+00:00" + }, + { + "name": "zendframework/zend-code", + "version": "3.4.1", + "source": { + "type": "git", + "url": "https://github.com/zendframework/zend-code.git", + "reference": "268040548f92c2bfcba164421c1add2ba43abaaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zendframework/zend-code/zipball/268040548f92c2bfcba164421c1add2ba43abaaa", + "reference": "268040548f92c2bfcba164421c1add2ba43abaaa", + "shasum": "" + }, + "require": { + "php": "^7.1", + "zendframework/zend-eventmanager": "^2.6 || ^3.0" + }, + "conflict": { + "phpspec/prophecy": "<1.9.0" + }, + "require-dev": { + "doctrine/annotations": "^1.7", + "ext-phar": "*", + "phpunit/phpunit": "^7.5.16 || ^8.4", + "zendframework/zend-coding-standard": "^1.0", + "zendframework/zend-stdlib": "^2.7 || ^3.0" + }, + "suggest": { + "doctrine/annotations": "Doctrine\\Common\\Annotations >=1.0 for annotation features", + "zendframework/zend-stdlib": "Zend\\Stdlib component" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.4.x-dev", + "dev-develop": "3.5.x-dev", + "dev-dev-4.0": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Zend\\Code\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Extensions to the PHP Reflection API, static code scanning, and code generation", + "keywords": [ + "ZendFramework", + "code", + "zf" + ], + "support": { + "chat": "https://zendframework-slack.herokuapp.com", + "docs": "https://docs.zendframework.com/zend-code/", + "forum": "https://discourse.zendframework.com/c/questions/components", + "issues": "https://github.com/zendframework/zend-code/issues", + "rss": "https://github.com/zendframework/zend-code/releases.atom", + "source": "https://github.com/zendframework/zend-code" + }, + "abandoned": "laminas/laminas-code", + "time": "2019-12-10T19:21:15+00:00" + }, + { + "name": "zendframework/zend-eventmanager", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/zendframework/zend-eventmanager.git", + "reference": "a5e2583a211f73604691586b8406ff7296a946dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zendframework/zend-eventmanager/zipball/a5e2583a211f73604691586b8406ff7296a946dd", + "reference": "a5e2583a211f73604691586b8406ff7296a946dd", + "shasum": "" + }, + "require": { + "php": "^5.6 || ^7.0" + }, + "require-dev": { + "athletic/athletic": "^0.1", + "container-interop/container-interop": "^1.1.0", + "phpunit/phpunit": "^5.7.27 || ^6.5.8 || ^7.1.2", + "zendframework/zend-coding-standard": "~1.0.0", + "zendframework/zend-stdlib": "^2.7.3 || ^3.0" + }, + "suggest": { + "container-interop/container-interop": "^1.1.0, to use the lazy listeners feature", + "zendframework/zend-stdlib": "^2.7.3 || ^3.0, to use the FilterChain feature" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev", + "dev-develop": "3.3-dev" + } + }, + "autoload": { + "psr-4": { + "Zend\\EventManager\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Trigger and listen to events within a PHP application", + "homepage": "https://github.com/zendframework/zend-eventmanager", + "keywords": [ + "event", + "eventmanager", + "events", + "zf2" + ], + "support": { + "issues": "https://github.com/zendframework/zend-eventmanager/issues", + "source": "https://github.com/zendframework/zend-eventmanager/tree/master" + }, + "abandoned": "laminas/laminas-eventmanager", + "time": "2018-04-25T15:33:34+00:00" + } + ], + "packages-dev": [ + { + "name": "behat/behat", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Behat.git", + "reference": "08052f739619a9e9f62f457a67302f0715e6dd13" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Behat/zipball/08052f739619a9e9f62f457a67302f0715e6dd13", + "reference": "08052f739619a9e9f62f457a67302f0715e6dd13", + "shasum": "" + }, + "require": { + "behat/gherkin": "^4.6.0", + "behat/transliterator": "^1.2", + "ext-mbstring": "*", + "php": ">=5.3.3", + "psr/container": "^1.0", + "symfony/config": "^2.7.51 || ^3.0 || ^4.0 || ^5.0", + "symfony/console": "^2.7.51 || ^2.8.33 || ^3.3.15 || ^3.4.3 || ^4.0.3 || ^5.0", + "symfony/dependency-injection": "^2.7.51 || ^3.0 || ^4.0 || ^5.0", + "symfony/event-dispatcher": "^2.7.51 || ^3.0 || ^4.0 || ^5.0", + "symfony/translation": "^2.7.51 || ^3.0 || ^4.0 || ^5.0", + "symfony/yaml": "^2.7.51 || ^3.0 || ^4.0 || ^5.0" + }, + "require-dev": { + "container-interop/container-interop": "^1.2", + "herrera-io/box": "~1.6.1", + "phpunit/phpunit": "^4.8.36 || ^6.5.14 || ^7.5.20", + "symfony/process": "~2.5 || ^3.0 || ^4.0 || ^5.0" + }, + "suggest": { + "ext-dom": "Needed to output test results in JUnit format." + }, + "bin": [ + "bin/behat" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Behat\\": "src/Behat/Behat/", + "Behat\\Testwork\\": "src/Behat/Testwork/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Scenario-oriented BDD framework for PHP 5.3", + "homepage": "http://behat.org/", + "keywords": [ + "Agile", + "BDD", + "ScenarioBDD", + "Scrum", + "StoryBDD", + "User story", + "business", + "development", + "documentation", + "examples", + "symfony", + "testing" + ], + "support": { + "issues": "https://github.com/Behat/Behat/issues", + "source": "https://github.com/Behat/Behat/tree/v3.7.0" + }, + "time": "2020-06-03T13:08:44+00:00" + }, + { + "name": "behat/gherkin", + "version": "v4.9.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Gherkin.git", + "reference": "0bc8d1e30e96183e4f36db9dc79caead300beff4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Gherkin/zipball/0bc8d1e30e96183e4f36db9dc79caead300beff4", + "reference": "0bc8d1e30e96183e4f36db9dc79caead300beff4", + "shasum": "" + }, + "require": { + "php": "~7.2|~8.0" + }, + "require-dev": { + "cucumber/cucumber": "dev-gherkin-22.0.0", + "phpunit/phpunit": "~8|~9", + "symfony/yaml": "~3|~4|~5" + }, + "suggest": { + "symfony/yaml": "If you want to parse features, represented in YAML files" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-0": { + "Behat\\Gherkin": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Gherkin DSL parser for PHP", + "homepage": "http://behat.org/", + "keywords": [ + "BDD", + "Behat", + "Cucumber", + "DSL", + "gherkin", + "parser" + ], + "support": { + "issues": "https://github.com/Behat/Gherkin/issues", + "source": "https://github.com/Behat/Gherkin/tree/v4.9.0" + }, + "time": "2021-10-12T13:05:09+00:00" + }, + { + "name": "behat/symfony2-extension", + "version": "2.1.5", + "source": { + "type": "git", + "url": "https://github.com/Behat/Symfony2Extension.git", + "reference": "d7c834487426a784665f9c1e61132274dbf2ea26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Symfony2Extension/zipball/d7c834487426a784665f9c1e61132274dbf2ea26", + "reference": "d7c834487426a784665f9c1e61132274dbf2ea26", + "shasum": "" + }, + "require": { + "behat/behat": "^3.4.3", + "php": ">=5.3.3", + "symfony/framework-bundle": "~2.0|~3.0|~4.0" + }, + "require-dev": { + "behat/mink": "~1.7@dev", + "behat/mink-browserkit-driver": "~1.3@dev", + "behat/mink-extension": "~2.0", + "phpspec/phpspec": "~2.0|~3.0|~4.0", + "phpunit/phpunit": "~4.0|~5.0", + "symfony/symfony": "~2.1|~3.0|~4.0" + }, + "type": "behat-extension", + "extra": { + "branch-alias": { + "dev-master": "2.1.x-dev" + } + }, + "autoload": { + "psr-0": { + "Behat\\Symfony2Extension": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christophe Coevoet", + "email": "stof@notk.org" + }, + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com" + } + ], + "description": "Symfony2 framework extension for Behat", + "homepage": "http://behat.org", + "keywords": [ + "BDD", + "framework", + "symfony" + ], + "support": { + "issues": "https://github.com/Behat/Symfony2Extension/issues", + "source": "https://github.com/Behat/Symfony2Extension/tree/master" + }, + "abandoned": "friends-of-behat/symfony-extension", + "time": "2018-04-20T15:48:23+00:00" + }, + { + "name": "behat/transliterator", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Transliterator.git", + "reference": "baac5873bac3749887d28ab68e2f74db3a4408af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Transliterator/zipball/baac5873bac3749887d28ab68e2f74db3a4408af", + "reference": "baac5873bac3749887d28ab68e2f74db3a4408af", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "chuyskywalker/rolling-curl": "^3.1", + "php-yaoi/php-yaoi": "^1.0", + "phpunit/phpunit": "^8.5.25 || ^9.5.19" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Transliterator\\": "src/Behat/Transliterator" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Artistic-1.0" + ], + "description": "String transliterator", + "keywords": [ + "i18n", + "slug", + "transliterator" + ], + "support": { + "issues": "https://github.com/Behat/Transliterator/issues", + "source": "https://github.com/Behat/Transliterator/tree/v1.5.0" + }, + "time": "2022-03-30T09:27:43+00:00" + }, + { + "name": "coduo/php-matcher", + "version": "2.1.x-dev", + "source": { + "type": "git", + "url": "https://github.com/coduo/php-matcher.git", + "reference": "85b4b3ab225261ba5ca6ae802edb0183f8bd1eb4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/coduo/php-matcher/zipball/85b4b3ab225261ba5ca6ae802edb0183f8bd1eb4", + "reference": "85b4b3ab225261ba5ca6ae802edb0183f8bd1eb4", + "shasum": "" + }, + "require": { + "coduo/php-to-string": "^2", + "doctrine/lexer": "1.0.*", + "ext-filter": "*", + "openlss/lib-array2xml": "~0.0.9", + "php": ">=5.3.0", + "symfony/expression-language": "^2.3|^3.0", + "symfony/property-access": "^2.3|^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "psr-4": { + "Coduo\\PHPMatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Norbert Orzechowicz", + "email": "norbert@orzechowicz.pl" + }, + { + "name": "Michał Dąbrowski", + "email": "dabrowski@brillante.pl" + } + ], + "description": "PHP Matcher enables you to match values with patterns", + "keywords": [ + "Match", + "json", + "matcher", + "tests" + ], + "time": "2017-07-20T10:19:34+00:00" + }, + { + "name": "coduo/php-to-string", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/coduo/php-to-string.git", + "reference": "b3f2aa97055ea62ed6c63fae0db33454b47f1a5d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/coduo/php-to-string/zipball/b3f2aa97055ea62ed6c63fae0db33454b47f1a5d", + "reference": "b3f2aa97055ea62ed6c63fae0db33454b47f1a5d", + "shasum": "" + }, + "require": { + "ext-intl": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "coduo/phpspec-data-provider-extension": "^1", + "phpspec/phpspec": "^2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "psr-0": { + "": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michał Dąbrowski", + "email": "dabrowski@brillante.pl" + }, + { + "name": "Norbert Orzechowicz", + "email": "norbert@orzechowicz.pl" + } + ], + "keywords": [ + "php", + "string", + "to", + "to string" + ], + "support": { + "issues": "https://github.com/coduo/php-to-string/issues", + "source": "https://github.com/coduo/php-to-string/tree/master" + }, + "time": "2019-08-09T13:19:26+00:00" + }, + { + "name": "doctrine/data-fixtures", + "version": "1.5.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/data-fixtures.git", + "reference": "ba37bfb776de763c5bf04a36d074cd5f5a083c42" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/ba37bfb776de763c5bf04a36d074cd5f5a083c42", + "reference": "ba37bfb776de763c5bf04a36d074cd5f5a083c42", + "shasum": "" + }, + "require": { + "doctrine/common": "^2.13|^3.0", + "doctrine/persistence": "^1.3.3|^2.0|^3.0", + "php": "^7.2 || ^8.0" + }, + "conflict": { + "doctrine/dbal": "<2.13", + "doctrine/phpcr-odm": "<1.3.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9.0", + "doctrine/dbal": "^2.13 || ^3.0", + "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0", + "doctrine/orm": "^2.7.0", + "ext-sqlite3": "*", + "jangregor/phpstan-prophecy": "^1", + "phpstan/phpstan": "^1.5", + "phpunit/phpunit": "^8.5 || ^9.5", + "symfony/cache": "^5.0 || ^6.0", + "vimeo/psalm": "^4.10" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)", + "doctrine/mongodb-odm": "For loading MongoDB ODM fixtures", + "doctrine/orm": "For loading ORM fixtures", + "doctrine/phpcr-odm": "For loading PHPCR ODM fixtures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\DataFixtures\\": "lib/Doctrine/Common/DataFixtures" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Data Fixtures for all Doctrine Object Managers", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "database" + ], + "support": { + "issues": "https://github.com/doctrine/data-fixtures/issues", + "source": "https://github.com/doctrine/data-fixtures/tree/1.5.3" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures", + "type": "tidelift" + } + ], + "time": "2022-04-19T10:01:44+00:00" + }, + { + "name": "doctrine/doctrine-fixtures-bundle", + "version": "v2.4.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineFixturesBundle.git", + "reference": "74b8cc70a4a25b774628ee59f4cdf3623a146273" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/74b8cc70a4a25b774628ee59f4cdf3623a146273", + "reference": "74b8cc70a4a25b774628ee59f4cdf3623a146273", + "shasum": "" + }, + "require": { + "doctrine/data-fixtures": "~1.0", + "doctrine/doctrine-bundle": "~1.0", + "php": ">=5.3.2", + "symfony/doctrine-bridge": "~2.7|~3.0|~4.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\FixturesBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + }, + { + "name": "Doctrine Project", + "homepage": "http://www.doctrine-project.org" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + } + ], + "description": "Symfony DoctrineFixturesBundle", + "homepage": "http://www.doctrine-project.org", + "keywords": [ + "Fixture", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues", + "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/master" + }, + "time": "2017-10-30T19:26:42+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.11.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/14daed4296fae74d9e3201d2c4925d1acb7aa614", + "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3,<3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.11.0" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2022-03-03T13:19:32+00:00" + }, + { + "name": "openlss/lib-array2xml", + "version": "0.0.10", + "source": { + "type": "git", + "url": "https://github.com/openlss/lib-array2xml.git", + "reference": "f6686668959a342ec326c5ad82ac557d767f34ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/openlss/lib-array2xml/zipball/f6686668959a342ec326c5ad82ac557d767f34ef", + "reference": "f6686668959a342ec326c5ad82ac557d767f34ef", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "type": "library", + "autoload": { + "psr-0": { + "LSS": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Bryan Tong", + "email": "contact@nullivex.com", + "homepage": "http://bryantong.com" + }, + { + "name": "Tony Butler", + "email": "spudz76@gmail.com", + "homepage": "http://openlss.org" + } + ], + "description": "Array2XML conversion library credit to lalit.org", + "homepage": "http://openlss.org", + "keywords": [ + "array", + "array conversion", + "xml", + "xml conversion" + ], + "time": "2015-09-16T18:59:23+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.3.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "622548b623e81ca6d78b721c5e029f4ce664f170" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/622548b623e81ca6d78b721c5e029f4ce664f170", + "reference": "622548b623e81ca6d78b721c5e029f4ce664f170", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^7.2 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.3", + "webmozart/assert": "^1.9.1" + }, + "require-dev": { + "mockery/mockery": "~1.3.2", + "psalm/phar": "^4.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "account@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.3.0" + }, + "time": "2021-10-19T17:43:47+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.6.1", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "77a32518733312af16a44300404e945338981de3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/77a32518733312af16a44300404e945338981de3", + "reference": "77a32518733312af16a44300404e945338981de3", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "phpdocumentor/reflection-common": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "psalm/phar": "^4.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.6.1" + }, + "time": "2022-03-15T21:29:03+00:00" + }, + { + "name": "phpspec/prophecy", + "version": "v1.10.3", + "source": { + "type": "git", + "url": "https://github.com/phpspec/prophecy.git", + "reference": "451c3cd1418cf640de218914901e51b064abb093" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/451c3cd1418cf640de218914901e51b064abb093", + "reference": "451c3cd1418cf640de218914901e51b064abb093", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.2", + "php": "^5.3|^7.0", + "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0", + "sebastian/comparator": "^1.2.3|^2.0|^3.0|^4.0", + "sebastian/recursion-context": "^1.0|^2.0|^3.0|^4.0" + }, + "require-dev": { + "phpspec/phpspec": "^2.5 || ^3.2", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10.x-dev" + } + }, + "autoload": { + "psr-4": { + "Prophecy\\": "src/Prophecy" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + }, + { + "name": "Marcello Duarte", + "email": "marcello.duarte@gmail.com" + } + ], + "description": "Highly opinionated mocking framework for PHP 5.3+", + "homepage": "https://github.com/phpspec/prophecy", + "keywords": [ + "Double", + "Dummy", + "fake", + "mock", + "spy", + "stub" + ], + "support": { + "issues": "https://github.com/phpspec/prophecy/issues", + "source": "https://github.com/phpspec/prophecy/tree/v1.10.3" + }, + "time": "2020-03-05T15:02:03+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "4.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "ef7b2f56815df854e66ceaee8ebe9393ae36a40d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/ef7b2f56815df854e66ceaee8ebe9393ae36a40d", + "reference": "ef7b2f56815df854e66ceaee8ebe9393ae36a40d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xmlwriter": "*", + "php": "^5.6 || ^7.0", + "phpunit/php-file-iterator": "^1.3", + "phpunit/php-text-template": "^1.2", + "phpunit/php-token-stream": "^1.4.2 || ^2.0", + "sebastian/code-unit-reverse-lookup": "^1.0", + "sebastian/environment": "^1.3.2 || ^2.0", + "sebastian/version": "^1.0 || ^2.0" + }, + "require-dev": { + "ext-xdebug": "^2.1.4", + "phpunit/phpunit": "^5.7" + }, + "suggest": { + "ext-xdebug": "^2.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "time": "2017-04-02T07:44:40+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "1.4.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/730b01bc3e867237eaac355e06a36b85dd93a8b4", + "reference": "730b01bc3e867237eaac355e06a36b85dd93a8b4", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "irc": "irc://irc.freenode.net/phpunit", + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/1.4.5" + }, + "time": "2017-11-27T13:52:08+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "time": "2015-06-21T13:50:34+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "1.0.9", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", + "reference": "3dcf38ca72b158baf0bc245e9184d3fdffa9c46f", + "shasum": "" + }, + "require": { + "php": "^5.3.3 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "time": "2017-02-26T11:10:40+00:00" + }, + { + "name": "phpunit/php-token-stream", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-token-stream.git", + "reference": "791198a2c6254db10131eecfe8c06670700904db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/791198a2c6254db10131eecfe8c06670700904db", + "reference": "791198a2c6254db10131eecfe8c06670700904db", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.2.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Wrapper around PHP's tokenizer extension.", + "homepage": "https://github.com/sebastianbergmann/php-token-stream/", + "keywords": [ + "tokenizer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-token-stream/issues", + "source": "https://github.com/sebastianbergmann/php-token-stream/tree/master" + }, + "abandoned": true, + "time": "2017-11-27T05:48:46+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "5.7.27", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "b7803aeca3ccb99ad0a506fa80b64cd6a56bbc0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b7803aeca3ccb99ad0a506fa80b64cd6a56bbc0c", + "reference": "b7803aeca3ccb99ad0a506fa80b64cd6a56bbc0c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "myclabs/deep-copy": "~1.3", + "php": "^5.6 || ^7.0", + "phpspec/prophecy": "^1.6.2", + "phpunit/php-code-coverage": "^4.0.4", + "phpunit/php-file-iterator": "~1.4", + "phpunit/php-text-template": "~1.2", + "phpunit/php-timer": "^1.0.6", + "phpunit/phpunit-mock-objects": "^3.2", + "sebastian/comparator": "^1.2.4", + "sebastian/diff": "^1.4.3", + "sebastian/environment": "^1.3.4 || ^2.0", + "sebastian/exporter": "~2.0", + "sebastian/global-state": "^1.1", + "sebastian/object-enumerator": "~2.0", + "sebastian/resource-operations": "~1.0", + "sebastian/version": "^1.0.6|^2.0.1", + "symfony/yaml": "~2.1|~3.0|~4.0" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "3.0.2" + }, + "require-dev": { + "ext-pdo": "*" + }, + "suggest": { + "ext-xdebug": "*", + "phpunit/php-invoker": "~1.1" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.7.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "source": "https://github.com/sebastianbergmann/phpunit/tree/5.7.27" + }, + "time": "2018-02-01T05:50:59+00:00" + }, + { + "name": "phpunit/phpunit-mock-objects", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", + "reference": "a23b761686d50a560cc56233b9ecf49597cc9118" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/a23b761686d50a560cc56233b9ecf49597cc9118", + "reference": "a23b761686d50a560cc56233b9ecf49597cc9118", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.2", + "php": "^5.6 || ^7.0", + "phpunit/php-text-template": "^1.2", + "sebastian/exporter": "^1.2 || ^2.0" + }, + "conflict": { + "phpunit/phpunit": "<5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^5.4" + }, + "suggest": { + "ext-soap": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "Mock Object library for PHPUnit", + "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", + "keywords": [ + "mock", + "xunit" + ], + "abandoned": true, + "time": "2017-06-30T09:13:00+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "1de8cd5c010cb153fcd68b8d0f64606f523f7619" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/1de8cd5c010cb153fcd68b8d0f64606f523f7619", + "reference": "1de8cd5c010cb153fcd68b8d0f64606f523f7619", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/1.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-11-30T08:15:22+00:00" + }, + { + "name": "sebastian/comparator", + "version": "1.2.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2b7424b55f5047b47ac6e5ccb20b2aea4011d9be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2b7424b55f5047b47ac6e5ccb20b2aea4011d9be", + "reference": "2b7424b55f5047b47ac6e5ccb20b2aea4011d9be", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "sebastian/diff": "~1.2", + "sebastian/exporter": "~1.2 || ~2.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "http://www.github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "time": "2017-01-29T09:50:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "1.4.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7f066a26a962dbe58ddea9f72a4e82874a3975a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7f066a26a962dbe58ddea9f72a4e82874a3975a4", + "reference": "7f066a26a962dbe58ddea9f72a4e82874a3975a4", + "shasum": "" + }, + "require": { + "php": "^5.3.3 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff" + ], + "time": "2017-05-22T07:24:03+00:00" + }, + { + "name": "sebastian/environment", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "5795ffe5dc5b02460c3e34222fee8cbe245d8fac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/5795ffe5dc5b02460c3e34222fee8cbe245d8fac", + "reference": "5795ffe5dc5b02460c3e34222fee8cbe245d8fac", + "shasum": "" + }, + "require": { + "php": "^5.6 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "time": "2016-11-26T07:53:53+00:00" + }, + { + "name": "sebastian/exporter", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "ce474bdd1a34744d7ac5d6aad3a46d48d9bac4c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ce474bdd1a34744d7ac5d6aad3a46d48d9bac4c4", + "reference": "ce474bdd1a34744d7ac5d6aad3a46d48d9bac4c4", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "sebastian/recursion-context": "~2.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "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" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "http://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "time": "2016-11-19T08:54:04+00:00" + }, + { + "name": "sebastian/global-state", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bc37d50fea7d017d3d340f230811c9f1d7280af4", + "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.2" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "time": "2015-10-12T03:26:01+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1311872ac850040a79c3c058bea3e22d0f09cbb7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1311872ac850040a79c3c058bea3e22d0f09cbb7", + "reference": "1311872ac850040a79c3c058bea3e22d0f09cbb7", + "shasum": "" + }, + "require": { + "php": ">=5.6", + "sebastian/recursion-context": "~2.0" + }, + "require-dev": { + "phpunit/phpunit": "~5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "time": "2017-02-18T15:18:39+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "2c3ba150cbec723aa057506e73a8d33bdb286c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/2c3ba150cbec723aa057506e73a8d33bdb286c9a", + "reference": "2c3ba150cbec723aa057506e73a8d33bdb286c9a", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "time": "2016-11-19T07:33:16+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", + "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", + "shasum": "" + }, + "require": { + "php": ">=5.6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "time": "2015-07-28T20:34:47+00:00" + }, + { + "name": "sebastian/version", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/99732be0ddb3361e16ad77b68ba41efc8e979019", + "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "time": "2016-10-03T07:35:21+00:00" + }, + { + "name": "sensio/generator-bundle", + "version": "v3.1.7", + "source": { + "type": "git", + "url": "https://github.com/sensiolabs/SensioGeneratorBundle.git", + "reference": "28cbaa244bd0816fd8908b93f90380bcd7b67a65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sensiolabs/SensioGeneratorBundle/zipball/28cbaa244bd0816fd8908b93f90380bcd7b67a65", + "reference": "28cbaa244bd0816fd8908b93f90380bcd7b67a65", + "shasum": "" + }, + "require": { + "symfony/console": "~2.7|~3.0", + "symfony/framework-bundle": "~2.7|~3.0", + "symfony/process": "~2.7|~3.0", + "symfony/yaml": "~2.7|~3.0", + "twig/twig": "^1.28.2|^2.0" + }, + "require-dev": { + "doctrine/orm": "~2.4", + "symfony/doctrine-bridge": "~2.7|~3.0", + "symfony/filesystem": "~2.7|~3.0", + "symfony/phpunit-bridge": "^3.3" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "3.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sensio\\Bundle\\GeneratorBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + } + ], + "description": "This bundle generates code for you", + "support": { + "issues": "https://github.com/sensiolabs/SensioGeneratorBundle/issues", + "source": "https://github.com/sensiolabs/SensioGeneratorBundle/tree/master" + }, + "abandoned": "symfony/maker-bundle", + "time": "2017-12-07T15:36:41+00:00" + }, + { + "name": "symfony/phpunit-bridge", + "version": "v3.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/phpunit-bridge.git", + "reference": "120273ad5d03a8deee08ca9260e2598f288f2bac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/120273ad5d03a8deee08ca9260e2598f288f2bac", + "reference": "120273ad5d03a8deee08ca9260e2598f288f2bac", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "conflict": { + "phpunit/phpunit": "<4.8.35|<5.4.3,>=5.0|<6.4,>=6.0|9.1.2" + }, + "suggest": { + "symfony/debug": "For tracking deprecated interfaces usages at runtime with DebugClassLoader" + }, + "bin": [ + "bin/simple-phpunit" + ], + "type": "symfony-bridge", + "extra": { + "thanks": { + "name": "phpunit/phpunit", + "url": "https://github.com/sebastianbergmann/phpunit" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Bridge\\PhpUnit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony PHPUnit Bridge", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/phpunit-bridge/tree/v3.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-11-13T16:28:59+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.11.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": "^7.2 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<4.6.1 || 4.6.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.11.0" + }, + "time": "2022-06-03T18:03:27+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": { + "friendsofsymfony/user-bundle": 20, + "coduo/php-matcher": 20 + }, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=7.2" + }, + "platform-dev": [], + "platform-overrides": { + "php": "7.2" + }, + "plugin-api-version": "1.1.0" +} diff --git a/configuration/crontab b/configuration/crontab new file mode 100644 index 0000000..baf1d6a --- /dev/null +++ b/configuration/crontab @@ -0,0 +1,9 @@ +CMD=/bin/console --env=stage + +* * * * * $CMD socialhose:notification:start -n 2>&1 > /dev/null +0 0 * * * $CMD socialhose:renew-search-limits -n 2>&1 > /dev/null +* */3 * * * $CMD socialhose:stored-query:update -n 2>&1 > /dev/null +* */3 * * * $CMD socialhose:query:remove_old -n 2>&1 > /dev/null + +59 23 * * * $CMD socialhose:downgrade-subscription-plan -n 2>&1 > /dev/null +59 23 * * * $CMD socialhose:cancel-subscription -n 2>&1 > /dev/null diff --git a/configuration/supervisord/documents_email b/configuration/supervisord/documents_email new file mode 100644 index 0000000..6942748 --- /dev/null +++ b/configuration/supervisord/documents_email @@ -0,0 +1,8 @@ +[program:documents_email] +directory={{CWD}} +command={{CWD}}/bin/console --env=stage rabbitmq:consumer documents_email +environment= +user=socialhose +autostart=true +autorestart=true +redirect_stderr=True \ No newline at end of file diff --git a/configuration/supervisord/documents_fetching b/configuration/supervisord/documents_fetching new file mode 100644 index 0000000..a81d67b --- /dev/null +++ b/configuration/supervisord/documents_fetching @@ -0,0 +1,8 @@ +[program:documents_fetching] +directory={{CWD}} +command={{CWD}}/bin/console --env=stage rabbitmq:consumer documents_fetch +environment= +user=socialhose +autostart=true +autorestart=true +redirect_stderr=True diff --git a/configuration/supervisord/notifications_fetching b/configuration/supervisord/notifications_fetching new file mode 100644 index 0000000..bb91bd3 --- /dev/null +++ b/configuration/supervisord/notifications_fetching @@ -0,0 +1,9 @@ +[program:notification_fetching] +directory={{CWD}} +command={{CWD}}/bin/console --env=stage rabbitmq:consumer notifications_fetch +environment= +user=socialhose +autostart=true +autorestart=true +redirect_stderr=True + diff --git a/configuration/supervisord/notifications_sending b/configuration/supervisord/notifications_sending new file mode 100644 index 0000000..da14122 --- /dev/null +++ b/configuration/supervisord/notifications_sending @@ -0,0 +1,8 @@ +[program:notification_sending] +directory={{CWD}} +command={{CWD}}/bin/console --env=stage rabbitmq:consumer notifications_send +environment= +user=socialhose +autostart=true +autorestart=true +redirect_stderr=True \ No newline at end of file diff --git a/deploy-aws.sh b/deploy-aws.sh new file mode 100644 index 0000000..f36fec2 --- /dev/null +++ b/deploy-aws.sh @@ -0,0 +1,8 @@ +#!/bin/bash +result=`ps aux | grep -i "deploy-aws.sh" | grep -v "grep" | wc -l` +if [ $result -ge 1 ] + then + echo "ABORT: Script already running." + else + cd /var/www/html && shopt -s extglob && echo eivuz6Ai | sudo -S rm -r !(var|vendor) +fi diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f69ab45 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,76 @@ +version: '3' + +networks: + socialhose: + +services: + + socialhose-php: + build: + context: docker/php-apache + args: + UID: ${UID} + container_name: socialhose-php + working_dir: /var/www/html + networks: + - socialhose + volumes: + - ./:/var/www/html + environment: + - APACHE_DOCUMENT_ROOT=/var/www/html/web + ports: + - "8081:80" + + socialhose-mysql: + image: mysql:5.7 + container_name: socialhose-mysql + networks: + - socialhose + volumes: + - ./docker/mysql/data:/var/lib/mysql + - ./docker/mysql/init.sql:/etc/mysql/init.sql + - ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf + environment: + - MYSQL_ROOT_PASSWORD=root + - MYSQL_DATABASE=socialhose + - MYSQL_USER=sh-user + - MYSQL_PASSWORD=root + command: --init-file /etc/mysql/init.sql + ports: + - 33066:3306 + + socialhose-rabbit: + build: docker/rabbit + container_name: socialhose-rabbit + working_dir: /var/www/html + networks: + - socialhose + volumes: + - ./:/var/www/html + + socialhose-elastic: + image: elasticsearch:5.6.9 + container_name: socialhose-elastic + working_dir: /var/www/html + networks: + - socialhose + ports: + - "9200:9200" + + socialhose-elastichq: + image: elastichq/elasticsearch-hq + container_name: socialhose-elastic-hq + working_dir: /var/www/html + networks: + - socialhose + ports: + - "5000:5000" + + socialhose-mail: + image: mailhog/mailhog + container_name: socialhose-mail + networks: + - socialhose + ports: + - "8025:8025" + - "1025:1025" \ No newline at end of file diff --git a/docker/mysql/init.sql b/docker/mysql/init.sql new file mode 100644 index 0000000..2cd7578 --- /dev/null +++ b/docker/mysql/init.sql @@ -0,0 +1,2 @@ +CREATE DATABASE IF NOT EXISTS socialhose DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE DATABASE IF NOT EXISTS socialhose_test DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/docker/php-apache/Dockerfile b/docker/php-apache/Dockerfile new file mode 100644 index 0000000..75c6ada --- /dev/null +++ b/docker/php-apache/Dockerfile @@ -0,0 +1,30 @@ +FROM webdevops/php-apache:7.4 + +WORKDIR "/var/www/html" +RUN apt update -y & apt -y dist-upgrade +RUN apt -y -f install apt-transport-https bash vim +RUN curl -sS https://downloads.mariadb.com/MariaDB/mariadb_repo_setup | /bin/bash +RUN apt -y install default-mysql-client mc acl sudo git nodejs npm cron python-pip + +RUN docker-php-ext-install bcmath + +# Create user +ARG UID +ENV USER application +#RUN adduser -q --disabled-password --gecos "" $USER --uid $UID +RUN usermod -aG sudo $USER +RUN sed -i 's/%sudo\tALL=(ALL:ALL) ALL/%sudo ALL=(ALL:ALL) NOPASSWD:ALL/g' /etc/sudoers + + + +# Add cron +COPY crontab /etc/cron.d/crontab +RUN sed -i 's/{{CWD}}/\/usr\/local\/bin\/php \/var\/www\/html\/bin\/console --env=stage/' /etc/cron.d/crontab +RUN chmod 0644 /etc/cron.d/crontab +RUN crontab /etc/cron.d/crontab +RUN touch /var/log/cron.log + +# Add supervisor +RUN pip install supervisor +RUN rmdir /app && ln -s /var/www/html/web /app +USER application \ No newline at end of file diff --git a/docker/php-apache/crontab b/docker/php-apache/crontab new file mode 100644 index 0000000..2adacc0 --- /dev/null +++ b/docker/php-apache/crontab @@ -0,0 +1,6 @@ +CMD=/bin/console --env=stage + +* * * * * {{CWD}} socialhose:notification:start 2>&1 > /dev/null +0 0 * * * {{CWD}} socialhose:renew-search-limits 2>&1 > /dev/null +*/30 * * * * {{CWD}} socialhose:stored-query:update 2>&1 > /dev/null +* */3 * * * {{CWD}} socialhose:query:remove_old 2>&1 > /dev/null diff --git a/docker/rabbit/Dockerfile b/docker/rabbit/Dockerfile new file mode 100644 index 0000000..1705863 --- /dev/null +++ b/docker/rabbit/Dockerfile @@ -0,0 +1,27 @@ +FROM rabbitmq:3.8-alpine + +RUN rabbitmq-plugins enable --offline rabbitmq_management + +# extract "rabbitmqadmin" from inside the "rabbitmq_management-X.Y.Z.ez" plugin zipfile +# see https://github.com/docker-library/rabbitmq/issues/207 +RUN set -eux; \ + erl -noinput -eval ' \ + { ok, AdminBin } = zip:foldl(fun(FileInArchive, GetInfo, GetBin, Acc) -> \ + case Acc of \ + "" -> \ + case lists:suffix("/rabbitmqadmin", FileInArchive) of \ + true -> GetBin(); \ + false -> Acc \ + end; \ + _ -> Acc \ + end \ + end, "", init:get_plain_arguments()), \ + io:format("~s", [ AdminBin ]), \ + init:stop(). \ + ' -- /plugins/rabbitmq_management-*.ez > /usr/local/bin/rabbitmqadmin; \ + [ -s /usr/local/bin/rabbitmqadmin ]; \ + chmod +x /usr/local/bin/rabbitmqadmin; \ + apk add --no-cache python3; \ + rabbitmqadmin --version + +EXPOSE 15671 15672 \ No newline at end of file diff --git a/frontend/.babelrc b/frontend/.babelrc new file mode 100644 index 0000000..d5d19f0 --- /dev/null +++ b/frontend/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["es2015", "react", "stage-0"], + "plugins": ["transform-runtime", "add-module-exports"] +} diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000..90913b4 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,30 @@ +# http://editorconfig.org + +# A special property that should be specified at the top of the file outside of +# any sections. Set to true to stop .editor config file search on current file +root = true + +[*] +# Indentation style +# Possible values - tab, space +indent_style = space + +# Indentation size in single-spaced characters +# Possible values - an integer, tab +indent_size = 2 + +# Line ending file format +# Possible values - lf, crlf, cr +end_of_line = lf + +# File character encoding +# Possible values - latin1, utf-8, utf-16be, utf-16le +charset = utf-8 + +# Denotes whether to trim whitespace at the end of lines +# Possible values - true, false +trim_trailing_whitespace = true + +# Denotes whether file should end with a newline +# Possible values - true, false +insert_final_newline = true diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 0000000..33ec151 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1,3 @@ +node_modules/** +app/index.html +config/** diff --git a/frontend/.eslintrc b/frontend/.eslintrc new file mode 100644 index 0000000..16c846c --- /dev/null +++ b/frontend/.eslintrc @@ -0,0 +1,22 @@ +{ + "parser": "babel-eslint", + "extends": ["standard", "standard-react"], + "env": { + "browser": true + }, + "globals": { + "__DEV__": false, + "__PROD__": false, + "__PLAYER_DEBUG__": false, + "__BASENAME__": false + }, + "rules": { + "semi": 0, + "spaced-comment": 0, + "brace-style": 0, + "no-trailing-spaces": 0, + "padded-blocks": 0, + "quotes": [2, "single"], + "space-before-function-paren": "off" // ignore + } +} diff --git a/frontend/.npmignore b/frontend/.npmignore new file mode 100644 index 0000000..a330746 --- /dev/null +++ b/frontend/.npmignore @@ -0,0 +1,10 @@ +app +config +server +webpack +.babelrc +.editorconfig +.eslintignore +.eslintrc +.gitignore +.npmignore diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..d92afd8 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,11 @@ +# dependencies +/node_modules + +# testing +/coverage + +# production +/build +/dist +/environments +/config \ No newline at end of file diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..92fe3c1 --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "endOfLine": "lf", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "none", + "bracketSpacing": true +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d539faa --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +# SOCIALHOSE - Front-end + +This folder is the front-end codebase for SOCIALHOSE. + +## Requirements + +- node `>=5.0.0` +- npm `^3.0.0` + +## Overview + +The project is using React 16.13.1 which supports hooks but due to early versions of ESLint and Webpack, it may not support few things. + +The frontend design follows the [ArchitectUI](https://dashboardpack.com/theme-details/architectui-dashboard-react-pro). Here are the [downloadable ZIP files](<(https://github.com/melzubeir/socialhose/issues/59#issuecomment-702164269)>) which also contain the design for RTL language. + +There is also Admin portal to manage users but it is not the part of this folder or React. + +## Getting Started + +This short guide will help you get started with setting this project up on your development machine. + +| Command | Description | +| --------------- | --------------------------------------------------- | +| `npm start` | Start development server on `http://localhost:5085` | +| `npm run build` | Create a build for the production at `/web/dist` | + +If we want to make a build locally for the first time, then follow the instructions given in `README.md` located at project's root directory (under Docker heading) which will generate a build in `/web/dist` and it will be served on `http://localhost:8081/`. + +Whereas in development, one has to follow the above steps for very first time and then use `npm run start` to start development server on `http://localhost:5085`. + +--- + +**Note:** +Resolve or disable ESLint errors before creating the build. If there are any remaining ESlint errors are there then it will fail to generate the build. (if there are any errors/warnings, then `npm start` will show while server is running) + +--- diff --git a/frontend/app/api/analyticApi.js b/frontend/app/api/analyticApi.js new file mode 100644 index 0000000..cd6ba51 --- /dev/null +++ b/frontend/app/api/analyticApi.js @@ -0,0 +1,21 @@ +import {createApi} from '../common/Common' + +export const getSavedAnalysesApi = createApi('GET', '/api/v1/analytic', { + inputData: (data) => { + if (data.sort !== 'numberCharts') { + data.sort = 's.' + data.sort + } + return data + }, + rejectData: (defHandler, xhr) => { + return defHandler(xhr, undefined, 'Cannot get saved analyses') + } +}) +export const deleteSavedAnalysesApi = createApi('DELETE', '/api/v1/analytic/delete', { + inputData: (ids) => { + return JSON.stringify({ids: ids}) + }, + rejectData: (defHandler, xhr) => { + return defHandler(xhr, undefined, 'Cannot delete saved analyses') + } +}) diff --git a/frontend/app/api/analytics/createAnalytics.js b/frontend/app/api/analytics/createAnalytics.js new file mode 100644 index 0000000..53eb7c3 --- /dev/null +++ b/frontend/app/api/analytics/createAnalytics.js @@ -0,0 +1,165 @@ +import { sum } from 'lodash' +import { convertlocaltoUTC } from '../../common/helper' +import { get, post, put } from '../httpInterceptor/httpInterceptor' + +const formatValues = (data) => { + let newObj = {} + for (const [key, value] of Object.entries(data)) { + for (let v in value) { + newObj[v] = newObj[v] + ? { ...newObj[v], [key]: value[v] } + : { [key]: value[v] } + } + } + + Object.keys(data).map((dt) => { + for (let item in newObj) { + newObj[item][dt] = newObj[item][dt] || 0 + } + }) + + const obj = Object.keys(newObj).map((v) => ({ + name: v, + data: newObj[v] + })) + + return obj +} + +export const addEditAnalyticsAPI = async (data, id) => { + let url = `/analysis${id ? `/${id}` : ''}` + + const bodyObj = {} + // bodyObj.filters = [] // need changes + bodyObj.filters = { + date: { + type: 'between', + start: convertlocaltoUTC(data.startDate, 'YYYY-MM-DD'), + end: convertlocaltoUTC(data.endDate, 'YYYY-MM-DD') + } + } + bodyObj.feeds = data.feeds.map((val) => val.id) + + const func = id ? put : post + const res = await func(url, bodyObj) + console.log('API Response :: addEditAnalytics ::: ', res) + return res +} + +export const getAnalyticDetailsAPI = async (id) => { + let url = `/analysis/${id}` + const res = await get(url) + console.log('API Response :: getAnalyticDetails ::: ', res) + return res +} + +export const createAlertAPI = async (data) => { + let url = '/notifications' + const res = await post(url, data) + console.log('API Response :: creteAnalytics ::: ', res) + return res +} + +/* Chart APIs */ +export const getOverviewBarAPI = async (type = 'none', id) => { + let isOther = type !== 'none' + let url = isOther + ? `/mention-over-time-bar-graph/${id}` + : `/mention-bar-graph/${id}` + const res = await post(url, isOther ? { type } : undefined) + if (isOther && res.data && res.data.data) { + res.data.data = res.data.data.map((feed) => ({ + name: feed.name, + data: formatValues(feed.data) + })) + } + console.log('API Response :: getOverviewBarAPI ::: ', res) + return res +} + +/* Used for Overview, Performance, Sentiment, Demographics */ +export const getOverviewPieAPI = async (type = 'none', id) => { + let isOther = type !== 'none' + let url = isOther + ? `/mention-over-time-pie-graph/${id}` + : `/mention-pie-graph/${id}` + const res = await post(url, isOther ? { type } : undefined) + console.log('API Response :: getOverviewPieAPI ::: ', res) + return res +} + +export const getInfluencersAPI = async (id, filter, data = undefined) => { + let url = `/influencer/${id}` + if (filter === 1) { + data = { isAuthorType: true } + } + const res = await post(url, data) + console.log('API Response :: getInfluencersAPI ::: ', res) + return res +} + +export const getEngagementsTimeAPI = async (id) => { + let url = `/engagement-over-time-bar-graph/${id}` + const res = await post(url) + console.log('API Response :: getEngagementsTimeAPI ::: ', res) + return res +} + +export const getEngagementsAPI = async (id) => { + let url = `/engagement-over-time-pie-graph/${id}` + const res = await post(url) + console.log('API Response :: getEngagementsAPI ::: ', res) + return res +} + +/* Themes */ + +export const getThemesTimeAPI = async (id) => { + let url = `/theme-over-time-bar-graph/${id}` + const res = await post(url) + const { data } = res.data + let newData = data + if (data) { + newData = data.map((feedData) => { + const { name, data } = feedData + let dataTotal = data.map((theme) => { + const { name, data } = theme + const total = sum(Object.values(data)) + return { name, data, total } + }) + dataTotal = topN(dataTotal, 5) + return { name, data: dataTotal } + }) + } + res.data.data = newData + console.log('API Response :: getThemesTimeAPI ::: ', res) + return res +} + +function topN(arr, n) { + if (n > arr.length) { + return arr + } + return arr + .slice() + .sort((a, b) => { + return b.total - a.total + }) + .slice(0, n) +} + +export const getThemesCloudAPI = async (id) => { + let url = `/theme-over-time-pie-graph/${id}` + const res = await post(url) + console.log('API Response :: getThemesCloudAPI ::: ', res) + return res +} + +/* World Map */ + +export const getWorldMapAPI = async (id) => { + let url = `/world-map/${id}` + const res = await post(url) + console.log('API Response :: getWorldMapAPI ::: ', res) + return res +} diff --git a/frontend/app/api/analytics/savedAnalytics.js b/frontend/app/api/analytics/savedAnalytics.js new file mode 100644 index 0000000..ed2e87c --- /dev/null +++ b/frontend/app/api/analytics/savedAnalytics.js @@ -0,0 +1,15 @@ +import { get, del } from '../httpInterceptor/httpInterceptor' + +export const savedAnalytics = async (params) => { + let url = '/analysis' + const res = await get(url, params) + console.log('API Response :: savedAnalytics ::: ', res) + return res +} + +export const deleteAnalytics = async (id) => { + let url = `/analysis/${id}` + const res = await del(url) + console.log('API Response :: deleteAnalytics ::: ', res) + return res +} diff --git a/frontend/app/api/articlesApi.js b/frontend/app/api/articlesApi.js new file mode 100644 index 0000000..c5c44f9 --- /dev/null +++ b/frontend/app/api/articlesApi.js @@ -0,0 +1,52 @@ +import {createApi} from '../common/Common' + +/** + * payload: {ids: [...]} + */ +export const deleteDocumentsFromFeed = createApi('POST', '/api/v1/feed/{feedId}/documents/delete', { + inputData: (idsArray) => JSON.stringify({ids: idsArray}), + urlData: (params, feedId) => ({feedId}) +}) + +/** + * payload: {emailTo, emailReplyTo, subject, content} + */ +export const sendDocumentsByEmail = createApi('POST', '/api/v1/documents/email', { +}) + +/** + * payload: {title, comment} + */ +export const commentDocument = createApi('POST', '/api/v1/documents/{documentId}/comments', { + urlData: (params, documentId) => ({documentId}) +}) + +/** + * payload: {ids: []} + */ +export const clipDocuments = createApi('POST', '/api/v1/feed/{feedId}/documents/clip', { + urlData: (params, feedId) => ({feedId}), + inputData: (idsArray) => JSON.stringify({ids: idsArray}) +}) + +/** + * payload: {title, comment} + */ +export const updateComment = createApi('PUT', '/api/v1/comments/{commentId}', { + urlData: (params, commentId) => ({commentId}) +}) + +export const deleteComment = createApi('DELETE', '/api/v1/comments/{commentId}', { + urlData: (params, commentId) => ({commentId}) +}) + +export const getComments = createApi('GET', '/api/v1/documents/{documentId}/comments', { + inputData: (params) => params, + urlData: (params, documentId) => ({documentId}) +}) + +export const readLater = createApi('POST', '/api/v1/feed/readLater/{documentId}', { + urlData: (params, documentId) => ({documentId}) +}) + +export const getRecentClipFeeds = createApi('GET', '/api/v1/feed/recentClip') diff --git a/frontend/app/api/dashboardApi.js b/frontend/app/api/dashboardApi.js new file mode 100644 index 0000000..d027b43 --- /dev/null +++ b/frontend/app/api/dashboardApi.js @@ -0,0 +1,68 @@ +import {createApi, mockApi} from '../common/Common' + +const base = '/api/v1/dashboards' + +/*class DashboardWidget { + id: number, + type: "feed" | "chart" | "video" | "youtube", + name?: string, + source?: Feed | Chart, + limit?: number, + url?: string +} + +class Dashboard { + id: ... + name: string, + layout: any, + widgets: DashboardWidget[] +} +*/ + +//export const getDashboards = createApi('GET', base); +export const getDashboards = mockApi([ + {id: 1, name: 'My Dashboard', layout: '{ver: 1, left: [1, 2], right: [3, 4]}', widgets: [ + {id: 1, type: 'feed', name: 'Widget1', source: {id: 1}, limit: 5}, + {id: 2, type: 'feed', name: 'Widget2', source: {id: 2}, limit: 5}, + {id: 3, type: 'feed', name: 'Widget3', source: {id: 3}, limit: 5}, + {id: 4, type: 'feed', name: 'Widget4', source: {id: 4}, limit: 5} + ]}, + {id: 2, name: 'Dashboard 2', layout: '{ver: 1, left: [5, 6, 7], right: [8]}', widgets: [ + {id: 5, type: 'feed', name: 'Widget5', source: {id: 1}, limit: 5}, + {id: 6, type: 'feed', name: 'Widget6', source: {id: 2}, limit: 5}, + {id: 7, type: 'feed', name: 'Widget7', source: {id: 3}, limit: 5}, + {id: 8, type: 'feed', name: 'Widget8', source: {id: 4}, limit: 5} + ]}, + {id: 44, name: 'Not_my_dashboard', layout: '{ver: 1, left: [], right: [9, 10, 11, 12]}', widgets: [ + {id: 9, type: 'feed', name: 'Widget9', source: {id: 1}, limit: 5}, + {id: 10, type: 'feed', name: 'Widget10', source: {id: 2}, limit: 5}, + {id: 11, type: 'feed', name: 'Widget11', source: {id: 3}, limit: 5}, + {id: 12, type: 'feed', name: 'Widget12', source: {id: 4}, limit: 5} + ]} +]) + +//payload = {name} +export const createDashboard = createApi('POST', base) + +//payload = dashboard widget +export const createDashboardWidget = createApi('POST', `${base}/{dashboardId}/widgets`, { + urlData: (payload, dashboardId) => ({dashboardId}) +}) + +export const getVideoWidgetUrl = createApi('GET', `${base}/{dashboardId}/widgets/{widgetId}/video`, { + urlData: (payload, dashboardId, widgetId) => ({dashboardId, widgetId}) +}) + +//payload = dashboard without widgets +export const updateDashboard = createApi('PUT', `${base}/{dashboardId}`, { + urlData: (payload, dashboardId) => ({dashboardId}) +}) + +//payload = dashboard widget +export const updateDashboardWidget = createApi('PUT', `${base}/{dashboardId}/widgets/{widgetId}`, { + urlData: (payload, dashboardId, widgetId) => ({dashboardId, widgetId}) +}) + +export const deleteDashboardWidget = createApi('DELETE', `${base}/{dashboardId}`) + +export const deleteDashboard = createApi('DELETE', `${base}/{dashboardId}`) diff --git a/frontend/app/api/emailHistoryApi.js b/frontend/app/api/emailHistoryApi.js new file mode 100644 index 0000000..5dd6696 --- /dev/null +++ b/frontend/app/api/emailHistoryApi.js @@ -0,0 +1,4 @@ +// import {createApi} from '../common/Common' + +// const baseUrl = '/api/v1/receivers' + diff --git a/frontend/app/api/feedsApi.js b/frontend/app/api/feedsApi.js new file mode 100644 index 0000000..f9f0c2d --- /dev/null +++ b/frontend/app/api/feedsApi.js @@ -0,0 +1,54 @@ +import {createApi} from '../common/Common' + +const root = '/api/v1/feed' + +/** + * payload: {feed: {name: string, category: id, subType: string}, search: {query: string, filters: Object, advancedFilters: Object}} + */ +export const createFeed = createApi('POST', root) + +/** + * payload: {feed: {name: string, category: id, subType: string}, search: {query: string, filters: Object, advancedFilters: Object}} + */ +export const saveFeed = createApi('PUT', `${root}/{feedId}`, { + urlData: (data, feedId) => ({feedId}) +}) + +/** + * payload = {name: string} + */ +export const renameFeed = createApi('PUT', `${root}/{feedId}/rename`, { + urlData: (payload, feedId) => ({feedId}) +}) + +export const moveFeed = createApi('POST', `${root}/{feedId}/move_to/{categoryId}`, { + urlData: (payload, feedId, categoryId) => ({feedId, categoryId}) +}) + +export const deleteFeed = createApi('DELETE', `${root}/{feedId}`, { + urlData: (payload, feedId) => ({feedId}) +}) + +/** + * payload: {page: number, advancedFilters: Object} + */ +export const getFeedSearchResults = createApi('POST', `${root}/{feedId}/documents`, { + urlData: (params, feedId) => ({feedId}) +}) + +/** + * payload = {export: bool} + */ +export const toggleExportFeed = createApi('PUT', `${root}/{feedId}/toggleExport`, { + urlData: (payload, feedId) => ({feedId}) +}) + +/** + * payload = {export: bool} + */ +export const toggleExportCategory = createApi('PUT', `${root}/toggleExport/{categoryId}`, { + urlData: (payload, categoryId) => ({categoryId}) +}) + +export const loadExportedFeeds = createApi('GET', `${root}/exported`) + diff --git a/frontend/app/api/groupsApi.js b/frontend/app/api/groupsApi.js new file mode 100644 index 0000000..2f54c6e --- /dev/null +++ b/frontend/app/api/groupsApi.js @@ -0,0 +1,17 @@ +import {createApi} from '../common/Common' + +const baseUrl = '/api/v1/recipients/groups' + +export const getItems = createApi('GET', baseUrl, { + inputData: (data) => data +}) + +export const createItem = createApi('POST', baseUrl) + +export const updateItem = createApi('PUT', baseUrl + '/{groupId}', { + urlData: (data, groupId) => ({groupId}) +}) + +export const deleteItems = createApi('POST', baseUrl + '/delete') + +export const activateItems = createApi('PUT', baseUrl + '/active') diff --git a/frontend/app/api/httpInterceptor/httpInterceptor.js b/frontend/app/api/httpInterceptor/httpInterceptor.js new file mode 100644 index 0000000..7332cd3 --- /dev/null +++ b/frontend/app/api/httpInterceptor/httpInterceptor.js @@ -0,0 +1,122 @@ +import axios from 'axios'; +import apiBase from '../../appConfig'; +import i18n from '../../i18n'; + +export const get = ( + url, + params, + isPublic = false, + responseType = null, + passedFullURL = false +) => { + let apiUrl = passedFullURL + ? `${apiBase.apiUrl}${url}` + : `${apiBase.apiUrl}/api/v1${url}`; + + const axiosInstance = axios.create(); + + const axiosObj = { + method: 'get', + url: apiUrl, + params: params + }; + + if (isPublic) { + // apis in which no authentication needed + axiosInstance.transformRequest = (data, headers) => { + delete headers.common['Authorization']; + }; + } + + if (responseType) axiosObj.responseType = responseType; + return axiosInstance(axiosObj) + .then((response) => handleResponse(response)) + .catch((error) => handleError(error)); +}; + +export function put(...rest) { + return dataRequest('put', ...rest); +} + +export function post(...rest) { + return dataRequest('post', ...rest); +} + +export function del(...rest) { + return dataRequest('delete', ...rest); +} + +const dataRequest = ( + type = 'post', + url, + bodyObj = undefined, + isPublic = false, + mediaFile = false, + passedFullURL = false +) => { + const apiUrl = passedFullURL + ? `${apiBase.apiUrl}${url}` + : `${apiBase.apiUrl}/api/v1${url}`; + + if (mediaFile) { + const formData = new FormData(); + Object.keys(bodyObj).map((key) => { + formData.append(key, bodyObj[key]); + }); + bodyObj = formData; + } + + const axiosInstance = axios.create(); + + const axiosObj = { + method: type, + url: apiUrl, + data: bodyObj + }; + + if (isPublic) { + // apis in which no authentication needed + axiosInstance.transformRequest = (data, headers) => { + delete headers.common['Authorization']; + }; + } + + return axiosInstance(axiosObj) + .then((response) => handleResponse(response)) + .catch((error) => handleError(error)); +}; + +export const handleResponse = (response) => { + if ( + response.data && + (response.data.code === 403 || response.data.code === 404) + ) { + return { + error: true, + errorMessage: response.data.message, + data: response.message + }; + } + return { + error: false, + data: response.data + }; +}; + +export const handleError = (error) => { + const { response } = error; + let errorMsg = i18n.t('common:alerts.error.somethingWrong'); + if (response && response.status === 422) { + if (response.data.message) errorMsg = response.data.message; + } else if (response && response.status === 401) { + // Unauthorized + } + console.log('API Error ::: ', JSON.stringify(response)); + + return { + error: true, + errorMessage: errorMsg, + data: response ? response.data.errors : null, + status: response ? response.status : null + }; +}; diff --git a/frontend/app/api/loginApi.js b/frontend/app/api/loginApi.js new file mode 100644 index 0000000..476f857 --- /dev/null +++ b/frontend/app/api/loginApi.js @@ -0,0 +1,68 @@ +import $ from 'jquery' +import {createApi} from '../common/Common' +import config from '../appConfig' +import { errorConstants } from '../common/constants' +import i18n from '../i18n' + +export const login = (userData) => { + return new Promise((resolve, reject) => { + $.ajax({ + type: 'POST', + url: config.apiUrl + '/security/token/create', + dataType: 'json', + data: JSON.stringify({ + email: userData.email, + password: userData.password + }), + success: function (data) { + resolve(data) + }, + error: function (jqXHR, textStatus, errorThrown) { + const errMessage = + jqXHR.responseJSON && + jqXHR.responseJSON.errors && + jqXHR.responseJSON.errors + .map((err) => + i18n.t(`loginApp:errorMessages.${errorConstants[err]}`, { + defaultValue: err || '' + }) + ) + .join(' '); + console.log(errorThrown + ': Error ' + jqXHR.status, 'jsonAPIERROR'); + reject({ + msg: errMessage || i18n.t('common:alerts.error.somethingWrong') + }); + } + }) + }) +} + +export const loginRefresh = (refreshToken) => { + return new Promise((resolve, reject) => { + $.ajax({ + type: 'POST', + url: config.apiUrl + '/security/token/refresh', + dataType: 'json', + data: JSON.stringify({ + refreshToken: refreshToken + }), + success: function (data) { + resolve(data) + }, + error: function (jqXHR, textStatus, errorThrown) { + console.log(errorThrown + ': Error ' + jqXHR.status, 'jsonAPIERROR') + reject({msg: 'Your session is expired, please login again'}) + + /* if (jqXHR.status === 401) { + reject({msg: 'Your session is expired, please login again'}); + } else { + reject({msg: 'Login error, please login again'}); + } */ + } + }) + }) +} + +export const getRestrictions = createApi('GET', '/api/v1/users/current/restrictions', { + inputData: (data) => data +}) diff --git a/frontend/app/api/notificationsApi.js b/frontend/app/api/notificationsApi.js new file mode 100644 index 0000000..4678ada --- /dev/null +++ b/frontend/app/api/notificationsApi.js @@ -0,0 +1,38 @@ +import {createApi} from '../common/Common' + +const baseUrl = '/api/v1/notifications' + +export const getItems = createApi('GET', baseUrl, { + inputData: (data) => data +}) + +export const getItem = createApi('GET', baseUrl + '/{id}', { + inputData: () => {}, + urlData: (data, id) => ({id}) +}) + +export const createItem = createApi('POST', baseUrl) + +export const updateItem = createApi('PUT', baseUrl + '/{id}', { + urlData: (data, id) => ({id}) +}) + +export const deleteItems = createApi('POST', baseUrl + '/delete') + +export const activateItems = createApi('PUT', baseUrl + '/active') + +export const publishItems = createApi('PUT', baseUrl + '/published') + +export const subscribeItems = createApi('POST', baseUrl + '/subscribe') + +export const getAllItems = createApi('GET', baseUrl + '/all', { + inputData: (data) => data +}) +export const getFilters = createApi('GET', baseUrl + '/filters', { + inputData: (data) => data +}) + +export const getHistory = createApi('GET', baseUrl + '/{notificationId}/history', { + inputData: (data) => data, + urlData: (data, notificationId) => ({notificationId}) +}) diff --git a/frontend/app/api/plans/userPlans.js b/frontend/app/api/plans/userPlans.js new file mode 100644 index 0000000..3d36efc --- /dev/null +++ b/frontend/app/api/plans/userPlans.js @@ -0,0 +1,154 @@ +import axios from 'axios'; +import { cloneDeep } from 'lodash'; +import appConfig from '../../appConfig'; +import { hubspotBaseURL } from '../../common/constants'; +import { getHPContext } from '../../common/helper'; +import { + get, + handleError, + handleResponse, + post +} from '../httpInterceptor/httpInterceptor'; + +export const cancelPlan = async () => { + let url = '/users/cancel/plan'; + const res = await post(url); + console.log('API Response :: cancelPlan ::: ', res); + return res; +}; + +export const getTransactions = async (params) => { + let url = '/users/invoices'; + const res = await get(url, params); + console.log('API Response :: getTransactions ::: ', res); + return res; +}; + +export const updatePlanPayment = async (data) => { + let url = '/users/update/plan'; + const res = await post(url, data); + console.log('API Response :: updatePlanPayment ::: ', res); + return res; +}; + +export const changeCardDetails = async (data) => { + let url = '/users/card/change'; + const res = await post(url, data); + console.log('API Response :: changeCard ::: ', res); + return res; +}; + +// submit update plan data to Hubspot form API +export const updatePlanHubspot = (dataObj) => { + const { hubSpotportalID } = appConfig; + if (!hubSpotportalID) { + return Promise.resolve('No IDs'); + } + + const data = cloneDeep(dataObj); + data.line1 = data.line2 ? [data.line1, data.line2].join(', ') : data.line1; + const hubSpotFormURL = `${hubspotBaseURL}/47b0e83d-0e26-4528-8822-9aec64db35e8`; + const hubSpotMapping = { + savedFeeds: 'feed_licenses', + searchesPerDay: 'search_licenses', + webFeeds: 'webfeed_licenses', + alerts: 'alert_licenses', + subscriberAccounts: 'user_accounts', + line1: 'address', + city: 'city', + state: 'state', + postal_code: 'zip', + country: 'country', + phone: 'phone', + email: 'email', + totalCost: 'amount' + }; + + const mediaTypesMapping = { + news: 'News', + blog: 'Blogs', + reddit: 'Reddit', + twitter: 'Twitter', + instagram: 'Instagram' + }; + + const mediaTypes = Object.keys(mediaTypesMapping) + .filter((key) => data[key]) + .map((v) => mediaTypesMapping[v]) + .join(';'); + + const newObj = Object.keys(hubSpotMapping) + .filter((key) => data[key]) + .map((key) => ({ + name: hubSpotMapping[key], + value: data[key] + })); + + newObj.push({ + name: 'media_types', + value: mediaTypes + }); + + newObj.push({ + name: 'analytics', + value: data['analytics'] && data['analytics'] !== 0 + }); + + return axios + .post(hubSpotFormURL, { + fields: newObj, + context: getHPContext() + }) + .then((response) => handleResponse(response)) + .catch((error) => handleError(error)); +}; + +// submit cancel plan data to Hubspot form API +export const cancelPlanHubspot = (dataObj) => { + const { hubSpotportalID } = appConfig; + if (!hubSpotportalID) { + return Promise.resolve('No IDs'); + } + + const data = cloneDeep(dataObj); + const hubSpotFormURL = `${hubspotBaseURL}/4d2496c3-0535-4723-8b5e-bd04e7903338`; + const hubSpotMapping = { + email: 'email', + content: 'TICKET.content', + subject: 'TICKET.subject' + }; + + const reason = { + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + Other: 'Other' + }; + + const reasonValues = Object.keys(reason) + .filter((key) => data[key]) + .map((v) => reason[v]) + .join(';'); + + const newObj = Object.keys(hubSpotMapping) + .filter((key) => data[key]) + .map((key) => ({ + name: hubSpotMapping[key], + value: data[key] + })); + + newObj.push({ + name: 'cancelreason', + value: reasonValues + }); + + return axios + .post(hubSpotFormURL, { + fields: newObj, + context: getHPContext() + }) + .then((response) => handleResponse(response)) + .catch((error) => handleError(error)); +}; diff --git a/frontend/app/api/receiversApi.js b/frontend/app/api/receiversApi.js new file mode 100644 index 0000000..b921f54 --- /dev/null +++ b/frontend/app/api/receiversApi.js @@ -0,0 +1,12 @@ +import {createApi} from '../common/Common' + +const baseUrl = '/api/v1/receivers' + +export const getItems = createApi('GET', baseUrl, { + inputData: (data) => data +}) + +export const getEmailHistory = createApi('GET', baseUrl + '/{id}/emailHistory', { + urlData: (payload, receiverId) => ({id: receiverId}), + inputData: data => data +}) diff --git a/frontend/app/api/recipientsApi.js b/frontend/app/api/recipientsApi.js new file mode 100644 index 0000000..11f0076 --- /dev/null +++ b/frontend/app/api/recipientsApi.js @@ -0,0 +1,17 @@ +import {createApi} from '../common/Common' + +const baseUrl = '/api/v1/recipients' + +export const getItems = createApi('GET', baseUrl, { + inputData: (data) => data +}) + +export const createItem = createApi('POST', baseUrl) + +export const updateItem = createApi('PUT', baseUrl + '/{recipientId}', { + urlData: (data, recipientId) => ({recipientId}) +}) + +export const deleteItems = createApi('POST', baseUrl + '/delete') + +export const activateItems = createApi('PUT', baseUrl + '/active') diff --git a/frontend/app/api/registration/registration.js b/frontend/app/api/registration/registration.js new file mode 100644 index 0000000..6c0ae25 --- /dev/null +++ b/frontend/app/api/registration/registration.js @@ -0,0 +1,72 @@ +import axios from 'axios'; +import appConfig from '../../appConfig'; +import { + get, + handleError, + handleResponse, + post +} from '../httpInterceptor/httpInterceptor'; +import { getHPContext } from '../../common/helper'; +import { hubspotBaseURL } from '../../common/constants'; + +export const getPlans = async () => { + const url = '/security/plans'; + const res = await get(url, null, true, null, true); + console.log('API Response :: getPlans ::: ', res); + return res; +}; + +export const updatePrice = async (data) => { + let url = '/security/cost_calculation'; + const res = await post(url, data, true, null, true); + console.log('API Response :: updatePrice ::: ', res); + return res; +}; + +export const registerUser = async (data) => { + let url = '/security/registration'; + const res = await post(url, data, true, null, true); + console.log('API Response :: registerUser ::: ', res); + return res; +}; + +export const activeAccount = async (token) => { + let url = `/security/registration/confirm/${token}`; + const res = await post(url, undefined, true, null, true); + console.log('API Response :: activeAccount ::: ', res); + return res; +}; + +// submit data for form API +export const submitHubspot = (data) => { + const { hubSpotportalID } = appConfig; + if (!hubSpotportalID) { + return Promise.resolve('No IDs'); + } + + const hubSpotFormURL = `${hubspotBaseURL}/070e31d4-8e6d-480d-89b2-872a6bb28ff4`; + const hubSpotMapping = { + email: 'email', + firstName: 'firstname', + lastName: 'lastname', + companyName: 'company', + jobFunction: 'job_function', + numberOfEmployee: 'numemployees', + industry: 'industry', + websiteUrl: 'website', + lifecyclestage: 'lifecyclestage' + }; + + const newObj = Object.keys(hubSpotMapping).map((key) => ({ + name: hubSpotMapping[key], + value: data[key] + })); + + return axios + .post(hubSpotFormURL, { + fields: newObj, + context: getHPContext() + }) + .then((response) => handleResponse(response)) + .catch((error) => handleError(error)); +}; diff --git a/frontend/app/api/registrationApi.js b/frontend/app/api/registrationApi.js new file mode 100644 index 0000000..c7465de --- /dev/null +++ b/frontend/app/api/registrationApi.js @@ -0,0 +1,16 @@ +import {createApi} from '../common/Common' + +const root = '/security/registration' + +export const getBillingPlans = createApi('GET', `${root}/plans`) + +export const sendRegistrationRequest = createApi('POST', root) + +export const finishRegistration = createApi('POST', `${root}/finish`) + +export const autocompleteOrganizationName = createApi('GET', `${root}/organizationAutocomplete`, { + inputData: (organizationName) => ({organizationName}) +}) + +export const requestPasswordReset = createApi('POST', '/security/resetting/request') +export const confirmPasswordReset = createApi('POST', '/security/resetting/confirm') diff --git a/frontend/app/api/searchApi.js b/frontend/app/api/searchApi.js new file mode 100644 index 0000000..c102f29 --- /dev/null +++ b/frontend/app/api/searchApi.js @@ -0,0 +1,85 @@ +import axios from 'axios' +import { cloneDeep } from 'lodash' +import appConfig from '../appConfig' +import {createApi} from '../common/Common' +import { hubspotBaseURL } from '../common/constants' +import { getHPContext } from '../common/helper' +import { handleError, handleResponse } from './httpInterceptor/httpInterceptor' + +const slRoot = '/api/v1/source-list' + +export const searchQuery = createApi('POST', '/api/v1/query/search', {}) + +export const searchSources = createApi('POST', '/api/v1/source-index/', {}) + +export const addSourcesToLists = createApi('POST', '/api/v1/source-index/add-to-sources-list', {}) + +export const replaceSourceListsForSource = createApi('POST', '/api/v1/source-index/{id}/list', { + urlData: (params) => ({id: params.id}), + inputData: (params) => JSON.stringify({sourceLists: params.sourceLists}) +}) + +export const getSourceLists = createApi('POST', `${slRoot}/list`, {}) + +export const addSourceLists = createApi('POST', `${slRoot}/`, { + inputData: (name) => JSON.stringify({name}) +}) + +export const renameSourceLists = createApi('PUT', `${slRoot}/{id}`, { + urlData: (params) => ({id: params.id}), + inputData: (params) => JSON.stringify({name: params.name}) +}) + +export const cloneSourceLists = createApi('POST', `${slRoot}/{id}/clone`, { + urlData: (params) => ({id: params.id}), + inputData: (params) => JSON.stringify({name: params.name}) +}) + +export const deleteSourceLists = createApi('DELETE', `${slRoot}/{id}`, { + urlData: (id) => ({id}), + inputData: () => {} +}) + +export const getSourcesOfList = createApi('POST', `${slRoot}/{id}/sources/search`, { + urlData: (data, id) => ({id}) +}) + +export const shareSourceList = createApi('POST', `${slRoot}/{id}/share`, { + urlData: (id) => ({id}), + inputData: () => null +}) +export const unshareSourceList = createApi('POST', `${slRoot}/{id}/unshare`, { + urlData: (id) => ({id}), + inputData: () => null +}) + +// submit search queries to Hubspot form API for free user +export const submitSearchHubspot = (dataObj) => { + const { hubSpotportalID } = appConfig; + if (!hubSpotportalID) { + return Promise.resolve('No IDs'); + } + + const data = cloneDeep(dataObj); + const hubSpotFormURL = `${hubspotBaseURL}/3f297902-d32d-44bb-89a6-12af1c7b886e`; + const hubSpotMapping = { + email: 'email', + searchquery: 'searchquery' + // raw_query: 'raw_query' + }; + + const newObj = Object.keys(hubSpotMapping) + .filter((key) => data[key]) + .map((key) => ({ + name: hubSpotMapping[key], + value: data[key] + })); + + return axios + .post(hubSpotFormURL, { + fields: newObj, + context: getHPContext() + }) + .then((response) => handleResponse(response)) + .catch((error) => handleError(error)); +}; diff --git a/frontend/app/api/sidebarCategoriesApi.js b/frontend/app/api/sidebarCategoriesApi.js new file mode 100644 index 0000000..5adf07a --- /dev/null +++ b/frontend/app/api/sidebarCategoriesApi.js @@ -0,0 +1,22 @@ +import {createApi} from '../common/Common' + +export const getCategories = createApi('GET', '/api/v1/categories') + +//payload = {name, parent} +export const addCategory = createApi('POST', '/api/v1/categories', { + urlData: (payload, feedId) => ({feedId}) +}) + +//payload = {name, parent} +export const renameCategory = createApi('PUT', '/api/v1/categories/{categoryId}', { + urlData: (payload, categoryId) => ({categoryId}) +}) + +export const moveCategory = createApi('POST', '/api/v1/categories/{categoryId}/move_to/{newCategoryId}', { + urlData: (payload, categoryId, newCategoryId) => ({categoryId, newCategoryId}) +}) + +//payload = {name, parent} +export const deleteCategory = createApi('DELETE', '/api/v1/categories/{categoryId}', { + urlData: (payload, categoryId) => ({categoryId}) +}) diff --git a/frontend/app/api/themesApi.js b/frontend/app/api/themesApi.js new file mode 100644 index 0000000..d025009 --- /dev/null +++ b/frontend/app/api/themesApi.js @@ -0,0 +1,7 @@ +import {createApi} from '../common/Common' + +const baseUrl = '/api/v1/notifications/themes' + +export const getDefaultItem = createApi('GET', baseUrl + '/default', { + inputData: (data) => data +}) diff --git a/frontend/app/api/usersApi.js b/frontend/app/api/usersApi.js new file mode 100644 index 0000000..e5f7ee9 --- /dev/null +++ b/frontend/app/api/usersApi.js @@ -0,0 +1,5 @@ +import {createApi} from '../common/Common' + +const root = '/api/v1/users' + +export const changePassword = createApi('POST', `${root}/change-password`) diff --git a/frontend/app/appConfig.js.docker b/frontend/app/appConfig.js.docker new file mode 100644 index 0000000..99f3d3c --- /dev/null +++ b/frontend/app/appConfig.js.docker @@ -0,0 +1,13 @@ +const appConfig = { + appEnv: 'local', + apiUrl: 'http://localhost:8081', + gtagID: 'G-XXXXXXXXX', // Global Tag for Google Analytics + gtagID2: 'UA-XXXXXXXXX', + fbPixelID: '123456', + hubSpotID: '123456', + insightTagID: '', + stripeKey: '', + hubSpotportalID: '' +}; + +export default appConfig; diff --git a/frontend/app/appConfig.js.staging b/frontend/app/appConfig.js.staging new file mode 100644 index 0000000..b984dac --- /dev/null +++ b/frontend/app/appConfig.js.staging @@ -0,0 +1,13 @@ +const appConfig = { + appEnv: 'staging', + apiUrl: 'http://stage.socialhose.io', + gtagID: 'G-XXXXXXXXX', // Global Tag for Google Analytics + gtagID2: 'UA-XXXXXXXXX', + fbPixelID: '123456', + hubSpotID: '123456', + insightTagID: '', + stripeKey: '', + hubSpotportalID: '' +}; + +export default appConfig; diff --git a/frontend/app/common/Common.js b/frontend/app/common/Common.js new file mode 100644 index 0000000..f0327ca --- /dev/null +++ b/frontend/app/common/Common.js @@ -0,0 +1,140 @@ +import $ from 'jquery' +import config from '../appConfig' + +export const parseSearchDays = function (date) { + const period = date.slice(-1) + const dateNum = parseInt(date) + + if (period === 'd') { + return dateNum + } else { + let daysCount = 0 + + for (let i = 0; i <= dateNum; i++) { + const date = new Date(new Date().setFullYear(new Date().getFullYear() - i)) + daysCount += date.getFullYear() % 4 === 0 ? 366 : 365 + console.log(daysCount) + } + return daysCount + } +} + +export const makeStickySidebar = function ({component, sidebarSelector, footerSelector, sidebarTopMargin, sidebarBottomMargin}) { + const sidebarEl = $(sidebarSelector) + const footerEl = $(footerSelector) + const sidebarTopPos = parseInt(sidebarEl.css('top')) + const sidebarBottomPos = parseInt(sidebarEl.css('bottom')) + + let windowHeight = $(window).height() + let docScrollTop = $(document).scrollTop() + + $(window).on('resize', function () { + windowHeight = $(window).height() //recalc win height + _updateSidebarPosition() + }) + + $(window).on('scroll', function () { + docScrollTop = $(document).scrollTop() //recalc scroll top + _updateSidebarPosition() + }) + + _updateSidebarPosition() + + function _updateSidebarPosition () { + const footerTop = footerEl.offset().top + const windowBottomPos = docScrollTop + windowHeight + // check if document scrollTop position cross sidebar top position with margin + //if so we set sidebar top position to its margin value + if (docScrollTop < sidebarTopPos - sidebarTopMargin) { + sidebarEl.css('top', sidebarTopPos - docScrollTop) + } else { + sidebarEl.css('top', sidebarTopMargin) + } + //fixing overlapping on footer + if (windowBottomPos >= footerTop + sidebarBottomMargin) { + sidebarEl.css('bottom', sidebarBottomPos - footerTop + windowBottomPos) + } else { + sidebarEl.css('bottom', sidebarBottomPos) + } + } + + component.componentWillUnmount = function () { + $(window).off('resize') + $(window).off('scroll') + } + + return _updateSidebarPosition +} + +//default handler - returns errors field from server response or throw error with given text +const defaultApiErrorHandler = (jqXHR, transKey = 'unknown', message = 'Unknown error') => { + if (jqXHR.status === 402) { + return [] + } + + if (jqXHR.responseJSON && jqXHR.responseJSON.errors && jqXHR.responseJSON.errors.length) { + return jqXHR.responseJSON.errors + } else { + return [{type: 'error', transKey: transKey, message: message}] + } +} + +export const createApi = (httpMethod, url, + { + urlData = false, + inputData = (payload) => JSON.stringify(payload), + resolveData = (response) => response, + rejectData = (defHandler, jqXHR) => { return defHandler(jqXHR) } + } = {} +) => { + return (token, payload, ...args) => { + let requestUrl = url + if (typeof urlData === 'function') { + const urlParams = urlData(payload, ...args) + console.log('%c urlParams=' + JSON.stringify(urlParams), 'color: green') + requestUrl = url.replace(/\{(.*?)\}/g, function (match, field) { + return urlParams[field] + }) + } + + return new Promise((resolve, reject) => { + let ajaxOptions = { + type: httpMethod, + url: config.apiUrl + requestUrl, + dataType: 'json', + contentType: 'application/json', + data: inputData(payload), + success: function (data) { + resolve(resolveData(data)) + }, + error: function (jqXHR, textStatus, errorThrown) { + console.log(`%c [API Error] HTTP ${jqXHR.status}, ${errorThrown}`, 'background: red; color: yellow') + reject(rejectData(defaultApiErrorHandler, jqXHR, textStatus, errorThrown)) + } + } + + if (token) { + ajaxOptions.headers = { + Authorization: 'Bearer ' + token + } + } + + // Used for backend debugging :) + if (__DEV__) { + ajaxOptions['xhrFields'] = { + withCredentials: true + } + } + + $.ajax(ajaxOptions) + }) + } +} + +export const mockApi = (fakeData, timeout = 2000) => () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(fakeData) + }, timeout) + }) +} diff --git a/frontend/app/common/Normalize.js b/frontend/app/common/Normalize.js new file mode 100644 index 0000000..f500d0b --- /dev/null +++ b/frontend/app/common/Normalize.js @@ -0,0 +1,27 @@ +export const normalize = function (arr, entityCallback) { + let ids = [] + let entities = {} + arr.forEach((item, i) => { + if (item.id) { + ids.push(item.id) + + if (entityCallback) { + entities[item.id] = entityCallback(item, item.id) + } + else { + entities[item.id] = item + } + } else { + ids.push(i.toString()) + + if (entityCallback) { + entities[i] = entityCallback(item, i) + } + else { + entities[i] = item + } + } + }) + + return {ids, entities} +} diff --git a/frontend/app/common/StringUtils.js b/frontend/app/common/StringUtils.js new file mode 100644 index 0000000..70caa52 --- /dev/null +++ b/frontend/app/common/StringUtils.js @@ -0,0 +1,30 @@ +export const padLeft = function (string, total) { + if (typeof string !== 'string') { + throw new Error('First parameter must be a string') + } + if (typeof total !== 'number') { + throw new Error('Second parameter must be a integer') + } + return new Array(total - string.length + 1).join('0') + string +} + +export const addOrdinalSuffix = function (num) { + if (typeof num !== 'number') { + return num + } + + const j = num % 10 + const k = num % 100 + + if (j === 1 && k !== 11) { + return num + 'st' + } + if (j === 2 && k !== 12) { + return num + 'nd' + } + if (j === 3 && k !== 13) { + return num + 'rd' + } + + return num + 'th' +} diff --git a/frontend/app/common/Timezones.js b/frontend/app/common/Timezones.js new file mode 100644 index 0000000..8ba24a9 --- /dev/null +++ b/frontend/app/common/Timezones.js @@ -0,0 +1,21 @@ +import moment from 'moment-timezone' + +const getZonesNames = function () { + return moment.tz.names() +} + +export const getCurrentTimezone = function () { + return moment.tz.guess() +} + +const getTimezones = function () { + const names = getZonesNames() + return names.map(name => { + const zone = moment.tz.zone(name) + const utc = moment.parseZone(zone).format('Z') + const label = `(UTC ${utc}) ${name}` + return {value: name, label} + }) +} + +export const timezones = getTimezones() diff --git a/frontend/app/common/constants.js b/frontend/app/common/constants.js new file mode 100644 index 0000000..542dba3 --- /dev/null +++ b/frontend/app/common/constants.js @@ -0,0 +1,20 @@ +import appConfig from '../appConfig.js'; + +const { appEnv, hubSpotportalID } = appConfig; + +// when `npm start` server in local +export const isDevelopment = process.env.NODE_ENV === 'development'; + +// when `npm run build` +export const isProduction = process.env.NODE_ENV === 'production'; + +// when run locally or build is generated for corresponding sites +export const isLive = appEnv === 'live'; +export const isStaging = appEnv === 'staging'; +export const isLocal = appEnv === 'local'; + +export const errorConstants = { + 'Bad credentials.': 'badCredentials' +}; + +export const hubspotBaseURL = `https://api.hsforms.com/submissions/v3/integration/submit/${hubSpotportalID}`; diff --git a/frontend/app/common/helper.js b/frontend/app/common/helper.js new file mode 100644 index 0000000..26249e4 --- /dev/null +++ b/frontend/app/common/helper.js @@ -0,0 +1,183 @@ +import moment from 'moment'; +import { cloneDeep } from 'lodash'; +import axios from 'axios'; +import Cookies from 'cookies-js'; + +// append scripts in body +export const appendScriptLink = (sources) => { + sources.map((src) => { + const script = document.createElement('script'); + script.type = 'text/javascript'; + script.setAttribute('src', src); + script.setAttribute('async', true); + document.body.appendChild(script); + }); +}; + +// load script into body part +export const loadScript = (source) => { + const s = document.createElement('script'); + s.type = 'text/javascript'; + s.async = true; + s.innerHTML = source; + document.body.appendChild(s); +}; + +// set document element value +export const setDocumentData = (tag, value) => { + if (tag === 'title') { + document.title = value + ? `${value} | SOCIALHOSE.IO App` + : 'Social Listening Platform | Social Analytics | SOCIALHOSE.IO App'; + } +}; + +// convert UTC date to Local date +export const convertUTCtoLocal = (date, format = 'YYYY-MM-DD HH:mm:ss') => { + if (!date) return ''; + const utcDate = moment.utc(date).format(); //is used to consider input as UTC if timezone offset is not passed + return moment(utcDate).format(format); +}; + +// convert Local date to UTC date +export const convertlocaltoUTC = (date, format = 'YYYY-MM-DD HH:mm:ss') => { + if (!date) return ''; + return moment.utc(date).format(format); +}; + +// get date +export const getDate = (date, format = 'YYYY-MM-DD HH:mm:ss') => { + return !date ? moment().format(format) : moment(date).format(format); +}; + +export const getMomentObject = (date) => { + return date ? (moment.isMoment(date) ? date : moment(date)) : null; +}; + +export const getQueryParams = (obj) => { + if (!obj) { + return null; + } + const { page, pageSize = 10, sorted, searchQuery = undefined } = obj; + const params = { + page: page + 1, + limit: pageSize, + query: searchQuery + }; + if (sorted && sorted.length) { + const sortedField = sorted[0]; + const sort = { + field: sortedField.id, + direction: sortedField.desc ? 'desc' : 'asc' + }; + params['sort'] = sort; + } + return params; +}; + +export function removeHttpsUrl(url) { + return !url ? '' : url.replace(/(^\w+:|^)\/\//, ''); +} + +export function capOnlyFirstLetter(string) { + // lodash: capitalize + return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase(); +} + +export function capFirstLetter(string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} + +export function getValidHttpUrl(string) { + let url; + + try { + url = new URL(string); + } catch (_) { + return false; + } + + url.protocol = 'https:'; + return url.toString(); +} + +export function getArray(obj, key = 'name', value = 'value') { + return Object.entries(obj).map((v) => ({ + [key]: v[0], + [value]: v[1] + })); +} + +// get title for source index table +export function getTitle(prevTitle) { + if (prevTitle && prevTitle.replace(/!+/g, '').trim().length > 0) { + return prevTitle; + } + + return '[No Name]'; +} + +export function abbreviateNumber(num) { + if (num >= 1000000000) { + return (num / 1000000000).toFixed(1).replace(/\.0$/, '') + 'G'; + } + if (num >= 1000000) { + return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; + } + if (num >= 1000) { + return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; + } + return num; +} + +export function notNullAndUnd(value) { + return value !== null && value !== undefined; +} + +export function validateForm(form, errors, handleValidation) { + let failed; + for (let val in errors) { + const fieldError = errors[val]; + if (fieldError) { + failed = true; + } else if (fieldError === null && !form[val] && form[val] !== 0) { + failed = true; + handleValidation(val, true); + } + } + if (failed) { + return false; + } else { + return cloneDeep(form); + } +} + +// get IP +export function getIP() { + return localStorage.getItem('ip'); +} + +export const setIP = async () => { + try { + const res = await axios.get('https://api.ipify.org/?format=json'); + res.data && res.data.ip && localStorage.setItem('ip', res.data.ip); + } catch (error) { + console.log(error); + } +}; + +export function getHPContext() { + return { + hutk: Cookies.get('hubspotutk') || undefined, + ipAddress: getIP() || undefined + }; +} + +export function arraymove(arr, fromIndex, toIndex) { + if (fromIndex === -1 || toIndex === -1) { + return; + } + var element = arr[fromIndex]; + arr.splice(fromIndex, 1); + arr.splice(toIndex, 0, element); +} diff --git a/frontend/app/common/scripts.js b/frontend/app/common/scripts.js new file mode 100644 index 0000000..56ed9b5 --- /dev/null +++ b/frontend/app/common/scripts.js @@ -0,0 +1,56 @@ +import React from 'react'; +import appConfig from '../appConfig'; + +const { gtagID, gtagID2, fbPixelID, hubSpotID, insightTagID } = appConfig; + +export const gtagScriptURL = ( + +); + +export const gtagScript = ( + +); + +export const fbPixelScript = ( + +); + +export const hubspotTracking = ( + +); + +export const linkedInsightTag = [ + , + + +]; diff --git a/frontend/app/components/App/Account/Plans/BillingDetailsForm.js b/frontend/app/components/App/Account/Plans/BillingDetailsForm.js new file mode 100644 index 0000000..d92b58e --- /dev/null +++ b/frontend/app/components/App/Account/Plans/BillingDetailsForm.js @@ -0,0 +1,200 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Col, FormGroup, Label, Row } from 'reactstrap'; +import { getData } from 'country-list'; +import { CardElement } from '@stripe/react-stripe-js'; + +import { Input } from '../../../common/FormControls'; +import { Trans, translate } from 'react-i18next'; + +const countries = getData().map((v) => ({ label: v.name, value: v.code })); + +const cardElementOptions = { + hidePostalCode: true, + style: { + base: { + fontSize: '16px', + color: '#424770' + }, + invalid: { + color: '#d92550' + } + } +}; + +function BillingDetailsForm(props) { + const { form, errors, handleChange, handleValidation, t } = props; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + By submitting, you agree to our + + Privacy Policy + + + Terms & Conditions + + + Acceptable Use Policy + + . + +

+ +
+ ); +} + +BillingDetailsForm.propTypes = { + t: PropTypes.func, + form: PropTypes.object, + errors: PropTypes.object, + handleChange: PropTypes.func, + handleValidation: PropTypes.func +}; + +export default React.memo( + translate(['tabsContent'], { wait: true })(BillingDetailsForm) +); diff --git a/frontend/app/components/App/Account/Plans/CancellationFeedback.js b/frontend/app/components/App/Account/Plans/CancellationFeedback.js new file mode 100644 index 0000000..d6b4a19 --- /dev/null +++ b/frontend/app/components/App/Account/Plans/CancellationFeedback.js @@ -0,0 +1,244 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import useForm from '../../../common/hooks/useForm'; +import { + ListGroupItem, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Row, + Form, + Button, + Col, + ListGroup, + Label +} from 'reactstrap'; +import { Checkbox, Input } from '../../../common/FormControls'; +import { cancelPlan, cancelPlanHubspot } from '../../../../api/plans/userPlans'; +import { planRoutes } from './UserPlans'; + +const formParams = { + rs1: 1, + rs2: 2, + rs3: 3, + rs4: 4, + rs5: 5, + rs6: 'Other' +}; + +const initForm = { + [formParams.rs1]: false, + [formParams.rs2]: false, + [formParams.rs3]: false, + [formParams.rs4]: false, + [formParams.rs5]: false, + [formParams.rs6]: false, + Other: false, + content: '', + email: '', + subject: 'Cancellation', + errors: { + email: null + } +}; + +function CancellationFeedback({ t, actions, isOpen = false, toggle, user }) { + const { + form, + handleChange, + handleValidation, + validateSubmit, + errors + } = useForm(initForm); + const [cancelLoading, setCancelLoading] = useState(false); + const [reasonError, setReasonError] = useState(''); + + useEffect(() => { + handleChange('email', user.email); + }, [user.email]); + + useEffect(() => { + if (Object.values(formParams).some((v) => form[v])) { + setReasonError(''); + } + }, [...Object.values(form)]); + + function cancelSubscription() { + const obj = validateSubmit(); + if (!obj) { + return actions.addAlert({ type: 'error', transKey: 'requiredInfo' }); + } else if (!Object.values(formParams).some((v) => obj[v])) { + setReasonError(t('plans.currentPlan.cancelModal.reasonSelect')); + return; + } + + setCancelLoading(true); + cancelPlan().then((res) => { + if (res.error) { + res.data + ? actions.addAlert(res.data) + : actions.addAlert({ type: 'error', transKey: 'somethingWrong' }); + setCancelLoading(false); + return; + } + + cancelPlanHubspot({ ...obj }); + + actions.addAlert({ + type: 'notice', + transKey: 'cancelledSubscription' + }); + + // refresh page on success and move to active plan details + setTimeout(() => { + window.location.pathname = `/app/plans/${planRoutes.current}`; + }, 1000); + }); + } + + return ( +
+ + + {t('plans.currentPlan.cancelModal.header')} + + + + +

+ {t('plans.currentPlan.cancelModal.line1', { + firstName: user.firstName + })} +

+

{t('plans.currentPlan.cancelModal.line2')}

+ + + {t('plans.currentPlan.cancelModal.warn1')} + + + {t('plans.currentPlan.cancelModal.warn2')} + + + {t('plans.currentPlan.cancelModal.warn3')} + + + {t('plans.currentPlan.cancelModal.warn4')} + + + + +
+

+ {t('plans.currentPlan.cancelModal.feedbackPara')} +

+
+ +
+ + + + + + + {reasonError} +
+
+ +
+ +
+
+ + + + +
+
+ ); +} + +CancellationFeedback.propTypes = { + t: PropTypes.func, + actions: PropTypes.object, + isOpen: PropTypes.bool, + toggle: PropTypes.func, + user: PropTypes.object +}; + +export default CancellationFeedback; diff --git a/frontend/app/components/App/Account/Plans/ChangeCard.js b/frontend/app/components/App/Account/Plans/ChangeCard.js new file mode 100644 index 0000000..7fa9406 --- /dev/null +++ b/frontend/app/components/App/Account/Plans/ChangeCard.js @@ -0,0 +1,185 @@ +import React, { Fragment, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js'; +import { Alert, Button, Card, CardBody, CardTitle, Col, Row } from 'reactstrap'; + +import { reduxActions } from '../../../../redux/utils/connect'; +import useForm from '../../../common/hooks/useForm'; +import useIsMounted from '../../../common/hooks/useIsMounted'; +import BillingDetailsForm from './BillingDetailsForm'; +import { changeCardDetails } from '../../../../api/plans/userPlans'; +import { planRoutes } from './UserPlans'; +import { setDocumentData } from '../../../../common/helper'; +import { translate } from 'react-i18next'; + +const initialForm = { + name: '', + line1: '', + line2: '', + city: '', + state: '', + postal_code: '', + country: '', + email: '', + phone: '', + errors: { + name: null, + line1: null, + city: null, + state: null, + postal_code: null, + country: null, + email: null, + phone: null + } +}; + +function ChangeCard({ actions, t }) { + const isMounted = useIsMounted(); + const stripe = useStripe(); + const elements = useElements(); + + const { + form, + errors, + handleChange, + handleValidation, + validateSubmit + } = useForm(initialForm); + const [paymentError, setPaymentError] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setDocumentData('title', 'Change Card'); + + return () => setDocumentData('title'); // default + }, []); + + const submitPayment = async () => { + if (!stripe || !elements) { + // Stripe.js has not loaded yet. + return; + } + + setPaymentError(false); + setLoading(true); + + const obj = validateSubmit(); + if (!obj) { + setLoading(false); + return actions.addAlert({ + type: 'error', + transKey: 'requiredInfo' + }); + } + + const cardElement = elements.getElement(CardElement); + const { + name, + line1, + line2, + city, + state, + postal_code, + country, + email, + phone + } = obj; + const { error, paymentMethod } = await stripe.createPaymentMethod({ + type: 'card', + card: cardElement, + billing_details: { + name, + email, + phone, + address: { + line1: line1, + line2: line2, + city: city, + state: state, + postal_code: postal_code, + country: country + } + } + }); + + if (error) { + setPaymentError(error); + setLoading(false); + return; + } + + const newObj = {}; + newObj.paymentID = paymentMethod.id; //stripe card element ID + const res = await changeCardDetails(newObj); + + if (!isMounted.current) { + return; + } + + if (res.error) { + res.data + ? actions.addAlert(res.data) + : actions.addAlert({ type: 'error', transKey: 'somethingWrong' }); + setLoading(false); + return; + } + + actions.addAlert({ type: 'notice', transKey: 'cardUpdated' }); + // refresh page on success and move to active plan details + setTimeout(() => { + window.location.pathname = `/app/plans/${planRoutes.current}`; + }, 1000); + }; + + return ( + + + + {t('plans.changeCard.heading')} +

{t('plans.changeCard.subText')}

+ + + {paymentError && ( + + +

+ {t('plans.changeCard.error')} +

+ {paymentError.message} +
+
+ )} +
+ +
+
+
+ + ); +} + +ChangeCard.propTypes = { + t: PropTypes.func.isRequired, + actions: PropTypes.object +}; + +export default reduxActions()( + translate(['tabsContent'], { wait: true })(ChangeCard) +); diff --git a/frontend/app/components/App/Account/Plans/CurrentPlan.js b/frontend/app/components/App/Account/Plans/CurrentPlan.js new file mode 100644 index 0000000..7492db5 --- /dev/null +++ b/frontend/app/components/App/Account/Plans/CurrentPlan.js @@ -0,0 +1,286 @@ +import React, { Fragment, useEffect, useState } from 'react'; +import { useHistory } from 'react-router'; +import PropTypes from 'prop-types'; +import { Button, Card, CardBody, CardTitle, Col, Row } from 'reactstrap'; +import reduxConnect from '../../../../redux/utils/connect'; +import { planRoutes } from './UserPlans'; +import { allMediaTypes } from '../../../../redux/modules/appState/searchByFilters'; +import { capitalize } from 'lodash'; +import { convertUTCtoLocal, setDocumentData } from '../../../../common/helper'; +import { translate } from 'react-i18next'; +import CancellationFeedback from './CancellationFeedback'; + +function CurrentPlan({ actions, user, t }) { + const [cancelModal, setCancelModal] = useState(false); + + const { restrictions } = user; + const { push } = useHistory(); + + useEffect(() => { + setDocumentData('title', 'Active Plan Details'); + + return () => setDocumentData('title'); // default + }, []); + + function changePlan() { + push(`/app/plans/${planRoutes.update}`); + } + + function toggleCancelModal() { + setCancelModal((prev) => !prev); + } + + const { + plans, + limits, + isPlanCancelled, + subStartDate, + subEndDate + } = restrictions; + + const selectedMedias = []; + const notSelectedMedias = []; + + allMediaTypes.map((v) => { + if (plans[v]) { + selectedMedias.push(t(`searchTab.sourceTypes.${v}`, capitalize(v))); + } else { + notSelectedMedias.push(t(`searchTab.sourceTypes.${v}`, capitalize(v))); + } + }); + + const isRTL = document.documentElement.dir === 'rtl'; + return ( + + + +
+
+
+ {t('plans.currentPlan.subHeading')} +
+
+ {plans.price === 0 + ? t('plans.currentPlan.freePlan') + : `$${plans.price}`} +
+
+ + {plans.price === 0 ? ( +   + ) : subStartDate && subEndDate ? ( + `${convertUTCtoLocal( + subStartDate, + 'MMM D, YYYY' + )} - ${convertUTCtoLocal(subEndDate, 'MMM D, YYYY')}` + ) : ( + t('plans.currentPlan.perMonth') + )} + +
+
+
+ + + + + + + + {t('plans.currentPlan.currentPlanDetails')} +
+

+ {t('plans.currentPlan.selectedMediaTypes')} +

+

+ {selectedMedias.length > 0 + ? selectedMedias.join(', ') + : t('plans.currentPlan.none')} + {notSelectedMedias.length > 0 ? ( + + ({t('plans.currentPlan.upgradeToGet')}:{' '} + {notSelectedMedias.join(', ')}) + + ) : ( + '' + )} +

+
+
+
+

+ {t('plans.currentPlan.selectedLicenses')} +

+ + +
+ {!isRTL ? ( +
+ {limits.savedFeeds.current}/{limits.savedFeeds.limit} +
+ ) : ( +
+ {limits.savedFeeds.limit}/{limits.savedFeeds.current} +
+ )} +
+ {t('plans.currentPlan.feedsLicenses')} +
+
+ + +
+ {!isRTL ? ( +
+ {limits.searchesPerDay.current}/ + {limits.searchesPerDay.limit} +
+ ) : ( +
+ {limits.searchesPerDay.limit}/ + {limits.searchesPerDay.current} +
+ )} +
+ {t('plans.currentPlan.searchLicenses')} +
+
+ + +
+ {!isRTL ? ( +
+ {limits.webFeeds.current}/{limits.webFeeds.limit} +
+ ) : ( +
+ {limits.webFeeds.limit}/{limits.webFeeds.current} +
+ )} +
+ {t('plans.currentPlan.webfeedLicenses')} +
+
+ + +
+ {!isRTL ? ( +
+ {limits.alerts.current}/{limits.alerts.limit} +
+ ) : ( +
+ {limits.alerts.limit}/{limits.alerts.current} +
+ )} +
+ {t('plans.currentPlan.alertLicenses')} +
+
+ + +
+ {!isRTL ? ( +
+ {limits.subscriberAccounts.current}/ + {limits.subscriberAccounts.limit} +
+ ) : ( +
+ {limits.subscriberAccounts.limit}/ + {limits.subscriberAccounts.current} +
+ )} +
+ {t('plans.currentPlan.userAccounts')} +
+
+ +
+
+
+
+

{t('plans.currentPlan.features')}

+

+ {plans.analytics ? ( + t('plans.currentPlan.analytics') + ) : ( + + {t('plans.currentPlan.none')} + + ({t('plans.currentPlan.upgradeToGet')}:{' '} + {t('plans.currentPlan.analytics')}) + + + )} +

+
+ {plans.price > 0 && ( + +
+
+ {!isPlanCancelled ? ( +
+ +

+ {t('plans.currentPlan.cancelWarning')} +

+
+ ) : ( +
+ +

+ {t('plans.currentPlan.alreadyCancelled')} +

+
+ )} +
+ + )} + + + + + + + ); +} + +CurrentPlan.propTypes = { + t: PropTypes.func.isRequired, + actions: PropTypes.object, + user: PropTypes.object +}; + +export default reduxConnect('user', ['common', 'auth', 'user'])( + translate(['tabsContent'], { wait: true })(CurrentPlan) +); diff --git a/frontend/app/components/App/Account/Plans/ShowTransactionDetails.js b/frontend/app/components/App/Account/Plans/ShowTransactionDetails.js new file mode 100644 index 0000000..bc10e53 --- /dev/null +++ b/frontend/app/components/App/Account/Plans/ShowTransactionDetails.js @@ -0,0 +1,168 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + Col, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Row, + Table +} from 'reactstrap'; +import { convertUTCtoLocal } from '../../../../common/helper'; +import moment from 'moment'; +import { capitalize } from 'lodash'; +import { translate } from 'react-i18next'; + +function ShowTransactionDetails(props) { + const { data, closeModal, t } = props; + + const plan = data && data.lines && data.lines.data && data.lines.data[0]; + + useEffect(() => { + return () => closeModal(); + }, []); + + return ( + + + {t('plans.transactions.modal.heading')} + + + {data && ( + + +
+ {t('plans.transactions.modal.transactionDetails')} +
+ + + + + + + + + + + + + + + + + + + + + + + +
{t('plans.transactions.modal.transactionDate')} + {convertUTCtoLocal( + moment.unix( + data.status_transitions && + data.status_transitions.paid_at + ), + 'MM/DD/YYYY hh:mm:ss a' + )} +
{t('plans.transactions.modal.activationDate')} + {convertUTCtoLocal( + moment.unix(plan && plan.period.start), + 'MM/DD/YYYY' + )} +
{t('plans.transactions.modal.expirationDate')} + {convertUTCtoLocal( + moment.unix(plan && plan.period.end), + 'MM/DD/YYYY' + )} +
{t('plans.transactions.modal.amount')}${data.amount_paid / 100}
{t('plans.transactions.modal.status')}{capitalize(data.status)}
+ + +
+ {t('plans.transactions.modal.billingDetails')} +
+ + + + + + + + + + + + + + + + + + + + + + + +
{t('plans.transactions.modal.name')}{data.customer_name || '-'}
{t('plans.transactions.modal.email')}{data.customer_email || '-'}
{t('plans.transactions.modal.phone')}{data.customer_phone || '-'}
{t('plans.transactions.modal.address')}{data.customer_address || '-'}
{t('plans.transactions.modal.invoiceNo')} + {data.number} ( + + {t('plans.transactions.modal.showInvoiceLink')} + + ) +
+ + {/* +
Plan Details
+ + + + + + + + + + + + + + + + + + + + + + + +
Feeds Licenses0
Webfeed Licenses0
Newsletter Licenses0
User Accounts0
AnalyticsNo
+ */} +
+ )} +
+ + + +
+ ); +} + +ShowTransactionDetails.propTypes = { + t: PropTypes.func, + closeModal: PropTypes.func, + data: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]).isRequired +}; + +export default React.memo( + translate(['tabsContent'], { wait: true })(ShowTransactionDetails) +); diff --git a/frontend/app/components/App/Account/Plans/UpdatePlan.js b/frontend/app/components/App/Account/Plans/UpdatePlan.js new file mode 100644 index 0000000..9663be0 --- /dev/null +++ b/frontend/app/components/App/Account/Plans/UpdatePlan.js @@ -0,0 +1,749 @@ +/* eslint-disable react/jsx-no-bind */ +import React, { Fragment, useCallback, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import Slider from 'rc-slider'; +import Tooltip from 'rc-tooltip'; +import { + Alert, + Button, + Card, + CardBody, + CardTitle, + Col, + Form, + FormGroup, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Row +} from 'reactstrap'; + +import { + licenses, + mediaTypes, + features, + addonFeatures +} from '../../../LoginRegister/Registration/PlanConstants'; +import useForm from '../../../common/hooks/useForm'; +import { debounce } from 'lodash'; +import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; + +import useIsMounted from '../../../common/hooks/useIsMounted'; +import reduxConnect from '../../../../redux/utils/connect'; +import { + getPlans, + updatePrice +} from '../../../../api/registration/registration'; +import { + updatePlanHubspot, + updatePlanPayment +} from '../../../../api/plans/userPlans'; +import { planRoutes } from './UserPlans'; +import BillingDetailsForm from './BillingDetailsForm'; + +import simpleNumberLocalizer from 'react-widgets-simple-number'; +import NumberPicker from 'react-widgets/lib/NumberPicker'; +import LoadersAdvanced from '../../../common/Loader/Loader'; +import { IoIosWarning } from 'react-icons/io'; +import { convertUTCtoLocal, setDocumentData } from '../../../../common/helper'; +import { translate } from 'react-i18next'; + +simpleNumberLocalizer(); + +const Handle = Slider.Handle; + +const handle = (props) => { + // eslint-disable-next-line react/prop-types + const { value, dragging, index, ...restProps } = props; + + return ( + + + + ); +}; + +const initialForm = { + savedFeeds: 0, + searchesPerDay: 0, + webFeeds: 0, + alerts: 0, + news: 0, + blog: 0, + reddit: 0, + instagram: 0, + twitter: 0, + analytics: 0, + subscriberAccounts: 0, + masterAccounts: 0 +}; + +const initialPaymentForm = { + name: '', + line1: '', + line2: '', + city: '', + state: '', + postal_code: '', + country: '', + email: '', + phone: '', + errors: { + name: null, + line1: null, + city: null, + state: null, + postal_code: null, + country: null, + email: null, + phone: null + } +}; + +function UpdatePlan({ actions, restrictions, t }) { + const stripe = useStripe(); + const elements = useElements(); + const isMounted = useIsMounted(); + + // first step + const { form, handleChange, resetForm } = useForm(initialForm); + const [updatingPrice, setUpdatingPrice] = useState(true); + const [totalCost, setTotalCost] = useState(' - '); + const [modal, setModal] = useState(false); + const [loading, setLoading] = useState(false); + const [planLoading, setPlanLoading] = useState(true); + const [planError, setPlanError] = useState(false); + const [planList, setPlanList] = useState([]); + const [disableUpdate, setDisableUpdate] = useState(true); + + // second step + const [nextStep, setNextStep] = useState(false); + const { + form: paymentForm, + handleChange: handlePaymentForm, + errors: paymentFormErrors, + handleValidation: handlePaymentValidation, + validateSubmit + } = useForm(initialPaymentForm); + const [paymentError, setPaymentError] = useState(false); + const [paymentLoading, setPaymentLoading] = useState(false); + + // to update price when input changes + useEffect(() => { + if (planList.length > 0) { + debouncePrice(form); + } + }, [...Object.values(form)]); + + const debouncePrice = useCallback( + debounce((form) => { + setUpdatingPrice(true); + updatePrice(form).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || isNaN(res.data.totalPrice)) { + actions.addAlert(res.data); + setUpdatingPrice(false); + setTotalCost('Error'); + return; + } + setTotalCost(res.data.totalPrice); + setUpdatingPrice(false); + }); + }, 1000), + [isMounted.current] + ); + + useEffect(() => { + if (!restrictions.isPlanCancelled && !restrictions.isPlanDowngrade) { + setDisableUpdate(false); + } else { + setDisableUpdate(true); + } + }, [restrictions.isPlanCancelled, restrictions.isPlanDowngrade]); + + useEffect(() => { + getBillingPlans(); + + setDocumentData('title', 'Update Plan'); + return () => setDocumentData('title'); // default + }, []); + + function getBillingPlans() { + setPlanLoading(true); + setPlanError(false); + getPlans().then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data || !res.data.length) { + setPlanError(true); + setPlanLoading(false); + res.data && res.data.length > 0 && actions.addAlert(res.data); + return; + } + setPlanLoading(false); + setPlanList(res.data); + + const modified = { ...initialForm }; + let selectedPlan = {}; + if (restrictions.plans.price > 0) { + selectedPlan = { ...restrictions.plans }; + Object.entries(restrictions.limits).map(([key, value]) => { + selectedPlan[key] = value.limit; + }); + selectedPlan.blog = selectedPlan.blogs; + delete selectedPlan.blogs; + } else { + selectedPlan = res.data[0]; + } + + Object.keys(initialForm).map((key) => { + modified[key] = + selectedPlan[key] === undefined + ? modified[key] + : selectedPlan[key] === true + ? 1 + : selectedPlan[key] === false + ? 0 + : selectedPlan[key]; + }); + resetForm(modified); + }); + } + + function changePlan(id) { + const selectedPlan = planList.find((plan) => plan.id === id); + const modified = { ...initialForm }; + Object.keys(initialForm).map((key) => { + modified[key] = + selectedPlan[key] === undefined + ? modified[key] + : selectedPlan[key] === true + ? 1 + : selectedPlan[key] === false + ? 0 + : selectedPlan[key]; + }); + resetForm(modified); + } + + function handleSubmit() { + if (restrictions.isPlanCancelled || restrictions.isPlanDowngrade) { + return; + } + // move to payment page if new basic user + // instruct according to upgrade and downgrade + // if card already stored then only update the plan by showing modal or providing option to change card + setLoading(true); + if (restrictions.isPaymentId) { + setModal(true); // show details of card + } else { + setNextStep(true); + window.scrollTo(0, 0); + } + + setLoading(false); + } + + function toggle() { + setModal((prev) => !prev); + } + + function proceedToDetails() { + toggle(); + setNextStep(true); + window.scrollTo(0, 0); + } + + const submitPayment = async () => { + if (!stripe || !elements) { + // Stripe.js has not loaded yet. + return; + } + + if (restrictions.isPlanCancelled || restrictions.isPlanDowngrade) { + return; + } + + setPaymentError(false); + setPaymentLoading(true); + + const obj = validateSubmit(); + if (!obj) { + setPaymentLoading(false); + return actions.addAlert({ + type: 'error', + transKey: 'requiredInfo' + }); + } + + const cardElement = elements.getElement(CardElement); + const { + name, + line1, + line2, + city, + state, + postal_code, + country, + email, + phone + } = obj; + const { error, paymentMethod } = await stripe.createPaymentMethod({ + type: 'card', + card: cardElement, + billing_details: { + name, + email, + phone, + address: { + line1: line1, + line2: line2, + city: city, + state: state, + postal_code: postal_code, + country: country + } + } + }); + + if (error) { + setPaymentError(error); + setPaymentLoading(false); + return; + } + + const newObj = { ...form }; + newObj.masterAccounts = '1'; + newObj.paymentID = paymentMethod.id; //stripe card element ID + const res = await updatePlanPayment(newObj); + + if (res.error) { + res.data + ? actions.addAlert(res.data) + : actions.addAlert({ type: 'error', transKey: 'somethingWrong' }); + setPaymentLoading(false); + return; + } + + window.gtag && + window.gtag('event', 'purchase', { + currency: 'USD', + value: totalCost + }); + + await updatePlanHubspot({ ...obj, ...form, totalCost }); + + actions.addAlert({ type: 'notice', transKey: 'planUpdated' }); + + // refresh page on success and move to active plan details + setTimeout(() => { + window.location.pathname = `/app/plans/${planRoutes.current}`; + }, 1000); + }; + + const proceedPayment = async () => { + // payment with old card + setLoading(true); + + const newObj = { ...form }; + newObj.masterAccounts = '1'; + const res = await updatePlanPayment(newObj); + + if (res.error) { + res.data + ? actions.addAlert(res.data) + : actions.addAlert({ type: 'error', transKey: 'somethingWrong' }); + setLoading(false); + return; + } + + window.gtag && + window.gtag('event', 'purchase', { + currency: 'USD', + value: totalCost + }); + + await updatePlanHubspot({ ...form, totalCost }); + + actions.addAlert({ type: 'notice', transKey: 'planUpdated' }); + + // refresh page on success and move to active plan details + setTimeout(() => { + window.location.pathname = `/app/plans/${planRoutes.current}`; + }, 1000); + }; + + function moveBack() { + window.scrollTo(0, 0); + setNextStep(false); + } + + if (planError || planLoading) { + return ( + + + + {t('plans.updatePlan.heading')} + {planError && ( +
+ + {t('plans.updatePlan.planLoadingFailed')}{' '} + +
+ )} +
+ {planLoading && } +
+ + ); + } + + const isRTL = document.documentElement.dir === 'rtl'; + + return ( + + + {!nextStep ? ( + + {t('plans.updatePlan.heading')} +

+ {t('plans.updatePlan.subText')}{' '} + + {t('plans.updatePlan.learnMoreBtn')} + + . +

+
+
+ + +
+
+ {t('plans.updatePlan.prePlans')} +
+
+ {planList.map((plan) => ( + + ))} +
+
+
+
+
+ {t('plans.updatePlan.mediaTypes')} +
+
+ {mediaTypes.map((type) => ( + + ))} +
+
+
+
+
+ {t('plans.updatePlan.licenses')} +
+ + {licenses.map((license) => ( + +
+ +
+ + + {form[license.name]} + +
+ + handleChange(license.name, val) + } + /> +
+
+ + ))} +
+
+
+ + +
+
+ {t('plans.updatePlan.features')} +
+
+ {features.map((type) => ( + + ))} +
+ {features.map((type) => + form[type.name] ? ( +

+ {type.desc} +

+ ) : null + )} +
+
+
+ + +
+
+ {t('plans.updatePlan.addOns')} +
+ + {addonFeatures.map((type) => ( + + + + + handleChange(type.name, val) + } + /> + + + ))} + +
+ +
+
+
+
+
+ {t('plans.updatePlan.totalCost')} +
+
+ {t('plans.updatePlan.monthly')} +
+
+
+ {/* {updatingPrice && ( +
+ +
+ )} */} +
+ ${totalCost} +
+
+
+
+ +
+
+ {restrictions.isPlanCancelled || restrictions.isPlanDowngrade ? ( +

+ {t('plans.updatePlan.cancelledWarning', { + text: restrictions.isPlanCancelled + ? 'cancelled' + : 'downgraded' + })}{' '} + {restrictions.subStartDate && restrictions.subEndDate + ? `(${convertUTCtoLocal( + restrictions.subStartDate, + 'MMM D, YYYY' + )} - ${convertUTCtoLocal( + restrictions.subEndDate, + 'MMM D, YYYY' + )})` + : ''} +

+ ) : ( + '' + )} +
+ +
+
+
+ ) : ( + + {t('plans.updatePlan.billingHeading')} + + + {paymentError && ( + + +

+ {t('plans.updatePlan.error')} +

+ {paymentError.message} +
+
+ )} +
+ + +
+
+ )} +
+ + + {t('plans.updatePlan.confirmationHeading')} + + +
+ {restrictions.plans && restrictions.plans.price > 0 ? ( + restrictions.plans.price === totalCost ? null : restrictions.plans + .price < totalCost ? ( +

+ {t('plans.updatePlan.upgradeNotice')} +

+ ) : ( +

+ {t('plans.updatePlan.downgradeNotice')} +

+ ) + ) : null} +

{t('plans.updatePlan.alreadyStoredCard')}

+
+
+ + + + +
+ + ); +} + +UpdatePlan.propTypes = { + t: PropTypes.func.isRequired, + actions: PropTypes.object, + restrictions: PropTypes.object +}; + +export default reduxConnect('restrictions', [ + 'common', + 'auth', + 'user', + 'restrictions' +])(translate(['tabsContent'], { wait: true })(UpdatePlan)); diff --git a/frontend/app/components/App/Account/Plans/UpgradePlanModal.js b/frontend/app/components/App/Account/Plans/UpgradePlanModal.js new file mode 100644 index 0000000..09a210f --- /dev/null +++ b/frontend/app/components/App/Account/Plans/UpgradePlanModal.js @@ -0,0 +1,72 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { Button, Modal, ModalBody, ModalHeader } from 'reactstrap'; +import { planRoutes } from './UserPlans'; +import { Trans, translate } from 'react-i18next'; + +function UpgradePlanModal({ isModalOpen = false, toggle, t }) { + function toggleModal() { + return toggle(); + } + + return ( + + + +
+
+ +
+

{t('plans.upgradeModal.heading')}

+
+

+ + You have to upgrade your plan to get access of these features. + Take a look at our bite-sized + à la carte menu options with monthly billing. + {' '} + + {t('plans.upgradeModal.learnMore')} + +

+
+
+ + +
+
+
+
+ ); +} + +UpgradePlanModal.propTypes = { + isModalOpen: PropTypes.bool, + t: PropTypes.func.isRequired, + toggle: PropTypes.func +}; + +export default React.memo( + translate(['tabsContent'], { wait: true })(UpgradePlanModal) +); diff --git a/frontend/app/components/App/Account/Plans/UserPlans.js b/frontend/app/components/App/Account/Plans/UserPlans.js new file mode 100644 index 0000000..14337d2 --- /dev/null +++ b/frontend/app/components/App/Account/Plans/UserPlans.js @@ -0,0 +1,128 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { + NavLink, + Redirect, + Route, + Switch, + useRouteMatch +} from 'react-router-dom'; +import reduxConnect from '../../../../redux/utils/connect'; +import ChangeCard from './ChangeCard'; +import CurrentPlan from './CurrentPlan'; +import UpdatePlan from './UpdatePlan'; +import UserTransactions from './UserTransactions'; +import { Card, CardBody, Col, Row } from 'reactstrap'; +import { translate } from 'react-i18next'; + +export const planRoutes = { + current: 'current', + changeCard: 'change-card', + txn: 'transactions', + update: 'update' +}; + +function UserPlans({ actions, restrictions, t }) { + const match = useRouteMatch(); + + useEffect(() => { + const { setEnableClosedSidebar } = actions; + actions.getRestrictions(); + setEnableClosedSidebar(true); + + return () => setEnableClosedSidebar(false); + }, []); + + return ( + + + + +
    +
  • + + + + + {t('plans.sidebar.activePlanDetails')} + +
  • + {restrictions.isPaymentId && ( +
  • + + + + + {t('plans.sidebar.changeCard')} + +
  • + )} +
  • + + + + + {t('plans.sidebar.updatePlan')} + +
  • +
  • + + + + + {t('plans.sidebar.yourTransactions')} + +
  • +
+
+
+ + + + + + {restrictions.isPaymentId && ( + + + + )} + + + + + + + + +
+ ); +} + +UserPlans.propTypes = { + t: PropTypes.func.isRequired, + actions: PropTypes.object.isRequired, + restrictions: PropTypes.object.isRequired +}; + +export default reduxConnect('restrictions', [ + 'common', + 'auth', + 'user', + 'restrictions' +])(translate(['tabsContent'], { wait: true })(UserPlans)); diff --git a/frontend/app/components/App/Account/Plans/UserTransactions.js b/frontend/app/components/App/Account/Plans/UserTransactions.js new file mode 100644 index 0000000..6919560 --- /dev/null +++ b/frontend/app/components/App/Account/Plans/UserTransactions.js @@ -0,0 +1,132 @@ +/* eslint-disable react/jsx-no-bind */ +/* eslint-disable react/prop-types */ +import React, { useState, useCallback, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { reduxActions } from '../../../../redux/utils/connect'; +import { + convertUTCtoLocal, + getQueryParams, + setDocumentData +} from '../../../../common/helper'; +import { getTransactions } from '../../../../api/plans/userPlans'; +import Table from '../../../common/Table/Table'; +import { Button, Col } from 'reactstrap'; +import ShowTransactionDetails from './ShowTransactionDetails'; +import moment from 'moment'; +import { capitalize } from 'lodash'; +import { translate } from 'react-i18next'; + +function UserTransactions(props) { + const [dataSource, setDataSource] = useState({ data: [] }); + const [loading, setLoading] = useState(true); + const [selectedData, setSelectedData] = useState(false); + const { actions, t } = props; + + useEffect(() => { + setDocumentData('title', 'User Transactions'); + + return () => setDocumentData('title'); // default + }, []); + + const columns = [ + { + id: 'activeDate', + Header: t('plans.transactions.activationDate'), + accessor: (d) => d.lines.data[0] && d.lines.data[0].period.start, + Cell: (props) => convertUTCtoLocal(moment.unix(props.value), 'MM/DD/YYYY') + }, + { + id: 'expireDate', + Header: t('plans.transactions.expirationDate'), + accessor: (d) => d.lines.data[0] && d.lines.data[0].period.end, + Cell: (props) => convertUTCtoLocal(moment.unix(props.value), 'MM/DD/YYYY') + }, + { + id: 'paid_at', + Header: t('plans.transactions.transactionDate'), + accessor: (d) => d.status_transitions.paid_at, + Cell: (props) => + convertUTCtoLocal(moment.unix(props.value), 'MM/DD/YYYY HH:mm:ss') + }, + { + Header: t('plans.transactions.amount'), + accessor: 'amount_paid', + Cell: (props) => (props.value ? `$${props.value / 100}` : '-') + }, + { + Header: t('plans.transactions.status'), + accessor: 'status', + Cell: (props) => capitalize(props.value) + }, + { + Header: t('plans.transactions.actions'), + accessor: 'id', + Cell: (props) => ( + + ) + } + ]; + + function closeModal() { + setSelectedData(false); + } + + const getTransactionList = useCallback((page, pageSize) => { + setLoading(true); + const params = getQueryParams({ page, pageSize }); + getTransactions(params).then((res) => { + if (res.error || !res.data || !res.data.success || !res.data.data) { + setDataSource({ data: [] }); // comment this line when API is ready + setLoading(false); + return actions.addAlert({ + type: 'error', + transKey: 'somethingWrong' + }); + } + + // setDataSource(sampleData); // comment this line when API is ready + setDataSource({ + data: + res.data.data.data && res.data.data.data.length > 0 + ? res.data.data.data + : [] + }); + setLoading(false); + }); + }, []); + + const { data = [], totalCount = 0, limit = 100, page = 1 } = dataSource; + return ( + + + + + ); +} + +UserTransactions.propTypes = { + t: PropTypes.func.isRequired, + actions: PropTypes.object +}; + +export default reduxActions()( + translate(['tabsContent'], { wait: true })(UserTransactions) +); diff --git a/frontend/app/components/App/App.js b/frontend/app/components/App/App.js new file mode 100644 index 0000000..fd2b398 --- /dev/null +++ b/frontend/app/components/App/App.js @@ -0,0 +1,226 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { compose } from 'redux'; +import { withRouter } from 'react-router-dom'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { DndProvider } from 'react-dnd'; +import { isMobile } from 'react-device-detect'; +import { TouchBackend } from 'react-dnd-touch-backend'; +import cx from 'classnames'; +import echarts from 'echarts'; +import ResizeDetector from 'react-resize-detector'; + +import AppHeader from './AppHeader/AppHeader'; +import WebTour from './AppHeader/WebTour'; +import Sidebar from './Sidebar/Sidebar'; +import reduxConnect from '../../redux/utils/connect'; +// import { NOTIFICATION_SUBSCREENS } from '../../redux/modules/appState/share/tabs'; +import LoadersAdvanced from '../common/Loader/Loader'; +import WesteronTheme from '../common/charts/WesterosTheme.json'; +import 'react-dates/initialize'; +import 'react-dates/lib/css/_datepicker.css'; +import Footer from '../common/Footer'; +import { Button, UncontrolledTooltip } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faQuestion } from '@fortawesome/free-solid-svg-icons'; +import { find, map } from 'lodash'; +import tourPages from './AppHeader/WebTourSteps'; +import { allMediaTypes } from '../../redux/modules/appState/searchByFilters'; +import { translate } from 'react-i18next'; +import i18n from '../../i18n'; +import * as timeago from 'timeago.js'; + +import ar from 'timeago.js/lib/lang/ar'; +import fr from 'timeago.js/lib/lang/fr'; + +// register it languages for time-ago. +timeago.register('ar', ar); +timeago.register('fr', fr); + +const DnDBackend = isMobile ? TouchBackend : HTML5Backend; + +class App extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + actions: PropTypes.object.isRequired, + children: PropTypes.element, + history: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + store: PropTypes.object.isRequired + }; + + state = { + showSidebar: true, + sidebarAnimationDisabled: true, + closedSmallerSidebar: false, + showTourIcon: false + }; + + componentDidMount() { + echarts.registerTheme('westeros', WesteronTheme); + this.checkIfTourGuide(); + + const { + common: { auth } + } = this.props.store; + + const activeLang = i18n.language.slice(0, 2); + this.props.actions.chooseLanguage(activeLang); + + if ( + auth && + auth.user && + auth.user.restrictions && + auth.user.restrictions.plans + ) { + const planDetails = auth.user.restrictions.plans; + let allowedMediaTypes = allMediaTypes.filter((v) => planDetails[v]); + /*if (auth.user.restrictions.plans.price === 0) { + // TODO: remove following restrictions when duplication fixes + const restrictedTemporary = ['news', 'blogs']; + allowedMediaTypes = allowedMediaTypes.filter( + (v) => !restrictedTemporary.includes(v) + ); + } */ + this.props.actions.toggleMediaType(allowedMediaTypes, true); + } else { + this.props.actions.toggleMediaType([], true); + } + } + + componentDidUpdate(prevProps) { + if (prevProps.location.pathname !== this.props.location.pathname) { + this.checkIfTourGuide(); + } + } + + checkIfTourGuide = () => { + const tourCurrentPaths = map(tourPages, 'showOn'); + const hasTour = tourCurrentPaths.some((path) => + this.props.location.pathname.startsWith(path) + ); + + if (hasTour) { + !this.state.showTourIcon && this.setState({ showTourIcon: true }); + return; + } + + this.state.showTourIcon && this.setState({ showTourIcon: false }); + }; + + showWebTour = () => { + const tourSendPaths = find(tourPages, (o) => + this.props.location.pathname.startsWith(o.showOn) + ); + + if (tourSendPaths) { + // Open in a new tab to reset every redux state + const win = window.open(`${tourSendPaths.to}?webtour=true`, '_blank'); + win.focus(); + } + }; + + render() { + const { store, actions, children, t } = this.props; + const { common: commonState, appState } = store; + const { sidebar, themeOptions } = appState; + const { base, auth } = commonState; + + const { + colorScheme, + enableFixedHeader, + enableFixedSidebar, + enableFixedFooter, + enableClosedSidebar, + closedSmallerSidebar, + enableMobileMenu, + enablePageTabsAlt + } = themeOptions; + + if (!auth.token) { + ; + } + + return ( + { + return ( + +
+ {this.state.showTourIcon && ( +
+ + + {t('userSettings.guidedTourTooltip')} + +
+ )} + + +
+ +
+
+ {children} +
+
+
+
+ +
+
+ ); + }} + /> + ); + } +} + +const applyDecorators = compose( + translate(['common'], { wait: true }), + withRouter, + reduxConnect() +); + +export default applyDecorators(App); diff --git a/frontend/app/components/App/AppHeader/AppHeader.js b/frontend/app/components/App/AppHeader/AppHeader.js new file mode 100644 index 0000000..c8b8189 --- /dev/null +++ b/frontend/app/components/App/AppHeader/AppHeader.js @@ -0,0 +1,110 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import HeaderSettings from './HeaderSettings'; +import SettingsPopup from './SettingsPopup'; +import cx from 'classnames'; +import MainTabsLinks from './MainTabsLinks'; +import CSSTransitionGroup from 'react-transition-group/CSSTransitionGroup'; +import HeaderLogo from './HeaderLogo'; +import HeaderDots from './HeaderDots'; + +export class AppHeader extends React.Component { + static propTypes = { + appCommonState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + userFirstName: PropTypes.string, + userLastName: PropTypes.string, + userRole: PropTypes.string.isRequired, + restrictions: PropTypes.object.isRequired, + themeOptions: PropTypes.object.isRequired + }; + + state = { + active: false, + mobile: false, + activeSecondaryMenuMobile: false + }; + + toggleResponsiveMenu = () => { + this.props.actions.toggleSidebar(); + }; + + activeSearchFunc = () => { + this.setState({ active: !this.state.active }); + }; + + render() { + const { + appCommonState, + restrictions, + actions, + userFirstName, + userLastName, + themeOptions + } = this.props; + const mainTabs = Object.keys(appCommonState.tabs); + + const { + headerBackgroundColor, + enableHeaderShadow, + enableMobileMenuSmall + } = themeOptions; + + const settingsPopupVisible = appCommonState.isSettingsPopupVisible; + + return ( + + + +
+
+ +
+
+ + +
+
+ + {settingsPopupVisible && ( + + )} +
+
+ ); + } +} + +export default AppHeader; diff --git a/frontend/app/components/App/AppHeader/AppMobileMenu.js b/frontend/app/components/App/AppHeader/AppMobileMenu.js new file mode 100644 index 0000000..3e708ff --- /dev/null +++ b/frontend/app/components/App/AppHeader/AppMobileMenu.js @@ -0,0 +1,99 @@ +/* eslint-disable react/jsx-no-bind */ +import PropTypes from 'prop-types'; +import React, { Fragment } from 'react'; +import { Slider } from 'react-burgers'; +import cx from 'classnames'; +import { faEllipsisV } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Button } from 'reactstrap'; +import reduxConnect from '../../../redux/utils/connect'; +import translate from 'react-i18next/dist/commonjs/translate'; +import { compose } from 'redux'; + +class AppMobileMenu extends React.Component { + constructor(props) { + super(props); + this.state = { + active: false, + mobile: false, + activeSecondaryMenuMobile: false + }; + } + + toggleMobileSidebar = () => { + const { setEnableMobileMenu } = this.props.actions; + const { enableMobileMenu } = this.props.appState.themeOptions; + setEnableMobileMenu(!enableMobileMenu); + }; + + toggleMobileSmall = () => { + const { setEnableMobileMenuSmall } = this.props.actions; + const { enableMobileMenuSmall } = this.props.appState.themeOptions; + setEnableMobileMenuSmall(!enableMobileMenuSmall); + }; + + state = { + openLeft: false, + openRight: false, + relativeWidth: false, + width: 280, + noTouchOpen: false, + noTouchClose: false + }; + + changeActive = () => { + this.setState({ active: !this.state.active }); + }; + + render() { + return ( + +
+
+ +
+
+
+ + + +
+
+ ); + } +} + +AppMobileMenu.propTypes = { + actions: PropTypes.object, + appState: PropTypes.object +}; + +const applyDecorators = compose( + reduxConnect('appState', ['appState']), + translate(['tabsContent'], { wait: true }) +); + +export default applyDecorators(AppMobileMenu); diff --git a/frontend/app/components/App/AppHeader/HeaderDots.js b/frontend/app/components/App/AppHeader/HeaderDots.js new file mode 100644 index 0000000..2e446f0 --- /dev/null +++ b/frontend/app/components/App/AppHeader/HeaderDots.js @@ -0,0 +1,168 @@ +/* eslint-disable no-unused-vars */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { + UncontrolledDropdown, + DropdownToggle, + DropdownMenu, + Col, + Row, + Button, + DropdownItem +} from 'reactstrap'; + +import { IoIosGrid } from 'react-icons/io'; +import Notifications from './Notifications'; +import LangSettingsMenu from './LangSettingsMenu'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faAngleDown } from '@fortawesome/free-solid-svg-icons'; +import { planRoutes } from '../Account/Plans/UserPlans'; +import { convertUTCtoLocal } from '../../../common/helper'; + +class HeaderDots extends React.Component { + static propTypes = { + mainTabs: PropTypes.array.isRequired, + restrictions: PropTypes.object.isRequired, + planDetails: PropTypes.object.isRequired, + t: PropTypes.func.isRequired + }; + + validateTab(tab) { + if (tab === 'analyze') { + if (!this.props.restrictions) { + return false; + } + const permissions = this.props.restrictions.permissions; + return permissions.analytics; + } + return true; + } + + render() { + const { t, mainTabs, planDetails, restrictions } = this.props; + const isFreeAccount = planDetails.price === 0; + const isRTL = document.documentElement.dir === 'rtl'; + + return ( +
+ + + {t('plans.currentPlan')} + + + +
+
+
+
+
+ {isFreeAccount + ? t('plans.freeBasicAccount') + : `$${planDetails.price}`} +
+ {!isFreeAccount && ( +

+ {restrictions.subStartDate && restrictions.subEndDate + ? `${convertUTCtoLocal( + isRTL + ? restrictions.subEndDate + : restrictions.subStartDate, + 'MMM D, YYYY' + )} - ${convertUTCtoLocal( + isRTL + ? restrictions.subStartDate + : restrictions.subEndDate, + 'MMM D, YYYY' + )}` + : t('plans.perMonth')} +

+ )} +
+
+
+ + + {t('plans.upgradePlan')} + + + + {t('plans.yourTransactions')} + + {!isFreeAccount && ( + + + {t('plans.changeCard')} + + )} +
+
+ + + +
+
+ +
+ + +
+ {mainTabs.map((tab, i) => { + if (!this.validateTab(tab)) return null; + return ( +
+ + + ); + })} + + + + + + + ); + } +} + +export default translate(['common'], { wait: true })(HeaderDots); diff --git a/frontend/app/components/App/AppHeader/HeaderLogo.js b/frontend/app/components/App/AppHeader/HeaderLogo.js new file mode 100644 index 0000000..0ea28ec --- /dev/null +++ b/frontend/app/components/App/AppHeader/HeaderLogo.js @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types' +import React, { Fragment } from 'react' + +import { Slider } from 'react-burgers' + +import { compose } from 'redux' +import reduxConnect from '../../../redux/utils/connect' +import translate from 'react-i18next/dist/commonjs/translate' +import AppMobileMenu from './AppMobileMenu' + +class HeaderLogo extends React.Component { + constructor (props) { + super(props) + this.state = { + active: false, + mobile: false, + activeSecondaryMenuMobile: false + } + } + + toggleEnableClosedSidebar = () => { + const { setEnableClosedSidebar } = this.props.actions + const { enableClosedSidebar } = this.props.appState.themeOptions + setEnableClosedSidebar(!enableClosedSidebar) + } + + state = { + openLeft: false, + openRight: false, + relativeWidth: false, + width: 280, + noTouchOpen: false, + noTouchClose: false + } + + changeActive = () => { + this.setState({ active: !this.state.active }) + } + + render () { + return ( + +
+
+
+
+ +
+
+
+ + + ) + } +} + +HeaderLogo.propTypes = { + actions: PropTypes.object, + appState: PropTypes.object +} + +const applyDecorators = compose( + reduxConnect('appState', ['appState']), + translate(['tabsContent'], { wait: true }) +) + +export default applyDecorators(HeaderLogo) diff --git a/frontend/app/components/App/AppHeader/HeaderSettings.js b/frontend/app/components/App/AppHeader/HeaderSettings.js new file mode 100644 index 0000000..2eee64c --- /dev/null +++ b/frontend/app/components/App/AppHeader/HeaderSettings.js @@ -0,0 +1,84 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import UserSettingsMenu from './UserSettingsMenu'; +import { DropdownToggle, DropdownMenu, Dropdown } from 'reactstrap'; +import { faAngleDown, faUser } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +export class HeaderSettings extends React.Component { + static propTypes = { + userFirstName: PropTypes.string.isRequired, + userLastName: PropTypes.string.isRequired + }; + + state = { + isOpen: false + }; + + toggleUserSettingsDrop = () => { + this.setState((prev) => ({ isOpen: !prev.isOpen })); + }; + + render() { + const { userFirstName, userLastName } = this.props; + const isRTL = document.documentElement.dir === 'rtl'; + + return ( + +
+
+
+
+ + +
+ +
+ {window.outerWidth >= 768 && ( + + )} +
+ + + +
+
+
+
+ {userFirstName + ' ' + userLastName} +
+
+
+
+
+
+ ); + } +} + +export default translate(['common'], { wait: true })( + React.memo(HeaderSettings) +); diff --git a/frontend/app/components/App/AppHeader/LangSettingsMenu.js b/frontend/app/components/App/AppHeader/LangSettingsMenu.js new file mode 100644 index 0000000..a08e8bb --- /dev/null +++ b/frontend/app/components/App/AppHeader/LangSettingsMenu.js @@ -0,0 +1,100 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { compose } from 'redux'; +import { translate } from 'react-i18next'; +import i18n from '../../../i18n'; +import { + UncontrolledDropdown, + DropdownToggle, + DropdownMenu, + DropdownItem +} from 'reactstrap'; +import reduxConnect from '../../../redux/utils/connect'; + +import Flag from 'react-flagkit'; + +const langCountry = { + en: 'US', + ar: 'SA', + fr: 'FR' +}; + +function LangSettingsMenu(props) { + const { + t, + base: { langs, activeLang }, + actions, + direction = '' + } = props; + + const chooseLang = (e) => { + const newLang = e.target.dataset.lang; + actions.chooseLanguage(newLang); + i18n.changeLanguage(newLang); + }; + const isRTL = document.documentElement.dir === 'rtl'; + + const dropDownProps = {}; + if (direction) { + dropDownProps.direction = direction; + } + + return ( + + +
+
+
+ +
+
+ + +
+
+
+
+ {t('langs.chooseLanguage')} +
+
+
+
+ + {langs.map((lang, i) => { + const translateTarget = 'langs.' + lang; + return ( + + + {t(translateTarget)} + + ); + })} +
+ + ); +} + +LangSettingsMenu.propTypes = { + t: PropTypes.func.isRequired, + base: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + direction: PropTypes.string +}; + +const applyDecorators = compose( + reduxConnect('base', ['common', 'base']), + translate(['common'], { wait: true }) +); + +export default applyDecorators(LangSettingsMenu); diff --git a/frontend/app/components/App/AppHeader/MainTabsLinks.js b/frontend/app/components/App/AppHeader/MainTabsLinks.js new file mode 100644 index 0000000..47d5ea1 --- /dev/null +++ b/frontend/app/components/App/AppHeader/MainTabsLinks.js @@ -0,0 +1,80 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { Link, withRouter } from 'react-router-dom'; +import { Nav, NavItem } from 'reactstrap'; +import cl from 'classnames'; + +export class MainTabsLinks extends React.Component { + static propTypes = { + tabs: PropTypes.object.isRequired, + restrictions: PropTypes.object.isRequired, + t: PropTypes.func.isRequired, + actions: PropTypes.object.isRequired, + location: PropTypes.object + }; + + validateTab = (tab) => { + if (tab === 'analyze') { + if (!this.props.restrictions) { + // to prevent: permissions of `undefined` + return false; + } + const permissions = this.props.restrictions.permissions; + return permissions.analytics; + } + return true; + }; + + showUpgradeModal = (e) => { + e.preventDefault(); + this.props.actions.toggleUpgradeModal(); + }; + + render() { + const { t, tabs, location } = this.props; + + return ( + + ); + } +} + +export default translate(['common'], { wait: true })( + withRouter(React.memo(MainTabsLinks)) +); diff --git a/frontend/app/components/App/AppHeader/Notifications.js b/frontend/app/components/App/AppHeader/Notifications.js new file mode 100644 index 0000000..7054036 --- /dev/null +++ b/frontend/app/components/App/AppHeader/Notifications.js @@ -0,0 +1,160 @@ +import React, { Fragment, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import city3 from '../../../styles/utils/images/dropdown-header/city3.jpg'; +import PerfectScrollbar from 'react-perfect-scrollbar'; +import reduxConnect from '../../../redux/utils/connect'; +import cl from 'classnames'; +import { + Alert, + Button, + DropdownMenu, + DropdownToggle, + Nav, + NavItem, + UncontrolledDropdown +} from 'reactstrap'; +import { Interpolate, translate } from 'react-i18next'; +import { compose } from 'redux'; +import { IoIosNotificationsOutline } from 'react-icons/io'; + +function Notifications({ alerts, t, actions }) { + const [alertsList, setAlertsList] = useState([]); + + useEffect(() => { + // Empty list when mounts + actions.removeAllAlerts(); + }, []); + + useEffect(() => { + const newAlerts = alerts + .reverse() + .map((alert) => { + return typeof alert === 'string' ? { message: alert } : alert; + }) + .map((alert) => { + const interpolateParameters = alert ? alert.parameters : {}; + const i18nKey = alert && `alerts.${alert.type}.${alert.transKey}`; + let type, msg; + + type = alert.type ? oldValueMapping[alert.type] : 'warning'; + + msg = t(i18nKey, { + ...interpolateParameters, + defaultValue: alert.message || t('error.unknown') + }); + + return { type, msg }; + }); + + setAlertsList(newAlerts); + }, [alerts.length]); + + const isRTL = document.documentElement.dir === 'rtl'; + + return ( + + +
+
+ +
+ {alertsList.length > 0 ? t('userSettings.notifications') : ''} +
+
+ + +
+
+
+
+
+ {t('userSettings.notifications')} +
+
+ 1 + ? 'userSettings.notificationsSub_plural' + : 'userSettings.notificationsSub' + } + alertLength={alertsList.length} + /> +
+
+
+
+ {alertsList.length > 0 && ( + +
+ +
+ {alertsList.map((item, i) => ( + +

+ {item.type} +

+ {item.msg} +
+ ))} +
+
+
+ +
+ )} + + + ); +} + +const oldValueMapping = { + notice: 'success', + warning: 'warning', + error: 'error' +}; + +const colorsMapping = { + success: 'success', + warning: 'warning', + error: 'danger' +}; + +Notifications.propTypes = { + t: PropTypes.func.isRequired, + alerts: PropTypes.array.isRequired, + actions: PropTypes.object.isRequired +}; + +const applyDecorators = compose( + reduxConnect('alerts', ['common', 'alerts']), + translate(['common'], { wait: true }) +); + +export default applyDecorators(Notifications); diff --git a/frontend/app/components/App/AppHeader/SearchBox.js b/frontend/app/components/App/AppHeader/SearchBox.js new file mode 100644 index 0000000..5910877 --- /dev/null +++ b/frontend/app/components/App/AppHeader/SearchBox.js @@ -0,0 +1,39 @@ +import React, { Fragment } from 'react' + +import cx from 'classnames' + +class SearchBox extends React.Component { + constructor (props) { + super(props) + + this.state = { + activeSearch: false + } + } + + activeSearchFunc = () => { + this.setState({ activeSearch: !this.state.activeSearch }) + } + + render () { + return ( + +
+
+ + +
+
+
+ ) + } +} + +export default SearchBox diff --git a/frontend/app/components/App/AppHeader/SettingsPopup.js b/frontend/app/components/App/AppHeader/SettingsPopup.js new file mode 100644 index 0000000..f05bc22 --- /dev/null +++ b/frontend/app/components/App/AppHeader/SettingsPopup.js @@ -0,0 +1,122 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { + Button, + Label, + Input, + FormGroup, + Modal, + ModalHeader, + ModalBody, + ModalFooter +} from 'reactstrap'; + +export class SettingsPopup extends React.Component { + static propTypes = { + hidePopup: PropTypes.func.isRequired, + setErrorMsg: PropTypes.func.isRequired, + changePassword: PropTypes.func.isRequired, + errorMsg: PropTypes.string, + t: PropTypes.func.isRequired + }; + + constructor() { + super(); + this.state = { + oldPassword: '', + newPassword: '', + confirmPassword: '' + }; + } + + hidePopup = () => { + this.props.hidePopup(); + this.props.setErrorMsg(null); + }; + + onSubmit = () => { + const { t } = this.props; + const { oldPassword, newPassword, confirmPassword } = this.state; + + // need more validations + if (!oldPassword || !newPassword || !confirmPassword) { + return this.props.setErrorMsg(t('userSettings.enterRequiredFields')); + } + + if (newPassword !== confirmPassword) { + return this.props.setErrorMsg(t('userSettings.passwordsNotMatched')); + } + + if (oldPassword && newPassword) { + this.props.changePassword(newPassword, oldPassword); + } + }; + + handleChange = (e) => { + const { name, value } = e.target; + this.setState({ [name]: value }); + }; + + render() { + const { t, errorMsg } = this.props; + + return ( + + + {t('userSettings.changePassword')} + + + + + + + + + + + + + + + +

{errorMsg}

+
+ + + + + +
+ ); + } +} + +export default translate(['tabsContent', 'common'], { wait: true })( + SettingsPopup +); diff --git a/frontend/app/components/App/AppHeader/SubTabWrapper.js b/frontend/app/components/App/AppHeader/SubTabWrapper.js new file mode 100644 index 0000000..e2357bb --- /dev/null +++ b/frontend/app/components/App/AppHeader/SubTabWrapper.js @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { NavLink } from 'react-router-dom'; +import { translate } from 'react-i18next'; + +class SubTabWrapper extends React.Component { + static propTypes = { + activeTabName: PropTypes.string.isRequired, + subTabs: PropTypes.array.isRequired, + t: PropTypes.func.isRequired, + children: PropTypes.object + }; + + render() { + const { t, activeTabName, subTabs, children } = this.props; + + return ( +
+
+
+
+
+
+ {subTabs && + subTabs.map((subTab) => { + const tabText = + activeTabName === 'dashboard' + ? subTab.title + : t('tabs.' + subTab.title); + const fullUrl = + '/app/' + activeTabName + '/' + subTab.url; + + return ( + + {tabText} + + ); + })} +
+
+
+
+
+ {children} +
+ ); + } +} + +export default translate(['common'], { wait: true })(SubTabWrapper); diff --git a/frontend/app/components/App/AppHeader/UserSettingsMenu.js b/frontend/app/components/App/AppHeader/UserSettingsMenu.js new file mode 100644 index 0000000..5268b8d --- /dev/null +++ b/frontend/app/components/App/AppHeader/UserSettingsMenu.js @@ -0,0 +1,165 @@ +/* eslint-disable react/jsx-no-bind */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { compose } from 'redux'; +import { translate } from 'react-i18next'; +import { Nav, Button, NavItem, NavLink } from 'reactstrap'; +import PerfectScrollbar from 'react-perfect-scrollbar'; +import city from '../../../images/city3.jpg'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faUser } from '@fortawesome/free-solid-svg-icons'; +import { reduxActions } from '../../../redux/utils/connect'; +import tourPages from './WebTourSteps'; + +import { useHistory } from 'react-router'; +import { planRoutes } from '../Account/Plans/UserPlans'; + +function UserSettingsMenu(props) { + const { push } = useHistory(); + + function hideMenu() { + props.toggleMenu(); + props.actions.setEnableMobileMenuSmall(false); + } + + function showUserSettings() { + hideMenu(); + props.actions.showUserSettingsPopup(); + } + + function onLogout() { + hideMenu(); + props.actions.logout(); + } + + function tourGuide(path) { + const win = window.open(`${path}?webtour=true`, '_blank'); + win.focus(); + + // props.actions.toggleWebTour(); for dev + } + + function gotToActivePlan() { + hideMenu(); + push(`/app/plans/${planRoutes.current}`); + } + + const { t } = props; + + return ( + +
+
+
+
+
+
+
+
+ +
+
+
+
+ {props.userFirstName + ' ' + props.userLastName}{' '} +
+
+
+ +
+
+
+
+
+
+ + {/*
*/} +
+ + + +
+ + ); +} + +UserSettingsMenu.propTypes = { + toggleMenu: PropTypes.func.isRequired, + userFirstName: PropTypes.string.isRequired, + userLastName: PropTypes.string.isRequired, + actions: PropTypes.object.isRequired, + t: PropTypes.func.isRequired +}; + +const applyDecorators = compose( + reduxActions(), + translate(['common'], { wait: true }) +); + +export default React.memo(applyDecorators(UserSettingsMenu)); diff --git a/frontend/app/components/App/AppHeader/WebTour.js b/frontend/app/components/App/AppHeader/WebTour.js new file mode 100644 index 0000000..b14c5b2 --- /dev/null +++ b/frontend/app/components/App/AppHeader/WebTour.js @@ -0,0 +1,120 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { find } from 'lodash'; +import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock'; +import Tour from 'reactour'; +import { useHistory, useLocation } from 'react-router'; + +import reduxConnect from '../../../redux/utils/connect'; +import tourPages from './WebTourSteps'; + +function WebTour({ + actions, + store: { + common: { base }, + appState: { themeOptions } + } +}) { + const [hasSidebar, setHasSidebar] = useState(window.innerWidth > 991); + const [tourData, setTourData] = useState({ content: [] }); + const location = useLocation(); + const { replace } = useHistory(); + + const { isTourOpen = false } = base; + const params = new URLSearchParams(location.search); + const webtour = params.get('webtour'); + + useEffect(() => { + if (webtour) { + const tour = find(tourPages, { + to: location.pathname + }); + + if (tour) { + setTourData(tour); + window.gtag && window.gtag('event', 'tutorial_begin', { + name: tour.name + }); + actions.toggleWebTour(); // open tour if param is available + } + } else { + actions.toggleWebTour(); // close if param is removed + } + }, [webtour]); + + useEffect(() => { + if (isTourOpen) { + if (window.innerWidth > 991) { + !hasSidebar && setHasSidebar(true); + } else { + hasSidebar && setHasSidebar(false); + } + } + }, [window.innerWidth]); + + const accentColor = '#0094bd'; + + function closeWebTour() { + const queryParams = new URLSearchParams(location.search); + + if (queryParams.has('webtour')) { + queryParams.delete('webtour'); + replace({ + search: queryParams.toString() + }); + } + } + + function getCurrentStep(step) { + const stepState = tourData.content; + const stepDetails = stepState.find((v, i) => i === step); + + if (step === stepState.length - 1) { + window.gtag && window.gtag('event', 'tutorial_complete', { + name: tourData.name + }); + } + + if (!hasSidebar) { + if (stepDetails.needSidebar) { + !themeOptions.enableMobileMenu && actions.setEnableMobileMenu(true); + } else { + themeOptions.enableMobileMenu && actions.setEnableMobileMenu(false); + } + } + } + + function disableBody(target) { + disableBodyScroll(target); + } + + function enableBody(target) { + enableBodyScroll(target); + } + + return ( + 0} + maskClassName="mask" + className="helper" + rounded={5} + startAt={0} + closeWithMask={false} + accentColor={accentColor} + onAfterOpen={disableBody} + onBeforeClose={enableBody} + disableFocusLock + lastStepNextButton={
Finish
} + /> + ); +} + +WebTour.propTypes = { + actions: PropTypes.object, + store: PropTypes.object +}; + +export default reduxConnect()(WebTour); diff --git a/frontend/app/components/App/AppHeader/WebTourSteps.js b/frontend/app/components/App/AppHeader/WebTourSteps.js new file mode 100644 index 0000000..1700720 --- /dev/null +++ b/frontend/app/components/App/AppHeader/WebTourSteps.js @@ -0,0 +1,172 @@ +import React from 'react'; +import i18n from '../../../i18n'; +import { Trans } from 'react-i18next'; + +const baseKey = 'tabsContent:webtour'; + +const steps = [ + { + selector: '', + content: i18n.t(`${baseKey}.search.start`) + }, + { + selector: '[data-tour="left-panel"]', + content: i18n.t(`${baseKey}.search.feedsView`), + resizeObservables: ['[data-tour="left-panel"]'], + needSidebar: true, + stepInteraction: false + }, + { + selector: '[data-tour="app-header-left"]', + content: () => ( + + There are 3 main pages: Search to find content, + Analyze to generate reports, and Share + to distribute findings via alerts or webfeeds. + + ), + onlyWeb: true, + stepInteraction: false + }, + { + selector: '[data-tour="app-header-user-settings"]', + content: i18n.t(`${baseKey}.search.userSettings`), + stepInteraction: false + }, + { + selector: '[data-tour="search-licenses"]', + content: i18n.t(`${baseKey}.search.license`), + stepInteraction: false + }, + { + selector: '[data-tour="input-field-search"]', + content: () => ( +

+ + A simple boolean search looks like this: + BMW AND Texas. Which will find all mentions of “bmw” + and "texas”. + +

+ ) + }, + { + selector: '[data-tour="select-date-range"]', + content: i18n.t(`${baseKey}.search.dateRange`), + stepInteraction: false + }, + { + selector: '[data-tour="select-media-types"]', + content: i18n.t(`${baseKey}.search.mediaChannels`) + }, + { + selector: '[data-tour="advanced-search"]', + content: () => ( + + Click on Advanced Search to uncover the different + options for your search. + + ), + resizeObservables: ['[data-tour="advanced-search"]'] + }, + { + selector: '[data-tour="advanced-search"]', + content: () => ( + + Emphasis: Include or exclude specific words or phrases + in the headline of a news article or a blog post. + + ), + resizeObservables: ['[data-tour="advanced-search-content"]'] + }, + { + selector: '[data-tour="advanced-search"]', + content: () => ( + + Languages: Capture the content that is tagged with the + following language(s). + + ), + resizeObservables: ['[data-tour="advanced-search-content"]'] + }, + { + selector: '[data-tour="advanced-search"]', + content: () => ( + + Locations: Include or exclude content that is geotagged + with the following countries or US States. + + ), + resizeObservables: ['[data-tour="advanced-search-content"]'] + }, + { + selector: '[data-tour="advanced-search"]', + content: () => ( + + Extras: Only show posts with images. + + ), + resizeObservables: ['[data-tour="advanced-search-content"]'] + }, + /* { + selector: '[data-tour="search-button"]', + content: () => ( + + Click Search icon. + + ) + }, */ + { + selector: '[data-tour="search-buttons"]', + content: i18n.t(`${baseKey}.search.saveSearch`), + stepInteraction: false + } +]; + +const analyticsSteps = [ + { + selector: '', + content: i18n.t(`${baseKey}.analytics.start`) + }, + { + selector: '[data-tour="left-panel"]', + content: i18n.t(`${baseKey}.analytics.dragFeed`), + resizeObservables: ['[data-tour="left-panel"]'], + needSidebar: true + }, + { + selector: '[data-tour="drop-feeds-box"]', + highlightedSelectors: ['[data-tour="left-panel"]'], + content: i18n.t(`${baseKey}.analytics.drop`) + }, + { + selector: '[data-tour="analytics-data-range"]', + content: i18n.t(`${baseKey}.analytics.dateRange`), + observe: '.DateRangePickerInput' + }, + { + selector: '[data-tour="create-analytics-button"]', + content: i18n.t(`${baseKey}.analytics.create`) + } +]; + +const tourPages = [ + { + translateKey: 'HowToSearch', + name: 'How to Search', + icon: 'pe-7s-search', + to: '/app/search/search', + showOn: '/app/search/search', + content: steps + }, + { + translateKey: 'HowToAnalyze', + name: 'How to Analyze', + icon: 'pe-7s-graph1', + to: '/app/analyze/create', + showOn: '/app/analyze', + content: analyticsSteps + } +]; + +export default tourPages; diff --git a/frontend/app/components/App/Sidebar/AddCategoryPopup.js b/frontend/app/components/App/Sidebar/AddCategoryPopup.js new file mode 100644 index 0000000..3ac21f2 --- /dev/null +++ b/frontend/app/components/App/Sidebar/AddCategoryPopup.js @@ -0,0 +1,67 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { + Button, + Modal, + ModalHeader, + ModalBody, + Label, + Input, + ModalFooter +} from 'reactstrap'; + +export class AddCategoryPopup extends React.Component { + state = { + folderName: '' + }; + + static propTypes = { + parentId: PropTypes.number.isRequired, + hideAddCategoryPopup: PropTypes.func.isRequired, + addCategory: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + }; + + onChangeName = (e) => { + const { value } = e.target; // need validation + this.setState({ folderName: value }); + }; + + hidePopup = () => { + this.props.hideAddCategoryPopup(); + }; + + onSubmit = () => { + const { folderName } = this.state; + this.props.addCategory(folderName, this.props.parentId); + this.props.hideAddCategoryPopup(); + }; + + render() { + const { t } = this.props; + const { folderName } = this.state; + + return ( + + + {t('sidebarPopup.addFolderBtn')} + + + + + + + + + + + ); + } +} + +export default translate(['common'], { wait: true })(AddCategoryPopup); diff --git a/frontend/app/components/App/Sidebar/AddClippingsFeedPopup.js b/frontend/app/components/App/Sidebar/AddClippingsFeedPopup.js new file mode 100644 index 0000000..e65cc1d --- /dev/null +++ b/frontend/app/components/App/Sidebar/AddClippingsFeedPopup.js @@ -0,0 +1,121 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import Select from 'react-select'; +import { + Button, + Modal, + ModalHeader, + ModalBody, + FormGroup, + Label, + Input, + ModalFooter +} from 'reactstrap'; + +export class AddClippingsFeedPopup extends React.Component { + static propTypes = { + parentId: PropTypes.number.isRequired, + hidePopup: PropTypes.func.isRequired, + addClippingsFeed: PropTypes.func.isRequired, + addAlert: PropTypes.func.isRequired, + categories: PropTypes.array.isRequired, + t: PropTypes.func.isRequired + }; + + constructor(props) { + super(props); + this.state = { + parentId: props.parentId, + feedName: '' + }; + } + + onChangeName = (e) => { + const { value } = e.target; + this.setState({ feedName: value }); + }; + + hidePopup = () => { + this.props.hidePopup(); + }; + + onSubmit = () => { + const { parentId } = this.state; + const { addAlert, addClippingsFeed, hidePopup } = this.props; + const { feedName } = this.state; + if (feedName) { + addClippingsFeed(feedName, parentId); + hidePopup(); + } else { + addAlert({ + type: 'error', + transKey: 'feedNameEmpty' + }); + } + }; + + flattenCategories = (categories, level = '') => { + return categories.reduce((result, category) => { + result.push({ + label: + level + + this.props.t(`sidebar.${category.name}`, { + defaultValue: category.name + }), + value: category.id + }); + if (category.childes && category.childes.length) { + return result.concat( + this.flattenCategories(category.childes, '- ' + level) + ); + } + return result; + }, []); + }; + + onParentCategorySelect = (value) => { + this.setState({ parentId: value }); + }; + + render() { + const { t, categories } = this.props; + const { parentId, feedName } = this.state; + const options = this.flattenCategories(categories); + + return ( + + + {t('sidebarPopup.addClippingsFeed')} + + + + + + + + + + +
+ +
+ ) + } +} + +export default Filter diff --git a/frontend/app/components/App/Sidebar/RenamePopup.js b/frontend/app/components/App/Sidebar/RenamePopup.js new file mode 100644 index 0000000..ae0fb09 --- /dev/null +++ b/frontend/app/components/App/Sidebar/RenamePopup.js @@ -0,0 +1,90 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import { + Button, + Input, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader +} from 'reactstrap' + +export class RenamePopup extends React.Component { + static propTypes = { + itemToRename: PropTypes.object.isRequired, + hideRenamePopup: PropTypes.func.isRequired, + renameFeed: PropTypes.func.isRequired, + renameCategory: PropTypes.func.isRequired, + addAlert: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + } + + constructor(props) { + super(props) + this.state = { + itemName: props.itemToRename.itemName + } + } + + hidePopup = () => { + this.props.hideRenamePopup() + } + + onSubmit = () => { + const newName = this.state.itemName + const { + itemToRename, + renameFeed, + renameCategory, + hideRenamePopup + } = this.props + + switch (this.props.itemToRename.itemType) { + case 'feed': + renameFeed(itemToRename.itemId, newName, itemToRename.parentId) + break + case 'directory': + renameCategory(itemToRename.itemId, newName, itemToRename.parentId) + break + } + + hideRenamePopup() + } + + onChangeName = (e) => { + const { value } = e.target // validation needed + + this.setState({ + itemName: value + }) + } + + render() { + const itemName = this.state.itemName + const { t } = this.props + + return ( + + + {t('commonWords.Rename')} + + + + + + + + + + + ) + } +} + +export default translate(['common'], { wait: true })(RenamePopup) diff --git a/frontend/app/components/App/Sidebar/Sidebar.js b/frontend/app/components/App/Sidebar/Sidebar.js new file mode 100644 index 0000000..405947b --- /dev/null +++ b/frontend/app/components/App/Sidebar/Sidebar.js @@ -0,0 +1,155 @@ +import React, { Fragment } from 'react' +import PropTypes from 'prop-types' +import cx from 'classnames' +import Categories from './Categories' +import Filter from './Filter' +import DeletePopup from './DeletePopup' +import RenamePopup from './RenamePopup' +import AddCategoryPopup from './AddCategoryPopup' +import AddClippingsFeedPopup from './AddClippingsFeedPopup' +import LoadersAdvanced from '../../common/Loader/Loader' +import CSSTransitionGroup from 'react-transition-group/CSSTransitionGroup' +import PerfectScrollbar from 'react-perfect-scrollbar' +import HeaderLogo from '../AppHeader/HeaderLogo' + +export class Sidebar extends React.Component { + static propTypes = { + actions: PropTypes.object.isRequired, + themeOptions: PropTypes.object.isRequired, + backgroundColor: PropTypes.string, + backgroundImage: PropTypes.any, + backgroundImageOpacity: PropTypes.any, + enableBackgroundImage: PropTypes.any, + enableMobileMenu: PropTypes.any, + enableSidebarShadow: PropTypes.any, + setEnableMobileMenu: PropTypes.func, + t: PropTypes.func, + sidebarState: PropTypes.object.isRequired + } + + constructor (props) { + super(props) + this.state = { + sidebarAnimationDisabled: true, + activeSearch: false + } + } + + toggleMobileSidebar = () => { + let { enableMobileMenu, setEnableMobileMenu } = this.props + setEnableMobileMenu(!enableMobileMenu) + } + + componentDidMount = () => { + this.props.actions.getSidebarCategories() + } + + activeSearchFunc = () => { + this.setState({ activeSearch: !this.state.activeSearch }) + } + + render () { + let { + backgroundColor, + enableBackgroundImage, + enableSidebarShadow, + backgroundImage, + backgroundImageOpacity + } = this.props.themeOptions + + const { sidebarState, actions } = this.props + + return ( + +
+ + + {!sidebarState.areCategoriesLoaded && } + +
+
+
+ + + +
+ + {sidebarState.popupVisible.delete && ( + + )} + + {sidebarState.popupVisible.rename && ( + + )} + + {sidebarState.popupVisible.addCategory && ( + + )} + + {sidebarState.popupVisible.addClippingsFeed && ( + + )} +
+
+
+
+
+ + ) + } +} + +export default React.memo(Sidebar) diff --git a/frontend/app/components/App/Sidebar/SidebarDropdown.js b/frontend/app/components/App/Sidebar/SidebarDropdown.js new file mode 100644 index 0000000..2beef1e --- /dev/null +++ b/frontend/app/components/App/Sidebar/SidebarDropdown.js @@ -0,0 +1,154 @@ +import React from 'react' +import PropTypes from 'prop-types' +import $ from 'jquery' +import { translate } from 'react-i18next' + +export class SidebarDropdown extends React.Component { + static propTypes = { + itemName: PropTypes.string.isRequired, + itemSubType: PropTypes.string.isRequired, + itemType: PropTypes.string.isRequired, + itemId: PropTypes.number.isRequired, + itemExported: PropTypes.bool, + parentId: PropTypes.number.isRequired, + parentAttrId: PropTypes.string.isRequired, + showDeletePopup: PropTypes.func.isRequired, + showRenamePopup: PropTypes.func.isRequired, + showAddCategoryPopup: PropTypes.func, + showAddClippingsPopup: PropTypes.func, + toggleExportFeed: PropTypes.func, + toggleExportCategory: PropTypes.func, + t: PropTypes.func.isRequired, + hideDropDown: PropTypes.func.isRequired + }; + + constructor (props) { + super(props) + this.state = { + dropdownTopPos: 'auto', + dropdownBottomPos: 'auto', + dropdownOpacity: 0 + } + } + + componentDidMount = () => { + const topPos = $('#' + this.props.parentAttrId).offset().top - $(document).scrollTop() + const dropdownHeight = $('#sidebar-category-dropdown').height() + + if ($(window).height() - topPos >= dropdownHeight) { + this.setState({ + dropdownTopPos: topPos, + dropdownOpacity: 1 + }) + } else { + this.setState({ + dropdownBottomPos: 5, + dropdownOpacity: 1 + }) + } + }; + + onExportToggle = () => { + const {itemId, toggleExportFeed, itemExported, hideDropDown} = this.props + toggleExportFeed(itemId, !itemExported) + hideDropDown() + }; + + onExportCategoryToggle = () => { + const {itemId, toggleExportCategory, itemExported, hideDropDown} = this.props + toggleExportCategory(itemId, !itemExported) + hideDropDown() + }; + + onDelete = () => { + this.props.showDeletePopup(this.props.itemId, this.props.itemType, this.props.itemName, this.props.parentId) + }; + + onRename = () => { + this.props.showRenamePopup(this.props.itemId, this.props.itemType, this.props.itemName, this.props.parentId) + }; + + onAddCategory = () => { + // set this item id as parent of new category + this.props.showAddCategoryPopup(this.props.itemId) + }; + + onAddClippingsFeedPopup = () => { + this.props.showAddClippingsPopup(this.props.itemId) + }; + + render () { + const { itemSubType, t, itemExported } = this.props + let dropdown + + switch (itemSubType) { + case 'my_content': + dropdown = + break + + case 'shared_content': + dropdown = + break + + case 'custom': + dropdown = + break + + case 'query_feed': + dropdown = + break + + case 'clip_feed': + dropdown = + break + + } + + return ( + dropdown + ) + } +} + +export default translate(['common'], { wait: true })(SidebarDropdown) diff --git a/frontend/app/components/App/TabsContent/AnalyzeNewTab/AnalyzeTab.js b/frontend/app/components/App/TabsContent/AnalyzeNewTab/AnalyzeTab.js new file mode 100644 index 0000000..5672c85 --- /dev/null +++ b/frontend/app/components/App/TabsContent/AnalyzeNewTab/AnalyzeTab.js @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import CSSTransitionGroup from 'react-transition-group/CSSTransitionGroup'; +import SubTabWrapper from '../../AppHeader/SubTabWrapper'; +import { Redirect, Route, Switch, withRouter } from 'react-router-dom'; +import ShowCharts from './CreateAnalysisSubTab/ShowCharts'; +import SavedAnalysisSubTab from './SavedAnalysisSubTab/SavedAnalysisSubTab'; +import CreateAnalysisSubTab from './CreateAnalysisSubTab/CreateAnalysisSubTab'; + +function AnalyzeTab(props) { + const { subTabs, allowAnalytics, history, activeTabName, match } = props; + + if (!allowAnalytics) { + history.push('/app/search/search'); + return null; + } + + return ( + + + + {/* */} + + + + + + + + + ); +} + +AnalyzeTab.propTypes = { + activeTabName: PropTypes.string, + children: PropTypes.any, + history: PropTypes.object, + match: PropTypes.object, + allowAnalytics: PropTypes.bool, + subTabs: PropTypes.array +}; + +export default withRouter(AnalyzeTab); diff --git a/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/AlertDialog.js b/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/AlertDialog.js new file mode 100644 index 0000000..958a386 --- /dev/null +++ b/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/AlertDialog.js @@ -0,0 +1,293 @@ +import React, { Fragment, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + Form, + FormGroup, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader +} from 'reactstrap'; +import Select from 'react-select'; +import { Input, Checkbox, RadioButton } from '../../../../common/FormControls'; +import useForm from '../../../../common/hooks/useForm.js'; +import { EXTRAS } from '../../../../../redux/modules/appState/share/forms/alertForm'; +import { createAlertAPI } from '../../../../../api/analytics/createAnalytics'; +import { getCurrentTimezone, timezones } from '../../../../../common/Timezones'; +import { compose } from 'redux'; +import reduxConnect from '../../../../../redux/utils/connect'; +import translate from 'react-i18next/dist/commonjs/translate'; +import { THEME_TYPES } from '../../../../../redux/modules/appState/share/forms/notificationForm'; + +const initialForm = { + name: '', + recipients: [], + subject: '', + automatedSubject: false, + unsubscribeNotification: false, + published: false, + allowUnsubscribe: false, + articleExtracts: EXTRAS.CONTEXTUAL, + highlight: false, + showSourceCountry: false, + showUserComments: false, + themeType: THEME_TYPES.PLAIN, + sendWhenEmpty: false, + timezone: getCurrentTimezone(), + notificationType: 'alert', + // automatic: [], // auto schedule + // sentUntil: '', + errors: { + name: null + } +}; + +function AlertDialog(props) { + const { toggle, isOpen, alertCharts, actions, resetAlertChart, user } = props; + const [loading, setLoading] = useState(false); + const { + form, + handleChange, + handleValidation, + errors, + validateSubmit, + resetForm + } = useForm(initialForm); + + function handleSubmit() { + const obj = validateSubmit(); + if (!obj) { + return actions.addAlert({ type: 'error', transKey: 'requiredInfo' }); + } + setLoading(true); + if (obj.automatedSubject) { + delete obj.subject; + } + + obj.sources = alertCharts.map((chart) => ({ + id: chart.id, + type: 'chart' + })); + + createAlertAPI(obj).then((res) => { + if (res.error) { + res.data + ? actions.addAlert(res.data) + : actions.addAlert({ type: 'error', transKey: 'somethingWrong' }); + setLoading(false); + return; + } + actions.addAlert({ type: 'notice', transKey: 'alertSaved' }); + setLoading(false); + toggle(); + resetForm(); + resetAlertChart(); + }); + } + + useEffect(() => { + if (form.recipients && user.recipient && user.recipient.id) { + handleChange('recipients', [user.recipient.id]); + } + + return () => resetForm(); + }, []); + + return ( + + Create Alert + +
+ + +
+ {alertCharts.map((chart, i, arr) => ( + + + {chart.name} + {arr.length - 1 !== i ? ', ' : ''} + + + ))} +
+
+ + + {!form.automatedSubject && ( + + )} + + + + + + + + + + + + + + +
+ ); + })} + + ); +} + +const filtersNames = [ + { name: 'Source', id: 0 }, + { name: 'Author', id: 1 } +]; + +Influencers.propTypes = { + t: PropTypes.func.isRequired, + feedData: PropTypes.object, + id: PropTypes.string, + actions: PropTypes.object +}; + +const applyDecorators = compose( + translate(['tabsContent'], { wait: true }), + reduxActions() +); + +export default applyDecorators(Influencers); diff --git a/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/Performance.js b/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/Performance.js new file mode 100644 index 0000000..ea4eb15 --- /dev/null +++ b/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/Performance.js @@ -0,0 +1,722 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Col, Row } from 'reactstrap'; +import ECharts from '../../../../../common/charts/ECharts'; +import ChartWrapper from '../ChartWrapper'; +import { + getBarOptions, + getPieOptions +} from '../../../../../common/charts/ChartsOptions'; +import { IoIosAdd, IoIosRefresh, IoIosCheckmark } from 'react-icons/io'; +import reduxConnect from '../../../../../../redux/utils/connect'; +import translate from 'react-i18next/dist/commonjs/translate'; +import { compose } from 'redux'; +import { + getEngagementsAPI, + getEngagementsTimeAPI, + getOverviewBarAPI, + getOverviewPieAPI +} from '../../../../../../api/analytics/createAnalytics'; +import useIsMounted from '../../../../../common/hooks/useIsMounted'; + +function Performance(props) { + const { actions, analyze, feedData, id, t } = props; + const isMounted = useIsMounted(); + const [barData, setBarData] = useState({ + data: [], + error: undefined, + loading: true, + vertical: false + }); + const [engBarData, setEngBarData] = useState({ + data: [], + error: undefined, + loading: true, + vertical: false + }); + const [potentialBarData, setPotentialBarData] = useState({ + data: [], + error: undefined, + loading: true, + vertical: false + }); + const [sentimentBar, setSentimentBar] = useState({ + data: [], + error: undefined, + loading: true + }); + const [pieMentions, setpieMentions] = useState({ + data: [], + error: undefined, + loading: true + }); + const [pieEng, setpieEng] = useState({ + data: [], + error: undefined, + loading: true + }); + /* const [pieReach, setpieReach] = useState({ + data: [], + error: undefined, + loading: true + }); */ + + useEffect(() => { + // pass filter + if (!id) { + return; + } + getBarChart(); + getEngBarChart(); + // getPotentialChart() + getSentimentChart(); + getpieMentions(); + getpieEngg(); + // getpieReach() + }, []); + + function updateResult(foo, id) { + switch (id) { + case cn.first: + getBarChart(); + return; + case cn.second: + getEngBarChart(); + return; + case cn.third: + // getPotentialChart() // Uncomment when API has data + return; + case cn.fourth: + getSentimentChart(); + return; + case cn.fifth: + getpieMentions(); + return; + case cn.sixth: + getpieEngg(); + return; + case cn.seventh: + // getpieReach() // Uncomment when API has data + return; + default: + return; + } + } + + useEffect(() => { + if (barData.data) { + setBarData((prev) => ({ + ...prev, + data: { + ...prev.data, + xAxis: prev.data.yAxis, + yAxis: prev.data.xAxis + } + })); + } + }, [barData.vertical]); + + useEffect(() => { + if (engBarData.data) { + setEngBarData((prev) => ({ + ...prev, + data: { + ...prev.data, + xAxis: prev.data.yAxis, + yAxis: prev.data.xAxis + } + })); + } + }, [engBarData.vertical]); + + useEffect(() => { + if (potentialBarData.data) { + setPotentialBarData((prev) => ({ + ...prev, + data: { + ...prev.data, + xAxis: prev.data.yAxis, + yAxis: prev.data.xAxis + } + })); + } + }, [potentialBarData.vertical]); + + function getBarChart() { + setBarData((prev) => ({ ...prev, loading: true })); + getOverviewBarAPI('none', id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // on error + setBarData((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + const { data } = res.data; + const labels = Object.keys(data[0].data); + + const datasets = data.map((item) => ({ + name: item.name, + type: barData.vertical ? 'bar' : 'line', + smooth: true, + data: Object.values(item.data) + })); + + const barOptions = getBarOptions(datasets, labels); + + setBarData({ + data: barOptions, + error: false, + loading: false, + vertical: false + }); + }); + } + + function getEngBarChart() { + setEngBarData((prev) => ({ ...prev, loading: true })); + getEngagementsTimeAPI(id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // on error + setEngBarData((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + + const { data } = res.data; + const labels = Object.keys(data[0].data); + const datasets = data.map((item) => ({ + name: item.name, + type: barData.vertical ? 'bar' : 'line', + smooth: true, + data: Object.values(item.data) + })); + + const barOptions = getBarOptions(datasets, labels); + + setEngBarData({ + data: barOptions, + error: false, + loading: false, + vertical: false + }); + }); + } + /* + function getPotentialChart() { + setPotentialBarData((prev) => ({ ...prev, loading: true })); + getOverviewBarAPI('none', id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // on error + setPotentialBarData((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + const { data } = res.data; + const labels = Object.keys(data); + + const datasets = { + name: 'Potential reach over time', + type: potentialBarData.vertical ? 'bar' : 'line', + smooth: true, + data: Object.values(data) + }; + + const barOptions = getBarOptions(datasets, labels); + + setPotentialBarData({ + data: barOptions, + error: false, + loading: false, + vertical: false + }); + }); + } */ + + function getSentimentChart() { + setSentimentBar((prev) => ({ ...prev, loading: true })); + getOverviewPieAPI('sentiment', id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // on error + setSentimentBar((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + const { data } = res.data; + const barOptions = {}; + Object.keys(data).forEach((feed) => { + const labels = ['Results']; + const datasets = ['POSITIVE', 'NEGATIVE', 'NEUTRAL'].map((item) => ({ + name: item, + type: 'bar', + data: [data[feed][item]] + })); + + barOptions[feed] = getBarOptions(datasets, labels); + }); + + setSentimentBar({ + data: barOptions, + error: false, + loading: false, + vertical: false + }); + }); + } + + function getpieMentions() { + setpieMentions((prev) => ({ ...prev, loading: true })); + getOverviewPieAPI('none', id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // alert on error + setpieMentions((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + + const { data } = res.data; + const pieOptions = getPieOptions( + Object.entries(data).map((v) => ({ name: v[0], value: v[1] })) + ); + + setpieMentions({ + data: pieOptions, + error: false, + loading: false + }); + }); + } + + function getpieEngg() { + setpieEng((prev) => ({ ...prev, loading: true })); + getEngagementsAPI(id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // alert on error + setpieEng((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + + // condition for other filter than 0 + const { data } = res.data; + const pieOptions = getPieOptions( + Object.entries(data).map((v) => ({ name: v[0], value: v[1] })) + ); + + setpieEng({ + data: pieOptions, + error: false, + loading: false + }); + }); + } + /* + function getpieReach() { + setpieReach((prev) => ({ ...prev, loading: true })); + getOverviewPieAPI('none', id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // alert on error + setpieReach((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + + const { data } = res.data; + const pieOptions = getPieOptions( + Object.entries(data).map((v) => ({ name: v[0], value: v[1] })) + ); + + setpieReach({ + data: pieOptions, + error: false, + loading: false + }); + }); + } */ + + function changeVertical(chart) { + switch (chart) { + case cn.first: + setBarData((prev) => ({ ...prev, vertical: !prev.vertical })); + return; + case cn.second: + setEngBarData((prev) => ({ ...prev, vertical: !prev.vertical })); + return; + case cn.third: + setPotentialBarData((prev) => ({ ...prev, vertical: !prev.vertical })); + return; + default: + return; + } + } + + const hideChart1Alert = analyze.alertCharts.find((v) => v.name === cn.first); + const hideChart2Alert = analyze.alertCharts.find((v) => v.name === cn.second); + // const hideChart3Alert = analyze.alertCharts.find((v) => v.name === cn.third); + const hideChart4Alert = (id) => + analyze.alertCharts.find((v) => v.name === cn.fourth && v.id === id); + const hideChart5Alert = analyze.alertCharts.find((v) => v.name === cn.fifth); + const hideChart6Alert = analyze.alertCharts.find((v) => v.name === cn.sixth); + /* const hideChart7Alert = analyze.alertCharts.find( + (v) => v.name === cn.seventh + ); */ + + const barchart1Menus = [ + { + title: '', // t('analyzeTab.chartMenus.addToAlert'), + icon: IoIosAdd, + size: 24, + fn: () => actions.addAlertChart({ name: cn.first, id: 'none' }), + showInMore: false, + hide: hideChart1Alert + }, + { + title: '', // t('analyzeTab.chartMenus.addedToAlerts'), + icon: IoIosCheckmark, + size: 24, + showInMore: false, + hide: !hideChart1Alert + }, + { + title: t('analyzeTab.chartMenus.refresh'), + icon: IoIosRefresh, + fn: () => updateResult(null, cn.first), + showInMore: false + }, + /* { + title: t('analyzeTab.chartMenus.addToDashboard'), + fn: () => {}, + showInMore: true + }, */ + { + title: t('analyzeTab.chartMenus.toggleHV'), + fn: () => changeVertical(cn.first), + showInMore: true + } + ]; + + const barchart2Menus = [ + { + title: '', // t('analyzeTab.chartMenus.addToAlert'), + icon: IoIosAdd, + size: 24, + fn: () => actions.addAlertChart({ name: cn.second, id: 'none' }), + showInMore: false, + hide: hideChart2Alert + }, + { + title: '', // t('analyzeTab.chartMenus.addedToAlerts'), + icon: IoIosCheckmark, + size: 24, + showInMore: false, + hide: !hideChart2Alert + }, + { + title: t('analyzeTab.chartMenus.refresh'), + icon: IoIosRefresh, + fn: () => updateResult(null, cn.second), + showInMore: false + }, + /* { + title: t('analyzeTab.chartMenus.addToDashboard'), + fn: () => {}, + showInMore: true + }, */ + { + title: t('analyzeTab.chartMenus.toggleHV'), + fn: () => changeVertical(cn.second), + showInMore: true + } + ]; + /* + const barchart3Menus = [ + { + title: '', // t('analyzeTab.chartMenus.addToAlert'), + icon: IoIosAdd, + size: 24, + fn: () => actions.addAlertChart({ name: cn.third, id: 'none' }), + showInMore: false, + hide: hideChart3Alert + }, + { + title: '', // t('analyzeTab.chartMenus.addedToAlerts'), + icon: IoIosCheckmark, + size: 24, + showInMore: false, + hide: !hideChart3Alert + }, + { + title: t('analyzeTab.chartMenus.refresh'), + icon: IoIosRefresh, + fn: () => updateResult(null, cn.third), + showInMore: false + }, + { + title: t('analyzeTab.chartMenus.addToDashboard'), + fn: () => {}, + showInMore: true + }, + { + title: t('analyzeTab.chartMenus.toggleHV'), + fn: () => changeVertical(cn.third), + showInMore: true + } + ]; + */ + function barchart4Menus(id) { + return [ + { + title: '', // t('analyzeTab.chartMenus.addToAlert'), + icon: IoIosAdd, + size: 24, + fn: () => actions.addAlertChart({ name: cn.fourth, id }), + showInMore: false, + hide: hideChart4Alert(id) + }, + { + title: '', // t('analyzeTab.chartMenus.addedToAlerts'), + icon: IoIosCheckmark, + size: 24, + showInMore: false, + hide: !hideChart4Alert(id) + }, + { + title: t('analyzeTab.chartMenus.refresh'), + icon: IoIosRefresh, + fn: () => updateResult(null, cn.fourth, id), + showInMore: false + } + /* { + title: t('analyzeTab.chartMenus.addToDashboard'), + fn: () => {}, + showInMore: true + } */ + ]; + } + + const pieChart1 = [ + { + title: '', // t('analyzeTab.chartMenus.addToAlert'), + icon: IoIosAdd, + size: 24, + fn: () => actions.addAlertChart({ name: cn.fifth, id: 'none' }), + showInMore: false, + hide: hideChart5Alert + }, + { + title: '', // t('analyzeTab.chartMenus.addedToAlerts'), + icon: IoIosCheckmark, + size: 24, + showInMore: false, + hide: !hideChart5Alert + }, + { + title: t('analyzeTab.chartMenus.refresh'), + icon: IoIosRefresh, + fn: () => updateResult(null, cn.fifth), + showInMore: false + } + // { title: t('analyzeTab.chartMenus.addToDashboard'), fn: () => {}, showInMore: true } + ]; + + const pieChart2 = [ + { + title: '', // t('analyzeTab.chartMenus.addToAlert'), + icon: IoIosAdd, + size: 24, + fn: () => actions.addAlertChart({ name: cn.sixth, id: 'none' }), + showInMore: false, + hide: hideChart6Alert + }, + { + title: '', // t('analyzeTab.chartMenus.addedToAlerts'), + icon: IoIosCheckmark, + size: 24, + showInMore: false, + hide: !hideChart6Alert + }, + { + title: t('analyzeTab.chartMenus.refresh'), + icon: IoIosRefresh, + fn: () => updateResult(null, cn.sixth), + showInMore: false + } + // { title: t('analyzeTab.chartMenus.addToDashboard'), fn: () => {}, showInMore: true } + ]; + /* + const pieChart3 = [ + { + title: '', // t('analyzeTab.chartMenus.addToAlert'), + icon: IoIosAdd, + size: 24, + fn: () => actions.addAlertChart({ name: cn.seventh, id: 'none' }), + showInMore: false, + hide: hideChart7Alert + }, + { + title: '', // t('analyzeTab.chartMenus.addedToAlerts'), + icon: IoIosCheckmark, + size: 24, + showInMore: false, + hide: !hideChart7Alert + }, + { + title: t('analyzeTab.chartMenus.refresh'), + icon: IoIosRefresh, + fn: () => updateResult(null, cn.seventh), + showInMore: false + } + // { title: t('analyzeTab.chartMenus.addToDashboard'), fn: () => {}, showInMore: true } + ]; + */ + return ( + + + + + + + + + + + + + + + + + + + + + + {/* + + + + + + + + + */} + {feedData.feeds.map((feed) => ( + + + + + + ))} + + ); +} + +const cn = { + first: 'Mentions over time', + second: 'Engagement over time', + third: 'Potential reach over time', + fourth: 'Proportion of sentiment', + fifth: 'Mentions', + sixth: 'Engagement', + seventh: 'Potential Reach' +}; + +Performance.propTypes = { + chartData: PropTypes.object, + actions: PropTypes.object, + feedData: PropTypes.object, + id: PropTypes.string, + analyze: PropTypes.object, + t: PropTypes.func +}; + +const applyDecorators = compose( + reduxConnect('analyze', ['appState', 'analyze']), + translate(['tabsContent'], { wait: true }) +); + +export default applyDecorators(React.memo(Performance)); diff --git a/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/Results.js b/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/Results.js new file mode 100644 index 0000000..226ba72 --- /dev/null +++ b/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/Results.js @@ -0,0 +1,403 @@ +import React, { Fragment, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Button, ButtonGroup, Col, Row } from 'reactstrap'; +import ECharts from '../../../../../common/charts/ECharts'; +import ChartWrapper from '../ChartWrapper'; +import { + getBarOptions, + getPieOptions +} from '../../../../../common/charts/ChartsOptions'; +import { IoIosAdd, IoIosRefresh, IoIosCheckmark } from 'react-icons/io'; +import reduxConnect from '../../../../../../redux/utils/connect'; +import translate from 'react-i18next/dist/commonjs/translate'; +import { compose } from 'redux'; +import { + getOverviewBarAPI, + getOverviewPieAPI +} from '../../../../../../api/analytics/createAnalytics'; +import useIsMounted from '../../../../../common/hooks/useIsMounted'; + +const initialBar = { + data: [], + error: undefined, + loading: true, + vertical: false +}; + +const initialPie = { data: [], error: undefined, loading: true }; + +function ResultsTab(props) { + const { actions, analyze, feedData, id, t } = props; + const isMounted = useIsMounted(); + const [barData, setBarData] = useState(initialBar); + const [barTimeData, setBarTimeData] = useState(initialBar); + const [pieData, setPieData] = useState(initialPie); + const [pieTimeData, setPieTimeData] = useState(initialPie); + const [filter, setFilter] = useState(filtersNames[0].id); + + useEffect(() => { + if (!id) { + return; + } + if (filter === filtersNames[0].id) { + getBarChart(); + getPieChart(); + } else { + getBarChartFeeds(); + getPieChartFeeds(); + } + }, [filter]); + + useEffect(() => { + if (barData.data) { + setBarData((prev) => ({ + ...prev, + data: { + ...prev.data, + xAxis: prev.data.yAxis, + yAxis: prev.data.xAxis + } + })); + } + }, [barData.vertical]); + + function updateResult(foo, id) { + switch (id) { + case cn.first: + filter === filtersNames[0].id ? getBarChart() : getBarChartFeeds(); + return; + case cn.second: + filter === filtersNames[0].id ? getPieChart() : getPieChartFeeds(); + return; + } + } + + function getBarChart() { + setBarData((prev) => ({ ...prev, loading: true })); + getOverviewBarAPI(filter, id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // on error + setBarData((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + const { data } = res.data; + const labels = data[0] ? Object.keys(data[0].data) : []; + const datasets = data.map((item) => ({ + name: item.name, + type: barData.vertical ? 'bar' : 'line', + smooth: true, + data: Object.values(item.data) + })); + + const barOptions = getBarOptions(datasets, labels); + + setBarData({ + data: barOptions, + error: false, + loading: false, + vertical: false + }); + }); + } + + function getBarChartFeeds() { + setBarTimeData((prev) => ({ ...prev, loading: true })); + getOverviewBarAPI(filter, id).then((res) => { + if (!isMounted.current) { + return false; + } + + if (res.error || !res.data.data) { + // on error + setBarTimeData((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + const { data } = res.data; + const barOptions = {}; + const errors = {}; + + data.map((feed) => { + const { name, data } = feed; + + if (!data || (Array.isArray(data) && data.length < 1)) { + errors[name] = t('analyzeTab.noData'); + return; + } + + const labels = Object.keys(data[0].data).sort(); + const datasets = data.map((item) => ({ + name: item.name, + type: barTimeData.vertical ? 'bar' : 'line', + smooth: true, + data: labels.map((v) => item.data[v]) + })); + + barOptions[name] = getBarOptions(datasets, labels); + }); + + setBarTimeData({ + data: barOptions, + error: errors, + loading: false, + vertical: false + }); + }); + } + + function getPieChart() { + setPieData((prev) => ({ ...prev, loading: true })); + getOverviewPieAPI(filter, id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // alert on error + setPieData((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + + const { data } = res.data; + const pieOptions = getPieOptions( + Object.entries(data).map((v) => ({ name: v[0], value: v[1] })) + ); + + setPieData({ + data: pieOptions, + error: false, + loading: false + }); + }); + } + + function getPieChartFeeds() { + setPieTimeData((prev) => ({ ...prev, loading: true })); + getOverviewPieAPI(filter, id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // alert on error + setPieTimeData((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + + const { data } = res.data; + const pieOptions = {}; + const errors = {}; + + Object.entries(data).forEach((feed) => { + const [name, value] = feed; + + if (!value || (Array.isArray(value) && value.length < 1)) { + errors[name] = t('analyzeTab.noData'); + } + + pieOptions[name] = getPieOptions( + Object.entries(value).map((v) => ({ + name: v[0], + value: v[1] + })) + ); + }); + + setPieTimeData({ + data: pieOptions, + error: errors, + loading: false + }); + }); + } + + function changeVertical() { + setBarData((prev) => ({ ...prev, vertical: !prev.vertical })); + } + + const hideChart1Alert = (id) => + analyze.alertCharts.find((v) => v.name === cn.first && v.id === id); + const hideChart2Alert = (id) => + analyze.alertCharts.find((v) => v.name === cn.second && v.id === id); + + const barchartMenus = (id) => [ + { + title: '', // t('analyzeTab.chartMenus.addToAlert'), + icon: IoIosAdd, + size: 24, + fn: () => actions.addAlertChart({ name: cn.first, id }), + showInMore: false, + hide: hideChart1Alert(id) + }, + { + title: '', // t('analyzeTab.chartMenus.addedToAlerts'), + icon: IoIosCheckmark, + size: 24, + showInMore: false, + hide: !hideChart1Alert(id) + }, + { + title: t('analyzeTab.chartMenus.refresh'), + icon: IoIosRefresh, + fn: () => updateResult(null, cn.first), + showInMore: false + }, + /* { + title: t('analyzeTab.chartMenus.addToDashboard'), + fn: () => {}, + showInMore: true + }, */ + { + title: 'Toggle Horizontal/Vertical', + fn: changeVertical, + showInMore: true + } + ]; + const piechartMenus = (id) => [ + { + title: '', // t('analyzeTab.chartMenus.addToAlert'), + icon: IoIosAdd, + size: 24, + fn: () => actions.addAlertChart({ name: cn.second, id }), + showInMore: false, + hide: hideChart2Alert(id) + }, + { + title: '', // t('analyzeTab.chartMenus.addedToAlerts'), + icon: IoIosCheckmark, + size: 24, + showInMore: false, + hide: !hideChart2Alert(id) + }, + { + title: t('analyzeTab.chartMenus.refresh'), + icon: IoIosRefresh, + fn: () => updateResult(null, cn.second), + showInMore: false + } + // { title: t('analyzeTab.chartMenus.addToDashboard'), fn: () => {}, showInMore: true } + ]; + + return ( + +
+ + {filtersNames.map((item) => ( + + ))} + +
+ {filter === filtersNames[0].id ? ( // feeds in single graph + +
+ + + + + + + + + + + ) : ( + feedData.feeds.map((feed) => ( + + + + + + + + + + + + + )) + )} + + ); +} + +const cn = { + first: 'Mentions Over Time', + second: 'Share of Mentions' +}; + +const filtersNames = [ + { name: 'None', transKey: 'none', id: 'none' }, + { name: 'Media Types', transKey: 'mediaTypes', id: 'media' }, + { name: 'Sentiments', transKey: 'sentiments', id: 'sentiment' }, + // { name: 'Countries', transKey:'countries', id: 'country' }, + { name: 'Languages', transKey: 'languages', id: 'language' } +]; + +ResultsTab.propTypes = { + actions: PropTypes.object, + id: PropTypes.string, + t: PropTypes.func, + feedData: PropTypes.object, + analyze: PropTypes.object +}; + +const applyDecorators = compose( + reduxConnect('analyze', ['appState', 'analyze']), + translate(['tabsContent'], { wait: true }) +); + +export default applyDecorators(React.memo(ResultsTab)); diff --git a/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/Sentiment.js b/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/Sentiment.js new file mode 100644 index 0000000..c8df20d --- /dev/null +++ b/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/Sentiment.js @@ -0,0 +1,255 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Col, Row } from 'reactstrap'; +import ECharts from '../../../../../common/charts/ECharts'; +import ChartWrapper from '../ChartWrapper'; +import { + getBarOptions, + getPieOptions +} from '../../../../../common/charts/ChartsOptions'; +import { IoIosAdd, IoIosRefresh, IoIosCheckmark } from 'react-icons/io'; +import reduxConnect from '../../../../../../redux/utils/connect'; +import translate from 'react-i18next/dist/commonjs/translate'; +import { compose } from 'redux'; +import { + getOverviewBarAPI, + getOverviewPieAPI +} from '../../../../../../api/analytics/createAnalytics'; +import useIsMounted from '../../../../../common/hooks/useIsMounted'; + +const initialBar = { + data: [], + error: undefined, + loading: true, + vertical: false +}; + +const initialPie = { data: [], error: undefined, loading: true }; + +function Sentiment(props) { + const { actions, analyze, feedData, id, t } = props; + const isMounted = useIsMounted(); + const [barData, setBarData] = useState(initialBar); + const [pieData, setPieData] = useState(initialPie); + + useEffect(() => { + if (!id) { + return; + } + getBarChart(); + getPieChart(); + }, []); + + useEffect(() => { + if (barData.data) { + setBarData((prev) => ({ + ...prev, + data: { + ...prev.data, + xAxis: prev.data.yAxis, + yAxis: prev.data.xAxis + } + })); + } + }, [barData.vertical]); + + function updateResult(foo, id) { + switch (id) { + case cn.first: + getBarChart(); + return; + case cn.second: + getPieChart(); + return; + } + } + + function getBarChart() { + setBarData((prev) => ({ ...prev, loading: true })); + getOverviewBarAPI('sentiment', id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // on error + setBarData((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + const { data } = res.data; + const barOptions = {}; + data.forEach((feed) => { + const { name, data } = feed; + const labels = Object.keys(data[0].data).sort(); + const datasets = data.map((item) => ({ + name: item.name, + type: barData.vertical ? 'bar' : 'line', + smooth: true, + data: labels.map((v) => item.data[v]) + })); + + barOptions[name] = getBarOptions(datasets, labels); + }); + + setBarData({ + data: barOptions, + error: false, + loading: false, + vertical: false + }); + }); + } + + function getPieChart() { + setPieData((prev) => ({ ...prev, loading: true })); + getOverviewPieAPI('sentiment', id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // alert on error + setPieData((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + + const { data } = res.data; + const pieOptions = {}; + Object.entries(data).forEach((feed) => { + const [name, value] = feed; + pieOptions[name] = getPieOptions( + Object.entries(value).map((v) => ({ + name: v[0], + value: v[1] + })) + ); + }); + + setPieData({ + data: pieOptions, + error: false, + loading: false + }); + }); + } + + function changeVertical() { + setBarData((prev) => ({ ...prev, vertical: !prev.vertical })); + } + + const hideChart1Alert = (id) => + analyze.alertCharts.find((v) => v.name === cn.first && v.id === id); + const hideChart2Alert = (id) => + analyze.alertCharts.find((v) => v.name === cn.second && v.id === id); + + const barchartMenus = (id) => [ + { + title: '', // t('analyzeTab.chartMenus.addToAlert'), + icon: IoIosAdd, + size: 24, + fn: () => actions.addAlertChart({ name: cn.first, id }), + showInMore: false, + hide: hideChart1Alert(id) + }, + { + title: '', // t('analyzeTab.chartMenus.addedToAlerts'), + icon: IoIosCheckmark, + size: 24, + showInMore: false, + hide: !hideChart1Alert(id) + }, + { + title: t('analyzeTab.chartMenus.refresh'), + icon: IoIosRefresh, + fn: () => updateResult(null, cn.first), + showInMore: false + }, + /* { + title: t('analyzeTab.chartMenus.addToDashboard'), + fn: () => {}, + showInMore: true + }, */ + { + title: 'Toggle Horizontal/Vertical', + fn: changeVertical, + showInMore: true + } + ]; + const piechartMenus = (id) => [ + { + title: '', // t('analyzeTab.chartMenus.addToAlert'), + icon: IoIosAdd, + size: 24, + fn: () => actions.addAlertChart({ name: cn.second, id }), + showInMore: false, + hide: hideChart2Alert(id) + }, + { + title: '', // t('analyzeTab.chartMenus.addedToAlerts'), + icon: IoIosCheckmark, + size: 24, + showInMore: false, + hide: !hideChart2Alert(id) + }, + { + title: t('analyzeTab.chartMenus.refresh'), + icon: IoIosRefresh, + fn: () => updateResult(null, cn.second), + showInMore: false + } + // { title: t('analyzeTab.chartMenus.addToDashboard'), fn: () => {}, showInMore: true } + ]; + + return feedData.feeds.map((feed) => ( + + + + + + + + + + + + + )); +} + +const cn = { + first: 'Sentiment Over Time', + second: 'Share of Sentiment' +}; + +Sentiment.propTypes = { + actions: PropTypes.object, + feedData: PropTypes.object, + analyze: PropTypes.object, + t: PropTypes.func +}; + +const applyDecorators = compose( + reduxConnect('analyze', ['appState', 'analyze']), + translate(['tabsContent'], { wait: true }) +); + +export default applyDecorators(React.memo(Sentiment)); diff --git a/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/Themes.js b/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/Themes.js new file mode 100644 index 0000000..5584b1b --- /dev/null +++ b/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/Themes.js @@ -0,0 +1,284 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Col, Row } from 'reactstrap'; +import ECharts from '../../../../../common/charts/ECharts'; +import 'echarts-wordcloud'; +import { capitalize } from 'lodash'; +import ChartWrapper from '../ChartWrapper'; +import { + getBarOptions, + PieToolbox, + WordCloudOptions +} from '../../../../../common/charts/ChartsOptions'; +import { IoIosAdd, IoIosRefresh, IoIosCheckmark } from 'react-icons/io'; +import reduxConnect from '../../../../../../redux/utils/connect'; +import translate from 'react-i18next/dist/commonjs/translate'; +import { compose } from 'redux'; +import { + getThemesCloudAPI, + getThemesTimeAPI +} from '../../../../../../api/analytics/createAnalytics'; +import useIsMounted from '../../../../../common/hooks/useIsMounted'; +import { capFirstLetter } from '../../../../../../common/helper'; + +const initialBar = { + data: [], + error: undefined, + loading: true, + vertical: false +}; + +const initialPie = { data: [], error: undefined, loading: true }; + +function Themes(props) { + const { actions, analyze, feedData, id, t } = props; + const isMounted = useIsMounted(); + const [barData, setBarData] = useState(initialBar); + const [wordData, setWordData] = useState(initialPie); + + useEffect(() => { + if (!id) { + return; + } + getBarChart(); + getWordCloud(); + }, []); + + useEffect(() => { + if (barData.data) { + setBarData((prev) => ({ + ...prev, + data: { + ...prev.data, + xAxis: prev.data.yAxis, + yAxis: prev.data.xAxis + } + })); + } + }, [barData.vertical]); + + function updateResult(foo, id) { + switch (id) { + case cn.first: + getBarChart(); + return; + case cn.second: + getWordCloud(); + return; + } + } + + function getBarChart() { + setBarData((prev) => ({ ...prev, loading: true })); + getThemesTimeAPI(id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // on error + setBarData((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + const { data } = res.data; + let labels = null; + const barOptions = {}; + const errors = {}; + data.forEach((feedData) => { + const { name, data } = feedData; + const datasets = data.map((item) => ({ + name: capitalize(item.name), + type: barData.vertical ? 'bar' : 'line', + smooth: true, + data: Object.values(item.data) + })); + + if (!labels && data && data[0] && data[0].data) { + labels = Object.keys(data[0].data); + } + + barOptions[name] = getBarOptions(datasets, labels); + + if (!datasets || (Array.isArray(datasets) && datasets.length < 1)) { + errors[name] = t('analyzeTab.noData'); + } + }); + + setBarData({ + data: barOptions, + error: errors, + loading: false, + vertical: false + }); + }); + } + + function getWordCloud() { + setWordData((prev) => ({ ...prev, loading: true })); + getThemesCloudAPI(id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // alert on error + setWordData((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + + const { data } = res.data; + const cloudOptions = {}; + const errors = {}; + data.forEach((feed) => { + const { name, data } = feed; + if (!data || (Array.isArray(data) && data.length < 1)) { + errors[name] = t('analyzeTab.noData'); + } + + cloudOptions[name] = { + tooltip: { + show: true + }, + toolbox: PieToolbox, + series: [ + { + ...WordCloudOptions, + data: Object.entries(data).map((v) => ({ + name: capFirstLetter(v[0]), + value: v[1] + })) + } + ] + }; + }); + setWordData({ + data: cloudOptions, + error: false, + loading: false + }); + }); + } + + function changeVertical() { + setBarData((prev) => ({ ...prev, vertical: !prev.vertical })); + } + + const hideChart1Alert = (id) => + analyze.alertCharts.find((v) => v.name === cn.first && v.id === id); + const hideChart2Alert = (id) => + analyze.alertCharts.find((v) => v.name === cn.second && v.id === id); + + const barchartMenus = (id) => [ + { + title: '', // t('analyzeTab.chartMenus.addToAlert'), + icon: IoIosAdd, + size: 24, + fn: () => actions.addAlertChart({ name: cn.first, id }), + showInMore: false, + hide: hideChart1Alert(id) + }, + { + title: '', // t('analyzeTab.chartMenus.addedToAlerts'), + icon: IoIosCheckmark, + size: 24, + showInMore: false, + hide: !hideChart1Alert(id) + }, + { + title: t('analyzeTab.chartMenus.refresh'), + icon: IoIosRefresh, + fn: () => updateResult(null, cn.first), + showInMore: false + }, + /* { + title: t('analyzeTab.chartMenus.addToDashboard'), + fn: () => {}, + showInMore: true + }, */ + { + title: 'Toggle Horizontal/Vertical', + fn: changeVertical, + showInMore: true + } + ]; + const wordCloudMenus = (id) => [ + { + title: '', // t('analyzeTab.chartMenus.addToAlert'), + icon: IoIosAdd, + size: 24, + fn: () => actions.addAlertChart({ name: cn.second, id }), + showInMore: false, + hide: hideChart2Alert(id) + }, + { + title: '', // t('analyzeTab.chartMenus.addedToAlerts'), + icon: IoIosCheckmark, + size: 24, + showInMore: false, + hide: !hideChart2Alert(id) + }, + { + title: t('analyzeTab.chartMenus.refresh'), + icon: IoIosRefresh, + fn: () => updateResult(null, cn.second), + showInMore: false + } + // { title: t('analyzeTab.chartMenus.addToDashboard'), fn: () => {}, showInMore: true } + ]; + + return feedData.feeds.map((feed) => ( + + + + + + + + + + + + + )); +} + +const cn = { + first: 'Themes over time', + second: 'Top Themes' +}; + +Themes.propTypes = { + chartData: PropTypes.object, + actions: PropTypes.object, + feedData: PropTypes.object, + t: PropTypes.func, + analyze: PropTypes.object +}; + +const applyDecorators = compose( + reduxConnect('analyze', ['appState', 'analyze']), + translate(['tabsContent'], { wait: true }) +); + +export default applyDecorators(React.memo(Themes)); diff --git a/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/WorldMap.js b/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/WorldMap.js new file mode 100644 index 0000000..1f340ac --- /dev/null +++ b/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/WorldMap.js @@ -0,0 +1,208 @@ +import React, { useEffect, useRef, useState, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Row, Col, ButtonGroup, Button } from 'reactstrap'; + +import 'leaflet/dist/leaflet.css'; +import L from 'leaflet'; +import 'leaflet-dvf/dist/leaflet-dvf'; +// keep above 3 in sequence +import ChartWrapper from '../ChartWrapper'; +import { getWorldMapAPI } from '../../../../../../api/analytics/createAnalytics'; +import useIsMounted from '../../../../../common/hooks/useIsMounted'; +import { translate } from 'react-i18next'; + +const initialPie = { + data: [], + error: undefined, + loading: true, + selected: undefined +}; + +function WorldMap(props) { + const { id, t } = props; + const mapRef = useRef(); + const isMounted = useIsMounted(); + const [pieData, setPieData] = useState(initialPie); + const [markers, setMarkers] = useState([]); + + const feedNames = (pieData.data && Object.keys(pieData.data)) || []; + + useEffect(() => { + mapRef.current = L.map('leaflet-map', { + center: [0, 0], + zoom: 2, + layers: [ + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + noWrap: true, + attribution: + '© OpenStreetMap contributors' + }) + ] + }); + + mapRef.current.whenReady(getMapSentiments); + }, []); + + useEffect(() => { + const { data, selected, error } = pieData; + const selectedData = data[feedNames[selected]]; + const hasErr = error && error[feedNames[selected]]; + clearMap(); + + if (selectedData && !hasErr) { + // loop to add marker + const markersList = []; + selectedData.forEach((data) => { + const [lat, lng] = getLatLong(data.LatLng); + if (!lat || !lng) { + return; + } + + let pieChartMarker = new L.PieChartMarker(new L.LatLng(lat, lng), { + ...options, + data: { + positive: data.POSITIVE, + negative: data.NEGATIVE, + neutral: data.NEUTRAL + } + }); + pieChartMarker.addTo(mapRef.current); + markersList.push(pieChartMarker); + }); + // eslint-disable-next-line new-cap + const group = new L.featureGroup(markersList); + mapRef.current.fitBounds(group.getBounds()); + setMarkers(markersList); + } + }, [pieData.data, pieData.selected]); + + function getLatLong(str) { + const [lat, lng] = str.split(', '); + return [lat && parseFloat(lat), lng && parseFloat(lng)]; + } + + function clearMap() { + if (mapRef.current) { + markers.forEach((v) => { + mapRef.current.removeLayer(v); + }); + } + } + + function getMapSentiments() { + setPieData((prev) => ({ ...prev, loading: true })); + getWorldMapAPI(id).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data.data) { + // alert on error + setPieData((prev) => ({ + ...prev, + loading: false, + error: res.errorMessage + })); + return; + } + + const { data } = res.data; + const dataValues = {}; + const errors = {}; + + data.map((feed) => { + const { name, data } = feed; + if (!data || (Array.isArray(data) && data.length < 1)) { + errors[name] = t('analyzeTab.noData'); + } + dataValues[name] = data; + }); + + setPieData({ + data: dataValues, + error: errors, + loading: false, + selected: 0 + }); + }); + } + + const style = { + height: 'max(300px, calc(100vh - 200px))' + }; + + return ( + + + + + + {feedNames.map((name, i) => ( + + ))} + +
+
+ {pieData.error && pieData.error[feedNames[pieData.selected]] ? ( +
+ {pieData.error[feedNames[pieData.selected]]} +
+ ) : null} +
+ + + + + ); +} + +const options = { + stroke: false, + fillOpacity: 0.7, + radius: 20, + gradient: false, + chartOptions: { + positive: { + fillColor: '#00FF00', + displayText: function (value) { + return value.toFixed(0); + } + }, + negative: { + fillColor: '#FF0000', + displayText: function (value) { + return value.toFixed(0); + } + }, + neutral: { + fillColor: '#000000', + displayText: function (value) { + return value.toFixed(0); + } + } + } + // Other L.Path style options +}; + +WorldMap.propTypes = { + actions: PropTypes.object, + feedData: PropTypes.object, + id: PropTypes.string, + t: PropTypes.func.isRequired, + analyze: PropTypes.object +}; + +export default translate(['tabsContent'], { wait: true })(WorldMap); diff --git a/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/index.js b/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/index.js new file mode 100644 index 0000000..c75d50a --- /dev/null +++ b/frontend/app/components/App/TabsContent/AnalyzeNewTab/CreateAnalysisSubTab/Tabs/index.js @@ -0,0 +1,17 @@ +import Results from './Results' +import Performance from './Performance' +import Influencers from './Influencers' +import Sentiment from './Sentiment' +import Themes from './Themes' +import Demographics from './Demographics' +import WorldMap from './WorldMap' + +export { + Results, + Performance, + Influencers, + Sentiment, + Themes, + Demographics, + WorldMap +} diff --git a/frontend/app/components/App/TabsContent/AnalyzeNewTab/SavedAnalysisSubTab/DeleteDialog.js b/frontend/app/components/App/TabsContent/AnalyzeNewTab/SavedAnalysisSubTab/DeleteDialog.js new file mode 100644 index 0000000..777edf7 --- /dev/null +++ b/frontend/app/components/App/TabsContent/AnalyzeNewTab/SavedAnalysisSubTab/DeleteDialog.js @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; +import { deleteAnalytics } from '../../../../../api/analytics/savedAnalytics'; +import { translate } from 'react-i18next'; + +function DeleteDialog(props) { + const [loading, setLoading] = useState(false); + const { actions, data, toggle, fetchData, t } = props; + + function handleSubmit() { + setLoading(true); + deleteAnalytics(data.value).then((res) => { + if (res.error) { + res.data + ? actions.addAlert(res.data) + : actions.addAlert({ type: 'error', transKey: 'somethingWrong' }); + setLoading(false); + return; + } + actions.addAlert({ type: 'notice', transKey: 'analyticsDeleted' }); + setLoading(false); + toggle(); + fetchData(); + }); + } + + return ( + + + {t('tabsContent:analyzeTab.deleteAnalysis')} + + +
+

{t('messages.deleteMessage')}

+
+
+ + + + +
+ ); +} + +DeleteDialog.propTypes = { + toggle: PropTypes.func, + t: PropTypes.func.isRequired, + data: PropTypes.object.isRequired, + fetchData: PropTypes.func, + actions: PropTypes.object +}; + +export default React.memo(translate(['common'], { wait: true })(DeleteDialog)); diff --git a/frontend/app/components/App/TabsContent/AnalyzeNewTab/SavedAnalysisSubTab/SavedAnalysisSubTab.js b/frontend/app/components/App/TabsContent/AnalyzeNewTab/SavedAnalysisSubTab/SavedAnalysisSubTab.js new file mode 100644 index 0000000..fd7318d --- /dev/null +++ b/frontend/app/components/App/TabsContent/AnalyzeNewTab/SavedAnalysisSubTab/SavedAnalysisSubTab.js @@ -0,0 +1,170 @@ +/* eslint-disable react/prop-types */ +import React, { + useState, + useCallback, + useMemo, + Fragment, + useEffect +} from 'react'; +import { Link } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { compose } from 'redux'; +import { Table } from '../../../../common/Table/Table'; +import { savedAnalytics } from '../../../../../api/analytics/savedAnalytics'; +import reduxConnect from '../../../../../redux/utils/connect'; +import { + getDate, + getQueryParams, + setDocumentData +} from '../../../../../common/helper'; +import { Button } from 'reactstrap'; +import DeleteDialog from './DeleteDialog'; +import i18n from '../../../../../i18n'; + +function SavedAnalysisSubTab(props) { + const [dataSource, setDataSource] = useState({ data: [] }); + const [loading, setLoading] = useState(true); + const [deleteValues, setDeleteValues] = useState(false); + const { t, actions } = props; + + useEffect(() => { + setDocumentData('title', 'Saved Analysis | Analyze'); + return () => { + setDocumentData('title'); + }; + }, []); + + const columns = useMemo(() => { + const columnsList = [ + { + id: 'feeds', + Header: t('analyzeTab.savedAnalytics.feeds'), + accessor: (d) => d.context.feeds, + Cell: (props) => + props.value ? props.value.map((v) => v.name).join(', ') : '' + }, + { + id: 'date', + Header: t('analyzeTab.savedAnalytics.dateRange'), + accessor: (d) => d.context.rawFilters.date, + Cell: (props) => + props.value + ? `${getDate(props.value.start, 'MM/DD/YYYY')} to ${getDate( + props.value.end, + 'MM/DD/YYYY' + )}` + : '-' + }, + { + Header: t('analyzeTab.savedAnalytics.createdAt'), + accessor: 'createdAt', + Cell: (props) => getDate(props.value, 'MM/DD/YYYY') + }, + { + Header: t('analyzeTab.savedAnalytics.actions'), + accessor: 'id', + Cell: (props) => getActions(props) + } + ]; + + return columnsList; + }, [getActions, i18n.language]); + + const getActions = useCallback((props) => { + return ( +
+ + + +
+ ); + }, []); + + const getSavedList = useCallback( + (page, pageSize) => { + setLoading(true); + const params = getQueryParams({ page, pageSize }); + savedAnalytics(params).then((res) => { + if (res.error || res.data === null || !res.data) { + setLoading(false); + return actions.addAlert({ + type: 'error', + transKey: 'somethingWrong' + }); + } + res.data.length > 0 && setDataSource(res.data[0]); + setLoading(false); + }); + }, + [savedAnalytics] + ); + + const { data = [], totalCount = 0, limit = 10, page = 1 } = dataSource; + return ( + +
+ {deleteValues && ( + + )} + + ); +} + +SavedAnalysisSubTab.propTypes = { + t: PropTypes.func.isRequired, + actions: PropTypes.object +}; + +const applyDecorators = compose( + translate(['tabsContent'], { wait: true }), + reduxConnect() +); + +export default applyDecorators(SavedAnalysisSubTab); diff --git a/frontend/app/components/App/TabsContent/AnalyzeNewTab/WelcomeSubTab/WelcomeSubTab.js b/frontend/app/components/App/TabsContent/AnalyzeNewTab/WelcomeSubTab/WelcomeSubTab.js new file mode 100644 index 0000000..ac02321 --- /dev/null +++ b/frontend/app/components/App/TabsContent/AnalyzeNewTab/WelcomeSubTab/WelcomeSubTab.js @@ -0,0 +1,61 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import { Link } from 'react-router-dom' +import { compose } from 'redux' +import { Card, Col, Row } from 'reactstrap' + +class WelcomeSubTab extends React.Component { + render () { + const { t } = this.props + + return ( + + + +
+
+
+ +
+
+
{t('analyzeTab.createNewAnalysis')}
+ + {t('analyzeTab.go')} + +
+
+ +
+
+
+
+ +
+
+
{t('analyzeTab.viewSavedAnalysis')}
+ + {t('analyzeTab.view')} + +
+
+ + + + ) + } +} + +WelcomeSubTab.propTypes = { + t: PropTypes.func.isRequired +} + +const applyDecorators = compose(translate(['tabsContent'], { wait: true })) + +export default applyDecorators(WelcomeSubTab) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/Article.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/Article.js new file mode 100644 index 0000000..fb19658 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/Article.js @@ -0,0 +1,548 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import TimeAgo from 'timeago-react'; +import ArticleComment from './ArticleComment'; +import { + UncontrolledDropdown, + DropdownToggle, + DropdownMenu, + DropdownItem, + CustomInput, + Button +} from 'reactstrap'; +import ShareMenu from './ShareMenu'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faFacebook, + faInstagram, + faPinterest, + faReddit, + faTumblr, + faTwitter, + faYoutube +} from '@fortawesome/free-brands-svg-icons'; +import { + faComments, + faEye, + faFrown, + faMeh, + faQuoteLeft, + faShareAlt, + faSmile, + faThumbsDown, + faThumbsUp +} from '@fortawesome/free-solid-svg-icons'; +import { + capOnlyFirstLetter, + convertUTCtoLocal, + abbreviateNumber, + notNullAndUnd +} from '../../../../../common/helper'; +import SourceIndexInfoPopup from '../SourceIndexSubTab/SourceIndexInfoPopup'; + +const icons = { + twitter: faTwitter, + facebook: faFacebook, + instagram: faInstagram, + tumblr: faTumblr, + pinterest: faPinterest, + reddit: faReddit, + youtube: faYoutube, + POSITIVE: faSmile, + NEGATIVE: faFrown, + NEUTRAL: faMeh +}; + +const colors = { + POSITIVE: '#3ac47d', + NEGATIVE: '#FC3939', + NEUTRAL: '#868e96', + twitter: '#1DA1F2', + facebook: '#4267B2', + reddit: '#FF5700', + instagram: '#8a3ab9', + tumblr: '#34526F', + pinterest: '#E60023', + youtube: '#FF0000' +}; + +export class Article extends React.Component { + constructor() { + super(); + this.state = { + shareMenu: false, + imgErr: false, + sourceModal: false + }; + + this.elemDesc = React.createRef(); + } + + selectArticle = () => { + this.props.selectArticle(this.props.article); + }; + + showEmailPopup = () => { + this.props.showEmailPopup([this.props.article]); + }; + + showCommentPopup = () => { + this.props.showCommentPopup(this.props.article); + }; + + showDeletePopup = () => { + this.props.showDeletePopup([this.props.article]); + }; + + showClipPopup = () => { + this.props.showClipPopup([this.props.article]); + }; + + toggleShareMenu = () => { + this.setState((prev) => ({ shareMenu: !prev.shareMenu })); + }; + + loadMoreComments = () => { + const { + loadMoreComments, + article: { + id: articleId, + comments: { count: offset } + } + } = this.props; + loadMoreComments(articleId, offset); + }; + + readLater = () => { + this.props.readArticleLater(this.props.article); + }; + + onImgError = () => { + this.setState({ imgErr: true }); + }; + + toggleSourceModal = () => { + this.setState((prev) => ({ sourceModal: !prev.sourceModal })); + }; + + render() { + const { article, t, i18n, showCommentPopup, deleteComment } = this.props; + let { + comments, + id, + source, + sentiment, + permalink, + publisher, + title, + image, + author, + content, + published, + mentions, + tags, + likes, + dislikes, + views, + shares, + categories + } = article; + const { imgErr } = this.state; + const { + data: commentsData, + count: commentsCount, // should get real post comment count + totalCount: commentsTotalCount + } = comments; + + const isArticleChosen = !!this.props.selectedArticles.find( + (item) => item.id === id + ); + + const offsetWidth = + this.elemDesc && + this.elemDesc.current && + this.elemDesc.current.offsetWidth; + + const hasRightCounters = + notNullAndUnd(likes) || + notNullAndUnd(dislikes) || + commentsCount || // add not null and undefined when counter shows + notNullAndUnd(views) || + notNullAndUnd(shares) || + notNullAndUnd(mentions); + + const isTwitter = source.siteType === 'twitter'; + const isInstagram = source.siteType === 'instagram'; + let username; + if (isTwitter) { + username = + author.link && + author.link.match( + /^https?:\/\/(www\.)?twitter\.com\/(#!\/)?([^\/]+)(\/\w+)*$/ + ); + username = username && username[3]; + } + if (isInstagram) { + username = + author.link && + author.link.match( + /(?:(?:http|https):\/\/)?(?:www\.)?(?:instagram\.com|instagr\.am)\/([A-Za-z0-9-_\.]+)/ + ); + username = username && username[1]; + } + + const isRTL = document.documentElement.dir === 'rtl'; + return ( +
+ + + + + + + + {t('searchTab.commentBtn')} + + + + {t('searchTab.clipBtn')} + + + + {t('searchTab.readLaterBtn')} + + + + {t('searchTab.archiveBtn')} + + + + {t('searchTab.emailBtn')} + + + + {t('searchTab.shareBtn')} + + + + {t('searchTab.deleteBtn')} + + + +
+
+ + {source.siteType && ( + + )} + {sentiment && ( + + )} +
+
+

+ {title && ( + + {title} + + )} +

+
+ {image && + !imgErr && + (!title && permalink ? ( + + + + ) : ( + + ))} + +
+ {author.name ? ( + author.link ? ( + + {username ? `@${username}` : author.name} + + ) : ( +

{author.name}

+ ) + ) : null} + {!title && permalink ? ( + +

+
+ ) : ( +

+ )} +
+
+ + {tags && tags.length && tags.length > 0 && ( +
+ {t('searchTab.tags')}: {tags.join(', ')} +
+ )} + + {categories && categories.length > 0 && ( +

+ {t('searchTab.categories')}:{' '} + {categories.join(', ')} +

+ )} +
+ {published && ( + + + + + | + + )} + + {source.type && ( + + {capOnlyFirstLetter(source.type)} + | + + )} + + {source.country && ( + + {source.country} + | + + )} + + {publisher && ( + + + | + + )} + + {source.title && ( + + {publisher ? ( + + {source.title} + + ) : ( + + )} + + )} +
+
+ {hasRightCounters && ( +
+
+ {notNullAndUnd(likes) && ( +
+ +

+ {abbreviateNumber(likes)} +

+
+ )} + {notNullAndUnd(dislikes) && ( +
+ +

+ {abbreviateNumber(dislikes)} +

+
+ )} + {/* {notNullAndUnd(commentsCount) && ( + Add above line when real comment counts are visible + */} + {commentsCount ? ( +
+ +

+ {abbreviateNumber(commentsCount)} +

+
+ ) : ( + '' + )} + {notNullAndUnd(views) && ( +
+ +

+ {abbreviateNumber(views)} +

+
+ )} + {notNullAndUnd(shares) && ( +
+ +

+ {abbreviateNumber(shares)} +

+
+ )} + {notNullAndUnd(mentions) && ( +
+ +

+ {abbreviateNumber(mentions)} +

+
+ )} +
+
+ )} +
+ + {commentsData && commentsData.length > 0 && ( +
+ {commentsData.map((comment) => { + return ( + + ); + })} + + {commentsCount < commentsTotalCount && ( + + )} +
+ )} + + {this.state.shareMenu && ( + + )} + + {this.state.sourceModal && ( + + )} +
+ ); + } +} + +Article.propTypes = { + article: PropTypes.object.isRequired, + selectedArticles: PropTypes.array.isRequired, + selectArticle: PropTypes.func.isRequired, + showEmailPopup: PropTypes.func.isRequired, + showDeletePopup: PropTypes.func.isRequired, + showCommentPopup: PropTypes.func.isRequired, + showClipPopup: PropTypes.func.isRequired, + deleteComment: PropTypes.func.isRequired, + readArticleLater: PropTypes.func.isRequired, + loadMoreComments: PropTypes.func.isRequired, + showShareMenu: PropTypes.func.isRequired, + i18n: PropTypes.object.isRequired, + t: PropTypes.func.isRequired +}; + +export default translate(['tabsContent'], { wait: true })(Article); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ArticleComment.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ArticleComment.js new file mode 100644 index 0000000..ac4a7f5 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ArticleComment.js @@ -0,0 +1,66 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate, Interpolate } from 'react-i18next' +import TimeAgo from 'timeago-react' +import { Button } from 'reactstrap' + +export class ArticleComment extends React.Component { + static propTypes = { + article: PropTypes.object.isRequired, + comment: PropTypes.func.isRequired, + deleteComment: PropTypes.func.isRequired, + showCommentPopup: PropTypes.func.isRequired, + i18n: PropTypes.object.isRequired, + t: PropTypes.func.isRequired + } + + onEdit = () => { + const { showCommentPopup, article, comment } = this.props + showCommentPopup(article, comment) + } + + onDelete = () => { + const { deleteComment, article, comment } = this.props + deleteComment(comment.id, article.id) + } + + render() { + const { comment, i18n } = this.props + + return ( +
+
+
+ + + + + + +
+
+ + +
+
+

+ {comment.title} + {comment.content} +

+
+ ) + } +} + +export default translate(['tabsContent'], { wait: true })(ArticleComment) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ClipArticles/ClipArticlesPopup.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ClipArticles/ClipArticlesPopup.js new file mode 100644 index 0000000..2d8b6d4 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ClipArticles/ClipArticlesPopup.js @@ -0,0 +1,90 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import ClipDragSource from './ClipDragSource' +import RecentFeed from './RecentFeed' +import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap' + +export class ClipArticlesPopup extends React.Component { + static propTypes = { + hidePopup: PropTypes.func.isRequired, + clipArticles: PropTypes.func.isRequired, + articles: PropTypes.array.isRequired, + recentClipFeeds: PropTypes.array.isRequired, + getRecentClipFeeds: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + } + + hidePopupFromOutside = (e) => { + if (e.target === e.currentTarget) this.hidePopup() + } + + hidePopup = () => { + this.props.hidePopup() + } + + onSubmit = () => { + this.hidePopup() + } + + componentWillMount = () => { + this.props.getRecentClipFeeds() + } + + onRecentFeedClick = (feed) => { + this.props.clipArticles(feed.id) + this.props.hidePopup() + } + + render() { + const { t, articles, recentClipFeeds } = this.props + + return ( + + + {t('searchTab.clipPopup.header')} + + +
+

{t('searchTab.clipPopup.hint1')}

+ +
+ +
+ + {recentClipFeeds && recentClipFeeds.length > 0 && ( +
+

{t('searchTab.clipPopup.hint2')}

+
+ {recentClipFeeds.map((feed) => { + return ( + + ) + })} +
+
+ )} +
+
+ + + +
+ ) + } +} + +export default translate(['tabsContent', 'common'], { wait: true })( + ClipArticlesPopup +) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ClipArticles/ClipDragSource.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ClipArticles/ClipDragSource.js new file mode 100644 index 0000000..b65801f --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ClipArticles/ClipDragSource.js @@ -0,0 +1,69 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { TYPES } from '../../../../../../redux/modules/appState/sidebar' +import { Interpolate } from 'react-i18next' +import { DragSource } from 'react-dnd' + +const source = { + beginDrag (props, monitor, component) { + setTimeout(() => { + component.setState({ + isDragging: true + }) + }, 0) + return { + type: TYPES.CLIP_ARTICLE + } + }, + + endDrag (props, monitor, component) { + component.setState({ + isDragging: false + }) + } +} + +/** + * Specifies which props to inject into component from Drag n Drop. + */ +function collect (connect) { + return { + // Call this function inside render() + // to let React DnD handle the drag events: + connectDragSource: connect.dragSource() + } +} + +export class ClipDragSource extends React.Component { + + static propTypes = { + articles: PropTypes.array.isRequired, + connectDragSource: PropTypes.func.isRequired + }; + + constructor (props) { + super(props) + this.state = { + isDragging: false + } + } + + render () { + + const style = { + visibility: this.state.isDragging ? 'hidden' : 'visible' + } + + return this.props.connectDragSource( +
+ + +
+ ) + } +} + +export default DragSource(TYPES.CLIP_ARTICLE, source, collect)(ClipDragSource) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ClipArticles/RecentFeed.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ClipArticles/RecentFeed.js new file mode 100644 index 0000000..f353e64 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ClipArticles/RecentFeed.js @@ -0,0 +1,26 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Button } from 'reactstrap' + +export default class RecentFeed extends React.Component { + + static propTypes = { + feed: PropTypes.object.isRequired, + onRecentFeedClick: PropTypes.func.isRequired + }; + + onClick = () => { + this.props.onRecentFeedClick(this.props.feed) + } + + render () { + const { feed } = this.props + + return ( + + ) + } + +} diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/CommentArticlePopup.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/CommentArticlePopup.js new file mode 100644 index 0000000..1bba175 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/CommentArticlePopup.js @@ -0,0 +1,139 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate, Interpolate } from 'react-i18next'; +import TimeAgo from 'timeago-react'; +import { + Button, + Input, + Modal, + ModalBody, + ModalFooter, + ModalHeader +} from 'reactstrap'; + +const initCharactersCount = 5000; + +export class CommentArticlePopup extends React.Component { + static propTypes = { + article: PropTypes.object.isRequired, + comment: PropTypes.object, + commentArticle: PropTypes.func.isRequired, + updateComment: PropTypes.func.isRequired, + hidePopup: PropTypes.func.isRequired, + i18n: PropTypes.object.isRequired, + t: PropTypes.func.isRequired + }; + + constructor(props) { + super(props); + const content = props.comment ? props.comment.content : ''; + this.state = { + charactersCount: initCharactersCount - content.length, + title: props.comment ? props.comment.title : '', + comment: content + }; + } + + handleTitleChange = (e) => { + const { value } = e.target; + this.setState({ title: value }); + }; + + hidePopup = () => { + this.props.hidePopup(); + }; + + onSubmit = () => { + const newComment = { + title: this.state.title, + content: this.state.comment + }; + if (this.props.comment) { + //edit exisitng + this.props.updateComment(newComment, this.props.article.id); + } else { + //create new comment + this.props.commentArticle(newComment, this.props.article.id); + } + this.hidePopup(); + }; + + onChangeComment = (e) => { + const charactersCount = initCharactersCount - e.target.value.length; + + if (charactersCount >= 0) { + this.setState({ + charactersCount: charactersCount, + comment: e.target.value + }); + } + }; + + render() { + const { t, i18n, article, comment } = this.props; + const popupTitle = comment + ? t('searchTab.commentPopup.editUserComment') + : t('searchTab.commentPopup.addUserComment'); + + return ( + + {popupTitle} + +
+ + {article.title} + +

{article.author.name}

+

+ +

+
+ + + + + +

+ +

+
+ + + + +
+ ); + } +} + +export default translate(['tabsContent', 'common'], { wait: true })( + CommentArticlePopup +); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/DeleteArticlesPopup.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/DeleteArticlesPopup.js new file mode 100644 index 0000000..3ae0cea --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/DeleteArticlesPopup.js @@ -0,0 +1,65 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Interpolate, translate } from 'react-i18next'; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; + +export class DeleteArticlesPopup extends React.Component { + static propTypes = { + articles: PropTypes.array.isRequired, + activeFeed: PropTypes.object, + hidePopup: PropTypes.func.isRequired, + deleteArticles: PropTypes.func.isRequired, + deleteArticlesFromFeed: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + }; + + onSubmit = () => { + const { + articles, + activeFeed, + deleteArticles, + deleteArticlesFromFeed, + hidePopup + } = this.props; + const ids = articles.map((a) => a.id); + if (activeFeed) { + deleteArticlesFromFeed(ids, activeFeed.id); + } else { + deleteArticles(ids); + } + hidePopup(); + }; + + render() { + const { t, articles, hidePopup } = this.props; + + return ( + + {t('commonWords.Confirm')} + +

+ {articles.length > 1 ? ( + + ) : ( + t('tabsContent:searchTab.deleteArticlePopupText') + )} +

+
+ + + + +
+ ); + } +} + +export default translate(['common'], { wait: true })(DeleteArticlesPopup); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/EmailArticlesPopup.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/EmailArticlesPopup.js new file mode 100644 index 0000000..c3244f2 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/EmailArticlesPopup.js @@ -0,0 +1,209 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import moment from 'moment' +import Select from 'react-select' +import { + Button, + Modal, + ModalHeader, + ModalBody, + Label, + Input, + ModalFooter, + FormGroup, + Col, + Container +} from 'reactstrap' +import QuillEditor from '../../../../common/QuillEditor' + +const replyToEmail = 'support@socialhose.io' + +export class EmailArticlesPopup extends React.Component { + static propTypes = { + articlesToEmail: PropTypes.array.isRequired, + emailArticles: PropTypes.func.isRequired, + hidePopup: PropTypes.func.isRequired, + recipients: PropTypes.object.isRequired, + loadRecipients: PropTypes.func.isRequired, + children: PropTypes.any, + t: PropTypes.func.isRequired + } + + constructor(props) { + super(props) + this.state = { + selectedRecipients: '' + } + this.editorRef = React.createRef() + } + + componentWillMount = () => { + !this.props.recipients.all.length && this.props.loadRecipients() + } + + componentDidMount = () => { + this.props.loadRecipients() + } + + hidePopup = () => { + this.props.hidePopup() + } + + collectParams = () => { // need to change with states + const recipients = this.state.selectedRecipients + if (!recipients) return false + return { + emailTo: recipients.map((r) => r.value), + emailReplyTo: document.getElementById('email-reply-to').value, + subject: document.getElementById('email-subject').value, + content: this.editorRef.current && this.editorRef.current.root.innerHTML + } + } + + onSubmit = () => { + const params = this.collectParams() + if (params) { + this.props.emailArticles(params) + } + } + + changeRecipient = (value) => { + this.setState({ + selectedRecipients: value + }) + } + + validEmails = (str) => { + const re = /\S+@\S+\.\S+/ + const arr = str.split(',') + for (let s of arr) { + if (!re.test(s)) { + return false + } + } + return true + } + + emailRe = /\S+@\S+\.\S+/ + + isValidNewOption = ({ label }) => { + return this.emailRe.test(label) + } + + promptTextCreator = (label) => { + return label + } + + render() { + const { t, articlesToEmail, recipients } = this.props + const { selectedRecipients } = this.state + + const recipientsAll = recipients.all.map((recipient) => ({ + value: recipient, + label: recipient + })) + + return ( + + + {t('searchTab.emailPopup.header')} + + + + + +
+ {recipients.pending && } + {!recipients.pending && ( + + )} + + + + + + + + + + + + + + + +
+ + {articlesToEmail.map((article) => { + return ( +
+

+ {article.title} +

+ +
+ + {article.source.title} + {' '} + | + + {article.author.name} + {' '} + | + {moment(article.published).format('LLL')} +
+ +

{article.content}

+
+ ) + })} +
+
+ + {this.props.children} + + + + + + + + ) + } +} + +export default translate(['tabsContent', 'common'], { wait: true })( + EmailArticlesPopup +) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/EmailConfirmPopup.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/EmailConfirmPopup.js new file mode 100644 index 0000000..dfcb5f6 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/EmailConfirmPopup.js @@ -0,0 +1,50 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap' + +class EmailConfirmPopup extends React.Component { + static propTypes = { + hidePopup: PropTypes.func.isRequired, + hideEmailPopup: PropTypes.func.isRequired, + sendDocumentsByEmail: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + } + + hidePopup = () => { + this.props.hidePopup() + } + + onSubmit = () => { + this.props.sendDocumentsByEmail() + this.hidePopup() + this.props.hideEmailPopup() + } + + render() { + const { t } = this.props + + return ( + + + {t('common:commonWords.Confirm')} + + +

{t('searchTab.emailPopup.sendConfirmWithoutSubject')}

+
+ + + + +
+ ) + } +} + +export default translate(['tabsContent', 'common'], { wait: true })( + EmailConfirmPopup +) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/MediaTypes.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/MediaTypes.js new file mode 100644 index 0000000..30a4892 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/MediaTypes.js @@ -0,0 +1,175 @@ +/* eslint-disable react/jsx-no-bind */ +import React, { Fragment, useState } from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import { translate } from 'react-i18next'; +import SearchDatesPopup from './SearchDatesPopup'; +import { Modal, Button, ModalHeader, ModalBody } from 'reactstrap'; +import { IoIosCalendar } from 'react-icons/io'; + +// previous commented code +// componentWillMount = () => { +// const { actions, userSubscription } = this.props; +// actions.setSearchLastDate(userSubscription); +// }; +export function MediaTypes(props) { + const [modal, setModal] = useState(false); + + const { + t, + mediaTypes, + actions, + chosenMediaTypes, + toggleMediaType, + toggleAllMediaTypes, + restrictions + } = props; + + const allSelected = mediaTypes.length === chosenMediaTypes.length; + + function toggle() { + setModal((modal) => !modal); + } + + // set only the allowed media types from restrictions initially + function allowPermissions(mediaType) { + if (!restrictions || !restrictions.plans) { + return false; + } + + // for selecting all + if (!mediaType) { + return mediaTypes.every((mt) => restrictions.plans[mt]); + } + + return restrictions.plans[mediaType]; + } + + function toggleSingleType(mediaType, value) { + /* const isFree = restrictions.plans.price === 0; + // TODO: remove following restrictions when duplication fixes + const restrictedTemporary = + isFree && ['news', 'blogs'].includes(mediaType) && value; + + if (!allowPermissions(mediaType) || restrictedTemporary) { */ + if (!allowPermissions(mediaType)) { + return actions.toggleUpgradeModal(); + } + toggleMediaType(mediaType, value); // restrict condition + } + + function toggleAllTypes() { + // TODO: remove following restrictions when duplication fixes + /* const isFree = restrictions.plans.price === 0; + if (!allowPermissions() || isFree) { */ + if (!allowPermissions()) { + return actions.toggleUpgradeModal(); + } + toggleAllMediaTypes(!allSelected); + } + + /* + const { + chosenSearchDate, + chosenSearchInterval + chosenStartDate, + chosenEndDate + } = props.searchByFiltersState + const isIntervalBetween = chosenSearchInterval === 'between'; + const searchDateBtnText = isIntervalBetween && + chosenStartDate !== '' || + isIntervalBetween && + chosenEndDate !== '' + ? chosenSearchDate : t('searchTab.userSubscription.' + chosenSearchDate); + */ + + return ( + +
+
+ + {mediaTypes.map((mediaType, i) => { + const isMediaTypeChosen = + chosenMediaTypes.indexOf(mediaType) !== -1; + return ( + + ); + })} +
+ +
+ + Select dates + + + + +
+ ); +} + +MediaTypes.propTypes = { + t: PropTypes.func.isRequired, + mediaTypes: PropTypes.array.isRequired, + chosenMediaTypes: PropTypes.array.isRequired, + toggleMediaType: PropTypes.func.isRequired, + toggleAllMediaTypes: PropTypes.func.isRequired, + restrictions: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + userSubscriptionDate: PropTypes.string.isRequired, + userSubscription: PropTypes.string.isRequired, + searchByFiltersState: PropTypes.object.isRequired +}; + +export default translate(['tabsContent'], { wait: true })(MediaTypes); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/RefinePanel.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/RefinePanel.js new file mode 100644 index 0000000..41221c7 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/RefinePanel.js @@ -0,0 +1,94 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import FiltersTable from '../../../../common/FiltersTable/FiltersTable' +import { Button } from 'reactstrap' + +export class RefinePanel extends React.Component { + + static propTypes = { + t: PropTypes.func.isRequired, + advancedFilters: PropTypes.object.isRequired, + selectedFilters: PropTypes.object.isRequired, + clearPending: PropTypes.object.isRequired, + filterPages: PropTypes.object.isRequired, + onRefine: PropTypes.func.isRequired, + actions: PropTypes.object.isRequired + }; + + onHiderClick = (e) => { + e.preventDefault() + this.props.actions.toggleRefinePanel() + }; + + onSelectFilter = (groupName, filterValue) => { + this.props.actions.selectRefineFilter(groupName, filterValue) + }; + + onClearFilters = (groupName) => { + this.props.actions.clearRefineFilters(groupName) + }; + + onClearAllFilters = () => { + this.props.actions.clearAllRefineFilters() + }; + + onMoreFilters = (groupName) => { + this.props.actions.loadMoreRefineFilters(groupName) + }; + + onLessFilters = (groupName) => { + this.props.actions.loadLessRefineFilters(groupName) + }; + + /* onPressEnter = (e) => { + if (e.keyCode === 13) { + const keyword = document.getElementById('refine-keyword').value + this.props.actions.selectRefineFilter('keyword', keyword) + setTimeout(() => { + this.props.onRefine() + }) + } + }; */ + + render () { + return ( +
+ + {/* */} + + +
+ ) + } + +} + +export default translate(['tabsContent'], { wait: true })(RefinePanel) + diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SaveFeedPopup.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SaveFeedPopup.js new file mode 100644 index 0000000..659f78b --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SaveFeedPopup.js @@ -0,0 +1,154 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import { + Button, + Modal, + ModalHeader, + ModalBody, + Label, + Input, + ModalFooter, + FormGroup +} from 'reactstrap' + +export class SaveFeedPopup extends React.Component { + static propTypes = { + feedCategories: PropTypes.array.isRequired, + saveType: PropTypes.string.isRequired, + toggleSaveFeedPopup: PropTypes.func.isRequired, + addAlert: PropTypes.func.isRequired, + onSaveAsFeed: PropTypes.func.isRequired, + getSidebarCategories: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + } + + constructor(props) { + super(props) + this.state = { + isFeedNameError: false, + feedCategoriesKeys: [], + feedName: '', + selectCategory: '' + } + } + + componentWillMount = () => { + let nestingCount = -1 + this.getCategoriesKeys(this.props.feedCategories, nestingCount) + } + + //function that generates new array of categories without nesting + getCategoriesKeys = (categories, nestingCount) => { + nestingCount += 1 + categories.forEach((category) => { + if (category.subType === 'deleted_content') return false + + const categoryName = '-'.repeat(nestingCount) + ' ' + category.name + + const feedCategoriesKeys = this.state.feedCategoriesKeys + feedCategoriesKeys.push({ id: category.id, name: categoryName }) + this.setState({ + feedCategoriesKeys: feedCategoriesKeys, + selectCategory: feedCategoriesKeys[0].id.toString() + }) + + if (category.childes.length) { + this.getCategoriesKeys(category.childes, nestingCount) + } + }) + } + + changeHandler = (e) => { + const { name, value } = e.target + this.setState({ [name]: value }) + } + + hidePopupFromOutside = (e) => { + if (e.target === e.currentTarget) this.hidePopup() + } + + hidePopup = () => { + this.props.toggleSaveFeedPopup() + } + + onSubmit = () => { + const { feedName: name, selectCategory: category } = this.state + + if (!name || !name.trim()) { + this.setState({ isFeedNameError: true }) + return false + } + + this.props.onSaveAsFeed(name, category) + this.hidePopup() + } + + render() { + const { t } = this.props + + const { + feedCategoriesKeys, + isFeedNameError, + feedName, + selectCategory + } = this.state + + return ( + + + {t('searchTab.saveFeedPopup.' + this.props.saveType)} + + + + + + {isFeedNameError && ( +

+ {t('searchTab.saveFeedPopup.feedNameErrorMsg')} +

+ )} +
+ + + + {feedCategoriesKeys.map((category) => { + return ( + + ) + })} + + +
+ + + + +
+ ) + } +} + +export default translate(['tabsContent', 'common'], { wait: true })( + SaveFeedPopup +) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/BetweenDatepickers.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/BetweenDatepickers.js new file mode 100644 index 0000000..76bb0d4 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/BetweenDatepickers.js @@ -0,0 +1,118 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { DateRangePicker } from 'react-dates' +import moment from 'moment' +import { getMomentObject } from '../../../../../../common/helper' + +export class BetweenDatepickers extends React.Component { + state = {} + + static propTypes = { + chosenSearchInterval: PropTypes.string.isRequired, + chosenStartDate: PropTypes.string.isRequired, + chosenEndDate: PropTypes.string.isRequired, + setSearchInterval: PropTypes.func.isRequired, + setSearchDate: PropTypes.func.isRequired, + setStartDate: PropTypes.func.isRequired, + minDate: PropTypes.object, + setEndDate: PropTypes.func.isRequired + } + + swapDate = (startDate, endDate) => { + if (startDate.isAfter(endDate)) { + const temp = startDate + startDate = endDate + endDate = temp + } + return { startDate, endDate } + } + /* + setDates = (date, isStartDate) => { + const { + chosenStartDate, + chosenEndDate, + setStartDate, + setEndDate, + setSearchDate + } = this.props + + const hasStartDate = !!chosenStartDate + const hasEndDate = !!chosenEndDate + let startDate = hasStartDate ? moment(chosenStartDate) : moment() + let endDate = hasEndDate ? moment(chosenEndDate) : moment() + + startDate = isStartDate ? date : startDate + endDate = !isStartDate ? date : endDate + + const swappedDate = this.swapDate(startDate, endDate) + startDate = swappedDate.startDate.format('YYYY-MM-DD') + endDate = swappedDate.endDate.format('YYYY-MM-DD') + + setStartDate(startDate.format('YYYY-MM-DD')) + setEndDate(endDate.format('YYYY-MM-DD')) + + const endDateLabel = hasEndDate ? endDate : 'now' + const startDateLabel = hasStartDate ? startDate : 'until' + let label = isStartDate + ? `${startDate} - ${endDateLabel}` + : `${startDateLabel} - ${endDate}` + setSearchDate(label) + } */ + + setBetweenInterval = () => { + const { chosenSearchInterval, setSearchInterval } = this.props + if (chosenSearchInterval === 'between') return false + + setSearchInterval('between') + } + + handleDateChange = ({ startDate, endDate }) => { + const { setStartDate, setEndDate } = this.props + + setStartDate(startDate ? startDate.format('YYYY-MM-DD') : null) + setEndDate(endDate ? endDate.format('YYYY-MM-DD') : null) + + if (startDate && endDate) { + this.setBetweenInterval() + } + } + + onFocusChange = (focus) => { + this.setState({ focusedInput: focus }) + } + + isOutsideRange = (date) => { + const today = moment() + return date.isAfter(today) || date.isBefore(this.props.minDate) + } + + render() { + const { chosenStartDate, chosenEndDate } = this.props + const today = moment() + const startDate = getMomentObject(chosenStartDate) + const endDate = getMomentObject(chosenEndDate) + + return ( +
+ +
+ ) + } +} + +export default BetweenDatepickers diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/DuplicatesTab.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/DuplicatesTab.js new file mode 100644 index 0000000..dbf3097 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/DuplicatesTab.js @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { Col, CustomInput, FormGroup } from 'reactstrap'; + +export class DuplicatesTab extends React.Component { + static propTypes = { + includeDuplicates: PropTypes.bool.isRequired, + toggleIncludeDuplicates: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + }; + + render() { + const { t } = this.props; + + return ( +
+ + + + + ); + } +} + +export default translate(['tabsContent'], { wait: true })(DuplicatesTab); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/EmphasisTab.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/EmphasisTab.js new file mode 100644 index 0000000..57312bf --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/EmphasisTab.js @@ -0,0 +1,53 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { Col, FormGroup, Input, Label } from 'reactstrap'; + +export class EmphasisTab extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + include: PropTypes.string.isRequired, + exclude: PropTypes.string.isRequired, + setHeadlineIncluded: PropTypes.func.isRequired, + setHeadlineExcluded: PropTypes.func.isRequired + }; + + setHeadInclude = (e) => { + const headline = e.target.value; + this.props.setHeadlineIncluded(headline); + }; + + setHeadExclude = (e) => { + const headline = e.target.value; + this.props.setHeadlineExcluded(headline); + }; + + render() { + const { t, include, exclude } = this.props; + + return ( + + + + + + + + + + + + + + + ); + } +} + +export default translate(['tabsContent'], { wait: true })(EmphasisTab); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/ExtrasTab.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/ExtrasTab.js new file mode 100644 index 0000000..003d757 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/ExtrasTab.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { Col, CustomInput, FormGroup } from 'reactstrap'; + +function ExtrasTab({ t, hasImages, toggleHasImages }) { + return ( + + + + + + ); +} + +ExtrasTab.propTypes = { + hasImages: PropTypes.bool.isRequired, + toggleHasImages: PropTypes.func.isRequired, + t: PropTypes.func.isRequired +}; + +export default translate(['tabsContent'], { wait: true })(ExtrasTab); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/LangsTab.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/LangsTab.js new file mode 100644 index 0000000..69ca36b --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/LangsTab.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { Col, CustomInput } from 'reactstrap'; + +export class LangsTab extends React.Component { + static propTypes = { + chosenLanguages: PropTypes.array.isRequired, + searchLanguages: PropTypes.array.isRequired, + toggleLang: PropTypes.func.isRequired, + toggleAllLangs: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + }; + + toggleLangs = ({ target: { id, checked } }) => { + this.props.toggleLang(id, checked); + }; + + toggleAllLangs = (e) => { + this.props.toggleAllLangs(e.target.checked); + }; + + render() { + const { t } = this.props; + const { searchLanguages, chosenLanguages } = this.props; + return ( + + + + {searchLanguages.map((lang) => ( + + ))} + + ); + } +} + +export default translate(['tabsContent'], { wait: true })(LangsTab); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/LocationItem.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/LocationItem.js new file mode 100644 index 0000000..feca319 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/LocationItem.js @@ -0,0 +1,61 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { DragSource } from 'react-dnd' + +const Types = { + LOC: 'location' +} + +const locationSource = { + beginDrag (props) { + // Return the data describing the dragged item + return { oldDropTargetType: props.dropTargetType } + }, + + endDrag (props, monitor, component) { + // When dropped on a compatible target, do something + if (monitor.getDropResult() !== null) { + const locFrom = props.dropTargetType + const locTo = monitor.getDropResult().newDropTargetType + + const locationType = props.locationType + const location = props.location + + props.moveLocation(locFrom, locTo, locationType, location) + } + } +} + +/** + * Specifies which props to inject into your component. + */ +function collectDragSource (connect) { + return { + // Call this function inside render() + // to let React DnD handle the drag events: + connectDragSource: connect.dragSource() + } +} + +export class LocationsTabList extends React.Component { + static propTypes = { + location: PropTypes.object.isRequired, + dropTargetType: PropTypes.string.isRequired, + moveLocation: PropTypes.func.isRequired, + connectDragSource: PropTypes.func.isRequired + }; + + render () { + const { connectDragSource } = this.props + const { location } = this.props + + return connectDragSource( +
  • + + {location.name} +
  • + ) + } +} + +export default DragSource(Types.LOC, locationSource, collectDragSource)(LocationsTabList) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/LocationsTab.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/LocationsTab.js new file mode 100644 index 0000000..4e4206f --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/LocationsTab.js @@ -0,0 +1,111 @@ +/* eslint-disable react/jsx-no-bind */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import LocationsTabList from './LocationsTabList'; +import { Button, Col, Row } from 'reactstrap'; + +export class LocationsTab extends React.Component { + static propTypes = { + locations: PropTypes.array.isRequired, + locationsToInclude: PropTypes.array.isRequired, + locationsToExclude: PropTypes.array.isRequired, + chosenLocationsType: PropTypes.string.isRequired, + changeLocationsType: PropTypes.func.isRequired, + moveLocation: PropTypes.func.isRequired, + clearLocations: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + }; + + constructor(props) { + super(props); + + this.state = { + dropdownOpen: false, + dropDownValue: 'country' + }; + } + + onClearLocations = () => { + this.props.clearLocations(); + this.props.changeLocationsType('country'); + this.setState({ dropDownValue: 'country' }); + }; + + selectLocation = (value) => { + this.props.changeLocationsType(value); + this.setState({ dropDownValue: value }); + }; + + render() { + const { + locations, + chosenLocationsType, + locationsToInclude, + locationsToExclude + } = this.props; + const { t } = this.props; + const locationsMainList = locations.filter((loc) => { + return loc.type === chosenLocationsType; + }); + const includeList = locationsToInclude.filter((loc) => { + return loc.type === chosenLocationsType; + }); + const excludeList = locationsToExclude.filter((loc) => { + return loc.type === chosenLocationsType; + }); + + const { dropDownValue } = this.state; + return ( +
    + + + + + + + + + + + + + + + + ); + } +} + +export default translate(['tabsContent'], { wait: true })(LocationsTab); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/LocationsTabList.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/LocationsTabList.js new file mode 100644 index 0000000..8c820b2 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/LocationsTabList.js @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { DropTarget } from 'react-dnd'; +import flow from 'lodash/flow'; +import LocationItem from './LocationItem'; +import { + ListGroup +} from 'reactstrap'; + +const targetTypes = ['location']; +const locationListTarget = { + drop(props, monitor, component) { + if (monitor.didDrop()) { + //check whether some nested + // target already handled drop + return; + } + + return { newDropTargetType: props.dropTargetType }; + }, + + canDrop(props, monitor) { + return props.dropTargetType !== monitor.getItem().oldDropTargetType; + } +}; + +function collectDropTarget(connect, monitor) { + return { + // Call this function inside render() + // to let React DnD handle the drag events: + connectDropTarget: connect.dropTarget(), + // You can ask the monitor about the current drag state: + itemType: monitor.getItemType() + }; +} + +export class LocationsTabList extends React.Component { + static propTypes = { + locations: PropTypes.array.isRequired, + chosenLocationsType: PropTypes.string.isRequired, + dropTargetType: PropTypes.string.isRequired, + moveLocation: PropTypes.func.isRequired, + t: PropTypes.func.isRequired, + connectDropTarget: PropTypes.func.isRequired + }; + + render() { + const { locations, chosenLocationsType, dropTargetType } = this.props; + const { t } = this.props; + const { connectDropTarget } = this.props; + + locations.forEach((location) => { + location.name = t('common:' + location.type + '.' + location.code); + }); + + const sortedLocations = locations.sort((a, b) => { + const nameA = a.name.toLowerCase(); + const nameB = b.name.toLowerCase(); + if (nameA < nameB) { + //sort string ascending + return -1; + } + if (nameA > nameB) { + return 1; + } + return 0; + }); + + return connectDropTarget( +
    +

    {t('searchTab.searchBySection.locations.' + dropTargetType)}

    + + {sortedLocations.map((location, i) => { + return ( + + ); + })} + +
    + ); + } +} + +export default flow( + DropTarget(targetTypes, locationListTarget, collectDropTarget), + translate(['tabsContent'], { wait: true }) +)(LocationsTabList); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SearchBy.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SearchBy.js new file mode 100644 index 0000000..9d85fda --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SearchBy.js @@ -0,0 +1,163 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import SearchByTabs from './SearchByTabs'; +import EmphasisTab from './EmphasisTab'; +import LangsTab from './LangsTab'; +import LocationsTab from './LocationsTab'; +import SourcesTab from './SourcesTab'; +import SourceListsTab from './SourceListsTab'; +import DuplicatesTab from './DuplicatesTab'; +import ExtrasTab from './ExtrasTab'; +import { translate } from 'react-i18next'; +import { Button, Container, Row } from 'reactstrap'; + +export class SearchBy extends React.Component { + static propTypes = { + userSubscriptionDate: PropTypes.string.isRequired, + userSubscription: PropTypes.string.isRequired, + searchByFiltersState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + t: PropTypes.func.isRequired + }; + + constructor(props) { + super(props); + this.state = { + animationDisabled: true, + arrowPosition: true + }; + } + + onToggleSearchBy = () => { + this.props.actions.toggleSearchBy(); + }; + + render() { + const { t } = this.props; + const { searchByFiltersState, actions } = this.props; + const visibleClass = searchByFiltersState.isSearchByVisible + ? ' visible' + : ' closed'; + + return ( +
    +
    + + + + + {searchByFiltersState.chosenSearchByTab === 'emphasis' && ( + + )} + + {searchByFiltersState.chosenSearchByTab === 'languages' && ( + + )} + + {searchByFiltersState.chosenSearchByTab === 'locations' && ( + + )} + + {searchByFiltersState.chosenSearchByTab === 'sources' && ( + + )} + + {searchByFiltersState.chosenSearchByTab === 'sourceLists' && ( + + )} + + {searchByFiltersState.chosenSearchByTab === 'duplicates' && ( + + )} + + {searchByFiltersState.chosenSearchByTab === 'extras' && ( + + )} + + +
    +
    + +
    + ); + } +} + +export default translate(['tabsContent'], { wait: true })(SearchBy); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SearchByTabs.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SearchByTabs.js new file mode 100644 index 0000000..24ba6e3 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SearchByTabs.js @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Nav, NavLink, NavItem } from 'reactstrap'; +import { translate } from 'react-i18next'; + +export class SearchByTabs extends React.Component { + static propTypes = { + searchByTabs: PropTypes.array.isRequired, + chosenSearchByTab: PropTypes.string.isRequired, + chooseSearchByTab: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + }; + + chooseSearchByTab = (newTab) => () => { + this.props.chooseSearchByTab(newTab); + }; + + render() { + const { searchByTabs } = this.props; + const { t } = this.props; + + return ( + + ); + } +} + +export default translate(['tabsContent'], { wait: true })(SearchByTabs); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourceIcon.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourceIcon.js new file mode 100644 index 0000000..e5066cb --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourceIcon.js @@ -0,0 +1,25 @@ +import React from 'react' +import PropTypes from 'prop-types' + +export class SourceIcon extends React.Component { + static propTypes = { + type: PropTypes.string.isRequired + }; + + acceptedTypes = ['blogs', 'clippings', 'forums', 'mixed', 'news', 'prints', 'socials', 'user-added', 'user-comments', 'videos']; + + render () { + const { type } = this.props + + if (!this.acceptedTypes.includes(type)) { + return null + } + + return ( + + ) + } + +} + +export default SourceIcon diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourceListsTab.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourceListsTab.js new file mode 100644 index 0000000..73fc4fa --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourceListsTab.js @@ -0,0 +1,56 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import SourceListsTabList from './SourceListsTabList'; +import { Col } from 'reactstrap'; + +export class SourceListsTab extends React.Component { + static propTypes = { + searchBySourceLists: PropTypes.array.isRequired, + searchBySourceListsToInclude: PropTypes.array.isRequired, + searchBySourceListsToExclude: PropTypes.array.isRequired, + getSourceLists: PropTypes.func.isRequired, + moveSourceList: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + }; + + componentWillMount = () => { + this.props.getSourceLists({ page: 1, limit: 25 }); + }; + + render() { + const { + searchBySourceLists, + searchBySourceListsToInclude, + searchBySourceListsToExclude + } = this.props; + + return ( + +
    + + + + + + + + + + ); + } +} + +export default translate(['tabsContent'], { wait: true })(SourceListsTab); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourceListsTabItem.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourceListsTabItem.js new file mode 100644 index 0000000..ca50e42 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourceListsTabItem.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { DragSource } from 'react-dnd'; + +const Types = { + SOURCE_LIST: 'sourceList' +}; + +const sourceListSource = { + beginDrag(props) { + // Return the data describing the dragged item + return { oldDropTargetType: props.dropTargetType }; + }, + + endDrag(props, monitor, component) { + // When dropped on a compatible target, do something + if (monitor.getDropResult() !== null) { + const from = props.dropTargetType; + const to = monitor.getDropResult().newDropTargetType; + + const sourceList = props.sourceList; + + props.moveSourceList(from, to, sourceList); + } + } +}; + +/** + * Specifies which props to inject into your component. + */ +function collectDragSource(connect) { + return { + // Call this function inside render() + // to let React DnD handle the drag events: + connectDragSource: connect.dragSource() + }; +} + +export class SourceListsTabItem extends React.Component { + static propTypes = { + sourceList: PropTypes.func.isRequired, + dropTargetType: PropTypes.string.isRequired, + moveSourceList: PropTypes.func.isRequired, + connectDragSource: PropTypes.func.isRequired + }; + + render() { + const { connectDragSource } = this.props; + const { sourceList } = this.props; + + return connectDragSource( +
  • + + {sourceList.name} +
  • + ); + } +} + +export default DragSource( + Types.SOURCE_LIST, + sourceListSource, + collectDragSource +)(SourceListsTabItem); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourceListsTabList.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourceListsTabList.js new file mode 100644 index 0000000..b350592 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourceListsTabList.js @@ -0,0 +1,75 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { DropTarget } from 'react-dnd'; +import flow from 'lodash/flow'; +import SourceListsTabItem from './SourceListsTabItem'; +import { ListGroup } from 'reactstrap'; + +const targetTypes = ['sourceList']; +const sourceListTarget = { + drop(props, monitor, component) { + if (monitor.didDrop()) { + //check whether some nested + // target already handled drop + return; + } + + return { newDropTargetType: props.dropTargetType }; + }, + + canDrop(props, monitor) { + return props.dropTargetType !== monitor.getItem().oldDropTargetType; + } +}; + +function collectDropTarget(connect, monitor) { + return { + // Call this function inside render() + // to let React DnD handle the drag events: + connectDropTarget: connect.dropTarget(), + // You can ask the monitor about the current drag state: + itemType: monitor.getItemType() + }; +} + +export class SourceListsTabList extends React.Component { + static propTypes = { + sourceLists: PropTypes.array.isRequired, + dropTargetType: PropTypes.string.isRequired, + moveSourceList: PropTypes.func.isRequired, + t: PropTypes.func.isRequired, + connectDropTarget: PropTypes.func.isRequired + }; + + render() { + const { sourceLists, dropTargetType } = this.props; + const { t } = this.props; + const { connectDropTarget } = this.props; + + return connectDropTarget( +
    +

    + {t('searchTab.searchBySection.sourceLists.' + dropTargetType)} +

    + + {sourceLists.map((sourceList, i) => { + return ( + + ); + })} + +
    + ); + } +} + +export default flow( + DropTarget(targetTypes, sourceListTarget, collectDropTarget), + translate(['tabsContent'], { wait: true }) +)(SourceListsTabList); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourcesTab.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourcesTab.js new file mode 100644 index 0000000..67fa472 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourcesTab.js @@ -0,0 +1,67 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import SourcesTabAvailSources from './SourcesTabAvailSources'; +import SourcesTabSelectedSources from './SourcesTabSelectedSources'; +import { Col } from 'reactstrap'; + +export class SourcesTab extends React.Component { + static propTypes = { + chosenMediaTypes: PropTypes.array.isRequired, + chosenLanguages: PropTypes.array.isRequired, + searchBySources: PropTypes.array.isRequired, + selectedSearchBySources: PropTypes.array.isRequired, + searchBySourcesType: PropTypes.string.isRequired, + searchBySourcesQuery: PropTypes.string.isRequired, + setSearchBySourcesQuery: PropTypes.func.isRequired, + getSearchBySources: PropTypes.func.isRequired, + addSelectedSearchBySource: PropTypes.func.isRequired, + removeSelectedSearchBySource: PropTypes.func.isRequired, + clearSearchBySources: PropTypes.func.isRequired, + includeExcludeSearchBySources: PropTypes.func.isRequired + }; + + render() { + const { + searchBySourcesQuery, + setSearchBySourcesQuery, + chosenMediaTypes, + chosenLanguages, + searchBySources, + getSearchBySources, + addSelectedSearchBySource, + searchBySourcesType, + clearSearchBySources, + selectedSearchBySources, + removeSelectedSearchBySource, + includeExcludeSearchBySources + } = this.props; + + return ( + +
    + + + + + + + ); + } +} + +export default SourcesTab; diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourcesTabAvailSources.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourcesTabAvailSources.js new file mode 100644 index 0000000..07591be --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourcesTabAvailSources.js @@ -0,0 +1,160 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +// import SourceIcon from './SourceIcon'; +import { Button, Input, InputGroup, InputGroupAddon, Table } from 'reactstrap'; +import { capitalize } from 'lodash'; +import { getTitle } from '../../../../../../common/helper'; +import cx from 'classnames'; +import { domainNames } from '../SearchSubTab'; + +export class SourcesTabAvailSources extends React.Component { + static propTypes = { + chosenMediaTypes: PropTypes.array.isRequired, + chosenLanguages: PropTypes.array.isRequired, + availSources: PropTypes.array.isRequired, + selectedSources: PropTypes.array.isRequired, + searchBySourcesQuery: PropTypes.string.isRequired, + setSearchBySourcesQuery: PropTypes.func.isRequired, + getSearchBySources: PropTypes.func.isRequired, + addSelectedSearchBySource: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + }; + + componentDidMount = () => { + this.searchSources(); + }; + + searchSources = () => { + const { + chosenLanguages, + chosenMediaTypes, + getSearchBySources, + searchBySourcesQuery + } = this.props; + const query = searchBySourcesQuery; + const dataToSend = {}; + dataToSend.page = 1; + dataToSend.limit = 100; + dataToSend.query = query; + dataToSend.filters = {}; + + const source = [] + const domain = [] + chosenMediaTypes.map((v) => { + if (domainNames.includes(v)) { + domain.push(`${v}.com`); + } else { + source.push(v); + } + }) + dataToSend.filters.publisher = { source, domain }; + + dataToSend.filters.language = chosenLanguages; + getSearchBySources(dataToSend); + }; + + chooseSource = (e) => { + const dataset = e.currentTarget.dataset; + const sourceTitle = dataset.sourceTitle; + const sourceType = dataset.sourceType; + const sourceId = dataset.sourceId; + this.props.addSelectedSearchBySource({ + title: sourceTitle, + type: sourceType, + id: sourceId + }); + }; + + onChangeSearchInput = (e) => { + const val = e.target.value; + this.props.setSearchBySourcesQuery(val); + }; + + onEnterSearchInput = (e) => { + if (e.keyCode === 13) this.searchSources(); + }; + + render() { + const { availSources, selectedSources } = this.props; + const { t } = this.props; + + return ( + + + + + + + + +

    + {t('searchTab.searchBySection.sources.availSources')} +

    +
    +
    + + + + + + + + + + {availSources.length > 0 ? ( + availSources.map((source, i) => { + return ( + v.id === source.id) + })} + data-source-title={source.title} + data-source-type={source.type} + data-source-id={source.id} + onClick={this.chooseSource} + key={i} + > + {/* */} + + + + + + ); + }) + ) : ( + + + + )} + +
    {t('searchTab.searchBySection.sources.source')}{t('searchTab.searchBySection.sources.siteType')}{t('searchTab.searchBySection.sources.mediatype')}{t('searchTab.searchBySection.sources.lang')}
    + + {getTitle(source.title)} + {capitalize(source.siteType) || '-'} + {capitalize(source.type) || '-'}{t(`common:language.${source.lang}`)}
    {t('common:messages.noRows')}
    +
    +
    + ); + } +} + +export default translate(['tabsContent'], { wait: true })( + SourcesTabAvailSources +); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourcesTabSelectedSources.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourcesTabSelectedSources.js new file mode 100644 index 0000000..40bf233 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchBy/SourcesTabSelectedSources.js @@ -0,0 +1,122 @@ +/* eslint-disable react/jsx-no-bind */ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { Button, CustomInput, Table } from 'reactstrap'; +import { IoIosCloseCircleOutline } from 'react-icons/io'; +import { capitalize } from 'lodash'; +import { getTitle } from '../../../../../../common/helper'; + +export class SourcesTabSelectedSources extends React.Component { + static propTypes = { + searchBySourcesType: PropTypes.string.isRequired, + selectedSources: PropTypes.array.isRequired, + removeSelectedSearchBySource: PropTypes.func.isRequired, + clearSearchBySources: PropTypes.func.isRequired, + includeExcludeSearchBySources: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + }; + + removeSource = (sourceId) => { + this.props.removeSelectedSearchBySource(sourceId); + }; + + includeExclide = (type) => { + this.props.includeExcludeSearchBySources(type); + }; + + render() { + const { selectedSources } = this.props; + const { t } = this.props; + + return ( + +
    + this.includeExclide('include')} + label={t('searchTab.searchBySection.sources.includeText')} + /> + + this.includeExclide('exclude')} + label={t('searchTab.searchBySection.sources.excludeText')} + /> +
    + +

    + {t('searchTab.searchBySection.sources.selectedSources')} +

    + +
    + + + + + + + + + + {selectedSources.length > 0 ? ( + selectedSources.map((source, i) => { + return ( + + {/* */} + + + + + ); + }) + ) : ( + + + + )} + +
    {t('searchTab.searchBySection.sources.source')}{t('searchTab.searchBySection.sources.mediatype')}
    + + {getTitle(source.title)}{capitalize(source.type) || '-'} + +
    + {t('common:messages.noRows')}
    + {t('searchTab.searchBySection.sources.selectSource')} +
    +
    + {selectedSources.length > 0 && ( + + )} +
    + ); + } +} + +export default translate(['tabsContent'], { wait: true })( + SourcesTabSelectedSources +); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchDatesPopup.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchDatesPopup.js new file mode 100644 index 0000000..47d83fb --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchDatesPopup.js @@ -0,0 +1,178 @@ +import React from 'react' +import moment from 'moment' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import BetweenDatepickers from './SearchBy/BetweenDatepickers' +import { compose } from 'redux' +import classnames from 'classnames' +import { Button, CustomInput, FormGroup } from 'reactstrap' + +export class SearchDatesPopup extends React.Component { + static propTypes = { + userSubscriptionDate: PropTypes.string.isRequired, + userSubscription: PropTypes.string.isRequired, + t: PropTypes.func.isRequired, + searchIntervals: PropTypes.array.isRequired, + searchLastDates: PropTypes.array.isRequired, + chosenSearchInterval: PropTypes.string.isRequired, + chosenSearchLastDate: PropTypes.string.isRequired, + chosenStartDate: PropTypes.string.isRequired, + chosenEndDate: PropTypes.string.isRequired, + setSearchInterval: PropTypes.func.isRequired, + setSearchLastDate: PropTypes.func.isRequired, + setSearchDate: PropTypes.func.isRequired, + setStartDate: PropTypes.func.isRequired, + setEndDate: PropTypes.func.isRequired + } + + setSearchInterval = (e) => { + const chosenInterval = e.target.dataset.interval + const chosenStartDate = this.props.chosenStartDate + const chosenEndDate = this.props.chosenEndDate + const chosenLastDate = this.props.chosenSearchLastDate + const isIntervalBetween = chosenInterval === 'between' + + this.props.setSearchInterval(chosenInterval) + + if ( + (isIntervalBetween && chosenStartDate !== '') || + (isIntervalBetween && chosenEndDate !== '') + ) { + const endDate = chosenEndDate !== '' ? chosenEndDate : 'now' + const startDate = chosenStartDate !== '' ? chosenStartDate : 'until' + + this.props.setSearchDate(startDate + ' - ' + endDate) + } + + if (chosenInterval === 'all') { + this.props.setSearchDate('all') + } + + if (chosenInterval === 'last') { + this.props.setSearchDate(chosenLastDate) + } + } + + setLastDate = (e) => { + const chosenLastDate = e.target.dataset.lastDate + const isDisabled = e.target.dataset.disabled === 'true' + + if (isDisabled) return false + + if (this.props.chosenSearchInterval !== 'last') { + this.props.setSearchInterval('last') + } + + this.props.setSearchLastDate(chosenLastDate) + this.props.setSearchDate(chosenLastDate) + } + + onReset = () => { + this.props.setSearchInterval('all') + this.props.setSearchDate('all') + this.props.setStartDate('') + this.props.setEndDate('') + } + + render() { + const { + t, + chosenSearchInterval, + chosenStartDate, + chosenEndDate, + setSearchInterval, + setSearchDate, + setStartDate, + setEndDate, + chosenSearchLastDate, + searchIntervals, + searchLastDates, + userSubscription + } = this.props + const subscriptionLimitIndex = searchLastDates.indexOf(userSubscription) + const minDate = moment().startOf('day').subtract( + parseInt(userSubscription.slice(0, -1)), + 'days' + ) + + return ( +
    +
    +

    + {t('searchTab.searchDates.subscriptionLabel')}: + + {t('searchTab.userSubscription.' + this.props.userSubscription)} + +

    +
    + +
    +
    + + + {searchIntervals.map((interval, i) => { + return ( +
    + + + {interval === 'last' && ( +
      + {searchLastDates.map((lastDate, i) => { + const isDisabled = i > subscriptionLimitIndex + const isActive = + chosenSearchLastDate === lastDate && + chosenSearchInterval === 'last' + const className = classnames('search-last-dates__item', { + disabled: isDisabled, + active: isActive + }) + + return ( +
    • + {t('searchTab.searchDates.' + lastDate)} +
    • + ) + })} +
    + )} + + {interval === 'between' && ( + + )} +
    + ) + })} +
    +
    + ) + } +} + +const applyDecorators = compose(translate(['tabsContent'], { wait: true })) + +export default applyDecorators(SearchDatesPopup) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchSubTab.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchSubTab.js new file mode 100644 index 0000000..0d31a20 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchSubTab.js @@ -0,0 +1,439 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import SearchSubTabHead from './SearchSubTabHead'; +import MediaTypes from './MediaTypes'; +import SearchingBlock from './SearchingBlock'; +import SearchingResults from './SearchingResults'; +import SearchBy from './SearchBy/SearchBy'; +import RefinePanel from './RefinePanel'; +import Restrictions from '../../../../common/Restrictions/Restrictions'; +import { parseSearchDays } from '../../../../../common/Common'; +import reduxConnect from '../../../../../redux/utils/connect'; +import { Card, CardBody, CardTitle } from 'reactstrap'; +import { setDocumentData } from '../../../../../common/helper'; +import { translate } from 'react-i18next'; +import { compose } from 'redux'; + +export const domainNames = ['reddit', 'twitter', 'instagram']; + +class SearchSubTab extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + store: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired + }; + + get searchState() { + return this.props.store.appState.search; + } + get searchByFiltersState() { + return this.props.store.appState.searchByFilters; + } + get articlesState() { + return this.props.store.appState.articles; + } + get authState() { + return this.props.store.common.auth; + } + + componentDidMount() { + setDocumentData('title', 'Search'); + } + + componentWillUnmount() { + setDocumentData('title'); + } + + _sendSearchQuery = (page, initialSearch = false) => { + const { actions } = this.props; + const dataToSend = this.gatherSearchQueryData(); + if (dataToSend) { + dataToSend.page = page; + dataToSend.advancedFilters = this.gatherAdvancedFilters(); + actions.getSearchResults(dataToSend, initialSearch); + } + }; + + _sendFeedQuery = (page, activeFeed) => { + const { actions } = this.props; + const params = { + page: page, + advancedFilters: this.gatherAdvancedFilters() + }; + actions.getFeedResults(params, activeFeed.id); + }; + + onSearchQuery = () => { + this._sendSearchQuery(1, true); + }; + + onRefine = () => { + const { activeFeed } = this.searchState; + if (activeFeed) { + this._sendFeedQuery(1, activeFeed); + } else { + this._sendSearchQuery(1); + } + }; + + onPager = ({ currentPage: page }) => { + const { activeFeed } = this.searchState; + if (activeFeed) { + this._sendFeedQuery(page, activeFeed); + } else { + this._sendSearchQuery(page); + } + }; + + onSaveAsFeed = (name, category) => { + const dataToSend = this.getFeedData(name, category, 'query_feed'); + dataToSend && this.props.actions.saveAsFeed(dataToSend); + }; + + onSaveFeed = () => { + const { actions } = this.props; + const { activeFeed } = this.searchState; + const dataToSend = this.getFeedData( + activeFeed.name, + activeFeed.category, + activeFeed.subType + ); + dataToSend && actions.saveFeed(dataToSend, activeFeed.id); + }; + + getFeedData = (name, category, feedSubType) => { + let dataToSend = {}; + + const searchQueryData = this.gatherSearchQueryData(); + + if (!searchQueryData) return false; + + dataToSend.search = searchQueryData; + dataToSend.search.advancedFilters = this.gatherAdvancedFilters(); + + dataToSend.feed = { + name: name, + category: category, + subType: feedSubType + }; + + const excludedArticles = this.articlesState.excludedArticles; + if (excludedArticles && excludedArticles.length) { + dataToSend.feed.excludedDocuments = excludedArticles; + } + + return dataToSend; + }; + + gatherSearchQueryData = () => { + const searchState = this.searchState; + const searchByFiltersState = this.searchByFiltersState; + const { userSubscription } = this.authState; + const { actions } = this.props; + + let dataToSend = {}; + + const query = searchState.loadedFeedQuery; + + if (!query) { + actions.addAlert({ type: 'error', transKey: 'searchQueryEmpty' }); + return false; + } + + dataToSend.query = query; + + dataToSend.filters = {}; //create filters prop + + //setting media types filter + if (searchByFiltersState.chosenMediaTypes.length) { + const source = []; + const domain = []; + searchByFiltersState.chosenMediaTypes.map((v) => { + if (domainNames.includes(v)) { + domain.push(`${v}.com`); + } else { + source.push(v); + } + }); + dataToSend.filters.publisher = { source, domain }; + } else { + actions.addAlert({ type: 'error', transKey: 'noMediaTypesSelected' }); + return false; + } + + // setting date filter + const chosenInterval = searchByFiltersState.chosenSearchInterval; + const chosenStartDate = searchByFiltersState.chosenStartDate; + const chosenEndDate = searchByFiltersState.chosenEndDate; + + if (chosenInterval === 'between') { + if (chosenStartDate !== '' || chosenEndDate !== '') { + dataToSend.filters.date = { + type: 'between', + start: chosenStartDate, + end: chosenEndDate + }; + } else { + dataToSend.filters.date = { + type: 'last', + days: + searchByFiltersState.chosenSearchDate === 'all' + ? parseSearchDays(userSubscription) + : parseSearchDays(searchByFiltersState.chosenSearchDate) + }; + } + } else if (chosenInterval === 'all') { + dataToSend.filters.date = { + type: 'last', + days: parseSearchDays(userSubscription) + }; + } else { + dataToSend.filters.date = { + type: 'last', + days: parseSearchDays(searchByFiltersState.chosenSearchLastDate) + }; + } + + //adding included or/and excluded headlines filter + const headlineIncluded = searchByFiltersState.headlineIncluded; + const headlineExcluded = searchByFiltersState.headlineExcluded; + + if (headlineIncluded.length || headlineExcluded.length) { + dataToSend.filters.headline = {}; + } + + if (headlineIncluded.length) { + dataToSend.filters.headline.include = headlineIncluded; + } + + if (headlineExcluded.length) { + dataToSend.filters.headline.exclude = headlineExcluded; + } + + //setting languages filter + const chosenLanguages = searchByFiltersState.chosenLanguages; + + if (chosenLanguages.length) { + dataToSend.filters.language = chosenLanguages; + } + + //setting locations filter + const locationsToInclude = searchByFiltersState.locationsToInclude; + const locationsToExclude = searchByFiltersState.locationsToExclude; + + const countriesToInclude = locationsToInclude.filter((loc) => { + return loc.type === 'country'; + }); + const statesToInclude = locationsToInclude.filter((loc) => { + return loc.type === 'state'; + }); + const countriesToExclude = locationsToExclude.filter((loc) => { + return loc.type === 'country'; + }); + const statesToExclude = locationsToExclude.filter((loc) => { + return loc.type === 'state'; + }); + + if (countriesToInclude.length || countriesToExclude.length) { + dataToSend.filters.country = {}; + } + + if (statesToInclude.length || statesToExclude.length) { + dataToSend.filters.state = {}; + } + + if (countriesToInclude.length) { + dataToSend.filters.country.include = countriesToInclude.map((loc) => { + return loc.code; + }); + } + + if (countriesToExclude.length) { + dataToSend.filters.country.exclude = countriesToExclude.map((loc) => { + return loc.code; + }); + } + + if (statesToInclude.length) { + dataToSend.filters.state.include = statesToInclude.map((loc) => { + return loc.code; + }); + } + + if (statesToExclude.length) { + dataToSend.filters.state.exclude = statesToExclude.map((loc) => { + return loc.code; + }); + } + + //setting source filter + const selectedSearchBySources = + searchByFiltersState.selectedSearchBySources; + if (selectedSearchBySources.length) { + dataToSend.filters.source = {}; + dataToSend.filters.source.type = searchByFiltersState.searchBySourcesType; + dataToSend.filters.source.ids = selectedSearchBySources.map((source) => { + return source.id; + }); + } + + //setting source lists filter + const sourceListsToInclude = + searchByFiltersState.searchBySourceListsToInclude; + const sourceListsToExclude = + searchByFiltersState.searchBySourceListsToExclude; + + if (sourceListsToInclude.length || sourceListsToExclude.length) { + dataToSend.filters.sourceList = {}; + } + + if (sourceListsToInclude.length) { + dataToSend.filters.sourceList.include = sourceListsToInclude.map( + (source) => { + return source.id; + } + ); + } + + if (sourceListsToExclude.length) { + dataToSend.filters.sourceList.exclude = sourceListsToExclude.map( + (source) => { + return source.id; + } + ); + } + + //setting duplicates filter + //dataToSend.filters.duplicates = searchByFiltersState.includeDuplicates; + + //setting 'has images' filter + dataToSend.filters.hasImage = searchByFiltersState.hasImages; + + return dataToSend; + }; + + gatherAdvancedFilters = () => { + return this.searchState.advancedFilters.selected; + }; + + render() { + const searchState = this.searchState; + const searchByFiltersState = this.searchByFiltersState; + const { + userSubscription, + userSubscriptionDate, + user: { restrictions } + } = this.authState; + const { store, actions } = this.props; + const feedCategories = store.appState.sidebar.categories; + const articlesState = store.appState.articles; + + const { advancedFilters } = searchState; + const activeFeed = searchState.activeFeed; + let isEditSearchVisible = + !searchState.loadedFeedQuery || searchState.isEditingFeed; + if (activeFeed && activeFeed.subType === 'clip_feed') { + isEditSearchVisible = false; + } + const hasActiveFeed = !!activeFeed; + + return ( + + {!hasActiveFeed && ( + + )} +
    + + +
    + {isEditSearchVisible && ( +
    + + + + + +
    + )} + + +
    +
    +
    + + + + {this.props.t('searchTab.results')} +
    + + + {searchState.isLoaded && advancedFilters.isVisible && ( + + )} +
    +
    +
    +
    +
    + ); + } +} + +const applyDecorators = compose( + reduxConnect(), + translate(['tabsContent'], { wait: true }) +); + +export default applyDecorators(SearchSubTab); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchSubTabHead.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchSubTabHead.js new file mode 100644 index 0000000..28a747a --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchSubTabHead.js @@ -0,0 +1,135 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import SaveFeedPopup from './SaveFeedPopup' +import { Button } from 'reactstrap' +export class SearchSubTabHead extends React.Component { + static propTypes = { + feedCategories: PropTypes.array.isRequired, + isSaveFeedPopupVisible: PropTypes.bool.isRequired, + activeFeed: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + isEditingFeed: PropTypes.bool.isRequired, + addAlert: PropTypes.func.isRequired, + toggleSaveFeedPopup: PropTypes.func.isRequired, + onSaveAsFeed: PropTypes.func.isRequired, + getSidebarCategories: PropTypes.func.isRequired, + editFeed: PropTypes.func.isRequired, + setNewSearch: PropTypes.func.isRequired, + renewSearchBy: PropTypes.func.isRequired, + changeActiveFeedName: PropTypes.func.isRequired, + saveFeed: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + } + + openSaveFeedPopup = () => { + this.props.toggleSaveFeedPopup() + } + + saveFeed = () => { + this.props.saveFeed() + } + + onEditFeed = () => { + this.props.editFeed() + } + + onNewSearch = () => { + this.props.setNewSearch() + this.props.renewSearchBy() + } + + onChangeFeedName = (event) => { + this.props.changeActiveFeedName(event.target.value) + } + + render() { + const { + t, + isEditingFeed, + isSaveFeedPopupVisible, + isSaving, + activeFeed + } = this.props + const feedIsLoaded = !!activeFeed + const showEditButton = + !!activeFeed && !isEditingFeed && activeFeed.subType === 'query_feed' + + return ( +
    +
    +
    + {!isEditingFeed && activeFeed &&

    {activeFeed.name}

    } +
    +
    + + + {!feedIsLoaded && ( + + )} + + {feedIsLoaded && isEditingFeed && ( + + )} + + {feedIsLoaded && isEditingFeed && ( + + )} + + {showEditButton && ( + + )} +
    +
    + + {isSaveFeedPopupVisible && ( + + )} +
    + ) + } +} + +export default translate(['tabsContent'], { wait: true })(SearchSubTabHead) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchingBlock.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchingBlock.js new file mode 100644 index 0000000..f4da8f4 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchingBlock.js @@ -0,0 +1,61 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import { Button, Input, InputGroup, InputGroupAddon } from 'reactstrap' +export class SearchingBlock extends React.Component { + static propTypes = { + searchResultsErrors: PropTypes.array.isRequired, + loadedFeedQuery: PropTypes.string, + onSearchQuery: PropTypes.func.isRequired, + actions: PropTypes.object.isRequired, + t: PropTypes.func.isRequired + } + + onPressEnter = (e) => { + if (e.keyCode === 13) { + this.props.onSearchQuery() + } + } + + onChangeQuery = (e) => { + const { actions } = this.props + const value = e.target.value; + // replace smart quotation marks with normal + let filterQuotes = value.replace(/[\u2018\u2019]/g, '\'').replace(/[\u201C\u201D]/g, '"') + // add space before operator if not + filterQuotes = filterQuotes.replace(/\s*\+/g, ' +').replace(/\s*\-/g, ' -').trimStart() + actions.changeFeedQuery(filterQuotes) + } + + render() { + let { t, loadedFeedQuery } = this.props + loadedFeedQuery = loadedFeedQuery || '' + + return ( +
    + + + + + + +
    + ) + } +} + +export default translate(['tabsContent'], { wait: true })(SearchingBlock) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchingResults.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchingResults.js new file mode 100644 index 0000000..7d63e4b --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchingResults.js @@ -0,0 +1,178 @@ +import React from 'react' +import PropTypes from 'prop-types' +import SearchingResultsTopPanel from './SearchingResultsTopPanel' +import Article from './Article' +import DeleteArticlesPopup from './DeleteArticlesPopup' +import EmailArticlesPopup from './EmailArticlesPopup' +import CommentArticlePopup from './CommentArticlePopup' +import ClipArticlesPopup from './ClipArticles/ClipArticlesPopup' +import Pager from '../../../../common/Pager/Pager' +import EmailConfirmPopup from './EmailConfirmPopup' +import NoRecords from '../../../../common/NoRecords' +import Loading from '../../../../common/Loading' +import { Interpolate, translate } from 'react-i18next' + +export class SearchingResults extends React.Component { + static propTypes = { + searchState: PropTypes.object.isRequired, + articlesState: PropTypes.object.isRequired, + isRefinePanelVisible: PropTypes.bool.isRequired, + toggleRefinePanel: PropTypes.func.isRequired, + onPager: PropTypes.func.isRequired, + actions: PropTypes.object.isRequired, + t: PropTypes.func.isRequired + }; + + forEachArticle = (cb) => { + const { searchState, articlesState } = this.props + return searchState.searchResults + .filter((article) => !articlesState.excludedArticles.includes(article.id)) + .map(cb) + }; + + render () { + const { searchState, articlesState, actions, t } = this.props + const isSearchResultsLoaded = searchState.searchResults.length > 0 + const numPages = Math.ceil( + searchState.searchResultTotalCount / searchState.searchResultLimit + ) + + const noRecords = searchState.searchResultsPending || !isSearchResultsLoaded || !searchState.isSynced + + if (searchState.searchResultsPending) { + return ( +
    + +
    + ) + } + + if (!searchState.isSynced) { + return ( +
    + +
    + ) + } + + if (searchState.isSynced && !isSearchResultsLoaded) { + return ( +
    + +
    + ) + } + + return ( +
    + + + {isSearchResultsLoaded && +

    + +

    + } +
    + {isSearchResultsLoaded && + this.forEachArticle((article, i) => { + return ( +
    + ) + })} + + {isSearchResultsLoaded && ( + + )} +
    + + {articlesState.deletePopup.visible && ( + + )} + + {articlesState.emailPopup.visible && ( + + {articlesState.emailConfirmPopup.visible && ( + + )} + + )} + + {articlesState.commentPopup.visible && ( + + )} + + {articlesState.clipPopup.visible && ( + + )} +
    + ) + } +} + +export default translate(['tabsContent'], { wait: true })(SearchingResults) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchingResultsTopPanel.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchingResultsTopPanel.js new file mode 100644 index 0000000..1020bfe --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/SearchingResultsTopPanel.js @@ -0,0 +1,111 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ButtonGroup, Button, CustomInput } from 'reactstrap'; +import { translate } from 'react-i18next'; + +export class SearchingResultsTopPanel extends React.Component { + static propTypes = { + noRecords: PropTypes.bool, + selectedArticles: PropTypes.array.isRequired, + searchResultsCount: PropTypes.number.isRequired, + selectAllArticles: PropTypes.func.isRequired, + showDeleteArticlesPopup: PropTypes.func.isRequired, + showEmailArticlesPopup: PropTypes.func.isRequired, + showClipArticlesPopup: PropTypes.func.isRequired, + isRefinePanelVisible: PropTypes.bool.isRequired, + toggleRefinePanel: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + }; + + onShowClick = (e) => { + e.preventDefault(); + this.props.toggleRefinePanel(); + }; + + selectAllArticles = (e) => { + const isChecked = e.target.checked; + if (this.props.searchResultsCount > 0) { + this.props.selectAllArticles(isChecked); + } + }; + + showDeleteArticlesPopup = () => { + if (this.props.selectedArticles.length > 0) { + this.props.showDeleteArticlesPopup(this.props.selectedArticles); + } + }; + + showEmailArticlesPopup = () => { + if (this.props.selectedArticles.length > 0) { + this.props.showEmailArticlesPopup(this.props.selectedArticles); + } + }; + + showClipArticlesPopup = () => { + if (this.props.selectedArticles.length > 0) { + this.props.showClipArticlesPopup(this.props.selectedArticles); + } + }; + + render() { + const { t, searchResultsCount, noRecords } = this.props; + const chosenArticlesCount = this.props.selectedArticles.length; + const isAllArticlesChosen = + this.props.searchResultsCount > 0 + ? searchResultsCount === chosenArticlesCount + : false; + + if (noRecords) { + return null; + } + + return ( +
    + + + + {/* */} + + + + + + + {!this.props.isRefinePanelVisible && ( + + )} +
    + ); + } +} + +export default translate(['tabsContent'], { wait: true })( + SearchingResultsTopPanel +); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ShareMenu.js b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ShareMenu.js new file mode 100644 index 0000000..2d863b9 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchSubTab/ShareMenu.js @@ -0,0 +1,50 @@ +import React from 'react' +import PropTypes from 'prop-types' +import {translate} from 'react-i18next' +import onClickOutside from 'react-onclickoutside' +import {compose} from 'redux' + +class ShareMenu extends React.Component { + + static propTypes = { + article: PropTypes.object.isRequired, + hideMenu: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + }; + + handleClickOutside = () => { + this.props.hideMenu() + }; + + _winOpen = (url) => { + window.open(url, 'share', 'width=600, height=450, top=0, left=0, toolbar=no') + }; + + onTweet = () => { + this._winOpen('https://twitter.com/intent/tweet?url=' + this.props.article.source.link) + this.props.hideMenu() + }; + + onYammer = () => { + this._winOpen('https://www.yammer.com/') + this.props.hideMenu() + }; + + render () { + const { t } = this.props + + return ( + + ) + } +} + +const applyDecorators = compose( + translate(['tabsContent'], {wait: true}), + onClickOutside +) + +export default applyDecorators(ShareMenu) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SearchTab.js b/frontend/app/components/App/TabsContent/SearchTab/SearchTab.js new file mode 100644 index 0000000..aaf79bf --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SearchTab.js @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Redirect, Route, Switch, withRouter } from 'react-router-dom'; +import SubTabWrapper from '../../AppHeader/SubTabWrapper'; +import CSSTransitionGroup from 'react-transition-group/CSSTransitionGroup'; +import SearchSubTab from './SearchSubTab/SearchSubTab'; +import SourceIndexSubTab from './SourceIndexSubTab/SourceIndexSubTab'; +import SourceListsSubTab from './SourceListsSubTab/SourceListsSubTab'; + +class SearchTab extends React.Component { + static propTypes = { + activeTabName: PropTypes.string, + match: PropTypes.object, + subTabs: PropTypes.array + }; + + render() { + const { activeTabName, subTabs, match } = this.props; + return ( + + + + + + + + + + + ); + } +} + +export default withRouter(SearchTab); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/InfoField.js b/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/InfoField.js new file mode 100644 index 0000000..816afa1 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/InfoField.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { Col } from 'reactstrap'; + +export class InfoField extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + label: PropTypes.string, + labelValue: PropTypes.string, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.element]) + }; + + render() { + const { t, label, children, labelValue } = this.props; + + return ( +
  • + +

    {labelValue || t(label)}

    + + +

    {children}

    + +
  • + ); + } +} + +export default translate(['tabsContent'], { wait: true })(InfoField); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/SourceIndexInfoPopup.js b/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/SourceIndexInfoPopup.js new file mode 100644 index 0000000..ec40fca --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/SourceIndexInfoPopup.js @@ -0,0 +1,143 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import PopupLayout from '../../../../common/Popups/PopupLayout'; +import InfoField from './InfoField'; +import { + capOnlyFirstLetter, + getTitle, + notNullAndUnd +} from '../../../../../common/helper'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCheckCircle } from '@fortawesome/free-solid-svg-icons'; + +class SourceIndexInfoPopup extends React.Component { + static propTypes = { + source: PropTypes.object.isRequired, + hideSourceInfoPopup: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + }; + + render() { + let { t, source, hideSourceInfoPopup } = this.props; + /* + const loc = cl( + source.city, + source.state, + source.country && t(`common:country.${source.country}`) + ) + .split(' ') + .join(', '); */ + /* + source = { + ...source, + tags: ['Lorem', 'ipsum', 'dolor', 'ipsum', 'dolor', 'ipsum', 'dolor'], + verified: true, + followers: 3333, + following: 33, + favorites: 333, + title: 'Title', + url: 'URL', + type: 'Type', + subType: 'Sub Type', + lang: 'en', + location: 'Washington, DC', + country: 'US', + spam_probability: '20%', + likes: 3 + }; */ + + return ( + +
      + + + {getTitle(source.title)} + + + + {source.url && ( + {source.url} + )} + + {source.type && ( + + {capOnlyFirstLetter(source.type)} + + )} + + {source.subType && ( + + {capOnlyFirstLetter(source.subType)} + + )} + + {source.verified && ( + + + + )} + + {source.lang && ( + + {t(`common:language.${source.lang}`, '-')} + + )} + + {source.location && ( + {source.location} + )} + + {source.country && ( + + {t(`common:country.${source.country}`)} + + )} + + {notNullAndUnd(source.followers) && ( + {source.followers} + )} + + {notNullAndUnd(source.following) && ( + {source.following} + )} + + {notNullAndUnd(source.favorites) && ( + {source.favorites} + )} + + {notNullAndUnd(source.likes) && ( + {source.likes} + )} + + {source.tags && source.tags.length > 0 && ( + {source.tags.join(', ')} + )} + + {source.spam_probability && ( + + {source.spam_probability} + + )} + + {source.source_profiles && ( + + {source.source_profiles.join(', ')} + + )} +
    +
    + ); + } +} + +export default translate(['tabsContent'], { wait: true })(SourceIndexInfoPopup); diff --git a/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/SourceIndexSubTab.js b/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/SourceIndexSubTab.js new file mode 100644 index 0000000..6d180d7 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/SourceIndexSubTab.js @@ -0,0 +1,186 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import SourceIndexTable from './SourceIndexTable' +import SourceIndexUpdatePopup from './SourceIndexUpdatePopup' +import FiltersTable from '../../../../common/FiltersTable/FiltersTable' +import { withRouter } from 'react-router-dom' +import reduxConnect from '../../../../../redux/utils/connect' +import { compose } from 'redux' +import { Button, ButtonGroup, Input, InputGroup, InputGroupAddon } from 'reactstrap' +import { setDocumentData } from '../../../../../common/helper' + +class SourceIndexSubTab extends React.Component { + static propTypes = { + sourcesState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + t: PropTypes.func.isRequired + }; + + componentDidMount() { + setDocumentData('title', 'Source Index | Search') + } + + componentWillUnmount() { + setDocumentData('title') + } + + _sourceIndexesState = () => this.props.sourcesState.sourceIndexesState; + _sourceLists = () => this.props.sourcesState.sourceListsState.data; + + loadSourceIndexes = (params) => { + this.props.actions.getSourceIndexes(params || null) + }; + + onSearchSources = () => { + this.loadSourceIndexes() + }; + + onEnterSearchInput = (e) => { + if (e.keyCode === 13) this.loadSourceIndexes() + }; + + onChangeSearchInput = (e) => { + this.props.actions.setSourceIndexSearchQuery(e.target.value) + }; + + onFetchData = (params) => { + this.loadSourceIndexes(params) + }; + + showAddToListPopup = () => { + const { actions } = this.props + const sourceIndexesState = this._sourceIndexesState() + if (sourceIndexesState.selectedIds.length === 0) { + actions.addAlert({ + type: 'notice', + transKey: 'noListsSelected', + id: 'noListsSelected' + }) + + return false + } + + actions.toggleAddSourceToListPopup() + }; + + onSelectFilter = (groupName, filterValue) => { + this.props.actions.selectSourcesFilter(groupName, filterValue) + }; + + onClearFilters = (groupName) => { + this.props.actions.clearSourcesFilters(groupName) + }; + + onClearAllFilters = () => { + this.props.actions.clearAllSourcesFilters() + }; + + onMoreFilters = (groupName) => { + this.props.actions.loadMoreSourcesFilters(groupName) + }; + + onLessFilters = (groupName) => { + this.props.actions.loadLessSourcesFilters(groupName) + }; + + render () { + const { t, actions } = this.props + const sourceIndexesState = this._sourceIndexesState() + const sourceLists = this._sourceLists() + const { + searchQuery, + selectedIds, + chosenListsToAddSources, + chosenSourceToUpdate, + advancedFilters + } = sourceIndexesState + + return ( +
    + + + + + + + + + + + +
    + + + +
    + + {sourceIndexesState.isAddPopupVisible && ( + + )} + + {sourceIndexesState.isUpdatePopupVisible && ( + + )} +
    + ) + } +} + +const applyDecorators = compose( + withRouter, + reduxConnect('sourcesState', ['appState', 'sourcesState']), + translate(['tabsContent'], { wait: true }) +) + +export default applyDecorators(SourceIndexSubTab) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/SourceIndexTable.js b/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/SourceIndexTable.js new file mode 100644 index 0000000..0e5e11a --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/SourceIndexTable.js @@ -0,0 +1,195 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate, Interpolate } from 'react-i18next' + +import Table from '../../../../common/Table/Table' +import CheckboxCell from '../../../../common/Table/CheckboxCell' +import SortableTh from '../../../../common/Table/SortableTh' +import SourceIndexInfoPopup from './SourceIndexInfoPopup' +import { Button } from 'reactstrap' +import { getTitle } from '../../../../../common/helper' + +export class SourceIndexTable extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + tableState: PropTypes.object.isRequired, + type: PropTypes.string.isRequired, + onFetch: PropTypes.func.isRequired, + onDeleteIndex: PropTypes.func, + actions: PropTypes.object.isRequired + }; + + onFetch = (page, pageSize, sorted) => { + const { tableState, onFetch } = this.props + const params = { + page: page + 1, + limit: pageSize, + query: tableState.searchQuery + } + if (sorted.length) { + const sortedField = sorted[0] + const sort = { + field: sortedField.id, + direction: sortedField.desc ? 'desc' : 'asc' + } + params['sort'] = sort + } + onFetch(params) + }; + + selectAllAction = (event) => { + const { actions } = this.props + actions.toggleAllSourceIndexes() + }; + + selectRowAction = (itemId) => { + const { actions } = this.props + actions.toggleSourceIndex(itemId) // TODO + }; + + showUpdateSourcePopup = (source) => (e) => { + e.preventDefault() + this.props.actions.showUpdateSourcePopup(source) + }; + + deleteSourceIndex = (source) => (e) => { + e.preventDefault() + this.props.onDeleteIndex(source) + }; + + toggleInfoPopup = (source) => () => { + const { type, actions } = this.props + actions.toggleInfoSourcePopup(type, source) + }; + + getColumns = () => { + const {t, type, tableState} = this.props + + let columns = [ + { + id: 'selectCheckbox', + accessor: '', + sortable: false, + width: 45, + className: 'cw-center-cell', + headerClassName: 'cw-center-cell', + Header: () => { + return ( + + ) + }, + Cell: ({original}) => { + const isSelected = tableState.selectedIds.includes(original.id) + return ( + + ) + } + }, { + Header: , + accessor: 'name', + Cell: ({original}) => { + return ( + + ) + } + }, { + id: 'mediaType', + Header: , + accessor: item => t(`searchTab.sourceTypes.${item.type}`) + }, { + id: 'country', + Header: , + accessor: item => { + return item.country ? t(`common:country.${item.country}`) : '' + } + }, { + id: 'action', + Header: t('sourceIndexTab.action'), + sortable: false, + Cell: ({original}) => { + return ( + + ) + } + }, { + id: 'deleteAction', + Header: t('sourceIndexTab.action'), + sortable: false, + Cell: ({original}) => { + return ( + + ) + } + } + ] + + const sourceIndexCols = ['selectCheckbox', 'name', 'mediaType', 'country', 'action'] + const sourceOfListCols = ['name', 'mediaType', 'country', 'deleteAction'] + let cols = type === 'sourceIndexesState' ? sourceIndexCols : sourceOfListCols + return columns.filter(col => cols.includes(col.id) || cols.includes(col.accessor)) + }; + + render () { + const {tableState} = this.props + const columns = this.getColumns() + const infoPopup = tableState.infoPopup + + return ( +
    + + + {infoPopup.visible && infoPopup.item && + + } + + + ) + } +} + +export default translate(['tabsContent'], { wait: true })(SourceIndexTable) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/SourceIndexUpdatePopup.js b/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/SourceIndexUpdatePopup.js new file mode 100644 index 0000000..ef42211 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SourceIndexSubTab/SourceIndexUpdatePopup.js @@ -0,0 +1,106 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate, Interpolate } from 'react-i18next' +import PopupLayout from '../../../../common/Popups/PopupLayout' +import { CustomInput } from 'reactstrap' +import { getTitle } from '../../../../../common/helper' + +export class SourceIndexUpdatePopup extends React.Component { + static propTypes = { + type: PropTypes.string.isRequired, + sourceLists: PropTypes.array.isRequired, + chosenLists: PropTypes.array.isRequired, + chosenSourceIndexes: PropTypes.array.isRequired, + updateItemTitle: PropTypes.string, + actions: PropTypes.object.isRequired, + t: PropTypes.func.isRequired + }; + + componentWillMount = () => { + const { sourceLists, actions } = this.props + if (sourceLists.length === 0) { + actions.getMainSourceLists({page: 1, limit: 50}) + } + }; + + onChoseList = (e) => { + const { type, chosenLists, actions } = this.props + const isChecked = e.target.checked + const listId = parseInt(e.target.dataset.listId) + const lists = isChecked ? chosenLists.concat(listId) : chosenLists.filter((id) => listId !== id) + const action = type === 'add' ? actions.setChosenListsToAddSources : actions.setChosenListsToUpdateSources + + action(lists) + }; + + onSubmit = () => { + const { actions, chosenSourceIndexes, chosenLists, type } = this.props + + actions.addSourcesToList({ + sources: chosenSourceIndexes, + sourceLists: chosenLists + }, type === 'add') + }; + + getBodyTitle () { + const { t, type, updateItemTitle } = this.props + if (type === 'add') { + return

    {t('sourceListsTab.popup.addToListDesc')}

    + } + else { + return ( +

    + +

    + ) + } + } + + render () { + const { type, sourceLists, chosenLists, actions } = this.props + const isAdd = type === 'add' + const title = isAdd ? 'addToListTitle' : 'updateListTitle' + const submitText = isAdd ? 'addBtn' : 'saveBtn' + const hideAction = isAdd ? actions.toggleAddSourceToListPopup : actions.hideUpdateSourcePopup + + return ( + +
    + {this.getBodyTitle()} + + {sourceLists.length > 0 && +
      + {sourceLists.map((list, i) => { + const isListChosen = chosenLists.includes(list.id) + return ( +
    • + +
    • + ) + })} +
    + } +
    +
    + ) + } +} + +export default translate(['tabsContent', 'common'], { wait: true })(SourceIndexUpdatePopup) + diff --git a/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceLists.js b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceLists.js new file mode 100644 index 0000000..7afe93e --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceLists.js @@ -0,0 +1,101 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import SourceListsAddPopup from './SourceListsAddPopup' +import SourceListsDeletePopup from './SourceListsDeletePopup' +import SourceListsRenamePopup from './SourceListsRenamePopup' +import SourceListsClonePopup from './SourceListsClonePopup' +import SourceListsTable from './SourceListsTable' +import { Button, CustomInput } from 'reactstrap' + +export class SourceLists extends React.Component { + static propTypes = { + sourceListsState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + t: PropTypes.func.isRequired + } + + onGlobalOnlyClick = () => { + const { actions, sourceListsState } = this.props + actions.toggleOnlyGlobal() + const params = { + page: sourceListsState.page, + limit: sourceListsState.limit, + onlyShared: !sourceListsState.onlyGlobal, + sort: { + field: sourceListsState.sortByField, + direction: sourceListsState.sortDirection + } + } + actions.getMainSourceLists(params) + } + + render() { + const { t, sourceListsState, actions } = this.props + const { + isAddListPopupVisible, + isDeletePopupVisible, + isRenameListPopupVisible, + isCloneListPopupVisible, + listToEdit + } = sourceListsState + + return ( +
    +
    + + +
    + + + + {isAddListPopupVisible && ( + + )} + + {isDeletePopupVisible && ( + + )} + + {isRenameListPopupVisible && ( + + )} + + {isCloneListPopupVisible && ( + + )} +
    + ) + } +} + +export default translate(['tabsContent'], { wait: true })(SourceLists) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsAddPopup.js b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsAddPopup.js new file mode 100644 index 0000000..873ede7 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsAddPopup.js @@ -0,0 +1,55 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import PopupLayout from '../../../../common/Popups/PopupLayout' +import { FormGroup, Input, Label } from 'reactstrap' + +export class SourceListsAddPopup extends React.Component { + static propTypes = { + toggleAddListPopup: PropTypes.func.isRequired, + addSourceList: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + } + + state = { + name: '' + } + + onSubmit = () => { + const { addSourceList } = this.props + addSourceList(this.state.name) + } + + handleChange = (e) => { + const { value } = e.target + this.setState({ name: value }) + } + + render() { + const { toggleAddListPopup, t } = this.props + + return ( + +
    + + + + +
    +
    + ) + } +} + +export default translate(['tabsContent', 'common'], { wait: true })( + SourceListsAddPopup +) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsClonePopup.js b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsClonePopup.js new file mode 100644 index 0000000..a449b83 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsClonePopup.js @@ -0,0 +1,65 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import PopupLayout from '../../../../common/Popups/PopupLayout' +import { FormGroup, Input, Label } from 'reactstrap' + +export class SourceListsClonePopup extends React.Component { + static propTypes = { + listToEdit: PropTypes.func.isRequired, + toggleCloneListPopup: PropTypes.func.isRequired, + cloneSourceList: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + } + + constructor(props) { + super(props) + this.state = { + name: + props.listToEdit && props.listToEdit.name + ? `${props.listToEdit.name} (copy)` + : '' + } + } + + handleChange = (e) => { + const { value } = e.target + this.setState({ + name: value + }) + } + + onSubmit = () => { + const { listToEdit, cloneSourceList } = this.props + cloneSourceList({ + id: listToEdit.id, + name: this.state.name + }) + } + + render() { + const { toggleCloneListPopup, t } = this.props + + return ( + + + + + + + ) + } +} + +export default translate(['tabsContent', 'common'], { wait: true })( + SourceListsClonePopup +) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsDeletePopup.js b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsDeletePopup.js new file mode 100644 index 0000000..13ee838 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsDeletePopup.js @@ -0,0 +1,42 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate, Interpolate } from 'react-i18next' +import PopupLayout from '../../../../common/Popups/PopupLayout' +import { getTitle } from '../../../../../common/helper'; + +export class SourceListsDeletePopup extends React.Component { + static propTypes = { + listToEdit: PropTypes.func.isRequired, + toggleDeleteListPopup: PropTypes.func.isRequired, + deleteSourceList: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + }; + + onSubmit = () => { + const { listToEdit, deleteSourceList } = this.props + deleteSourceList(listToEdit) + }; + + render () { + const { listToEdit, toggleDeleteListPopup } = this.props + const value = listToEdit.name || listToEdit.title || '' + + return ( + + + + ) + } +} + +export default translate(['tabsContent', 'common'], { wait: true })(SourceListsDeletePopup) + diff --git a/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsRenamePopup.js b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsRenamePopup.js new file mode 100644 index 0000000..576d614 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsRenamePopup.js @@ -0,0 +1,64 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import PopupLayout from '../../../../common/Popups/PopupLayout' +import { FormGroup, Input, Label } from 'reactstrap' + +export class SourceListsRenamePopup extends React.Component { + static propTypes = { + listToEdit: PropTypes.func.isRequired, + toggleRenameListPopup: PropTypes.func.isRequired, + renameSourceList: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + } + + constructor(props) { + super(props) + this.state = { + name: (props.listToEdit && props.listToEdit.name) || '' + } + } + + handleChange = (e) => { + const { value } = e.target + this.setState({ + name: value + }) + } + + onSubmit = () => { + const { listToEdit, renameSourceList } = this.props + const data = { + id: listToEdit.id, + name: this.state.name + } + + renameSourceList(data, listToEdit.name) + } + + render() { + const { toggleRenameListPopup, t } = this.props + + return ( + + + + + + + ) + } +} + +export default translate(['tabsContent', 'common'], { wait: true })( + SourceListsRenamePopup +) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsSubTab.js b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsSubTab.js new file mode 100644 index 0000000..09927cb --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsSubTab.js @@ -0,0 +1,51 @@ +import React, { Fragment } from 'react' +import PropTypes from 'prop-types' +import SourceLists from './SourceLists' +import SourcesOfList from './SourcesOfList' +import { withRouter } from 'react-router-dom' +import reduxConnect from '../../../../../redux/utils/connect' +import { compose } from 'redux' +import { setDocumentData } from '../../../../../common/helper' + +class SourceListsSubTab extends React.Component { + static propTypes = { + sourcesState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired + } + + componentDidMount() { + setDocumentData('title', 'Source Lists | Search') + } + + componentWillUnmount() { + setDocumentData('title') + } + + render() { + const { sourcesState, actions } = this.props + const { sourcesOfListState, sourceListsState } = sourcesState + const sourcesOfListVisible = sourcesOfListState.isSourcesOfListVisible + + return ( + + {!sourcesOfListVisible && ( + + )} + + {sourcesOfListVisible && ( + + )} + + ) + } +} + +const applyDecorators = compose( + withRouter, + reduxConnect('sourcesState', ['appState', 'sourcesState']) +) + +export default applyDecorators(SourceListsSubTab) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsTable.js b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsTable.js new file mode 100644 index 0000000..55c7641 --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourceListsTable.js @@ -0,0 +1,256 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import moment from 'moment' + +import Table from '../../../../common/Table/Table' +import SortableTh from '../../../../common/Table/SortableTh' +import { Button } from 'reactstrap' + +export class SourceListsTable extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + tableState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired + } + + onFetch = (page, pageSize, sorted) => { + const { actions, tableState } = this.props + const params = { + page: page + 1, + limit: pageSize, + onlyShared: tableState.onlyGlobal + } + if (sorted.length) { + const sortedField = sorted[0] + const sort = { + field: sortedField.id, + direction: sortedField.desc ? 'desc' : 'asc' + } + params['sort'] = sort + } + actions.getMainSourceLists(params) + } + + showDeleteListPopup = (item) => () => { + this.props.actions.toggleDeleteListPopup(item) + } + + showRenameListPopup = (item) => () => { + this.props.actions.toggleRenameListPopup(item) + } + + showCloneListPopup = (item) => () => { + this.props.actions.toggleCloneListPopup(item) + } + + showSourcesOfList = (item) => () => { + this.props.actions.showSourcesOfList(item) + } + + onShareList = (id) => () => { + this.props.actions.shareSourceList(id) + } + + onUnshareList = (id) => () => { + this.props.actions.unshareSourceList(id) + } + + getColumns() { + const { t } = this.props + + let columns = [ + { + Header: , + accessor: 'name', + Cell: ({ original }) => { + return ( + + {original.name} + + ) + } + }, + { + id: 'sources', + Header: , + accessor: (item) => item.sourceNumber + }, + { + id: 'createdBy', + Header: , + accessor: (item) => `${item.user.firstName} ${item.user.lastName}` + }, + { + id: 'lastUpdated', + Header: , + accessor: (item) => + item.updatedAt && moment(item.updatedAt).format('Do MMM YYYY') + }, + { + id: 'lastUpdatedBy', + Header: , + accessor: (item) => + item.updatedBy && + `${item.updatedBy.firstName} ${item.updatedBy.lastName}` + }, + { + id: 'action', + Header: t('sourceIndexTab.action'), + // sortable: false, + minWidth: 220, + Cell: ({ original }) => { + return ( + // + // + // + // + // + //

    Hello

    + // {/* + // + // {t("sourceListsTab.unshare")} + // + // + // + // {t("sourceListsTab.share")} + // + // + // + // {t("sourceListsTab.rename")} + // + // + // + // {t("sourceListsTab.clone")} + // + // + // + // {t("sourceListsTab.delete")} + // */} + //
    + //
    + + //
    + // + // + // + // + // + // + // + // {t("sourceListsTab.unshare")} + // + // + // + // {t("sourceListsTab.share")} + // + // + // + // {t("sourceListsTab.rename")} + // + // + // + // {t("sourceListsTab.clone")} + // + // + // + // {t("sourceListsTab.delete")} + // + // + // + //
    +
    + + + + +
    + ) + } + } + ] + + const cols = [ + 'name', + 'sources', + 'createdBy', + 'lastUpdated', + 'lastUpdatedBy', + 'action' + ] + return columns.filter( + (col) => cols.includes(col.id) || cols.includes(col.accessor) + ) + } + + render() { + const { tableState } = this.props + const columns = this.getColumns() + + return ( +
    +
    + + ) + } +} + +export default translate(['tabsContent'], { wait: true })(SourceListsTable) diff --git a/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourcesOfList.js b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourcesOfList.js new file mode 100644 index 0000000..75a555d --- /dev/null +++ b/frontend/app/components/App/TabsContent/SearchTab/SourceListsSubTab/SourcesOfList.js @@ -0,0 +1,111 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import SourceIndexTable from '../SourceIndexSubTab/SourceIndexTable' +import SourceListsDeletePopup from './SourceListsDeletePopup' +import { Button, Input, InputGroup, InputGroupAddon } from 'reactstrap' + +export class SourcesOfList extends React.Component { + static propTypes = { + sourcesOfListState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + t: PropTypes.func.isRequired + }; + + componentWillMount = () => { + this.searchSources('') + }; + + searchSources = (query) => { + const { actions, sourcesOfListState } = this.props + + actions.getSourcesOfList(sourcesOfListState.visibleList.id, { + query: query, + page: sourcesOfListState.page, + limit: sourcesOfListState.limit + }) + }; + + onSearchSources = () => { + const query = this.props.sourcesOfListState.searchQuery + this.searchSources(query) + }; + + onEnterSearchInput = (e) => { + if (e.keyCode === 13) this.onSearchSources() + }; + + onChangeSearchInput = (e) => { + const val = e.target.value + this.props.actions.setSourcesOfListSearchQuery(val) + }; + + onFetchData = (params) => { + const { sourcesOfListState, actions } = this.props + actions.getSourcesOfList(sourcesOfListState.visibleList.id, params) + }; + + onDeleteIndex = (source) => { + const { sourcesOfListState, actions } = this.props + const listId = sourcesOfListState.visibleList.id + + actions.updateListSources({ + id: source.id, + sourceLists: source.listIds.filter(id => id !== listId) + }) + }; + + render () { + const { t, sourcesOfListState, actions } = this.props + const { searchQuery, visibleList, isDeletePopupVisible, listToEdit } = sourcesOfListState + + return ( +
    + + +
    +

    {visibleList.name} ({visibleList.sourceNumber})

    + + + + + + +
    + + + + {isDeletePopupVisible && + + } +
    + ) + } +} + +export default translate(['tabsContent'], { wait: true })(SourcesOfList) + diff --git a/frontend/app/components/App/TabsContent/ShareTab/ExportSubTab/ExportFeedsTable.js b/frontend/app/components/App/TabsContent/ShareTab/ExportSubTab/ExportFeedsTable.js new file mode 100644 index 0000000..13751c5 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ExportSubTab/ExportFeedsTable.js @@ -0,0 +1,60 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import ExportFeedsTableRow from './ExportFeedsTableRow' +import LoadersAdvanced from '../../../../common/Loader/Loader' +import { Table, Card, CardBody } from 'reactstrap' + +class ExportFeedsTable extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + isLoading: PropTypes.bool.isRequired, + tableData: PropTypes.array.isRequired, + showPopup: PropTypes.func.isRequired, + unexportFeed: PropTypes.func.isRequired, + goToFeed: PropTypes.func.isRequired + } + + render() { + const { + tableData, + isLoading, + showPopup, + unexportFeed, + goToFeed, + t + } = this.props + + return ( + + {isLoading && } + +
    + + + + + + + + + {tableData.map((feed) => { + return ( + + ) + })} + +
    {t('exportTab.feedName')}{t('exportTab.exportWith')}{t('exportTab.actions')}
    + + + ) + } +} + +export default translate(['tabsContent'], { wait: true })(ExportFeedsTable) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ExportSubTab/ExportFeedsTableRow.js b/frontend/app/components/App/TabsContent/ShareTab/ExportSubTab/ExportFeedsTableRow.js new file mode 100644 index 0000000..fc88284 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ExportSubTab/ExportFeedsTableRow.js @@ -0,0 +1,132 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Interpolate, translate } from 'react-i18next'; +import Select from 'react-select'; +import { Modal, ModalBody, ModalFooter, Button, ModalHeader } from 'reactstrap'; + +class ExportFeedsTableRow extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + feed: PropTypes.object.isRequired, + showPopup: PropTypes.func.isRequired, + unexportFeed: PropTypes.func.isRequired, + goToFeed: PropTypes.func.isRequired + }; + + constructor(props) { + super(props); + this.state = { + format: 'rss', + modal: false + }; + } + + showExportPopup = () => { + this.props.showPopup(this.props.feed, this.state.format); + }; + + toggle = () => { + this.setState((prev) => ({ modal: !prev.modal })); + }; + + exportOptions = [ + { label: 'RSS 2.0', value: 'rss' }, + { label: 'Atom 1.0', value: 'atom' }, + { label: 'TSV', value: 'tsv' }, + { label: 'HTML', value: 'html' } + ]; + + onChangeFormat = (format) => { + this.setState({ + format: format + }); + }; + + onDeleteClick = () => { + this.setState({ modal: false }); + this.props.unexportFeed(this.props.feed.id); + }; + + goToFeed = (e) => { + e.preventDefault(); + this.props.goToFeed(this.props.feed.id); + }; + + render() { + const { feed, t } = this.props; + + return ( + + + + + + + +
    + + +
    +
    + + ) + } +} + +export default translate(['tabsContent'], { wait: true })(FiltersTopBar) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageEmailsSubTab/ManageEmailsSubTab.js b/frontend/app/components/App/TabsContent/ShareTab/ManageEmailsSubTab/ManageEmailsSubTab.js new file mode 100644 index 0000000..77507bd --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageEmailsSubTab/ManageEmailsSubTab.js @@ -0,0 +1,87 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import TopBar from './TopBar'; +import EmailsTable from './EmailsTable'; +import reduxConnect from '../../../../../redux/utils/connect'; +import AlertForm from './AlertForm'; +import Navigation from './Navigation'; +import FiltersTable from './FiltersTable'; +import FiltersTopBar from './FiltersTopBar'; +import { EMAILS_SUBSCREENS } from '../../../../../redux/modules/appState/share/tabs'; +import { setDocumentData } from '../../../../../common/helper'; + +class ManageEmailsSubTab extends React.Component { + static propTypes = { + shareState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired + }; + + componentDidMount() { + setDocumentData('title', 'Manage Recipients | Share') + } + + componentWillUnmount() { + setDocumentData('title') + } + + render() { + const { shareState, actions } = this.props; + const { subScreenVisible } = shareState.tabs.emails; + + return ( +
    + {subScreenVisible === EMAILS_SUBSCREENS.EMAILS_TABLE && ( +
    + + +
    + )} + + {(subScreenVisible === EMAILS_SUBSCREENS.ALERT_FORM || + subScreenVisible === EMAILS_SUBSCREENS.NEWSLETTER_FORM) && ( + + )} + + {subScreenVisible === EMAILS_SUBSCREENS.ALERT_FORM && ( + + )} + + {/* {subScreenVisible === EMAILS_SUBSCREENS.NEWSLETTER_FORM && + + } */} + + {subScreenVisible === EMAILS_SUBSCREENS.FILTERS_TABLE && ( + + + + + )} +
    + ); + } +} + +export default reduxConnect('shareState', ['appState', 'share'])( + ManageEmailsSubTab +); diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageEmailsSubTab/Navigation.js b/frontend/app/components/App/TabsContent/ShareTab/ManageEmailsSubTab/Navigation.js new file mode 100644 index 0000000..eaa16d3 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageEmailsSubTab/Navigation.js @@ -0,0 +1,30 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import { EMAILS_SUBSCREENS } from '../../../../../redux/modules/appState/share/tabs' +import { Button } from 'reactstrap' + +class Navigation extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + actions: PropTypes.object.isRequired + }; + + backToTable = () => { + this.props.actions.switchShareSubScreen( + 'emails', + EMAILS_SUBSCREENS.EMAILS_TABLE + ) + }; + + render () { + + return ( + + ) + } +} + +export default translate(['tabsContent'], { wait: true })(Navigation) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageEmailsSubTab/NewsletterForm.js b/frontend/app/components/App/TabsContent/ShareTab/ManageEmailsSubTab/NewsletterForm.js new file mode 100644 index 0000000..c73c1c1 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageEmailsSubTab/NewsletterForm.js @@ -0,0 +1,14 @@ +import PropTypes from 'prop-types' +import {NewsletterForm as BaseNewsletterForm} from '../NotificatoinsSubTab/forms/NewsletterForm' +import {translate} from 'react-i18next' + +export class NewsletterForm extends BaseNewsletterForm { + static propTypes = { + t: PropTypes.func.isRequired, + state: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired + }; + +} + +export default translate(['tabsContent'], { wait: true })(NewsletterForm) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageEmailsSubTab/TopBar.js b/frontend/app/components/App/TabsContent/ShareTab/ManageEmailsSubTab/TopBar.js new file mode 100644 index 0000000..de2458e --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageEmailsSubTab/TopBar.js @@ -0,0 +1,72 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import { EMAILS_SUBSCREENS } from '../../../../../redux/modules/appState/share/tabs' +import { Button } from 'reactstrap' + +export class TopBar extends React.Component { + static propTypes = { + actions: PropTypes.object.isRequired, + tableState: PropTypes.object.isRequired, + t: PropTypes.func.isRequired + }; + + onCreate = (type) => () => { + const { actions } = this.props + actions.startCreateNotification(type, 'emails', 'emails') + }; + + goToFiltersTable = () => { + const { actions } = this.props + actions.switchShareSubScreen('emails', EMAILS_SUBSCREENS.FILTERS_TABLE) + }; + + render () { + const { + t, + tableState: { filter } + } = this.props + + const filterName = filter + ? `${filter.name} (${t('manageEmailsTab.' + filter.type)})` + : t('manageEmailsTab.allEmails') + + return ( +
    +

    + {t('manageEmailsTab.currentFilter') + ': '}{" "} + {filterName} +

    +
    + +
    + + {/* */} +
    +
    +
    + ) + } +} + +export default translate(['tabsContent'], { wait: true })(TopBar) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/GroupsTable.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/GroupsTable.js new file mode 100644 index 0000000..66d6f2a --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/GroupsTable.js @@ -0,0 +1,52 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import ReceiversTable from './ReceiversTable' +import SortableTh from '../../../../common/Table/SortableTh' +import LinkCell from '../../../../common/Table/LinkCell' + +class GroupsTable extends ReceiversTable { + static propTypes = { + t: PropTypes.func.isRequired, + tableState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + tableActions: PropTypes.object.isRequired, + deleteSingleText: PropTypes.string.isRequired, + deleteMultipleText: PropTypes.string.isRequired + }; + + nameClickAction = (item) => { + this.props.actions.startEditGroup(item) + }; + + defineColumns () { + //const {t} = this.props; + const colDefs = super.defineColumns() + return { + ...colDefs, + 'recipientsNumber': { + Header: , + accessor: item => item.recipients.length || '', + width: 140 + }, + 'name': { + Header: , + accessor: 'name', + Cell: (row) => { + return ( + + {row.value} + + ) + } + } + } + } + + getColumns () { + return ['selectCheckbox', 'name', 'recipientsNumber', 'subscriptions', 'creationDate', 'active'] + } + +} + +export default translate(['tabsContent'], { wait: true })(GroupsTable) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/ManageRecipientsSubTab.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/ManageRecipientsSubTab.js new file mode 100644 index 0000000..fb06ef0 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/ManageRecipientsSubTab.js @@ -0,0 +1,83 @@ +import React from 'react' +import PropTypes from 'prop-types' +import TopBar from './TopBar' +import RecipientsTable from './RecipientsTable' +import { RECEIVER_TABLES, RECEIVER_SUBSCREENS } from '../../../../../redux/modules/appState/share/tabs' +import {RecipientForm} from './forms/ReceiverForm' +import GroupsTable from './GroupsTable' +import {withRouter} from 'react-router-dom' +import reduxConnect from '../../../../../redux/utils/connect' +import {compose} from 'redux' +import { setDocumentData } from '../../../../../common/helper' + +class ManageRecipientsSubTab extends React.Component { + static propTypes = { + shareState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired + }; + + componentDidMount() { + setDocumentData('title', 'Manage Emails | Share') + } + + componentWillUnmount() { + setDocumentData('title') + } + + render () { + + const { shareState, actions } = this.props + const { subScreenVisible, tableVisible } = shareState.tabs.recipients + const tableState = shareState.tables[tableVisible] + + return ( +
    + + {subScreenVisible === RECEIVER_SUBSCREENS.TABLES && +
    + + + {tableVisible === RECEIVER_TABLES.RECIPIENTS && + + } + {tableVisible === RECEIVER_TABLES.GROUPS && + + } +
    + } + + {(subScreenVisible === RECEIVER_SUBSCREENS.RECIPIENT_FORM || subScreenVisible === RECEIVER_SUBSCREENS.GROUP_FORM) && + + } + +
    + ) + } +} + +const applyDecorators = compose( + withRouter, + reduxConnect('shareState', ['appState', 'share']) +) + +export default applyDecorators(ManageRecipientsSubTab) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/ReceiversTable.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/ReceiversTable.js new file mode 100644 index 0000000..e790b96 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/ReceiversTable.js @@ -0,0 +1,108 @@ +import React from 'react' +import GenericTable from '../common/GenericTable' +import SortableTh from '../../../../common/Table/SortableTh' +import PropTypes from 'prop-types' +import { ButtonGroup, Button } from 'reactstrap' +import { convertUTCtoLocal } from '../../../../../common/helper' + +class ReceiversTable extends GenericTable { + static propTypes = { + t: PropTypes.func.isRequired, + tableState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + tableActions: PropTypes.object.isRequired, + deleteSingleText: PropTypes.string.isRequired, + deleteMultipleText: PropTypes.string.isRequired + }; + + onActivateButtonClick = () => { + const { tableState, tableActions } = this.props + tableActions.toggleActive(tableState.selectedIds, true) + }; + + onPauseButtonClick = () => { + const { tableState, tableActions } = this.props + tableActions.toggleActive(tableState.selectedIds, false) + }; + + togglerOnAction = (itemId) => { + const { tableActions } = this.props + tableActions.toggleActive([itemId], true) + }; + + togglerOffAction = (itemId) => { + const { tableActions } = this.props + tableActions.toggleActive([itemId], false) + }; + + getActionsPanel = () => { + const { t } = this.props + + return ( + + + + + + ) + }; + + _formatSubscriptions (subscriptions) { + const { t } = this.props + const result = [] + if (subscriptions.alert > 0) { + result.push(`${subscriptions.alert} ${t('notificationsTab.alerts')}`) + } + if (subscriptions.newsletter > 0) { + result.push(`${subscriptions.newsletter} ${t('notificationsTab.newsletters')}`) + } + return result.join(', ') + } + + defineColumns () { + const { t } = this.props + const colDefinitions = super.defineColumns() + return { + ...colDefinitions, + subscriptions: { + sortable: false, + Header: t('manageRecipientsTab.subscriptions'), + accessor: (item) => this._formatSubscriptions(item.subscriptions), + width: 170 + }, + creationDate: { + Header: , + accessor: (item) => convertUTCtoLocal(item.creationDate, 'DD MMM YYYY HH:mm'), + width: 100 + }, + active: this.createTogglerColumn( + 'manageRecipientsTab.status', + 'active', + 'active', + 'paused', + this.togglerOnAction, + this.togglerOffAction + ) + } + } +} + +export default ReceiversTable diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/RecipientsTable.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/RecipientsTable.js new file mode 100644 index 0000000..b3147d4 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/RecipientsTable.js @@ -0,0 +1,60 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import ReceiversTable from './ReceiversTable' +import SortableTh from '../../../../common/Table/SortableTh' +import LinkCell from '../../../../common/Table/LinkCell' + +class RecipientsTable extends ReceiversTable { + static propTypes = { + t: PropTypes.func.isRequired, + tableState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + tableActions: PropTypes.object.isRequired, + deleteSingleText: PropTypes.string.isRequired, + deleteMultipleText: PropTypes.string.isRequired + }; + + nameClickAction = (item) => { + this.props.actions.startEditRecipient(item) + }; + + defineColumns () { + const {t} = this.props + const colDefs = super.defineColumns() + return { + ...colDefs, + 'email': { + Header: , + accessor: 'email', + width: 170 + }, + 'groups': { + sortable: false, + Header: t('manageRecipientsTab.groups'), + accessor: item => item.groups.map(group => group.name).join(', '), + width: 170 + }, + 'name': { + Header: , + accessor: 'name', + Cell: (row) => { + const {original} = row + const name = `${original.firstName} ${original.lastName}` + return ( + + {name} + + ) + } + } + } + } + + getColumns () { + return ['selectCheckbox', 'name', 'email', 'groups', 'subscriptions', 'creationDate', 'active'] + } + +} + +export default translate(['tabsContent'], { wait: true })(RecipientsTable) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/TableFilter.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/TableFilter.js new file mode 100644 index 0000000..822a707 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/TableFilter.js @@ -0,0 +1,77 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { Button, Input, InputGroup, InputGroupAddon } from 'reactstrap' + +const INPUT_THROTTLE_TIME = 300 + +export class TableFilter extends React.Component { + static propTypes = { + type: PropTypes.string.isRequired, + onFilterRequest: PropTypes.func.isRequired + }; + + constructor () { + super() + this.state = { + value: '' + } + } + + onFilter = (event) => { + this._onFilterImpl(event.target.value) + }; + + _onFilterImpl (filterValue) { + const { onFilterRequest } = this.props + this.setState({ value: filterValue }) + if (this.inputDelay) { + clearTimeout(this.inputDelay) + } + this.inputDelay = setTimeout(() => { + onFilterRequest(filterValue) + }, INPUT_THROTTLE_TIME) + } + + onClear = () => { + const value = this.state.value + value && this._onFilterImpl('') + }; + + render () { + const { type } = this.props + const value = this.state.value + const hasValue = !!value + const iconClasses = classnames('fa', { + 'fa-search': !hasValue, + 'fa-times': hasValue + }) + + const placeholder = `Find ${type}` + + return ( + + + + + + {/* */} + + ) + } +} + +export default TableFilter diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/TopBar.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/TopBar.js new file mode 100644 index 0000000..efde1bb --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/TopBar.js @@ -0,0 +1,77 @@ +import React, { Fragment } from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import TableFilter from './TableFilter' +import TableSwitcher from '../common/TableSwitcher/TableSwitcher' +import { Button } from 'reactstrap' + +export class TopBar extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + tables: PropTypes.array.isRequired, + tableVisible: PropTypes.string.isRequired, + actions: PropTypes.object.isRequired + }; + + onNewRecipient = () => { + const { actions } = this.props + actions.startCreateRecipient() + }; + + onNewGroup = () => { + const { actions } = this.props + actions.startCreateGroup() + }; + + onFilterRequest = (filter) => { + this.loadTable({ filter }) + }; + + loadTable = (params) => { + const { tableVisible: type } = this.props + this.props.actions.shareTables[type].loadTable(params || null) + }; + + render () { + const { t, tables, tableVisible, actions } = this.props + + return ( + +
    + + +
    + + +
    +
    + +
    + ) + } +} + +export default translate(['tabsContent'], { wait: true })(TopBar) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/BasicGroupInfo.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/BasicGroupInfo.js new file mode 100644 index 0000000..512dc5f --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/BasicGroupInfo.js @@ -0,0 +1,61 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import InputField from './InputField' +import { Card, CardBody, CardTitle, Form, FormGroup, Input, Label } from 'reactstrap' + +export class BasicGroupInfo extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + item: PropTypes.object.isRequired, + formActions: PropTypes.object.isRequired + }; + + onChangeFor = (field) => (event) => { + const { formActions } = this.props + formActions.changeField(field, event.target.value) + }; + + render () { + const { t, item } = this.props + + return ( + + + {t('manageRecipientsTab.form.group.basicInfo')} + + + + + + + + + +
    + {!!item.recipients && ( +

    + {t('manageRecipientsTab.form.group.recipientsNumber')}:{' '} + {item.recipients.length} +

    + )} +
    +
    + ) + } + +} + +export default translate(['tabsContent'], { wait: true })(BasicGroupInfo) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/BasicRecipientInfo.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/BasicRecipientInfo.js new file mode 100644 index 0000000..49f3d3f --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/BasicRecipientInfo.js @@ -0,0 +1,54 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import InputField from './InputField' +import { Card, CardBody, CardTitle, Form } from 'reactstrap' + +export class BasicRecipientInfo extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + item: PropTypes.object.isRequired, + formActions: PropTypes.object.isRequired + } + + onChangeFor = (field) => (event) => { + const { formActions } = this.props + formActions.changeField(field, event.target.value) + } + + render() { + const { item, t } = this.props + + return ( + + + {t('manageRecipientsTab.form.recipient.basicInfo')} +
    + + + + + + +
    +
    + ) + } +} + +export default translate(['tabsContent'], { wait: true })(BasicRecipientInfo) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/BreadCrumbs.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/BreadCrumbs.js new file mode 100644 index 0000000..aff042b --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/BreadCrumbs.js @@ -0,0 +1,27 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' + +export class BreadCrumbs extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, + onBack: PropTypes.func.isRequired + }; + + render () { + const { t, title, onBack } = this.props + + return ( + + ) + } + +} + +export default translate(['tabsContent'], { wait: true })(BreadCrumbs) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/FormTopBar.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/FormTopBar.js new file mode 100644 index 0000000..417c5cf --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/FormTopBar.js @@ -0,0 +1,119 @@ +import React, { Fragment } from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import Toggler from '../../../../../common/Table/Toggler' +import { Button, Card, CardBody, CardTitle, Label } from 'reactstrap' +import { convertUTCtoLocal } from '../../../../../../common/helper' + +export class FormTopBar extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + formType: PropTypes.string.isRequired, + receiver: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired + } + + togglerAction = () => { + const { actions, formType } = this.props + actions.shareForms[formType].toggleActive() + } + + onBack = () => { + this.props.actions.switchShareSubScreen('recipients', 'tables') + } + + onDelete = () => { + const { formType, actions } = this.props + actions.shareForms[formType].confirmDelete() + } + + onSave = () => { + const { actions, formType } = this.props + actions.shareForms[formType].saveReceiver() + } + + render() { + const { t, formType, receiver } = this.props + const hasItem = !!receiver.id + const trPath = 'manageRecipientsTab.form' + let title = t(`${trPath}.${formType}.unsaved`) + if (hasItem) { + title = + formType === 'group' + ? receiver.name + : `${receiver.firstName} ${receiver.lastName}` + } + + return ( + + + + + + {title} + +
    +
    + + +
    +
    + {hasItem && ( + + )} + + +
    +
    + + {hasItem && receiver.creationDate && ( +

    + {t(`${trPath}.${formType}.creationDate`)}: + {convertUTCtoLocal(receiver.creationDate, 'DD MMM YYYY HH:mm')} +

    + )} +
    +
    +
    + ) + } +} + +export default translate(['tabsContent'], { wait: true })(FormTopBar) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/InputField.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/InputField.js new file mode 100644 index 0000000..12dbcd6 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/InputField.js @@ -0,0 +1,29 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import { FormGroup, Input, Label } from 'reactstrap' + +export class InputField extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + formType: PropTypes.string.isRequired, + field: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + onChangeFor: PropTypes.func.isRequired + }; + + render () { + const { t, formType, field, value, onChangeFor } = this.props + const trPath = `manageRecipientsTab.form.${formType}` + + return ( + + + + + ) + } + +} + +export default translate(['tabsContent'], { wait: true })(InputField) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/ReceiverForm.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/ReceiverForm.js new file mode 100644 index 0000000..3cc60a5 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/ReceiverForm.js @@ -0,0 +1,138 @@ +import React, { Fragment } from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import FormTopBar from './FormTopBar' +import BasicRecipientInfo from './BasicRecipientInfo' +import BasicGroupInfo from './BasicGroupInfo' +import TablesTabs from './TablesTabs' +import EmailHistoryTable from './tables/EmailHistoryTable' +import DeletePopup from '../../common/DeletePopup' +import { + RECIPIENT_FORM_TABLES, + GROUP_FORM_TABLES, + RECEIVER_SUBSCREENS +} from '../../../../../../redux/modules/appState/share/tabs' +import ReceiverSubscriptionsTable from './tables/ReceiverSubscriptionsTable' +import ReceiverGroupsTable from './tables/ReceiverGroupsTable' +import ReceiverRecipientsTable from './tables/ReceiverRecipientsTable' +import { Card, CardBody, CardHeader, Col, Nav, Row } from 'reactstrap' + +export class RecipientForm extends React.Component { + static propTypes = { + formType: PropTypes.string.isRequired, + shareState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired + } + + chooseTableTab = (tab) => { + const { actions, formType } = this.props + actions.shareForms[formType].chooseTableTab(tab) + } + + render() { + const { formType, shareState, actions } = this.props + const formState = shareState.forms[formType] // receiver + const formActions = actions.shareForms[formType] + + let allTabs = formState.tabs.all + if (!formState.id) { + allTabs = allTabs.filter( + (tab) => tab !== RECIPIENT_FORM_TABLES.EMAIL_HISTORY + ) + } + const activeTab = formState.tabs.active + + const tableState = shareState.tables.receiverForm[activeTab] + const tableActions = actions.shareTables.receiverForm[activeTab] + + const deleteText = + formType === RECEIVER_SUBSCREENS.GROUP_FORM ? 'group' : 'recipient' + + return ( + + + + + + {formType === RECEIVER_SUBSCREENS.RECIPIENT_FORM && ( + + )} + + {formType === RECEIVER_SUBSCREENS.GROUP_FORM && ( + + )} + + + + + + + + + {activeTab === RECIPIENT_FORM_TABLES.SUBSCRIPTIONS && ( + + )} + + {activeTab === RECIPIENT_FORM_TABLES.GROUPS && ( + + )} + + {activeTab === RECIPIENT_FORM_TABLES.EMAIL_HISTORY && ( + + )} + + {activeTab === GROUP_FORM_TABLES.RECIPIENTS && ( + + )} + + + + + + {formState.isDeletePopupVisible && ( + + )} + + ) + } +} + +export default translate(['tabsContent'], { wait: true })(RecipientForm) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/TablesTabs.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/TablesTabs.js new file mode 100644 index 0000000..5ce98df --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/TablesTabs.js @@ -0,0 +1,35 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import { NavItem, NavLink } from 'reactstrap' + +export class TablesTabs extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + tabs: PropTypes.array.isRequired, + activeTab: PropTypes.string.isRequired, + chooseTableTab: PropTypes.func.isRequired + } + + chooseTableTab = (tab) => () => { + this.props.chooseTableTab(tab) + } + + render() { + const { t, tabs, activeTab } = this.props + + return tabs.map((tab, i) => ( + + + {t(`manageRecipientsTab.tables.${tab}`)} + + + )) + } +} + +export default translate(['tabsContent'], { wait: true })(TablesTabs) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/EmailHistoryTable.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/EmailHistoryTable.js new file mode 100644 index 0000000..4137cd0 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/EmailHistoryTable.js @@ -0,0 +1,45 @@ +import React from 'react' +import PropTypes from 'prop-types' +import {translate} from 'react-i18next' +import ReceiverFormTable from './ReceiverFormTable' +import SortableTh from '../../../../../../common/Table/SortableTh' +import { convertUTCtoLocal } from '../../../../../../../common/helper' + +export class EmailHistoryTable extends ReceiverFormTable { + static propTypes = { + t: PropTypes.func.isRequired, + receiver: PropTypes.object.isRequired, + tableState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + tableActions: PropTypes.object.isRequired + }; + + defineColumns () { + const {t} = this.props + return { + ...super.defineColumns(), + 'ScheduledTimes': { + sortable: false, + Header: t('notificationsTab.ScheduledTimes'), + accessor: item => this.scheduleFormat(item.schedule), + width: 170 + }, + + 'sentTime': { + Header: , + accessor: item => convertUTCtoLocal(item.sentTime, 'DD MMM YYYY HH:mm'), + width: 170 + } + } + } + + getColumns () { + return ['name', 'type', 'ScheduledTimes', 'sentTime'] + } + + noCard () { + return true + } +} + +export default translate(['tabsContent'], { wait: true })(EmailHistoryTable) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/FormTableTopBar.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/FormTableTopBar.js new file mode 100644 index 0000000..7330e35 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/FormTableTopBar.js @@ -0,0 +1,82 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import TableFilter from '../../TableFilter' +import { Nav, NavItem, NavLink } from 'reactstrap' + +class FormTableTopBar extends React.Component { + static propTypes = { + tableActions: PropTypes.object.isRequired, + statusFilter: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + yesText: PropTypes.string.isRequired, + noText: PropTypes.string.isRequired, + allText: PropTypes.string.isRequired, + receiver: PropTypes.object.isRequired, + t: PropTypes.func.isRequired + } + + onFilterRequest = (filter) => { + const { tableActions, receiver } = this.props + tableActions.loadTable({ filter }, receiver) + } + + onStatusFilter = (statusFilter) => { + return () => { + const { tableActions, receiver } = this.props + tableActions.loadTable({ statusFilter }, receiver) + } + } + + render() { + const { + type, + t, + yesText, + noText, + allText, + statusFilter, + receiver + } = this.props + + return ( +
    + {receiver.id && ( + + )} + + +
    + ) + } +} + +export default translate(['tabsContent'], { wait: true })(FormTableTopBar) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/ReceiverFormTable.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/ReceiverFormTable.js new file mode 100644 index 0000000..68523fa --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/ReceiverFormTable.js @@ -0,0 +1,45 @@ +import React from 'react' +import PropTypes from 'prop-types' +import GenericTable from '../../../common/GenericTable' +import SortableTh from '../../../../../../common/Table/SortableTh' + +class ReceiverFormTable extends GenericTable { + + static propTypes = { + t: PropTypes.func.isRequired, + tableState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + tableActions: PropTypes.object.isRequired, + receiver: PropTypes.object.isRequired + }; + + fetchData = (page, pageSize, sorted) => { + const { tableActions, receiver } = this.props + const params = { + page: page + 1, + limit: pageSize + } + if (sorted.length) { + const sortedField = sorted[0] + params['sortField'] = sortedField.id + params['sortDirection'] = sortedField.desc ? 'desc' : 'asc' + } + tableActions.loadTable(params, receiver) + }; + + defineColumns () { + const {t} = this.props + const colDefs = super.defineColumns() + return { + ...colDefs, + 'active': { + Header: , + accessor: item => item.active ? t('notificationsTab.active') : t('notificationsTab.paused'), + width: 100 + } + } + } + +} + +export default ReceiverFormTable diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/ReceiverGroupsTable.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/ReceiverGroupsTable.js new file mode 100644 index 0000000..8ea522e --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/ReceiverGroupsTable.js @@ -0,0 +1,108 @@ +import React from 'react' +import PropTypes from 'prop-types' +import {translate} from 'react-i18next' +import ReceiverFormTable from './ReceiverFormTable' +import SortableTh from '../../../../../../common/Table/SortableTh' +import LinkCell from '../../../../../../common/Table/LinkCell' +import FormTableTopBar from './FormTableTopBar' + +export class ReceiverGroupsTable extends ReceiverFormTable { + static propTypes = { + t: PropTypes.func.isRequired, + tableState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + tableActions: PropTypes.object.isRequired, + receiver: PropTypes.object.isRequired, + formActions: PropTypes.object.isRequired + }; + + togglerOnAction = (itemId) => { + this.props.formActions.toggleGroup(itemId, true) + this.props.tableActions.toggleEnrolled(itemId, true) + }; + + togglerOffAction = (itemId) => { + this.props.formActions.toggleGroup(itemId, false) + this.props.tableActions.toggleEnrolled(itemId, false) + }; + + _formatSubscriptions (subscriptions) { + console.log('format subsc', subscriptions) + const result = [] + if (subscriptions.alert > 0) { + result.push(`${subscriptions.alert} Alerts`) + } + if (subscriptions.newsletter > 0) { + result.push(`${subscriptions.newsletter} Newsletters`) + } + return result.join(', ') + }; + + _formatRecipients (number) { + if (number) { + if (number === 1) { + return '1 Recipient' + } else { + return number + ' Recipients' + } + } + return '' + } + + defineColumns () { + const {t} = this.props + return { + ...super.defineColumns(), + 'groupName': { + Header: , + accessor: 'name', + Cell: (row) => { + return ( + + {row.value} + + ) + } + }, + 'enrolled': this.createTogglerColumn('manageRecipientsTab.form.recipient.enroll', 'enrolled', 'yes', 'no', this.togglerOnAction, this.togglerOffAction), + 'subscriptions': { + sortable: false, + Header: t('manageRecipientsTab.subscriptions'), + accessor: item => this._formatSubscriptions(item.subscriptions), + width: 170 + }, + 'recipients': { + sortable: false, + Header: t('manageRecipientsTab.recipients'), + accessor: item => this._formatRecipients(item.recipients.length), + width: 170 + } + } + } + + getColumns () { + return ['groupName', 'subscriptions', 'recipients', 'active', 'enrolled'] + } + + noCard () { + return true + } + + getActionsPanel () { + const {tableState, tableActions, receiver} = this.props + return ( + + ) + } + +} + +export default translate(['tabsContent'], { wait: true })(ReceiverGroupsTable) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/ReceiverRecipientsTable.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/ReceiverRecipientsTable.js new file mode 100644 index 0000000..db49776 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/ReceiverRecipientsTable.js @@ -0,0 +1,75 @@ +import React from 'react' +import PropTypes from 'prop-types' +import {translate} from 'react-i18next' +import ReceiverFormTable from './ReceiverFormTable' +import SortableTh from '../../../../../../common/Table/SortableTh' +import FormTableTopBar from './FormTableTopBar' +import { convertUTCtoLocal } from '../../../../../../../common/helper' + +export class ReceiverRecipientsTable extends ReceiverFormTable { + static propTypes = { + t: PropTypes.func.isRequired, + tableState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + tableActions: PropTypes.object.isRequired, + receiver: PropTypes.object.isRequired, + formActions: PropTypes.object.isRequired + }; + + togglerOnAction = (itemId) => { + this.props.formActions.toggleRecipient(itemId, true) + this.props.tableActions.toggleEnrolled(itemId, true) + }; + + togglerOffAction = (itemId) => { + this.props.formActions.toggleRecipient(itemId, false) + this.props.tableActions.toggleEnrolled(itemId, false) + }; + + defineColumns () { + const {t} = this.props + return { + ...super.defineColumns(), + 'enrolled': this.createTogglerColumn('manageRecipientsTab.form.recipient.enroll', 'enrolled', 'yes', 'no', this.togglerOnAction, this.togglerOffAction), + 'name': { + Header: , + accessor: item => `${item.firstName} ${item.lastName}` + }, + 'email': { + Header: , + accessor: 'email', + width: 170 + }, + 'addedDate': { + Header: t('manageRecipientsTab.form.group.addedDate'), + accessor: item => item.creationDate ? convertUTCtoLocal(item.creationDate, 'DD MMM YYYY HH:mm') : '', + width: 170 + } + } + } + + getColumns () { + return ['name', 'email', 'addedDate', 'active', 'enrolled'] + } + + noCard () { + return true + } + + getActionsPanel () { + const {tableState, tableActions, receiver} = this.props + return ( + + ) + } +} + +export default translate(['tabsContent'], { wait: true })(ReceiverRecipientsTable) diff --git a/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/ReceiverSubscriptionsTable.js b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/ReceiverSubscriptionsTable.js new file mode 100644 index 0000000..fae59ff --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/ManageRecipientsSubTub/forms/tables/ReceiverSubscriptionsTable.js @@ -0,0 +1,58 @@ +import React from 'react' +import PropTypes from 'prop-types' +import {translate} from 'react-i18next' +import ReceiverFormTable from './ReceiverFormTable' +import FormTableTopBar from './FormTableTopBar' + +export class ReceiverSubscriptionsTable extends ReceiverFormTable { + static propTypes = { + t: PropTypes.func.isRequired, + tableState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + tableActions: PropTypes.object.isRequired, + receiver: PropTypes.object.isRequired, + formActions: PropTypes.object.isRequired + }; + + togglerOnAction = (itemId) => { + this.props.formActions.toggleSubscription(itemId, true) + this.props.tableActions.toggleSubscribed(itemId, true) + }; + + togglerOffAction = (itemId) => { + this.props.formActions.toggleSubscription(itemId, false) + this.props.tableActions.toggleSubscribed(itemId, false) + }; + + defineColumns () { + return { + ...super.defineColumns(), + 'subscribed': this.createTogglerColumn('notificationsTab.action', 'subscribed', 'subscribed', 'unsubscribed', this.togglerOnAction, this.togglerOffAction) + } + } + + getColumns () { + return ['name', 'type', 'ScheduledTimes', 'active', 'subscribed'] + } + + noCard () { + return true + } + + getActionsPanel () { + const {tableState, tableActions, receiver} = this.props + return ( + + ) + } +} + +export default translate(['tabsContent'], { wait: true })(ReceiverSubscriptionsTable) diff --git a/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/MyEmailsTable.js b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/MyEmailsTable.js new file mode 100644 index 0000000..1a9405c --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/MyEmailsTable.js @@ -0,0 +1,146 @@ +import React, { Fragment } from 'react' +import GenericTable from '../common/GenericTable' +import { translate } from 'react-i18next' +import PropTypes from 'prop-types' +import { NOTIFICATION_TABLES } from '../../../../../redux/modules/appState/share/tabs' +import SortableTh from '../../../../common/Table/SortableTh' +import { ButtonGroup, Button } from 'reactstrap' + +export class MyEmailsTable extends GenericTable { + static propTypes = { + t: PropTypes.func.isRequired, + tableState: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + tableActions: PropTypes.object.isRequired, + restrictions: PropTypes.object, + deleteSingleText: PropTypes.string.isRequired, + deleteMultipleText: PropTypes.string.isRequired + }; + + togglerOnAction = (itemId) => { + this.props.tableActions.toggleActive([itemId], true) + }; + + togglerOffAction = (itemId) => { + this.props.tableActions.toggleActive([itemId], false) + }; + + nameClickAction = (item) => { + const { actions } = this.props + actions.startEditNotification(item, NOTIFICATION_TABLES.MY_EMAILS) + }; + + onPublishButtonClick = () => { + const { tableState, tableActions } = this.props + tableActions.togglePublish(tableState.selectedIds, true) + }; + + onUnPublishButtonClick = () => { + const { tableState, tableActions } = this.props + tableActions.togglePublish(tableState.selectedIds, false) + }; + + _recipientsFormat (recipients) { + if (recipients.length === 1) { + return recipients[0].email + } + return `${recipients.length} ${this.props.t( + 'notificationsTab.recipients' + )}` + } + + defineColumns () { + const { t } = this.props + + const colDefinitions = super.defineColumns() + return { + ...colDefinitions, + active: this.createTogglerColumn( + 'notificationsTab.action', + 'active', + 'active', + 'paused', + this.togglerOnAction, + this.togglerOffAction + ), + Recipients: { + sortable: false, + Header: t('notificationsTab.Recipients'), + accessor: (item) => this._recipientsFormat(item.recipients), + width: 110 + }, + published: { + Header: , + accessor: (item) => + item.published + ? t('common:commonWords.Yes') + : t('common:commonWords.No'), + width: 100 + } + } + } + + getColumns () { + return [ + 'selectCheckbox', + 'name', + 'type', + 'published', + 'ScheduledTimes', + 'sourcesCount', + 'Recipients', + 'active', + 'delete' + ] + } + + getActionsPanel () { + const { t, restrictions } = this.props + return ( + + {this.getRestrictions(restrictions)} + + + + + + + + + + + ) + } +} + +export default translate(['tabsContent'], { wait: true })(MyEmailsTable) diff --git a/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/Navigation.js b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/Navigation.js new file mode 100644 index 0000000..19a496d --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/Navigation.js @@ -0,0 +1,26 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import { Button } from 'reactstrap' + +class Navigation extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + actions: PropTypes.object.isRequired + }; + + backToTables = () => { + this.props.actions.switchShareSubScreen('notifications', 'tables') + }; + + render () { + + return ( + + ) + } +} + +export default translate(['tabsContent'], { wait: true })(Navigation) diff --git a/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/NotificationsSubTab.js b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/NotificationsSubTab.js new file mode 100644 index 0000000..f811bd8 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/NotificationsSubTab.js @@ -0,0 +1,106 @@ +import React from 'react' +import PropTypes from 'prop-types' +import TopBar from './TopBar' +import Navigation from './Navigation' +import AlertForm from './forms/AlertForm' +import {NOTIFICATION_TABLES, NOTIFICATION_SUBSCREENS} from '../../../../../redux/modules/appState/share/tabs' +import MyEmailsTable from './MyEmailsTable' +import PublishedEmailsTable from './PublishedEmailsTable' +import {withRouter} from 'react-router-dom' +import reduxConnect from '../../../../../redux/utils/connect' +import {compose} from 'redux' +import { setDocumentData } from '../../../../../common/helper' + +class NotificationsSubTab extends React.Component { + static propTypes = { + store: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired + }; + + _shareState = () => this.props.store.appState.share; + + _authState = () => this.props.store.common.auth; + + componentDidMount() { + setDocumentData('title', 'Alerts | Share') + } + + componentWillUnmount() { + setDocumentData('title') + } + + render () { + const { actions } = this.props + + const shareState = this._shareState() + const {user: {restrictions}} = this._authState() + + const { subScreenVisible, tableVisible } = shareState.tabs.notifications + + return ( +
    + + {subScreenVisible === NOTIFICATION_SUBSCREENS.TABLES && +
    + + + {tableVisible === NOTIFICATION_TABLES.MY_EMAILS && + + } + + {tableVisible === NOTIFICATION_TABLES.PUBLISHED && + + + } +
    + } + + {(subScreenVisible === NOTIFICATION_SUBSCREENS.ALERT_FORM || subScreenVisible === NOTIFICATION_SUBSCREENS.NEWSLETTER_FORM) && + + } + + {subScreenVisible === NOTIFICATION_SUBSCREENS.ALERT_FORM && + + } + + {/* {subScreenVisible === NOTIFICATION_SUBSCREENS.NEWSLETTER_FORM && + + } */} + +
    + ) + } +} + +const applyDecorators = compose( + withRouter, + reduxConnect() +) + +export default applyDecorators(NotificationsSubTab) diff --git a/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/PublishedEmailsTable.js b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/PublishedEmailsTable.js new file mode 100644 index 0000000..bbc27c7 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/PublishedEmailsTable.js @@ -0,0 +1,89 @@ +import React, { Fragment } from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import GenericTable from '../common/GenericTable' +import {NOTIFICATION_TABLES} from '../../../../../redux/modules/appState/share/tabs' +import SortableTh from '../../../../common/Table/SortableTh' +import { Button, ButtonGroup } from 'reactstrap' + +class PublishedEmailsTable extends GenericTable { + + static propTypes = { + t: PropTypes.func.isRequired, + tableState: PropTypes.object.isRequired, + restrictions: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + tableActions: PropTypes.object.isRequired, + deleteSingleText: PropTypes.string.isRequired, + deleteMultipleText: PropTypes.string.isRequired + }; + + onSubscribeButtonClick = () => { + const { tableState, tableActions } = this.props + tableActions.toggleSubscribe(tableState.selectedIds, true) + }; + + onUnSubscribeButtonClick = () => { + const { tableState, tableActions } = this.props + tableActions.toggleSubscribe(tableState.selectedIds, false) + }; + + togglerOnAction = (itemId) => { + this.props.tableActions.toggleSubscribe([itemId], true) + }; + + togglerOffAction = (itemId) => { + const {tableState, tableActions, actions} = this.props + const notification = tableState.data.find(item => item.id === itemId) + if (notification.allowUnsubscribe) { + tableActions.toggleSubscribe([itemId], false) + } else { + actions.addAlert({type: 'error', transKey: 'cannotUnsubscribe'}) + } + }; + + nameClickAction = (item) => { + const { actions } = this.props + actions.startEditNotification(item, NOTIFICATION_TABLES.PUBLISHED) + }; + + defineColumns () { + const {t} = this.props + const colDefinitions = super.defineColumns() + return { + ...colDefinitions, + 'subscribed': this.createTogglerColumn('notificationsTab.action', 'subscribed', 'subscribed', 'unsubscribed', this.togglerOnAction, this.togglerOffAction), + 'active': { + Header: , + accessor: item => item.active ? t('notificationsTab.active') : t('notificationsTab.paused'), + width: 100 + } + } + }; + + getColumns () { + return ['selectCheckbox', 'name', 'type', 'owner', 'ScheduledTimes', 'active', 'subscribed'] + } + + getActionsPanel () { + const {t, restrictions} = this.props + + return ( + + {this.getRestrictions(restrictions)} + + + + + + + ) + } + +} + +export default translate(['tabsContent'], { wait: true })(PublishedEmailsTable) diff --git a/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/TopBar.js b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/TopBar.js new file mode 100644 index 0000000..6e4090d --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/TopBar.js @@ -0,0 +1,50 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import TableSwitcher from '../common/TableSwitcher/TableSwitcher' +import { NOTIFICATION_SUBSCREENS } from '../../../../../redux/modules/appState/share/tabs' +import { Button } from 'reactstrap' +class TopBar extends React.Component { + static propTypes = { + actions: PropTypes.object.isRequired, + tables: PropTypes.array.isRequired, + tableVisible: PropTypes.string.isRequired, + t: PropTypes.func.isRequired + } + + onCreate = (type) => () => { + const { actions, tableVisible } = this.props + actions.startCreateNotification(type, tableVisible) + } + + loadTable = (type) => { + this.props.actions.shareTables[type].loadTable(null) + } + + render() { + const { t, tables, tableVisible, actions } = this.props + + return ( +
    +
    + + +
    + +
    +
    +
    + ) + } +} + +export default translate(['tabsContent'], { wait: true })(TopBar) diff --git a/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/AlertForm.js b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/AlertForm.js new file mode 100644 index 0000000..70a327d --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/AlertForm.js @@ -0,0 +1,399 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { + timezones, + getCurrentTimezone +} from '../../../../../../common/Timezones'; +import Select from 'react-select'; +import moment from 'moment'; +import DatePicker from 'react-datepicker'; +import RecipientsSelect from './RecipientsSelect'; +import CheckboxField from './CheckboxField'; +import RadioField from './RadioField'; +import BooleanRadioGroup from './BooleanRadioGroup'; +import SourcesDropTarget from './sources/SourcesDropTarget'; +import Sources from './sources/Sources'; +import Scheduling from './scheduling/Scheduling'; +import SaveAsPopup from './SaveAsPopup'; +import History from './History'; +import { EXTRAS } from '../../../../../../redux/modules/appState/share/forms/alertForm'; +import { THEME_TYPES } from '../../../../../../redux/modules/appState/share/forms/notificationForm'; +import { + Button, + Card, + CardBody, + CardTitle, + Col, + Container, + CustomInput, + Form, + FormGroup, + Input, + Label +} from 'reactstrap'; + +export class AlertForm extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + state: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + switchShareSubScreen: PropTypes.func.isRequired + }; + + changeName = (event) => { + this.props.actions.changeName(event.target.value); + }; + + changeSubject = (event) => { + this.props.actions.changeSubject(event.target.value); + }; + + changeAutoSubject = () => { + const { state, actions } = this.props; + actions.changeAutoSubject(!state.automatedSubject); + }; + + changeRecipient = (value) => { + this.props.actions.changeRecipients(value.split(',')); + }; + + changeTimezone = (zone) => { + this.props.actions.changeTimezone(zone.value); + }; + + toggleTimezone = () => { + const { state, actions } = this.props; + if (state.isEnabledTimezone) { + actions.changeTimezone(getCurrentTimezone()); + } + actions.toggleTimezone(); + }; + + changeSendUntil = (value) => { + const sendUntil = value ? moment(value).format('YYYY-MM-DD') : ''; + this.props.actions.changeSendUntil(sendUntil); + }; + + cancel = () => { + this.props.switchShareSubScreen('notifications', 'tables'); + }; + + create = () => { + const { state, actions } = this.props; + const isEdit = !!state.id; + actions.saveAlert(isEdit); + }; + + edit = (name) => { + const { actions } = this.props; + actions.changeName(name); + actions.saveAlert(false); + }; + + showSaveAsPopup = () => { + this.props.actions.toggleSaveAsPopup(); + }; + + render() { + const { state, actions, t } = this.props; + const isEdit = !!state.id; + const name = state.name; + + const extract = state.content.extract; + const userComments = state.content.showInfo.userComments; + + const sendUntil = !state.sendUntil + ? state.sendUntil + : moment(state.sendUntil).toDate(); + + return ( + + + + {isEdit + ? t('notificationsTab.form.editAlert') + : t('notificationsTab.form.createAlert')} + +
    + +
    + + + + + + + + + + + + {!state.automatedSubject && ( + + + + + + + )} + + + + + + + + + + + + + + + + + + + + + {t('notificationsTab.form.articleExtracts')} + + +
    + + + + + +
    + +
    + + + + + + + + {t('notificationsTab.form.showUserComments')} + + +
    + + + +
    + +
    + + + {t('notificationsTab.form.layout')} + +
    + + + +
    + +
    + + + +
    + + + + + + + + + + + ) + + } + +} +export default translate(['tabsContent'], { wait: true })(NewsletterForm) diff --git a/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/RadioField.js b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/RadioField.js new file mode 100644 index 0000000..1ac5a72 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/RadioField.js @@ -0,0 +1,49 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { CustomInput } from 'reactstrap' + +export class RadioField extends React.PureComponent { + static propTypes = { + label: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + checkedValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.bool + ]), + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.bool + ]), + onChange: PropTypes.func.isRequired + }; + + onChange = (event) => { + const { onChange } = this.props + let value = event.target.value + if (value === 'true' || value === 'false') { + value = value === 'true' + } + + onChange(value) + }; + + render () { + const { label, name, checkedValue, value } = this.props + + return ( + + ) + } + +} + +export default RadioField diff --git a/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/RecipientsSelect.js b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/RecipientsSelect.js new file mode 100644 index 0000000..9e411d1 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/RecipientsSelect.js @@ -0,0 +1,45 @@ +import React from 'react' +import PropTypes from 'prop-types' +import Select from 'react-select' +import { Col, FormGroup, Label } from 'reactstrap' + +export class RecipientsSelect extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + state: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired + }; + + loadOptions = (input) => { + const { actions } = this.props + return actions.getRecipients(input) + }; + + changeRecipient = (value) => { + const { actions } = this.props + actions.changeRecipients(value) + }; + + render () { + const { state, t } = this.props + const recipients = state.recipients + + return ( + + + + + + + ) + } + +} + +export default RecipientsSelect diff --git a/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/SaveAsPopup.js b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/SaveAsPopup.js new file mode 100644 index 0000000..8c0fcef --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/SaveAsPopup.js @@ -0,0 +1,76 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import { + Button, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Label, + FormGroup, + Input +} from 'reactstrap' + +export class SaveAsPopup extends React.Component { + static propTypes = { + name: PropTypes.string.isRequired, + togglePopup: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + } + + constructor(props) { + super(props) + this.state = { + name: `${props.name} (copy)` + } + } + + hidePopup = () => { + this.props.togglePopup() + } + + onSubmit = () => { + this.props.onSubmit(this.state.name) + this.hidePopup() + } + + handleChange = (e) => { + const { name, value } = e.target + this.setState({ [name]: value }) + } + + render() { + const { t } = this.props + + return ( + + + {t('notificationsTab.popup.saveAs')} + + + + + + + + + + + + + ) + } +} + +export default translate(['tabsContent', 'common'], { wait: true })(SaveAsPopup) diff --git a/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/scheduling/ScheduleOptions.js b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/scheduling/ScheduleOptions.js new file mode 100644 index 0000000..9c9dbd2 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/scheduling/ScheduleOptions.js @@ -0,0 +1,137 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import ScheduleSelectField from './ScheduleSelectField' +import { IoIosCloseCircleOutline } from 'react-icons/io' + +export class ScheduleOptions extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + id: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + type: PropTypes.string.isRequired, + item: PropTypes.object.isRequired, + constants: PropTypes.object.isRequired, + canDelete: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onRemove: PropTypes.func + }; + + onChange = (field, value) => { + const { id, item, onChange } = this.props + const newItem = { + ...item, + [field]: value + } + onChange(id, newItem) + }; + + onRemove = () => { + const { id, canDelete, onRemove } = this.props + if (canDelete && onRemove) { + onRemove(id) + } + }; + + render () { + const { t, id, type, item, canDelete = false, constants } = this.props + const showTime = (type === 'daily' && item.time === 'once') || (type !== 'daily') + + return ( +
    + {id !== 'new' && +
    {t(`notificationsTab.form.type.${type}`)} 
    + } +

    Send

    + {type === 'daily' && +
    + + + +
    + } + + {type === 'weekly' && +
    + + + +
    + } + + {type === 'monthly' && +
    + +
    + } + + {(type === 'weekly' || type === 'monthly') && +
    of the month 
    + } + + {showTime && +
    + at + + : + +
    + } + + {canDelete && ( + + )} +
    + ) + } + +} + +export default translate(['tabsContent'], { wait: true })(ScheduleOptions) diff --git a/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/scheduling/ScheduleSelectField.js b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/scheduling/ScheduleSelectField.js new file mode 100644 index 0000000..0414db3 --- /dev/null +++ b/frontend/app/components/App/TabsContent/ShareTab/NotificatoinsSubTab/forms/scheduling/ScheduleSelectField.js @@ -0,0 +1,60 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import Select from 'react-select' +import classnames from 'classnames' +import { padLeft, addOrdinalSuffix } from '../../../../../../../common/StringUtils' + +export class ScheduleSelectField extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + needTranslate: PropTypes.bool, + field: PropTypes.string.isRequired, + items: PropTypes.array.isRequired, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + onChange: PropTypes.func.isRequired + }; + + paddingFields = ['hour', 'minute']; + suffixFields = ['monthDay']; + + onChange = (item) => { + const { field, onChange } = this.props + onChange(field, item.value) + }; + + render () { + const { t, needTranslate = true, items, value, field } = this.props + const classes = classnames('schedule-select-field', `schedule-select-field--${field}`) + const options = items.map(item => { + let label = '' + if (needTranslate) { + label = t(`notificationsTab.form.${field}.${item}`) + } + else { + label = (this.paddingFields.includes(field)) ? padLeft(item.toString(), 2) : item + label = (this.suffixFields.includes(field)) ? addOrdinalSuffix(item) : label + } + return { + value: item, + label + } + }) + + return ( + + + + +
    +
    + + {t('forgotPass.signIn')} + +
    +
    + +
    +
    + + )} +
    + +
    + +
    + +
    + + ); +} + +ForgotPassword.propTypes = { + register: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + t: PropTypes.func.isRequired +}; + +const applyDecorators = compose( + reduxConnect('register', ['common', 'register']), + translate(['loginApp', 'common'], { wait: true }) +); + +export default applyDecorators(ForgotPassword); diff --git a/frontend/app/components/LoginRegister/Login.js b/frontend/app/components/LoginRegister/Login.js new file mode 100644 index 0000000..2b6691c --- /dev/null +++ b/frontend/app/components/LoginRegister/Login.js @@ -0,0 +1,153 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { compose } from 'redux'; +import reduxConnect from '../../redux/utils/connect'; +import { Link } from 'react-router-dom'; +import { Col, Row, Button, Form, FormGroup, Label, Input } from 'reactstrap'; + +import CommonSection from './CommonSection'; +import { setDocumentData } from '../../common/helper'; +import { isLive } from '../../common/constants'; +import LangSettingsMenu from '../App/AppHeader/LangSettingsMenu'; + +class Login extends React.Component { + static propTypes = { + store: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + t: PropTypes.func.isRequired + }; + + constructor() { + super(); + this.state = { + email: '', + password: '' + }; + } + + componentDidMount() { + setDocumentData('title', 'Login'); + } + + componentWillUnmount() { + this.props.actions.authSetError(''); + setDocumentData('title'); + } + + submitHandler = (e) => { + e.preventDefault(); + const { email, password } = this.state; + this.props.actions.login(email, password); + }; + + changeHandler = ({ target: { name, value } }) => { + this.setState({ [name]: value }); + }; + + render() { + const { t, store } = this.props; + const loginError = store.common.auth.form.error; + + return ( +
    + + + + +
    +

    +
    {t('login.mainLabel')}
    + {t('login.subLabel')} +

    +
    + {t('login.noAccount')}{' '} + {isLive ? ( + + {t('login.signUpNow')} + + ) : ( + + {t('login.signUpNow')} + + )} +
    + +
    +
    + + + + + + + + + + + + + + + {loginError && ( +

    {loginError}

    + )} + +
    + +
    +
    + + {t('login.forgotPass')} + {' '} + +
    +
    + +
    + +
    + +
    + +
    +
    + ); + } +} + +const applyDecorators = compose( + reduxConnect(), + translate(['loginApp'], { wait: true }) +); + +export default applyDecorators(Login); diff --git a/frontend/app/components/LoginRegister/Registration/BasicDetailsPage.js b/frontend/app/components/LoginRegister/Registration/BasicDetailsPage.js new file mode 100644 index 0000000..a69edb9 --- /dev/null +++ b/frontend/app/components/LoginRegister/Registration/BasicDetailsPage.js @@ -0,0 +1,241 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button, Col, Form, Row } from 'reactstrap'; +import { useHistory, Link } from 'react-router-dom'; + +import { registerSteps } from './Register'; +import { reduxActions } from '../../../redux/utils/connect'; +import { Input } from '../../common/FormControls'; +import { industryList } from './PlanConstants'; + +function BasicDetailsPage({ + form, + disabledFields, + handleChange, + handleValidation, + validateSubmit, + setCompletedSteps, + actions, + errors +}) { + const history = useHistory(); + + function nextStep() { + const obj = validateSubmit(); + if (!obj) { + return actions.addAlert({ + type: 'error', + transKey: 'requiredInfo' + }); + } + + setCompletedSteps((prev) => ({ ...prev, [registerSteps[0]]: true })); + history.push(`/auth/register/${registerSteps[1]}`); + } + + return ( + +
    + + +

    + NOTE: You will not be asked to enter any credit card information + if you opt for the Free Basic Account. +

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + Already have an account?{' '} + + Sign in + +
    + +
    + + + ); +} + +export const employeesOptions = [ + { + label: '1-5', + value: '1-5' + }, + { + label: '5-25', + value: '5-25' + }, + { + label: '25-50', + value: '25-50' + }, + { + label: '50-100', + value: '50-100' + }, + { + label: '100-500', + value: '100-500' + }, + { + label: '500-1000', + value: '500-1000' + }, + { + label: '1000+', + value: '1000+' + } +]; + +BasicDetailsPage.propTypes = { + form: PropTypes.object, + disabledFields: PropTypes.array, + errors: PropTypes.object, + handleChange: PropTypes.func, + validateSubmit: PropTypes.func, + setCompletedSteps: PropTypes.func, + handleValidation: PropTypes.func, + actions: PropTypes.object +}; + +export default React.memo(reduxActions()(BasicDetailsPage)); diff --git a/frontend/app/components/LoginRegister/Registration/CostCalculator.js b/frontend/app/components/LoginRegister/Registration/CostCalculator.js new file mode 100644 index 0000000..48d1cd8 --- /dev/null +++ b/frontend/app/components/LoginRegister/Registration/CostCalculator.js @@ -0,0 +1,425 @@ +/* eslint-disable react/jsx-no-bind */ +import React, { useCallback, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Col, + Row, + Button, + Container, + Card, + CardBody, + FormGroup, + Label +} from 'reactstrap'; +import Tooltip from 'rc-tooltip'; +import Slider from 'rc-slider'; +import simpleNumberLocalizer from 'react-widgets-simple-number'; +import NumberPicker from 'react-widgets/lib/NumberPicker'; +import { debounce } from 'lodash'; + +import { getPlans, updatePrice } from '../../../api/registration/registration'; +import logo from '../../../images/logo/logo-small.png'; +import { reduxActions } from '../../../redux/utils/connect'; +import useForm from '../../common/hooks/useForm'; +import useIsMounted from '../../common/hooks/useIsMounted'; +import { addonFeatures, features, licenses, mediaTypes } from './PlanConstants'; +import { registerSteps } from './Register'; +import Loading from '../../common/Loading'; +import { IoIosWarning } from 'react-icons/io'; +import { useHistory } from 'react-router'; +import Footer from '../../common/Footer'; +import { setDocumentData } from '../../../common/helper'; +import LangSettingsMenu from '../../App/AppHeader/LangSettingsMenu'; + +simpleNumberLocalizer(); + +const Handle = Slider.Handle; + +const handle = (props) => { + // eslint-disable-next-line react/prop-types + const { value, dragging, index, ...restProps } = props; + + return ( + + + + ); +}; + +const initialForm = { + savedFeeds: 0, + searchesPerDay: 0, + webFeeds: 0, + alerts: 0, + news: 0, + blog: 0, + reddit: 0, + instagram: 0, + twitter: 0, + analytics: 0, + subscriberAccounts: 0, + masterAccounts: 0 +}; + +function CostCalculator({ actions }) { + const { form, handleChange, resetForm } = useForm(initialForm); + const [planList, setPlanList] = useState([]); + const [planLoading, setPlanLoading] = useState(true); + const [planError, setPlanError] = useState(false); + const [totalCost, setTotalCost] = useState(' - '); + const [updatingPrice, setUpdatingPrice] = useState(true); + const isMounted = useIsMounted(); + const history = useHistory(); + + useEffect(() => { + getBillingPlans(); + + setDocumentData('title', 'Cost Calculator'); + + return () => setDocumentData('title'); // default + }, []); + + // to update price when input changes + useEffect(() => { + if (planList.length > 0) { + debouncePrice(form); + } + }, [...Object.values(form)]); + + const debouncePrice = useCallback( + debounce((form) => { + setUpdatingPrice(true); + updatePrice(form).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || isNaN(res.data.totalPrice)) { + actions.addAlert(res.data); + setUpdatingPrice(false); + setTotalCost(' - '); + return; + } + setTotalCost(res.data.totalPrice); + setUpdatingPrice(false); + }); + }, 1000), + [] + ); + + function changePlan(id) { + const selectedPlan = planList.find((plan) => plan.id === id); + const modified = { ...initialForm }; + Object.keys(initialForm).map((key) => { + modified[key] = + selectedPlan[key] === undefined + ? modified[key] + : selectedPlan[key] === true + ? 1 + : selectedPlan[key] === false + ? 0 + : selectedPlan[key]; + }); + resetForm(modified); + } + + function getBillingPlans() { + setPlanLoading(true); + setPlanError(false); + getPlans().then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data || !res.data.length) { + setPlanError(true); + res.data && res.data.length > 0 && actions.addAlert(res.data); + return; + } + setPlanLoading(false); + setPlanList(res.data); + + const modified = { ...initialForm }; + const selectedPlan = res.data[0]; + Object.keys(initialForm).map((key) => { + modified[key] = + selectedPlan[key] === undefined + ? modified[key] + : selectedPlan[key] === true + ? 1 + : selectedPlan[key] === false + ? 0 + : selectedPlan[key]; + }); + resetForm(modified); + }); + } + + const isRTL = document.documentElement.dir === 'rtl'; + return ( +
    +
    + + + +
    + + + +

    Cost Calculator

    + + + + + + +

    + Bite-sized à la carte menu options with + monthly billing. No annual contracts. You can sign up for + a FREE basic plan and add options as needed. To get you + started, you can select one of the pre-configured plans we + designed for you. +

    +
    + {planError ? ( +
    + + Sorry, something went wrong.{' '} + +
    + ) : planLoading ? ( + + ) : null} + {!planLoading && !planError && ( + + +
    +
    + Pre-configured Plans +
    +
    + {planList.map((plan) => ( + + ))} +
    +
    +
    +
    +
    + Media Types +
    +
    + {mediaTypes.map((type) => ( + + ))} +
    +
    +
    + +
    +
    Licenses
    + + {licenses.map((license) => ( + +
    + +
    + + + {form[license.name]} + +
    + + handleChange(license.name, val) + } + /> +
    +
    + + ))} +
    +
    +
    + + +
    +
    + Features +
    + + + {features.map((type) => ( + + ))} + + +
    + + +
    +
    + Add-ons +
    + + {addonFeatures.map((type) => ( + + + + + handleChange(type.name, val) + } + /> + + + ))} + +
    + +
    + + + )} + + + + + + +
    +
    +

    + Billed Monthly +

    +

    + + ${totalCost} + + + /mo + +

    +
    +
    + + + Learn more + +
    +
    +
    +
    + + + +
    + +
    + + +
    +
    +
    +
    + ); +} + +CostCalculator.propTypes = { + actions: PropTypes.object.isRequired +}; + +export default reduxActions()(CostCalculator); diff --git a/frontend/app/components/LoginRegister/Registration/FreeAccountPage.js b/frontend/app/components/LoginRegister/Registration/FreeAccountPage.js new file mode 100644 index 0000000..d10939a --- /dev/null +++ b/frontend/app/components/LoginRegister/Registration/FreeAccountPage.js @@ -0,0 +1,198 @@ +/* eslint-disable react/jsx-no-bind */ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Link, useHistory } from 'react-router-dom'; +import { Col, Row, Button } from 'reactstrap'; +import { Input } from '../../common/FormControls'; + +import { registerSteps } from './Register'; +import { reduxActions } from '../../../redux/utils/connect'; +import { + registerUser, + submitHubspot +} from '../../../api/registration/registration'; +import { setDocumentData, validateForm } from '../../../common/helper'; + +function FreeAccountPage({ + form = {}, + errors, + handleChange, + handleValidation, + validateSubmitBasic, + completedSteps, + actions +}) { + const { replace, push } = useHistory(); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!completedSteps[registerSteps[0]]) { + replace(`/auth/register/${registerSteps[0]}`); + return; + } + + setDocumentData('title', 'Register'); + + return () => setDocumentData('title'); // default + }, []); + + const handleSubmit = async () => { + const obj = validateForm( + { password: form.password, confirmPassword: form.confirmPassword }, + { password: errors.password, confirmPassword: errors.confirmPassword }, + handleValidation + ); + if (!obj) { + return actions.addAlert({ + type: 'error', + transKey: 'requiredInfo' + }); + } + + const basicForm = validateSubmitBasic(); + if (!basicForm) { + actions.addAlert({ + type: 'error', + transKey: 'requiredInfo' + }); + push(`/auth/register/${registerSteps[0]}`); + return; + } + + setLoading(true); + basicForm.password = obj.password; + registerUser(basicForm).then((res) => { + if (res.error) { + res.data + ? actions.addAlert(res.data) + : actions.addAlert({ type: 'error', transKey: 'somethingWrong' }); + setLoading(false); + return; + } + + window.gtag && + window.gtag('event', 'sign_up', { + method: 'Free' + }); + + submitHubspot({ + ...basicForm, + lifecyclestage: 'marketingqualifiedlead' // label: Marketing Qualified Lead + }).then(() => { + push('/auth/register-success', { + email: basicForm.email, + isFreeUser: res.data.isFreeUser + }); + }); + }); + }; + + function onPasswordValidate(name, error) { + if (name === 'password') { + handleValidation('password', error); + } + if (form.confirmPassword !== '' && form.password !== form.confirmPassword) { + handleValidation('confirmPassword', 'Confirm Password does not match.'); + } else if (form.confirmPassword !== '') { + handleValidation('confirmPassword', null); + } + } + + return ( + +

    + Create password for your free basic account +

    + + + + + + + + +

    + By registering, you agree to our{' '} + + Privacy Policy + + ,{' '} + + Terms & Conditions + {' '} + and{' '} + + Acceptable Use Policy + + . +

    + +
    + +
    + + + + +
    + + ); +} + +FreeAccountPage.propTypes = { + form: PropTypes.object, + errors: PropTypes.object, + handleChange: PropTypes.func, + handleValidation: PropTypes.func, + validateSubmitBasic: PropTypes.func, + completedSteps: PropTypes.object, + actions: PropTypes.object +}; + +export default React.memo(reduxActions()(FreeAccountPage)); diff --git a/frontend/app/components/LoginRegister/Registration/PaymentDetailsPage.js b/frontend/app/components/LoginRegister/Registration/PaymentDetailsPage.js new file mode 100644 index 0000000..dbe4d71 --- /dev/null +++ b/frontend/app/components/LoginRegister/Registration/PaymentDetailsPage.js @@ -0,0 +1,441 @@ +/* eslint-disable react/jsx-no-bind */ +import React, { useState, Fragment, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { Link, useHistory } from 'react-router-dom'; +import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import { Col, Row, Button, Alert, FormGroup, Label } from 'reactstrap'; +import { getData } from 'country-list'; + +import { reduxActions } from '../../../redux/utils/connect'; +import { + registerUser, + submitHubspot +} from '../../../api/registration/registration'; +import { Input } from '../../common/FormControls'; +import { registerSteps } from './Register'; + +const countries = getData().map((v) => ({ label: v.name, value: v.code })); + +const cardElementOptions = { + hidePostalCode: true, + style: { + base: { + fontSize: '16px', + color: '#424770' + }, + invalid: { + color: '#d92550' + } + } +}; + +function PaymentDetailsPage({ + form = {}, + errors, + handleChange, + handleValidation, + updatingPrice, + validateSubmitBasic, + totalCost, + planDetails, + completedSteps, + validateSubmit, + actions +}) { + const stripe = useStripe(); + const elements = useElements(); + const { push, replace } = useHistory(); + const [paymentError, setPaymentError] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!completedSteps[registerSteps[0]]) { + replace(`/auth/register/${registerSteps[0]}`); + return; + } + if (!completedSteps[registerSteps[1]]) { + replace(`/auth/register/${registerSteps[1]}`); + return; + } + }, []); + + const handleSubmit = async () => { + if (!stripe || !elements) { + // Stripe.js has not loaded yet. + return; + } + + setPaymentError(false); + setLoading(true); + + const obj = validateSubmit(); + if (!obj) { + setLoading(false); + return actions.addAlert({ + type: 'error', + transKey: 'requiredInfo' + }); + } + + const cardElement = elements.getElement(CardElement); + const { + name, + line1, + line2, + city, + state, + postal_code, + country, + email, + phone + } = obj; + const { error, paymentMethod } = await stripe.createPaymentMethod({ + type: 'card', + card: cardElement, + billing_details: { + name, + email, + phone, + address: { + line1: line1, + line2: line2, + city: city, + state: state, + postal_code: postal_code, + country: country + } + } + }); + + if (error) { + setPaymentError(error); + setLoading(false); + return; + } + + const basicForm = validateSubmitBasic(); + if (!basicForm) { + setLoading(false); + actions.addAlert({ + type: 'error', + transKey: 'requiredInfo' + }); + return push(`/auth/register/${registerSteps[0]}`); + } + + const newObj = { ...planDetails, ...basicForm }; + newObj.password = obj.password; + newObj.masterAccounts = '1'; + newObj.paymentID = paymentMethod.id; //stripe card element ID + const res = await registerUser(newObj); + + if (res.error) { + res.data + ? actions.addAlert(res.data) + : actions.addAlert({ type: 'error', transKey: 'somethingWrong' }); + setLoading(false); + return; + } + + if (res.data && res.data.paymentError) { + setPaymentError({ message: res.data.message }); + setLoading(false); + return; + } + + window.gtag && window.gtag('event', 'sign_up', { + method: 'Paid', + amount_paid: totalCost + }); + + window.gtag && window.gtag('event', 'purchase', { + currency: 'USD', + value: totalCost + }); + + submitHubspot({ + ...basicForm, + lifecyclestage: 'customer' // label: Customer + }).then(() => { + push('/auth/register-success', { + email: basicForm.email, + isFreeUser: res.data.isFreeUser + }); + }); + }; + + function onPasswordValidate(name, error) { + if (name === 'password') { + handleValidation('password', error); + } + if (form.confirmPassword !== '' && form.password !== form.confirmPassword) { + handleValidation('confirmPassword', 'Confirm Password does not match.'); + } else if (form.confirmPassword !== '') { + handleValidation('confirmPassword', null); + } + } + + return ( + +
    +

    + Create Password +

    + + + + + + + + + +
    +
    +

    + Billing and Payment Details +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    + By registering, you agree to our{' '} + + Privacy Policy + + ,{' '} + + Terms & Conditions + {' '} + and{' '} + + Acceptable Use Policy + + . +

    + +
    + + + {paymentError && ( + + +

    + Error +

    + {paymentError.message} +
    +
    + )} + {paymentError && ( +

    + You can also choose your plan later. Click to get your + + Free Basic Account + + . +

    + )} +
    + + + + +
    +
    + + ); +} + +PaymentDetailsPage.propTypes = { + changePlan: PropTypes.func, + form: PropTypes.object, + errors: PropTypes.object, + planDetails: PropTypes.object, + handleChange: PropTypes.func, + handleValidation: PropTypes.func, + validateSubmitBasic: PropTypes.func, + validateSubmit: PropTypes.func, + actions: PropTypes.object, + completedSteps: PropTypes.object, + updatingPrice: PropTypes.bool, + totalCost: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) +}; + +export default React.memo(reduxActions()(PaymentDetailsPage)); diff --git a/frontend/app/components/LoginRegister/Registration/PlanConstants.js b/frontend/app/components/LoginRegister/Registration/PlanConstants.js new file mode 100644 index 0000000..77fb99d --- /dev/null +++ b/frontend/app/components/LoginRegister/Registration/PlanConstants.js @@ -0,0 +1,390 @@ +// no more used +export const paramsMapping = { + savedFeeds: 'savedFeeds', + searchesPerDay: 'searchesPerDay', + webFeeds: 'webFeedLicense', + alerts: 'alerts', + news: 'news', + blogs: 'blogs', + reddit: 'Reddit', + instagram: 'Instagram', + twitter: 'Twitter', + analytics: 'Analytics', + newsletter: 'Newsletter', + subscriberAccounts: 'subscriberAccounts', + masterAccounts: 'masterAccounts' +}; + +export const planButtons = [ + { + name: 'Social Starter', + defaultValues: { + savedFeeds: 5, + searchesPerDay: 10, + webFeeds: 0, + alerts: 0, + subscriberAccounts: 1, + news: false, + blog: false, + reddit: false, + instagram: true, + twitter: false, + analytics: true, + newsletter: false + } + }, + { + name: 'PR Starter', + wide: true, + defaultValues: { + savedFeeds: 5, + searchesPerDay: 10, + webFeeds: 0, + alerts: 0, + subscriberAccounts: 1, + news: true, + blog: true, + reddit: true, + instagram: false, + twitter: true, + analytics: true, + newsletter: false + } + }, + { + name: 'The Works', + defaultValues: { + savedFeeds: 5, + searchesPerDay: 10, + webFeeds: 2, + alerts: 2, + subscriberAccounts: 3, + news: true, + blog: true, + reddit: true, + instagram: true, + twitter: true, + analytics: true, + newsletter: true + } + } +]; + +export const mediaTypes = [ + { title: 'News', transKey: 'news', name: 'news', price: '$20' }, + { title: 'Blog', transKey: 'blogs', name: 'blog', price: '$15' }, + { title: 'Reddit', transKey: 'reddit', name: 'reddit', price: '$1' }, + { + title: 'Instagram', + transKey: 'instagram', + name: 'instagram', + price: '$3' + }, + { title: 'Twitter', transKey: 'twitter', name: 'twitter', price: '$3' } +]; + +export const features = [ + { + name: 'analytics', + title: 'Analytics', + transKey: 'analytics', + price: '$15' + // desc: 'Analytics can be added to any package $15 x Number of Feeds' + } + /* { + name: 'Newsletter', + price: '$5', + desc: + '$5 per alert newsletter with unlimited recipients and recipient groups' + } */ +]; + +export const addonFeatures = [ + { + name: 'subscriberAccounts', + title: 'User Accounts', + transKey: 'userAccounts', + props: { + min: 1, + defaultValue: 1, + max: 100 + } + } +]; + +export const licenses = [ + { + name: 'savedFeeds', + title: 'Feed Licenses', + transKey: 'feedsLicenses', + props: { + min: 0, + max: 200, + marks: { + 0: 0, + 200: 200 + }, + step: 1 + } + }, + { + name: 'searchesPerDay', + title: 'Search Licenses (per day)', + transKey: 'searchLicenses', + props: { + min: 10, + max: 200, + marks: { + 10: 10, + 200: 200 + }, + step: 10 + } + }, + { + name: 'webFeeds', + title: 'Webfeed Licenses', + transKey: 'webfeedLicenses', + price: '$5', + props: { + min: 0, + max: 200, + marks: { + 0: 0, + 200: 200 + } + } + }, + { + name: 'alerts', + title: 'Alert Licenses', + transKey: 'alertLicenses', + props: { + min: 0, + max: 100, + marks: { + 0: 0, + 100: 100 + } + } + } + /* { + name: 'newsletterLicenses', + title: 'Newsletter Licenses', + props: { + min: 0, + max: 10, + marks: { + 0: 0, + 10: 10 + } + } + }, */ +]; + +export const industryList = [ + { label: 'Accounting', value: 'Accounting' }, + { label: 'Airlines/Aviation', value: 'Airlines/Aviation' }, + { + label: 'Alternative Dispute Resolution', + value: 'Alternative Dispute Resolution' + }, + { label: 'Alternative Medicine', value: 'Alternative Medicine' }, + { label: 'Animation', value: 'Animation' }, + { label: 'Apparel & Fashion', value: 'Apparel & Fashion' }, + { label: 'Architecture & Planning', value: 'Architecture & Planning' }, + { label: 'Arts and Crafts', value: 'Arts and Crafts' }, + { label: 'Automotive', value: 'Automotive' }, + { label: 'Aviation & Aerospace', value: 'Aviation & Aerospace' }, + { label: 'Banking', value: 'Banking' }, + { label: 'Biotechnology', value: 'Biotechnology' }, + { label: 'Broadcast Media', value: 'Broadcast Media' }, + { label: 'Building Materials', value: 'Building Materials' }, + { + label: 'Business Supplies and Equipment', + value: 'Business Supplies and Equipment' + }, + { label: 'Capital Markets', value: 'Capital Markets' }, + { label: 'Chemicals', value: 'Chemicals' }, + { + label: 'Civic & Social Organization', + value: 'Civic & Social Organization' + }, + { label: 'Civil Engineering', value: 'Civil Engineering' }, + { label: 'Commercial Real Estate', value: 'Commercial Real Estate' }, + { + label: 'Computer & Network Security', + value: 'Computer & Network Security' + }, + { label: 'Computer Games', value: 'Computer Games' }, + { label: 'Computer Hardware', value: 'Computer Hardware' }, + { label: 'Computer Networking', value: 'Computer Networking' }, + { label: 'Computer Software', value: 'Computer Software' }, + { label: 'Internet', value: 'Internet' }, + { label: 'Construction', value: 'Construction' }, + { label: 'Consumer Electronics', value: 'Consumer Electronics' }, + { label: 'Consumer Goods', value: 'Consumer Goods' }, + { label: 'Consumer Services', value: 'Consumer Services' }, + { label: 'Cosmetics', value: 'Cosmetics' }, + { label: 'Dairy', value: 'Dairy' }, + { label: 'Defense & Space', value: 'Defense & Space' }, + { label: 'Design', value: 'Design' }, + { label: 'Education Management', value: 'Education Management' }, + { label: 'E-Learning', value: 'E-Learning' }, + { + label: 'Electrical/Electronic Manufacturing', + value: 'Electrical/Electronic Manufacturing' + }, + { label: 'Entertainment', value: 'Entertainment' }, + { label: 'Environmental Services', value: 'Environmental Services' }, + { label: 'Events Services', value: 'Events Services' }, + { label: 'Executive Office', value: 'Executive Office' }, + { label: 'Facilities Services', value: 'Facilities Services' }, + { label: 'Farming', value: 'Farming' }, + { label: 'Financial Services', value: 'Financial Services' }, + { label: 'Fine Art', value: 'Fine Art' }, + { label: 'Fishery', value: 'Fishery' }, + { label: 'Food & Beverages', value: 'Food & Beverages' }, + { label: 'Food Production', value: 'Food Production' }, + { label: 'Fund-Raising', value: 'Fund-Raising' }, + { label: 'Furniture', value: 'Furniture' }, + { label: 'Gambling & Casinos', value: 'Gambling & Casinos' }, + { label: 'Glass, Ceramics & Concrete', value: 'Glass, Ceramics & Concrete' }, + { label: 'Government Administration', value: 'Government Administration' }, + { label: 'Government Relations', value: 'Government Relations' }, + { label: 'Graphic Design', value: 'Graphic Design' }, + { + label: 'Health, Wellness and Fitness', + value: 'Health, Wellness and Fitness' + }, + { label: 'Higher Education', value: 'Higher Education' }, + { label: 'Hospital & Health Care', value: 'Hospital & Health Care' }, + { label: 'Hospitality', value: 'Hospitality' }, + { label: 'Human Resources', value: 'Human Resources' }, + { label: 'Import and Export', value: 'Import and Export' }, + { + label: 'Individual & Family Services', + value: 'Individual & Family Services' + }, + { label: 'Industrial Automation', value: 'Industrial Automation' }, + { label: 'Information Services', value: 'Information Services' }, + { + label: 'Information Technology and Services', + value: 'Information Technology and Services' + }, + { label: 'Insurance', value: 'Insurance' }, + { label: 'International Affairs', value: 'International Affairs' }, + { + label: 'International Trade and Development', + value: 'International Trade and Development' + }, + { label: 'Investment Banking', value: 'Investment Banking' }, + { label: 'Investment Management', value: 'Investment Management' }, + { label: 'Judiciary', value: 'Judiciary' }, + { label: 'Law Enforcement', value: 'Law Enforcement' }, + { label: 'Law Practice', value: 'Law Practice' }, + { label: 'Legal Services', value: 'Legal Services' }, + { label: 'Legislative Office', value: 'Legislative Office' }, + { label: 'Leisure, Travel & Tourism', value: 'Leisure, Travel & Tourism' }, + { label: 'Libraries', value: 'Libraries' }, + { label: 'Logistics and Supply Chain', value: 'Logistics and Supply Chain' }, + { label: 'Luxury Goods & Jewelry', value: 'Luxury Goods & Jewelry' }, + { label: 'Machinery', value: 'Machinery' }, + { label: 'Management Consulting', value: 'Management Consulting' }, + { label: 'Maritime', value: 'Maritime' }, + { label: 'Market Research', value: 'Market Research' }, + { label: 'Marketing and Advertising', value: 'Marketing and Advertising' }, + { + label: 'Mechanical or Industrial Engineering', + value: 'Mechanical or Industrial Engineering' + }, + { label: 'Media Production', value: 'Media Production' }, + { label: 'Medical Devices', value: 'Medical Devices' }, + { label: 'Medical Practice', value: 'Medical Practice' }, + { label: 'Mental Health Care', value: 'Mental Health Care' }, + { label: 'Military', value: 'Military' }, + { label: 'Mining & Metals', value: 'Mining & Metals' }, + { label: 'Motion Pictures and Film', value: 'Motion Pictures and Film' }, + { label: 'Museums and Institutions', value: 'Museums and Institutions' }, + { label: 'Music', value: 'Music' }, + { label: 'Nanotechnology', value: 'Nanotechnology' }, + { label: 'Newspapers', value: 'Newspapers' }, + { + label: 'Nonprofit Organization Management', + value: 'Nonprofit Organization Management' + }, + { label: 'Oil & Energy', value: 'Oil & Energy' }, + { label: 'Online Media', value: 'Online Media' }, + { label: 'Outsourcing/Offshoring', value: 'Outsourcing/Offshoring' }, + { label: 'Package/Freight Delivery', value: 'Package/Freight Delivery' }, + { label: 'Packaging and Containers', value: 'Packaging and Containers' }, + { label: 'Paper & Forest Products', value: 'Paper & Forest Products' }, + { label: 'Performing Arts', value: 'Performing Arts' }, + { label: 'Pharmaceuticals', value: 'Pharmaceuticals' }, + { label: 'Philanthropy', value: 'Philanthropy' }, + { label: 'Photography', value: 'Photography' }, + { label: 'Plastics', value: 'Plastics' }, + { label: 'Political Organization', value: 'Political Organization' }, + { + label: 'Primary/Secondary Education', + value: 'Primary/Secondary Education' + }, + { label: 'Printing', value: 'Printing' }, + { + label: 'Professional Training & Coaching', + value: 'Professional Training & Coaching' + }, + { label: 'Program Development', value: 'Program Development' }, + { label: 'Public Policy', value: 'Public Policy' }, + { + label: 'Public Relations and Communications', + value: 'Public Relations and Communications' + }, + { label: 'Public Safety', value: 'Public Safety' }, + { label: 'Publishing', value: 'Publishing' }, + { label: 'Railroad Manufacture', value: 'Railroad Manufacture' }, + { label: 'Ranching', value: 'Ranching' }, + { label: 'Real Estate', value: 'Real Estate' }, + { + label: 'Recreational Facilities and Services', + value: 'Recreational Facilities and Services' + }, + { label: 'Religious Institutions', value: 'Religious Institutions' }, + { label: 'Renewables & Environment', value: 'Renewables & Environment' }, + { label: 'Research', value: 'Research' }, + { label: 'Restaurants', value: 'Restaurants' }, + { label: 'Retail', value: 'Retail' }, + { + label: 'Security and Investigations', + value: 'Security and Investigations' + }, + { label: 'Semiconductors', value: 'Semiconductors' }, + { label: 'Shipbuilding', value: 'Shipbuilding' }, + { label: 'Sporting Goods', value: 'Sporting Goods' }, + { label: 'Sports', value: 'Sports' }, + { label: 'Staffing and Recruiting', value: 'Staffing and Recruiting' }, + { label: 'Supermarkets', value: 'Supermarkets' }, + { label: 'Telecommunications', value: 'Telecommunications' }, + { label: 'Textiles', value: 'Textiles' }, + { label: 'Think Tanks', value: 'Think Tanks' }, + { label: 'Tobacco', value: 'Tobacco' }, + { + label: 'Translation and Localization', + value: 'Translation and Localization' + }, + { + label: 'Transportation/Trucking/Railroad', + value: 'Transportation/Trucking/Railroad' + }, + { label: 'Utilities', value: 'Utilities' }, + { + label: 'Venture Capital & Private Equity', + value: 'Venture Capital & Private Equity' + }, + { label: 'Veterinary', value: 'Veterinary' }, + { label: 'Warehousing', value: 'Warehousing' }, + { label: 'Wholesale', value: 'Wholesale' }, + { label: 'Wine and Spirits', value: 'Wine and Spirits' }, + { label: 'Wireless', value: 'Wireless' }, + { label: 'Writing and Editing', value: 'Writing and Editing' } +]; diff --git a/frontend/app/components/LoginRegister/Registration/PlanDetailsPage.js b/frontend/app/components/LoginRegister/Registration/PlanDetailsPage.js new file mode 100644 index 0000000..2f635ad --- /dev/null +++ b/frontend/app/components/LoginRegister/Registration/PlanDetailsPage.js @@ -0,0 +1,278 @@ +/* eslint-disable react/jsx-no-bind */ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import Tooltip from 'rc-tooltip'; +import Slider from 'rc-slider'; +import { Link, useHistory } from 'react-router-dom'; +import { Col, Row, Button, Form, FormGroup, Label } from 'reactstrap'; +import { addonFeatures, features, licenses, mediaTypes } from './PlanConstants'; +import { registerSteps } from './Register'; + +import simpleNumberLocalizer from 'react-widgets-simple-number'; +import NumberPicker from 'react-widgets/lib/NumberPicker'; + +simpleNumberLocalizer(); + +const Handle = Slider.Handle; + +const handle = (props) => { + // eslint-disable-next-line react/prop-types + const { value, dragging, index, ...restProps } = props; + + return ( + + + + ); +}; + +function PlanDetailsPage({ + changePlan, + form = {}, + handleChange, + updatingPrice, + totalCost, + completedSteps, + setCompletedSteps, + planList +}) { + const { push, replace } = useHistory(); + + useEffect(() => { + if (!completedSteps[registerSteps[0]]) { + replace(`/auth/register/${registerSteps[0]}`); + } + }, []); + + function nextStep() { + if (!updatingPrice) { + setCompletedSteps((prev) => ({ ...prev, [registerSteps[1]]: true })); + push(`/auth/register/${registerSteps[2]}`); + } + } + + const isRTL = document.documentElement.dir === 'rtl'; + return ( + +

    + If you know EXACTLY what you want, you can build your package below. + Otherwise, you can sign up for a Free Basic Account{' '} + here. +

    + +
    + + +
    +
    Pre-configured Plans
    +
    + {planList.map((plan) => ( + + ))} +
    +
    + +
    +
    Media Types
    +
    + {mediaTypes.map((type) => ( + + ))} +
    +
    + + +
    +
    Licenses
    + + {licenses.map((license) => ( + +
    + +
    + + + {form[license.name]} + +
    + handleChange(license.name, val)} + /> +
    +
    + + ))} +
    +
    + + + +
    +
    Features
    + + + {features.map((type) => ( + + ))} + {/*
    + {features.map((type) => + form[type.name] ? ( +

    + {type.desc} +

    + ) : null + )} +
    */} + +
    +
    + + +
    +
    Add-ons
    + + {addonFeatures.map((type) => ( + + + + handleChange(type.name, val)} + /> + + + ))} + +
    + +
    + +
    +
    +
    +
    Total Cost
    +
    Monthly
    +
    +
    + {/* {updatingPrice && ( +
    + +
    + )} */} +
    + ${totalCost} +
    +
    +
    +
    + +
    + +
    + + + +
    + + + + +
    +
    + + + ); +} + +PlanDetailsPage.propTypes = { + changePlan: PropTypes.func, + form: PropTypes.object, + handleChange: PropTypes.func, + completedSteps: PropTypes.object, + planList: PropTypes.array, + setCompletedSteps: PropTypes.func, + updatingPrice: PropTypes.bool, + totalCost: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) +}; + +export default React.memo(PlanDetailsPage); diff --git a/frontend/app/components/LoginRegister/Registration/Register.js b/frontend/app/components/LoginRegister/Registration/Register.js new file mode 100644 index 0000000..2405328 --- /dev/null +++ b/frontend/app/components/LoginRegister/Registration/Register.js @@ -0,0 +1,385 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useHistory, useLocation, useParams } from 'react-router-dom'; +import { Button, Col, Row } from 'reactstrap'; +import { debounce } from 'lodash'; +import PerfectScrollbar from 'react-perfect-scrollbar'; + +import { getPlans, updatePrice } from '../../../api/registration/registration'; +import CommonSection from '../CommonSection'; +import useForm from '../../common/hooks/useForm'; +import useIsMounted from '../../common/hooks/useIsMounted'; +import PlanDetailsPage from './PlanDetailsPage'; +import PaymentDetailsPage from './PaymentDetailsPage'; +import FreeAccountPage from './FreeAccountPage'; +import BasicDetailsPage from './BasicDetailsPage'; +import logo from '../../../images/logo/logo-small.png'; +import Loading from '../../common/Loading'; +import { reduxActions } from '../../../redux/utils/connect'; +import { IoIosWarning } from 'react-icons/io'; +import { setDocumentData } from '../../../common/helper'; + +const initialBasicForm = { + email: '', + firstName: '', + lastName: '', + companyName: '', + jobFunction: '', + numberOfEmployee: '', + industry: '', + websiteUrl: '', + errors: { + email: null, + firstName: null, + lastName: null, + companyName: null, + jobFunction: null, + numberOfEmployee: null, + industry: null + } +}; + +const initialForm = { + savedFeeds: 0, + searchesPerDay: 0, + webFeeds: 0, + alerts: 0, + news: 0, + blog: 0, + reddit: 0, + instagram: 0, + twitter: 0, + analytics: 0, + subscriberAccounts: 0, + masterAccounts: 0 +}; + +const initialPaymentForm = { + password: '', + confirmPassword: '', + name: '', + line1: '', + line2: '', + city: '', + state: '', + postal_code: '', + country: '', + email: '', + phone: '', + errors: { + password: null, + confirmPassword: null, + name: null, + line1: null, + city: null, + state: null, + postal_code: null, + country: null, + email: null, + phone: null + } +}; + +export const registerSteps = { + 0: 'account-information', + 1: 'build-package', + 2: 'payment', + 3: 'basic-account' +}; + +const registerStepNames = [ + { id: registerSteps[0], step: 0, name: 'Account Information' }, + { id: registerSteps[1], step: 1, name: 'Build Package' }, + { id: registerSteps[2], step: 2, name: 'Payment / Finish' } +]; + +const freeRegisterStepNames = [ + { id: registerSteps[0], step: 0, name: 'Account Information' }, + { id: registerSteps[3], step: 1, name: 'Finish' } +]; + +// const allowedReferrers = ['www.socialhose.io', 'landing.socialhose.io']; + +function Register({ actions }) { + const { push } = useHistory(); + const { step } = useParams(); + const { search } = useLocation(); + const isMounted = useIsMounted(); + + const { + form: formBasic, + errors: errorsBasic, + handleChange: handleChangeBasic, + handleValidation: handleValidationBasic, + validateSubmit: validateSubmitBasic, + resetForm: resetFormBasic + } = useForm(initialBasicForm); + const { + form: formPlan, + handleChange: handleChangePlan, + resetForm: resetFormPlan + } = useForm(initialForm); + const { + form: paymentForm, + handleChange: handlePaymentForm, + errors: paymentFormErrors, + handleValidation: handlePaymentValidation, + validateSubmit + } = useForm(initialPaymentForm); + + const [planList, setPlanList] = useState([]); + const [planLoading, setPlanLoading] = useState(true); + const [planError, setPlanError] = useState(false); + + const [currentStep, setCurrentStep] = useState(); + const [completedSteps, setCompletedSteps] = useState({}); + const [updatingPrice, setUpdatingPrice] = useState(true); + const [totalCost, setTotalCost] = useState(0); + const [disabledFields, setDisabledFields] = useState([]); + const ref = useRef(); + + const searchParams = new URLSearchParams(search); + + useEffect(() => { + const obj = {}; + const objErrs = {}; + for (let [key, value] of searchParams) { + if (value) { + obj[key] = value; + objErrs[key] = false; + } + } + setDisabledFields(Object.keys(obj)); + resetFormBasic({ + ...formBasic, + ...obj, + errors: { + ...errorsBasic, + ...objErrs + } + }); + + getBillingPlans(); + setDocumentData('title', 'Register'); + + return () => setDocumentData('title'); // default + }, []); + + useEffect(() => { + /* const allowUser = + document.referrer && + allowedReferrers.find((path) => document.referrer.includes(path)); + + // only allowed referrer can access the page + if (isLive && !allowUser) { + push('/auth/login'); + return; + } */ + + if (!step || !Object.values(registerSteps).includes(step)) { + push(`/auth/register/${registerSteps[0]}`); + ref.current && (ref.current.scrollTop = 0); + return; + } + + if (currentStep !== step) { + setCurrentStep(step); + ref.current && (ref.current.scrollTop = 0); + } + }, [step]); + + // to update price when input changes + useEffect(() => { + debouncePrice(formPlan); + }, [...Object.values(formPlan)]); + + const debouncePrice = useCallback( + debounce((form) => { + setUpdatingPrice(true); + updatePrice(form).then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || isNaN(res.data.totalPrice)) { + actions.addAlert(res.data); + setUpdatingPrice(false); + setTotalCost('Error'); + return; + } + setTotalCost(res.data.totalPrice); + setUpdatingPrice(false); + }); + }, 1000), + [] + ); + + function getBillingPlans() { + setPlanLoading(true); + setPlanError(false); + getPlans().then((res) => { + if (!isMounted.current) { + return false; + } + if (res.error || !res.data || !res.data.length) { + setPlanError(true); + res.data && res.data.length > 0 && actions.addAlert(res.data); + return; + } + setPlanLoading(false); + setPlanList(res.data); + + const modified = { ...initialForm }; + const selectedPlan = res.data[0]; + Object.keys(initialForm).map((key) => { + modified[key] = + selectedPlan[key] === undefined + ? modified[key] + : selectedPlan[key] === true + ? 1 + : selectedPlan[key] === false + ? 0 + : selectedPlan[key]; + }); + resetFormPlan(modified); + }); + } + + function changePlan(id) { + const selectedPlan = planList.find((plan) => plan.id === id); + const modified = { ...initialForm }; + Object.keys(initialForm).map((key) => { + modified[key] = + selectedPlan[key] === undefined + ? modified[key] + : selectedPlan[key] === true + ? 1 + : selectedPlan[key] === false + ? 0 + : selectedPlan[key]; + }); + resetFormPlan(modified); + } + + function getClassName(step, arr) { + let cl = ''; + const stepDetails = arr.find((v) => v.id === currentStep); + if (step.id === currentStep) { + cl = 'form-wizard-step-doing'; + } else if ( + stepDetails && + stepDetails.step > step.step && + completedSteps[step.id] + ) { + cl = 'form-wizard-step-done'; + } else { + cl = 'form-wizard-step-todo'; + } + + return cl; + } + + const stepProgressNames = + currentStep === registerSteps[3] + ? freeRegisterStepNames + : registerStepNames; + + return ( + + + { + ref.current = container; + }} + > +
    + + + +
    +
    +
      + {stepProgressNames.map((s, i, arr) => ( +
    1. + {i + 1} + {s.name} +
    2. + ))} +
    +
    + {planError ? ( +
    + + Sorry, something went wrong.{' '} + +
    + ) : planLoading ? ( + + ) : null} + + {!planLoading && currentStep === registerSteps[0] && ( + + )} + {!planLoading && currentStep === registerSteps[1] && ( + + )} + {!planLoading && currentStep === registerSteps[2] && ( + + )} + {!planLoading && currentStep === registerSteps[3] && ( + + )} +
    + + +
    + ); +} + +Register.propTypes = { + actions: PropTypes.object.isRequired +}; + +export default reduxActions()(Register); diff --git a/frontend/app/components/LoginRegister/Registration/RegisterConfirmEmail.js b/frontend/app/components/LoginRegister/Registration/RegisterConfirmEmail.js new file mode 100644 index 0000000..b5e053d --- /dev/null +++ b/frontend/app/components/LoginRegister/Registration/RegisterConfirmEmail.js @@ -0,0 +1,113 @@ +import React, { Fragment, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { Button, Col, Row } from 'reactstrap'; +import { useHistory, useParams } from 'react-router'; +import { activeAccount } from '../../../api/registration/registration'; +import Loading from '../../common/Loading'; +import logo from '../../../images/logo/logo-small.png'; +import { setDocumentData } from '../../../common/helper'; +import { translate } from 'react-i18next'; +import LangSettingsMenu from '../../App/AppHeader/LangSettingsMenu'; + +function RegisterConfirmEmail({ t }) { + const { token } = useParams(); + const history = useHistory(); + const [msg, setMsg] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!token) { + history.push('/auth/login'); + return; + } + + setLoading(true); + activeAccount(token).then((res) => { + if (res.error) { + setMsg({ + text: t('register.verification.failed') + }); + setLoading(false); + return; + } + + window.gtag && + window.gtag('event', 'email_verified', { + email_verified: true + }); + + setMsg({ + isSuccess: true, + text: t('register.verification.success') + }); + setLoading(false); + }); + + setDocumentData('title', 'Account Verification'); + + return () => setDocumentData('title'); // default + }, []); + + if (!token) { + return null; + } + + return ( + + +
    +
    +
    + +
    + {loading && } + {!loading && ( + + {msg.isSuccess ? ( + +
    + + +
    +
    +
    +
    {msg.text}
    + + + ) : ( + +
    + + + + +
    +
    {msg.text}
    +
    + )} + + )} +
    +
    + +
    + +
    + + ); +} + +RegisterConfirmEmail.propTypes = { + t: PropTypes.func.isRequired +}; + +export default translate(['loginApp'], { wait: true })(RegisterConfirmEmail); diff --git a/frontend/app/components/LoginRegister/Registration/RegisterFreeAccount.js b/frontend/app/components/LoginRegister/Registration/RegisterFreeAccount.js new file mode 100644 index 0000000..dae8001 --- /dev/null +++ b/frontend/app/components/LoginRegister/Registration/RegisterFreeAccount.js @@ -0,0 +1,417 @@ +import React, { useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Link, useHistory, useLocation, useParams } from 'react-router-dom'; +import { Button, Col, Row, Form } from 'reactstrap'; +import PerfectScrollbar from 'react-perfect-scrollbar'; + +import CommonSection from '../CommonSection'; +import useForm from '../../common/hooks/useForm'; +import useIsMounted from '../../common/hooks/useIsMounted'; +import logo from '../../../images/logo/logo-small.png'; +import { reduxActions } from '../../../redux/utils/connect'; +import { employeesOptions } from './BasicDetailsPage'; +import { + registerUser, + submitHubspot +} from '../../../api/registration/registration'; +import { validateForm } from '../../../common/helper'; +import { Input } from '../../common/FormControls'; +import { registerSteps } from './Register'; +import { industryList } from './PlanConstants'; +import { Trans, translate } from 'react-i18next'; +import LangSettingsMenu from '../../App/AppHeader/LangSettingsMenu'; + +const initialBasicForm = { + email: '', + firstName: '', + lastName: '', + companyName: '', + jobFunction: '', + numberOfEmployee: '', + industry: '', + websiteUrl: '', + password: '', + confirmPassword: '', + errors: { + email: null, + firstName: null, + lastName: null, + companyName: null, + jobFunction: null, + numberOfEmployee: null, + industry: null, + password: null, + confirmPassword: null + } +}; + +function RegisterFree({ actions, t }) { + const { search } = useLocation(); + const { push, replace } = useHistory(); + const { step } = useParams(); + const isMounted = useIsMounted(); + const [loading, setLoading] = useState(false); + + const { + form, + errors, + handleChange, + handleValidation, + validateSubmit, + resetForm + } = useForm(initialBasicForm); + + const [disabledFields, setDisabledFields] = useState([]); + const ref = useRef(); + + const searchParams = new URLSearchParams(search); + + useEffect(() => { + if (step !== registerSteps[0]) { + replace(`/auth/register/${registerSteps[0]}`); + } + + const obj = {}; + const objErrs = {}; + for (let [key, value] of searchParams) { + if (Object.keys(initialBasicForm).includes(key) && value) { + obj[key] = value; + objErrs[key] = false; + } + } + setDisabledFields(Object.keys(obj)); + resetForm({ + ...form, + ...obj, + errors: { + ...errors, + ...objErrs + } + }); + }, []); + + const handleSubmit = async () => { + const basicForm = validateSubmit(); + if (!basicForm) { + actions.addAlert({ + type: 'error', + transKey: 'requiredInfo' + }); + return; + } + + const obj = validateForm( + { password: form.password, confirmPassword: form.confirmPassword }, + { password: errors.password, confirmPassword: errors.confirmPassword }, + handleValidation + ); + if (!obj) { + return actions.addAlert({ + type: 'error', + transKey: 'requiredInfo' + }); + } + + setLoading(true); + delete basicForm.confirmPassword; + registerUser(basicForm).then((res) => { + if (!isMounted.current) { + return; + } + + if (res.error) { + res.data + ? actions.addAlert(res.data) + : actions.addAlert({ type: 'error', transKey: 'somethingWrong' }); + setLoading(false); + return; + } + + window.gtag && + window.gtag('event', 'sign_up', { + method: 'Free' + }); + + submitHubspot({ + ...basicForm, + lifecyclestage: 'marketingqualifiedlead' // label: Marketing Qualified Lead + }).then(() => { + push('/auth/register-success', { + email: basicForm.email, + isFreeUser: res.data.isFreeUser + }); + }); + }); + }; + + function onPasswordValidate(name, error) { + if (name === 'password') { + handleValidation('password', error); + } + if (form.confirmPassword !== '' && form.password !== form.confirmPassword) { + handleValidation('confirmPassword', t('register.passwordNotMatched')); + } else if (form.confirmPassword !== '') { + handleValidation('confirmPassword', null); + } + } + + const isRTL = document.documentElement.dir === 'rtl'; + return ( + + + { + ref.current = container; + }} + > +
    + + + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    + + By registering, you agree to our + + Privacy Policy + + + Terms & Conditions + + + Acceptable Use Policy + + . + +

    + +
    + +
    +
    + {t('register.signInText')}{' '} + + {t('register.signInBtn')} + +
    + +
    + + +
    + +
    +
    + + +
    + ); +} + +RegisterFree.propTypes = { + t: PropTypes.func.isRequired, + actions: PropTypes.object.isRequired +}; + +export default reduxActions()( + translate(['loginApp'], { wait: true })(RegisterFree) +); diff --git a/frontend/app/components/LoginRegister/Registration/RegisterSuccess.js b/frontend/app/components/LoginRegister/Registration/RegisterSuccess.js new file mode 100644 index 0000000..ac95626 --- /dev/null +++ b/frontend/app/components/LoginRegister/Registration/RegisterSuccess.js @@ -0,0 +1,80 @@ +import React, { Fragment, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { Col, Row } from 'reactstrap'; +import logo from '../../../images/logo/logo-small.png'; +import { useHistory, useLocation } from 'react-router'; +import { setDocumentData } from '../../../common/helper'; +import { Trans, translate } from 'react-i18next'; +import LangSettingsMenu from '../../App/AppHeader/LangSettingsMenu'; + +function RegisterSuccess({ t }) { + const { state } = useLocation(); + const history = useHistory(); + + const email = state ? state.email : ''; + + useEffect(() => { + if (!email) { + history.push('/auth/login'); + return; + } + + setDocumentData('title', 'Registration Success'); + + return () => setDocumentData('title'); // default + }, []); + + if (!email) { + return null; + } + + return ( + + +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    + {state && state.isFreeUser ? ( + + + You have successfully
    + Free Basic Account. +
    +
    + ) : ( + + + You have successfully paid and +
    + registered to SOCIALHOSE.IO. +
    +
    + )} +
    +

    + {t('register.successBottomText', { email })} +

    +
    +
    + +
    + +
    + + ); +} + +RegisterSuccess.propTypes = { + t: PropTypes.func.isRequired +}; + +export default translate(['loginApp'], { wait: true })(RegisterSuccess); diff --git a/frontend/app/components/LoginRegister/ResetPassword.js b/frontend/app/components/LoginRegister/ResetPassword.js new file mode 100644 index 0000000..5056ee9 --- /dev/null +++ b/frontend/app/components/LoginRegister/ResetPassword.js @@ -0,0 +1,110 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { compose } from 'redux'; +import { Link } from 'react-router-dom'; +import { Button, Col, Form, FormGroup, Input, Label, Row } from 'reactstrap'; +import reduxConnect from '../../redux/utils/connect'; +import CommonSection from './CommonSection'; +import { setDocumentData } from '../../common/helper'; +import LangSettingsMenu from '../App/AppHeader/LangSettingsMenu'; + +function ResetPassword(props) { + const { history, t } = props; + const [password, setPassword] = useState(''); + + useEffect(() => { + const confirmationToken = location.search.split('=')[1]; + if (!confirmationToken) { + history.push('/auth/forgot-password'); + return; + } + + setDocumentData('title', 'Reset Password'); + + return () => setDocumentData('title'); // default + }, []); + + function changeHandler({ target: { value } }) { + setPassword(value); + } + + function submitHandler(e) { + e.preventDefault(); + const confirmationToken = location.search.split('=')[1]; + if (password && confirmationToken) { + props.actions.confirmPasswordReset(confirmationToken, password); + } + } + + return ( +
    + + + + +
    +

    +
    {t('resetPass.mainLabel')}
    + {t('resetPass.subLabel')} +

    +
    +
    + + + + + + + + +
    +
    + + {t('resetPass.signIn')} + +
    +
    + +
    +
    +
    +
    + +
    + +
    + + +
    + ); +} + +ResetPassword.propTypes = { + store: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, + t: PropTypes.func.isRequired +}; + +const applyDecorators = compose( + reduxConnect(), + translate(['loginApp', 'common'], { wait: true }) +); + +export default applyDecorators(ResetPassword); diff --git a/frontend/app/components/common/FiltersTable/FilterGroup.js b/frontend/app/components/common/FiltersTable/FilterGroup.js new file mode 100644 index 0000000..206861b --- /dev/null +++ b/frontend/app/components/common/FiltersTable/FilterGroup.js @@ -0,0 +1,216 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import FilterItem from './FilterItem'; +import { ADV_FILTERS_LIMIT } from '../../../redux/modules/appState/search'; +import classnames from 'classnames'; +import { Button } from 'reactstrap'; + +export class FilterGroup extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + groupName: PropTypes.string.isRequired, + filters: PropTypes.array.isRequired, + selectedFilters: PropTypes.object, + count: PropTypes.number.isRequired, + totalCount: PropTypes.number.isRequired, + clearPending: PropTypes.bool.isRequired, + callbacks: PropTypes.object.isRequired + }; + + static translatableGroups = { + articleDate: 'articleDate', + country: 'country', + language: 'language', + articleLanguage: 'language', + sourceCountry: 'country', + sentiment: 'sentiment' + }; + + translateItem = (itemName) => { + return this.props.t(`${this.translateKey}.${itemName}`, { + defaultValue: itemName + }); + }; + + constructor(props) { + super(props); + this.state = { isExpanded: !!this.props.totalCount }; + if (this.props.groupName in FilterGroup.translatableGroups) { + this.translateKey = FilterGroup.translatableGroups[this.props.groupName]; + } else { + this.translateItem = (x) => x; //equiv function + } + } + + componentDidUpdate = (prevProps) => { + const needUpdate = prevProps.totalCount !== this.props.totalCount; + if (needUpdate) { + this.setState({ + isExpanded: !!this.props.totalCount + }); + } + }; + + toggleExpand = () => { + this.setState({ + isExpanded: !this.state.isExpanded + }); + }; + + onMoreClick = () => { + this.props.callbacks.moreFilters(this.props.groupName); + }; + + onLessClick = () => { + this.props.callbacks.lessFilters(this.props.groupName); + }; + + onRefineClick = () => { + this.props.callbacks.refine(); + }; + + onClearClick = () => { + this.props.callbacks.clearFilters(this.props.groupName); + }; + + onItemClick = (filterValue) => { + this.props.callbacks.selectFilter(this.props.groupName, filterValue); + }; + + forEachItem = (fn) => { + return this.props.filters && this.props.filters.slice(0, this.props.count).map(fn); + }; + + render() { + const clsName = 'filters-table__group'; + const { t, selectedFilters, clearPending, count, totalCount } = this.props; + + let includedCounter = 0; + let excludedCounter = 0; + for (let value in selectedFilters) { + if (selectedFilters.hasOwnProperty(value)) { + if (selectedFilters[value] === -1) excludedCounter++; + if (selectedFilters[value] === 1) includedCounter++; + } + } + + const moreVisible = count < totalCount; + const lessVisible = count > ADV_FILTERS_LIMIT; + const hasSelected = !!excludedCounter || !!includedCounter; + + const moreVisibleClass = classnames('filters-table__more', { + 'filters-table__more--visible': moreVisible, + 'filters-table__more--hidden': !moreVisible && lessVisible, + 'filters-table__more--none': !moreVisible && !lessVisible + }); + + const lessVisibleClass = classnames('filters-table__less', { + 'filters-table__less--visible': lessVisible, + 'filters-table__less--hidden': !lessVisible && moreVisible, + 'filters-table__less--none': !lessVisible && !moreVisible + }); + + const refineVisibleClass = classnames( + 'filters-table__more my-2 ml-2 mr-1', + { + 'filters-table__more--none': !hasSelected && !clearPending + } + ); + + const clearVisibleClass = classnames('filters-table__less my-2 mr-2 ml-1', { + 'filters-table__less--none': !hasSelected + }); + + const isRTL = document.documentElement.dir === 'rtl'; + + return ( +
    +
    + {isRTL ? ( + + ) : ( + + )} + + {t('advancedFilters.' + this.props.groupName, { + defaultValue: this.props.groupName + })} +
    + {!includedCounter && !excludedCounter && ( + + )} + {!!includedCounter && ( + {includedCounter} + )} + {!!excludedCounter && ( + -{excludedCounter} + )} +
    +
    + + {this.state.isExpanded && ( +
    + {this.forEachItem((item) => { + return ( + + ); + })} + + {clearPending && ( +
    + {t('filtersTable.clearMessage')} +
    + )} + +
    + + + +
    + +
    + + + +
    +
    + )} +
    + ); + } +} + +export default translate(['common'], { wait: true })(FilterGroup); diff --git a/frontend/app/components/common/FiltersTable/FilterItem.js b/frontend/app/components/common/FiltersTable/FilterItem.js new file mode 100644 index 0000000..9ce1189 --- /dev/null +++ b/frontend/app/components/common/FiltersTable/FilterItem.js @@ -0,0 +1,40 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import classnames from 'classnames' + +export class FilterItem extends React.Component { + + static propTypes = { + t: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + count: PropTypes.number.isRequired, + onItemClick: PropTypes.func.isRequired, + selectionState: PropTypes.number + }; + + onItemClick = () => { + const { onItemClick, value } = this.props + onItemClick(value) + }; + + render () { + const { selectionState, title, count } = this.props + const mainClass = 'filters-table__item' + const classes = classnames(mainClass, { + [`${mainClass}--included`]: selectionState === 1, + [`${mainClass}--excluded`]: selectionState === -1 + }) + + return ( +
    + {title} + {count} +
    + ) + } + +} + +export default translate(['tabsContent'], { wait: true })(FilterItem) diff --git a/frontend/app/components/common/FiltersTable/FiltersTable.js b/frontend/app/components/common/FiltersTable/FiltersTable.js new file mode 100644 index 0000000..94c729b --- /dev/null +++ b/frontend/app/components/common/FiltersTable/FiltersTable.js @@ -0,0 +1,95 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import FilterGroup from './FilterGroup'; +import { Button } from 'reactstrap'; +import { arraymove } from '../../../common/helper'; + +export class FiltersTable extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + filters: PropTypes.object.isRequired, + selectedFilters: PropTypes.object.isRequired, + clearPending: PropTypes.object.isRequired, + pages: PropTypes.object.isRequired, + callbacks: PropTypes.object.isRequired + }; + + forEachGroup = (fn) => { + const { pages, filters, selectedFilters } = this.props; + const filterKeys = Object.keys(filters); + + arraymove( + filterKeys, + filterKeys.indexOf('sentiment'), + filterKeys.indexOf('articleDate') + 1 + ); + + return filterKeys.map((groupName) => { + return fn( + groupName, + filters[groupName], + pages[groupName], + selectedFilters[groupName] || {} + ); + }); + }; + + onRefineButton = () => { + this.props.callbacks.refine(); + }; + + onClearAllButton = () => { + this.props.callbacks.clearAllFilters(); + setTimeout(() => { + this.props.callbacks.refine(); + }, 200); + }; + + render() { + const { callbacks, clearPending, t } = this.props; + return ( +
    +
    + {this.forEachGroup( + (groupName, groupFilters, groupPage, selectedFilters) => { + return ( + + ); + } + )} +
    + +
    + + +
    +
    + ); + } +} + +export default translate(['common'], { wait: true })(FiltersTable); diff --git a/frontend/app/components/common/Footer.js b/frontend/app/components/common/Footer.js new file mode 100644 index 0000000..5ed828a --- /dev/null +++ b/frontend/app/components/common/Footer.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; + +function Footer({ t }) { + return ( + + ); +} + +Footer.propTypes = { + t: PropTypes.func.isRequired +}; + +export default translate(['loginApp'], { wait: true })(Footer); diff --git a/frontend/app/components/common/FormControls/Checkbox.js b/frontend/app/components/common/FormControls/Checkbox.js new file mode 100644 index 0000000..460eb8b --- /dev/null +++ b/frontend/app/components/common/FormControls/Checkbox.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormGroup, Label, CustomInput } from 'reactstrap'; +import { Interpolate, translate } from 'react-i18next'; + +// need changes: err cannot be removed once triggered +function Checkbox({ + t, + title, + hideTitle, + name, + formGroupClass, + required, + trueValue, + value, + disabled, + error, + description, + handleChange +}) { + function onChange(e) { + const { name, checked } = e.target; + let oppValue = null; + if (typeof trueValue === 'boolean') oppValue = false; + else if (typeof trueValue === 'number') oppValue = 0; + handleChange(name, checked ? trueValue : oppValue); + } + + return ( + + {!hideTitle && ( + + )} + + {error === true ? ( + + + + ) : ( + {error} + )} + + ); +} + +Checkbox.defaultProps = { + handleChange: () => {}, + trueValue: true, + disabled: false, + hideTitle: false, + formGroupClass: '' +}; + +Checkbox.propTypes = { + t: PropTypes.func, + title: PropTypes.string, + name: PropTypes.string.isRequired, + type: PropTypes.string, + formGroupClass: PropTypes.string, + value: PropTypes.bool, + hideTitle: PropTypes.bool, + trueValue: PropTypes.any, + description: PropTypes.any, + required: PropTypes.bool, + disabled: PropTypes.bool, + error: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]), + handleChange: PropTypes.func.isRequired +}; + +export default React.memo(translate(['common'], { wait: true })(Checkbox)); diff --git a/frontend/app/components/common/FormControls/Input.js b/frontend/app/components/common/FormControls/Input.js new file mode 100644 index 0000000..6148457 --- /dev/null +++ b/frontend/app/components/common/FormControls/Input.js @@ -0,0 +1,140 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { + Input as ReactInput, + FormGroup, + Label, + FormText, + InputGroup +} from 'reactstrap'; +import { translate } from 'react-i18next'; + +function Input({ + t, + title, + name, + required, + disabled, + value, + type, + options, + placeholder, + error, + description, + regex, + handleChange, + handleValidation, + validationMessage, + inputGroupAddon +}) { + function onChange(e) { + handleChange(e.target.name.trim(''), e.target.value); + } + + function onValidate(e) { + if (!handleValidation) return; + const { value, name } = e.target; + let errorMsg = required ? null : undefined; + if (!value.replace(/\s/g, '').length && required) { + errorMsg = true; + } else if (value && regex && !regex.test(value)) { + errorMsg = validationMessage || t('messages.invalidMsg', { title }); + } + + handleValidation(name, errorMsg); + } + + let optionsJSX; + if (type === 'select' && Array.isArray(options)) { + optionsJSX = [ + + ]; + options.map((option) => + optionsJSX.push( + + ) + ); + } + + return ( + + + {inputGroupAddon ? ( + + + + {inputGroupAddon} + + + ) : ( + + )} + {error === true ? ( + {t('messages.inputMsg', { title })} + ) : ( + {error} + )} + {description && {description}} + + ); +} + +Input.defaultProps = { + handleChange: () => {}, + handleValidation: () => {}, + disabled: false +}; + +Input.propTypes = { + t: PropTypes.func, + title: PropTypes.string, + name: PropTypes.string.isRequired, + type: PropTypes.string, + value: PropTypes.string, + placeholder: PropTypes.string, + validationMessage: PropTypes.string, + required: PropTypes.bool, + disabled: PropTypes.bool, + error: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.object, + PropTypes.string + ]), + regex: PropTypes.object, + options: PropTypes.any, + description: PropTypes.any, + inputGroupAddon: PropTypes.any, + handleChange: PropTypes.func, + handleValidation: PropTypes.func +}; + +export default React.memo(translate(['common'], { wait: true })(Input)); diff --git a/frontend/app/components/common/FormControls/RadioButton.js b/frontend/app/components/common/FormControls/RadioButton.js new file mode 100644 index 0000000..05d5102 --- /dev/null +++ b/frontend/app/components/common/FormControls/RadioButton.js @@ -0,0 +1,93 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormGroup, Label, CustomInput } from 'reactstrap'; +import { translate } from 'react-i18next'; + +function RadioButton({ + t, + title, + name, + required, + value, + disabled, + error, + inline, + options, + valueKey, + labelKey, + formClass, + handleChange +}) { + function onChange(e, value) { + let errorMsg = required ? null : undefined; + if (!value && required) { + errorMsg = t('messages.selectMsg', { title: name }); + } else { + errorMsg = false; + } + handleChange(name, value, errorMsg); + } + + return ( + + + {options.map((o, i) => ( + + ))} + {error === true ? ( + {t('messages.inputMsg', { title })} + ) : ( + {error} + )} + + ); +} + +RadioButton.defaultProps = { + options: [], + valueKey: 'value', + labelKey: 'label', + value: null, + handleChange: () => {}, + handleValidation: () => {}, + disabled: false +}; + +RadioButton.propTypes = { + t: PropTypes.func, + title: PropTypes.string, + name: PropTypes.string.isRequired, + type: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), + formClass: PropTypes.string, + options: PropTypes.array, + labelKey: PropTypes.string, + valueKey: PropTypes.string, + required: PropTypes.bool, + disabled: PropTypes.bool, + inline: PropTypes.bool, + error: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]), + handleChange: PropTypes.func.isRequired +}; + +export default React.memo( + translate(['tabsContent'], { wait: true })(RadioButton) +); diff --git a/frontend/app/components/common/FormControls/index.js b/frontend/app/components/common/FormControls/index.js new file mode 100644 index 0000000..ab368ab --- /dev/null +++ b/frontend/app/components/common/FormControls/index.js @@ -0,0 +1,5 @@ +import Input from './Input' +import Checkbox from './Checkbox' +import RadioButton from './RadioButton' + +export { Input, Checkbox, RadioButton } diff --git a/frontend/app/components/common/Loader/Loader.js b/frontend/app/components/common/Loader/Loader.js new file mode 100644 index 0000000..7218402 --- /dev/null +++ b/frontend/app/components/common/Loader/Loader.js @@ -0,0 +1,16 @@ +import React, { Component } from 'react' + +export default class LoadersAdvanced extends Component { + render () { + return ( +
    +
    +
    +
    +
    +
    +
    +
    + ) + } +} diff --git a/frontend/app/components/common/Loader/Loader.scss b/frontend/app/components/common/Loader/Loader.scss new file mode 100644 index 0000000..a9bb785 --- /dev/null +++ b/frontend/app/components/common/Loader/Loader.scss @@ -0,0 +1,70 @@ +.out-space { + position: absolute; + background-color: #f7f7f7; + opacity: 0.9; + width: 100%; + height: 100%; + z-index: 99999; + display: -ms-flexbox; + display: flex; + -ms-flex-pack: center; + justify-content: center; + -ms-flex-align: center; + align-items: center; +} + +.lds-ellipsis { + display: inline-block; + position: relative; + width: 80px; + height: 80px; +} +.lds-ellipsis div { + position: absolute; + top: 33px; + width: 13px; + height: 13px; + border-radius: 50%; + background: $primary; + animation-timing-function: cubic-bezier(0, 1, 1, 0); +} +.lds-ellipsis div:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; +} +.lds-ellipsis div:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; +} +.lds-ellipsis div:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; +} +.lds-ellipsis div:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; +} +@keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + 100% { + transform: scale(1); + } +} +@keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + 100% { + transform: scale(0); + } +} +@keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + 100% { + transform: translate(24px, 0); + } +} diff --git a/frontend/app/components/common/Loading.js b/frontend/app/components/common/Loading.js new file mode 100644 index 0000000..2a4019e --- /dev/null +++ b/frontend/app/components/common/Loading.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import i18n from '../../i18n'; + +function Loading({ + show = true, + message = i18n.t('common:commonWords.loading') +}) { + if (!show) { + return null; + } + + return ( +
    + + + +

    {message}

    +
    + ); +} + +Loading.propTypes = { + show: PropTypes.bool, + message: PropTypes.string +}; + +export default Loading; diff --git a/frontend/app/components/common/NoRecords.js b/frontend/app/components/common/NoRecords.js new file mode 100644 index 0000000..c8c1057 --- /dev/null +++ b/frontend/app/components/common/NoRecords.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { IoIosInformationCircle } from 'react-icons/io'; +import i18n from '../../i18n'; + +function NoRecords({ + show = true, + message = i18n.t('common:messages.noResults') +}) { + if (!show) { + return null; + } + + return ( +
    + +

    {message}

    +
    + ); +} + +NoRecords.propTypes = { + show: PropTypes.bool, + message: PropTypes.string +}; + +export default NoRecords; diff --git a/frontend/app/components/common/Pager/LimitSelector.js b/frontend/app/components/common/Pager/LimitSelector.js new file mode 100644 index 0000000..82355f9 --- /dev/null +++ b/frontend/app/components/common/Pager/LimitSelector.js @@ -0,0 +1,30 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Button } from 'reactstrap' + +export default class LimitSelector extends React.Component { + + static propTypes = { + pagerAction: PropTypes.func.isRequired, + limit: PropTypes.number.isRequired, + isCurrent: PropTypes.bool.isRequired + }; + + onClick = () => { + !this.props.isCurrent && this.props.pagerAction({limitByPage: this.props.limit}) + }; + + render () { + let className = 'table-pager__limit' + if (this.props.isCurrent) { + className += ' ' + className + '--current' + } + + return ( + + ) + } + +} diff --git a/frontend/app/components/common/Pager/PageSelector.js b/frontend/app/components/common/Pager/PageSelector.js new file mode 100644 index 0000000..2869e52 --- /dev/null +++ b/frontend/app/components/common/Pager/PageSelector.js @@ -0,0 +1,34 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { PaginationItem, PaginationLink } from 'reactstrap' + +export default class PageSelector extends React.Component { + static propTypes = { + pagerAction: PropTypes.func.isRequired, + index: PropTypes.number.isRequired, + isEllipsis: PropTypes.bool.isRequired, + isCurrent: PropTypes.bool.isRequired + }; + + onClick = () => { + !this.props.isCurrent && + this.props.pagerAction({ currentPage: this.props.index }) + }; + + render () { + if (this.props.isEllipsis) { + return . . . + } else { + // const currentClass = this.props.isCurrent + // ? ' table-pager__page--current' + // : '' + return ( + + + {this.props.index} + + + ) + } + } +} diff --git a/frontend/app/components/common/Pager/Pager.js b/frontend/app/components/common/Pager/Pager.js new file mode 100644 index 0000000..ea73049 --- /dev/null +++ b/frontend/app/components/common/Pager/Pager.js @@ -0,0 +1,125 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import LimitSelector from './LimitSelector' +import PageSelector from './PageSelector' +import { ButtonGroup, Pagination, PaginationItem, PaginationLink } from 'reactstrap' + +export class Pager extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + pagerAction: PropTypes.func.isRequired, + currentPage: PropTypes.number.isRequired, + numPages: PropTypes.number.isRequired, + limitByPage: PropTypes.number.isRequired, + hideLimitSelector: PropTypes.bool + }; + + static limits = [10, 25, 50, 100, 200]; + + onClickPrevPage = () => { + if (this.props.currentPage > 1) { + this.props.pagerAction({ currentPage: this.props.currentPage - 1 }) + } + }; + + onClickNextPage = () => { + if (this.props.currentPage < this.props.numPages) { + this.props.pagerAction({ currentPage: this.props.currentPage + 1 }) + } + }; + + getPaginationTemplate = (maxLength = 7) => { + const { numPages, currentPage } = this.props + let res = {} + if (numPages === 0) return res + + //always show first, last and current page + res[1] = 1 + res[numPages] = 1 + res[currentPage] = 1 + + if (currentPage <= maxLength - 3) { + //show all from 1 to 5 + for (let i = 2; i < maxLength - 1; i++) { + if (i < numPages) res[i] = 1 + } + } else if (currentPage >= numPages - maxLength + 4) { + //show last five pages + for (let i = numPages - maxLength + 3; i < numPages; i++) { + res[i] = 1 + } + } else { + //just show neighbours of current page + let shift = Math.floor((maxLength - 5) / 2) + for (let i = currentPage - shift; i <= currentPage + shift; i++) { + res[i] = 1 + } + } + //and show ellipsis + if (numPages > 1) { + if (!res[2]) res[2] = 0 + if (!res[numPages - 1]) res[numPages - 1] = 0 + } + return res + }; + + render () { + const pages = this.getPaginationTemplate() + // const prevDisabledClass = + // this.props.currentPage > 1 ? '' : ' table-pager__page--disabled' + // const nextDisabledClass = + // this.props.currentPage < this.props.numPages + // ? '' + // : ' table-pager__page--disabled' + + return ( +
    + + + + + + {Object.keys(pages).map((index) => { + return ( + + ) + })} + + + + + + + {!this.props.hideLimitSelector && ( +
    + Show + + {Pager.limits.map((val) => { + return ( + + ) + })} + +
    + )} +
    + ) + } +} + +export default translate(['tabsContent'], { wait: true })(Pager) diff --git a/frontend/app/components/common/Popups/PopupLayout.js b/frontend/app/components/common/Popups/PopupLayout.js new file mode 100644 index 0000000..f4a3bbb --- /dev/null +++ b/frontend/app/components/common/Popups/PopupLayout.js @@ -0,0 +1,88 @@ +import React, { Fragment } from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import classnames from 'classnames' +import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap' + +export class PopupLayout extends React.Component { + static propTypes = { + className: PropTypes.string, + children: PropTypes.element.isRequired, + title: PropTypes.string, + showFooter: PropTypes.bool, + footer: PropTypes.element, + cancelText: PropTypes.string, + submitText: PropTypes.string, + submitColor: PropTypes.string, + onHide: PropTypes.func.isRequired, + onSubmit: PropTypes.func, + t: PropTypes.func.isRequired + }; + + hidePopup = () => { + this.props.onHide() + }; + + onSubmit = () => { + const { onSubmit } = this.props + onSubmit() && this.hidePopup() + }; + + getHeader () { + const { t, title = 'common:commonWords.Confirm' } = this.props + return ( + + {t(title)} + + ) + } + + getFooter () { + const { + t, + footer, + showFooter = true, + cancelText = 'common:commonWords.Cancel', + submitText = 'common:commonWords.Confirm', + submitColor = 'primary' + } = this.props + if (!showFooter) return null + + const hasFooter = !!footer + + return ( + + {hasFooter && footer} + {!hasFooter && ( + + + + + )} + + ) + } + + render () { + const { children, className } = this.props + const classes = classnames('popup', className) + + return ( +
    + + {this.getHeader()} + {children} + {this.getFooter()} + +
    + ) + } +} + +export default translate(['tabsContent', 'common'], { wait: true })( + PopupLayout +) diff --git a/frontend/app/components/common/QuillEditor.js b/frontend/app/components/common/QuillEditor.js new file mode 100644 index 0000000..8eaf22e --- /dev/null +++ b/frontend/app/components/common/QuillEditor.js @@ -0,0 +1,83 @@ +import React, { useEffect } from 'react' +import PropTypes from 'prop-types' +import Quill from 'quill' +import 'quill/dist/quill.core.css' +import 'quill/dist/quill.snow.css' + +function QuillEditor({ id, children, reference, className }) { + const editorRef = reference + + useEffect(() => { + // all custom font-sizes and font-families should set up in the whitelist first + const size = Quill.import('attributors/style/size') + const font = Quill.import('formats/font') + + size.whitelist = ['10px', '13px', '16px', '18px', '24px', '32px', '48px'] + font.whitelist = [ + 'roboto', + 'lato', + 'times', + 'arial', + 'courier', + 'georgia', + 'trebuchet', + 'verdana' + ] + + Quill.register(size, true) + Quill.register(font, true) + + //all custom labels and font-families are setting up via css, library works that way, in our case we setting up font-families and font-sizes + const toolbarOptions = [ + [ + { + font: [ + 'roboto', + 'lato', + 'times', + 'arial', + 'courier', + 'georgia', + 'trebuchet', + 'verdana' + ] + }, + { + size: ['10px', '12px', '14px', '16px', '18px', '24px', '32px', '48px'] + } + ], + ['bold', 'italic', 'underline', 'strike'], + [{ script: 'sub' }, { script: 'super' }], + [{ align: [] }], + [{ indent: '-1' }, { indent: '+1' }], + [{ list: 'ordered' }, { list: 'bullet' }], + ['link', 'image'], + [{ color: [] }, { background: [] }], + ['clean'] + ] + + editorRef.current = new Quill(`#${id}`, { + theme: 'snow', + modules: { + toolbar: toolbarOptions + } + }) + + editorRef.current.focus() + }, []) + + return ( +
    + {children} +
    + ) +} + +QuillEditor.propTypes = { + id: PropTypes.string, + children: PropTypes.any, + reference: PropTypes.any, + className: PropTypes.string +} + +export default QuillEditor diff --git a/frontend/app/components/common/Restrictions/Restrictions.js b/frontend/app/components/common/Restrictions/Restrictions.js new file mode 100644 index 0000000..6dcbd48 --- /dev/null +++ b/frontend/app/components/common/Restrictions/Restrictions.js @@ -0,0 +1,246 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import { Row, Col, Progress } from 'reactstrap'; + +export class Restrictions extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + restrictions: PropTypes.object, + restrictionsIds: PropTypes.array + }; + + getBarColor(percentage) { + return percentage > 50 + ? percentage > 75 + ? 'danger' + : 'warning' + : 'success'; + } + + render() { + const { restrictions, restrictionsIds, t } = this.props; + if (!restrictions) return ''; + let searchLicense = null; + let searchLicenseLimit = null; + let saveLicense = null; + let saveLicenseLimit = null; + let alerts = null; + let alertsLimit = null; + let webFeeds = null; + let webFeedsLimit = null; + let isAlerts = false; + + restrictionsIds.map((id) => { + const restriction = restrictions[id]; + if (id === 'alerts') { + alerts = restriction.current; + alertsLimit = restriction.limit; + isAlerts = true; + } + + if (id === 'searchesPerDay') { + searchLicense = restriction.current; + searchLicenseLimit = restriction.limit; + } + + if (id === 'savedFeeds') { + saveLicense = restriction.current; + saveLicenseLimit = restriction.limit; + } + + if (id === 'webFeeds') { + webFeeds = restriction.current; + webFeedsLimit = restriction.limit; + } + }); + + const alertPerc = (alerts * 100) / alertsLimit; + const searchPerc = (searchLicense * 100) / searchLicenseLimit; + const feedPerc = (saveLicense * 100) / saveLicenseLimit; + const webFeedPerc = (webFeeds * 100) / webFeedsLimit; + + return ( + + {isAlerts ? ( + + +
    +
    +
    +
    +
    + {t('restrictions.alertLicenses')} +
    +
    + {t('restrictions.perMonth')} +
    +
    +
    +
    + {alertsLimit} +
    +
    +
    +
    + +
    + {alerts} / {alertsLimit} +
    +
    +
    +
    + + {/* {restrictions.newsletters && ( + +
    +
    +
    +
    +
    {t('restrictions.totalNewsltter')}
    +
    +
    +
    + {restrictions.newsletters.limit} +
    +
    +
    +
    + +
    + {restrictions.newsletters.current} /{' '} + {restrictions.newsletters.limit} +
    +
    +
    +
    + + )} */} + +
    +
    +
    +
    +
    + {t('restrictions.webfeedLicenses')} +
    +
    + {t('restrictions.perMonth')} +
    +
    +
    +
    + {webFeedsLimit} +
    +
    +
    +
    + +
    + {webFeeds} / {webFeedsLimit} +
    +
    +
    +
    + +
    + ) : ( + + +
    +
    +
    +
    +
    + {t('restrictions.searchLicenses')} +
    +
    + {t('restrictions.perDay')} +
    +
    +
    +
    + {searchLicenseLimit} +
    +
    +
    +
    + +
    + {searchLicense} / {searchLicenseLimit} +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + {t('restrictions.feedLicenses')} +
    +
    + {t('restrictions.perMonth')} +
    +
    +
    +
    + {saveLicenseLimit} +
    +
    +
    +
    + +
    + {saveLicense} / {saveLicenseLimit} +
    +
    +
    +
    + +
    + )} +
    + ); + } +} + +export default translate(['tabsContent'], { wait: true })(Restrictions); diff --git a/frontend/app/components/common/Table/CheckboxCell.js b/frontend/app/components/common/Table/CheckboxCell.js new file mode 100644 index 0000000..c4b8776 --- /dev/null +++ b/frontend/app/components/common/Table/CheckboxCell.js @@ -0,0 +1,28 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' + +export class CheckboxCell extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + id: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + checked: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired + }; + + onChange = () => { + this.props.onChange(this.props.id) + }; + + render () { + return ( + + ) + } + +} + +export default translate(['tabsContent'], { wait: true })(CheckboxCell) diff --git a/frontend/app/components/common/Table/DeleteButton.js b/frontend/app/components/common/Table/DeleteButton.js new file mode 100644 index 0000000..ca45bd8 --- /dev/null +++ b/frontend/app/components/common/Table/DeleteButton.js @@ -0,0 +1,26 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import { Button } from 'reactstrap' + +export class DeleteButton extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + id: PropTypes.number.isRequired, + onDelete: PropTypes.func.isRequired + } + + onDelete = () => { + this.props.onDelete(this.props.id) + } + + render() { + return ( + + ) + } +} + +export default translate(['tabsContent'], { wait: true })(DeleteButton) diff --git a/frontend/app/components/common/Table/LinkCell.js b/frontend/app/components/common/Table/LinkCell.js new file mode 100644 index 0000000..ed5783d --- /dev/null +++ b/frontend/app/components/common/Table/LinkCell.js @@ -0,0 +1,36 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import { Button } from 'reactstrap'; + +export class LinkCell extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + item: PropTypes.object.isRequired, + children: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.element + ]), + onClick: PropTypes.func.isRequired + }; + + onClick = () => { + this.props.onClick(this.props.item) + }; + + render () { + return ( + + ) + } + +} + +export default translate(['tabsContent'], { wait: true })(LinkCell) diff --git a/frontend/app/components/common/Table/SortableTh.js b/frontend/app/components/common/Table/SortableTh.js new file mode 100644 index 0000000..04eb0fa --- /dev/null +++ b/frontend/app/components/common/Table/SortableTh.js @@ -0,0 +1,26 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' + +export class SortableTh extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + title: PropTypes.string.isRequired + }; + + render () { + const { t, title } = this.props + + return ( +
    + {t(title)} + + + + +
    + ) + } +} + +export default translate(['tabsContent'], { wait: true })(SortableTh) diff --git a/frontend/app/components/common/Table/Table.js b/frontend/app/components/common/Table/Table.js new file mode 100644 index 0000000..b43ba19 --- /dev/null +++ b/frontend/app/components/common/Table/Table.js @@ -0,0 +1,163 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { translate, Interpolate } from 'react-i18next'; +import ReactTable from 'react-table'; +import 'react-table/react-table.css'; +import Pager from '../Pager/Pager'; +import { Row, Col, Card, CardBody, CardTitle } from 'reactstrap'; +import LoadersAdvanced from '../Loader/Loader'; + +// const steps = [ +// { name: 'Account Information' }, +// { name: 'Payment Information' }, +// { name: 'Finish Wizard' } +// ] + +export class Table extends React.Component { + static propTypes = { + t: PropTypes.func, + data: PropTypes.array.isRequired, + columns: PropTypes.array.isRequired, + totalCount: PropTypes.number.isRequired, + showTotalCount: PropTypes.bool, + noCard: PropTypes.bool, + limit: PropTypes.number.isRequired, + page: PropTypes.number.isRequired, + isLoading: PropTypes.bool.isRequired, + onFetchData: PropTypes.func.isRequired, + onRowClick: PropTypes.func, + cardTitle: PropTypes.string + }; + + onFetchData = (state) => { + this.props.onFetchData( + state.page, + state.pageSize, + state.sorted, + state.filtered + ); + }; + + onPageAction = (pageState) => { + const { totalCount } = this.props; + const gridState = this.refs.grid.state; + const { page, pageSize, sorted, filtered } = gridState; + let state = { page, pageSize, sorted, filtered }; + + if (pageState.limitByPage) { + state.pageSize = pageState.limitByPage; + } + if (pageState.currentPage) { + state.page = pageState.currentPage - 1; + } + if (totalCount < state.pageSize) { + state.page = 0; + } + + this.onFetchData(state); + }; + + getPagination = () => { + const { showTotalCount = false, totalCount, page, limit } = this.props; + const numPages = Math.ceil(totalCount / limit); + + return totalCount > 0 ? ( +
    + {showTotalCount && ( +
    + +
    + )} + + +
    + ) : null; + }; + + getLoading = (props) => { + if (!props.loading) return null; + + return ; + //
    ; + }; + + getTrProps = (state, rowInfo, column, instance) => { + const { onRowClick } = this.props; + let result = {}; + if (onRowClick) { + result.onClick = (e) => { + onRowClick(e, state, rowInfo, column, instance); + }; + } + return result; + }; + + NoDataConst = () => ( +
    + {this.props.t('common:messages.noRows', { + defaultValue: 'No rows found' + })} +
    + ); + + render() { + const { + data, + columns, + page, + limit, + isLoading, + cardTitle, + noCard + } = this.props; + + const renderTable = ( + + ); + + if (noCard) { + return renderTable; + } + + return ( + + + + + {cardTitle && {cardTitle}} + {renderTable} + + + + + ); + } +} + +export default translate(['tabsContent'], { wait: true })(Table); diff --git a/frontend/app/components/common/Table/Toggler.js b/frontend/app/components/common/Table/Toggler.js new file mode 100644 index 0000000..440bc4f --- /dev/null +++ b/frontend/app/components/common/Table/Toggler.js @@ -0,0 +1,50 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' +import { ButtonGroup, Button } from 'reactstrap' + +export class Toggler extends React.Component { + static propTypes = { + t: PropTypes.func.isRequired, + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + turnOnAction: PropTypes.func.isRequired, + turnOffAction: PropTypes.func.isRequired, + state: PropTypes.bool.isRequired, + enabledText: PropTypes.string.isRequired, + disabledText: PropTypes.string.isRequired + }; + + onOnClick = () => { + !this.props.state && this.props.turnOnAction(this.props.id) + }; + + onOffClick = () => { + this.props.state && this.props.turnOffAction(this.props.id) + }; + + render () { + const { enabledText, disabledText, state, t } = this.props + return ( + + + + + ) + } +} + +export default translate(['tabsContent'], { wait: true })(Toggler) diff --git a/frontend/app/components/common/TableHeaderSortItem.js b/frontend/app/components/common/TableHeaderSortItem.js new file mode 100644 index 0000000..71285c1 --- /dev/null +++ b/frontend/app/components/common/TableHeaderSortItem.js @@ -0,0 +1,51 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate } from 'react-i18next' + +export class TableHeaderSortItem extends React.Component { + + static propTypes = { + t: PropTypes.func.isRequired, + isSorted: PropTypes.bool.isRequired, + sortDirection: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + width: PropTypes.string, + sortAction: PropTypes.func.isRequired, + fieldName: PropTypes.string.isRequired, + textAlign: PropTypes.string + }; + + onClick = () => { + const sortDirection = (!this.props.isSorted || this.props.sortDirection === 'desc') ? 'asc' : 'desc' + this.props.sortAction({sortByField: this.props.fieldName, sortDirection: sortDirection}) + }; + + render () { + const {t, isSorted, sortDirection} = this.props + return ( + + + {t(this.props.title)} + + {!isSorted && + + } + + {isSorted && sortDirection === 'asc' && + + } + + {isSorted && sortDirection === 'desc' && + + } + + + ) + } + +} + +export default translate(['tabsContent'], { wait: true })(TableHeaderSortItem) diff --git a/frontend/app/components/common/alerts/Alert.js b/frontend/app/components/common/alerts/Alert.js new file mode 100644 index 0000000..78ef977 --- /dev/null +++ b/frontend/app/components/common/alerts/Alert.js @@ -0,0 +1,53 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { translate, Interpolate } from 'react-i18next' + +export class Alert extends React.Component { + static propTypes = { + alert: PropTypes.func.isRequired, + removeAlert: PropTypes.func.isRequired, + t: PropTypes.func.isRequired + }; + + componentDidMount = () => { + setTimeout(this.closeAlert, 5000) + }; + + closeAlert = () => { + const { removeAlert, alert } = this.props + removeAlert(alert.id) + }; + + onClose = (e) => { + e.preventDefault() + this.closeAlert() + }; + + render () { + const { alert } = this.props + const interpolateParameters = alert.parameters || {} + return ( +
    +
    +

    {alert.type}

    + + + + +
    + +
    +

    + +

    +
    +
    + ) + } +} + +export default translate(['common'], { wait: true })(Alert) diff --git a/frontend/app/components/common/alerts/Alerts.js b/frontend/app/components/common/alerts/Alerts.js new file mode 100644 index 0000000..d7a4bab --- /dev/null +++ b/frontend/app/components/common/alerts/Alerts.js @@ -0,0 +1,40 @@ +import React from 'react' +import PropTypes from 'prop-types' +import Alert from './Alert' + +export class Alerts extends React.Component { + static propTypes = { + alerts: PropTypes.array.isRequired, + removeAlert: PropTypes.func.isRequired + }; + + forEachAlert = (callback) => { + return this.props.alerts + .map((alert) => { + return (typeof alert === 'string') ? {message: alert} : alert + }) + .map(callback) + }; + + render () { + const { alerts, removeAlert } = this.props + if (alerts.length <= 0) return null + + return ( +
    + {this.forEachAlert((alert, i) => { + return ( + + ) + })} +
    + ) + } + +} + +export default Alerts diff --git a/frontend/app/components/common/charts/ChartsOptions.js b/frontend/app/components/common/charts/ChartsOptions.js new file mode 100644 index 0000000..c23c310 --- /dev/null +++ b/frontend/app/components/common/charts/ChartsOptions.js @@ -0,0 +1,138 @@ +export const BarToolbox = { + feature: { + saveAsImage: { + name: 'Socialhose Chart', + show: true, + title: 'Save as Image', + // name: 'Results Over Time', // need to set dynamic + type: 'jpeg', + backgroundColor: '#FFFFFF', + pixelRatio: 2 + }, + dataZoom: { + show: true, + title: { + zoom: 'Zoom', + back: 'Restore Zoom' + } + }, + dataView: { + show: true, + title: 'View Data', + readOnly: true, + lang: ['View Data', 'Close', 'Refresh'] + }, + magicType: { + show: true, + title: { + line: 'Line Chart', + bar: 'Bar Chart', + tiled: 'Tiled Chart' + }, + type: ['line', 'bar', 'tiled'] + }, + restore: { show: true, title: 'Restore' } + } +} + +export const PieToolbox = { + feature: { + saveAsImage: { + name: 'Socialhose Chart', + show: true, + title: 'Save as Image', + // name: 'Share of Topics', // need to set dynamic + type: 'jpeg', + backgroundColor: '#FFFFFF', + pixelRatio: 2 + }, + dataView: { + show: true, + readOnly: true, + title: 'View Data', + lang: ['View Data', 'Close', 'Refresh'] + }, + restore: { show: true, title: 'Restore' } + } +} + +export function getBarOptions(data, labels) { + return { + tooltip: { + show: true + }, + toolbox: BarToolbox, + xAxis: { + type: 'category', + data: labels + }, + yAxis: { + type: 'value' + }, + series: data, + legend: { + y: 'bottom', + show: true + } + } +} + +export function getPieOptions(data) { + return { + tooltip: { + show: true + }, + toolbox: PieToolbox, + series: { + type: 'pie', + data: data, + label: { + position: 'outer', + alignTo: 'none', + bleedMargin: 5 + }, + top: '10%', + bottom: '10%' + }, + legend: { + top: 'bottom', + show: true + } + } +} + +export const WordCloudOptions = { + type: 'wordCloud', + shape: 'circle', + sizeRange: [12, 35], + rotationRange: [0, 0], + width: '100%', + height: '100%', + top: '10%', + bottom: '10%', + drawOutOfBound: false, + gridSize: 8, + textStyle: { + normal: { + fontFamily: 'sans-serif', + fontWeight: 'bold', + // Color can be a callback function or a color string + color: function () { + // Random color + return ( + 'rgb(' + + [ + Math.round(Math.random() * 160), + Math.round(Math.random() * 160), + Math.round(Math.random() * 160) + ].join(',') + + ')' + ) + } + }, + emphasis: { + shadowBlur: 1, + shadowColor: '#333' + } + } +} diff --git a/frontend/app/components/common/charts/ECharts.js b/frontend/app/components/common/charts/ECharts.js new file mode 100644 index 0000000..331ee1d --- /dev/null +++ b/frontend/app/components/common/charts/ECharts.js @@ -0,0 +1,70 @@ +import React, { useEffect, useRef, useState } from 'react' +import PropTypes from 'prop-types' +import echarts from 'echarts' +import cx from 'classnames' + +function ECharts(props) { + const { options, style, className, loading, message } = props + const [chart, setChart] = useState(null) + const chartRef = useRef() + + useEffect(() => { + const chart = echarts.init(chartRef.current, 'westeros') + chart.setOption({ ...options, resizeObserver }, true) // second param is for 'noMerge' + setChart(chart) + if (resizeObserver) resizeObserver.observe(chartRef.current) + }, [options]) + + useEffect(() => { + if (!chart) { + return + } + if (loading) { + chart.showLoading() + return + } + + chart.hideLoading() + }, [chart, loading]) + + useEffect(() => { + if (chart && options && message) { + chart.clear() + } + }, [message]) + + const newStyle = { + height: 350, + ...style + } + + return ( +
    +
    + {message ?
    {message}
    : null} +
    + ) +} + +ECharts.propTypes = { + loading: PropTypes.bool, + options: PropTypes.any, + className: PropTypes.string, + style: PropTypes.object, + message: PropTypes.any +} + +const resizeObserver = new window.ResizeObserver((entries) => { + entries.map(({ target }) => { + const instance = echarts.getInstanceByDom(target) + if (instance) { + instance.resize() + } + }) +}) + +export default React.memo(ECharts) diff --git a/frontend/app/components/common/charts/WesterosTheme.json b/frontend/app/components/common/charts/WesterosTheme.json new file mode 100644 index 0000000..55de52b --- /dev/null +++ b/frontend/app/components/common/charts/WesterosTheme.json @@ -0,0 +1,382 @@ +{ + "color": ["#516b91", "#59c4e6", "#edafda", "#93b7e3", "#a5e7f0", "#cbb0e3"], + "backgroundColor": "rgba(0,0,0,0)", + "textStyle": {}, + "title": { + "textStyle": { + "color": "#516b91" + }, + "subtextStyle": { + "color": "#93b7e3" + } + }, + "line": { + "itemStyle": { + "borderWidth": "2" + }, + "lineStyle": { + "width": "2" + }, + "symbolSize": "6", + "symbol": "emptyCircle", + "smooth": true + }, + "radar": { + "itemStyle": { + "borderWidth": "2" + }, + "lineStyle": { + "width": "2" + }, + "symbolSize": "6", + "symbol": "emptyCircle", + "smooth": true + }, + "bar": { + "itemStyle": { + "barBorderWidth": 0, + "barBorderColor": "#ccc" + } + }, + "pie": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + } + }, + "scatter": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + } + }, + "boxplot": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + } + }, + "parallel": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + } + }, + "sankey": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + } + }, + "funnel": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + } + }, + "gauge": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + } + }, + "candlestick": { + "itemStyle": { + "color": "#edafda", + "color0": "transparent", + "borderColor": "#d680bc", + "borderColor0": "#8fd3e8", + "borderWidth": "2" + } + }, + "graph": { + "itemStyle": { + "borderWidth": 0, + "borderColor": "#ccc" + }, + "lineStyle": { + "width": 1, + "color": "#aaaaaa" + }, + "symbolSize": "6", + "symbol": "emptyCircle", + "smooth": true, + "color": ["#516b91", "#59c4e6", "#edafda", "#93b7e3", "#a5e7f0", "#cbb0e3"], + "label": { + "color": "#eeeeee" + } + }, + "map": { + "itemStyle": { + "normal": { + "areaColor": "#f3f3f3", + "borderColor": "#516b91", + "borderWidth": 0.5 + }, + "emphasis": { + "areaColor": "#a5e7f0", + "borderColor": "#516b91", + "borderWidth": 1 + } + }, + "label": { + "normal": { + "textStyle": { + "color": "#000" + } + }, + "emphasis": { + "textStyle": { + "color": "#516b91" + } + } + } + }, + "geo": { + "itemStyle": { + "normal": { + "areaColor": "#f3f3f3", + "borderColor": "#516b91", + "borderWidth": 0.5 + }, + "emphasis": { + "areaColor": "#a5e7f0", + "borderColor": "#516b91", + "borderWidth": 1 + } + }, + "label": { + "normal": { + "textStyle": { + "color": "#000" + } + }, + "emphasis": { + "textStyle": { + "color": "#516b91" + } + } + } + }, + "categoryAxis": { + "axisLine": { + "show": true, + "lineStyle": { + "color": "#cccccc" + } + }, + "axisTick": { + "show": false, + "lineStyle": { + "color": "#333" + } + }, + "axisLabel": { + "show": true, + "textStyle": { + "color": "#999999" + } + }, + "splitLine": { + "show": true, + "lineStyle": { + "color": ["#eeeeee"] + } + }, + "splitArea": { + "show": false, + "areaStyle": { + "color": ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"] + } + } + }, + "valueAxis": { + "axisLine": { + "show": true, + "lineStyle": { + "color": "#cccccc" + } + }, + "axisTick": { + "show": false, + "lineStyle": { + "color": "#333" + } + }, + "axisLabel": { + "show": true, + "textStyle": { + "color": "#999999" + } + }, + "splitLine": { + "show": true, + "lineStyle": { + "color": ["#eeeeee"] + } + }, + "splitArea": { + "show": false, + "areaStyle": { + "color": ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"] + } + } + }, + "logAxis": { + "axisLine": { + "show": true, + "lineStyle": { + "color": "#cccccc" + } + }, + "axisTick": { + "show": false, + "lineStyle": { + "color": "#333" + } + }, + "axisLabel": { + "show": true, + "textStyle": { + "color": "#999999" + } + }, + "splitLine": { + "show": true, + "lineStyle": { + "color": ["#eeeeee"] + } + }, + "splitArea": { + "show": false, + "areaStyle": { + "color": ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"] + } + } + }, + "timeAxis": { + "axisLine": { + "show": true, + "lineStyle": { + "color": "#cccccc" + } + }, + "axisTick": { + "show": false, + "lineStyle": { + "color": "#333" + } + }, + "axisLabel": { + "show": true, + "textStyle": { + "color": "#999999" + } + }, + "splitLine": { + "show": true, + "lineStyle": { + "color": ["#eeeeee"] + } + }, + "splitArea": { + "show": false, + "areaStyle": { + "color": ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"] + } + } + }, + "toolbox": { + "iconStyle": { + "normal": { + "borderColor": "#999999" + }, + "emphasis": { + "borderColor": "#516b91" + } + } + }, + "legend": { + "textStyle": { + "color": "#999999" + } + }, + "tooltip": { + "axisPointer": { + "lineStyle": { + "color": "#cccccc", + "width": 1 + }, + "crossStyle": { + "color": "#cccccc", + "width": 1 + } + } + }, + "timeline": { + "lineStyle": { + "color": "#8fd3e8", + "width": 1 + }, + "itemStyle": { + "normal": { + "color": "#8fd3e8", + "borderWidth": 1 + }, + "emphasis": { + "color": "#8fd3e8" + } + }, + "controlStyle": { + "normal": { + "color": "#8fd3e8", + "borderColor": "#8fd3e8", + "borderWidth": 0.5 + }, + "emphasis": { + "color": "#8fd3e8", + "borderColor": "#8fd3e8", + "borderWidth": 0.5 + } + }, + "checkpointStyle": { + "color": "#8fd3e8", + "borderColor": "rgba(138,124,168,0.37)" + }, + "label": { + "normal": { + "textStyle": { + "color": "#8fd3e8" + } + }, + "emphasis": { + "textStyle": { + "color": "#8fd3e8" + } + } + } + }, + "visualMap": { + "color": ["#516b91", "#59c4e6", "#a5e7f0"] + }, + "dataZoom": { + "backgroundColor": "rgba(0,0,0,0)", + "dataBackgroundColor": "rgba(255,255,255,0.3)", + "fillerColor": "rgba(167,183,204,0.4)", + "handleColor": "#a7b7cc", + "handleSize": "100%", + "textStyle": { + "color": "#333333" + } + }, + "markPoint": { + "label": { + "color": "#eeeeee" + }, + "emphasis": { + "label": { + "color": "#eeeeee" + } + } + } +} diff --git a/frontend/app/components/common/hooks/useForm.js b/frontend/app/components/common/hooks/useForm.js new file mode 100644 index 0000000..da3acb2 --- /dev/null +++ b/frontend/app/components/common/hooks/useForm.js @@ -0,0 +1,65 @@ +import { useState, useCallback } from 'react'; +import { cloneDeep } from 'lodash'; + +function useForm({ errors: initialErrors, ...rest }) { + const [form, setForm] = useState(rest); + const [errors, setErrors] = useState(initialErrors); + + const handleChange = useCallback((name, value, err = undefined) => { + setForm((form) => ({ + ...form, + [name]: value + })); + + if (errors) { + setErrors((errors) => ({ + ...errors, + [name]: err !== undefined ? err : errors[name] + })); + } + }, []); + + const handleValidation = useCallback((name, err) => { + setErrors((errors) => ({ + ...errors, + [name]: err + })); + }, []); + + const validateSubmit = useCallback(() => { + let failed; + for (let val in errors) { + const fieldError = errors[val]; + if (fieldError) { + failed = true; + } else if (fieldError === null && !form[val] && form[val] !== 0) { + failed = true; + handleValidation(val, true); + } + } + if (failed) { + return false; + } else { + return cloneDeep(form); + } + }, [form, errors, handleValidation]); + + function resetForm(values = {}) { + // eslint-disable-next-line no-unused-vars + const { errors, ...formValues } = values; + setForm(formValues || rest); + setErrors(initialErrors); + } + + return { + form, + handleChange, + handleValidation, + setForm, + validateSubmit, + errors, + resetForm + }; +} + +export default useForm; diff --git a/frontend/app/components/common/hooks/useIsMounted.js b/frontend/app/components/common/hooks/useIsMounted.js new file mode 100644 index 0000000..43a8a88 --- /dev/null +++ b/frontend/app/components/common/hooks/useIsMounted.js @@ -0,0 +1,16 @@ +import { useRef, useEffect } from 'react'; + +function useIsMounted() { + const isMounted = useRef(false); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + return isMounted; +} + +export default useIsMounted; diff --git a/frontend/app/components/common/hooks/usePageTracking.js b/frontend/app/components/common/hooks/usePageTracking.js new file mode 100644 index 0000000..5a967bd --- /dev/null +++ b/frontend/app/components/common/hooks/usePageTracking.js @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; +import { isLive } from '../../../common/constants'; + +function usePageTracking() { + const location = useLocation(); + const history = useHistory(); + + useEffect(() => { + // google analytics + if (window.gtag && isLive) { + setTimeout(() => { + window.gtag('event', 'page_view', { + page_location: window.location.href, + page_path: location.pathname + location.search + // page_title: '', + }); + }, 0); + } + }, [window.gtag, location]); + + useEffect(() => { + isLive && + history.listen(() => { + // Added to history listen to prevent first pageview call which is called by hubspot tracking script + setTimeout(() => { // to wait until document title updates + const _hsq = window._hsq; + if (location && _hsq) { + // hubspot tracking + _hsq.push(['setPath', location.pathname + location.search]); + _hsq.push(['trackPageView']); + } + + if (location && window.lintrk) { + // linkedin insight tracking + window.lintrk('track'); + } + }, 0); + }); + }, []); + + return null; +} + +export default usePageTracking; diff --git a/frontend/app/components/common/modal/ModalPopup.js b/frontend/app/components/common/modal/ModalPopup.js new file mode 100644 index 0000000..5476c0c --- /dev/null +++ b/frontend/app/components/common/modal/ModalPopup.js @@ -0,0 +1,35 @@ +import React, { useEffect, useState } from 'react' +import PropTypes from 'prop-types' +import { Modal } from 'reactstrap' + +function ModalPopup(props) { + const { children, modalProps, show, hideModal, handled } = props + const [open, setOpen] = useState(true) + + useEffect(() => setOpen(false)) // when unmounts + + function toggle() { + setOpen((prev) => !prev) + } + + return ( + + {children && (handled ? children : children(toggle, open))} + + ) +} + +ModalPopup.propTypes = { + handled: PropTypes.bool, + show: PropTypes.bool, + hideModal: PropTypes.func, + children: PropTypes.func.isRequired, + modalProps: PropTypes.object +} + +export default React.memo(ModalPopup) diff --git a/frontend/app/fonts/FontAwesome.otf b/frontend/app/fonts/FontAwesome.otf new file mode 100644 index 0000000..401ec0f Binary files /dev/null and b/frontend/app/fonts/FontAwesome.otf differ diff --git a/frontend/app/fonts/Linearicons-Free.eot b/frontend/app/fonts/Linearicons-Free.eot new file mode 100755 index 0000000..e531c22 Binary files /dev/null and b/frontend/app/fonts/Linearicons-Free.eot differ diff --git a/frontend/app/fonts/Linearicons-Free.svg b/frontend/app/fonts/Linearicons-Free.svg new file mode 100755 index 0000000..f498bf4 --- /dev/null +++ b/frontend/app/fonts/Linearicons-Free.svg @@ -0,0 +1,199 @@ + + + + + +{ + "fontFamily": "Linearicons-Free", + "majorVersion": 1, + "minorVersion": 0, + "description": "Designed by Perxis (https://perxis.com).\nFont generated by IcoMoon.", + "copyright": "Copyright © 2015 Perxis.com. All Rights Reserved.", + "designerURL": "https://perxis.com", + "license": "https://linearicons.com/free/license", + "fontURL": "https://linearicons.com", + "licenseURL": "https://linearicons.com/free/license", + "version": "Version 1.0", + "fontId": "Linearicons-Free", + "psName": "Linearicons-Free", + "subFamily": "Regular", + "fullName": "Linearicons-Free" +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/app/fonts/Linearicons-Free.ttf b/frontend/app/fonts/Linearicons-Free.ttf new file mode 100755 index 0000000..73d6783 Binary files /dev/null and b/frontend/app/fonts/Linearicons-Free.ttf differ diff --git a/frontend/app/fonts/Linearicons-Free.woff b/frontend/app/fonts/Linearicons-Free.woff new file mode 100755 index 0000000..63b5b75 Binary files /dev/null and b/frontend/app/fonts/Linearicons-Free.woff differ diff --git a/frontend/app/fonts/Linearicons-Free.woff2 b/frontend/app/fonts/Linearicons-Free.woff2 new file mode 100755 index 0000000..d4c5dda Binary files /dev/null and b/frontend/app/fonts/Linearicons-Free.woff2 differ diff --git a/frontend/app/fonts/fontawesome-webfont.eot b/frontend/app/fonts/fontawesome-webfont.eot new file mode 100644 index 0000000..e9f60ca Binary files /dev/null and b/frontend/app/fonts/fontawesome-webfont.eot differ diff --git a/frontend/app/fonts/fontawesome-webfont.svg b/frontend/app/fonts/fontawesome-webfont.svg new file mode 100644 index 0000000..855c845 --- /dev/null +++ b/frontend/app/fonts/fontawesome-webfont.svg @@ -0,0 +1,2671 @@ + + + + +Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 + By ,,, +Copyright Dave Gandy 2016. All rights reserved. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app/fonts/fontawesome-webfont.ttf b/frontend/app/fonts/fontawesome-webfont.ttf new file mode 100644 index 0000000..35acda2 Binary files /dev/null and b/frontend/app/fonts/fontawesome-webfont.ttf differ diff --git a/frontend/app/fonts/fontawesome-webfont.woff b/frontend/app/fonts/fontawesome-webfont.woff new file mode 100644 index 0000000..400014a Binary files /dev/null and b/frontend/app/fonts/fontawesome-webfont.woff differ diff --git a/frontend/app/fonts/fontawesome-webfont.woff2 b/frontend/app/fonts/fontawesome-webfont.woff2 new file mode 100644 index 0000000..4d13fc6 Binary files /dev/null and b/frontend/app/fonts/fontawesome-webfont.woff2 differ diff --git a/frontend/app/fonts/fontello.eot b/frontend/app/fonts/fontello.eot new file mode 100644 index 0000000..8efca38 Binary files /dev/null and b/frontend/app/fonts/fontello.eot differ diff --git a/frontend/app/fonts/fontello.svg b/frontend/app/fonts/fontello.svg new file mode 100644 index 0000000..8b819f8 --- /dev/null +++ b/frontend/app/fonts/fontello.svg @@ -0,0 +1,20 @@ + + + +Copyright (C) 2017 by original authors @ fontello.com + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/app/fonts/fontello.ttf b/frontend/app/fonts/fontello.ttf new file mode 100644 index 0000000..2598a04 Binary files /dev/null and b/frontend/app/fonts/fontello.ttf differ diff --git a/frontend/app/fonts/fontello.woff b/frontend/app/fonts/fontello.woff new file mode 100644 index 0000000..ac0d19e Binary files /dev/null and b/frontend/app/fonts/fontello.woff differ diff --git a/frontend/app/fonts/fontello.woff2 b/frontend/app/fonts/fontello.woff2 new file mode 100644 index 0000000..b8fb9bb Binary files /dev/null and b/frontend/app/fonts/fontello.woff2 differ diff --git a/frontend/app/i18n.js b/frontend/app/i18n.js new file mode 100644 index 0000000..665867b --- /dev/null +++ b/frontend/app/i18n.js @@ -0,0 +1,47 @@ +import i18n from 'i18next'; +// import XHR from 'i18next-xhr-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import resBundle from 'i18next-resource-store-loader!./locales/index.js'; +import { isDevelopment } from './common/constants'; + +/*function loadLocales (url, options, callback, data) { + try { + let waitForLocale = require('bundle!./locales/' + url + '.json'); + waitForLocale((locale) => { + callback(locale, {status: '200'}); + }) + } catch (e) { + callback(null, {status: '404'}); + } +}*/ + +i18n + // .use(XHR) + .use(LanguageDetector) + .init({ + fallbackLng: 'en', + ns: ['common'], + defaultNS: 'common', + + debug: isDevelopment, + + interpolation: { + escapeValue: false, // not needed for react!! + formatSeparator: ',', + format: (value, format, lng) => { + if (format === 'uppercase') return value.toUpperCase(); + return value; + } + }, + resources: resBundle, + /*backend: { + loadPath: '{{lng}}/{{ns}}', + parse: (data) => data, + ajax: loadLocales + },*/ + detection: { + order: ['localStorage', 'cookie', 'navigator', 'querystring', 'htmlTag'] + } + }); + +export default i18n; diff --git a/frontend/app/images/001-2000X1600.jpg b/frontend/app/images/001-2000X1600.jpg new file mode 100644 index 0000000..097d984 Binary files /dev/null and b/frontend/app/images/001-2000X1600.jpg differ diff --git a/frontend/app/images/002-2000X1600.jpg b/frontend/app/images/002-2000X1600.jpg new file mode 100644 index 0000000..ea594f7 Binary files /dev/null and b/frontend/app/images/002-2000X1600.jpg differ diff --git a/frontend/app/images/003-2000X1600.jpg b/frontend/app/images/003-2000X1600.jpg new file mode 100644 index 0000000..4b3a4d8 Binary files /dev/null and b/frontend/app/images/003-2000X1600.jpg differ diff --git a/frontend/app/images/004-2000X1600.jpg b/frontend/app/images/004-2000X1600.jpg new file mode 100644 index 0000000..683f0d4 Binary files /dev/null and b/frontend/app/images/004-2000X1600.jpg differ diff --git a/frontend/app/images/005-2000X1600.jpg b/frontend/app/images/005-2000X1600.jpg new file mode 100644 index 0000000..d0cea3e Binary files /dev/null and b/frontend/app/images/005-2000X1600.jpg differ diff --git a/frontend/app/images/006-2000X1600.jpg b/frontend/app/images/006-2000X1600.jpg new file mode 100644 index 0000000..ba27f3a Binary files /dev/null and b/frontend/app/images/006-2000X1600.jpg differ diff --git a/frontend/app/images/007-2000X1600.jpg b/frontend/app/images/007-2000X1600.jpg new file mode 100644 index 0000000..c31cb60 Binary files /dev/null and b/frontend/app/images/007-2000X1600.jpg differ diff --git a/frontend/app/images/008-2000X1600.jpg b/frontend/app/images/008-2000X1600.jpg new file mode 100644 index 0000000..2e0267c Binary files /dev/null and b/frontend/app/images/008-2000X1600.jpg differ diff --git a/frontend/app/images/009-2000X1600.jpg b/frontend/app/images/009-2000X1600.jpg new file mode 100644 index 0000000..57b43f4 Binary files /dev/null and b/frontend/app/images/009-2000X1600.jpg differ diff --git a/frontend/app/images/010-2000X1600.jpg b/frontend/app/images/010-2000X1600.jpg new file mode 100644 index 0000000..2f9dcae Binary files /dev/null and b/frontend/app/images/010-2000X1600.jpg differ diff --git a/frontend/app/images/011-2000X1600.jpg b/frontend/app/images/011-2000X1600.jpg new file mode 100644 index 0000000..cf81537 Binary files /dev/null and b/frontend/app/images/011-2000X1600.jpg differ diff --git a/frontend/app/images/012-2000X1600.jpg b/frontend/app/images/012-2000X1600.jpg new file mode 100644 index 0000000..b14932a Binary files /dev/null and b/frontend/app/images/012-2000X1600.jpg differ diff --git a/frontend/app/images/013-2000X1600.jpg b/frontend/app/images/013-2000X1600.jpg new file mode 100644 index 0000000..239d76e Binary files /dev/null and b/frontend/app/images/013-2000X1600.jpg differ diff --git a/frontend/app/images/014-2000X1600.jpg b/frontend/app/images/014-2000X1600.jpg new file mode 100644 index 0000000..1321356 Binary files /dev/null and b/frontend/app/images/014-2000X1600.jpg differ diff --git a/frontend/app/images/015-2000X1600.jpg b/frontend/app/images/015-2000X1600.jpg new file mode 100644 index 0000000..0c012e9 Binary files /dev/null and b/frontend/app/images/015-2000X1600.jpg differ diff --git a/frontend/app/images/016-2000X1600.jpg b/frontend/app/images/016-2000X1600.jpg new file mode 100644 index 0000000..b1daab6 Binary files /dev/null and b/frontend/app/images/016-2000X1600.jpg differ diff --git a/frontend/app/images/017-2000X1600.jpg b/frontend/app/images/017-2000X1600.jpg new file mode 100644 index 0000000..4990775 Binary files /dev/null and b/frontend/app/images/017-2000X1600.jpg differ diff --git a/frontend/app/images/018-2000X1600.jpg b/frontend/app/images/018-2000X1600.jpg new file mode 100644 index 0000000..fdb8485 Binary files /dev/null and b/frontend/app/images/018-2000X1600.jpg differ diff --git a/frontend/app/images/019-2000X1600.jpg b/frontend/app/images/019-2000X1600.jpg new file mode 100644 index 0000000..98cc0fd Binary files /dev/null and b/frontend/app/images/019-2000X1600.jpg differ diff --git a/frontend/app/images/abstract10.jpg b/frontend/app/images/abstract10.jpg new file mode 100644 index 0000000..04ef955 Binary files /dev/null and b/frontend/app/images/abstract10.jpg differ diff --git a/frontend/app/images/abstract5.jpg b/frontend/app/images/abstract5.jpg new file mode 100644 index 0000000..d4a1872 Binary files /dev/null and b/frontend/app/images/abstract5.jpg differ diff --git a/frontend/app/images/abstract8.jpg b/frontend/app/images/abstract8.jpg new file mode 100644 index 0000000..92a43b6 Binary files /dev/null and b/frontend/app/images/abstract8.jpg differ diff --git a/frontend/app/images/abstract9.jpg b/frontend/app/images/abstract9.jpg new file mode 100644 index 0000000..33c44f7 Binary files /dev/null and b/frontend/app/images/abstract9.jpg differ diff --git a/frontend/app/images/analyze-icon.png b/frontend/app/images/analyze-icon.png new file mode 100644 index 0000000..cc6ff45 Binary files /dev/null and b/frontend/app/images/analyze-icon.png differ diff --git a/frontend/app/images/analyze.png b/frontend/app/images/analyze.png new file mode 100644 index 0000000..9af524b Binary files /dev/null and b/frontend/app/images/analyze.png differ diff --git a/frontend/app/images/avatar-small.jpg b/frontend/app/images/avatar-small.jpg new file mode 100644 index 0000000..d7d709e Binary files /dev/null and b/frontend/app/images/avatar-small.jpg differ diff --git a/frontend/app/images/bg-images/004.jpg b/frontend/app/images/bg-images/004.jpg new file mode 100644 index 0000000..fc239e0 Binary files /dev/null and b/frontend/app/images/bg-images/004.jpg differ diff --git a/frontend/app/images/bg-images/007.jpg b/frontend/app/images/bg-images/007.jpg new file mode 100644 index 0000000..5484bb8 Binary files /dev/null and b/frontend/app/images/bg-images/007.jpg differ diff --git a/frontend/app/images/bg-images/012.jpg b/frontend/app/images/bg-images/012.jpg new file mode 100644 index 0000000..904856b Binary files /dev/null and b/frontend/app/images/bg-images/012.jpg differ diff --git a/frontend/app/images/chart_line.png b/frontend/app/images/chart_line.png new file mode 100644 index 0000000..b639ad8 Binary files /dev/null and b/frontend/app/images/chart_line.png differ diff --git a/frontend/app/images/city copy 2.jpg b/frontend/app/images/city copy 2.jpg new file mode 100644 index 0000000..c19888d Binary files /dev/null and b/frontend/app/images/city copy 2.jpg differ diff --git a/frontend/app/images/city1.jpg b/frontend/app/images/city1.jpg new file mode 100644 index 0000000..c1e068e Binary files /dev/null and b/frontend/app/images/city1.jpg differ diff --git a/frontend/app/images/city3.jpg b/frontend/app/images/city3.jpg new file mode 100644 index 0000000..a51670b Binary files /dev/null and b/frontend/app/images/city3.jpg differ diff --git a/frontend/app/images/city_copy.jpg b/frontend/app/images/city_copy.jpg new file mode 100644 index 0000000..c19888d Binary files /dev/null and b/frontend/app/images/city_copy.jpg differ diff --git a/frontend/app/images/citydark_copy.jpg b/frontend/app/images/citydark_copy.jpg new file mode 100644 index 0000000..40ab8f2 Binary files /dev/null and b/frontend/app/images/citydark_copy.jpg differ diff --git a/frontend/app/images/citynights_copy.jpg b/frontend/app/images/citynights_copy.jpg new file mode 100644 index 0000000..4f5b5fd Binary files /dev/null and b/frontend/app/images/citynights_copy.jpg differ diff --git a/frontend/app/images/close.png b/frontend/app/images/close.png new file mode 100644 index 0000000..7e88ed0 Binary files /dev/null and b/frontend/app/images/close.png differ diff --git a/frontend/app/images/dashboard-icon.png b/frontend/app/images/dashboard-icon.png new file mode 100644 index 0000000..e0e9a92 Binary files /dev/null and b/frontend/app/images/dashboard-icon.png differ diff --git a/frontend/app/images/dashboard.png b/frontend/app/images/dashboard.png new file mode 100644 index 0000000..0500851 Binary files /dev/null and b/frontend/app/images/dashboard.png differ diff --git a/frontend/app/images/feed-type-blogs.png b/frontend/app/images/feed-type-blogs.png new file mode 100644 index 0000000..77bb146 Binary files /dev/null and b/frontend/app/images/feed-type-blogs.png differ diff --git a/frontend/app/images/feed-type-clippings.png b/frontend/app/images/feed-type-clippings.png new file mode 100644 index 0000000..c6d4bd9 Binary files /dev/null and b/frontend/app/images/feed-type-clippings.png differ diff --git a/frontend/app/images/feed-type-forums.png b/frontend/app/images/feed-type-forums.png new file mode 100644 index 0000000..0df024b Binary files /dev/null and b/frontend/app/images/feed-type-forums.png differ diff --git a/frontend/app/images/feed-type-mixed.png b/frontend/app/images/feed-type-mixed.png new file mode 100644 index 0000000..876685d Binary files /dev/null and b/frontend/app/images/feed-type-mixed.png differ diff --git a/frontend/app/images/feed-type-news.png b/frontend/app/images/feed-type-news.png new file mode 100644 index 0000000..b591437 Binary files /dev/null and b/frontend/app/images/feed-type-news.png differ diff --git a/frontend/app/images/feed-type-prints.png b/frontend/app/images/feed-type-prints.png new file mode 100644 index 0000000..525f858 Binary files /dev/null and b/frontend/app/images/feed-type-prints.png differ diff --git a/frontend/app/images/feed-type-socials.png b/frontend/app/images/feed-type-socials.png new file mode 100644 index 0000000..9586536 Binary files /dev/null and b/frontend/app/images/feed-type-socials.png differ diff --git a/frontend/app/images/feed-type-user-added.png b/frontend/app/images/feed-type-user-added.png new file mode 100644 index 0000000..855cc63 Binary files /dev/null and b/frontend/app/images/feed-type-user-added.png differ diff --git a/frontend/app/images/feed-type-user-comments.png b/frontend/app/images/feed-type-user-comments.png new file mode 100644 index 0000000..83c8c3f Binary files /dev/null and b/frontend/app/images/feed-type-user-comments.png differ diff --git a/frontend/app/images/feed-type-videos.png b/frontend/app/images/feed-type-videos.png new file mode 100644 index 0000000..fdb13f1 Binary files /dev/null and b/frontend/app/images/feed-type-videos.png differ diff --git a/frontend/app/images/globe-icon.svg b/frontend/app/images/globe-icon.svg new file mode 100644 index 0000000..fd97b1a --- /dev/null +++ b/frontend/app/images/globe-icon.svg @@ -0,0 +1,10 @@ + + globe-icon + + + + + + + + diff --git a/frontend/app/images/glyph_16px_design-development@1x.png b/frontend/app/images/glyph_16px_design-development@1x.png new file mode 100644 index 0000000..1edbc0e Binary files /dev/null and b/frontend/app/images/glyph_16px_design-development@1x.png differ diff --git a/frontend/app/images/loader.gif b/frontend/app/images/loader.gif new file mode 100644 index 0000000..d101a1d Binary files /dev/null and b/frontend/app/images/loader.gif differ diff --git a/frontend/app/images/loadingspinner.gif b/frontend/app/images/loadingspinner.gif new file mode 100644 index 0000000..a62861c Binary files /dev/null and b/frontend/app/images/loadingspinner.gif differ diff --git a/frontend/app/images/login-bg.png b/frontend/app/images/login-bg.png new file mode 100644 index 0000000..0c26303 Binary files /dev/null and b/frontend/app/images/login-bg.png differ diff --git a/frontend/app/images/logo-device.png b/frontend/app/images/logo-device.png new file mode 100644 index 0000000..c8b0c1c Binary files /dev/null and b/frontend/app/images/logo-device.png differ diff --git a/frontend/app/images/logo/apple-touch-icon.png b/frontend/app/images/logo/apple-touch-icon.png new file mode 100644 index 0000000..ca3cebf Binary files /dev/null and b/frontend/app/images/logo/apple-touch-icon.png differ diff --git a/frontend/app/images/logo/favicon.ico b/frontend/app/images/logo/favicon.ico new file mode 100644 index 0000000..ed134cc Binary files /dev/null and b/frontend/app/images/logo/favicon.ico differ diff --git a/frontend/app/images/logo/logo-small.png b/frontend/app/images/logo/logo-small.png new file mode 100644 index 0000000..d4285c1 Binary files /dev/null and b/frontend/app/images/logo/logo-small.png differ diff --git a/frontend/app/images/logo/logo-square-small.png b/frontend/app/images/logo/logo-square-small.png new file mode 100644 index 0000000..e1a53e4 Binary files /dev/null and b/frontend/app/images/logo/logo-square-small.png differ diff --git a/frontend/app/images/logo/logo-square.png b/frontend/app/images/logo/logo-square.png new file mode 100644 index 0000000..fb5e644 Binary files /dev/null and b/frontend/app/images/logo/logo-square.png differ diff --git a/frontend/app/images/logo/logo.png b/frontend/app/images/logo/logo.png new file mode 100644 index 0000000..374a7bc Binary files /dev/null and b/frontend/app/images/logo/logo.png differ diff --git a/frontend/app/images/main-nav-left-border.png b/frontend/app/images/main-nav-left-border.png new file mode 100644 index 0000000..6a36590 Binary files /dev/null and b/frontend/app/images/main-nav-left-border.png differ diff --git a/frontend/app/images/marketing-image-world.jpg b/frontend/app/images/marketing-image-world.jpg new file mode 100644 index 0000000..68c4327 Binary files /dev/null and b/frontend/app/images/marketing-image-world.jpg differ diff --git a/frontend/app/images/minus.png b/frontend/app/images/minus.png new file mode 100644 index 0000000..e92a5ae Binary files /dev/null and b/frontend/app/images/minus.png differ diff --git a/frontend/app/images/minus2.png b/frontend/app/images/minus2.png new file mode 100644 index 0000000..037fe9d Binary files /dev/null and b/frontend/app/images/minus2.png differ diff --git a/frontend/app/images/news-icon.svg b/frontend/app/images/news-icon.svg new file mode 100644 index 0000000..fe44750 --- /dev/null +++ b/frontend/app/images/news-icon.svg @@ -0,0 +1,10 @@ + + news-icon + + + + + + + + diff --git a/frontend/app/images/plans-bg.png b/frontend/app/images/plans-bg.png new file mode 100644 index 0000000..71dcbb1 Binary files /dev/null and b/frontend/app/images/plans-bg.png differ diff --git a/frontend/app/images/plus.png b/frontend/app/images/plus.png new file mode 100644 index 0000000..4614a54 Binary files /dev/null and b/frontend/app/images/plus.png differ diff --git a/frontend/app/images/plus2.png b/frontend/app/images/plus2.png new file mode 100644 index 0000000..39bbdfd Binary files /dev/null and b/frontend/app/images/plus2.png differ diff --git a/frontend/app/images/profile.png b/frontend/app/images/profile.png new file mode 100644 index 0000000..f57cea1 Binary files /dev/null and b/frontend/app/images/profile.png differ diff --git a/frontend/app/images/register-bg.png b/frontend/app/images/register-bg.png new file mode 100644 index 0000000..df60b73 Binary files /dev/null and b/frontend/app/images/register-bg.png differ diff --git a/frontend/app/images/search-icon.png b/frontend/app/images/search-icon.png new file mode 100644 index 0000000..afaad8f Binary files /dev/null and b/frontend/app/images/search-icon.png differ diff --git a/frontend/app/images/search.png b/frontend/app/images/search.png new file mode 100644 index 0000000..3f93f22 Binary files /dev/null and b/frontend/app/images/search.png differ diff --git a/frontend/app/images/share-icon.png b/frontend/app/images/share-icon.png new file mode 100644 index 0000000..db37654 Binary files /dev/null and b/frontend/app/images/share-icon.png differ diff --git a/frontend/app/images/share.png b/frontend/app/images/share.png new file mode 100644 index 0000000..7d54733 Binary files /dev/null and b/frontend/app/images/share.png differ diff --git a/frontend/app/images/social-icons/Amazon.png b/frontend/app/images/social-icons/Amazon.png new file mode 100644 index 0000000..4db2199 Binary files /dev/null and b/frontend/app/images/social-icons/Amazon.png differ diff --git a/frontend/app/images/social-icons/Basecamp.png b/frontend/app/images/social-icons/Basecamp.png new file mode 100644 index 0000000..6823250 Binary files /dev/null and b/frontend/app/images/social-icons/Basecamp.png differ diff --git a/frontend/app/images/social-icons/Behance.png b/frontend/app/images/social-icons/Behance.png new file mode 100644 index 0000000..9540691 Binary files /dev/null and b/frontend/app/images/social-icons/Behance.png differ diff --git a/frontend/app/images/social-icons/Blogger.png b/frontend/app/images/social-icons/Blogger.png new file mode 100644 index 0000000..6f771f1 Binary files /dev/null and b/frontend/app/images/social-icons/Blogger.png differ diff --git a/frontend/app/images/social-icons/DeviantArt.png b/frontend/app/images/social-icons/DeviantArt.png new file mode 100644 index 0000000..5950dd6 Binary files /dev/null and b/frontend/app/images/social-icons/DeviantArt.png differ diff --git a/frontend/app/images/social-icons/Dribbble.png b/frontend/app/images/social-icons/Dribbble.png new file mode 100644 index 0000000..04ee2b6 Binary files /dev/null and b/frontend/app/images/social-icons/Dribbble.png differ diff --git a/frontend/app/images/social-icons/Dropbox.png b/frontend/app/images/social-icons/Dropbox.png new file mode 100644 index 0000000..200c31a Binary files /dev/null and b/frontend/app/images/social-icons/Dropbox.png differ diff --git a/frontend/app/images/social-icons/Evernote.png b/frontend/app/images/social-icons/Evernote.png new file mode 100644 index 0000000..75434c8 Binary files /dev/null and b/frontend/app/images/social-icons/Evernote.png differ diff --git a/frontend/app/images/social-icons/Facebook.png b/frontend/app/images/social-icons/Facebook.png new file mode 100644 index 0000000..c49e502 Binary files /dev/null and b/frontend/app/images/social-icons/Facebook.png differ diff --git a/frontend/app/images/social-icons/Flickr.png b/frontend/app/images/social-icons/Flickr.png new file mode 100644 index 0000000..c508c30 Binary files /dev/null and b/frontend/app/images/social-icons/Flickr.png differ diff --git a/frontend/app/images/social-icons/Forrst.png b/frontend/app/images/social-icons/Forrst.png new file mode 100644 index 0000000..6ac972a Binary files /dev/null and b/frontend/app/images/social-icons/Forrst.png differ diff --git a/frontend/app/images/social-icons/GitHub.png b/frontend/app/images/social-icons/GitHub.png new file mode 100644 index 0000000..cf4670c Binary files /dev/null and b/frontend/app/images/social-icons/GitHub.png differ diff --git a/frontend/app/images/social-icons/GooglePlus.png b/frontend/app/images/social-icons/GooglePlus.png new file mode 100644 index 0000000..b951312 Binary files /dev/null and b/frontend/app/images/social-icons/GooglePlus.png differ diff --git a/frontend/app/images/social-icons/Instagram.png b/frontend/app/images/social-icons/Instagram.png new file mode 100644 index 0000000..3ef2fe2 Binary files /dev/null and b/frontend/app/images/social-icons/Instagram.png differ diff --git a/frontend/app/images/social-icons/LastFM.png b/frontend/app/images/social-icons/LastFM.png new file mode 100644 index 0000000..731eb2c Binary files /dev/null and b/frontend/app/images/social-icons/LastFM.png differ diff --git a/frontend/app/images/social-icons/LinkedIn.png b/frontend/app/images/social-icons/LinkedIn.png new file mode 100644 index 0000000..3f61cdc Binary files /dev/null and b/frontend/app/images/social-icons/LinkedIn.png differ diff --git a/frontend/app/images/social-icons/Picasa.png b/frontend/app/images/social-icons/Picasa.png new file mode 100644 index 0000000..e5523a5 Binary files /dev/null and b/frontend/app/images/social-icons/Picasa.png differ diff --git a/frontend/app/images/social-icons/Pinterest.png b/frontend/app/images/social-icons/Pinterest.png new file mode 100644 index 0000000..d82f953 Binary files /dev/null and b/frontend/app/images/social-icons/Pinterest.png differ diff --git a/frontend/app/images/social-icons/Reddit.png b/frontend/app/images/social-icons/Reddit.png new file mode 100644 index 0000000..05530db Binary files /dev/null and b/frontend/app/images/social-icons/Reddit.png differ diff --git a/frontend/app/images/social-icons/Rss.png b/frontend/app/images/social-icons/Rss.png new file mode 100644 index 0000000..6c8904b Binary files /dev/null and b/frontend/app/images/social-icons/Rss.png differ diff --git a/frontend/app/images/social-icons/ShareThis.png b/frontend/app/images/social-icons/ShareThis.png new file mode 100644 index 0000000..16a3739 Binary files /dev/null and b/frontend/app/images/social-icons/ShareThis.png differ diff --git a/frontend/app/images/social-icons/Skype.png b/frontend/app/images/social-icons/Skype.png new file mode 100644 index 0000000..fffd7ed Binary files /dev/null and b/frontend/app/images/social-icons/Skype.png differ diff --git a/frontend/app/images/social-icons/StumbleUpon.png b/frontend/app/images/social-icons/StumbleUpon.png new file mode 100644 index 0000000..8eacd43 Binary files /dev/null and b/frontend/app/images/social-icons/StumbleUpon.png differ diff --git a/frontend/app/images/social-icons/Tumblr.png b/frontend/app/images/social-icons/Tumblr.png new file mode 100644 index 0000000..d520443 Binary files /dev/null and b/frontend/app/images/social-icons/Tumblr.png differ diff --git a/frontend/app/images/social-icons/Twitter.png b/frontend/app/images/social-icons/Twitter.png new file mode 100644 index 0000000..9f498c7 Binary files /dev/null and b/frontend/app/images/social-icons/Twitter.png differ diff --git a/frontend/app/images/social-icons/Vimeo.png b/frontend/app/images/social-icons/Vimeo.png new file mode 100644 index 0000000..6b8e483 Binary files /dev/null and b/frontend/app/images/social-icons/Vimeo.png differ diff --git a/frontend/app/images/social-icons/Vine.png b/frontend/app/images/social-icons/Vine.png new file mode 100644 index 0000000..7d9702f Binary files /dev/null and b/frontend/app/images/social-icons/Vine.png differ diff --git a/frontend/app/images/social-icons/YouTube.png b/frontend/app/images/social-icons/YouTube.png new file mode 100644 index 0000000..8916bbd Binary files /dev/null and b/frontend/app/images/social-icons/YouTube.png differ diff --git a/frontend/app/images/source.png b/frontend/app/images/source.png new file mode 100644 index 0000000..47e5962 Binary files /dev/null and b/frontend/app/images/source.png differ diff --git a/frontend/app/index.html b/frontend/app/index.html new file mode 100644 index 0000000..0631e11 --- /dev/null +++ b/frontend/app/index.html @@ -0,0 +1,24 @@ + + + + + Social Listening Platform | Social Analytics | SOCIALHOSE.IO App + + + + + + + +
    + + + + diff --git a/frontend/app/locales/ar/common.json b/frontend/app/locales/ar/common.json new file mode 100644 index 0000000..7abf618 --- /dev/null +++ b/frontend/app/locales/ar/common.json @@ -0,0 +1,575 @@ +{ + "commonWords": { + "Or": "أو", + "or": "أو", + "Confirm": "تأكيد", + "confirm": "تأكيد", + "Delete": "مسح", + "delete": "مسح", + "Cancel": "إلغاء", + "submit": "أرسل", + "cancel": "إلغاء", + "Yes": "نعم", + "yes": "نعم", + "No": "لا", + "no": "لا", + "Rename": "إعادة تسمية", + "rename": "إعادة تسمية", + "loading": "جارٍ التحميل" + }, + "messages": { + "deleteMessage": "احذف الرسالة", + "noResults": "لا توجد نتائج", + "noRows": "لا توجد صفوف", + "selectMsg": "اختر الرسالة {{title}}", + "inputMsg": "ادخل الرسالة {{title}}", + "invalidMsg": "ادخل رسالة صالحة {{title}}", + "dropdownValue0": "اختر {{title}}" + }, + "tabs": { + "search": "ابحث", + "analyze": "حلل", + "share": "شارك", + "dashboard": "لوحة المعلومات", + "sourceIndex": "فهرس المصدر", + "sourceLists": "قوائم المصادر", + "welcome": "مرحباً بك", + "createAnalysis": "انشئ تحليلاً", + "savedAnalysis": "التحليلات المحفوظة", + "notifications": "الإشعارات", + "manageRecipients": "إدارة المستقبلين", + "manageEmails": "إدارة قائمة البريد الالكتروينة", + "export": "تصدير" + }, + "plans": { + "currentPlan": "الباقة الحالية", + "freeBasicAccount": "حساب أساسي مجاني", + "perMonth": "شهريا", + "activePlanDetails": "تفاصيل الباقة الحالية", + "upgradePlan": "تحديث الباقة", + "yourTransactions": "معاملاتك", + "changeCard": "تغيير البطاقة" + }, + "whatsNew": { + "label": "ما الجديد" + }, + "langs": { + "chooseLanguage": "اختر اللغة", + "ar": "العربية", + "en": "الإنجليزية", + "es": "الإسبانية", + "de": "الألمانية", + "fr": "الفرنسية", + "he": "العبرية", + "nl": "الهولندية", + "pt": "البرتغالية" + }, + "userSettings": { + "settings": "الضبط", + "help": "مساعدة", + "signOut": "تسجيل الخروج", + "changePassword": "تغيير كلمة المرور", + "enterRequiredFields": "ادخل كلمة المرور القديمة", + "passwordsNotMatched": "ادخل كلمة المرور الجديدة ", + "enterOldPassword": "أعد إدخال كلمة المرور الجديدة", + "enterNewPassword": "ادخل الحقول المطلوبة", + "retypeNewPassword": "كلمات المرور غير متطابقة", + "notifications": "الإشعارات", + "notificationsSub": "لديك {{alertLength}} إشعار", + "notificationsSub_plural": "لديك {{alertLength}} إشعارات", + "clearAll": "احذف الكل", + "guidedTourTooltip": "جولة إرشادية", + "userGuide": "دليل المستخدم", + "HowToSearch": "كيفية البحث", + "HowToAnalyze": "كيفية التحليل" + }, + "sidebar": { + "My Hose": "الخرطوم", + "Shared Hose": "مشترك", + "Deleted Hose": "محذوف", + "typeToSearch": "اكتب للبحث" + }, + "sidebarDropdown": { + "AddClippingsFeed": "أضف الموجزات المقتطفة", + "AddFolder": "أضف ملف", + "DownloadSearchCriteria": "تنزيل معايير البحث", + "EditSearchTemplate": "تعديل قالب البحث", + "ViewUserComments": "عرض تعليقات المستخدم", + "RenameFolder": "إعادة تسمية الملف", + "DeleteFolder": "امسح الملف", + "AddArticle": "أضف مقالاً", + "AddToDashboard": "أضف إلى لوحة المعلومات", + "AnalyzeFeed": "تحليل الموجز", + "DownloadArticleData": "تنزيل بيانات المنشور", + "DownloadFeedStatistics": "تنزيل إحصاءات الموجزات", + "ExportFeed": "تصدير الموجز", + "ExportFeeds": "تصدير الموجزات", + "UnexportFeed": "إلغاء تصدير الموجز", + "UnexportFeeds": "إلغاء تصدير الموجزات", + "RenameFeed": "إعادة تسمية الموجز", + "DeleteFeed": "احذف الموجز" + }, + "sidebarPopup": { + "areYouSure": "هل أنت متأكد؟", + "enterNamelabel": "أضف اسم العلامة", + "enterFolderName": "أضف اسم الملف", + "addFolderBtn": "إضافة زر الملف", + "addClippingsFeed": "أضف الموجزات المقتطفة", + "feedName": "اسم الموجز", + "folder": "ملف" + }, + "alerts": { + "type": { + "success": "نجاح", + "warning": "تحذير", + "error": "خطأ" + }, + "notice": { + "renameFeedNotice": "إعادة تسمية ملاحظةالموجز", + "renameFolderNotice": "إعادة ملاحظة الملف", + "noListsSelected": "لم يتم اختيار قوائم", + "updateListsForSourceNotice": "حدث القوائم لملاحظة المصدر", + "deleteSourceList": "احذف قائمة المصادر", + "addSourceList": "أضف قائمة المصادر", + "renameSourceList": "إعادة تسمية قائمة المصادر", + "cloneSourceList": "نسخ قائمة المصادر", + "shareSourceList": "مشاركة قائمة المصادر", + "unshareSourceList": "إلغاء مشاركة قائمة المصادر", + "saveFeed": "حفظ الموجز", + "clipDocument": "ملف قصاصة", + "alertSaved": "تم حفظ التنبيه", + "recipientSaved": "تم حفظ المستلم", + "groupSaved": "تم حفظ المجموعة", + "articleDeleted": "تم مسح المنشور", + "analyticsDeleted": "تم حذف التحليلات", + "planUpdated": "تم تحديث الباقة", + "cardUpdated": "تم تحديث البطاقة", + "cancelledSubscription": "إشتراك ملغي" + }, + "error": { + "renameFolderEmpty": "أعد التسمية، الملف فارغ", + "updateCategoryNameNotUnique": "قم بتحديث الفئة الاسم ليس فريداً", + "createSourceListNameNotUnique": "انشئ قائمة مصادر الاسم ليس فريداً", + "searchQueryEmpty": "استعلام البحث فارغ", + "createFeedQueryEmpty": "انشئ استعلام الموجز فارغ", + "unknown": "غير معروف", + "cannotUnsubscribe": "لا يمكن الاشتراك", + "restriction": "تقييد", + "noMediaTypesSelected": "لم يتم اختيار نوع وسائط", + "groupNameEmpty": "اسم المجموعة فارغ", + "recipientNamesEmpty": "أسماء المستلمين فارغة", + "createUserEmailNotUnique": "انشئ مستخدم الإيميل ليس فريداً", + "feedNameEmpty": "اسم الموجز فارغ", + "requiredInfo": "المعلومات المطلوبة", + "somethingWrong": "شئ ما خطأ", + "somethingWrong2": "عذرا، هناك خطأ ما. يرجى المحاولة مرة أخرى في وقت لاحق." + } + }, + "language": { + "all": "الكل", + "af": "الأفريكانية", + "sq": "الألبانية", + "ar": "العربية", + "bn": "البنغالية", + "bs": "البوسنية", + "bg": "البلغارية", + "ca": "الكاتالونية", + "zh": "الصينية", + "hr": "الكرواتية", + "cs": "التشيكية", + "da": "دانماركي", + "nl": " الهولندية", + "en": "الإنجليزية", + "et": "الإستونية", + "tl": "تاغالوغ", + "fi": "الفنلندية", + "fr": "الفرنسية", + "de": "الألمانية", + "el": "اليونانية", + "gu": "الغوجاراتية", + "he": "العبرية", + "hi": "الهندية", + "hu": "المجرية", + "is": "الأيسلندية", + "id": "الأندونيسية", + "it": "الإيطالية", + "ja": "اليابانية", + "ko": "الكورية", + "lv": "اللاتفيا", + "lt": "الليتوانية", + "mk": "المقدونية", + "ms": "الملايو", + "no": "النرويجية", + "fa": "الفارسية", + "pl": "البولندية", + "pt": "البرتغالية", + "ro": "الرومانية", + "ru": "الروسية", + "sr": "الصربية", + "sk": "السلوفاكية", + "sl": "السلوفينية", + "es": "الأسبانية", + "sv": "السويدية", + "ta": "التاميل", + "th": "التايلاندية", + "tr": "التركية", + "uk": "الأوكرانية", + "ur": "الأردية", + "vi": "الفيتنامية", + "und": "غير معرف", + "U": "مجهول" + }, + "country": { + "AD": "أندورا", + "AE": "الإمارات العربية المتحدة", + "AF": "أفغانستان", + "AG": "أنتيغوا وبربودا", + "AI": "أنغيلا", + "AL": "ألبانيا", + "AM": "أرمينيا", + "AO": "أنغولا", + "AQ": "أنتاركتيكا", + "AR": "الأرجنتين", + "AS": "ساموا الأمريكية", + "AT": "النمسا", + "AU": "أستراليا", + "AW": "أروبا", + "AX": "جزر آلاند", + "AZ": "أذربيجان", + "BA": "البوسنة والهرسك", + "BB": "بربادوس", + "BD": "بنغلاديش", + "BE": "بلجيكا", + "BF": "بوركينا فاسو", + "BG": "بلغاريا", + "BH": "البحرين", + "BI": "بوروندي", + "BJ": "بنين", + "BL": "سانت بارتيليمي", + "BM": "برمودا", + "BN": "بروناي", + "BO": "بوليفيا", + "BQ": "بونير", + "BR": "البرازيل", + "BS": "جزر البهاما", + "BT": "بوتان", + "BV": "جزيرة بوفيت", + "BW": "بوتسوانا", + "BY": "بيلاروسيا", + "BZ": "بليز", + "CA": "كندا", + "CC": "جزر كوكوس", + "CD": "جمهورية الكونغو الديمقراطية", + "CF": "جمهورية افريقيا الوسطى", + "CG": "الكونغو", + "CH": "سويسرا", + "CI": "ساحل العاج", + "CK": "جزر كوك", + "CL": "تشيلي", + "CM": "الكاميرون", + "CN": "الصين", + "CO": "كولومبيا", + "CR": "كوستا ريكا", + "CU": "كوبا", + "CV": "كابو فيردي", + "CW": "كوراساو", + "CX": "جزيرة الكريسماس", + "CY": "قبرص", + "CZ": "التشيك", + "DE": "ألمانيا", + "DJ": "جيبوتي", + "DK": "الدنمارك", + "DM": "دومينيكا", + "DO": "جمهورية الدومنيكان", + "DZ": "الجزائر", + "EC": "الاكوادور", + "EE": "إستونيا", + "EG": "مصر", + "EH": "الصحراء الغربية", + "ER": "إريتريا", + "ES": "إسبانيا", + "ET": "أثيوبيا", + "FI": "فنلندا", + "FJ": "فيجي", + "FK": "جزر فوكلاند", + "FM": "ميكرونيزيا", + "FO": "جزر فاروس", + "FR": "فرنسا", + "GA": "الجابون", + "GB": "المملكة المتحدة", + "GD": "غرينادا", + "GE": "جورجيا", + "GF": "غيانا الفرنسية", + "GG": "غيرنسي", + "GH": "غانا", + "GI": "جبل طارق", + "GL": "الأرض الخضراء", + "GM": "غامبيا", + "GN": "غينيا", + "GP": "جوادلوب", + "GQ": "غينيا الإستوائية", + "GR": "اليونان", + "GS": "جورجيا الجنوبية وجزر ساندويتش الجنوبية", + "GT": "غواتيمالا", + "GU": "غوام", + "GW": "غينيا بيساو", + "GY": "غيانا", + "HK": "هونج كونج", + "HM": "قلب الجزيرة وجزر ماكدونالز", + "HN": "هندوراس", + "HR": "كرواتيا", + "HT": "هايتي", + "HU": "هنغاريا", + "ID": "إندونيسيا", + "IE": "أيرلندا", + "IL": "إسرائيل", + "IM": "جزيرة آيل أوف مان", + "IN": "الهند", + "IO": "إقليم المحيط البريطاني الهندي", + "IQ": "العراق", + "IR": "إيران", + "IS": "أيسلندا", + "IT": "إيطاليا", + "JE": "جيرسي", + "JM": "جامايكا", + "JO": "الأردن", + "JP": "اليابان", + "KE": "كينيا", + "KG": "قيرغيزستان", + "KH": "كمبوديا", + "KI": "كيريباتي", + "KM": "جزر القمر", + "KN": "سانت كيتس ونيفيس", + "KP": "كوريا الشمالية", + "KR": "كوريا الجنوبية", + "KW": "الكويت", + "KY": "جزر كايمان", + "KZ": "كازاخستان", + "LA": "جمهورية لاو الديمقراطية الشعبية", + "LB": "لبنان", + "LC": "القديسة لوسيا", + "LI": "ليختنشتاين", + "LK": "سيريلانكا", + "LR": "ليبيريا", + "LS": "ليسوتو", + "LT": "ليتوانيا", + "LU": "لوكسمبورغ", + "LV": "لاتفيا", + "LY": "ليبيا", + "MA": "المغرب", + "MC": "موناكو", + "MD": "مولدوفا", + "ME": "الجبل الأسود", + "MF": "سانت مارتن", + "MG": "مدغشقر", + "MH": "جزر مارشال", + "MK": "مقدونيا", + "ML": "مالي", + "MM": "ميانمار", + "MN": "منغوليا", + "MO": "ماكاو", + "MP": "جزر مريانا الشمالية", + "MQ": "مارتينيك", + "MR": "موريتانيا", + "MS": "مونتسيرات", + "MT": "مالطا", + "MU": "موريشيوس", + "MV": "جزر المالديف", + "MW": "ملاوي", + "MX": "المكسيك", + "MY": "ماليزيا", + "MZ": "موزمبيق", + "NA": "ناميبيا", + "NC": "كاليدونيا الجديدة", + "NE": "النيجر", + "NF": "جزيرة نورفولك", + "NG": "نيجيريا", + "NI": "نيكاراغوا", + "NL": "هولندا", + "NO": "النرويج", + "NP": "نيبال", + "NR": "ناورو", + "NU": "نيوي", + "NZ": "نيوزيلندا", + "OM": "سلطنة عمان", + "PA": "بنما", + "PE": "بيرو", + "PF": "بولينيزيا الفرنسية", + "PG": "بابوا غينيا الجديدة", + "PH": "الفلبين", + "PK": "باكستان", + "PL": "بولندا", + "PM": "سانت بيير وميكلون", + "PN": "بيتكيرن", + "PR": "بورتوريكو", + "PS": "فلسطين", + "PT": "البرتغال", + "PW": "بالاو", + "PY": "باراغواي", + "QA": "دولة قطر", + "RE": "جمع شمل", + "RO": "رومانيا", + "RS": "صربيا", + "RU": "الاتحاد الروسي", + "RW": "رواندا", + "SA": "المملكة العربية السعودية", + "SB": "جزر سليمان", + "SC": "سيشيل", + "SD": "السودان", + "SE": "السويد", + "SG": "سنغافورة", + "SH": "سانت هيلانة وأسنسيون وتريستان دا كونها", + "SI": "سلوفينيا", + "SJ": "سفالبارد وجان ماين", + "SK": "سلوفاكيا", + "SL": "سيرا ليون", + "SM": "سان مارينو", + "SN": "السنغال", + "SO": "الصومال", + "SR": "سورينام", + "SS": "جنوب السودان", + "ST": "ساو تومي وبرينسيبي", + "SV": "السلفادور", + "SX": "سينت مارتن (الجزء الهولندي)", + "SY": "الجمهورية العربية السورية", + "SZ": "سوازيلاند", + "TC": "جزر تركس وكايكوس", + "TD": "تشاد", + "TF": "المناطق الجنوبية لفرنسا", + "TG": "توجو", + "TH": "تايلاند", + "TJ": "طاجيكستان", + "TK": "توكيلاو", + "TL": "تيمور ليشتي", + "TM": "تركمانستان", + "TN": "تونس", + "TO": "تونغا", + "TR": "تركيا", + "TT": "ترينداد وتوباغو", + "TV": "توفالو", + "TW": "تايوان", + "TZ": "تنزانيا", + "UA": "أوكرانيا", + "UG": "أوغندا", + "UM": "جزر الولايات المتحدة البعيدة الصغرى", + "US": "الولايات المتحدة", + "UY": "أوروغواي", + "UZ": "أوزبكستان", + "VA": "الفاتيكان", + "VC": "سانت فنسنت وجزر غرينادين", + "VE": "فنزويلا", + "VG": "جزر فيرجن البريطانية", + "VI": "جزر فيرجن الأمريكية", + "VN": "فيتنام", + "VU": "فانواتو", + "WF": "واليس وفوتونا", + "WS": "ساموا", + "YE": "اليمن", + "YT": "مايوت", + "ZA": "جنوب أفريقيا", + "ZM": "زامبيا", + "ZW": "زيمبابوي" + }, + "state": { + "AL": "ألاباما", + "AK": "ألاسكا", + "AZ": "أريزونا", + "AR": "أركنساس", + "CA": "كاليفورنيا", + "CO": "كولورادو", + "CT": "كونيتيكت", + "DE": "ديلاوير", + "DC": "مقاطعة كولومبيا", + "FL": "فلوريدا", + "GA": "جورجيا", + "HI": "هاواي", + "ID": "ايداهو", + "IL": "إلينوي", + "IN": "إنديانا", + "IA": "ايوا", + "KS": "كانساس", + "KY": "كنتاكي", + "LA": "لويزيانا", + "ME": "مين", + "MD": "ماريلاند", + "MA": "ماساتشوستس", + "MI": "ميشيغان", + "MN": "مينيسوتا", + "MS": "ميسيسيبي", + "MO": "ميسوري", + "MT": "مونتانا", + "NE": "نبراسكا", + "NV": "نيفادا", + "NH": "نيو هامبشاير", + "NJ": "نيو جيرسي", + "NM": "نيو مكسيكو", + "NY": "نيويورك", + "NC": "شمال كارولينا", + "ND": "شمال داكوتا", + "OH": "أوهايو", + "OK": "أوكلاهوما", + "OR": "أوريغون", + "PA": "بنسلفانيا", + "RI": "جزيرة رود", + "SC": "كارولينا الجنوبية", + "SD": "جنوب داكوتا", + "TN": "تينيسي", + "TX": "تكساس", + "UT": "يوتا", + "VT": "فيرمونت", + "VA": "فرجينيا", + "WA": "واشنطن", + "WV": "فرجينيا الغربية", + "WI": "ويسكونسن", + "WY": "وايومنغ" + }, + "sentiment": { + "POSITIVE": "إيجابي", + "NEUTRAL": "محايد", + "NEGATIVE": "سلبي" + }, + "articleDate": { + "15 Minutes": "15 دقيقة", + "30 Minutes": "30 دقيقة", + "1 Hour": "1 ساعة", + "24 Hour": "24 ساعة", + "7 Days": "7 أيام" + }, + "filtersTable": { + "refine": "تصفية", + "clear": "حذف", + "clearAll": "حذف الكل", + "more": "المزيد", + "less": "أقل", + "clearMessage": "تم مسح هذا الفلتر. انقر فوق 'تصفية' لرؤية القائمة الكاملة" + }, + "advancedFilters": { + "articleDate": "تاريخ المنشور", + "sourceCity": "المدينة المصدر", + "city": "المدينة", + "section": "القسم", + "sourceSection": "مصدر القسم", + "author": "الكاتب", + "articleLanguage": "لغة المنشور", + "reach": "الوصول", + "sentiment": "المشاعر", + "sourceCountry": "الدولة المصدر", + "state": "الولاية", + "sourceState": "ولاية المصدر", + "source": "المصدر", + "country": "الدولة", + "language": "اللغة", + "mediaType": "نوع الوسائط", + "keywordRefine": "تصفية", + "publisher": "الناشر" + }, + "restrictions": { + "searchesPerDay": "البحوث في اليوم", + "savedFeeds": "الموجزات المحفوظة", + "alerts": "التنبيهات", + "newsletters": "الرسائل الإخبارية" + }, + "errorMessages": { + "badCredentials": "البريد الإلكتروني أو كلمة المرور غير صحيحة." + } +} diff --git a/frontend/app/locales/ar/loginApp.json b/frontend/app/locales/ar/loginApp.json new file mode 100644 index 0000000..ac98b53 --- /dev/null +++ b/frontend/app/locales/ar/loginApp.json @@ -0,0 +1,86 @@ +{ + "signIn": "تسجيل الدخول", + "login": { + "mainLabel": "تسجيل الدخول", + "subLabel": "إلى حسابك في SOCIALHOSE", + "noAccount": "ليس لديك حساب؟", + "signUpNow": "انشئ حساب الآن", + "form": { + "emailLabel": "البريد الإلكتروني", + "emailPlaceholder": "البريد الإلكتروني", + "passwordLabel": "كلمة المرور", + "passwordPlaceholder": "كلمة المرور" + }, + "forgotPass": "نسيت كلمة المرور؟", + "signInBtn": "تسجيل الدخول" + }, + "register": { + "passwordNotMatched": "تأكيد كلمة المرور لا تتطابق", + "labels": { + "email": "البريد الإلكتروني", + "firstName": "الاسم الأول", + "lastName": "الاسم الأخير", + "company": "الشركة", + "jobFunction": "المهام الوظيفية", + "employees": "الموظفين", + "industry": "المجال", + "websiteURL": "رابط الموقع", + "password": "كلمة المرور", + "confirmPassword": "تأكيد كلمة المرور" + }, + "placeholders": { + "password": "ادخل كلمة المرور", + "confirmPassword": "تأكيد كلمة المرور" + }, + "signInText": "هل لديك حساب؟", + "signInBtn": "تسجيل الدخول", + "loading": "جار التحميل...", + "registerBtn": "سجل", + "agreement": "<بالتسجيل أنت توافق على<1> سياسيات الخصوصية الخاصة بنا و <2> الشروط والأحكام و <3> سياسة الاستخدام المقبولة بالتسجيل بنجاح في SOCIALHOSE.IO <2> بحساب أساسي مجاني<2>.\n", + "paidRegisterSuccess": "نجحت عملية الدفع <1 /> و التسجيل في SOCIALHOSE.IO", + "successBottomText": "تحقق من بريدك الإلكتروني ({{email}}) عن رابط لتنشيط حسابك. إذا لم يظهر في بضع دقائق ، فتحقق من ملف الرسائل غير المرغوب فيها.", + "verification": { + "failed": "عذرا ، لا يمكننا التحقق من حسابك.", + "success": "لقد نجحت في التحقق من عنوان بريدك الإلكتروني.", + "loginBtn": "سجل دخولك الآن" + } + }, + "forgotPass": { + "mainLabel": "نسيت كلمة المرور؟", + "subLabel": "استخدم النموذج أدناه لاستعادته.", + "emailLabel": "البريد الإلكتروني", + "emailPlaceholder": "البريد الإلكتروني", + "signIn": "تسجيل الدخول إلى الحساب الحالي", + "resetBtn": "إستعادة كلمة المرور" + }, + "resetPass": { + "mainLabel": "إعادة تعيين كلمة المرور", + "subLabel": "استخدم النموذج أدناه لاستعاد كلمة المرور.", + "newPasswordLabel": "كلمة المرور الجديدة", + "newPasswordPlaceholder": "كلمة المرور", + "signIn": "تسجيل الدخول إلى الحساب الحالي", + "resetBtn": "إعادة تعيين كلمة المرور" + }, + "footer": { + "privacyPolicy": "سياسة الخصوصية", + "acceptableUsePolicy": "سياسة الاستخدام المقبول", + "termsConditions": "الشروط والأحكام", + "copyright": "حقوق الطبع © {{year}} .SOCIALHOSE.IO كل الحقوق محفوظة\n" + }, + "commonSection": { + "insightsHeading": "رؤى عن المستهلكين والجمهور", + "insightsText": "تعرف على جمهورك وقم بتقسيمه حتى تتمكن من فهمه والوصول إليه بشكل أفضل. اكتسب رؤى حول عادات المستهلك لتصنع أفضل محتوى يناسبهم.", + "brandHeading": "رؤى عن المستهلكين والجمهور", + "brandText": "باستخدام تقنياتنا، ستتمكن من إدارة تواجد علامتك التجارية عبر الإنترنت بسلاسة والحصول على رؤى عن المحادثات حول علامتك التجارية على الفور.", + "socialHeading": "الاستماع الإجتماعي", + "socialText": "إن البيانات التي تستطيع الحصول عليها من شبكات التواصل الإجتماعي نقوم بتوفيرها باستخدام قاعدة بيانات Elasticsearch وجعلها قابلة للبحث في أي و قت" + }, + "errorMessages": { + "badCredentials": "البريد الإلكتروني أو كلمة المرور غير صحيحة." + }, + "messages": { + "forgotPasswordSubmit": "تحقق من بريدك الإلكتروني ({{email}}) بحثًا عن رابط لإعادة تعيين كلمة مرورك. إذا لم يظهر في غضون بضع دقائق ، فتحقق من مجلد الرسائل غير المرغوب فيها.", + "passwordUpdated": "لقد قمت بتحديث كلمة المرور الخاصة بك بنجاح." + } +} diff --git a/frontend/app/locales/ar/tabsContent.json b/frontend/app/locales/ar/tabsContent.json new file mode 100644 index 0000000..15378d2 --- /dev/null +++ b/frontend/app/locales/ar/tabsContent.json @@ -0,0 +1,756 @@ +{ + "searchTab": { + "clearBtn": "مسح الكل ", + "searchBtn": "بحث", + "newSearchBtn": "بحث جديد", + "editFeedBtn": "تعديل", + "saveBtn": "حفظ", + "savingBtn": "جار الحفظ...", + "saveAsBtn": "حفظ باسم", + "sourceTypes": { + "all": "اختيار الكل", + "blogs": "المدونات", + "news": "الأخبار", + "classifieds": "الإعلانات المبوبة", + "comments": "التعليقات", + "forums": "المنتديات", + "reviews": "استعراضات", + "reddit": "ريديت", + "twitter": "تويتر", + "instagram": "إنستغرام" + }, + "tags": "علامات", + "categories": "الفئات", + "moreComments": "المزيد من التعليقات", + "datesRange": "حدد النطاق الزمني", + "commentMetadata": "{{author}}", + "searchInputPlaceholder": "ادخل مصطلحات البحث (مثلا، +\"مجلس الشورى\" +البحرين) ", + "addIndexTermsBtn": "أضف مصطلحات الفهرسة", + "userSubscription": { + "until": "حتى", + "now": "الآن", + "all": "كل التواريخ", + "1d": "يوم واحد", + "7d": "7 أيام", + "15d": "15 يوماً", + "30d": "30 يوماً", + "60d": "60 يوماً", + "90d": "90 يوماً", + "100d": "100 يوماً", + "1y": "سنة", + "2y": "سنتان", + "3y": "3 سنوات", + "5y": "5 سنوات" + }, + "searchDates": { + "btnLabel": "البحث", + "subscriptionLabel": "اشتراكك", + "resetBtn": "إعادة ضبط", + "all": "كل التواريخ", + "1d": "يوم واحد", + "7d": "7 أيام", + "15d": "أسبوعان", + "30d": "شهر", + "60d": "شهران", + "150d": "5 أشهر", + "300d": "10 أشهر", + "90d": "90 يوماً", + "100d": "100 يوماً", + "1y": "عام", + "2y": "عامان", + "3y": "3 أعوام", + "5y": "5 أعوام", + "last": "ابحث في آخر", + "between": "ابحث بين", + "and": "و" + }, + "searchBySection": { + "searchByBtn": "بحث متقدم", + "emphasis": { + "title": "توكيد", + "headlineLabel": "العنوان", + "positionLabel": "الوضع", + "ensitivityLabel": "الحساسية", + "include": "يشمل", + "exclude": "استبعاد", + "anywhereCheck": "أي مكان", + "headlinePositionLabel_1": "يجب أن تظهر مصطلحات البحث أولاً أو في العنوان الرئيسي", + "headlinePositionLabel_2": "لنص المقال", + "nonSensitive": "غير حساس", + "caseSensitive": "حالة حساسة", + "characterSensitive": "حساسية للحروف المعلمة + و حالة الحرف \n" + }, + "languages": { + "title": "اللغات" + }, + "locations": { + "title": "المواقع", + "countriesSelect": "الدول", + "statesSelect": "الولايات الأمريكية", + "locations": "اسحب المواقع إلى المربعات الأخرى للاختيار.", + "locationsToInclude": "يجب أن تكون المقالة في هذه المواقع...", + "locationsToExclude": "...و ليست في هذه المواقع" + }, + "sources": { + "title": "المصادر", + "source": "المصدر", + "siteType": "نوع الموقع", + "includeText": "تشمل", + "excludeText": "استبعاد", + "availSources": "المصادر المتاحة:", + "selectedSources": "المصادر المختارة:", + "mediatype": "نوع الوسائط", + "country": "الدولة", + "lang": "اللغة", + "rank": "الفئة", + "selectSource": "اختر المصادر من القائمة على اليسار" + }, + "sourceLists": { + "title": "قائمة المصادر", + "searchBySourceListsAvailable": "قائمة المصادر المتاحة", + "searchBySourceListsToInclude": "قوائم المصادر المُختارة", + "searchBySourceListsToExclude": "قوائم المصادر المُستبعدة" + }, + "date": { + "title": "التاريخ", + "all": "لا توجد تصفية للتاريخ", + "last": "في آخر", + "between": "بين", + "d": "الأيام", + "y": "الأعوام", + "inclusive": "متضمنة", + "datePickersDivider": "و" + }, + "duplicates": { + "title": "التكرار", + "includeDuplicates": "تضمين التكرارات" + }, + "extras": { + "title": "إضافات", + "hasImages": "عرض المقالات بالصور فقط" + } + }, + "loading": "جار التحميل", + "results": "النتائج", + "noResults": "لا توجد نتائج", + "notSynchronized": "غيرمتزامن", + "articlesCountDivider": "عرض {{resultsCount}} نتيجة من إجمالي {{totalCount}}", + "orderedBy": "الترتيب حسب", + "orderSelect": { + "date": "التاريخ", + "relevance": "الملاءمة" + }, + "tagBtn": "إشارة", + "clipBtn": "قصاصة", + "filter": "تصفية", + "hide": "إخفاء", + "emailBtn": "البريد الإلكتروني", + "deleteBtn": "امسح", + "commentBtn": "تعليق", + "readLaterBtn": "إقرا لاحقاً", + "archiveBtn": "الأرشفة", + "shareBtn": "المشاركة", + "hoursAgo": "قبل ساعات", + "hourAgo": "قبل ساعة", + "words": "كلمات", + "Reach": "قم بالوصول", + "saveFeedPopup": { + "typeSave": "حفظ الموجز", + "typeSaveAs": "حفظ الموجز باسم", + "nameLabel": "اسم الموجز", + "folderLabel": "الملف", + "feedNameErrorMsg": "الرجاء إدخال اسم موجز صالح" + }, + "deleteArticlePopupText": "متأكد أنك تريد حذف هذا المنشور", + "deleteArticlePopupText_plural": "متأكد أنك تريد حذف هذه {{articlesLength}} المنشورات؟\n", + "emailPopup": { + "header": "منشورات الإيميل", + "labelTo": "إلى", + "labelReplyTo": "رد على", + "labelSubject": "الموضوع", + "submitBtn": "أرسل", + "dontSend": "لا ترسل", + "sendAnyway": "أرسل على أي حال", + "sendConfirmWithoutSubject": "هل تريد إرسال هذه الرسالة بدون عنوان؟ " + }, + "commentPopup": { + "addUserComment": "أضف تعليقات المستخدم", + "editUserComment": "تعديل تعليقات المستخدم", + "inputTitlePlaceholder": "العنوان (اختياري)", + "commentPlanceholder": "تعليق", + "charactersLeft": "متبقي {{count}} أحرف" + }, + "clipPopup": { + "header": "قصاصات مقالات ", + "hint1": "\nاسحب الأداة إلى موجز محفوظ على الجانب الأيسر من الشاشة:", + "hint2": "أو حدد موجزًا قمت بقصه مؤخرًا إلى:", + "clippedArticles": "قصاصات منشور/منشورات\n" + }, + "tweet": "غرد هذا الرابط\n", + "yammer": "أنشر هذا الرابط على Yammer\n" + }, + "sourceIndexTab": { + "mainInputPlaceholder": "ابحث عن اسم المصدر أو عنوان URL أو معرف المصدر", + "addToSourceListsBtn": "أضف إلى قائمة المصادر\n", + "addRssBtn": "أضف موجز RSS", + "name": "الاسم", + "mediaType": "نوع الوسائط", + "license": "الرخصة", + "country": "الدولة", + "location": "الموقع", + "rank": "التصنيف", + "action": "فعل", + "actionBtn": "إضافة / إزالة ({{listCount}})", + "showingCounter": "عرض {{startCount}} إلى {{endCount}} من {{totalCount}} من الإدخالات", + "sourceInfoPopupTitle": "تفاصيل المصدر", + "homeUrl": "عنوان URL للصفحة الرئيسية", + "lang": "اللغة", + "titleLabel": "العنوان", + "categories": "الفئات" + }, + "sourceListsTab": { + "mainTitle": "قوائم المصادر", + "addListBtn": "أضف قائمة", + "showGlobalCheck": "إظهار قوائم المصادر العالمية فقط", + "tableLabels": { + "name": "الاسم", + "sources": "المصادر", + "createdBy": "انشئت بواسطة", + "lastUpdated": "آخر تحديث", + "lastUpdatedBy": "آخر تحديث بواسطة", + "action": "فعل" + }, + "share": "شارك", + "unshare": "إلغاء المشاركة", + "rename": "إعادة تسمية", + "clone": "استنساخ", + "delete": "امسح", + "showingCounter": "عرض {{startCount}} إلى {{endCount}} من {{totalCount}} من الإدخالات", + "popup": { + "addToListTitle": "أضف قوائم المصدر", + "addToListDesc": "أضف مصادر إلى قوائم المصادر التالية", + "addBtn": "أضف", + "updateListTitle": "تحديث قوائم المصدر", + "updateListDesc": "إضافة أو إزالة المصدر \"{{name}}\"", + "saveBtn": "حفظ", + "enterListName": "الرجاء إدخال اسم قائمة المصادر", + "addListBtn": "أضف قائمة", + "deleteListTitle": "تأكيد", + "deleteListDesc": "هل أنت متأكد أنك تريد حذف القائمة \"{{name}}\"؟", + "deleteListSubmitBtn": "نعم", + "renameListTitle": "الرجاء إدخال اسم قائمة المصادر", + "renameListSubmitBtn": "إعادة تسمية", + "cloneListSubmitBtn": "نسخ" + } + }, + "analyzeTab": { + "welcomeMsg": "مرحباً بك في التحليل", + "welcomeQuestion": "ماذا تريد أن تفعل؟", + "welcomeSubtext": "(اختر من الخيارات التالية أدناه)", + "go": "اذهب", + "view": "عرض", + "createNewAnalysis": "انشئ تحليلاً جديداً", + "openRecentAnalysis": "افتح التحليل الأخير", + "viewSavedAnalysis": "عرض التحليلات المحفوظة", + "noRecentAnalysis": "لا يوجد تحليل حديث", + "loading": "جار التحميل، الرجاء الانتظار", + "savedAnalysis": "التحليلات المحفوظة", + "newAnalysis": "تحليل", + "deleteAnalysis": "حذف التحليل", + "Name": "الاسم", + "NumberOfCharts": "عدد المحادثات", + "DateCreated": "تاريخ الإنشاء", + "LastUpdated": "آخر تحديث", + "showingCounter": "عرض {{startCount}} إلى {{endCount}} من {{totalCount}} تحليل محفوظ", + "enterDetails": "ادخل التاصيل", + "updateDetails": "تحديث التفاصيل", + "selectFeeds": "اسحب الموجزات إلى هنا", + "selectedFeeds": "الموجزات المحددة", + "dateRange": "حدد النطاق الزمني", + "startDatePlaceholder": "تاريخ البدء", + "endDatePlaceholder": "تاريخ الانتهاء", + "submit": "إرسال", + "updateBtn": "تحديث", + "createBtn": "انشئ", + "dropDesc": "اسحب الموجزات من اللوحة اليسرى وأسقطها هنا", + "releaseDesc": "اسقاط لإضافة الخلاصة", + "noData": "لا توجد بيانات", + "createAlert": "إنشاء تنبيه: تم تحديد {{alertsLength}}", + "createAlertBtn": "انشئ تنبيه", + "selectedCharts": "الرسوم البيانية المختارة", + "chartMenus": { + "addToAlert": "أضف لإنشاء تنبيه", + "addedToAlerts": "تمت الإضافة إلى التنبيهات", + "refresh": "تنشيط", + "addToDashboard": "تمت الإضافة إلى لوحة المعلومات", + "toggleHV": "تبديل أفقي / عمودي" + }, + "charts": { + "topCountries": "أعلى البلدان", + "topLanguages": "أهم اللغات", + "gender": "الجنس", + "mentions": "حالات الذكر ", + "mentionsOverTime": "الذكر على مدى الوقت", + "engagement": "التفاعل", + "engagementOverTime": "التفاعل على مدى الوقت", + "potentialReach": "الوصول المُحتمل", + "potentialReachOverTime": "الوصول المُحتمل على مدى الوقت", + "proportionofSentiment": "حجم المشاعر", + "topInfluencers": "كبار المؤثرين", + "topThemes": "أهم الموضوعات", + "themesOverTime": "الموضوعات على مدى الوقت", + "sentimentOverTime": "الموضوعات على مدى الوقت\nSentiment Over Time", + "shareofSentiment": "نسبة المشاعر\n" + }, + "influencerCols": { + "details": "التفاصيل", + "sentiments": "المشاعر", + "reach": "الوصول", + "rank": "التصنيف", + "influencers": "المؤثرون", + "sourceType": "نوع المصدر", + "total": "الإجمالي", + "positive": "إيجابي", + "neutral": "محايد", + "negative": "سلبي", + "engagement": "التفاعل", + "engagementPerMention": "التفاعل مع كل ذكر" + }, + "overviewCharts": { + "overview": "نظرة عامة", + "none": "لا شئ", + "mediaTypes": "نوع الوسائط", + "sentiments": "المشاعر", + "countries": "الدول", + "languages": "اللغات", + "performance": "الآداء", + "influencers": "المؤثرون", + "sentiment": "المشاعر", + "themes": "الموضوعات", + "demographics": "الخصائص السكانية", + "worldMap": "خريطة العالم" + }, + "savedAnalytics": { + "feeds": "الموجزات", + "dateRange": "النطاق الزمني", + "createdAt": "انشئ في", + "actions": "الإجراءات", + "view": "عرض", + "edit": "تعديل", + "delete": "مسح" + } + }, + "tableSwitcher": { + "myEmails": "قائمة إيميلاتي", + "publishedEmails": "رسائل البريد الإلكتروني المنشورة", + "recipients": "المستلمون", + "groups": "المجموعات" + }, + "deletePopup": { + "alert": "متأكد أنك تريد حذف هذا التنبيه؟\n", + "alerts": "هل تريد بالتأكيد حذف {{count}} تنبيهاً؟", + "recipient": "متأكد أنك تريد حذف هذا المستلم؟", + "recipients": "هل أنت متأكد من أنك تريد حذف هؤلاء المستلمين البالغ عددهم {{count}}؟", + "group": "هل أنت متأكد أنك تريد حذف هذه المجموعة؟", + "groups": "هل أنت متأكد من أنك تريد حذف {{count}} من المجموعات؟", + "email": "هل أنت متأكد أنك تريد حذف هذا البريد الإلكتروني؟", + "emails": "هل أنت متأكد من أنك تريد حذف رسائل البريد الإلكتروني هذه البالغ عددها {{count}}؟" + }, + "notificationsTab": { + "newAlert": "تنبيه جديد", + "newNewsletter": "رسالة إخبارية جديدة\n", + "activate": "تنشيط", + "pause": "إيقاف", + "delete": "حذف", + "publish": "نشر", + "unpublish": "إلغاء", + "subscribe": "إشتراك", + "unsubscribe": "إلغاء الاشتراك", + "name": "الاسم", + "type": "النوع", + "published": "تم النشر", + "ScheduledTimes": "الأوقات المجدولة", + "contents": "المحتويات", + "Recipients": "المستلمون", + "action": "فعل", + "alert": "تنبيه", + "alerts": "alerts", + "newsletter": "رسالة إخبارية", + "newsletters": "Newsletters", + "chartsFeeds": "الرسوم البيانية/ الموجزات", + "recipients": "مستلم", + "scheduledTimes": "الأوقات المجدولة", + "sentTime": "زمن الإرسال", + "owner": "المالك", + "status": "الحالة", + "active": "نشط", + "paused": "غير نشط", + "subscribed": "مشترك", + "unsubscribed": "تم إلغاء الإشتراك\n\n", + "deleteAlertText": "هل أنت متأكد أنك تريد حذف هذا التنبيه؟\n", + "deleteNewsletterText": "هل أنت متأكد أنك تريد حذف هذه النشرة الإخبارية؟\n ", + "deleteAlertsText": "هل تريد بالتأكيد حذف {{عدد}} تنبيهاً؟\n", + "back": "الرجوع", + "form": { + "dragFeed": "اسحب موجزًا ​​أو مخططًا إلى هذه المنطقة لإضافته", + "activeScheduledTimes": " الأوقات المجدولة النشطة", + "editAlert": "تعديل التنبيه \n ", + "createAlert": "إنشاء تنبيه\n ", + "add": "إضافة", + "name": "الاسم", + "recipient": "المستلم(ين)", + "emailSubject": "البريد الإلكتروني الموضوع\n ", + "automatedEmail": "البريد الإلكتروني الآلي\n", + "automatedEmailDesc": "استخدام موضوع البريد الإلكتروني الآلي بناءاً على الموجزات", + "publish": "نشر", + "publishDesc": "التنبيهات والرسائل الإخبارية التي يتم نشرها متاحة للمستخدمين الآخرين للاشتراك\n", + "unsubscribe": "رابط إلغاء الاشتراك\n ", + "unsubscribeDesc": "السماح للمستلمين بإلغاء اشتراك من التنبيه\n ", + "notifications": "الإشعارات", + "notificationsDesc": "إخطار المنشئ عندما يقوم المستلمون بإلغاء الاشتراك", + "feeds": "الموجزات/الرسوم البيانية\n", + "options": "الخيارات", + "articleExtracts": "مقتطفات المنشور\n", + "contextualExtracts": "مقتطف سياقي", + "startExtracts": "بداية استخراج النص\n", + "noExtracts": "لا توجد مقتطفات للمقال\n", + "highlightKeywords": "تمييز الكلمات الرئيسية\n ", + "showSourceCountry": "عرض بلد المصدر\n ", + "showUserComments": "عرض تعليقات المستخدم\n ", + "layout": "التصميم", + "enhancedHtml": " HTML محسن", + "plainHtml": "HTML عادي", + "sendWhenEmpty": "الارسال عندما يكون فارغاً\n", + "timezone": "\nالمنطقة الزمنية", + "change": "تغيير", + "automatic": "آلي", + "sendUntil": "أرسل حتى\n\n", + "cancel": "إلغاء", + "save": "حفظ", + "saveAs": "حفظ باسم", + "selectDate": "اختر التاريخ", + "type": { + "daily": "يومياً", + "weekly": "أسبوعياً", + "monthly": "شهرياً" + }, + "time": { + "15m": "كل 15 دقيقة", + "30m": "كل 30 دقيقة ", + "1h": "كل ساعة", + "2h": "كل ساعتين ", + "3h": "كل 3 ساعات\n ", + "4h": "كل 4 ساعات \n", + "6h": "كل 6 ساعات\n", + "12h": "كل 12 ساعة\n ", + "once": "مرة يومياً\n " + }, + "days": { + "all": "كل الأيام\n", + "weekdays": "أيام الأسبوع", + "weekends": "عطلات آخر الأسبوع" + }, + "period": { + "every": "كل", + "first": "أول", + "second": "ثانياً", + "third": "ثالثاً", + "fourth": "رابعاً", + "last": "آخراً" + }, + "day": { + "monday": "الإثنين\n", + "tuesday": "الثلاثاء", + "wednesday": "الأربعاء", + "thursday": "الخميس", + "friday": "الجمعة", + "saturday": "السبت", + "sunday": "الأحد" + } + }, + "history": { + "hideSendHistory": "إخفاء تاريخ الإرسال\n ", + "showSendHistory": "عرض تاريخ الإرسال\n ", + "sentTime": "تاريخ الإرسال\n ", + "showMore": "عرض المزيد\n ", + "loading": "جار التحميل...\n" + }, + "newsLetter": { + "createNewsletter": "انشئ نشرة إخبارية\n ", + "name": "اسم" + }, + "popup": { + "saveAsPlaceholder": "الرجاء إدخال اسم التنبيه الجديد\n", + "save": "حفظ", + "saveAs": "حفظ باسم" + } + }, + "manageRecipientsTab": { + "newRecipient": "مستلم جديد", + "newGroup": "مجموعة جديدة", + "name": "الاسم", + "groupName": "اسم المجموعة", + "email": "عنوان البريد الإلكتروني\n\n ", + "groups": "المجموعات", + "subscriptions": "الإشتراكات", + "creationDate": "تاريخ الإنشاء", + "status": "الحالة", + "recipientsNumber": "عدد المستلمين", + "recipients": "المستلمون", + "Subscribed": "مشترك", + "Unsubscribed": "تم إلغاء الإشتراك ", + "All": "الكل", + "Enrolled": "مقيد", + "NotEnrolled": "غير مقيد", + "form": { + "recipient": { + "basicInfo": "معلومات أساسية", + "nameStatus": "حالة الملف الشخصي", + "unsaved": "مستخدم غير محفوظ", + "deleteButton": "حذف المستخدم", + "firstName": "الاسم الأول\n ", + "lastName": "الاسم الأخير", + "email": "عنوان البريد الإلكتروني", + "enroll": "مقيد", + "creationDate": "تاريخ الإنشاء" + }, + "group": { + "basicInfo": "المعلومات الأساسية", + "nameStatus": "حالة المجموعة", + "unsaved": "مجموعة غير محفوظة\n ", + "deleteButton": "حذف المجموعة", + "name": "اسم المجموعة\n ", + "description": "الوصف", + "recipientsNumber": "عدد الأعضاء", + "addedDate": "التاريخ المُضاف", + "creationDate": "تاريخ الإنشاء" + }, + "cancel": "إلغاء", + "save": "حفظ" + }, + "tables": { + "recipients": "المستلمون", + "groups": "المجموعات", + "emailHistory": "سجل البريد الإلكتروني\n ", + "subscriptions": "الإشتراكات" + } + }, + "manageEmailsTab": { + "owner": "المالك", + "recipient": "المستلم", + "feed": "الموجز", + "selectFilter": "اختيار تصفية", + "notifications": "# التنبيهات ", + "allEmails": "كل الإيميلات", + "emailFilter": "تصفية الإيميل", + "filter": "تصفية", + "filterBy": "تصفية حسب:", + "currentFilter": "التصفية الحالية", + "refresh": "تنشيط" + }, + "toggler": { + "active": "نشط", + "paused": "تم الإيقاف مؤقتاً", + "subscribed": "تم الإشتراك ", + "unsubscribed": "تم إلغاء الإشتراك", + "yes": "نعم", + "no": "لا" + }, + "exportTab": { + "topMessage": "حدد موجز ويب لتصديره إلى الشبكة الداخلية أو التطبيق الخاص بك. لإضافة موجز إلى هذه الصفحة ، اختر \"تصدير موجز\" من القائمة المنسدلة للموجز. يسمح لك بتصدير عدد من الخلاصات حسب ترخيص التصدير الخاص بك.", + "feedName": "اسم الموجز", + "exportWith": "تصدير ب", + "actions": "الإجراءات", + "delete": "امسح", + "close": "اغلق", + "export": "تصدير", + "confirm": "تأكيد", + "exportDeleteMessage": "هل أنت متأكد أنك تريد حذف \"{{feedName}}\" من قائمة التصدير؟", + "exportPopup": { + "line1": "عنوان URL لتصدير لهذا الخلاصة كما يلي:", + "line2": "لاستدعاء الخلاصة باستخدام Secure HTTP ، قم بتغيير http: // إلى https: //", + "line3": "يمكن تعديل العوامل التالية:", + "param1": "قم بتعيين عدد النتائج المراد إرجاعها ، بين 1 و 200", + "param2": "عيّن نوع مقتطفات المقالة: \"s\" لبداية المقالة ، أو \"sc\" لمقتطف سياقي يعتمد على مصطلحات البحث ، أو \"n\" لعدم وجود مقتطفات للمقالات ", + "param3": "اضبط على \"1\" لتضمين عناوين URL للصور ، اضبط على \"0\" لاستبعاد عناوين URL للصور\n", + "param4": "أضف \"& text_format = text\" إلى عنوان URL لتنسيق العنوان واستخراجه كنص عادي بدلاً من HTML" + } + }, + "restrictions": { + "perDay": "يوميا", + "perMonth": "شهريا", + "alertLicenses": "رخصة التنبيه", + "totalNewsltter": "إجمالي الرسائل الإخبارية", + "webfeedLicenses": "رخص موجزات الويب", + "searchLicenses": "رخص البحث", + "feedLicenses": "رخص الموجزات" + }, + "plans": { + "sidebar": { + "activePlanDetails": "تفاصيل الباقة النشطة\n ", + "changeCard": "تغيير البطاقة\n ", + "updatePlan": "تحديث الباقة", + "yourTransactions": "تعاملاتك" + }, + "currentPlan": { + "subHeading": "الباقة الحالية", + "freePlan": "مجاناً\n", + "perMonth": "في الشهر", + "changePlan": "تغيير الباقة", + "upgradeYourPlan": "قم بترقية باقتك\n", + "upgradeText": "ادفع فقط مقابل ما تريد من الخيارات التالية. كل الفواتير شهرية.\n", + "currentPlanDetails": "تفاصيل الباقة الحالية\n ", + "selectedMediaTypes": "نوع الوسائط المُختارة\n ", + "upgradeToGet": "قم بالترقية للحصول على\n ", + "none": "لا شئ", + "selectedLicenses": "الرخص المُختارة", + "feedsLicenses": "رخص الموجزات", + "searchLicenses": "رخص البحث", + "webfeedLicenses": "رخص موجزات الويب", + "alertLicenses": "رخص التنبيهات\n ", + "userAccounts": "حسابات المستخدمين ", + "features": "المميزات\n", + "analytics": "\nالتحليلات", + "cancelSubscriptionBtn": "\n إلغاء الإشتراك", + "cancelWarning": "(إذا قمت بإلغاء باقتك الحالية ، فسيتم تحويلك إلى الباقة الأساسية المجانية بدءاً من دورة الفوترة التالية.)", + "alreadyCancelled": "لقد قمت مسبقاً بإلغاء باقتك الحالية.", + "cancelModal": { + "header": "إلغاء الإشتراك", + "line1": "سنفتقدك {{first name}}.\n", + "line2": "حسنًا ، إليك ما سيحدث ...", + "warn1": "سيتم حذف جميع موجزاتك نهائياً.", + "warn2": "سيتوقف كل تنبيهاتك عن العمل فوراً. ستبقى مجموعات القائمة البريدية في حسابك، ولكن لن تتمكن من إرسال أي تنبيهات.", + "warn3": "ستظل قادراً على إنشاء موجز جديد بهذا الحساب الأساسي المجاني.", + "warn4": "عند إلغاء باقتك الحالية، لا يمكنك تحديثها باقتك حتى تنتهي دورة الفواتير الحالية.", + "undoBtn": "تراجع", + "loadingBtn": "جار التحميل...", + "cancelSubscriptionBtn": "إلغاء الاشتراك", + "reasonSelect": "الرجاء تحديد خيار واحد على الأقل.", + "feedbackPara": "قبل أن تذهب ، هل تمانع في إخبارنا لماذا؟ ستساعدنا ملاحظاتك على التحسن.", + "reasonCancellation": "سبب الالغاء", + "noNeeds": "SOCIALHOSE.IO لا يلبي احتياجاتنا.", + "tooNoisy": "نتائج البحث غير دقيقة.", + "confusing": "النظام صعب الإستخدام.", + "expensive": "إنه مكلف للغاية.", + "covid": "بسبب مرض فيروس كورونا.", + "other": "آخر", + "tellMore": "نأسف لسماع ذلك. هل تود إخبارنا بالمزيد؟" + } + }, + "updatePlan": { + "heading": "تحديث الباقة", + "planLoadingFailed": "عذراً، حدث خطأ ما.", + "tryAgainBtn": "حاول ثانية", + "subText": "قائمة خيارات صغيرة حسب الكلب بفوترة شهرية.\nيمكنك اختيار احدى الباثات الجاهزة المصممة لك.", + "learnMoreBtn": "تعلم المزيد", + "prePlans": "\nباقة معدة مسبقاً", + "mediaTypes": "نوع الوسائط", + "licenses": "التراخيص", + "features": "المميزات", + "deselectTooltip": "اضغط للتحديد", + "selectTooltip": "اضغط لإلغاء الإختيار", + "addOns": "الإضافات", + "totalCost": "التكلفة الكلية", + "monthly": "شهرية", + "cancelledWarning": "لقد قمت {{text}} باقتك الحالية. و بذلك ، يمكنك تحديث الباقة بعد انتهاء دورة الفوترة الحالية فقط.", + "continueBtnLoading": "جار التحميل...", + "continueBtn": "تابع ليتم التأكيد", + "billingHeading": "تفاصيل الفوترة و الدفع", + "error": "خطأ", + "back": "الرجوع", + "payBtn": "ادفع ${{totalCost}}", + "confirmationHeading": "تأكيد", + "upgradeNotice": "لقد اخترت ترقية الباقة. نظرًا لأنك ستضيف أي خدمات إضافية في منتصف دورة الفوترة ، فسيتم إعادة حساب الإجمالي بناءً على باقتك بالكامل وتحصيل الفرق بالكامل ، حتى إذا كانت هناك أيام بعد دورة الفوترة.", + "downgradeNotice": "لقد قمت بالرجوع إلى إصدار سابق من الباقة. لذلك ، سيعتبر هذا التحديث فعالاً عند دورة الفوترة التالية ، بما في ذلك الوصول إلى الخيارات / الميزات. بمعنى ، يمكنك الرجوع إلى إصدار سابق x أيام قبل دورة الفوترة ، واصل استخدام الخدمة ولكن سيحدث التغييرعند بداية دورة الفوترة القادمة. عند المتابعة ، لا يمكنك تحديث باقتك حتى تنتهي دورة الفوترة.", + "alreadyStoredCard": "لديك بطاقة دفع محفوظة مسبقاً. هل أنت متأكد أنك استخدامها؟", + "payWithOtherCardBtn": "ادفع باستخدام بطاقة أخرى", + "payWithStoredCardBtn": "ادقع باستخدام بطاقة محفوظة", + "payLoading": "المعالجة جارية..." + }, + "billingForm": { + "fullName": "الاسم كاملاً", + "addr1": "سطر العنوان 1", + "addr1Desc": "على سبيل المثال ، الشارع أو صندوق البريد أو اسم الشركة", + "addr2": "سطر العنوان 2", + "addr2Desc": "على سبيل المثال ، شقة أو جناح أو وحدة أو مبنى", + "city": "المدينة", + "cityDesc": "مدينة أو حي أو ضاحية أو بلدة أو قرية", + "state": "الولاية", + "stateDesc": "الولاية أو البلدة أو المقاطعة أو المنطقة\n", + "zip": "الرمز البريدي", + "zipDesc": "الرمز البريدي", + "country": "الدولة", + "email": "البريد الالكتروني للدفع", + "phone": "رقم الهاتف للدفع", + "phoneDesc": "رقم الهاتف شاملاً الامتداد", + "cardHeading": "تفاصيل البطاقة", + "agreement": "بإلارسال ، فإنك توافق على <1> سياسة الخصوصية و <2> البنود والشروط و <3> سياسة الاستخدام المقبول " + }, + "upgradeModal": { + "heading": " تحديث الباقة!", + "text": "يجب عليك ترقية باقتك للوصول إلى هذه الميزات.", + "learnMore": "اعرف المزيد", + "upgradeNowBtn": "قم بالترقية الآن", + "maybeLaterBtn": "ربما لاحقاً" + }, + "transactions": { + "heading": "تعاملاتك", + "activationDate": "تاريخ التفعيل\n", + "expirationDate": "تايخ الانتهاء", + "transactionDate": "تاريخ المعاملة\n", + "amount": "المبلغ", + "status": "الحالة", + "actions": "الإجراءات", + "more": "المزيد", + "modal": { + "heading": "تفاصيل المعاملة و الباقة\n", + "transactionDetails": "تفاصيل المعاملة", + "transactionDate": "تاريخ المعاملة", + "activationDate": "تاريخ التفعيل", + "expirationDate": "تاريخ الانتهاء", + "amount": "المبلغ", + "status": "الحالة", + "billingDetails": "تفاصيل الفوترة\n", + "name": "الاسم", + "email": "البريد الإلكتروني", + "phone": "الهاتف", + "address": "العنوان", + "invoiceNo": "رقم الإيصال", + "showInvoiceLink": "عرض الإيصال", + "cancelBtn": "إلغاء" + } + }, + "changeCard": { + "heading": "تغيير تفاصيل الفوترة و الدفع", + "subText": "إذا قمت بتغيير بطاقتك ، فسيتم سداد مدفوعاتك المستقبلية باستخدام هذه البطاقة.", + "error": "خطأ", + "loadingBtn": "جار التحميل...", + "changeCardBtn": "تغيير البطاقة" + } + }, + "webtour": { + "search": { + "start": "مرحبا! دعنا نبدأ في توجيهك إلى نظامنا!", + "feedsView": "هنا ، يمكنك تنظيم موجزاتك وملفاتك الخاصة.", + "mainTabs": "هناك 3 صفحات رئيسية: <1> ابحث للعثور على المحتوى ، <1> حلل لإنشاء التقارير ، و <1> شارك لتوزيع النتائج عبر التنبيهات أو موجزات الويب.", + "userSettings": "يمكنك تكوين إعدادات المستخدم هنا. يمكنك أيضًا العثور على هذا الدليل هنا في أي وقت في المستقبل.", + "license": "هنا يمكنك رؤية بدلات الترخيص الخاصة بك أثناء استخدامها.", + "searchField": "يبدو البحث المنطقي البسيط كالتالي: <1> +BMW -Texas . والتي سوف تجد جميع الإشارات إلى 'bmw' و بدون 'texas'.", + "dateRange": "يمكنك تعيين النطاق الزمني هنا لتوسيع نطاق البحث أو تضييقه.", + "mediaChannels": "حدد وسائل الإعلام التي ترغب في تضمينها في البحث.", + "advancedSearch": "انقر على <1> بحث متقدم للكشف عن الخيارات المختلفة لبحثك.", + "emphasis": "<0> التأكيد: قم بتضمين أو استبعاد كلمات أو عبارات معينة في عنوان مقال إخباري أو منشور مدونة.", + "languages": "<0> اللغات: التقط المحتوى الذي تم وضع علامة عليه باللغة (اللغات) التالية.", + "locations": "<0> المواقع: تضمين أو استبعاد المحتوى الذي تم وضع علامة جغرافية عليه مع البلدان التالية أو الولايات الأمريكية.", + "extras": "<0> إضافات: إظهار المشاركات التي تحتوي على صور فقط.", + "saveSearch": "إذا أعجبتك نتائج البحث ، يمكنك حفظها كموجز في الخرطوم، أو إعادة تعيينها بالنقر فوق بحث جديد." + }, + "analytics": { + "start": "لقد جعلنا تشغيل التحليلات في غاية البساطة!", + "dragFeed": "يمكنك النقر فوق أي من موجزاتك وسحبها وإسقاطها في صندوق الإسقاط.", + "drop": "هذا صندوق كبير! يمكنك إسقاط أكثر من موجز واحد.", + "dateRange": "حدد النطاق الزمني الذي تريد أن يغطيه تحليلك.", + "create": "انقر فوق إنشاء. هذا هو!" + } + } +} diff --git a/frontend/app/locales/de/common.json b/frontend/app/locales/de/common.json new file mode 100644 index 0000000..49c1d46 --- /dev/null +++ b/frontend/app/locales/de/common.json @@ -0,0 +1,450 @@ +{ + "commonWords": { + "Or": "Or", + "or": "or", + "Confirm": "Confirm", + "confirm": "confirm", + "Delete": "Delete", + "delete": "delete", + "Cancel": "Cancel", + "cancel": "cancel", + "Yes": "Yes", + "yes": "yes", + "Rename": "Rename", + "rename": "rename", + "loading": "loading..." + }, + "interpolateSample": "you can interpolate {{value}} or {{component}} via interpolate component and use formatting {{up, uppercase}}!", + "tabs": { + "dashboard": "Dashboard", + "search": "Search", + "analyze": "Analyze", + "share": "Share", + "sourceIndex": "Source Index", + "sourceLists": "Source Lists", + "welcome": "Welcome", + "createAnalysis": "Create Analysis", + "savedAnalysis": "Saved Analysis", + "alertsNewsletters": "Alerts/Newsletters", + "manageRecipients": "Manage Recipients", + "export": "Export" + }, + "whatsNew": { + "label": "What's New" + }, + "langs": { + "en": "English", + "es": "Español", + "de": "Deutsch", + "fr": "Français", + "nl": "Nederlands", + "pt": "Português" + }, + "userSettings": { + "settings": "Settings", + "help": "Help", + "signOut": "Sign Out", + "changePassword": "Change password", + "enterOldPassword": "Enter old password", + "enterNewPassword": "Enter new password", + "retypeNewPassword": "Retype new password" + }, + "sidebarDropdown": { + "AddClippingsFeed": "Add clippings feed", + "AddFolder": "Add folder", + "DownloadSearchCriteria": "Download search criteria", + "EditSearchTemplate": "Edit search template", + "ExportFeeds": "Export feeds", + "ViewUserComments": "View user comments", + "RenameFolder": "Rename folder", + "DeleteFolder": "Delete folder", + "AddArticle": "Add article", + "AddToDashboard": "Add to dashboard", + "AnalyzeFeed": "Analyze feed", + "DownloadArticleData": "Download article data", + "DownloadFeedStatistics": "Download feed statistics", + "ExportFeed": "Export feed", + "RenameFeed": "Rename feed", + "DeleteFeed": "Delete feed" + }, + "sidebarPopup": { + "areYouSure": "are you sure", + "enterNamelabel": "Please enter a new name", + "enterFolderName": "Please enter a folder name", + "addFolderBtn": "Add Folder" + }, + "alerts": { + "notice": { + "renameFeedNotice": "Your feed was successfully renamed ", + "renameFolderNotice": "Your folder was successfully renamed ", + "noListsSelected": "Please select a source", + "updateListsForSourceNotice": "Source list changes saved", + "deleteSourceList": "Source list {{name}} is deleted.", + "renameSourceList": "Source list is renamed.", + "saveFeed": "Feed successfully saved." + }, + "error": { + "renameFolderEmpty": "Folder name must not be empty.", + "updateCategoryNameNotUnique": "There is already folder with name {{parameters}}.", + "createSourceListNameNotUnique": "Source List names must be unique. Please change the Source List name and save." + } + }, + "language": { + "all": "All", + "af": "Afrikaans", + "sq": "Albanian", + "ar": "Arabic", + "bn": "Bengali", + "bs": "Bosnian", + "bg": "Bulgarian", + "ca": "Catalan", + "zh": "Chinese", + "hr": "Croatian", + "cs": "CZECH", + "da": "Danish", + "nl": "Dutch", + "en": "English", + "et": "Estonian", + "tl": "Filipino", + "fi": "Finnish", + "fr": "French", + "de": "German", + "el": "Greek", + "gu": "Gujarati", + "he": "Hebrew", + "hi": "Hindi", + "hu": "Hungarian", + "is": "Icelandic", + "id": "Indonesian", + "it": "Italian", + "ja": "Japanese", + "ko": "Korean", + "lv": "Latvian", + "lt": "Lithuanian", + "mk": "Macedonian", + "ms": "Malay", + "no": "Norwegian", + "fa": "Persian", + "pl": "Polish", + "pt": "Portuguese", + "ro": "Romanian", + "ru": "Russian", + "sr": "Serbian", + "sk": "Slovak", + "sl": "Slovenian", + "es": "Spanish", + "sv": "Swedish", + "ta": "Tamil", + "th": "Thai", + "tr": "Turkish", + "uk": "Ukrainian", + "ur": "Urdu", + "vi": "Vietnamese", + "und": "Undefined" + }, + "country": { + "AD": "Andorra", + "AE": "United Arab Emirates", + "AF": "Afghanistan", + "AG": "Antigua and Barbuda", + "AI": "Anguilla", + "AL": "Albania", + "AM": "Armenia", + "AO": "Angola", + "AQ": "Antarctica", + "AR": "Argentina", + "AS": "American Samoa", + "AT": "Austria", + "AU": "Australia", + "AW": "Aruba", + "AX": "Åland Islands", + "AZ": "Azerbaijan", + "BA": "Bosnia and Herzegovina", + "BB": "Barbados", + "BD": "Bangladesh", + "BE": "Belgium", + "BF": "Burkina Faso", + "BG": "Bulgaria", + "BH": "Bahrain", + "BI": "Burundi", + "BJ": "Benin", + "BL": "Saint Barthélemy", + "BM": "Bermuda", + "BN": "Brunei Darussalam", + "BO": "Bolivia, Plurinational State of", + "BQ": "Bonaire, Sint Eustatius and Saba", + "BR": "Brazil", + "BS": "Bahamas", + "BT": "Bhutan", + "BV": "Bouvet Island", + "BW": "Botswana", + "BY": "Belarus", + "BZ": "Belize", + "CA": "Canada", + "CC": "Cocos (Keeling) Islands", + "CD": "Congo, the Democratic Republic of the", + "CF": "Central African Republic", + "CG": "Congo", + "CH": "Switzerland", + "CI": "Côte d'Ivoire", + "CK": "Cook Islands", + "CL": "Chile", + "CM": "Cameroon", + "CN": "China", + "CO": "Colombia", + "CR": "Costa Rica", + "CU": "Cuba", + "CV": "Cabo Verde", + "CW": "Curaçao", + "CX": "Christmas Island", + "CY": "Cyprus", + "CZ": "Czechia", + "DE": "Germany", + "DJ": "Djibouti", + "DK": "Denmark", + "DM": "Dominica", + "DO": "Dominican Republic", + "DZ": "Algeria", + "EC": "Ecuador", + "EE": "Estonia", + "EG": "Egypt", + "EH": "Western Sahara", + "ER": "Eritrea", + "ES": "Spain", + "ET": "Ethiopia", + "FI": "Finland", + "FJ": "Fiji", + "FK": "Falkland Islands (Malvinas)", + "FM": "Micronesia, Federated States of", + "FO": "Faroe Islands", + "FR": "France", + "GA": "Gabon", + "GB": "United Kingdom", + "GD": "Grenada", + "GE": "Georgia", + "GF": "French Guiana", + "GG": "Guernsey", + "GH": "Ghana", + "GI": "Gibraltar", + "GL": "Greenland", + "GM": "Gambia", + "GN": "Guinea", + "GP": "Guadeloupe", + "GQ": "Equatorial Guinea", + "GR": "Greece", + "GS": "South Georgia and the South Sandwich Islands", + "GT": "Guatemala", + "GU": "Guam", + "GW": "Guinea-Bissau", + "GY": "Guyana", + "HK": "Hong Kong", + "HM": "Heard Island and McDonald Islands", + "HN": "Honduras", + "HR": "Croatia", + "HT": "Haiti", + "HU": "Hungary", + "ID": "Indonesia", + "IE": "Ireland", + "IL": "Israel", + "IM": "Isle of Man", + "IN": "India", + "IO": "British Indian Ocean Territory", + "IQ": "Iraq", + "IR": "Iran, Islamic Republic of", + "IS": "Iceland", + "IT": "Italy", + "JE": "Jersey", + "JM": "Jamaica", + "JO": "Jordan", + "JP": "Japan", + "KE": "Kenya", + "KG": "Kyrgyzstan", + "KH": "Cambodia", + "KI": "Kiribati", + "KM": "Comoros", + "KN": "Saint Kitts and Nevis", + "KP": "Korea (the Democratic People's Republic of)", + "KR": "Korea (the Republic of)", + "KW": "Kuwait", + "KY": "Cayman Islands", + "KZ": "Kazakhstan", + "LA": "Lao People's Democratic Republic", + "LB": "Lebanon", + "LC": "Saint Lucia", + "LI": "Liechtenstein", + "LK": "Sri Lanka", + "LR": "Liberia", + "LS": "Lesotho", + "LT": "Lithuania", + "LU": "Luxembourg", + "LV": "Latvia", + "LY": "Libya", + "MA": "Morocco", + "MC": "Monaco", + "MD": "Moldova, Republic of", + "ME": "Montenegro", + "MF": "Saint Martin (French part)", + "MG": "Madagascar", + "MH": "Marshall Islands", + "MK": "Macedonia, the former Yugoslav Republic of", + "ML": "Mali", + "MM": "Myanmar", + "MN": "Mongolia", + "MO": "Macao", + "MP": "Northern Mariana Islands", + "MQ": "Martinique", + "MR": "Mauritania", + "MS": "Montserrat", + "MT": "Malta", + "MU": "Mauritius", + "MV": "Maldives", + "MW": "Malawi", + "MX": "Mexico", + "MY": "Malaysia", + "MZ": "Mozambique", + "NA": "Namibia", + "NC": "New Caledonia", + "NE": "Niger", + "NF": "Norfolk Island", + "NG": "Nigeria", + "NI": "Nicaragua", + "NL": "Netherlands[note 1]", + "NO": "Norway", + "NP": "Nepal", + "NR": "Nauru", + "NU": "Niue", + "NZ": "New Zealand", + "OM": "Oman", + "PA": "Panama", + "PE": "Peru", + "PF": "French Polynesia", + "PG": "Papua New Guinea", + "PH": "Philippines", + "PK": "Pakistan", + "PL": "Poland", + "PM": "Saint Pierre and Miquelon", + "PN": "Pitcairn", + "PR": "Puerto Rico", + "PS": "Palestine, State of", + "PT": "Portugal", + "PW": "Palau", + "PY": "Paraguay", + "QA": "Qatar", + "RE": "Réunion", + "RO": "Romania", + "RS": "Serbia", + "RU": "Russian Federation", + "RW": "Rwanda", + "SA": "Saudi Arabia", + "SB": "Solomon Islands", + "SC": "Seychelles", + "SD": "Sudan", + "SE": "Sweden", + "SG": "Singapore", + "SH": "Saint Helena, Ascension and Tristan da Cunha", + "SI": "Slovenia", + "SJ": "Svalbard and Jan Mayen", + "SK": "Slovakia", + "SL": "Sierra Leone", + "SM": "San Marino", + "SN": "Senegal", + "SO": "Somalia", + "SR": "Suriname", + "SS": "South Sudan", + "ST": "Sao Tome and Principe", + "SV": "El Salvador", + "SX": "Sint Maarten (Dutch part)", + "SY": "Syrian Arab Republic", + "SZ": "Swaziland", + "TC": "Turks and Caicos Islands", + "TD": "Chad", + "TF": "French Southern Territories", + "TG": "Togo", + "TH": "Thailand", + "TJ": "Tajikistan", + "TK": "Tokelau", + "TL": "Timor-Leste", + "TM": "Turkmenistan", + "TN": "Tunisia", + "TO": "Tonga", + "TR": "Turkey", + "TT": "Trinidad and Tobago", + "TV": "Tuvalu", + "TW": "Taiwan, Province of China[note 2]", + "TZ": "Tanzania, United Republic of", + "UA": "Ukraine", + "UG": "Uganda", + "UM": "United States Minor Outlying Islands", + "US": "United States", + "UY": "Uruguay", + "UZ": "Uzbekistan", + "VA": "Holy See (Vatican City State)", + "VC": "Saint Vincent and the Grenadines", + "VE": "Venezuela, Bolivarian Republic of", + "VG": "Virgin Islands, British", + "VI": "Virgin Islands, U.S.", + "VN": "Viet Nam", + "VU": "Vanuatu", + "WF": "Wallis and Futuna", + "WS": "Samoa", + "YE": "Yemen", + "YT": "Mayotte", + "ZA": "South Africa", + "ZM": "Zambia", + "ZW": "Zimbabwe" + }, + "state": { + "AL": "Alabama", + "AK": "Alaska", + "AZ": "Arizona", + "AR": "Arkansas", + "CA": "California", + "CO": "Colorado", + "CT": "Connecticut", + "DE": "Delaware", + "DC": "District of Columbia", + "FL": "Florida", + "GA": "Georgia", + "HI": "Hawaii", + "ID": "Idaho", + "IL": "Illinois", + "IN": "Indiana", + "IA": "Iowa", + "KS": "Kansas", + "KY": "Kentucky", + "LA": "Louisiana", + "ME": "Maine", + "MD": "Maryland", + "MA": "Massachusetts", + "MI": "Michigan", + "MN": "Minnesota", + "MS": "Mississippi", + "MO": "Missouri", + "MT": "Montana", + "NE": "Nebraska", + "NV": "Nevada", + "NH": "New Hampshire", + "NJ": "New Jersey", + "NM": "New Mexico", + "NY": "New York", + "NC": "North Carolina", + "ND": "North Dakota", + "OH": "Ohio", + "OK": "Oklahoma", + "OR": "Oregon", + "PA": "Pennsylvania", + "RI": "Rhode Island", + "SC": "South Carolina", + "SD": "South Dakota", + "TN": "Tennessee", + "TX": "Texas", + "UT": "Utah", + "VT": "Vermont", + "VA": "Virginia", + "WA": "Washington", + "WV": "West Virginia", + "WI": "Wisconsin", + "WY": "Wyoming" + } + +} diff --git a/frontend/app/locales/de/loginApp.json b/frontend/app/locales/de/loginApp.json new file mode 100644 index 0000000..6b7057a --- /dev/null +++ b/frontend/app/locales/de/loginApp.json @@ -0,0 +1,16 @@ +{ + "login": { + "mainLabel": "De Sign in", + "emailLabel": "DE Email Address", + "password": "DE Password", + "rememberMe": "de Remember me", + "forgotPass": "de Forgot your Password?", + "signInBtn": "de Sign In" + }, + "forgotPass": { + "mainLabel": "DE Password Reset", + "backLabel": "DE Back to Sign In", + "emailLabel": "DE Email Address", + "resetBtn": "DE Password Reset" + } +} diff --git a/frontend/app/locales/de/tabsContent.json b/frontend/app/locales/de/tabsContent.json new file mode 100644 index 0000000..3e2e0d5 --- /dev/null +++ b/frontend/app/locales/de/tabsContent.json @@ -0,0 +1,350 @@ +{ + "searchTab": { + "clearBtn": "Clear", + "searchBtn": "Search", + "newSearchBtn": "New Search", + "editFeedBtn": "Edit", + "saveBtn": "Save", + "savingBtn": "Saving...", + "saveAsBtn": "Save As", + "sourceTypes": { + "all": "Select All", + "blogs": "Blogs", + "news": "News", + "classifieds": "Classifieds", + "comments": "Comments", + "forums": "Forums", + "reviews": "Reviews", + "facebook": "Facebook", + "twitter": "Twitter", + "instagram": "Instagram", + "flickr": "Flickr", + "youtube": "Youtube", + "vimeo": "Vimeo" + }, + "searchInputPlaceholder": "Enter search terms", + "addIndexTermsBtn": "Add Index Terms", + "userSubscription": { + "until": "until", + "now": "now", + "all": "All Dates", + "1d": "1 day", + "7d": "7 days", + "15d": "15 days", + "30d": "30 days", + "60d": "60 days", + "90d": "90 days", + "100d": "100 days", + "1y": "1 year", + "2y": "2 years", + "3y": "3 years", + "5y": "5 years" + }, + "searchDates": { + "btnLabel": "Search", + "subscriptionLabel": "Your subscription", + "upgradeQuestion": "Need an upgrade", + "contactBtn": "Contact us", + "resetBtn": "Reset", + "all": "All Dates", + "1d": "1D", + "7d": "7D", + "15d": "2W", + "30d": "1M", + "60d": "2M", + "90d": "90D", + "100d": "100D", + "1y": "1Y", + "2y": "2Y", + "3y": "3Y", + "5y": "5Y", + "last": "Search the Last", + "between": "Search Between", + "and": "and" + }, + "searchBySection": { + "searchByBtn": "Advanced search", + "emphasis": { + "title": "Emphasis", + "headlineLabel": "Headline", + "positionLabel": "Position", + "ensitivityLabel": "Sensitivity", + "include": "Includes", + "exclude": "Excludes", + "anywhereCheck": "Anywhere", + "headlinePositionLabel_1": "Search terms must appear in headline or first", + "headlinePositionLabel_2": "of article text", + "nonSensitive": "Non-sensitive", + "caseSensitive": "Case sensitive", + "characterSensitive": "Case + accented character sensitive" + }, + "languages": { + "title": "Languages" + }, + "locations": { + "title": "Locations", + "countriesSelect": "Countries", + "statesSelect": "US States", + "locations": "Drag locations to other boxes to select.", + "locationsToInclude": "Article must be in these locations...", + "locationsToExclude": "...and NOT in these locations." + }, + "sources": { + "title": "Sources", + "source": "Source", + "includeText": "Include", + "excludeText": "Exclude", + "availSources": "Available sources", + "selectedSources": "Selected sources", + "mediatype": "Media Type", + "country": "Country", + "lang": "Language", + "rank": "Rank" + }, + "sourceLists": { + "title": "Source Lists", + "searchBySourceListsAvailable": "Available Source Lists", + "searchBySourceListsToInclude": "Selected Source Lists", + "searchBySourceListsToExclude": "Excluded Source Lists" + }, + "date": { + "title": "Date", + "all": "No date filter", + "last": "In the last", + "between": "Between", + "d": "days", + "y": "years", + "inclusive": "inclusive", + "datePickersDivider": "and" + }, + "duplicates": { + "title": "Duplicates", + "includeDuplicates": "Include duplicates" + }, + "extras": { + "title": "Extras", + "hasImages": "Only show articles with images" + } + }, + "loading": "Loading", + "noResults": "No Results", + "articlesCountDivider": "results from total of", + "orderedBy": "ordered by", + "orderSelect": { + "date": "date", + "relevance": "relevance" + }, + "tagBtn": "Tag", + "clipBtn": "Clip", + "emailBtn": "Email", + "deleteBtn": "Delete", + "commentBtn": "Comment", + "readLaterBtn": "Read Later", + "archiveBtn": "Archive", + "shareBtn": "Share", + "hoursAgo": "hours ago", + "hourAgo": "hour ago", + "words": "words", + "Reach": "Reach", + "saveFeedPopup": { + "typeSave": "Save Feed", + "typeSaveAs": "Save Feed As", + "nameLabel": "Feed name*", + "folderLabel": "Folder*", + "feedNameErrorMsg": "Please enter a valid feed name" + }, + "deleteArticlePopupText": "Are you sure you wish to delete {{count}} articles?", + "emailPopup": { + "header": "Email Articles", + "labelTo": "To", + "labelReplyTo": "Reply To", + "labelSubject": "Subject", + "submitBtn": "Send" + }, + "commentPopup": { + "header": "Add User Comment", + "inputTitlePlaceholder": "Title (optional)", + "commentPlanceholder": "Comment", + "charactersLeft": "{{count}} characters left" + } + }, + "sourceIndexTab": { + "mainInputPlaceholder": "Search for Source Name, URL, or Source ID", + "addToSourceListsBtn": "Add to Source Lists", + "addRssBtn": "Add RSS Feed", + "name": "Name", + "mediaType": "Media Type", + "license": "License", + "country": "Country", + "location": "Location", + "rank": "Rank", + "action": "Action", + "actionBtn": "Add/Remove ({{listsCount}})", + "showingCounter": "Showing {{startCount}} to {{endCount}} of {{totalCount}} entries", + "sourceInfoPopupTitle": "Source Details", + "homeUrl": "Home URL", + "lang": "Language", + "titleLabel": "Title", + "categories": "Categories" + }, + "sourceListsTab": { + "mainTitle": "Source Lists", + "addListBtn": "Add a List", + "showGlobalCheck": "Show Only Global Source Lists", + "tableLabels": { + "name": "Name", + "sources": "Sources", + "createdBy": "Created By", + "lastUpdated": "Last Updated", + "lastUpdatedBy": "Last Updated By", + "action": "Action" + }, + "share": "Share", + "unshare": "Unshare", + "rename": "Rename", + "clone": "Clone", + "delete": "Delete", + "showingCounter": "Showing {{startCount}} to {{endCount}} of {{totalCount}} entries", + "popup": { + "addToListTitle": "Add to Source Lists", + "addToListDesc": "Add sources to the following Source Lists", + "addBtn": "Add", + "updateListTitle": "Update Source Lists", + "updateListDesc": "Add or remove source \"{{name}}\"", + "saveBtn": "Save", + "enterListName": "Please enter source list name", + "addListBtn": "Add A List", + "deleteListTitle": "Confirm", + "deleteListDesc": "Are you sure you want to delete the list \"{{name}}\"?", + "deleteListSubmitBtn": "Yes", + "renameListTitle": "Please enter source list name", + "renameListSubmitBtn": "Rename", + "cloneListSubmitBtn": "Clone" + } + }, + "advancedFilters": { + "articleDate": "Article Date", + "sourceCity": "Source City", + "city": "City", + "section": "Source Section", + "sourceSection": "Source Section", + "author": "Author", + "articleLanguage": "Article Language", + "reach": "Reach", + "sentiment": "Sentiment", + "sourceCountry": "Source Country", + "state": "Source State", + "sourceState": "Source State", + "source": "Source", + "country": "Country", + "language": "Language", + "mediaType": "Media type", + "keywordRefine": "Keyword refine", + "publisher": "Publisher" + }, + + "analyzeTab": { + "welcomeMsg": "Welcome to Analyze", + "welcomeQuestion": "What would you like to perform?", + "welcomeSubtext": "(Choose from the following options below)", + "go": "Go", + "view": "View", + "createNewAnalysis": "Create New Analysis", + "openRecentAnalysis": "Open Recent Analysis", + "viewSavedAnalysis": "View Saved Analysis", + "noRecentAnalysis": "No recent analysis", + "loading": "Loading, please wait", + "savedAnalysis": "Saved analysis", + "newAnalysis": "New analysis", + "deleteAnalyses": "Delete analyses", + "deleteAnalysis": "Delete analysis", + "deleteAnalysesText": "Are you sure you want to delete the selected analyses (which include your current one)?", + "deleteAnalysisText": "You are about to delete your current analysis. Are you sure?", + "Name": "Name", + "NumberOfCharts": "Number of charts", + "DateCreated": "Date Created", + "LastUpdated": "Last Updated", + "showingCounter": "Showing {{startCount}} to {{endCount}} of {{totalCount}} saved analyses" + }, + + "notificationsTab": { + "myEmails": "My Emails", + "publishedEmails": "Published Emails", + "newAlert": "New Alert", + "newNewsletter": "New Newsletter", + "activate": "Activate", + "pause": "Pause", + "delete": "Delete", + "publish": "Publish", + "unpublish": "Unpublish", + "subscribe": "Subscribe", + "unsubscribe": "Unsubscribe", + "name": "Name", + "type": "Type", + "published": "Published", + "ScheduledTimes": "Scheduled Times", + "contents": "Contents", + "Recipients": "Recipients", + "action": "Action", + "alert": "Alert", + "newsletter": "Newsletter", + "chartsFeeds": "Charts/Feeds", + "recipients": "recipients", + "scheduledTimes": "scheduled Times", + "owner": "Owner", + "status": "Status", + "active": "Active", + "paused": "Paused", + "subscribed": "Subscribed", + "unsubscribed": "Unsubscribed", + "deleteAlertText": "Are you sure you want to delete this alert?", + "deleteNewsletterText": "Are you sure you want to delete this newsletter?", + "deleteAlertsText": "Are you sure you want to delete these {{count}} alerts / newsletters?", + "back": "Back", + "form": { + "type": { + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly" + }, + "time": { + "15m": "every 15 minutes", + "30m": "every 30 minutes", + "1h": "every hour", + "2h": "every 2 hour", + "3h": "every 3 hour", + "4h": "every 4 hour", + "6h": "every 6 hour", + "12h": "every 12 hour", + "once": "once per day" + }, + "days": { + "all": "All days", + "weekdays": "Weekdays", + "weekends": "Weekends" + }, + "period": { + "every": "Every", + "first": "First", + "second": "Second", + "third": "Third", + "fourth": "Fourth", + "last": "Last" + }, + "day": { + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday" + } + }, + "popup": { + "saveAsPlaceholder": "Please enter the name of the new alert", + "save": "Save", + "saveAs": "Save As" + } + } +} diff --git a/frontend/app/locales/en/common.json b/frontend/app/locales/en/common.json new file mode 100644 index 0000000..e0dcef8 --- /dev/null +++ b/frontend/app/locales/en/common.json @@ -0,0 +1,572 @@ +{ + "commonWords": { + "Or": "Or", + "or": "or", + "Confirm": "Confirm", + "confirm": "confirm", + "Delete": "Delete", + "delete": "delete", + "Cancel": "Cancel", + "submit": "Submit", + "cancel": "cancel", + "Yes": "Yes", + "yes": "yes", + "No": "No", + "no": "no", + "Rename": "Rename", + "rename": "rename", + "loading": "Loading..." + }, + "messages": { + "deleteMessage": "Are you sure you want to delete this?", + "noResults": "No results", + "noRows": "No rows found", + "selectMsg": "Please Select {{title}}.", + "inputMsg": "Please Enter {{title}}.", + "invalidMsg": "Please enter valid {{title}}.", + "dropdownValue0": "Select {{title}}" + }, + "tabs": { + "search": "Search", + "analyze": "Analyze", + "share": "Share", + "dashboard": "Dashboard", + "sourceIndex": "Source Index", + "sourceLists": "Source Lists", + "welcome": "Welcome", + "createAnalysis": "Create Analysis", + "savedAnalysis": "Saved Analysis", + "notifications": "Alerts", + "manageRecipients": "Manage Recipients", + "manageEmails": "Manage Emails", + "export": "Export" + }, + "plans": { + "currentPlan": "Current Plan", + "freeBasicAccount": "Free Basic Account", + "perMonth": "per month", + "activePlanDetails": "Active Plan Details", + "upgradePlan": "Upgrade Plan", + "yourTransactions": "Your Transactions", + "changeCard": "Change Payment Card" + }, + "whatsNew": { + "label": "What's New" + }, + "langs": { + "chooseLanguage": "Choose Language", + "ar": "Arabic", + "en": "English", + "es": "Español", + "de": "Deutsch", + "fr": "Français", + "he": "Hebrew", + "nl": "Nederlands", + "pt": "Português" + }, + "userSettings": { + "settings": "Settings", + "help": "Help", + "signOut": "Sign Out", + "changePassword": "Change password", + "enterRequiredFields": "Please enter required fields", + "passwordsNotMatched": "Passwords do not match", + "enterOldPassword": "Enter old password", + "enterNewPassword": "Enter new password", + "retypeNewPassword": "Retype new password", + "notifications": "Notifications", + "notificationsSub": "You have {{alertLength}} notification", + "notificationsSub_plural": "You have {{alertLength}} notifications", + "clearAll": "Clear all", + "guidedTourTooltip": "Guided Tour", + "userGuide": "User Guide", + "HowToSearch": "How to Search", + "HowToAnalyze": "How to Analyze" + }, + "sidebar": { + "My Hose": "The Hose", + "Shared Hose": "Shared", + "Deleted Hose": "Deleted", + "typeToSearch": "Type to search" + }, + "sidebarDropdown": { + "AddClippingsFeed": "Add clippings feed", + "AddFolder": "Add folder", + "DownloadSearchCriteria": "Download search criteria", + "EditSearchTemplate": "Edit search template", + "ViewUserComments": "View user comments", + "RenameFolder": "Rename folder", + "DeleteFolder": "Delete folder", + "AddArticle": "Add post", + "AddToDashboard": "Add to dashboard", + "AnalyzeFeed": "Analyze feed", + "DownloadArticleData": "Download post data", + "DownloadFeedStatistics": "Download feed statistics", + "ExportFeed": "Export feed", + "ExportFeeds": "Export feeds", + "UnexportFeed": "Unexport feed", + "UnexportFeeds": "Unexport feeds", + "RenameFeed": "Rename feed", + "DeleteFeed": "Delete feed" + }, + "sidebarPopup": { + "areYouSure": "are you sure", + "enterNamelabel": "Please enter a new name", + "enterFolderName": "Please enter a folder name", + "addFolderBtn": "Add Folder", + "addClippingsFeed": "Add Clippings Feed", + "feedName": "Feed name", + "folder": "Folder" + }, + "alerts": { + "type": { + "success": "Success", + "warning": "Warning", + "error": "Error" + }, + "notice": { + "renameFeedNotice": "Your feed was successfully renamed ", + "renameFolderNotice": "Your folder was successfully renamed ", + "noListsSelected": "Please select a source", + "updateListsForSourceNotice": "Source list changes saved", + "deleteSourceList": "Source list {{name}} deleted.", + "addSourceList": "Source list {{name}} added.", + "renameSourceList": "Source list is renamed.", + "cloneSourceList": "Source list is cloned.", + "shareSourceList": "Source list shared", + "unshareSourceList": "Source list unshared", + "saveFeed": "Feed successfully saved.", + "clipDocument": "Post(s) successfully added to your feed", + "alertSaved": "Your alert has successfully saved", + "recipientSaved": "Recipient successfully saved", + "groupSaved": "Group successfully saved", + "articleDeleted": "Post successfully deleted from feed", + "analyticsDeleted": "Analytics has been deleted successfully.", + "planUpdated": "You have successfully updated your plan.", + "cardUpdated": "You have successfully updated your payment card.", + "cancelledSubscription": "Your subscription is successfully cancelled." + }, + "error": { + "renameFolderEmpty": "Folder name must not be empty.", + "updateCategoryNameNotUnique": "There is already folder with name {{parameters}}.", + "createSourceListNameNotUnique": "Source List names must be unique. Please change the Source List name and save.", + "searchQueryEmpty": "Search query should not be blank.", + "createFeedQueryEmpty": "Search query should not be blank.", + "unknown": "Unknown server error", + "cannotUnsubscribe": "You cannot unsubscribe from this notification", + "restriction": "You have reached the limit of your license. Please contact client services to discuss your license.", + "noMediaTypesSelected": "You must have at least one media type selected.", + "groupNameEmpty": "Group name must not be empty", + "recipientNamesEmpty": "Recipient name and email must not be empty", + "createUserEmailNotUnique": "User with email {{current}} already exists", + "feedNameEmpty": "Feed name must not be empty", + "requiredInfo": "Please enter required information.", + "somethingWrong": "Sorry, something went wrong. Please try again.", + "somethingWrong2": "Sorry, something went wrong. Please try again later." + } + }, + "language": { + "all": "All", + "af": "Afrikaans", + "sq": "Albanian", + "ar": "Arabic", + "bn": "Bengali", + "bs": "Bosnian", + "bg": "Bulgarian", + "ca": "Catalan", + "zh": "Chinese", + "hr": "Croatian", + "cs": "Czech", + "da": "Danish", + "nl": "Dutch", + "en": "English", + "et": "Estonian", + "tl": "Filipino", + "fi": "Finnish", + "fr": "French", + "de": "German", + "el": "Greek", + "gu": "Gujarati", + "he": "Hebrew", + "hi": "Hindi", + "hu": "Hungarian", + "is": "Icelandic", + "id": "Indonesian", + "it": "Italian", + "ja": "Japanese", + "ko": "Korean", + "lv": "Latvian", + "lt": "Lithuanian", + "mk": "Macedonian", + "ms": "Malay", + "no": "Norwegian", + "fa": "Persian", + "pl": "Polish", + "pt": "Portuguese", + "ro": "Romanian", + "ru": "Russian", + "sr": "Serbian", + "sk": "Slovak", + "sl": "Slovenian", + "es": "Spanish", + "sv": "Swedish", + "ta": "Tamil", + "th": "Thai", + "tr": "Turkish", + "uk": "Ukrainian", + "ur": "Urdu", + "vi": "Vietnamese", + "und": "Undefined", + "U": "Unknown" + }, + "country": { + "AD": "Andorra", + "AE": "United Arab Emirates", + "AF": "Afghanistan", + "AG": "Antigua and Barbuda", + "AI": "Anguilla", + "AL": "Albania", + "AM": "Armenia", + "AO": "Angola", + "AQ": "Antarctica", + "AR": "Argentina", + "AS": "American Samoa", + "AT": "Austria", + "AU": "Australia", + "AW": "Aruba", + "AX": "Åland Islands", + "AZ": "Azerbaijan", + "BA": "Bosnia and Herzegovina", + "BB": "Barbados", + "BD": "Bangladesh", + "BE": "Belgium", + "BF": "Burkina Faso", + "BG": "Bulgaria", + "BH": "Bahrain", + "BI": "Burundi", + "BJ": "Benin", + "BL": "Saint Barthélemy", + "BM": "Bermuda", + "BN": "Brunei Darussalam", + "BO": "Bolivia, Plurinational State of", + "BQ": "Bonaire, Sint Eustatius and Saba", + "BR": "Brazil", + "BS": "Bahamas", + "BT": "Bhutan", + "BV": "Bouvet Island", + "BW": "Botswana", + "BY": "Belarus", + "BZ": "Belize", + "CA": "Canada", + "CC": "Cocos (Keeling) Islands", + "CD": "Congo, the Democratic Republic of the", + "CF": "Central African Republic", + "CG": "Congo", + "CH": "Switzerland", + "CI": "Côte d'Ivoire", + "CK": "Cook Islands", + "CL": "Chile", + "CM": "Cameroon", + "CN": "China", + "CO": "Colombia", + "CR": "Costa Rica", + "CU": "Cuba", + "CV": "Cabo Verde", + "CW": "Curaçao", + "CX": "Christmas Island", + "CY": "Cyprus", + "CZ": "Czechia", + "DE": "Germany", + "DJ": "Djibouti", + "DK": "Denmark", + "DM": "Dominica", + "DO": "Dominican Republic", + "DZ": "Algeria", + "EC": "Ecuador", + "EE": "Estonia", + "EG": "Egypt", + "EH": "Western Sahara", + "ER": "Eritrea", + "ES": "Spain", + "ET": "Ethiopia", + "FI": "Finland", + "FJ": "Fiji", + "FK": "Falkland Islands (Malvinas)", + "FM": "Micronesia, Federated States of", + "FO": "Faroe Islands", + "FR": "France", + "GA": "Gabon", + "GB": "United Kingdom", + "GD": "Grenada", + "GE": "Georgia", + "GF": "French Guiana", + "GG": "Guernsey", + "GH": "Ghana", + "GI": "Gibraltar", + "GL": "Greenland", + "GM": "Gambia", + "GN": "Guinea", + "GP": "Guadeloupe", + "GQ": "Equatorial Guinea", + "GR": "Greece", + "GS": "South Georgia and the South Sandwich Islands", + "GT": "Guatemala", + "GU": "Guam", + "GW": "Guinea-Bissau", + "GY": "Guyana", + "HK": "Hong Kong", + "HM": "Heard Island and McDonald Islands", + "HN": "Honduras", + "HR": "Croatia", + "HT": "Haiti", + "HU": "Hungary", + "ID": "Indonesia", + "IE": "Ireland", + "IL": "Israel", + "IM": "Isle of Man", + "IN": "India", + "IO": "British Indian Ocean Territory", + "IQ": "Iraq", + "IR": "Iran, Islamic Republic of", + "IS": "Iceland", + "IT": "Italy", + "JE": "Jersey", + "JM": "Jamaica", + "JO": "Jordan", + "JP": "Japan", + "KE": "Kenya", + "KG": "Kyrgyzstan", + "KH": "Cambodia", + "KI": "Kiribati", + "KM": "Comoros", + "KN": "Saint Kitts and Nevis", + "KP": "Korea (the Democratic People's Republic of)", + "KR": "Korea (the Republic of)", + "KW": "Kuwait", + "KY": "Cayman Islands", + "KZ": "Kazakhstan", + "LA": "Lao People's Democratic Republic", + "LB": "Lebanon", + "LC": "Saint Lucia", + "LI": "Liechtenstein", + "LK": "Sri Lanka", + "LR": "Liberia", + "LS": "Lesotho", + "LT": "Lithuania", + "LU": "Luxembourg", + "LV": "Latvia", + "LY": "Libya", + "MA": "Morocco", + "MC": "Monaco", + "MD": "Moldova, Republic of", + "ME": "Montenegro", + "MF": "Saint Martin (French part)", + "MG": "Madagascar", + "MH": "Marshall Islands", + "MK": "Macedonia, the former Yugoslav Republic of", + "ML": "Mali", + "MM": "Myanmar", + "MN": "Mongolia", + "MO": "Macao", + "MP": "Northern Mariana Islands", + "MQ": "Martinique", + "MR": "Mauritania", + "MS": "Montserrat", + "MT": "Malta", + "MU": "Mauritius", + "MV": "Maldives", + "MW": "Malawi", + "MX": "Mexico", + "MY": "Malaysia", + "MZ": "Mozambique", + "NA": "Namibia", + "NC": "New Caledonia", + "NE": "Niger", + "NF": "Norfolk Island", + "NG": "Nigeria", + "NI": "Nicaragua", + "NL": "Netherlands[note 1]", + "NO": "Norway", + "NP": "Nepal", + "NR": "Nauru", + "NU": "Niue", + "NZ": "New Zealand", + "OM": "Oman", + "PA": "Panama", + "PE": "Peru", + "PF": "French Polynesia", + "PG": "Papua New Guinea", + "PH": "Philippines", + "PK": "Pakistan", + "PL": "Poland", + "PM": "Saint Pierre and Miquelon", + "PN": "Pitcairn", + "PR": "Puerto Rico", + "PS": "Palestine, State of", + "PT": "Portugal", + "PW": "Palau", + "PY": "Paraguay", + "QA": "Qatar", + "RE": "Réunion", + "RO": "Romania", + "RS": "Serbia", + "RU": "Russian Federation", + "RW": "Rwanda", + "SA": "Saudi Arabia", + "SB": "Solomon Islands", + "SC": "Seychelles", + "SD": "Sudan", + "SE": "Sweden", + "SG": "Singapore", + "SH": "Saint Helena, Ascension and Tristan da Cunha", + "SI": "Slovenia", + "SJ": "Svalbard and Jan Mayen", + "SK": "Slovakia", + "SL": "Sierra Leone", + "SM": "San Marino", + "SN": "Senegal", + "SO": "Somalia", + "SR": "Suriname", + "SS": "South Sudan", + "ST": "Sao Tome and Principe", + "SV": "El Salvador", + "SX": "Sint Maarten (Dutch part)", + "SY": "Syrian Arab Republic", + "SZ": "Swaziland", + "TC": "Turks and Caicos Islands", + "TD": "Chad", + "TF": "French Southern Territories", + "TG": "Togo", + "TH": "Thailand", + "TJ": "Tajikistan", + "TK": "Tokelau", + "TL": "Timor-Leste", + "TM": "Turkmenistan", + "TN": "Tunisia", + "TO": "Tonga", + "TR": "Turkey", + "TT": "Trinidad and Tobago", + "TV": "Tuvalu", + "TW": "Taiwan, Province of China[note 2]", + "TZ": "Tanzania, United Republic of", + "UA": "Ukraine", + "UG": "Uganda", + "UM": "United States Minor Outlying Islands", + "US": "United States", + "UY": "Uruguay", + "UZ": "Uzbekistan", + "VA": "Holy See (Vatican City State)", + "VC": "Saint Vincent and the Grenadines", + "VE": "Venezuela, Bolivarian Republic of", + "VG": "Virgin Islands, British", + "VI": "Virgin Islands, U.S.", + "VN": "Viet Nam", + "VU": "Vanuatu", + "WF": "Wallis and Futuna", + "WS": "Samoa", + "YE": "Yemen", + "YT": "Mayotte", + "ZA": "South Africa", + "ZM": "Zambia", + "ZW": "Zimbabwe" + }, + "sentiment": { + "POSITIVE": "Positive", + "NEUTRAL": "Neutral", + "NEGATIVE": "Negative" + }, + "articleDate": { + "15 Minutes": "15 Minutes", + "30 Minutes": "30 Minutes", + "1 Hour": "1 Hour", + "24 Hour": "24 Hours", + "7 Days": "7 Days" + }, + "state": { + "AL": "Alabama", + "AK": "Alaska", + "AZ": "Arizona", + "AR": "Arkansas", + "CA": "California", + "CO": "Colorado", + "CT": "Connecticut", + "DE": "Delaware", + "DC": "District of Columbia", + "FL": "Florida", + "GA": "Georgia", + "HI": "Hawaii", + "ID": "Idaho", + "IL": "Illinois", + "IN": "Indiana", + "IA": "Iowa", + "KS": "Kansas", + "KY": "Kentucky", + "LA": "Louisiana", + "ME": "Maine", + "MD": "Maryland", + "MA": "Massachusetts", + "MI": "Michigan", + "MN": "Minnesota", + "MS": "Mississippi", + "MO": "Missouri", + "MT": "Montana", + "NE": "Nebraska", + "NV": "Nevada", + "NH": "New Hampshire", + "NJ": "New Jersey", + "NM": "New Mexico", + "NY": "New York", + "NC": "North Carolina", + "ND": "North Dakota", + "OH": "Ohio", + "OK": "Oklahoma", + "OR": "Oregon", + "PA": "Pennsylvania", + "RI": "Rhode Island", + "SC": "South Carolina", + "SD": "South Dakota", + "TN": "Tennessee", + "TX": "Texas", + "UT": "Utah", + "VT": "Vermont", + "VA": "Virginia", + "WA": "Washington", + "WV": "West Virginia", + "WI": "Wisconsin", + "WY": "Wyoming" + }, + "filtersTable": { + "refine": "Refine", + "clear": "Clear", + "clearAll": "Clear All", + "more": "More", + "less": "Less", + "clearMessage": "This filter has been cleared. Click 'Refine' to see full list." + }, + "advancedFilters": { + "articleDate": "Post Date", + "sourceCity": "Source City", + "city": "City", + "section": "Source Section", + "sourceSection": "Source Section", + "author": "Author", + "articleLanguage": "Post Language", + "reach": "Reach", + "sentiment": "Sentiment", + "sourceCountry": "Source Country", + "state": "Source State", + "sourceState": "Source State", + "source": "Source", + "country": "Country", + "language": "Language", + "mediaType": "Media type", + "keywordRefine": "Keyword refine", + "publisher": "Publisher" + }, + "restrictions": { + "searchesPerDay": "Search License", + "savedFeeds": "Save License", + "alerts": "Alert License", + "newsletters": "Newsletter License" + } +} diff --git a/frontend/app/locales/en/loginApp.json b/frontend/app/locales/en/loginApp.json new file mode 100644 index 0000000..85c5bfd --- /dev/null +++ b/frontend/app/locales/en/loginApp.json @@ -0,0 +1,86 @@ +{ + "signIn": "Sign In", + "login": { + "mainLabel": "Sign in", + "subLabel": "with your Socialhose Account.", + "noAccount": "No account?", + "signUpNow": "Sign up now", + "form": { + "emailLabel": "Email", + "emailPlaceholder": "Email", + "passwordLabel": "Password", + "passwordPlaceholder": "Password" + }, + "forgotPass": "Forgot your Password?", + "signInBtn": "Sign In" + }, + "register": { + "passwordNotMatched": "Confirm Password does not match.", + "labels": { + "email": "Email", + "firstName": "First Name", + "lastName": "Last Name", + "company": "Company", + "jobFunction": "Job Function", + "employees": "Employees", + "industry": "Industry", + "websiteURL": "Website URL", + "password": "Password", + "confirmPassword": "Confirm Password" + }, + "placeholders": { + "password": "Enter Password", + "confirmPassword": "Confirm Password" + }, + "signInText": "Already have an account?", + "signInBtn": "Sign in", + "loading": "Loading...", + "registerBtn": "Register", + "agreement": "By registering, you agree to our <1>Privacy Policy, <2>Terms & Conditions and <3>Acceptable Use Policy.", + "freeRegisterSuccess": "You have successfully <1 /> registered to SOCIALHOSE.IO with a <2>Free Basic Account.", + "paidRegisterSuccess": "You have successfully paid and <1 /> registered to SOCIALHOSE.IO.", + "successBottomText": "Check your email ({{email}}) for a link to activate your account. If it doesn’t appear within a few minutes, check your spam folder.", + "verification": { + "failed": "Sorry, we cannot verify your account.", + "success": "You have successfully verified your email address.", + "loginBtn": "Login Now" + } + }, + "forgotPass": { + "mainLabel": "Forgot your Password?", + "subLabel": "Use the form below to recover it.", + "emailLabel": "Email", + "emailPlaceholder": "Email", + "signIn": "Sign in to existing account", + "resetBtn": "Recover Password" + }, + "resetPass": { + "mainLabel": "Reset Password", + "subLabel": "Use the form below to reset the password.", + "newPasswordLabel": "New Password", + "newPasswordPlaceholder": "Password", + "signIn": "Sign in to existing account", + "resetBtn": "Reset Password" + }, + "footer": { + "privacyPolicy": "Privacy Policy", + "acceptableUsePolicy": "Acceptable Use Policy", + "termsConditions": "Terms & Conditions", + "copyright": "Copyright © {{year}} SOCIALHOSE.IO. All rights reserved." + }, + "commonSection": { + "insightsHeading": "Consumer and Audience Insights", + "insightsText": "Know your audience and segment them so you can better understand and reach them. Gain insights into consumer habits to better create relevant content.", + "brandHeading": "Consumer and Audience Insights", + "brandText": "With our technology, you'll be able to seamlessly manage your brand's online presence and get insights into conversations happening around your brand instantaneously.", + "socialHeading": "Social Listening", + "socialText": "Your access to a data from multiple social networks is provided by our Elasticsearch database, making it searchable at all times." + }, + "errorMessages": { + "badCredentials": "The email or password is incorrect." + }, + "messages": { + "forgotPasswordSubmit": "Check your email ({{email}}) for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder.", + "passwordUpdated": "You have successfully updated your password." + } +} diff --git a/frontend/app/locales/en/tabsContent.json b/frontend/app/locales/en/tabsContent.json new file mode 100644 index 0000000..cb3cc4d --- /dev/null +++ b/frontend/app/locales/en/tabsContent.json @@ -0,0 +1,761 @@ +{ + "searchTab": { + "clearBtn": "Clear All", + "searchBtn": "Search", + "newSearchBtn": "New Search", + "editFeedBtn": "Edit", + "saveBtn": "Save", + "savingBtn": "Saving...", + "saveAsBtn": "Save As", + "sourceTypes": { + "all": "Select All", + "blogs": "Blogs", + "news": "News", + "classifieds": "Classifieds", + "comments": "Comments", + "forums": "Forums", + "reviews": "Reviews", + "reddit": "Reddit", + "twitter": "Twitter", + "instagram": "Instagram" + }, + "tags": "Tags", + "categories": "Categories", + "moreComments": "More comments", + "datesRange": "Select Date Range", + "commentMetadata": "{{author}}", + "searchInputPlaceholder": "Enter search terms (e.g. +covid +texas -abbott -vaccine +\"ted cruz\")", + "addIndexTermsBtn": "Add Index Terms", + "userSubscription": { + "until": "until", + "now": "now", + "all": "All Dates", + "1d": "1 day", + "7d": "7 days", + "15d": "15 days", + "30d": "30 days", + "60d": "60 days", + "90d": "90 days", + "100d": "100 days", + "1y": "1 year", + "2y": "2 years", + "3y": "3 years", + "5y": "5 years" + }, + "searchDates": { + "btnLabel": "Search", + "subscriptionLabel": "Your subscription", + "resetBtn": "Reset", + "all": "All Dates", + "1d": "1D", + "7d": "7D", + "15d": "2W", + "30d": "1M", + "60d": "2M", + "150d": "5M", + "300d": "10M", + "90d": "90D", + "100d": "100D", + "1y": "1Y", + "2y": "2Y", + "3y": "3Y", + "5y": "5Y", + "last": "Search the Last", + "between": "Search Between", + "and": "and" + }, + "searchBySection": { + "searchByBtn": "Advanced search", + "emphasis": { + "title": "Emphasis", + "headlineLabel": "Headline", + "positionLabel": "Position", + "ensitivityLabel": "Sensitivity", + "include": "Includes", + "exclude": "Excludes", + "anywhereCheck": "Anywhere", + "headlinePositionLabel_1": "Search terms must appear in headline or first", + "headlinePositionLabel_2": "of post text", + "nonSensitive": "Non-sensitive", + "caseSensitive": "Case sensitive", + "characterSensitive": "Case + accented character sensitive" + }, + "languages": { + "title": "Languages" + }, + "locations": { + "title": "Locations", + "countriesSelect": "Countries", + "statesSelect": "US States", + "locations": "Drag locations to other boxes to select.", + "locationsToInclude": "Post must be in these locations...", + "locationsToExclude": "...and NOT in these locations." + }, + "sources": { + "title": "Sources", + "source": "Source", + "siteType": "Site Type", + "includeText": "Include", + "excludeText": "Exclude", + "availSources": "Available sources:", + "selectedSources": "Selected sources:", + "mediatype": "Media Type", + "country": "Country", + "lang": "Language", + "rank": "Rank", + "selectSource": "Select sources from the left table." + }, + "sourceLists": { + "title": "Source Lists", + "searchBySourceListsAvailable": "Available Source Lists", + "searchBySourceListsToInclude": "Selected Source Lists", + "searchBySourceListsToExclude": "Excluded Source Lists" + }, + "date": { + "title": "Date", + "all": "No date filter", + "last": "In the last", + "between": "Between", + "d": "days", + "y": "years", + "inclusive": "inclusive", + "datePickersDivider": "and" + }, + "duplicates": { + "title": "Duplicates", + "includeDuplicates": "Include duplicates" + }, + "extras": { + "title": "Extras", + "hasImages": "Only show posts with images" + } + }, + "loading": "Loading", + "results": "Results", + "noResults": "No Results", + "notSynchronized": "Not synchronized", + "articlesCountDivider": "Showing {{resultsCount}} results from total of {{totalCount}}", + "orderedBy": "ordered by", + "orderSelect": { + "date": "date", + "relevance": "relevance" + }, + "tagBtn": "Tag", + "clipBtn": "Clip", + "filter": "Filter", + "hide": "Hide", + "emailBtn": "Email", + "deleteBtn": "Delete", + "commentBtn": "Comment", + "readLaterBtn": "Read Later", + "archiveBtn": "Archive", + "shareBtn": "Share", + "hoursAgo": "hours ago", + "hourAgo": "hour ago", + "words": "words", + "Reach": "Reach", + "saveFeedPopup": { + "typeSave": "Save Feed", + "typeSaveAs": "Save Feed As", + "nameLabel": "Feed Name", + "folderLabel": "Folder", + "feedNameErrorMsg": "Please enter a valid feed name" + }, + "deleteArticlePopupText": "Are you sure you want to delete this post?", + "deleteArticlePopupText_plural": "Are you sure you want to delete these {{articlesLength}} posts?", + "emailPopup": { + "header": "Email Posts", + "labelTo": "To", + "labelReplyTo": "Reply To", + "labelSubject": "Subject", + "submitBtn": "Send", + "dontSend": "Don't send", + "sendAnyway": "Send anyway", + "sendConfirmWithoutSubject": "Do you want to send this message without a subject?" + }, + "commentPopup": { + "addUserComment": "Add User Comment", + "editUserComment": "Edit User Comment", + "inputTitlePlaceholder": "Title (optional)", + "commentPlanceholder": "Comment", + "charactersLeft": "{{count}} characters left" + }, + "clipPopup": { + "header": "Clip Posts", + "hint1": "Drag the widget to a saved feed on the left side of the screen:", + "hint2": "Or select a feed that you have recently clipped to:", + "clippedArticles": "{{count}} clipped posts/s" + }, + "tweet": "Tweet this link", + "yammer": "Post this link on Yammer" + }, + "sourceIndexTab": { + "mainInputPlaceholder": "Search for Source Name, URL, or Source ID", + "addToSourceListsBtn": "Add to Source Lists", + "addRssBtn": "Add RSS Feed", + "name": "Name", + "mediaType": "Media Type", + "license": "License", + "country": "Country", + "location": "Location", + "rank": "Rank", + "action": "Action", + "actionBtn": "Add/Remove ({{listsCount}})", + "showingCounter": "Showing {{startCount}} to {{endCount}} of {{totalCount}} entries", + "sourceInfoPopupTitle": "Source Details", + "homeUrl": "Home URL", + "lang": "Language", + "titleLabel": "Title", + "categories": "Categories" + }, + "sourceListsTab": { + "mainTitle": "Source Lists", + "addListBtn": "Add a List", + "showGlobalCheck": "Show Only Global Source Lists", + "tableLabels": { + "name": "Name", + "sources": "Sources", + "createdBy": "Created By", + "lastUpdated": "Last Updated", + "lastUpdatedBy": "Last Updated By", + "action": "Action" + }, + "share": "Share", + "unshare": "Unshare", + "rename": "Rename", + "clone": "Clone", + "delete": "Delete", + "showingCounter": "Showing {{startCount}} to {{endCount}} of {{totalCount}} entries", + "popup": { + "addToListTitle": "Add to Source Lists", + "addToListDesc": "Add sources to the following Source Lists", + "addBtn": "Add", + "updateListTitle": "Update Source Lists", + "updateListDesc": "Add or remove source \"{{name}}\"", + "saveBtn": "Save", + "enterListName": "Please enter source list name", + "addListBtn": "Add A List", + "deleteListTitle": "Confirm", + "deleteListDesc": "Are you sure you want to delete the list \"{{name}}\"?", + "deleteListSubmitBtn": "Yes", + "renameListTitle": "Please enter source list name", + "renameListSubmitBtn": "Rename", + "cloneListSubmitBtn": "Clone" + } + }, + "analyzeTab": { + "welcomeMsg": "Welcome to Analyze", + "welcomeQuestion": "What would you like to perform?", + "welcomeSubtext": "(Choose from the following options below)", + "go": "Go", + "view": "View", + "createNewAnalysis": "Create New Analysis", + "openRecentAnalysis": "Open Recent Analysis", + "viewSavedAnalysis": "View Saved Analysis", + "noRecentAnalysis": "No recent analysis", + "loading": "Loading, please wait", + "savedAnalysis": "Saved analysis", + "newAnalysis": "New analysis", + "deleteAnalysis": "Delete Analysis", + "Name": "Name", + "NumberOfCharts": "Number of charts", + "DateCreated": "Date Created", + "LastUpdated": "Last Updated", + "showingCounter": "Showing {{startCount}} to {{endCount}} of {{totalCount}} saved analyses", + "enterDetails": "Enter Details", + "updateDetails": "Update Details", + "selectFeeds": "Drop Feeds Here", + "selectedFeeds": "Selected Feeds", + "dateRange": "Select Date Range", + "startDatePlaceholder": "Start Date", + "endDatePlaceholder": "End Date", + "submit": "Submit", + "updateBtn": "Update", + "createBtn": "Create", + "dropDesc": "Drag feeds from the left panel and drop here", + "releaseDesc": "Release to add the feed", + "noData": "No data", + "createAlert": "Create Alert: {{alertsLength}} selected", + "createAlertBtn": "Create Alert", + "selectedCharts": "Selected Charts", + "chartMenus": { + "addToAlert": "Add to Create Alert", + "addedToAlerts": "Added to Alerts", + "refresh": "Refresh", + "addToDashboard": "Add to Dashboard", + "toggleHV": "Toggle Horizontal/Vertical" + }, + "charts": { + "topCountries": "Top Countries", + "topLanguages": "Top Languages", + "gender": "Gender", + "mentions": "Mentions", + "mentionsOverTime": "Mentions Over Time", + "engagement": "Engagement", + "engagementOverTime": "Engagement Over Time", + "potentialReach": "Potential Reach", + "potentialReachOverTime": "Potential Reach Over Time", + "proportionofSentiment": "Proportion of Sentiment", + "topInfluencers": "Top Influencers", + "topThemes": "Top Themes", + "themesOverTime": "Themes Over Time", + "sentimentOverTime": "Sentiment Over Time", + "shareofSentiment": "Share of Sentiment" + }, + "influencerCols": { + "details": "Details", + "sentiments": "Sentiments", + "reach": "Reach", + "rank": "Rank", + "influencers": "Influencers", + "sourceType": "Source Type", + "total": "Total", + "positive": "Positive", + "neutral": "Neutral", + "negative": "Negative", + "engagement": "Engagement", + "engagementPerMention": "Engagement Per Mention" + }, + "overviewCharts": { + "overview": "Overview", + "none": "None", + "mediaTypes": "Media Types", + "sentiments": "Sentiments", + "countries": "Countries", + "languages": "Languages", + "performance": "Performance", + "influencers": "Influencers", + "sentiment": "Sentiment", + "themes": "Themes", + "demographics": "Demographics", + "worldMap": "World Map" + }, + "savedAnalytics": { + "feeds": "Feeds", + "dateRange": "Date Range", + "createdAt": "Created At", + "actions": "Actions", + "view": "View", + "edit": "Edit", + "delete": "Delete" + } + }, + "tableSwitcher": { + "myEmails": "My Emails", + "publishedEmails": "Published Emails", + "recipients": "Recipients", + "groups": "Groups" + }, + "deletePopup": { + "alert": "Are you sure you want to delete this alert?", + "alerts": "Are you sure you want to delete these {{count}} alerts?", + "recipient": "Are you sure want to delete this recipient?", + "recipients": "Are you sure you want to delete these {{count}} recipients?", + "group": "Are you sure you want to delete this group?", + "groups": "Are you sure you want to delete these {{count}} groups?", + "email": "Are you sure you want to delete this email?", + "emails": "Are you sure you want to delete these {{count}} emails?" + }, + + "notificationsTab": { + "newAlert": "New Alert", + "newNewsletter": "New Newsletter", + "activate": "Activate", + "pause": "Pause", + "delete": "Delete", + "publish": "Publish", + "unpublish": "Unpublish", + "subscribe": "Subscribe", + "unsubscribe": "Unsubscribe", + "name": "Name", + "type": "Type", + "published": "Published", + "ScheduledTimes": "Scheduled Times", + "contents": "Contents", + "Recipients": "Recipients", + "action": "Action", + "alert": "Alert", + "alerts": "alerts", + "newsletter": "Newsletter", + "newsletters": "Newsletters", + "chartsFeeds": "Charts/Feeds", + "recipients": "recipients", + "scheduledTimes": "scheduled Times", + "sentTime": "Sent Time", + "owner": "Owner", + "status": "Status", + "active": "Active", + "paused": "Paused", + "subscribed": "Subscribed", + "unsubscribed": "Unsubscribed", + "deleteAlertText": "Are you sure you want to delete this alert?", + "deleteNewsletterText": "Are you sure you want to delete this newsletter?", + "deleteAlertsText": "Are you sure you want to delete these {{count}} alerts?", + "back": "Back", + "form": { + "dragFeed": "Drag a feed or chart to this area to add it", + "activeScheduledTimes": "Active scheduled times", + "editAlert": "Edit Alert", + "createAlert": "Create Alert", + "add": "Add", + "name": "Name", + "recipient": "Recipient(s)", + "emailSubject": "Email Subject", + "automatedEmail": "Automated Email", + "automatedEmailDesc": "Use automated email subject based on the feeds", + "publish": "Publish", + "publishDesc": "Alerts and Newsletters that are Published are available for other users to subscribe", + "unsubscribe": "Unsubscribe Link", + "unsubscribeDesc": "Allow recipients to unsubscribe from Alert", + "notifications": "Notifications", + "notificationsDesc": "Notify creator when recipients unsubscribe", + "feeds": "Feeds/Charts", + "options": "Options", + "articleExtracts": "Post Extracts", + "contextualExtracts": "Contextual extract", + "startExtracts": "Start of text extract", + "noExtracts": "No post extract", + "highlightKeywords": "Highlight Keywords", + "showSourceCountry": "Show Source Country", + "showUserComments": "Show User Comments", + "layout": "Layout", + "enhancedHtml": "Enhanced HTML", + "plainHtml": "Plain HTML", + "sendWhenEmpty": "Send When Empty", + "timezone": "Timezone", + "change": "Change", + "automatic": "Automatic", + "sendUntil": "Send Until", + "cancel": "Cancel", + "save": "Save", + "saveAs": "Save As", + "selectDate": "Select a Date", + "type": { + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly" + }, + "time": { + "15m": "every 15 minutes", + "30m": "every 30 minutes", + "1h": "every hour", + "2h": "every 2 hour", + "3h": "every 3 hour", + "4h": "every 4 hour", + "6h": "every 6 hour", + "12h": "every 12 hour", + "once": "once per day" + }, + "days": { + "all": "All days", + "weekdays": "Weekdays", + "weekends": "Weekends" + }, + "period": { + "every": "Every", + "first": "First", + "second": "Second", + "third": "Third", + "fourth": "Fourth", + "last": "Last" + }, + "day": { + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday" + } + }, + "history": { + "hideSendHistory": "Hide send history", + "showSendHistory": "Show send history", + "sentTime": "Sent Time", + "showMore": "Show more", + "loading": "Loading..." + }, + "newsLetter": { + "createNewsletter": "Create Newsletter", + "name": "Name" + }, + "popup": { + "saveAsPlaceholder": "Please enter the name of the new alert", + "save": "Save", + "saveAs": "Save As" + } + }, + + "manageRecipientsTab": { + "newRecipient": "New Recipient", + "newGroup": "New Group", + "name": "Name", + "groupName": "Group Name", + "email": "Email Address", + "groups": "Groups", + "subscriptions": "Subscriptions", + "creationDate": "Creation Date", + "status": "Status", + "recipientsNumber": "Number of Recipients", + "recipients": "Recipients", + "Subscribed": "Subscribed", + "Unsubscribed": "Unsubscribed", + "All": "All", + "Enrolled": "Enrolled", + "NotEnrolled": "Not Enrolled", + "form": { + "recipient": { + "basicInfo": "Basic Information", + "nameStatus": "Profile Status", + "unsaved": "Unsaved User", + "deleteButton": "Delete User", + "firstName": "First Name", + "lastName": "Last Name", + "email": "Email Address", + "enroll": "Enroll", + "creationDate": "Creation Date" + }, + "group": { + "basicInfo": "Basic Information", + "nameStatus": "Group Status", + "unsaved": "Unsaved Group", + "deleteButton": "Delete Group", + "name": "Group Name", + "description": "Description", + "recipientsNumber": "Number of Members", + "addedDate": "Added Date", + "creationDate": "Creation Date" + }, + "cancel": "Cancel", + "save": "Save" + }, + "tables": { + "recipients": "Recipients", + "groups": "Groups", + "emailHistory": "Email History", + "subscriptions": "Subscriptions" + } + }, + + "manageEmailsTab": { + "owner": "Owner", + "recipient": "Recipient", + "feed": "Feed", + "selectFilter": "Select Filter", + "notifications": "# Alerts ", + "allEmails": "All Emails", + "emailFilter": "Email Filter", + "filter": "Filter", + "filterBy": "Filter By: ", + "currentFilter": "Current Filter", + "refresh": "Refresh" + }, + + "toggler": { + "active": "Active", + "paused": "Paused", + "subscribed": "Subscribed", + "unsubscribed": "Unsubscribed", + "yes": "Yes", + "no": "No" + }, + + "exportTab": { + "topMessage": "Select feeds to export to your intranet or application. To add a feed to this page, select \"Export feed\" from the feed's dropdown menu. You can export up to the number of feeds set for your export license.", + "feedName": "Feed Name", + "exportWith": "Export With", + "actions": "Actions", + "delete": "Delete", + "close": "Close", + "export": "Export", + "confirm": "Confirm", + "exportDeleteMessage": "Are you sure you want to delete '{{feedName}}' from export list?", + "exportPopup": { + "line1": "The export URL for this feed is as follows:", + "line2": "to call the feed using Secure HTTP, change http:// to https://", + "line3": "The following parameters can be adjusted:", + "param1": "Set the number of results to be returned, between 1 and 200", + "param2": "Set the type of post extract: \"s\" for the start of post, \"sc\" for a contextual extract based around the search terms, or \"n\" for no post extract", + "param3": "Set to \"1\" to include image URLs, set to \"0\" to exclude image URLs", + "param4": "Add \"&text_format=text\" to the URL to format the headline and extract as plain text instead of HTML" + } + }, + "restrictions": { + "perDay": "per day", + "perMonth": "per month", + "alertLicenses": "Alert Licenses", + "totalNewsltter": "Total Newsltter", + "webfeedLicenses": "Webfeed Licenses", + "searchLicenses": "Search Licenses", + "feedLicenses": "Feed Licenses" + }, + "plans": { + "sidebar": { + "activePlanDetails": "Active Plan Details", + "changeCard": "Change Card", + "updatePlan": "Update Plan", + "yourTransactions": "Your Transactions" + }, + "currentPlan": { + "subHeading": "Current Plan", + "freePlan": "FREE", + "perMonth": "per month", + "changePlan": "Change plan", + "upgradeYourPlan": "Upgrade Your Plan", + "upgradeText": "Only pay for what you want from the following options. All billing is monthly.", + "currentPlanDetails": "Current Plan Details", + "selectedMediaTypes": "Selected Media Types", + "upgradeToGet": "Upgrade to get", + "none": "None", + "selectedLicenses": "Selected Licenses", + "feedsLicenses": "Feed Licenses", + "searchLicenses": "Search Licenses", + "webfeedLicenses": "Webfeed Licenses", + "alertLicenses": "Alert Licenses", + "userAccounts": "User Accounts", + "features": "Features", + "analytics": "Analytics", + "cancelSubscriptionBtn": "Cancel Subscription", + "cancelWarning": "(If you cancel your current plan, then you will be converted to Free Basic Plan from the next billing cycle.)", + "alreadyCancelled": "You have already cancelled your current plan.", + "cancelModal": { + "header": "Cancel Subscription", + "line1": "We'll miss you, {{firstName}}.", + "line2": "Okay, here's what's going to happen...", + "warn1": "All your feeds will be permanently deleted.", + "warn2": "All your alerts will stop working immediately. Your mailing list groups will remain in your account, but will not be sending out any alerts.", + "warn3": "You will still have access to create a new feed as a Free Basic Account.", + "warn4": "Once you cancel your current plan, you cannot update your plan until the current billing cycle ends.", + "undoBtn": "Undo", + "loadingBtn": "Loading...", + "cancelSubscriptionBtn": "Cancel Subscription", + "reasonSelect": "Please select at least one option.", + "feedbackPara": "Before you go, would you mind letting us know why? Your feedback will help us get better.", + "reasonCancellation": "Reason for cancellation", + "noNeeds": "SOCIALHOSE.IO is not working for our needs", + "tooNoisy": "The search results are too noisy / misses", + "confusing": "The system is confusing", + "expensive": "It's too expensive", + "covid": "COVID-19 Related", + "other": "Other", + "tellMore": "We're sorry to hear that. Would you like to tell us more?" + } + }, + "updatePlan": { + "heading": "Update Plan", + "planLoadingFailed": "Sorry, something went wrong.", + "tryAgainBtn": "Try again", + "subText": "Bite-sized à la carte menu options with monthly billing. You can select one of the pre-configured plans we designed for you.", + "learnMoreBtn": "Learn more", + "prePlans": "Pre-configured Plans", + "mediaTypes": "Media Types", + "licenses": "Licenses", + "features": "Features", + "deselectTooltip": "Click to deselect", + "selectTooltip": "Click to select", + "addOns": "Add-ons", + "totalCost": "Total Cost", + "monthly": "Monthly", + "cancelledWarning": "You have {{text}} your current plan. Therefore, you can only update your plan after the current billing cycle ends.", + "continueBtnLoading": "Loading...", + "continueBtn": "Continue to Confirmation", + "billingHeading": "Billing and Payment Details", + "error": "Error", + "back": "Back", + "payBtn": "Pay ${{totalCost}}", + "confirmationHeading": "Confirmation", + "upgradeNotice": "You have chosen to upgrade the plan. As you are going to add any additional services in the middle of a billing cycle, the total will be re-calculated based on your entire package and charged the difference in full, even if there are days over from the billing cycle.", + "downgradeNotice": "You have chosen to downgrade the plan. So, it will take effect on the next billing cycle, including access to the options/features. Meaning, you can downgrade x days before the billing cycle, continue to have the service but the downgrade happens on the beginning of the upcoming billing cycle. Once you proceed, you cannot update your plan until the billing cycle ends.", + "alreadyStoredCard": "You already have a payment card stored. Are you sure you want to proceed with it?", + "payWithOtherCardBtn": "Pay with other card", + "payWithStoredCardBtn": "Pay with stored card", + "payLoading": "Processing..." + }, + "billingForm": { + "fullName": "Full Name", + "addr1": "Address Line 1", + "addr1Desc": "e.g., street, PO Box, or company name", + "addr2": "Address Line 2", + "addr2Desc": "e.g., apartment, suite, unit, or building", + "city": "City", + "cityDesc": "City, district, suburb, town, or village", + "state": "State", + "stateDesc": "State, county, province, or region", + "zip": "Zip", + "zipDesc": "ZIP or postal code", + "country": "Country", + "email": "Billing Email", + "phone": "Billing Phone Number", + "phoneDesc": "Phone number including extension", + "cardHeading": "Card Details", + "agreement": "By submitting, you agree to our <1>Privacy Policy, <2>Terms & Conditions and <3>Acceptable Use Policy" + }, + "upgradeModal": { + "heading": "Upgrade Plan!", + "text": "In order to access this feature, you must upgrade your plan first.", + "learnMore": "Learn more", + "upgradeNowBtn": "Upgrade now", + "maybeLaterBtn": "Maybe later" + }, + "transactions": { + "heading": "Your Transactions", + "activationDate": "Activation Date", + "expirationDate": "Expiration Date", + "transactionDate": "Transaction Date", + "amount": "Amount", + "status": "Status", + "actions": "Actions", + "more": "More", + "modal": { + "heading": "Transaction and Plan Details", + "transactionDetails": "Transaction Details", + "transactionDate": "Transaction Date", + "activationDate": "Activation Date", + "expirationDate": "Expiration Date", + "amount": "Amount", + "status": "Status", + "billingDetails": "Billing Details", + "name": "Name", + "email": "Email", + "phone": "Phone", + "address": "Address", + "invoiceNo": "Invoice No.", + "showInvoiceLink": "Show Invoice", + "cancelBtn": "Cancel" + } + }, + "changeCard": { + "heading": "Change Billing and Payment Details", + "subText": "If you change your card, your future payments will be done using this card.", + "error": "Error", + "loadingBtn": "Loading...", + "changeCardBtn": "Change Card" + } + }, + "webtour": { + "search": { + "start": "Welcome! Let us get you started by orienting you with our system!", + "feedsView": "Here, you can organize your feeds and folders.", + "mainTabs": "There are 3 main pages: <1>Search to find content, <1>Analyze to generate reports, and <1>Share to distribute findings via alerts or webfeeds.", + "userSettings": "You can configure user settings here. You can also find this guide here any time in the future.", + "license": "Here you can see your license allowances as you utilize them.", + "searchField": "A simple boolean search looks like this: <1>+BMW -Texas. Which will find all mentions of \"bmw\" without \"texas\" in them.", + "dateRange": "You can set the date range here to broaden or narrow the scope of your search.", + "mediaChannels": "Select the media channels that you would like to include in your search.", + "advancedSearch": "Click on <1>Advanced Search to uncover the different options for your search.", + "emphasis": "<0>Emphasis: Include or exclude specific words or phrases in the headline of a news post or a blog post.", + "languages": "<0>Languages: Capture the content that is tagged with the following language(s).", + "locations": "<0>Locations: Include or exclude content that is geotagged with the following countries or US States.", + "extras": "<0>Extras: Only show posts with images.", + "saveSearch": "If you like your search results you can save it as a feed, or reset by clicking on New search." + }, + "analytics": { + "start": "We made running analytics so simple, it’s almost ridiculous!", + "dragFeed": "You click and drag any of your feeds and drop it into the drop box.", + "drop": "That’s a big box! You can drop more than just one feed.", + "dateRange": "Select the date range you want your analysis to cover.", + "create": "Click create. That’s it!" + } + } +} diff --git a/frontend/app/locales/fr/common.json b/frontend/app/locales/fr/common.json new file mode 100644 index 0000000..56b7540 --- /dev/null +++ b/frontend/app/locales/fr/common.json @@ -0,0 +1,572 @@ +{ + "commonWords": { + "Or": "Ou", + "or": "ou", + "Confirm": "Confirmer", + "confirm": "confirmer", + "Delete": "Supprimer", + "delete": "supprimer", + "Cancel": "Annuler", + "submit": "Soumettre", + "cancel": "annuler", + "Yes": "Oui", + "yes": "oui", + "No": "Non", + "no": "non", + "Rename": "Renommer", + "rename": "renommer", + "loading": "Chargement en cours…" + }, + "messages": { + "deleteMessage": "Êtes-vous sûr de vouloir supprimer ceci ?", + "noResults": "Aucun résultat", + "noRows": "Aucune rangée n'a été trouvée", + "selectMsg": "Veuillez sélectionner{{title}}.", + "inputMsg": "Veuillez entrer {{title}}.", + "invalidMsg": "Veuillez saisir un {{title}} valide.", + "dropdownValue0": "Sélectionner {{title}}" + }, + "tabs": { + "search": "Rechercher", + "analyze": "Analyser", + "share": "Partager", + "dashboard": "Tableau de bord", + "sourceIndex": "Index des sources", + "sourceLists": "Listes source", + "welcome": "Bienvenue", + "createAnalysis": "Créer une analyse", + "savedAnalysis": "Analyse enregistrée", + "notifications": "Alertes", + "manageRecipients": "Gérer les destinataires", + "manageEmails": "Gérer les e-mails", + "export": "Exporter" + }, + "plans": { + "currentPlan": "Forfait actuel", + "freeBasicAccount": "Compte de base gratuit", + "perMonth": "mensuellement", + "activePlanDetails": "Détails du forfait actif", + "upgradePlan": "Passer à un forfait supérieur", + "yourTransactions": "Vos Transactions", + "changeCard": "Modifier la carte de paiement" + }, + "whatsNew": { + "label": "Nouveautés" + }, + "langs": { + "chooseLanguage": "Choisir la langue", + "ar": "Arabe", + "en": "Anglais", + "es": "Espagnol", + "de": "Allemand", + "fr": "Français", + "he": "Hébreu", + "nl": "Néerlandais", + "pt": "Portugais" + }, + "userSettings": { + "settings": "Paramètres", + "help": "Aide", + "signOut": "Se Déconnecter", + "changePassword": "Modifier le mot de passe", + "enterRequiredFields": "Veuillez saisir les champs requis", + "passwordsNotMatched": "Les mots de passe ne correspondent pas", + "enterOldPassword": "Saisir l'ancien mot de passe", + "enterNewPassword": "Saisir le nouveau mot de passe", + "retypeNewPassword": "Confirmer le nouveau mot de passe", + "notifications": "Notifications", + "notificationsSub": "Vous avez {{alertLength}} notification", + "notificationsSub_plural": "Vous avez {{alertLength}} notifications", + "clearAll": "Tout effacer", + "guidedTourTooltip": "Visite guidée", + "userGuide": "Guide de l'utilisateur", + "HowToSearch": "Comment chercher", + "HowToAnalyze": "Comment analyser" + }, + "sidebar": { + "My Hose": "My Hose", + "Shared Hose": "Shared Hose", + "Deleted Hose": "Deleted Hose", + "typeToSearch": "Tapez pour rechercher" + }, + "sidebarDropdown": { + "AddClippingsFeed": "Ajouter des clippings feed", + "AddFolder": "Ajouter un dossier", + "DownloadSearchCriteria": "Télécharger les critères de recherche", + "EditSearchTemplate": "Modifier le modèle de recherche", + "ViewUserComments": "Afficher les commentaires des utilisateurs", + "RenameFolder": "Renommer le dossier", + "DeleteFolder": "Supprimer le dossier", + "AddArticle": "Ajouter un article", + "AddToDashboard": "Ajouter au tableau de bord", + "AnalyzeFeed": "Analyser le feed", + "DownloadArticleData": "Télécharger les données d'article", + "DownloadFeedStatistics": "Télécharger les statistiques du feed", + "ExportFeed": "Exporter le feed", + "ExportFeeds": "Exporter les feeds", + "UnexportFeed": "Ne pas exporter le feed", + "UnexportFeeds": "Ne pas exporter les feeds", + "RenameFeed": "Renommer le feed", + "DeleteFeed": "Supprimer le feed" + }, + "sidebarPopup": { + "areYouSure": "êtes-vous sûr", + "enterNamelabel": "Veuillez entrer un nouveau nom", + "enterFolderName": "Veuillez saisir un nom de dossier", + "addFolderBtn": "Ajouter le dossier", + "addClippingsFeed": "Ajouter des clippings feed", + "feedName": "Nom du feed", + "folder": "Dossier" + }, + "alerts": { + "type": { + "success": "Succès", + "warning": "Attention", + "error": "Erreur" + }, + "notice": { + "renameFeedNotice": "Votre feed a bien été renommé ", + "renameFolderNotice": "Votre dossier a été renommé avec succès ", + "noListsSelected": "Veuillez sélectionner une source", + "updateListsForSourceNotice": "Modifications de la liste source enregistrées", + "deleteSourceList": "Liste source {{name}} supprimée.", + "addSourceList": "Liste source {{name}} ajoutée.", + "renameSourceList": "La liste source est renommée.", + "cloneSourceList": "La liste source est clonée.", + "shareSourceList": "Liste source partagée", + "unshareSourceList": "Liste source non partagée", + "saveFeed": "Feed sauvegardée avec succès.", + "clipDocument": "Article(s) ajouté(s) avec succès à votre feed", + "alertSaved": "Votre alerte a bien été enregistrée", + "recipientSaved": "Destinataire enregistré avec succès", + "groupSaved": "Groupe enregistré avec succès", + "articleDeleted": "L'article a bien été supprimé du feed", + "analyticsDeleted": "L'analyse a été supprimée avec succès.", + "planUpdated": "Vous avez mis à jour votre forfait avec succès.", + "cardUpdated": "Vous avez mis à jour votre carte de paiement avec succès.", + "cancelledSubscription": "Votre abonnement a été annulé avec succès." + }, + "error": { + "renameFolderEmpty": "Le nom du dossier ne doit pas être vide.", + "updateCategoryNameNotUnique": "Il existe déjà un dossier avec le nom {{parameters}}.", + "createSourceListNameNotUnique": "Les noms de liste source doivent être uniques. Veuillez changer le nom de la liste source et enregistrer.", + "searchQueryEmpty": "La requête de recherche ne doit pas être vide.", + "createFeedQueryEmpty": "La requête de recherche ne doit pas être vide.", + "unknown": "Erreur de serveur inconnue", + "cannotUnsubscribe": "Vous ne pouvez pas vous désabonner de cette notification", + "restriction": "Vous avez atteint la limite de votre licence. Veuillez contacter le service client pour discuter de votre licence.", + "noMediaTypesSelected": "Vous devez avoir au moins un type de média sélectionné.", + "groupNameEmpty": "Le nom du groupe ne doit pas être vide", + "recipientNamesEmpty": "Le nom et l'adresse e-mail du destinataire ne doivent pas être vides", + "createUserEmailNotUnique": "L'utilisateur avec l'e-mail {{current}} existe déjà", + "feedNameEmpty": "Le nom du feed ne doit pas être vide", + "requiredInfo": "Veuillez saisir les informations requises.", + "somethingWrong": "Désolé, un problème est survenu. Veuillez réessayer.", + "somethingWrong2": "Sorry, something went wrong. Please try again later." + } + }, + "language": { + "all": "Tous", + "af": "Afrikaans", + "sq": "Albanais", + "ar": "Arabe", + "bn": "Bengali", + "bs": "Bosniaque", + "bg": "Bulgare", + "ca": "Catalan", + "zh": "Mandarin", + "hr": "Croate", + "cs": "Tchèque", + "da": "Danois", + "nl": "Néerlandais", + "en": "Anglais", + "et": "Estonien", + "tl": "Tagalog", + "fi": "Finlandais", + "fr": "Français", + "de": "Allemand", + "el": "Grec", + "gu": "Gujarati", + "he": "Hébreu", + "hi": "Hindi", + "hu": "Hongrois", + "is": "Islandais", + "id": "Indonésien", + "it": "Italien", + "ja": "Japonais", + "ko": "Coréen", + "lv": "Letton", + "lt": "Lituanien", + "mk": "Macédonien", + "ms": "Malais", + "no": "Norvégien", + "fa": "Perse", + "pl": "Polonais", + "pt": "Portugais", + "ro": "Roumain", + "ru": "Russe", + "sr": "Serbe", + "sk": "Slovaque", + "sl": "Slovène", + "es": "Espagnol", + "sv": "Suédois", + "ta": "Tamil", + "th": "Thaïlandais", + "tr": "Turc", + "uk": "Ukrainien", + "ur": "Urdu", + "vi": "Vietnamien", + "und": "Non défini", + "U": "Unknown" + }, + "country": { + "AD": "Andorre", + "AE": "Émirats Arabes Unis", + "AF": "Afghanistan", + "AG": "Antigua e Barbuda", + "AI": "Anguilla", + "AL": "Albanie", + "AM": "Arménie", + "AO": "Angola", + "AQ": "Antarctique", + "AR": "Argentine", + "AS": "Samoa américaines", + "AT": "Autriche", + "AU": "Australie", + "AW": "Aruba", + "AX": "Iles Åland", + "AZ": "Azerbaïdjan", + "BA": "Bosnie-Herzégovine", + "BB": "Barbade", + "BD": "Bangladesh", + "BE": "Belgique", + "BF": "Burkina Faso", + "BG": "Bulgarie", + "BH": "Bahreïn", + "BI": "Burundi", + "BJ": "Bénin", + "BL": "Saint Barthélémy", + "BM": "Bermudes", + "BN": "Brunei Darussalam", + "BO": "Bolivie", + "BQ": "Bonaire, Saint-Eustache et Saba", + "BR": "Brésil", + "BS": "Bahamas", + "BT": "Bhoutan", + "BV": "Île Bouvet", + "BW": "Botswana", + "BY": "Bélarus", + "BZ": "Belize", + "CA": "Canada", + "CC": "Cocos (Îles)", + "CD": "République Démocratique du Congo", + "CF": "République Centrafricaine", + "CG": "Congo", + "CH": "Suisse", + "CI": "Côte d’Ivoire", + "CK": "Îles Cook", + "CL": "Chili", + "CM": "Cameroun", + "CN": "Chine", + "CO": "Colombie", + "CR": "Costa Rica", + "CU": "Cuba", + "CV": "Cap-Vert", + "CW": "Curaçao", + "CX": "Christmas (Île)", + "CY": "Chypre", + "CZ": "République tchèque", + "DE": "Allemagne", + "DJ": "Djibouti", + "DK": "Danemark", + "DM": "Dominique", + "DO": "République dominicaine", + "DZ": "Algérie", + "EC": "Équateur", + "EE": "Estonie", + "EG": "Égypte", + "EH": "Sahara occidental", + "ER": "Érythrée", + "ES": "Espagne", + "ET": "Éthiopie", + "FI": "Finlande", + "FJ": "Fiji", + "FK": "Malouines (Îles)", + "FM": "États Fédéraux de Micronésie", + "FO": "Féroé (Îles)", + "FR": "France", + "GA": "Gabon", + "GB": "Royaume-Uni", + "GD": "Grenade", + "GE": "Géorgie", + "GF": "Guyane française", + "GG": "Guernesey", + "GH": "Ghana", + "GI": "Gibraltar", + "GL": "Groenland", + "GM": "Gambie", + "GN": "Guinée", + "GP": "Guadeloupe", + "GQ": "Guinée équatoriale", + "GR": "Grèce", + "GS": "Géorgie du Sud-et-les Îles Sandwich du Sud", + "GT": "Guatemala", + "GU": "Guam", + "GW": "Guinée Bissau", + "GY": "Guyane", + "HK": "Hong Kong", + "HM": "Îles Heard-et-MacDonald", + "HN": "Honduras", + "HR": "Croatie", + "HT": "Haïti", + "HU": "Hongrie", + "ID": "Indonésie", + "IE": "Irlande", + "IL": "Israël", + "IM": "Île de Man", + "IN": "Inde", + "IO": "Territoire britannique de l’océan Indien", + "IQ": "Irak", + "IR": "République Islamique d'Iran", + "IS": "Islande", + "IT": "Italie", + "JE": "Jersey", + "JM": "Jamaïque", + "JO": "Jordanie", + "JP": "Japon", + "KE": "Kenya", + "KG": "Kirghizistan", + "KH": "Cambodge", + "KI": "Kiribati", + "KM": "Comores", + "KN": "Saint-Christophe-et-Niévès", + "KP": "Korea (the Democratic People's Republic of)", + "KR": "Corée (la République de)", + "KW": "Koweït", + "KY": "Îles Caïmans", + "KZ": "Kazakhstan", + "LA": "Laos", + "LB": "Liban", + "LC": "Sainte-Lucie", + "LI": "Liechtenstein", + "LK": "Sri Lanka", + "LR": "Libéria", + "LS": "Lesotho", + "LT": "Lituanie", + "LU": "Luxembourg", + "LV": "Lettonie", + "LY": "Libye", + "MA": "Maroc", + "MC": "Monaco", + "MD": "République de Moldavie", + "ME": "Monténégro", + "MF": "Saint-Martin (Antilles françaises)", + "MG": "Madagascar", + "MH": "Îles Marshall", + "MK": "Macédoine", + "ML": "Mali", + "MM": "Birmanie", + "MN": "Mongolie", + "MO": "Macao", + "MP": "Mariannes du Nord (Îles)", + "MQ": "Martinique", + "MR": "Mauritanie", + "MS": "Montserrat", + "MT": "Malte", + "MU": "Île Maurice", + "MV": "Maldives", + "MW": "Malawi", + "MX": "Mexique", + "MY": "Malaisie", + "MZ": "Mozambique", + "NA": "Namibie", + "NC": "Nouvelle-Calédonie", + "NE": "Niger", + "NF": "Île Norfolk", + "NG": "Nigeria", + "NI": "Nicaragua", + "NL": "Pays-Bas [note 1]", + "NO": "Norvège", + "NP": "Népal", + "NR": "Nauru", + "NU": "Niue", + "NZ": "Nouvelle-Zélande", + "OM": "Oman", + "PA": "Panama", + "PE": "Pérou", + "PF": "Polynésie française", + "PG": "Papouasie-Nouvelle-Guinée", + "PH": "Philippines", + "PK": "Pakistan", + "PL": "Pologne", + "PM": "Saint Pierre et Miquelon", + "PN": "Pitcairn (Îles)", + "PR": "Porto Rico", + "PS": "Palestine", + "PT": "Portugal", + "PW": "Palaos", + "PY": "Paraguay", + "QA": "Qatar", + "RE": "Réunion", + "RO": "Roumanie", + "RS": "Serbie", + "RU": "Russie", + "RW": "Rwanda", + "SA": "Arabie saoudite", + "SB": "Îles Salomon", + "SC": "Seychelles", + "SD": "Soudan", + "SE": "Suède", + "SG": "Singapour", + "SH": "Sainte-Hélène, Ascension et Tristan da Cunha", + "SI": "Slovénie", + "SJ": "Svalbard et Jan Mayen", + "SK": "Slovaquie", + "SL": "Sierra Leone", + "SM": "Saint-Marin", + "SN": "Sénégal", + "SO": "Somalie", + "SR": "Suriname", + "SS": "Soudan du Sud", + "ST": "Sao Tomé-et-Principe", + "SV": "Salvador", + "SX": "Saint-Martin (Royaume des Pays-Bas)", + "SY": "République arabe syrienne", + "SZ": "Swaziland", + "TC": "Îles Turques-et-Caïques", + "TD": "Tchad", + "TF": "Terres australes et antarctiques françaises", + "TG": "Togo", + "TH": "Thaïlande", + "TJ": "Tadjikistan", + "TK": "Tokelau", + "TL": "Timor oriental", + "TM": "Turkménistan", + "TN": "Tunisie", + "TO": "Tonga", + "TR": "Turquie", + "TT": "Trinité-et-Tobago", + "TV": "Tuvalu", + "TW": "Taïwan, Province de Chine [note 2]", + "TZ": "République unie de Tanzanie", + "UA": "Autres", + "UG": "Ouganda", + "UM": "Îles mineures éloignées des États-Unis", + "US": "États-Unis", + "UY": "Uruguay", + "UZ": "Ouzbékistan", + "VA": "Vatican (État de la Cité du)", + "VC": "Saint-Vincent-et-les-Grenadines", + "VE": "Venezuela", + "VG": "Îles Vierges britanniques", + "VI": "Îles Vierges des États-Unis", + "VN": "Vietnam", + "VU": "Vanuatu", + "WF": "Wallis-et-Futuna", + "WS": "Samoa", + "YE": "Yémen", + "YT": "Mayotte", + "ZA": "Afrique du Sud", + "ZM": "Zambie", + "ZW": "Zimbabwe" + }, + "state": { + "AL": "Alabama", + "AK": "Alaska", + "AZ": "Arizona", + "AR": "Arkansas", + "CA": "Californie", + "CO": "Colorado", + "CT": "Connecticut", + "DE": "Delaware", + "DC": "District de Columbia", + "FL": "Floride", + "GA": "Géorgie", + "HI": "Hawaï", + "ID": "Idaho", + "IL": "Illinois", + "IN": "Indiana", + "IA": "Iowa", + "KS": "Kansas", + "KY": "Kentucky", + "LA": "Louisiane", + "ME": "Maine", + "MD": "Maryland", + "MA": "Massachusetts", + "MI": "Michigan", + "MN": "Minnesota", + "MS": "Mississippi", + "MO": "Missouri", + "MT": "Montana", + "NE": "Nebraska", + "NV": "Nevada", + "NH": "New Hampshire", + "NJ": "New Jersey", + "NM": "Nouveau-Mexique", + "NY": "New York", + "NC": "Caroline du Nord", + "ND": "Dakota du Nord", + "OH": "Ohio", + "OK": "Oklahoma", + "OR": "Oregon", + "PA": "Pennsylvanie", + "RI": "Rhode Island", + "SC": "Caroline du Sud", + "SD": "Dakota du Sud", + "TN": "Tennessee", + "TX": "Texas", + "UT": "Utah", + "VT": "Vermont", + "VA": "Virginie", + "WA": "Washington", + "WV": "Virginie-Occidentale", + "WI": "Wisconsin", + "WY": "Wyoming" + }, + "sentiment": { + "POSITIVE": "Positif", + "NEUTRAL": "Neutre", + "NEGATIVE": "Négatif" + }, + "articleDate": { + "15 Minutes": "15 minutes", + "30 Minutes": "30 minutes", + "1 Hour": "1 heure", + "24 Hour": "24 heures", + "7 Days": "7 jours" + }, + "filtersTable": { + "refine": "Filtrer", + "clear": "Effacer", + "clearAll": "Tout effacer", + "more": "Plus", + "less": "Moins", + "clearMessage": "Ce filtre a été effacé. Cliquez sur \" Affiner \" pour afficher la liste complète." + }, + "advancedFilters": { + "articleDate": "Articles par date", + "sourceCity": "Ville de Départ", + "city": "Ville", + "section": "Section Source", + "sourceSection": "Section Source", + "author": "Auteur", + "articleLanguage": "Langue de l'article", + "reach": "Atteindre", + "sentiment": "Opinion", + "sourceCountry": "Pays d'origine", + "state": "État source", + "sourceState": "État source", + "source": "Source", + "country": "Pays", + "language": "Langue", + "mediaType": "Type de média", + "keywordRefine": "Affiner le mot-clé", + "publisher": "Éditeur" + }, + "restrictions": { + "searchesPerDay": "Rechercher la licence", + "savedFeeds": "Sauvegarder la licence", + "alerts": "Licence d'alerte", + "newsletters": "Licence de newsletter" + } +} diff --git a/frontend/app/locales/fr/loginApp.json b/frontend/app/locales/fr/loginApp.json new file mode 100644 index 0000000..efbf683 --- /dev/null +++ b/frontend/app/locales/fr/loginApp.json @@ -0,0 +1,86 @@ +{ + "signIn": "Se connecter", + "login": { + "mainLabel": "Se connecter", + "subLabel": "avec votre compte Socialhose.", + "noAccount": "Pas encore de compte ?", + "signUpNow": "Inscrivez-vous dès maintenant", + "form": { + "emailLabel": "E-mail", + "emailPlaceholder": "E-mail", + "passwordLabel": "Mot de passe", + "passwordPlaceholder": "Mot de passe" + }, + "forgotPass": "Mot de passe oublié ?", + "signInBtn": "Se connecter" + }, + "register": { + "passwordNotMatched": "La confirmation du mot de passe ne correspond pas", + "labels": { + "email": "E-mail", + "firstName": "Prénom", + "lastName": "Nom", + "company": "Société", + "jobFunction": "Fonction d'emploi", + "employees": "Salariés", + "industry": "Secteur", + "websiteURL": "URL de site Web", + "password": "Mot de passe", + "confirmPassword": "Confirmer le mot de passe" + }, + "placeholders": { + "password": "Saisir le mot de passe", + "confirmPassword": "Confirmer le mot de passe" + }, + "signInText": "Avez-vous déjà un compte ?", + "signInBtn": "Se connecter", + "loading": "Chargement en cours…", + "registerBtn": "S'inscrire", + "agreement": "En vous inscrivant, vous acceptez notre <1> Politique de confidentialité , nos <2> Conditions générales et notre <3> Politique d'utilisation acceptable .", + "freeRegisterSuccess": "Vous vous êtes <1 /> enregistré avec succès sur SOCIALHOSE.IO avec un <2> Compte de base gratuit .", + "paidRegisterSuccess": "Vous avez payé et vous êtes <1 /> enregistré avec succès sur SOCIALHOSE.IO.", + "successBottomText": "Vérifiez votre e-mail ({{email}}) pour un lien pour activer votre compte. S'il n'apparaît pas dans quelques minutes, vérifiez votre dossier spam.", + "verification": { + "failed": "Désolé, nous ne pouvons pas vérifier votre compte.", + "success": "Votre adresse email a été vérifiée avec succès.", + "loginBtn": "Se connecter maintenant" + } + }, + "forgotPass": { + "mainLabel": "Mot de passe oublié ?", + "subLabel": "Utilisez le formulaire ci-dessous pour le récupérer.", + "emailLabel": "E-mail", + "emailPlaceholder": "E-mail", + "signIn": "Connectez-vous à votre compte existant", + "resetBtn": "Récupérer le mot de passe" + }, + "resetPass": { + "mainLabel": "Réinitialiser le mot de passe", + "subLabel": "Utilisez le formulaire ci-dessous pour réinitialiser votre mot de passe.", + "newPasswordLabel": "Nouveau mot de passe", + "newPasswordPlaceholder": "Mot de passe", + "signIn": "Connectez-vous à votre compte existant", + "resetBtn": "Réinitialiser le mot de passe" + }, + "footer": { + "privacyPolicy": "Politique de confidentialité", + "acceptableUsePolicy": "Politique d'utilisation acceptable", + "termsConditions": "Conditions générales", + "copyright": "Droit d'auteur © {{year}} SOCIALHOSE.IO. Tous droits réservés." + }, + "commonSection": { + "insightsHeading": "Insights sur les consommateurs et le public", + "insightsText": "Connaissez votre public et segmentez-le pour mieux le comprendre et le toucher. Obtenez des informations sur les habitudes des consommateurs pour améliorer la création de contenu pertinent.", + "brandHeading": "Insights sur les consommateurs et le public", + "brandText": "Grâce à notre technologie, vous serez en mesure de gérer de manière transparente la présence en ligne de votre marque et d'obtenir instantanément des insights sur les conversations se déroulant autour de votre marque.", + "socialHeading": "Social Listening", + "socialText": "Votre accès à des données provenant de plusieurs réseaux sociaux est fourni par notre base de données Elasticsearch, ce qui la rend consultable à tout moment." + }, + "errorMessages": { + "badCredentials": "The email or password is incorrect." + }, + "messages": { + "forgotPasswordSubmit": "Check your email ({{email}}) for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder.", + "passwordUpdated": "You have successfully updated your password." + } +} diff --git a/frontend/app/locales/fr/tabsContent.json b/frontend/app/locales/fr/tabsContent.json new file mode 100644 index 0000000..b085406 --- /dev/null +++ b/frontend/app/locales/fr/tabsContent.json @@ -0,0 +1,761 @@ +{ + "searchTab": { + "clearBtn": "Tout effacer", + "searchBtn": "Rechercher", + "newSearchBtn": "Nouvelle recherche", + "editFeedBtn": "Éditer", + "saveBtn": "Sauvegarder", + "savingBtn": "Enregistrement en cours...", + "saveAsBtn": "Enregistrer sous", + "sourceTypes": { + "all": "Tout sélectionner", + "blogs": "Blogs", + "news": "News", + "classifieds": "Annonces", + "comments": "Commentaires", + "forums": "Forums", + "reviews": "Avis", + "reddit": "Reddit", + "twitter": "Twitter", + "instagram": "Instagram" + }, + "tags": "Tags", + "categories": "Catégories", + "moreComments": "Plus de commentaires", + "datesRange": "Sélectionner la fourchette de dates", + "commentMetadata": "{{author}}", + "searchInputPlaceholder": "Taper vos termes de recherche", + "addIndexTermsBtn": "Ajouter des termes d'index", + "userSubscription": { + "until": "jusqu'à", + "now": "à présent", + "all": "Toutes les dates", + "1d": "1 jour", + "7d": "7 jours", + "15d": "15 jours", + "30d": "30 jours", + "60d": "60 jours", + "90d": "90 jours", + "100d": "100 jours", + "1y": "1 an", + "2y": "2 ans", + "3y": "3 ans", + "5y": "5 ans" + }, + "searchDates": { + "btnLabel": "Rechercher", + "subscriptionLabel": "Votre abonnement", + "resetBtn": "Réinitialiser", + "all": "Toutes les dates", + "1d": "1 J", + "7d": "7 J", + "15d": "2 S", + "30d": "1 M", + "60d": "2 M", + "150d": "5 M", + "300d": "10 M", + "90d": "90 J", + "100d": "100 J", + "1y": "1 A", + "2y": "2 A", + "3y": "3 A", + "5y": "5 A", + "last": "Rechercher le dernier", + "between": "Chercher entre", + "and": "et" + }, + "searchBySection": { + "searchByBtn": "Recherche avancée", + "emphasis": { + "title": "Emphase", + "headlineLabel": "Titre", + "positionLabel": "Rang", + "ensitivityLabel": "Sensibilité", + "include": "Inclut", + "exclude": "Exclut", + "anywhereCheck": "N'importe où", + "headlinePositionLabel_1": "Les termes de recherche doivent apparaître dans le titre ou en premier", + "headlinePositionLabel_2": "du texte de l'article", + "nonSensitive": "Non sensible", + "caseSensitive": "Sensible à la casse", + "characterSensitive": "Sensibilité à la casse et aux caractères accentués" + }, + "languages": { + "title": "Langues" + }, + "locations": { + "title": "Localisations", + "countriesSelect": "Pays", + "statesSelect": "États américains", + "locations": "Faites glisser les emplacements vers d'autres cases pour les sélectionner.", + "locationsToInclude": "L'article doit être dans ces endroits ...", + "locationsToExclude": "... et non PAS dans ces endroits." + }, + "sources": { + "title": "Sources", + "source": "Source", + "siteType": "Type de site", + "includeText": "Inclure", + "excludeText": "Exclure", + "availSources": "Sources disponibles :", + "selectedSources": "Sources sélectionnées :", + "mediatype": "Type de média", + "country": "Pays", + "lang": "Langue", + "rank": "Classement", + "selectSource": "Sélectionnez les sources dans le tableau de gauche." + }, + "sourceLists": { + "title": "Listes source", + "searchBySourceListsAvailable": "Listes source disponibles", + "searchBySourceListsToInclude": "Listes sources sélectionnées", + "searchBySourceListsToExclude": "Listes sources exclues" + }, + "date": { + "title": "Date", + "all": "Pas de filtre de date", + "last": "Durant les derniers", + "between": "Entre", + "d": "jours", + "y": "ans", + "inclusive": "inclusive", + "datePickersDivider": "et" + }, + "duplicates": { + "title": "Doublons", + "includeDuplicates": "Inclure les doublons" + }, + "extras": { + "title": "Extras", + "hasImages": "Afficher uniquement les articles avec des images" + } + }, + "loading": "Chargement en cours", + "results": "Résultats", + "noResults": "Aucun résultat", + "notSynchronized": "Pas synchronisé", + "articlesCountDivider": "Affichage de {{resultsCount}} résultats sur un total de {{totalCount}}", + "orderedBy": "commandé par", + "orderSelect": { + "date": "date", + "relevance": "pertinence" + }, + "tagBtn": "Tag", + "clipBtn": "Clip", + "filter": "Filtrer", + "hide": "Masquer", + "emailBtn": "E-mail", + "deleteBtn": "Supprimer", + "commentBtn": "Commentaires", + "readLaterBtn": "Lire plus tard", + "archiveBtn": "Archiver", + "shareBtn": "Partager", + "hoursAgo": "il y a () heures", + "hourAgo": "il y a () heure", + "words": "mots", + "Reach": "Atteindre", + "saveFeedPopup": { + "typeSave": "Enregistrer le feed", + "typeSaveAs": "Enregistrer le feed sous", + "nameLabel": "Nom du feed", + "folderLabel": "Dossier", + "feedNameErrorMsg": "Veuillez saisir un nom de feed valide" + }, + "deleteArticlePopupText": "Êtes-vous sûr de vouloir supprimer cet article ?", + "deleteArticlePopupText_plural": "Voulez-vous vraiment supprimer ces {{articlesLength}} articles ?", + "emailPopup": { + "header": "Articles par e-mail", + "labelTo": "Vers", + "labelReplyTo": "Répondre à", + "labelSubject": "Objet", + "submitBtn": "Envoyer", + "dontSend": "Ne pas envoyer", + "sendAnyway": "Poursuivre l’envoi", + "sendConfirmWithoutSubject": "Voulez-vous envoyer ce message sans objet ?" + }, + "commentPopup": { + "addUserComment": "Ajouter un commentaire utilisateur", + "editUserComment": "Modifier le commentaire utilisateur", + "inputTitlePlaceholder": "Titre (Optionnel)", + "commentPlanceholder": "Ecrire un commentaire", + "charactersLeft": "{{count}} caractères restants" + }, + "clipPopup": { + "header": "Articles extraits", + "hint1": "Faites glisser le widget vers un feed enregistré sur le côté gauche de l'écran :", + "hint2": "Ou sélectionnez un feed que vous avez récemment extrait :", + "clippedArticles": "{{count}} article(s) extrait(s)" + }, + "tweet": "Tweeter ce lien", + "yammer": "Publier ce lien sur Yammer" + }, + "sourceIndexTab": { + "mainInputPlaceholder": "Rechercher le nom de la source, l'URL ou l'ID de la source", + "addToSourceListsBtn": "Ajouter aux listes source", + "addRssBtn": "Ajouter un feed RSS", + "name": "Nom", + "mediaType": "Type de média", + "license": "Licence", + "country": "Pays", + "location": "Localisation", + "rank": "Classement", + "action": "Action", + "actionBtn": "Ajouter/Retirer ({{listsCount}})", + "showingCounter": "Affichage de {{startCount}} à {{endCount}} des {{totalCount}} entrées", + "sourceInfoPopupTitle": "Détails de la source", + "homeUrl": "URL accueil", + "lang": "Langue", + "titleLabel": "Titre", + "categories": "Catégories" + }, + "sourceListsTab": { + "mainTitle": "Listes source", + "addListBtn": "Ajouter une liste", + "showGlobalCheck": "Afficher uniquement les listes source globales", + "tableLabels": { + "name": "Nom", + "sources": "Sources", + "createdBy": "Créé par", + "lastUpdated": "Dernière mise à jour", + "lastUpdatedBy": "Dernière mise à jour par", + "action": "Action" + }, + "share": "Partager", + "unshare": "Annuler le partage", + "rename": "Renommer", + "clone": "Cloner", + "delete": "Supprimer", + "showingCounter": "Affichage de {{startCount}} à {{endCount}} des {{totalCount}} entrées", + "popup": { + "addToListTitle": "Ajouter aux listes source", + "addToListDesc": "Ajouter des sources aux listes source suivantes", + "addBtn": "Ajouter", + "updateListTitle": "Mettre à jour les listes source", + "updateListDesc": "Ajouter ou supprimer la source \"{{name}}\"", + "saveBtn": "Sauvegarder", + "enterListName": "Veuillez saisir le nom de la liste source", + "addListBtn": "Ajouter une liste", + "deleteListTitle": "Confirmer", + "deleteListDesc": "Voulez-vous vraiment supprimer la liste {{name}} ?", + "deleteListSubmitBtn": "Oui", + "renameListTitle": "Veuillez saisir le nom de la liste source", + "renameListSubmitBtn": "Renommer", + "cloneListSubmitBtn": "Cloner" + } + }, + "analyzeTab": { + "welcomeMsg": "Bienvenue dans Analyze", + "welcomeQuestion": "Qu'aimeriez-vous effectuer ?", + "welcomeSubtext": "(Choisissez parmi les options suivantes ci-dessous)", + "go": "Aller", + "view": "Afficher", + "createNewAnalysis": "Créer une nouvelle analyse", + "openRecentAnalysis": "Ouvrir une analyse récente", + "viewSavedAnalysis": "Afficher l'analyse enregistrée", + "noRecentAnalysis": "Aucune analyse récente", + "loading": "Chargement, veuillez patienter", + "savedAnalysis": "Analyse enregistrée", + "newAnalysis": "Nouvelle analyse", + "deleteAnalysis": "Supprimer l'analyse", + "Name": "Nom", + "NumberOfCharts": "Nombre de graphiques", + "DateCreated": "Date de création", + "LastUpdated": "Dernière mise à jour", + "showingCounter": "Affichage de {{startCount}} à {{endCount}} des {{totalCount}} analyses enregistrées", + "enterDetails": "Entrez les détails", + "updateDetails": "Mettre à jour les informations", + "selectFeeds": "Déposer les feeds ici", + "selectedFeeds": "Feed sélectionnés", + "dateRange": "Sélectionner la fourchette de dates", + "startDatePlaceholder": "Date de début", + "endDatePlaceholder": "Date de fin", + "submit": "Soumettre", + "updateBtn": "Mise à Jour", + "createBtn": "Créer", + "dropDesc": "Faites glisser les feeds depuis le panneau de gauche et déposez-les ici", + "releaseDesc": "Relâchez pour ajouter le feed", + "noData": "Aucune donnée", + "createAlert": "Créer une alerte : {{alertsLength}} sélectionné", + "createAlertBtn": "Créer une Alerte", + "selectedCharts": "Graphiques sélectionnés", + "chartMenus": { + "addToAlert": "Ajouter pour créer une alerte", + "addedToAlerts": "Ajouté aux alertes", + "refresh": "Rafraîchir", + "addToDashboard": "Ajouter au tableau de bord", + "toggleHV": "Basculer horizontalement / verticalement" + }, + "charts": { + "topCountries": "Principaux Pays", + "topLanguages": "Principales langues", + "gender": "Sexe", + "mentions": "Mentions", + "mentionsOverTime": "Mentions au fil du temps", + "engagement": "Engagement", + "engagementOverTime": "Engagement au fil du temps", + "potentialReach": "Portée Potentielle", + "potentialReachOverTime": "Portée potentielle au fil du temps", + "proportionofSentiment": "Proportion d'opinion", + "topInfluencers": "Principaux influenceurs", + "topThemes": "Principaux thèmes", + "themesOverTime": "Thèmes au fil du temps", + "sentimentOverTime": "Opinion au fil du temps", + "shareofSentiment": "Part d'opinion" + }, + "influencerCols": { + "details": "Détails", + "sentiments": "Opinions", + "reach": "Atteindre", + "rank": "Classement", + "influencers": "Influenceurs", + "sourceType": "Type de source", + "total": "Total", + "positive": "Positif", + "neutral": "Neutre", + "negative": "Négatif", + "engagement": "Engagement", + "engagementPerMention": "Engagement par mention" + }, + "overviewCharts": { + "overview": "Vue d’ensemble", + "none": "Aucun(e)", + "mediaTypes": "Type de médias", + "sentiments": "Opinions", + "countries": "Des pays", + "languages": "Langues", + "performance": "Performance", + "influencers": "Influenceurs", + "sentiment": "Opinion", + "themes": "Thèmes", + "demographics": "Données démographiques", + "worldMap": "Carte du monde" + }, + "savedAnalytics": { + "feeds": "Feeds", + "dateRange": "Plage de dates", + "createdAt": "Créé Le", + "actions": "Actions", + "view": "Afficher", + "edit": "Éditer", + "delete": "Supprimer" + } + }, + "tableSwitcher": { + "myEmails": "Mes e-mails", + "publishedEmails": "E-mails publiés", + "recipients": "Destinataires", + "groups": "Groupes" + }, + "deletePopup": { + "alert": "Voulez-vous vraiment supprimer cette alerte ?", + "alerts": "Voulez-vous vraiment supprimer ces {{count}} alertes ?", + "recipient": "Voulez-vous vraiment supprimer ce destinataire ?", + "recipients": "Voulez-vous vraiment supprimer ces {{count}} destinataires ?", + "group": "Voulez-vous vraiment supprimer ce groupe ?", + "groups": "Voulez-vous vraiment supprimer ces {{count}} groupes ?", + "email": "Voulez-vous vraiment supprimer cet e-mail ?", + "emails": "Voulez-vous vraiment supprimer ces {{count}} e-mails ?" + }, + + "notificationsTab": { + "newAlert": "Nouvelle alerte", + "newNewsletter": "Nouvelle newsletter", + "activate": "Activer", + "pause": "Pause", + "delete": "Supprimer", + "publish": "Publier", + "unpublish": "Annuler la publication", + "subscribe": "Souscrire", + "unsubscribe": "Se désinscrire", + "name": "Nom", + "type": "Catégorie", + "published": "Publié", + "ScheduledTimes": "Horaires programmées", + "contents": "Table des matières", + "Recipients": "Destinataires", + "action": "Action", + "alert": "alerte", + "alerts": "alertes", + "newsletter": "Newsletter", + "newsletters": "Newsletters", + "chartsFeeds": "Graphiques/Feeds", + "recipients": "destinataires", + "scheduledTimes": "horaires programmés", + "sentTime": "Heure d'envoi", + "owner": "Propriétaire", + "status": "Statut", + "active": "Actif", + "paused": "En pause", + "subscribed": "Abonné", + "unsubscribed": "Désabonné", + "deleteAlertText": "Voulez-vous vraiment supprimer cette alerte ?", + "deleteNewsletterText": "Voulez-vous vraiment supprimer cette newsletter ?", + "deleteAlertsText": "Voulez-vous vraiment supprimer ces {{count}} alertes ?", + "back": "Retour", + "form": { + "dragFeed": "Faites glisser un feed ou un graphique vers cette zone pour l'ajouter", + "activeScheduledTimes": "Horaires actifs programmés", + "editAlert": "Modifier l'alerte", + "createAlert": "Créer une Alerte", + "add": "Ajouter", + "name": "Nom", + "recipient": "Destinataire(s)", + "emailSubject": "Sujet de l’e-mail", + "automatedEmail": "E-mail automatisé", + "automatedEmailDesc": "Utilisez l'objet de l'e-mail automatisé en fonction des flux", + "publish": "Publier", + "publishDesc": "Les alertes et les newsletters publiées sont disponibles pour que les autres utilisateurs s'abonnent", + "unsubscribe": "Lien de désabonnement", + "unsubscribeDesc": "Autoriser les destinataires à se désabonner de l'alerte", + "notifications": "Notifications", + "notificationsDesc": "Avertir le créateur lorsque les destinataires se désabonnent", + "feeds": "Feed/Graphiques", + "options": "Options", + "articleExtracts": "Extraits d'articles", + "contextualExtracts": "Extrait contextuel", + "startExtracts": "Début de l'extrait de texte", + "noExtracts": "Aucun extrait d'article", + "highlightKeywords": "Mettre en évidence les mots-clés", + "showSourceCountry": "Afficher le pays d'origine", + "showUserComments": "Afficher les commentaires des utilisateurs", + "layout": "Disposition", + "enhancedHtml": "HTML amélioré", + "plainHtml": "HTML simple", + "sendWhenEmpty": "Envoyer lorsqu'il est vide", + "timezone": "Fuseau horaire", + "change": "Changer", + "automatic": "Automatique", + "sendUntil": "Envoyer jusqu'à", + "cancel": "Annuler", + "save": "Sauvegarder", + "saveAs": "Enregistrer sous", + "selectDate": "Sélectionnez une date", + "type": { + "daily": "Quotidiennement", + "weekly": "Hebdomadaire", + "monthly": "Mensuellement" + }, + "time": { + "15m": "toutes les 15 minutes", + "30m": "toutes les 30 minutes", + "1h": "toutes les heures", + "2h": "toutes les 2 heures", + "3h": "toutes les 3 heures", + "4h": "toutes les 4 heures", + "6h": "toutes les 6 heures", + "12h": "toutes les 12 heures", + "once": "une fois par jour" + }, + "days": { + "all": "Tous les jours", + "weekdays": "Jours de la semaine", + "weekends": "Les week-ends" + }, + "period": { + "every": "Chaque", + "first": "Premier", + "second": "Deuxième", + "third": "Troisième", + "fourth": "Quatrième", + "last": "Dernier" + }, + "day": { + "monday": "Lundi", + "tuesday": "Mardi", + "wednesday": "Mercredi", + "thursday": "Jeudi", + "friday": "vendredi", + "saturday": "Samedi", + "sunday": "Dimanche" + } + }, + "history": { + "hideSendHistory": "Masquer l'historique des envois", + "showSendHistory": "Afficher l'historique des envois", + "sentTime": "Heure d'envoi", + "showMore": "Afficher plus", + "loading": "Chargement en cours…" + }, + "newsLetter": { + "createNewsletter": "Créer une newsletter", + "name": "Nom" + }, + "popup": { + "saveAsPlaceholder": "Veuillez saisir le nom de la nouvelle alerte", + "save": "Sauvegarder", + "saveAs": "Enregistrer sous" + } + }, + + "manageRecipientsTab": { + "newRecipient": "Nouveau destinataire", + "newGroup": "Nouveau groupe", + "name": "Nom", + "groupName": "Nom du groupe", + "email": "Adresse e-mail", + "groups": "Groupes", + "subscriptions": "Abonnements", + "creationDate": "Date de création", + "status": "Statut", + "recipientsNumber": "Nombre de destinataires", + "recipients": "Destinataires", + "Subscribed": "Abonné", + "Unsubscribed": "Désabonné", + "All": "Tous", + "Enrolled": "Inscrit", + "NotEnrolled": "Non inscrit", + "form": { + "recipient": { + "basicInfo": "Informations de base", + "nameStatus": "Statut du profil", + "unsaved": "Utilisateur non enregistré", + "deleteButton": "Supprimer l’utilisateur", + "firstName": "Prénom", + "lastName": "Nom", + "email": "Adresse e-mail", + "enroll": "Inscrire", + "creationDate": "Date de création" + }, + "group": { + "basicInfo": "Informations de base", + "nameStatus": "Statut du Groupe", + "unsaved": "Groupe non enregistré", + "deleteButton": "Supprimer le groupe", + "name": "Nom du groupe", + "description": "Description", + "recipientsNumber": "Nombre de membres", + "addedDate": "Date ajoutée", + "creationDate": "Date de création" + }, + "cancel": "Annuler", + "save": "Sauvegarder" + }, + "tables": { + "recipients": "Destinataires", + "groups": "Groupes", + "emailHistory": "Historique des e-mails", + "subscriptions": "Abonnements" + } + }, + + "manageEmailsTab": { + "owner": "Propriétaire", + "recipient": "Destinataire", + "feed": "Feed", + "selectFilter": "Sélectionner un filtre", + "notifications": "# d'alertes ", + "allEmails": "Tous les e-mails", + "emailFilter": "Filtre e-mail", + "filter": "Filtrer", + "filterBy": "Filtrer par : ", + "currentFilter": "Filtre actuel :", + "refresh": "Rafraîchir" + }, + + "toggler": { + "active": "Actif", + "paused": "En pause", + "subscribed": "Abonné", + "unsubscribed": "Désabonné", + "yes": "Oui", + "no": "Non" + }, + + "exportTab": { + "topMessage": "Sélectionnez les feeds à exporter vers votre intranet ou votre application. Pour ajouter un feed à cette page, sélectionnez \"Exporter le feed\" dans le menu déroulant du feed. Vous pouvez exporter jusqu'au nombre de flux défini pour votre licence d'exportation.", + "feedName": "Nom du feed", + "exportWith": "Exporter avec", + "actions": "Actions", + "delete": "Supprimer", + "close": "Fermer", + "export": "Exporter", + "confirm": "Confirmer", + "exportDeleteMessage": "Voulez-vous vraiment supprimer \"{{feedName}}\" de la liste d'exportation?", + "exportPopup": { + "line1": "L'URL d'exportation de ce flux est la suivante :", + "line2": "pour appeler le flux à l'aide de HTTP sécurisé, remplacez http: // par https: //", + "line3": "Les paramètres suivants peuvent être ajustés :", + "param1": "Définissez le nombre de résultats à renvoyer, entre 1 et 200", + "param2": "Définissez le type d'extrait d'article: \"s\" pour le début de l'article, \"sc\" pour un extrait contextuel basé sur les termes de recherche ou \"n\" pour aucun extrait d'article", + "param3": "Défini sur \"1\" pour inclure les URL d'images, sur \"0\" pour exclure les URL d'images", + "param4": "Ajoutez \"& text_format = text\" à l'URL pour mettre en forme le titre et extraire en texte brut au lieu de HTML" + } + }, + "restrictions": { + "perDay": "par jour", + "perMonth": "mensuellement", + "alertLicenses": "Licences d'alerte", + "totalNewsltter": "Total Newsltter", + "webfeedLicenses": "Licences Webfeed", + "searchLicenses": "Rechercher des licences", + "feedLicenses": "Licences feed" + }, + "plans": { + "sidebar": { + "activePlanDetails": "Détails du forfait actif", + "changeCard": "Modifier la carte", + "updatePlan": "Mise à jour du forfait", + "yourTransactions": "Vos Transactions" + }, + "currentPlan": { + "subHeading": "Forfait actuel", + "freePlan": "GRATUIT", + "perMonth": "mensuellement", + "changePlan": "Changer de forfait", + "upgradeYourPlan": "Passer à un forfait supérieur", + "upgradeText": "Ne payez que ce que vous voulez parmi les options suivantes. Toute facturation est mensuelle.", + "currentPlanDetails": "Détails relatifs à votre forfait actuel", + "selectedMediaTypes": "Types de médias sélectionnés", + "upgradeToGet": "Mettre à jour pour obtenir", + "none": "Aucun(e)", + "selectedLicenses": "Licences sélectionnées", + "feedsLicenses": "Licences feeds", + "searchLicenses": "Rechercher des licences", + "webfeedLicenses": "Licences Webfeed", + "alertLicenses": "Licences d'alerte", + "userAccounts": "Comptes utilisateur", + "features": "Fonctionnalité", + "analytics": "Analyse", + "cancelSubscriptionBtn": "Annuler l’abonnement", + "cancelWarning": "(Si vous annulez votre forfait actuel, vous serez converti en forfait de base gratuit à partir du prochain cycle de facturation.)", + "alreadyCancelled": "Vous avez déjà annulé votre forfait actuel.", + "cancelModal": { + "header": "Annuler l’abonnement", + "line1": "Vous nous manquerez, {{firstName}}.", + "line2": "D'accord, voici ce qui va se passer ...", + "warn1": "Tous vos feeds seront définitivement supprimés.", + "warn2": "Toutes vos alertes cesseront de fonctionner immédiatement. Vos groupes de listes de diffusion resteront dans votre compte, mais n'enverront aucune alerte.", + "warn3": "Vous aurez toujours accès pour créer un nouveau feed en tant que compte de base gratuit.", + "warn4": "Une fois que vous avez annulé votre forfait actuel, vous ne pourrez pas mettre à jour votre forfait avant la fin du cycle de facturation actuel.", + "undoBtn": "Annuler", + "loadingBtn": "Chargement en cours…", + "cancelSubscriptionBtn": "Annuler l’abonnement", + "reasonSelect": "Please select at least one option.", + "feedbackPara": "Before you go, would you mind letting us know why? Your feedback will help us get better.", + "reasonCancellation": "Reason for cancellation", + "noNeeds": "SOCIALHOSE.IO is not working for our needs", + "tooNoisy": "The search results are too noisy / misses", + "confusing": "The system is confusing", + "expensive": "It's too expensive", + "covid": "COVID-19 Related", + "other": "Other", + "tellMore": "We're sorry to hear that. Would you like to tell us more?" + } + }, + "updatePlan": { + "heading": "Mise à jour du forfait", + "planLoadingFailed": "Désolé, un problème est survenu.", + "tryAgainBtn": "Veuillez réessayer", + "subText": "Options de menu à la carte avec facturation mensuelle. Vous pouvez sélectionner l'un des forfaits préconfigurés que nous avons conçus pour vous.", + "learnMoreBtn": "Pour en savoir plus", + "prePlans": "Forfaits préconfigurés", + "mediaTypes": "Type de médias", + "licenses": "Licences", + "features": "Fonctionnalité", + "deselectTooltip": "Cliquer pour désélectionner", + "selectTooltip": "Cliquer pour sélectionner", + "addOns": "Extensions", + "totalCost": "Coût total", + "monthly": "Mensuellement", + "cancelledWarning": "Vous avez {{text}} votre forfait actuel. Par conséquent, vous ne pouvez mettre à jour votre forfait qu'après la fin du cycle de facturation actuel.", + "continueBtnLoading": "Chargement en cours…", + "continueBtn": "Continuer vers la confirmation", + "billingHeading": "Détails de facturation et de paiement", + "error": "Erreur", + "back": "Retour", + "payBtn": "Payez {{totalCost}} $", + "confirmationHeading": "Confirmation", + "upgradeNotice": "Vous avez choisi de mettre à niveau le forfait. Si vous ajoutez des services supplémentaires au milieu d'un cycle de facturation, le total sera recalculé sur la base de l'ensemble de votre forfait et la différence facturée dans sa totalité, même s'il reste des jours dans le cycle de facturation.", + "downgradeNotice": "Vous avez choisi de baisser le niveau du forfait. En conséquence, la décision prendra effet lors du prochain cycle de facturation, y compris l'accès aux options/fonctionnalités. En d'autres termes, vous pouvez abaisser le niveau de service x jours avant le cycle de facturation, continuer à bénéficier du service, alors que la réduction du niveau de prestation aura lieu au début du cycle de facturation suivant. Une fois que vous aurez procédé, vous ne pourrez pas mettre à jour votre forfait avant le terme du cycle de facturation.", + "alreadyStoredCard": "Vous avez déjà une carte de paiement stockée. Êtes-vous sûr de vouloir poursuivre ?", + "payWithOtherCardBtn": "Payer avec une autre carte", + "payWithStoredCardBtn": "Payer avec la carte enregistrée", + "payLoading": "En cours de traitement ..." + }, + "billingForm": { + "fullName": "Nom complet", + "addr1": "Ligne d'adresse 1", + "addr1Desc": "p. ex., rue, boîte postale ou nom de l'entreprise", + "addr2": "Ligne d'adresse 2", + "addr2Desc": "p. ex., un appartement, une suite, un e unité ou un bâtiment", + "city": "Ville", + "cityDesc": "Ville, quartier, banlieue, ville ou village", + "state": "État", + "stateDesc": "État, comté, province ou région", + "zip": "Code postal", + "zipDesc": "Code postal", + "country": "Pays", + "email": "E-mail de facturation", + "phone": "Numéro de téléphone de facturation", + "phoneDesc": "Numéro de téléphone avec extension", + "cardHeading": "Données de la carte", + "agreement": "En vous inscrivant, vous acceptez notre <1> Politique de confidentialité , nos <2> Conditions générales et notre <3> Politique d'utilisation acceptable ." + }, + "upgradeModal": { + "heading": "Passer à un forfait supérieur ! ", + "text": "Vous devez mettre à niveau votre forfait pour accéder à ces fonctionnalités. Jetez un œil à nos <2>options de menus à la carte facturées mensuellement.", + "learnMore": "Pour en savoir plus", + "upgradeNowBtn": "Mettre à jour maintenant", + "maybeLaterBtn": "Peut-être plus tard" + }, + "transactions": { + "heading": "Vos Transactions", + "activationDate": "Date d’activation", + "expirationDate": "Date d'expiration", + "transactionDate": "Date de transaction", + "amount": "Montant", + "status": "Statut", + "actions": "Actions", + "more": "Plus", + "modal": { + "heading": "Détails de la transaction et du forfait", + "transactionDetails": "Détails de la transaction", + "transactionDate": "Date de transaction", + "activationDate": "Date d’activation", + "expirationDate": "Date d'expiration", + "amount": "Montant", + "status": "Statut", + "billingDetails": "Détails de facturation", + "name": "Nom", + "email": "E-mail", + "phone": "Téléphone", + "address": "Adresse", + "invoiceNo": "Nº de facture", + "showInvoiceLink": "Afficher la facture", + "cancelBtn": "Annuler" + } + }, + "changeCard": { + "heading": "Modifier les détails de facturation et de paiement", + "subText": "Si vous changez de carte, vos futurs paiements seront effectués avec cette carte.", + "error": "Erreur", + "loadingBtn": "Chargement en cours…", + "changeCardBtn": "Modifier la carte" + } + }, + "webtour": { + "search": { + "start": "Welcome! Let us get you started by orienting you with our system!", + "feedsView": "Here, you can organize your feeds and folders.", + "mainTabs": "There are 3 main pages: <1>Search to find content, <1>Analyze to generate reports, and <1>Share to distribute findings via alerts or webfeeds.", + "userSettings": "You can configure user settings here. You can also find this guide here any time in the future.", + "license": "Here you can see your license allowances as you utilize them.", + "searchField": "A simple boolean search looks like this: <1>BMW AND Texas. Which will find all mentions of \"bmw\" and \"texas\".", + "dateRange": "You can set the date range here to broaden or narrow the scope of your search.", + "mediaChannels": "Select the media channels that you would like to include in your search.", + "advancedSearch": "Click on <1>Advanced Search to uncover the different options for your search.", + "emphasis": "<0>Emphasis: Include or exclude specific words or phrases in the headline of a news article or a blog post.", + "languages": "<0>Languages: Capture the content that is tagged with the following language(s).", + "locations": "<0>Locations: Include or exclude content that is geotagged with the following countries or US States.", + "extras": "<0>Extras: Only show posts with images.", + "saveSearch": "If you like your search results you can save it as a feed, or reset by clicking on New search." + }, + "analytics": { + "start": "We made running analytics so simple, it’s almost ridiculous!", + "dragFeed": "You click and drag any of your feeds and drop it into the drop box.", + "drop": "That’s a big box! You can drop more than just one feed.", + "dateRange": "Select the date range you want your analysis to cover.", + "create": "Click create. That’s it!" + } + } +} diff --git a/frontend/app/locales/index.js b/frontend/app/locales/index.js new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/app/locales/index.js @@ -0,0 +1 @@ + diff --git a/frontend/app/main.js b/frontend/app/main.js new file mode 100644 index 0000000..d24af7e --- /dev/null +++ b/frontend/app/main.js @@ -0,0 +1,62 @@ +import React, { Fragment } from 'react'; +import ReactDOM from 'react-dom'; +import { fromJS } from 'immutable'; +import { Provider } from 'react-redux'; +import { createBrowserHistory as createHistory } from 'history'; +import { Router } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import { rootReducers } from './redux/root'; + +import i18n from './i18n'; +import DevTools from './redux/utils/DevTools'; +import configureStore, { hasBrowserExt } from './redux/configureStore'; +import { Elements } from '@stripe/react-stripe-js'; +import { loadStripe } from '@stripe/stripe-js'; + +// import { syncHistoryWithStore } from 'react-router-redux'; +// import { routerSelectLocationState } from './redux/utils/common'; +// import configureRoutes from './routing/routes'; + +import 'react-select/dist/react-select.css'; +import './styles/core.scss'; + +import AppRoutes from './routing/AppRoutes'; // keep after loading css +import SiteScripts from './routing/SiteScripts'; +import { isLocal, isProduction } from './common/constants'; +import appConfig from './appConfig'; + +export const history = createHistory(); + +const stripePromise = loadStripe(appConfig.stripeKey); + +export const storeObj = configureStore(fromJS({}), rootReducers); + +export const init = function (options) { + if (!options.containerId) { + console.error('There are no containerId'); + return false; + } + + const store = (this.store = storeObj); + // const routes = configureRoutes(store); + // const history = syncHistoryWithStore(browserHistory, store, { + // selectLocationState: routerSelectLocationState + // }); + + ReactDOM.render( + + {isProduction && } + + + + + + + {isLocal && !hasBrowserExt && } + + + + , + document.getElementById(options.containerId) + ); +}; diff --git a/frontend/app/redux/configureStore.js b/frontend/app/redux/configureStore.js new file mode 100644 index 0000000..9437c19 --- /dev/null +++ b/frontend/app/redux/configureStore.js @@ -0,0 +1,38 @@ +import { applyMiddleware, compose, createStore } from 'redux'; +import thunk from 'redux-thunk'; +import { routerMiddleware } from 'react-router-redux'; +import { history } from '../main'; + +export const hasBrowserExt = window.__REDUX_DEVTOOLS_EXTENSION__; + +export default function configureStore(initialState, rootReducer) { + const middleware = applyMiddleware(thunk, routerMiddleware(history)); + + let createStoreWithMiddleware; + + if (process.env.NODE_ENV === 'development') { + if (hasBrowserExt) { + // show browser devtools if available + const storeEnhancers = + (typeof window === 'object' && + window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || + compose; + + createStoreWithMiddleware = storeEnhancers(middleware); + } else { + createStoreWithMiddleware = compose( + middleware, + require('./utils/DevTools').default.instrument() + ); + } + } else { + createStoreWithMiddleware = compose(middleware); + } + + const store = createStoreWithMiddleware(createStore)( + rootReducer, + initialState + ); + + return store; +} diff --git a/frontend/app/redux/modules/abstract/reduxModule.js b/frontend/app/redux/modules/abstract/reduxModule.js new file mode 100644 index 0000000..2cab633 --- /dev/null +++ b/frontend/app/redux/modules/abstract/reduxModule.js @@ -0,0 +1,178 @@ +import { createAction, handleActions } from 'redux-actions' +import { fromJS } from 'immutable' +import {thunkAction} from '../../utils/common' + +export class ReduxModule { + + constructor () { + this._namespace = this.getNamespace() + this._actions = {} + this._reducers = {} + } + + init () { + this._initialState = fromJS(this.getInitialState()) + const actions = this.defineActions() + const reducers = this._addNamespaceToReducers(this.defineReducers()) + Object.assign(this._actions, actions) + Object.assign(this._reducers, reducers) + this.reducers = handleActions(this._reducers, this._initialState) + this.actions = this._actions + } + + getNamespace () { + //implement in subclasses + } + + getInitialState () { + //implement in subclasses + } + + defineActions () { + //implement in subclasses + //it should return hash of function + /** + * return { + * action1, + * action2 + * } + */ + } + + defineReducers () { + //implement in subclasses + /* + return { + [actionName]: this.****Reducer() + } + or use this.addReducer() + */ + } + + _addNamespaceToReducers (obj) { + const result = {} + for (let actionName in obj) { + result[this.ns(actionName)] = obj[actionName] + } + return result + } + + /** utils **/ + ns (actionName) { + return `${this._namespace} ${actionName}` + } + + evalPath (path) { + return path + //TODO: something better + /*return path.map( + (item) => (typeof item === 'function') ? item(state) : item + );*/ + } + + /** action creators **/ + createAction (actionName, actionFn) { + return createAction(this.ns(actionName), actionFn) + } + + thunkAction (actionName, actionMethod, emitPending) { + return thunkAction(this.ns(actionName), actionMethod, emitPending) + } + + thunkPendingReducer (field) { + return (state, {payload: {isPending}}) => state.set(field, isPending) + } + + /** reducer creators **/ + setReducer (field) { + return (state, {payload: value}) => state.set(field, value) + } + + setInReducer (path) { + return (state, {payload: value}) => state.setIn(this.evalPath(path), value) + } + + setFieldReducer () { + return (state, {payload: {field, value}}) => state.set(field, value) + } + + resetReducer (field, defaultValue) { + return (state) => state.set(field, defaultValue) + } + + mergeReducer () { + return (state, {payload: values}) => state.merge(values) + } + + mergeInReducer (path) { + return (state, {payload: values}) => state.mergeIn(this.evalPath(path), values) + } + + toggleReducer (field) { + return (state) => state.set(field, !state.get(field)) + } + + toggleInReducer (path) { + return (state) => { + const realPath = this.evalPath(path) + return state.setIn(realPath, !state.getIn(realPath)) + } + } + + addReducer (actionName, reducerFn) { + this._reducers[this.ns(actionName)] = reducerFn + } + + //do not prefix with namespace + addExternalReducer (actionName, reducerFn) { + this._reducers[actionName] = reducerFn + } + + /** handler creators + * handler = action + reducer + **/ + createHandler (actionName, actionFn, reducerFn) { + const action = this.createAction(actionName, actionFn) + this.addReducer(actionName, reducerFn) + return action + } + + set (actionName, field) { + return this.createHandler(actionName, {}, this.setReducer(field)) + } + + setIn (actionName, path) { + return this.createHandler(actionName, {}, this.setInReducer(path)) + } + + setField (actionName) { + return this.createHandler(actionName, (field, value) => ({field, value}), this.setFieldReducer()) + } + + reset (actionName, field, defaultValue) { + return this.createHandler(actionName, {}, this.resetReducer(field, defaultValue)) + } + + merge (actionName) { + return this.createHandler(actionName, {}, this.mergeReducer()) + } + + mergeIn (actionName, path) { + return this.createHandler(actionName, {}, this.mergeInReducer(path)) + } + + toggle (actionName, field) { + return this.createHandler(actionName, {}, this.toggleReducer(field)) + } + + toggleIn (actionName, path) { + return this.createHandler(actionName, {}, this.toggleInReducer(path)) + } + + resetToInitialState (actionName) { + return this.createHandler(actionName, {}, () => this._initialState) + } + +} + +export default ReduxModule diff --git a/frontend/app/redux/modules/appState/analyze/analyze.js b/frontend/app/redux/modules/appState/analyze/analyze.js new file mode 100644 index 0000000..b2f4558 --- /dev/null +++ b/frontend/app/redux/modules/appState/analyze/analyze.js @@ -0,0 +1,49 @@ +import { fromJS } from 'immutable' +import { createAction, handleActions } from 'redux-actions' + +// Action types +const ADD_ALERT_CHART = 'ADD_ALERT_CHART' +const REMOVE_ALERT_CHART = 'REMOVE_ALERT_CHART' +const RESET_ALERT_CHART = 'RESET_ALERT_CHART' + +// Actions +const addAlertChart = createAction(ADD_ALERT_CHART, (payload) => payload) +const removeAlertChart = createAction(REMOVE_ALERT_CHART, (payload) => payload) +const resetAlertChart = createAction(RESET_ALERT_CHART, (payload) => payload) + +export const analyzeActions = { + addAlertChart, + removeAlertChart, + resetAlertChart +} + +// Reducer +const initialState = fromJS({ + alertCharts: [] +}) + +export default handleActions( + { + [ADD_ALERT_CHART]: (state, { payload }) => { + const charts = state.getIn(['alertCharts']) + if (charts.find((v) => v.name === payload)) { + return state + } + return state.setIn(['alertCharts'], [...charts, payload]) + }, + [REMOVE_ALERT_CHART]: (state, { payload }) => { + const charts = state + .getIn(['alertCharts']) + .filter( + (item) => + item.name !== payload.name || + (item.id ? item.id !== payload.id : true) + ) + return state.setIn(['alertCharts'], charts) + }, + [RESET_ALERT_CHART]: (state) => { + return state.setIn(['alertCharts'], []) + } + }, + initialState +) diff --git a/frontend/app/redux/modules/appState/analyzeTab.js b/frontend/app/redux/modules/appState/analyzeTab.js new file mode 100644 index 0000000..0ca397f --- /dev/null +++ b/frontend/app/redux/modules/appState/analyzeTab.js @@ -0,0 +1,220 @@ +/** ----------------CONSTANTS---------------- **/ +import {createAction, handleActions} from 'redux-actions' +import {fromJS, Map} from 'immutable' + +import {getSavedAnalysesApi, deleteSavedAnalysesApi} from '../../../api/analyticApi' +import {thunkAction, tokenInject} from '../../utils/common' + +const GET_RECENT_ANALYSIS = 'GET_RECENT_ANALYSIS' + +const CONFIRM_DELETE_ANALYSES = 'CONFIRM_DELETE_ANALYSES' +const CANCEL_DELETE_ANALYSES = 'CANCEL_DELETE_ANALYSES' +const DELETE_ANALYSES = 'DELETE_ANALYSES' + +const SELECT_TABLE_ROW = 'SELECT_ANALYSES_TABLE_ROW' +const SELECT_ALL_ROWS = 'SELECT_ANALYSES_TABLE_ALL_ROWS' +const SET_TABLE_PARAMS = 'SET_ANALYSES_TABLE_PARAMS' +const RELOAD_TABLE = 'RELOAD_ANALYSES_TABLE' + +const TOGGLE_SIDEBAR = 'TOGGLE_SIDEBAR' + +/** -------------ACTIONS------------------------ **/ + +const getRecentAnalysis = thunkAction(GET_RECENT_ANALYSIS, ({token, fulfilled}) => { + return getSavedAnalysesApi(token, {sort: 'id'}).then(fulfilled) +}) + +const confirmDeleteAnalyses = createAction(CONFIRM_DELETE_ANALYSES, (idsArray) => idsArray) +const cancelDeleteAnalyses = createAction(CANCEL_DELETE_ANALYSES) +const deleteSavedAnalysesPending = () => { + return {type: DELETE_ANALYSES + '_PENDING'} +} + +const deleteSavedAnalysesFulfilled = (payload) => { + return {type: DELETE_ANALYSES + '_FULFILLED', payload} +} + +const deleteSavedAnalyses = () => { + return tokenInject((dispatch, getState, token) => { + dispatch(deleteSavedAnalysesPending()) + const tableState = getState().getIn(['appState', 'analyzeTab', 'tableState']).toJS() + deleteSavedAnalysesApi( + token, tableState.idsToDelete + ).then(() => { + return getSavedAnalysesApi(token, { + page: tableState.currentPage, + limit: tableState.limitByPage, + sort: tableState.sortByField, + direction: tableState.sortDirection + }) + }).then((data) => { + dispatch(deleteSavedAnalysesFulfilled(data)) + }) + }) +} + +/** +Change table page or sort state +params = {currentPage, limitByPage, sortField, sortDirection} +*/ +const setAnalysesTableParams = createAction(SET_TABLE_PARAMS, (params) => params) +const selectAnalysesTableRow = createAction(SELECT_TABLE_ROW, (itemId) => itemId) +const selectAnalysesTableAllRows = createAction(SELECT_ALL_ROWS) + +const reloadAnalysesTablePending = () => { + return {type: RELOAD_TABLE + '_PENDING'} +} + +const reloadAnalysesTableFulfilled = (payload) => { + return {type: RELOAD_TABLE + '_FULFILLED', payload} +} + +const reloadAnalysesTable = (params) => { + return tokenInject((dispatch, getState, token) => { + dispatch(reloadAnalysesTablePending()) + dispatch(setAnalysesTableParams(params)) + const state = getState().getIn(['appState', 'analyzeTab', 'tableState']).toJS() + getSavedAnalysesApi(token, { + page: state.currentPage, + limit: state.limitByPage, + sort: state.sortByField, + direction: state.sortDirection + }).then((data) => { + dispatch(reloadAnalysesTableFulfilled(data)) + }) + }) +} + +export const actions = { + getRecentAnalysis, + confirmDeleteAnalyses, + cancelDeleteAnalyses, + deleteSavedAnalyses, + setAnalysesTableParams, + selectAnalysesTableAllRows, + selectAnalysesTableRow, + reloadAnalysesTable +} + +/** -----------STATE--------- **/ + +export const initialState = fromJS({ + //for welcome tab + recentAnalysis: [], + isRecentAnalysisPending: false, + //for saved tab + isSidebarVisible: true, + sidebarState: { + isRecentlyViewedPending: true, + recentlyViewed: [] + }, + tableState: { + currentPage: 1, + limitByPage: 10, + sortByField: 'id', + sortDirection: 'asc', + data: [], + count: 0, + totalCount: 0, + isLoading: true, + isDeletePopupVisible: false, + idsToDelete: [], + selectedIds: {}, //map of ids of items that selected in table + isAllSelected: false + } +}) + +/** -----------HANDLERS--------------- **/ + +export default handleActions({ + [`${GET_RECENT_ANALYSIS}_PENDING`]: (state) => { + return state.merge({ + recentAnalysis: [], + isRecentAnalysisPending: true + }) + }, + + [`${GET_RECENT_ANALYSIS}_FULFILLED`]: (state, {payload}) => { + return state.merge({ + recentAnalysis: payload.data, + isRecentAnalysisPending: false + }) + }, + + [CONFIRM_DELETE_ANALYSES]: (state, {payload: idsArray}) => { + return state.mergeIn(['tableState'], { + isDeletePopupVisible: true, + idsToDelete: idsArray + }) + }, + + [CANCEL_DELETE_ANALYSES]: (state) => { + return state.mergeIn(['tableState'], { + isDeletePopupVisible: false, + idsToDelete: [] + }) + }, + + [`${DELETE_ANALYSES}_PENDING`]: (state) => { + return state.mergeIn(['tableState'], { + isDeletePopupVisible: false, + isLoading: true + }) + }, + + [`${DELETE_ANALYSES}_FULFILLED`]: (state, {payload}) => { + return state.mergeIn(['tableState'], { + data: payload.data, + count: payload.count, + totalCount: payload.totalCount, + isLoading: false + }) + }, + + [SET_TABLE_PARAMS]: (state, {payload: tableState}) => { + return state.mergeIn(['tableState'], tableState) + }, + + [SELECT_TABLE_ROW]: (state, {payload: {itemId, select}}) => { + const path = ['tableState', 'selectedIds', itemId.toString()] + return select ? state.setIn(path, 1) : state.deleteIn(path) + }, + + [SELECT_ALL_ROWS]: (state) => { + const isAllSelected = state.getIn(['tableState', 'isAllSelected']) + + if (isAllSelected) { //then deselect all + return state.mergeIn(['tableState'], { + isAllSelected: false, + selectedIds: {} + }) + } else { //select all currently loaded data + let selectedIds = {} + const data = state.getIn(['tableState', 'data']).toJS() + data.forEach((item) => { selectedIds[item.id.toString()] = 1 }) + return state.mergeIn(['tableState'], { + isAllSelected: true, + selectedIds: selectedIds + }) + } + }, + + [`${RELOAD_TABLE}_PENDING`]: (state, {payload: params}) => { + return state.setIn(['tableState', 'selectedIds'], Map({})).setIn(['tableState', 'isLoading'], true) + }, + + [`${RELOAD_TABLE}_FULFILLED`]: (state, {payload}) => { + return state.mergeIn(['tableState'], { + data: payload.data, + count: payload.count, + totalCount: payload.totalCount, + isLoading: false + }) + }, + + [TOGGLE_SIDEBAR]: (state, {payload}) => { + const isVisible = !state.get('isSidebarVisible') + return state.set('isSidebarVisible', isVisible) + } + +}, initialState) diff --git a/frontend/app/redux/modules/appState/articles.js b/frontend/app/redux/modules/appState/articles.js new file mode 100644 index 0000000..0c72568 --- /dev/null +++ b/frontend/app/redux/modules/appState/articles.js @@ -0,0 +1,299 @@ +import {createAction, handleActions} from 'redux-actions' +import {fromJS} from 'immutable' +import * as api from '../../../api/articlesApi' +import {addAlert} from '../common/alerts' +import {getSidebarCategories} from './sidebar' +import {thunkAction} from '../../utils/common' +import * as _ from 'lodash' + +/** + * + * Constants + */ +const NS = '[Articles]' +const SHOW_DELETE_POPUP = `${NS} Show delete popup` +const HIDE_DELETE_POPUP = `${NS} Hide delete popup` +const SHOW_EMAIL_POPUP = `${NS} Show email popup` +const HIDE_EMAIL_POPUP = `${NS} Hide email popup` +const SHOW_COMMENT_POPUP = `${NS} Show comment popup` +const HIDE_COMMENT_POPUP = `${NS} Hide comment popup` +const SHOW_CLIP_POPUP = `${NS} Show clip popup` +const HIDE_CLIP_POPUP = `${NS} Hide clip popup` +const SHOW_EMAIL_CONFIRM_POPUP = `${NS} Show email confirm popup` +const HIDE_EMAIL_CONFIRM_POPUP = `${NS} Hide email confirm popup` +const SET_EMAIL_PARAMS = `${NS} Set email params` + +//This actions, when fulfilled reduced in search.js! so export this +export const COMMENT_ARTICLE = `${NS} Comment article` +export const UPDATE_COMMENT = `${NS} Update comment` +export const DELETE_COMMENT = `${NS} Delete comment` +export const LOAD_MORE_COMMENTS = `${NS} Load more comments` + +export const DELETE_ARTICLES_FROM_FEED = `${NS} Delete articles from feed` +export const DELETE_ARTICLES = `${NS} Delete articles from search results` + +const EMAIL_ARTICLES = `${NS} Email articles` +const SEND_DOCUMENTS_BY_EMAIL = `${NS} Send documents by email` +const CLIP_ARTICLES = `${NS} Clip articles` +const GET_RECENT_CLIP_FEEDS = `${NS} Get recent clip feeds` +const READ_ARTICLE_LATER = `${NS} Read article later` +const LOAD_RECIPIENTS = `${NS} Load recipients` + +const SHOW_SHARE_MENU = `${NS} Show share menu` +const HIDE_SHARE_MENU = `${NS} Hide share menu` + +export const ARTICLE_COMMENTS_LIMIT = 100 + +/** + * Actions + */ +const showDeleteArticlesPopup = createAction(SHOW_DELETE_POPUP, articles => articles) +const showEmailArticlesPopup = createAction(SHOW_EMAIL_POPUP, articles => articles) +const showCommentArticlePopup = createAction(SHOW_COMMENT_POPUP, (article, comment) => ({article, comment})) +const showClipArticlesPopup = createAction(SHOW_CLIP_POPUP, articles => articles) +const showEmailConfirmPopup = createAction(SHOW_EMAIL_CONFIRM_POPUP) +const showShareMenu = createAction(SHOW_SHARE_MENU, article => article) +const hideDeleteArticlesPopup = createAction(HIDE_DELETE_POPUP) +const hideEmailArticlesPopup = createAction(HIDE_EMAIL_POPUP) +const hideCommentArticlePopup = createAction(HIDE_COMMENT_POPUP) +const hideClipArticlesPopup = createAction(HIDE_CLIP_POPUP) +const hideEmailConfirmPopup = createAction(HIDE_EMAIL_CONFIRM_POPUP) +const hideShareMenu = createAction(HIDE_SHARE_MENU) + +const commentArticle = thunkAction(COMMENT_ARTICLE, (comment, articleId, {token, fulfilled}) => { + return api + .commentDocument(token, comment, articleId) + .then((comment) => { + fulfilled({comment, articleId}) + }) +}) + +const updateComment = thunkAction(UPDATE_COMMENT, (newComment, articleId, {getState, token, fulfilled}) => { + const commentToUpdate = getState().getIn(['appState', 'articles', 'commentPopup', 'comment']).toJS() + return api + .updateComment(token, newComment, commentToUpdate.id) + .then((comment) => { + fulfilled({comment, articleId}) + }) +}) + +const deleteComment = thunkAction(DELETE_COMMENT, (commentId, articleId, {token, fulfilled}) => { + return api + .deleteComment(token, undefined, commentId) + .then(() => { + fulfilled({commentId, articleId}) + }) +}) + +const loadMoreComments = thunkAction(LOAD_MORE_COMMENTS, (articleId, offset, {token, fulfilled}) => { + return api + .getComments(token, {offset: offset, limit: ARTICLE_COMMENTS_LIMIT}, articleId) + .then((response) => { + fulfilled({response, articleId}) + }) +}) + +const deleteArticles = createAction(DELETE_ARTICLES, ids => ids) + +const deleteArticlesFromFeed = thunkAction(DELETE_ARTICLES_FROM_FEED, (ids, feedId, {token, dispatch, fulfilled}) => { + return api + .deleteDocumentsFromFeed(token, ids, feedId) + .then(() => { + dispatch(deleteArticles(ids)) + fulfilled({ids, feedId}) + dispatch(addAlert({ + type: 'notice', + transKey: 'articleDeleted' + })) + }) +}) + +const setEmailParams = createAction(SET_EMAIL_PARAMS, params => params) + +const emailArticles = thunkAction(EMAIL_ARTICLES, (params, {dispatch}) => { + dispatch(setEmailParams(params)) + if (params.subject) { + dispatch(sendDocumentsByEmail()) + } else { + dispatch(showEmailConfirmPopup()) + } +}) + +const sendDocumentsByEmail = thunkAction(SEND_DOCUMENTS_BY_EMAIL, ({token, getState, fulfilled, dispatch}) => { + const params = getState().getIn(['appState', 'articles', 'emailPopup', 'emailParams']) + return api + .sendDocumentsByEmail(token, params) + .then(() => { + dispatch(hideEmailArticlesPopup()) + fulfilled() + }) +}) + +const clipArticles = thunkAction(CLIP_ARTICLES, (feedId, {token, fulfilled, getState, dispatch}) => { + const articlesToClip = getState().getIn(['appState', 'articles', 'clipPopup', 'articles']).toJS() + const documentIds = articlesToClip.map((a) => a.id) + return api + .clipDocuments(token, documentIds, feedId) + .then(() => { + dispatch(addAlert([{type: 'notice', transKey: 'clipDocument'}])) + dispatch(hideClipArticlesPopup()) + fulfilled() + }) +}) + +const getRecentClipFeeds = thunkAction(GET_RECENT_CLIP_FEEDS, ({token, fulfilled}) => { + return api + .getRecentClipFeeds(token) + .then(fulfilled) +}) + +const readArticleLater = thunkAction(READ_ARTICLE_LATER, (article, {token, dispatch, fulfilled}) => { + return api + .readLater(token, undefined, article.id) + .then(() => { + dispatch(addAlert([{type: 'notice', transKey: 'clipDocument'}])) + dispatch(getSidebarCategories()) + fulfilled() + }) +}) + +const loadRecipients = thunkAction(LOAD_RECIPIENTS, ({getState, fulfilled}) => { + setTimeout(() => { + const user = getState().getIn(['common', 'auth', 'user']) + fulfilled([user.get('email')]) + }, 100) +}, true) + +export const actions = { + showDeleteArticlesPopup, + showCommentArticlePopup, + showEmailArticlesPopup, + showClipArticlesPopup, + showEmailConfirmPopup, + showShareMenu, + hideDeleteArticlesPopup, + hideCommentArticlePopup, + hideEmailArticlesPopup, + hideClipArticlesPopup, + hideEmailConfirmPopup, + hideShareMenu, + commentArticle, + updateComment, + deleteComment, + loadMoreComments, + deleteArticles, + deleteArticlesFromFeed, + emailArticles, + clipArticles, + getRecentClipFeeds, + readArticleLater, + loadRecipients, + sendDocumentsByEmail +} + +/** + * State + */ +export const initialState = fromJS({ + emailPopup: { + visible: false, + articles: [], + recipients: { + all: [], + pending: false + }, + emailParams: null + }, + deletePopup: { + visible: false, + articles: [] + }, + clipPopup: { + visible: false, + articles: [] + }, + commentPopup: { + visible: false, + article: null, + comment: null + }, + emailConfirmPopup: { + visible: false + }, + shareMenu: { + visible: false, + article: null + }, + excludedArticles: [], //map of ids + recentClipFeeds: [] +}) + +/** + * Reducers + */ +const hidePopup = (type) => (state) => + state.mergeIn([type + 'Popup'], {visible: false, articles: []}) + +const showPopup = (type) => (state, {payload: articles}) => + state.mergeIn([type + 'Popup'], {visible: true, articles: articles}) + +export default handleActions({ + + [SHOW_DELETE_POPUP]: showPopup('delete'), + [SHOW_EMAIL_POPUP]: showPopup('email'), + [SHOW_CLIP_POPUP]: showPopup('clip'), + [SHOW_EMAIL_CONFIRM_POPUP]: showPopup('emailConfirm'), + [HIDE_DELETE_POPUP]: hidePopup('delete'), + [HIDE_EMAIL_POPUP]: hidePopup('email'), + [HIDE_CLIP_POPUP]: hidePopup('clip'), + [HIDE_EMAIL_CONFIRM_POPUP]: hidePopup('emailConfirm'), + + [SHOW_COMMENT_POPUP]: (state, {payload: {article, comment}}) => { + return state.mergeIn(['commentPopup'], { + visible: true, + article: article, + comment: comment + }) + }, + + [HIDE_COMMENT_POPUP]: (state) => { + return state.mergeIn(['commentPopup'], { + visible: false, + article: null, + comment: null + }) + }, + + [DELETE_ARTICLES]: (state, {payload: ids}) => { + const excludedArticles = state.get('excludedArticles') + const results = _.union(excludedArticles, ids) + return state.set('excludedArticles', results) + }, + + [`${GET_RECENT_CLIP_FEEDS} fulfilled`]: (state, {payload: recentClipFeeds}) => { + return state.set('recentClipFeeds', recentClipFeeds) + }, + + [SHOW_SHARE_MENU]: (state, {payload: article}) => { + return state.mergeIn(['shareMenu'], { + visible: true, + article + }) + }, + + [HIDE_SHARE_MENU]: (state) => { + return state.setIn(['shareMenu', 'visible'], false) + }, + + [`${LOAD_RECIPIENTS} pending`]: (state, {payload: {isPending}}) => { + return state.setIn(['emailPopup', 'recipients', 'pending'], isPending) + }, + + [`${LOAD_RECIPIENTS} fulfilled`]: (state, {payload: emails}) => { + return state.setIn(['emailPopup', 'recipients', 'all'], emails) + }, + + [SET_EMAIL_PARAMS]: (state, {payload: params}) => state.setIn(['emailPopup', 'emailParams'], params) + +}, initialState) + diff --git a/frontend/app/redux/modules/appState/dashboards.js b/frontend/app/redux/modules/appState/dashboards.js new file mode 100644 index 0000000..6c52af4 --- /dev/null +++ b/frontend/app/redux/modules/appState/dashboards.js @@ -0,0 +1,45 @@ +import * as api from '../../../api/dashboardApi' +import ReduxModule from '../abstract/reduxModule' + +export const LOAD_DASHBOARDS = 'Load dashboards' + +class Dashboards extends ReduxModule { + + getNamespace () { + return '[Dashboard]' + } + + _loadDashboards ({token, fulfilled}) { + return api + .getDashboards(token) + .then((dashboards) => { + fulfilled(dashboards) + }) + } + + defineActions () { + const loadDashboards = this.thunkAction(LOAD_DASHBOARDS, this._loadDashboards) + + return { + loadDashboards + } + } + + getInitialState () { + return { + dashboards: [] + } + } + + defineReducers () { + return { + [LOAD_DASHBOARDS]: this.setReducer('dashboards') + } + } + +} + +const dashboards = new Dashboards() +dashboards.init() + +export default dashboards diff --git a/frontend/app/redux/modules/appState/search.js b/frontend/app/redux/modules/appState/search.js new file mode 100644 index 0000000..0c51b84 --- /dev/null +++ b/frontend/app/redux/modules/appState/search.js @@ -0,0 +1,407 @@ +import {createAction, handleActions} from 'redux-actions' +import {fromJS} from 'immutable' +import * as searchApi from '../../../api/searchApi' +import * as feedsApi from '../../../api/feedsApi' +import {addAlert} from '../common/alerts' +import {getRestrictions} from '../common/auth' +import {getSidebarCategories} from './sidebar' +import {renewSearchBy, setCommonFilters} from './searchByFilters' +import {thunkAction, tokenInject} from '../../utils/common' +import * as helpers from '../../utils/helpers/search' +import { findFeedById } from '../../utils/helpers/sidebar' +import {filtersFromServerFormat, ADV_FILTERS_LIMIT} from '../../utils/helpers/advancedFilters' + +import { + COMMENT_ARTICLE, + DELETE_ARTICLES, + DELETE_ARTICLES_FROM_FEED, + DELETE_COMMENT, + LOAD_MORE_COMMENTS, + UPDATE_COMMENT +} from './articles' + +/* + * Constants + * */ +const GET_SEARCH_RESULTS = 'GET_SEARCH_RESULTS' + +const TOGGLE_REFINE_PANEL = 'TOGGLE_REFINE_PANEL' +const SELECT_REFINE_FILTER = 'SELECT_REFINE_FILTER' +const CLEAR_REFINE_FILTERS = 'CLEAR_REFINE_FILTERS' +const CLEAR_ALL_REFINE_FILTERS = 'CLEAR_ALL_REFINE_FILTERS' +const LOAD_MORE_REFINE_FILTERS = 'LOAD_MORE_REFINE_FILTERS' +const LOAD_LESS_REFINE_FILTERS = 'LOAD_LESS_REFINE_FILTERS' + +const TOGGLE_SAVE_FEED_POPUP = 'TOGGLE_SAVE_FEED_POPUP' + +const SET_FEED_RESULTS = 'SET_FEED_RESULTS' + +const EDIT_FEED = 'EDIT_FEED' + +const SET_NEW_SEARCH = 'SET_NEW_SEARCH' +const CHANGE_FEED_QUERY = 'CHANGE_FEED_QUERY' +const SET_ACTIVE_FEED = 'SET_ACTIVE_FEED' +const CHANGE_ACTIVE_FEED_NAME = 'CHANGE_ACTIVE_FEED_NAME' + +const SEARCH_SET_VALUE = 'SEARCH_SET_VALUE' + +const SELECT_ARTICLE = 'SELECT_ARTICLE' +const SELECT_ALL_ARTICLES = 'SELECT_ALL_ARTICLES' +const SAVE_FEED = 'SAVE_FEED' +const SAVE_AS_FEED = 'SAVE_AS_FEED' + +/* + * Actions + * */ +const getSearchResultsPending = createAction(GET_SEARCH_RESULTS + '_PENDING') +const getSearchResultsRejected = createAction(GET_SEARCH_RESULTS + '_REJECTED', (errors) => errors) +const setFeedResults = createAction(SET_FEED_RESULTS, (response) => response) + +const _getSearchResults = (dispatch, getState, apiPromise, initialSearch = false) => { + dispatch(getSearchResultsPending()) + return apiPromise + .then((response) => { + dispatch(setFeedResults(response)) + if (response.meta.search.filters) { + dispatch(setCommonFilters(response.meta.search.filters, response.meta.sourceLists, response.meta.sources)) + } else { + dispatch(renewSearchBy()) + } + if (response.feed) { + const categories = getState().getIn(['appState', 'sidebar', 'categories']).toJS() + const feed = findFeedById(categories, parseInt(response.feed)) + dispatch(setActiveFeed(fromJS(feed))) + } + const isFreeUser = getState().getIn(['common', 'auth', 'user', 'restrictions', 'plans', 'price']) === 0; + if (initialSearch && isFreeUser) { + const email = getState().getIn(['common', 'auth', 'user', 'email']) + searchApi.submitSearchHubspot({ + email: email, + searchquery: response.meta.search.query + }); + } + dispatch(getRestrictions()) + }) + .catch((errors) => { + dispatch(getSearchResultsRejected(errors)) + dispatch(addAlert(errors)) + }) +} + +const getSearchResults = (data, initialSearch) => { + return tokenInject((dispatch, getState, token) => { + const apiPromise = searchApi.searchQuery(token, data) + _getSearchResults(dispatch, getState, apiPromise, initialSearch) + }) +} + +const getFeedResults = (data, feedId) => { + return tokenInject((dispatch, getState, token) => { + const apiPromise = feedsApi.getFeedSearchResults(token, data, feedId) + _getSearchResults(dispatch, getState, apiPromise) + }) +} + +const saveAsFeed = thunkAction(SAVE_AS_FEED, (dataToSend, {token, dispatch, fulfilled}) => { + return feedsApi + .createFeed(token, dataToSend) + .then(() => { + fulfilled() + dispatch(getSidebarCategories()) + dispatch(getRestrictions()) + dispatch(addAlert({ + type: 'notice', + transKey: 'saveFeed', + id: 'saveFeed' + })) + }) +}, true) + +const saveFeed = thunkAction(SAVE_FEED, (dataToSend, feedId, {token, dispatch, fulfilled}) => { + return feedsApi + .saveFeed(token, dataToSend, feedId) + .then(() => { + fulfilled() + dispatch(getSidebarCategories()) + dispatch(getRestrictions()) + dispatch(addAlert({ + type: 'notice', + transKey: 'saveFeed', + id: 'saveFeed' + })) + }) +}, true) + +const toggleRefinePanel = createAction(TOGGLE_REFINE_PANEL) +const selectRefineFilter = createAction(SELECT_REFINE_FILTER, (groupName, filterValue) => { + return {groupName, filterValue} +}) +const clearRefineFilters = createAction(CLEAR_REFINE_FILTERS) +const clearAllRefineFilters = createAction(CLEAR_ALL_REFINE_FILTERS) +const loadMoreRefineFilters = createAction(LOAD_MORE_REFINE_FILTERS, groupName => groupName) +const loadLessRefineFilters = createAction(LOAD_LESS_REFINE_FILTERS, groupName => groupName) + +const toggleSaveFeedPopup = createAction(TOGGLE_SAVE_FEED_POPUP) + +const editFeed = createAction(EDIT_FEED) +const setNewSearch = createAction(SET_NEW_SEARCH) +const changeFeedQuery = createAction(CHANGE_FEED_QUERY, value => value) + +const setActiveFeed = createAction(SET_ACTIVE_FEED, feed => feed) + +const changeActiveFeedName = createAction(CHANGE_ACTIVE_FEED_NAME, feedName => feedName) + +const selectArticle = createAction(SELECT_ARTICLE, article => article) +const selectAllArticles = createAction(SELECT_ALL_ARTICLES, select => select) + +export const actions = { + getSearchResults, + toggleSaveFeedPopup, + toggleRefinePanel, + loadMoreRefineFilters, + loadLessRefineFilters, + selectRefineFilter, + clearRefineFilters, + clearAllRefineFilters, + setFeedResults, + editFeed, + setNewSearch, + saveAsFeed, + saveFeed, + getFeedResults, + setActiveFeed, + changeActiveFeedName, + changeFeedQuery, + selectArticle, + selectAllArticles +} + +/* + * State + * */ +export const initialState = fromJS({ + isEditingFeed: false, + isSavingFeed: false, + activeFeed: null, + loadedFeedName: null, + loadedFeedQuery: null, + searchResults: [], + searchResultsErrors: [], + searchResultsPending: false, + searchResultCount: 0, + searchResultTotalCount: 0, + searchResultPage: 1, + searchResultLimit: 100, + isSavedSearchVisible: false, + isSaveFeedPopupVisible: false, + isSynced: true, + isLoaded: false, + advancedFilters: { + all: {}, + pages: {}, //{groupName1: {count: xx, totalCount: yy}, groupName2: ....} + selected: {}, // {keyword: {"fsdfdsf": 1}, groupName1: {value1: 0, value2: 1, ....}, groupName2: {}, ... } will be sent to the server + pending: {}, //which groups is not applied yet + isVisible: true + }, + selectedArticles: [] +}) + +const deselectArticlesReducer = (state, {payload: ids}) => { + const selectedArticles = state.get('selectedArticles').toJS() + const filtered = selectedArticles.filter((article) => !ids.includes(article.id)) + return state.set('selectedArticles', fromJS(filtered)) +} +/* + * Reducers + * */ +export default handleActions({ + + [SEARCH_SET_VALUE]: (state, {payload}) => { + const {field, value} = payload + return state.set(field, value) + }, + + [`${GET_SEARCH_RESULTS}_PENDING`]: (state) => { + return state.merge({ + searchResults: [], + searchResultCount: 0, + searchResultTotalCount: 0, + searchResultPage: 1, + searchResultsPending: true + }) + }, + + [`${GET_SEARCH_RESULTS}_REJECTED`]: (state, {payload}) => { + return state.merge({ + searchResults: [], + searchResultsErrors: payload, + searchResultCount: 0, + searchResultTotalCount: 0, + searchResultsPending: false + }) + }, + + [SET_FEED_RESULTS]: (state, {payload: response}) => { + const {documents, advancedFilters, meta} = response + + const selectedFilters = meta.search.advancedFilters + const {allFilters, pages} = filtersFromServerFormat(advancedFilters) + + delete allFilters.reach // to hide reach in advanced filters + + helpers.mergeAdvancedFilters(allFilters, selectedFilters, pages) + + return state + .merge({ + searchResults: documents.data, + searchResultsErrors: [], + searchResultCount: documents.count, + searchResultTotalCount: documents.totalCount, + searchResultPage: documents.page, + searchResultLimit: documents.limit, + searchResultsPending: false, + selectedArticles: [], + loadedFeedQuery: meta.search.query, + isSynced: meta.status === 'synced', + isLoaded: true, + isEditingFeed: true + }) + .mergeIn(['advancedFilters'], { + all: allFilters, + selected: selectedFilters, + pages: pages, + pending: {} + }) + }, + + [SET_ACTIVE_FEED]: (state, {payload: feed}) => { + return state.merge({ + 'activeFeed': feed, + 'isEditingFeed': false + }) + }, + + [CHANGE_ACTIVE_FEED_NAME]: (state, {payload: feedName}) => { + return state.setIn(['activeFeed', 'name'], feedName) + }, + + [TOGGLE_SAVE_FEED_POPUP]: (state, {payload}) => { + const isVisible = !state.get('isSaveFeedPopupVisible') + return state.set('isSaveFeedPopupVisible', isVisible) + }, + + [TOGGLE_REFINE_PANEL]: (state) => { + const path = ['advancedFilters', 'isVisible'] + return state.setIn(path, !state.getIn(path)) + }, + + [SELECT_REFINE_FILTER]: (state, {payload: {groupName, filterValue}}) => { + const path = ['advancedFilters', 'selected', groupName] + //two groups without multiple selection + if (groupName === 'articleDate' || groupName === 'keyword') { + state = state.deleteIn(path) + } + //tri-state switch + const currentState = state.getIn([...path, filterValue]) + let newState + if (currentState === undefined) { + newState = 1 + } + else if (currentState === 1) { + newState = -1 + } + return state.deleteIn(['advancedFilters', 'pending', groupName]).setIn([...path, filterValue], newState) + }, + + [CLEAR_REFINE_FILTERS]: (state, {payload: groupName}) => { + return state.setIn(['advancedFilters', 'pending', groupName], true).deleteIn(['advancedFilters', 'selected', groupName]) + }, + + [CLEAR_ALL_REFINE_FILTERS]: (state) => { + return state.mergeIn(['advancedFilters'], { + selected: {}, + pending: {} + }) + }, + + [LOAD_LESS_REFINE_FILTERS]: (state, {payload: groupName}) => { + const path = ['advancedFilters', 'pages', groupName, 'count'] + const currentCount = state.getIn(path) + return state.setIn(path, Math.max(currentCount - ADV_FILTERS_LIMIT, ADV_FILTERS_LIMIT)) + }, + + [LOAD_MORE_REFINE_FILTERS]: (state, {payload: groupName}) => { + const path = ['advancedFilters', 'pages', groupName] + const currentCount = state.getIn([...path, 'count']) + const totalCount = state.getIn([...path, 'totalCount']) + return state.setIn([...path, 'count'], Math.min(currentCount + ADV_FILTERS_LIMIT, totalCount)) + }, + + [EDIT_FEED]: (state) => { + return state.set('isEditingFeed', true) + }, + + [SET_NEW_SEARCH]: (state) => { + return state.merge(initialState.toJS()) + }, + + [CHANGE_FEED_QUERY]: (state, {payload: value}) => { + return state.merge({ + loadedFeedQuery: value, + isEditingFeed: true + }) + }, + + [`${SAVE_FEED} pending`]: (state, {payload: {isPending}}) => { + return state.set('isSavingFeed', isPending) + }, + + [`${SAVE_AS_FEED} pending`]: (state, {payload: {isPending}}) => { + return state.set('isSavingFeed', isPending) + }, + + [SELECT_ARTICLE]: (state, {payload: article}) => { + let selectedArticles = state.get('selectedArticles').toJS() + const articleIndex = helpers.indexById(selectedArticles, article.id) + if (articleIndex === -1) { //not selected yet + selectedArticles = selectedArticles.concat(article) + } else { + selectedArticles.splice(articleIndex, 1) + } + return state.set('selectedArticles', fromJS(selectedArticles)) + }, + + [SELECT_ALL_ARTICLES]: (state, {payload: select}) => { + const selected = select ? state.get('searchResults').toJS() : fromJS([]) + return state.set('selectedArticles', selected) + }, + + [`${LOAD_MORE_COMMENTS} fulfilled`]: (state, {payload}) => { + const {articleId, response} = payload + const articles = state.get('searchResults') + return state.set('searchResults', helpers.loadMoreComments(articles, articleId, response.data)) + }, + + [`${COMMENT_ARTICLE} fulfilled`]: (state, {payload}) => { + const {comment, articleId} = payload + const articles = state.get('searchResults') + return state.set('searchResults', helpers.addComment(articles, articleId, comment)) + }, + + [`${UPDATE_COMMENT} fulfilled`]: (state, {payload}) => { + const {comment, articleId} = payload + const articles = state.get('searchResults') + return state.set('searchResults', helpers.updateComment(articles, articleId, comment)) + }, + + [`${DELETE_COMMENT} fulfilled`]: (state, {payload}) => { + const {commentId, articleId} = payload + const articles = state.get('searchResults') + return state.set('searchResults', helpers.deleteComment(articles, articleId, commentId)) + }, + + //only remove deleted articles the from selection + [DELETE_ARTICLES]: deselectArticlesReducer, + [`${DELETE_ARTICLES_FROM_FEED} fulfilled`]: deselectArticlesReducer + +}, initialState) diff --git a/frontend/app/redux/modules/appState/searchByFilters.js b/frontend/app/redux/modules/appState/searchByFilters.js new file mode 100644 index 0000000..9258c0d --- /dev/null +++ b/frontend/app/redux/modules/appState/searchByFilters.js @@ -0,0 +1,560 @@ +import { createAction, handleActions } from 'redux-actions' +import { fromJS } from 'immutable' + +import { searchSources, getSourceLists } from '../../../api/searchApi' +import {thunkAction} from '../../utils/common' +import { storeObj } from '../../../main' +/* + * Constants + * */ +export const TOGGLE_MEDIA_TYPE = 'TOGGLE_MEDIA_TYPE' +export const TOGGLE_ALL_MEDIA_TYPES = 'TOGGLE_ALL_MEDIA_TYPES' + +export const SET_SEARCH_INTERVAL = 'SET_SEARCH_INTERVAL' +export const SET_SEARCH_LAST_DATE = 'SET_SEARCH_LAST_DATE' +export const SET_SEARCH_DATE = 'SET_SEARCH_DATE' +export const SET_START_DATE = 'SET_START_DATE' +export const SET_END_DATE = 'SET_END_DATE' + +export const TOGGLE_SEARCH_BY = 'TOGGLE_SEARCH_BY' +export const CHOOSE_SEARCH_BY_TAB = 'CHOOSE_SEARCH_BY_TAB' + +export const SET_HEADLINE_INCLUDED = 'SET_HEADLINE_INCLUDED' +export const SET_HEADLINE_EXCLUDED = 'SET_HEADLINE_EXCLUDED' + +export const TOGGLE_LANG = 'TOGGLE_LANG' +export const TOGGLE_ALL_LANGS = 'TOGGLE_ALL_LANGS' + +export const CHANGE_LOCATIONS_TYPE = 'CHANGE_LOCATIONS_TYPE' + +export const MOVE_LOCATION = 'MOVE_LOCATION' + +export const CLEAR_LOCATIONS = 'CLEAR_LOCATIONS' + +export const GET_SEARCH_BY_SOURCES = 'GET_SEARCH_BY_SOURCES' +export const SET_SEARCH_BY_SOURCES_QUERY = 'SET_SEARCH_BY_SOURCES_QUERY' + +export const ADD_SELECTED_SEARCH_BY_SOURCE = 'ADD_SELECTED_SEARCH_BY_SOURCE' +export const REMOVE_SELECTED_SEARCH_BY_SOURCE = 'REMOVE_SELECTED_SEARCH_BY_SOURCE' + +export const INCLUDE_EXCLUDE_SEARCH_BY_SOURCES = 'INCLUDE_EXCLUDE_SEARCH_BY_SOURCES' +export const CLEAR_SEARCH_BY_SOURCES = 'CLEAR_SEARCH_BY_SOURCES' + +export const GET_SEARCH_BY_SOURCE_LISTS = 'GET_SEARCH_BY_SOURCE_LISTS' + +export const MOVE_SOURCE_LIST = 'MOVE_SOURCE_LIST' + +export const TOGGLE_INCLUDE_DUPLICATES = 'TOGGLE_INCLUDE_DUPLICATES' +export const TOGGLE_HAS_IMAGES = 'TOGGLE_HAS_IMAGES' + +export const RENEW_SEARCH_BY = 'RENEW_SEARCH_BY' +export const SET_COMMON_FILTERS = 'SET_COMMON_FILTERS' + +/* + * Actions + * */ +export const toggleMediaType = createAction(TOGGLE_MEDIA_TYPE, (chosenType, isChosen) => { + return {chosenType, isChosen} +}) +export const toggleAllMediaTypes = createAction(TOGGLE_ALL_MEDIA_TYPES, isChosen => isChosen) + +export const setSearchInterval = createAction(SET_SEARCH_INTERVAL, (newInterval) => newInterval) +export const setSearchLastDate = createAction(SET_SEARCH_LAST_DATE, (newLastDate) => newLastDate) +export const setSearchDate = createAction(SET_SEARCH_DATE, (newDate) => newDate) +export const setStartDate = createAction(SET_START_DATE, (newDate) => newDate) +export const setEndDate = createAction(SET_END_DATE, (newDate) => newDate) + +export const toggleSearchBy = createAction(TOGGLE_SEARCH_BY) +export const chooseSearchByTab = createAction(CHOOSE_SEARCH_BY_TAB, (tabName) => tabName) + +export const setHeadlineIncluded = createAction(SET_HEADLINE_INCLUDED, (headline) => headline) +export const setHeadlineExcluded = createAction(SET_HEADLINE_EXCLUDED, (headline) => headline) + +export const toggleLang = createAction(TOGGLE_LANG, (chosenLang, isChosen) => { + return {chosenLang, isChosen} +}) +export const toggleAllLangs = createAction(TOGGLE_ALL_LANGS, isChosen => isChosen) + +export const changeLocationsType = createAction(CHANGE_LOCATIONS_TYPE, (newLocationsType) => newLocationsType) + +export const moveLocation = createAction(MOVE_LOCATION, (from, to, locType, loc) => { + return {from, to, locType, loc} +}) + +export const clearLocations = createAction(CLEAR_LOCATIONS) + +export const getSearchBySources = thunkAction(GET_SEARCH_BY_SOURCES, (data, {token, fulfilled}) => { + return searchSources(token, data) + .then((data) => { + fulfilled(data) + }) +}) +export const setSearchBySourcesQuery = createAction(SET_SEARCH_BY_SOURCES_QUERY, (query) => query) + +export const addSelectedSearchBySource = createAction(ADD_SELECTED_SEARCH_BY_SOURCE, (source) => source) + +export const removeSelectedSearchBySource = createAction(REMOVE_SELECTED_SEARCH_BY_SOURCE, (sourceId) => sourceId) +export const clearSearchBySources = createAction(CLEAR_SEARCH_BY_SOURCES) + +export const includeExcludeSearchBySources = createAction(INCLUDE_EXCLUDE_SEARCH_BY_SOURCES, (sourcesType) => sourcesType) + +export const getSearchBySourceLists = thunkAction(GET_SEARCH_BY_SOURCE_LISTS, (data, {token, fulfilled}) => { + return getSourceLists(token, data) + .then((data) => { + fulfilled(data) + }) +}) + +export const moveSourceList = createAction(MOVE_SOURCE_LIST, (from, to, list) => { + return {from, to, list} +}) + +export const toggleIncludeDuplicates = createAction(TOGGLE_INCLUDE_DUPLICATES) +export const toggleHasImages = createAction(TOGGLE_HAS_IMAGES) + +export const renewSearchBy = createAction(RENEW_SEARCH_BY) + +export const setCommonFilters = createAction(SET_COMMON_FILTERS, (filters, sourceLists, sources) => { + return {filters, sourceLists, sources} +}) + +export const actions = { + toggleMediaType, + toggleAllMediaTypes, + setSearchInterval, + setSearchLastDate, + setSearchDate, + setStartDate, + setEndDate, + toggleSearchBy, + chooseSearchByTab, + setHeadlineIncluded, + setHeadlineExcluded, + toggleLang, + toggleAllLangs, + changeLocationsType, + moveLocation, + clearLocations, + getSearchBySources, + setSearchBySourcesQuery, + addSelectedSearchBySource, + removeSelectedSearchBySource, + clearSearchBySources, + includeExcludeSearchBySources, + getSearchBySourceLists, + moveSourceList, + toggleIncludeDuplicates, + toggleHasImages, + renewSearchBy, + setCommonFilters +} + +/* + * State + * */ +const locations = [{code: 'AD', type: 'country'}, {code: 'AE', type: 'country'}, {code: 'AF', type: 'country'}, {code: 'AG', type: 'country'}, {code: 'AI', type: 'country'}, {code: 'AL', type: 'country'}, {code: 'AM', type: 'country'}, {code: 'AO', type: 'country'}, {code: 'AQ', type: 'country'}, {code: 'AR', type: 'country'}, {code: 'AS', type: 'country'}, + {code: 'AT', type: 'country'}, {code: 'AU', type: 'country'}, {code: 'AW', type: 'country'}, {code: 'AX', type: 'country'}, {code: 'AZ', type: 'country'}, {code: 'BA', type: 'country'}, {code: 'BB', type: 'country'}, {code: 'BD', type: 'country'}, {code: 'BE', type: 'country'}, {code: 'BF', type: 'country'}, {code: 'BG', type: 'country'}, {code: 'BH', type: 'country'}, {code: 'BI', type: 'country'}, {code: 'BJ', type: 'country'}, + {code: 'BL', type: 'country'}, {code: 'BM', type: 'country'}, {code: 'BN', type: 'country'}, {code: 'BO', type: 'country'}, {code: 'BQ', type: 'country'}, {code: 'BR', type: 'country'}, {code: 'BS', type: 'country'}, {code: 'BT', type: 'country'}, {code: 'BV', type: 'country'}, {code: 'BW', type: 'country'}, {code: 'BY', type: 'country'}, {code: 'BZ', type: 'country'}, {code: 'CA', type: 'country'}, {code: 'CC', type: 'country'}, + {code: 'CD', type: 'country'}, {code: 'CF', type: 'country'}, {code: 'CG', type: 'country'}, {code: 'CH', type: 'country'}, {code: 'CI', type: 'country'}, {code: 'CK', type: 'country'}, {code: 'CL', type: 'country'}, {code: 'CM', type: 'country'}, {code: 'CN', type: 'country'}, {code: 'CO', type: 'country'}, {code: 'CR', type: 'country'}, {code: 'CU', type: 'country'}, {code: 'CV', type: 'country'}, {code: 'CW', type: 'country'}, + {code: 'CX', type: 'country'}, {code: 'CY', type: 'country'}, {code: 'CZ', type: 'country'}, {code: 'DE', type: 'country'}, {code: 'DJ', type: 'country'}, {code: 'DK', type: 'country'}, {code: 'DM', type: 'country'}, {code: 'DO', type: 'country'}, {code: 'DZ', type: 'country'}, {code: 'EC', type: 'country'}, {code: 'EE', type: 'country'}, {code: 'EG', type: 'country'}, {code: 'EH', type: 'country'}, {code: 'ER', type: 'country'}, + {code: 'ES', type: 'country'}, {code: 'ET', type: 'country'}, {code: 'FI', type: 'country'}, {code: 'FJ', type: 'country'}, {code: 'FK', type: 'country'}, {code: 'FM', type: 'country'}, {code: 'FO', type: 'country'}, {code: 'FR', type: 'country'}, {code: 'GA', type: 'country'}, {code: 'GB', type: 'country'}, {code: 'GD', type: 'country'}, {code: 'GE', type: 'country'}, {code: 'GF', type: 'country'}, {code: 'GG', type: 'country'}, + {code: 'GH', type: 'country'}, {code: 'GI', type: 'country'}, {code: 'GL', type: 'country'}, {code: 'GM', type: 'country'}, {code: 'GN', type: 'country'}, {code: 'GP', type: 'country'}, {code: 'GQ', type: 'country'}, {code: 'GR', type: 'country'}, {code: 'GS', type: 'country'}, {code: 'GT', type: 'country'}, {code: 'GU', type: 'country'}, {code: 'GW', type: 'country'}, {code: 'GY', type: 'country'}, {code: 'HK', type: 'country'}, + {code: 'HM', type: 'country'}, {code: 'HN', type: 'country'}, {code: 'HR', type: 'country'}, {code: 'HT', type: 'country'}, {code: 'HU', type: 'country'}, {code: 'ID', type: 'country'}, {code: 'IE', type: 'country'}, {code: 'IL', type: 'country'}, {code: 'IM', type: 'country'}, {code: 'IN', type: 'country'}, {code: 'IO', type: 'country'}, {code: 'IQ', type: 'country'}, {code: 'IR', type: 'country'}, {code: 'IS', type: 'country'}, + {code: 'IT', type: 'country'}, {code: 'JE', type: 'country'}, {code: 'JM', type: 'country'}, {code: 'JO', type: 'country'}, {code: 'JP', type: 'country'}, {code: 'KE', type: 'country'}, {code: 'KG', type: 'country'}, {code: 'KH', type: 'country'}, {code: 'KI', type: 'country'}, {code: 'KM', type: 'country'}, {code: 'KN', type: 'country'}, {code: 'KP', type: 'country'}, {code: 'KR', type: 'country'}, {code: 'KW', type: 'country'}, + {code: 'KY', type: 'country'}, {code: 'KZ', type: 'country'}, {code: 'LA', type: 'country'}, {code: 'LB', type: 'country'}, {code: 'LC', type: 'country'}, {code: 'LI', type: 'country'}, {code: 'LK', type: 'country'}, {code: 'LR', type: 'country'}, {code: 'LS', type: 'country'}, {code: 'LT', type: 'country'}, {code: 'LU', type: 'country'}, {code: 'LV', type: 'country'}, {code: 'LY', type: 'country'}, {code: 'MA', type: 'country'}, + {code: 'MC', type: 'country'}, {code: 'MD', type: 'country'}, {code: 'ME', type: 'country'}, {code: 'MF', type: 'country'}, {code: 'MG', type: 'country'}, {code: 'MH', type: 'country'}, {code: 'MK', type: 'country'}, {code: 'ML', type: 'country'}, {code: 'MM', type: 'country'}, {code: 'MN', type: 'country'}, {code: 'MO', type: 'country'}, {code: 'MP', type: 'country'}, {code: 'MQ', type: 'country'}, {code: 'MR', type: 'country'}, + {code: 'MS', type: 'country'}, {code: 'MT', type: 'country'}, {code: 'MU', type: 'country'}, {code: 'MV', type: 'country'}, {code: 'MW', type: 'country'}, {code: 'MX', type: 'country'}, {code: 'MY', type: 'country'}, {code: 'MZ', type: 'country'}, {code: 'NA', type: 'country'}, {code: 'NC', type: 'country'}, {code: 'NE', type: 'country'}, {code: 'NF', type: 'country'}, {code: 'NG', type: 'country'}, {code: 'NI', type: 'country'}, + {code: 'NL', type: 'country'}, {code: 'NO', type: 'country'}, {code: 'NP', type: 'country'}, {code: 'NR', type: 'country'}, {code: 'NU', type: 'country'}, {code: 'NZ', type: 'country'}, {code: 'OM', type: 'country'}, {code: 'PA', type: 'country'}, {code: 'PE', type: 'country'}, {code: 'PF', type: 'country'}, {code: 'PG', type: 'country'}, {code: 'PH', type: 'country'}, {code: 'PK', type: 'country'}, {code: 'PL', type: 'country'}, + {code: 'PM', type: 'country'}, {code: 'PN', type: 'country'}, {code: 'PR', type: 'country'}, {code: 'PS', type: 'country'}, {code: 'PT', type: 'country'}, {code: 'PW', type: 'country'}, {code: 'PY', type: 'country'}, {code: 'QA', type: 'country'}, {code: 'RE', type: 'country'}, {code: 'RO', type: 'country'}, {code: 'RS', type: 'country'}, {code: 'RU', type: 'country'}, {code: 'RW', type: 'country'}, {code: 'SA', type: 'country'}, + {code: 'SB', type: 'country'}, {code: 'SC', type: 'country'}, {code: 'SD', type: 'country'}, {code: 'SE', type: 'country'}, {code: 'SG', type: 'country'}, {code: 'SH', type: 'country'}, {code: 'SI', type: 'country'}, {code: 'SJ', type: 'country'}, {code: 'SK', type: 'country'}, {code: 'SL', type: 'country'}, {code: 'SM', type: 'country'}, {code: 'SN', type: 'country'}, {code: 'SO', type: 'country'}, {code: 'SR', type: 'country'}, + {code: 'SS', type: 'country'}, {code: 'ST', type: 'country'}, {code: 'SV', type: 'country'}, {code: 'SX', type: 'country'}, {code: 'SY', type: 'country'}, {code: 'SZ', type: 'country'}, {code: 'TC', type: 'country'}, {code: 'TD', type: 'country'}, {code: 'TF', type: 'country'}, {code: 'TG', type: 'country'}, {code: 'TH', type: 'country'}, {code: 'TJ', type: 'country'}, {code: 'TK', type: 'country'}, {code: 'TL', type: 'country'}, + {code: 'TM', type: 'country'}, {code: 'TN', type: 'country'}, {code: 'TO', type: 'country'}, {code: 'TR', type: 'country'}, {code: 'TT', type: 'country'}, {code: 'TV', type: 'country'}, {code: 'TW', type: 'country'}, {code: 'TZ', type: 'country'}, {code: 'UA', type: 'country'}, {code: 'UG', type: 'country'}, {code: 'UM', type: 'country'}, {code: 'US', type: 'country'}, {code: 'UY', type: 'country'}, {code: 'UZ', type: 'country'}, + {code: 'VA', type: 'country'}, {code: 'VC', type: 'country'}, {code: 'VE', type: 'country'}, {code: 'VG', type: 'country'}, {code: 'VI', type: 'country'}, {code: 'VN', type: 'country'}, {code: 'VU', type: 'country'}, {code: 'WF', type: 'country'}, {code: 'WS', type: 'country'}, {code: 'YE', type: 'country'}, {code: 'YT', type: 'country'}, {code: 'ZA', type: 'country'}, {code: 'ZM', type: 'country'}, {code: 'ZW', type: 'country'}, {code: 'AL', type: 'state'}, {code: 'AK', type: 'state'}, {code: 'AZ', type: 'state'}, {code: 'AR', type: 'state'}, {code: 'CA', type: 'state'}, {code: 'CO', type: 'state'}, {code: 'CT', type: 'state'}, {code: 'DE', type: 'state'}, {code: 'DC', type: 'state'}, {code: 'FL', type: 'state'}, {code: 'GA', type: 'state'}, {code: 'HI', type: 'state'}, + {code: 'ID', type: 'state'}, {code: 'IL', type: 'state'}, {code: 'IN', type: 'state'}, {code: 'IA', type: 'state'}, {code: 'KS', type: 'state'}, {code: 'KY', type: 'state'}, {code: 'LA', type: 'state'}, {code: 'ME', type: 'state'}, {code: 'MD', type: 'state'}, {code: 'MA', type: 'state'}, {code: 'MI', type: 'state'}, {code: 'MN', type: 'state'}, {code: 'MS', type: 'state'}, {code: 'MO', type: 'state'}, + {code: 'MT', type: 'state'}, {code: 'NE', type: 'state'}, {code: 'NV', type: 'state'}, {code: 'NH', type: 'state'}, {code: 'NJ', type: 'state'}, {code: 'NM', type: 'state'}, {code: 'NY', type: 'state'}, {code: 'NC', type: 'state'}, {code: 'ND', type: 'state'}, {code: 'OH', type: 'state'}, {code: 'OK', type: 'state'}, {code: 'OR', type: 'state'}, {code: 'PA', type: 'state'}, {code: 'RI', type: 'state'}, + {code: 'SC', type: 'state'}, {code: 'SD', type: 'state'}, {code: 'TN', type: 'state'}, {code: 'TX', type: 'state'}, {code: 'UT', type: 'state'}, {code: 'VT', type: 'state'}, {code: 'VA', type: 'state'}, {code: 'WA', type: 'state'}, {code: 'WV', type: 'state'}, {code: 'WI', type: 'state'}, {code: 'WY', type: 'state'}] + +export const allMediaTypes = [ // last 3 are domain params + 'news', + 'blogs', + 'reddit', + 'twitter', + 'instagram' +]; + +export const initialState = fromJS({ + // mediaTypes: ['news', 'blogs', 'socials', 'videos', 'forums', 'photo'], + mediaTypes: allMediaTypes, + // chosenMediaTypes: ['news', 'blogs', 'socials', 'videos', 'forums', 'photo'], + chosenMediaTypes: [], // set only the allowed media types from restrictions initially + searchLastDates: ['1d', '7d', '15d', '30d'], // 15d as 2W, to match with subscription + searchIntervals: ['all', 'last', 'between'], + chosenSearchDate: 'all', + chosenSearchInterval: 'all', + chosenSearchLastDate: '1d', + chosenStartDate: '', + chosenEndDate: '', + isSearchByVisible: false, + searchByTabs: [ + 'emphasis', 'languages', 'locations', 'sources', 'sourceLists', 'extras' + ], + chosenSearchByTab: 'emphasis', + searchLanguages: ['af', 'sq', 'ar', 'bn', 'bs', 'bg', 'ca', 'zh', 'hr', 'cs', + 'da', 'nl', 'en', 'et', 'tl', 'fi', 'fr', 'de', 'el', 'he', 'hi', 'hu', 'is', + 'id', 'it', 'ja', 'ko', 'lv', 'lt', 'mk', 'ms', 'no', 'fa', 'pl', 'pt', 'ro', + 'ru', 'sr', 'sk', 'sl', 'es', 'sv', 'ta', 'th', 'tr', 'uk', 'ur', 'vi'], + chosenLanguages: [], + locations, + chosenLocationsType: 'country', + locationsToInclude: [], + locationsToExclude: [], + headlineIncluded: '', + headlineExcluded: '', + searchBySourcesQuery: '', + searchBySources: [], + selectedSearchBySources: [], + searchBySourcesType: 'include', + searchBySourceLists: [], + searchBySourceListsAvailable: [], + searchBySourceListsToInclude: [], + searchBySourceListsToExclude: [], + hasImages: false +}) + +/* + * Reducers + * */ +export default handleActions({ + + [TOGGLE_MEDIA_TYPE]: (state, {payload}) => { + const chosenTypes = state.get('chosenMediaTypes') + + const newChosenTypes = payload.isChosen + ? chosenTypes.concat(payload.chosenType) + : chosenTypes.filter((type) => { + return payload.chosenType !== type + }) + + return state.set('chosenMediaTypes', newChosenTypes) + }, + + [TOGGLE_ALL_MEDIA_TYPES]: (state, {payload: isChosen}) => { + const chosenTypes = isChosen ? state.get('mediaTypes').toJS() : [] + return state.set('chosenMediaTypes', chosenTypes) + }, + + [SET_SEARCH_INTERVAL]: (state, {payload: newInterval}) => { + return state.set('chosenSearchInterval', newInterval) + }, + + [SET_SEARCH_LAST_DATE]: (state, {payload: newLastDate}) => { + return state.set('chosenSearchLastDate', newLastDate) + }, + + [SET_SEARCH_DATE]: (state, {payload: newDate}) => { + return state.set('chosenSearchDate', newDate) + }, + + [SET_START_DATE]: (state, {payload: newDate}) => { + return state.set('chosenStartDate', newDate) + }, + + [SET_END_DATE]: (state, {payload: newDate}) => { + return state.set('chosenEndDate', newDate) + }, + + [TOGGLE_SEARCH_BY]: (state, {payload}) => { + const isSearchByVisible = !state.get('isSearchByVisible') + return state.set('isSearchByVisible', isSearchByVisible) + }, + + [CHOOSE_SEARCH_BY_TAB]: (state, {payload: tabName}) => { + return state.set('chosenSearchByTab', tabName) + }, + + [SET_HEADLINE_INCLUDED]: (state, {payload: headline}) => { + return state.set('headlineIncluded', headline) + }, + + [SET_HEADLINE_EXCLUDED]: (state, {payload: headline}) => { + return state.set('headlineExcluded', headline) + }, + + [TOGGLE_LANG]: (state, {payload}) => { + const chosenLangs = state.get('chosenLanguages') + + const newChosenLangs = payload.isChosen + ? chosenLangs.concat(payload.chosenLang) + : chosenLangs.filter((lang) => { + return payload.chosenLang !== lang + }) + + return state.set('chosenLanguages', newChosenLangs) + }, + + [TOGGLE_ALL_LANGS]: (state, {payload: isChosen}) => { + const chosenLangs = isChosen ? state.get('searchLanguages').toJS() : [] + return state.set('chosenLanguages', chosenLangs) + }, + + [CHANGE_LOCATIONS_TYPE]: (state, {payload: newLocationsType}) => { + return state.set('chosenLocationsType', newLocationsType) + }, + + [MOVE_LOCATION]: (state, { payload }) => { + const listFrom = state.get(payload.from) + const filteredList = listFrom.filter((loc) => { + return loc.get('code') !== payload.loc.code + }) + const listTo = state.get(payload.to).push(fromJS(payload.loc)) + + return state.merge({ + [payload.from]: filteredList, + [payload.to]: listTo + }) + }, + + [CLEAR_LOCATIONS]: (state) => { + const locations = initialState.get('locations') + const chosenLocationsType = initialState.get('chosenLocationsType') + const locationsToInclude = initialState.get('locationsToInclude') + const locationsToExclude = initialState.get('locationsToExclude') + + return state.merge({ + locations, + chosenLocationsType, + locationsToInclude, + locationsToExclude + }) + }, + + [`${GET_SEARCH_BY_SOURCES} fulfilled`]: (state, { payload }) => { + const response = payload.sources.data + return state.set('searchBySources', response) + }, + + [SET_SEARCH_BY_SOURCES_QUERY]: (state, {payload: query}) => { + return state.set('searchBySourcesQuery', query) + }, + + [ADD_SELECTED_SEARCH_BY_SOURCE]: (state, {payload: source}) => { + const selectedSources = state.get('selectedSearchBySources') + const isNew = !selectedSources.find(chosenSource => chosenSource.get('id') === source.id) + const newSources = isNew ? selectedSources.push(fromJS(source)) : selectedSources + + return state.set('selectedSearchBySources', newSources) + }, + + [REMOVE_SELECTED_SEARCH_BY_SOURCE]: (state, {payload: sourceId}) => { + const newSources = state.get('selectedSearchBySources').filter((sourceItem) => { + return sourceItem.get('id') !== sourceId + }) + return state.set('selectedSearchBySources', newSources) + }, + + [CLEAR_SEARCH_BY_SOURCES]: (state) => { + return state.merge({ + 'selectedSearchBySources': fromJS([]) + }) + }, + + [INCLUDE_EXCLUDE_SEARCH_BY_SOURCES]: (state, {payload: sourceType}) => { + return state.set('searchBySourcesType', sourceType) + }, + + [`${GET_SEARCH_BY_SOURCE_LISTS} fulfilled`]: (state, { payload }) => { + const includedListsIds = state.get('searchBySourceListsToInclude').map(list => list.get('id')) + const excludedListsIds = state.get('searchBySourceListsToExclude').map(list => list.get('id')) + const usedListsIds = includedListsIds.concat(excludedListsIds) + + const allLists = fromJS(payload.data) + const lists = allLists.filter(list => !usedListsIds.includes(list.get('id'))) + + return state.merge({ + searchBySourceListsAvailable: lists, + searchBySourceLists: allLists + }) + }, + + [MOVE_SOURCE_LIST]: (state, { payload }) => { + const listFrom = fromJS(state.get(payload.from)) + const filteredList = listFrom.filter((list) => { + return list.get('id') !== payload.list.id + }) + const listTo = state.get(payload.to).push(fromJS(payload.list)) + + return state.merge({ + [payload.from]: filteredList, + [payload.to]: listTo + }) + }, + + [TOGGLE_INCLUDE_DUPLICATES]: (state, {payload}) => { + const includeDuplicates = !state.get('includeDuplicates') + return state.set('includeDuplicates', includeDuplicates) + }, + + [TOGGLE_HAS_IMAGES]: (state, {payload}) => { + const hasImages = !state.get('hasImages') + return state.set('hasImages', hasImages) + }, + + [RENEW_SEARCH_BY]: (state) => { + + const { + common: { auth } + } = storeObj.getState().toJS(); + + let chosenMediaTypes = []; + + if ( + auth && + auth.user && + auth.user.restrictions && + auth.user.restrictions.plans + ) { + const planDetails = auth.user.restrictions.plans; + chosenMediaTypes = allMediaTypes.filter((v) => planDetails[v]); + /* if (auth.user.restrictions.plans.price === 0) { + // TODO: remove following restrictions when duplication fixes + const restrictedTemporary = ['news', 'blogs']; + chosenMediaTypes = chosenMediaTypes.filter( + (v) => !restrictedTemporary.includes(v) + ); + } */ + } + + return state.merge(initialState.toJS()).set('chosenMediaTypes', chosenMediaTypes) + }, + + [SET_COMMON_FILTERS]: (state, {payload}) => { + const { filters, sourceLists, sources } = payload + + let result = initialState + .merge({ + chosenSearchByTab: state.get('chosenSearchByTab'), + isSearchByVisible: state.get('isSearchByVisible'), + searchBySources: state.get('searchBySources'), + searchBySourceLists: state.get('searchBySourceLists') + }) + .toJS() + + if (filters.headline) { + result.headlineIncluded = filters.headline.include || '' + result.headlineExcluded = filters.headline.exclude || '' + } + + const { source, domain } = filters.publisher || {} + if (source || domain) { + const medias = (source || []).concat(domain || []); + result.chosenMediaTypes = medias.map((v) => v.split('.')[0]); + } + + if (filters.source && sources.length > 0) { + result.searchBySourcesType = filters.source.type + result.selectedSearchBySources = sources + } + + let usedSourceLists = [] + if (filters.sourceList) { + if (filters.sourceList.include) { + result.searchBySourceListsToInclude = sourceLists.filter(source => { + return filters.sourceList.include.includes(source.id) + }) + usedSourceLists = usedSourceLists.concat(filters.sourceList.include) + } + if (filters.sourceList.exclude) { + result.searchBySourceListsToExclude = sourceLists.filter(source => { + return filters.sourceList.exclude.includes(source.id) + }) + usedSourceLists = usedSourceLists.concat(filters.sourceList.exclude) + } + } + + result.searchBySourceListsAvailable = result.searchBySourceLists.filter(source => { + return !usedSourceLists.includes(source.id) + }) + + if (filters.language) { + result.chosenLanguages = filters.language + } + + if (filters.country || filters.state) { + let locationsToInclude = [] + let locationsToExclude = [] + let locationsMain = [] + locations.forEach(location => { + const code = location.code + + if (location.type === 'country') { + if (filters.country && filters.country.include && filters.country.include.includes(code)) { + locationsToInclude.push(location) + } + else if (filters.country && filters.country.exclude && filters.country.exclude.includes(code)) { + locationsToExclude.push(location) + } + else { + locationsMain.push(location) + } + } + + if (location.type === 'state') { + if (filters.state && filters.state.include && filters.state.include.includes(code)) { + locationsToInclude.push(location) + } + else if (filters.state && filters.state.exclude && filters.state.exclude.includes(code)) { + locationsToExclude.push(location) + } + else { + locationsMain.push(location) + } + } + }) + + result.locationsToInclude = locationsToInclude + result.locationsToExclude = locationsToExclude + result.locations = locationsMain + } + + if (filters.date) { + let dateResult = {} + if (filters.date.type === 'all') { + dateResult = { + chosenSearchDate: 'all', + chosenSearchInterval: 'all', + chosenSearchLastDate: '', + chosenStartDate: '', + chosenEndDate: '' + } + } + else if (filters.date.type === 'last') { + dateResult = { + chosenSearchDate: filters.date.days + 'd', + chosenSearchInterval: 'last', + chosenSearchLastDate: filters.date.days + 'd', + chosenStartDate: '', + chosenEndDate: '' + } + } + else if (filters.date.type === 'between') { + dateResult = { + chosenSearchDate: `${filters.date.start} - ${filters.date.end}`, + chosenSearchInterval: 'between', + chosenSearchLastDate: '', + chosenStartDate: filters.date.start, + chosenEndDate: filters.date.end + } + } + result = Object.assign(result, dateResult) + } + if (filters.duplicates) { + result.includeDuplicates = filters.duplicates + } + if (filters.hasImage) { + result.hasImages = filters.hasImage + } + + return state.merge(result) + } + +}, initialState) diff --git a/frontend/app/redux/modules/appState/share/emailThemes/themeForm.js b/frontend/app/redux/modules/appState/share/emailThemes/themeForm.js new file mode 100644 index 0000000..6ed7c75 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/emailThemes/themeForm.js @@ -0,0 +1,232 @@ +import {ReduxModule} from '../../../abstract/reduxModule' + +class ThemeForm extends ReduxModule { + + getNamespace () { + return '[Theme form]' + } + + getInitialState () { + return {} + } +} + +export default ThemeForm + +/*export class ThemeForm extends abstractModule { + constructor () { + super(); + this.initNamespace('THEMES_'); + } + + initInitialState () { + const rgbaString = 'rgba(0, 0, 0, 1)'; + const string = ''; + const int = 0; + const boolean = false; + const languageCode = 'en'; + const File = new File; + + const FontOptions = { + family: string, + size: int, + style: { + bold: boolean, + italic: boolean, + underline: boolean + } + }; + + // sample of structure for backend + const BackendThemeOptions = { + type: 'enhanced' || 'plain', + summary: string, + conclusion: string, + header: { + imageUrl: string, + logoLink: string || '', + title: 'Newsletter' // only for enhanced + }, + fonts: { + header: FontOptions, + tableOfContents: FontOptions, + feeTitle: FontOptions, + articleHeadline: FontOptions, + source: FontOptions, + author: FontOptions, + date: FontOptions, + articleContent: FontOptions + }, + content: { + highlightKeywords: { + bold: boolean, + color: rgbaString + }, + showInfo: { + sourceCountry: boolean, + articleSentiment: boolean, + articleCount: boolean, + images: boolean, + sharingOptions: boolean, + sectionDivider: boolean, + userComments: 'no' || 'with_author_date' || 'without_author_date', + tableOfContents: { + visible: boolean, + headline: 'no' || 'headline' || 'headline_source_date' || 'source_headline_date' + } + }, + language: languageCode, + extract: 'start' || 'context' || 'no' + }, + colors: { + background: { + header: rgbaString, + emailBody: rgbaString, + accent: rgbaString + }, + text: { + header: rgbaString, + articleHeadline: rgbaString, + articleContent: rgbaString, + author: rgbaString, + publishDate: rgbaString, + source: rgbaString + } + } + }; + + this.initialState = fromJS({ + name: string, + template: string, + options: { + type: 'enhanced' || 'plain', + summary: { + value: string, + isEdit: boolean + }, + conclusion: { + value: string, + isEdit: boolean + }, + header: { + imageUrl: { + file: File, + url: string + }, + logoLink: string, + title: { + value: string, + isEdit: boolean + } + }, + fonts: { + header: FontOptions, + tableOfContents: FontOptions, + feeTitle: FontOptions, + articleHeadline: FontOptions, + source: FontOptions, + author: FontOptions, + date: FontOptions, + articleContent: FontOptions + }, + content: { + language: languageCode, + extract: { + value: 'start', + entities: [{ + value: 'start', + label: 'Start of text extract' + }, { + value: 'context', + label: 'Contextual extract' + }, { + value: 'no', + label: 'No article extract' + }] + }, + highlightKeywords: { + bold: boolean, + color: rgbaString, + colorPresets: [rgbaString] + }, + showInfo: { + sourceCountry: boolean, + articleSentiment: boolean, + articleCount: boolean, + images: boolean, + sharingOptions: boolean, + sectionDivider: boolean, + userComments: { + value: 'no', + entities: [{ + value: 'no', + label: 'No User Comments' + }, { + value: 'with_author_date', + label: 'User Comments with Author/Date' + }, { + value: 'without_author_date', + label: 'User Comments without Author/Date' + }] + }, + tableOfContents: { + types: { + value: 'simple', + entities: [{ + value: 'simple', + label: 'Table of contents' + }, { + value: 'headlines', + label: 'Table of contents with headlines' + }, { + value: 'no', + label: 'No table of contents' + }] + }, + headlines: { + value: null, + entities: [{ + value: 'headline', + label: 'Headline only' + }, { + value: 'headline_source_date', + label: 'Headline | Source | Date' + }, { + value: 'source_headline_date', + label: 'Source | Headline | Date' + }] + } + } + + } + }, + colors: { + background: { + header: rgbaString, + emailBody: rgbaString, + accent: rgbaString + }, + text: { + header: rgbaString, + articleHeadline: rgbaString, + articleContent: rgbaString, + author: rgbaString, + publishDate: rgbaString, + source: rgbaString + } + } + } + }); + } + + initActions () { + this.actions = { + }; + } +} + +const themeForm = new ThemeForm(); +themeForm.init(); + +export default themeForm.reducers; +*/ diff --git a/frontend/app/redux/modules/appState/share/emailThemes/themes.js b/frontend/app/redux/modules/appState/share/emailThemes/themes.js new file mode 100644 index 0000000..581d3fd --- /dev/null +++ b/frontend/app/redux/modules/appState/share/emailThemes/themes.js @@ -0,0 +1,49 @@ +import * as api from '../../../../../api/themesApi' +import ReduxModule from '../../../abstract/reduxModule' + +const GET_DEFAULT_THEME = 'Get default theme' + +export class Themes extends ReduxModule { + + getNamespace () { + return '[Themes]' + } + + getDefaultTheme = ({token, fulfilled}) => { + return api + .getDefaultItem(token) + .then((data) => { + fulfilled(data) + return data + }) + }; + + defineActions () { + const getDefaultTheme = this.thunkAction(GET_DEFAULT_THEME, this.getDefaultTheme) + return { + getDefaultTheme + } + } + + getInitialState () { + return { + isPending: false, + themes: [], + defaultTheme: null, + activeId: '' + } + } + + defineReducers () { + return { + [`${GET_DEFAULT_THEME} fulfilled`]: this.setReducer('defaultTheme') + } + } + +} + +const themes = new Themes() +themes.init() +export default themes + +export const getDefaultTheme = themes.actions.getDefaultTheme diff --git a/frontend/app/redux/modules/appState/share/exportFeeds.js b/frontend/app/redux/modules/appState/share/exportFeeds.js new file mode 100644 index 0000000..66196ee --- /dev/null +++ b/frontend/app/redux/modules/appState/share/exportFeeds.js @@ -0,0 +1,77 @@ +import {handleActions, createAction} from 'redux-actions' +import {fromJS} from 'immutable' +import {thunkAction} from '../../../utils/common' +import * as api from '../../../../api/feedsApi' +import {getSidebarCategories} from '../sidebar' +import { getRestrictions } from '../../common/auth' + +/** CONSTANTS **/ +const NS = '[Export]' +const LOAD_EXPORTED_FEEDS = `${NS} Load exported feeds` +const SHOW_EXPORT_POPUP = `${NS} Show export popup` +const HIDE_EXPORT_POPUP = `${NS} Hide export popup` +const UNEXPORT_FEED = `${NS} Unexport feed` + +/** ACTIONS **/ + +const loadExportedFeeds = thunkAction(LOAD_EXPORTED_FEEDS, ({token, fulfilled}) => { + return api + .loadExportedFeeds(token) + .then((data) => { + fulfilled(data) + }) +}, true) + +const showExportPopup = createAction(SHOW_EXPORT_POPUP, (feed, exportFormat) => ({feed, exportFormat})) +const hideExportPopup = createAction(HIDE_EXPORT_POPUP) + +const unexportFeed = thunkAction(UNEXPORT_FEED, (feedId, {token, fulfilled, dispatch}) => { + return api + .toggleExportFeed(token, {export: false}, feedId) + .then(() => { + fulfilled() + dispatch(getSidebarCategories()) + dispatch(getRestrictions()) + dispatch(loadExportedFeeds()) + }) +}) + +export const actions = { + loadExportedFeeds, + showExportPopup, + hideExportPopup, + unexportFeed +} + +//**** STATE ****// +export const initialState = fromJS({ + isLoading: false, + tableData: [], + popupVisible: false, + selectedFeed: null, + exportFormat: '' +}) + +export default handleActions({ + + [`${LOAD_EXPORTED_FEEDS} pending`]: (state, {payload: {isPending}}) => { + return state.set('isLoading', isPending) + }, + + [`${LOAD_EXPORTED_FEEDS} fulfilled`]: (state, {payload: data}) => { + return state.set('tableData', data) + }, + + [SHOW_EXPORT_POPUP]: (state, {payload: {feed, exportFormat}}) => { + return state.merge({ + selectedFeed: feed, + popupVisible: true, + exportFormat + }) + }, + + [HIDE_EXPORT_POPUP]: (state) => { + return state.set('popupVisible', false) + } + +}, initialState) diff --git a/frontend/app/redux/modules/appState/share/forms/alertForm.js b/frontend/app/redux/modules/appState/share/forms/alertForm.js new file mode 100644 index 0000000..92314b6 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/forms/alertForm.js @@ -0,0 +1,117 @@ +import { getCurrentTimezone } from '../../../../../common/Timezones' +import { createItem, updateItem } from '../../../../../api/notificationsApi' +import {NotificationForm, THEME_TYPES} from './notificationForm' +import {addAlert} from '../../../common/alerts' +import {getRestrictions} from '../../../common/auth' +import {switchShareSubScreen, NOTIFICATION_SUBSCREENS} from '../tabs' + +export const EXTRAS = { + CONTEXTUAL: 'context', + START: 'start', + NO: 'no' +} + +export class AlertForm extends NotificationForm { + + getNamespace () { + return '[Alert Form]' + } + + getInitialState () { + return { + name: 'New Alert', + recipients: [], + subject: '', + automatedSubject: false, + published: false, + allowUnsubscribe: false, + unsubscribeNotification: false, + sources: [], + content: { + extract: EXTRAS.CONTEXTUAL, + highlightKeywords: { + highlight: false + }, + showInfo: { + sourceCountry: false, + userComments: 'no' + } + }, + themeType: THEME_TYPES.PLAIN, + sendWhenEmpty: false, + timezone: getCurrentTimezone(), + isEnabledTimezone: false, + isDefaultTimezone: true, + sendUntil: '', + scheduling: { + constants: { + type: ['daily', 'weekly', 'monthly'], + time: ['15m', '30m', '1h', '2h', '3h', '4h', '6h', '12h', 'once'], + days: ['all', 'weekdays', 'weekends'], + period: ['every', 'first', 'second', 'third', 'fourth', 'last'], + day: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + monthDay: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 'Last'], + hour: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23], + minute: [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55] + }, + newTime: { + type: 'daily', + time: '15m', + days: 'all', + period: 'every', + monthDay: 1, + day: 'monday', + hour: 13, + minute: 0 + }, + times: [] + }, + showSaveAsPopup: false, + sendHistory: { + isOpen: false, + isPending: false, + isLoadingCompleted: false, + page: 0, + limit: 5, + totalCount: 0, + entities: [] + } + } + } + + serialize (data, theme) { + let serialized = super.serialize(data, theme) + return { + ...serialized, + notificationType: 'alert' + } + } + + saveAlert = (isEdit, {dispatch, getState, token}) => { + const data = getState().getIn(['appState', 'share', 'forms', 'alert']) + const id = data.get('id') + const theme = getState().getIn(['appState', 'share', 'themes', 'defaultTheme']) + const alertData = this.serialize(data, theme) + const promise = isEdit ? updateItem(token, alertData, id) : createItem(token, alertData) + return promise + .then((data) => { + dispatch(getRestrictions()) + dispatch(addAlert({type: 'notice', transKey: 'alertSaved'})) + dispatch(switchShareSubScreen('notifications', NOTIFICATION_SUBSCREENS.TABLES)) + }) + }; + + defineActions () { + const actions = super.defineActions() + const saveAlert = this.thunkAction('SAVE_ALERT', this.saveAlert) + return { + ...actions, + saveAlert + } + } + +} + +const instance = new AlertForm() +instance.init() +export default instance diff --git a/frontend/app/redux/modules/appState/share/forms/groupForm.js b/frontend/app/redux/modules/appState/share/forms/groupForm.js new file mode 100644 index 0000000..9d516eb --- /dev/null +++ b/frontend/app/redux/modules/appState/share/forms/groupForm.js @@ -0,0 +1,94 @@ +import {ReceiverForm} from './receiverForm' +import { + GROUP_FORM_TABLES, switchShareSubScreen, switchShareTable, RECEIVER_TABLES, RECEIVER_SUBSCREENS +} from '../tabs' +import {fromJS} from 'immutable' +import * as api from '../../../../../api/groupsApi' +import {addAlert} from '../../../common/alerts' + +const SAVE_GROUP = 'Save group' + +export class GroupForm extends ReceiverForm { + + constructor () { + super(api) + } + + getNamespace () { + return '[Group Form]' + } + + serialize (data) { + return { + name: data.name, + description: data.description, + active: data.active, + recipients: data.recipients, + notifications: data.subscriptions + } + }; + + normalize (data) { + let newState = this.getInitialState() + newState = { + ...newState, + id: data.id, + name: data.name, + description: data.description || '', + active: data.active, + recipients: data.recipients, + subscriptions: data.subscriptions.ids + } + return fromJS(newState) + }; + + saveGroup = ({dispatch, fulfilled, getState, token}) => { + const data = getState().getIn(['appState', 'share', 'forms', 'group']).toJS() + const id = data.id + + if (data.name) { + const payload = this.serialize(data) + const apiRequest = id ? api.updateItem(token, payload, id) : api.createItem(token, payload) + return apiRequest.then(() => { + fulfilled() + dispatch(addAlert({type: 'notice', transKey: 'groupSaved'})) + dispatch(switchShareSubScreen('recipients', RECEIVER_SUBSCREENS.TABLES)) + dispatch(switchShareTable('recipients', RECEIVER_TABLES.GROUPS)) + }) + + } else { + dispatch(addAlert({type: 'error', transKey: 'groupNameEmpty'})) + } + }; + + defineActions () { + const saveReceiver = this.thunkAction(SAVE_GROUP, this.saveGroup, true) + + return { + ...super.defineActions(), + saveReceiver, + deleteReceiver: this.deleteGroup + } + } + + getTableTabs () { + return [GROUP_FORM_TABLES.RECIPIENTS, GROUP_FORM_TABLES.SUBSCRIPTIONS, GROUP_FORM_TABLES.EMAIL_HISTORY] + } + + getInitialState () { + const state = super.getInitialState() + return { + name: '', + description: '', + creationDate: '', + recipients: [], + subscriptions: [], + ...state + } + } + +} + +const instance = new GroupForm() +instance.init() +export default instance diff --git a/frontend/app/redux/modules/appState/share/forms/newsletterForm.js b/frontend/app/redux/modules/appState/share/forms/newsletterForm.js new file mode 100644 index 0000000..7715080 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/forms/newsletterForm.js @@ -0,0 +1,18 @@ +import {NotificationForm} from './notificationForm' + +export class NewsletterForm extends NotificationForm { + + getNamespace () { + return '[Newsletter Form]' + } + + getInitialState () { + return { + } + } + +} + +const instance = new NewsletterForm() +instance.init() +export default instance diff --git a/frontend/app/redux/modules/appState/share/forms/notificationForm.js b/frontend/app/redux/modules/appState/share/forms/notificationForm.js new file mode 100644 index 0000000..c40e9de --- /dev/null +++ b/frontend/app/redux/modules/appState/share/forms/notificationForm.js @@ -0,0 +1,313 @@ +import {ReduxModule} from '../../../abstract/reduxModule' +import {fromJS} from 'immutable' +import { getItems as getReceiversApi } from '../../../../../api/receiversApi' +import { getHistory as getHistoryApi } from '../../../../../api/notificationsApi' +import {tokenInject} from '../../../../utils/common' +import {addAlert} from '../../../common/alerts' + +const ADD_SOURCE = 'ADD_SOURCE' +const REMOVE_SOURCE = 'REMOVE_SOURCE' +const MOVE_SOURCE = 'MOVE_SOURCE' +const ADD_SCHEDULE = 'ADD_SCHEDULE' +const REMOVE_SCHEDULE = 'REMOVE_SCHEDULE' +const FILL_FORM = 'FILL_FORM' +const HISTORY = 'HISTORY' +const CHANGE_SCHEDULE = 'CHANGE_SCHEDULE' + +export const THEME_TYPES = { + PLAIN: 'plain', + ENHANCED: 'enhanced' +} + +export class NotificationForm extends ReduxModule { + + normalize (data, theme) { + // console.log('notificationForm::normalize, input=', data) + let newState = this.getInitialState() + const automatic = data.automatic + const notificationType = data.type + const themeType = data.themeType + const recipients = data.recipients + const themeDiff = themeType === 'plain' ? data.plainDiff : data.enhancedDiff + const immTheme = fromJS(theme[themeType]) + + const fieldsToDelete = ['recipients', 'automatic', 'plainDiff', 'enhancedDiff', 'owner', 'type'] + fieldsToDelete.forEach(field => delete data[field]) + + newState = { + ...newState, + ...data, + sendUntil: data.sendUntil || '', + notificationType + } + + newState.scheduling.times = automatic.map(time => { + if (time.type === 'monthly') { + time.monthDay = (time.day === 'last') ? 'Last' : parseInt(time.day) + } + return time + }) + + newState.recipients = recipients.map(r => ({label: r.name, value: r.id})) + + newState = fromJS(newState) + + const paths = ['content:extract', 'content:highlightKeywords:highlight', 'content:showInfo:sourceCountry', 'content:showInfo:userComments'] + paths.forEach(path => { + const immPath = path.split(':') + const value = (themeDiff[path]) ? themeDiff[path] : immTheme.getIn(immPath) + newState = newState.setIn(immPath, value) + }) + + // console.log('notificationForm::normalize, output=', newState.toJS()) + return newState + } + + _serializeScheduleItem (time) { + switch (time.type) { + case 'daily': + delete time.period + delete time.day + delete time.hour + delete time.minute + break + case 'weekly': + delete time.time + delete time.days + break + case 'monthly': + delete time.period + delete time.time + delete time.days + time.day = (time.monthDay === 'Last') ? 'last' : time.monthDay.toString() + break + } + delete time.monthDay + + return time + } + + serialize (state, theme) { + const data = state.toJS() + console.log('notificationForm:serialize, input=', data) + const scheduling = data.scheduling + const themeType = data.themeType + + const fieldsToDelete = ['scheduling', 'showSaveAsPopup', 'id', 'content', 'isEnabledTimezone', 'isDefaultTimezone', 'sendHistory', 'active'] + fieldsToDelete.forEach(field => delete data[field]) + + const automatic = scheduling.times.map(this._serializeScheduleItem) + + const sources = data.sources.map(source => { + return { + type: source.type, + id: source.id + } + }) + + const recipients = data.recipients.map(recipient => recipient.value) + + const themeDiff = {} + const paths = ['content:extract', 'content:highlightKeywords:highlight', 'content:showInfo:sourceCountry', 'content:showInfo:userComments'] + const immTheme = fromJS(theme[themeType]) + paths.forEach(path => { + const immPath = path.split(':') + const themeValue = immTheme.getIn(immPath) + const stateValue = state.getIn(immPath) + if (themeValue !== stateValue) { + themeDiff[path] = stateValue + } + }) + const enhancedDiff = themeType === 'enhanced' ? themeDiff : {} + const plainDiff = themeType === 'plain' ? themeDiff : {} + + const alertData = { + ...data, + name: data.name || 'New Alert', + theme: theme.id, + automatic, + recipients, + sources, + enhancedDiff, + plainDiff + } + console.log('notificationForm:serialize, output=', alertData) + return alertData + } + + getHistory (notificationId, page, limit, {fulfilled, token}) { + return getHistoryApi(token, {page, limit}, notificationId) + .then((historyData) => { + fulfilled(historyData) + }) + } + + getRecipients (filter) { + return tokenInject((dispatch, getState, token) => { + return getReceiversApi(token, {filter}) + .then((data) => { + const options = data.map(recipient => { + const label = (recipient.type === 'recipient') + ? `${recipient.name} <${recipient.email}>` + : recipient.name + return {value: recipient.id, label} + }) + return {options} + }) + .catch((errors) => { + dispatch(addAlert(errors)) + }) + }) + }; + + defineActions () { + const changeName = this.set('NAME', 'name') + const changeRecipients = this.set('RECIPIENTS', 'recipients') + const changeSubject = this.set('SUBJECT', 'subject') + const changeAutoSubject = this.set('AUTO_SUBJECT', 'automatedSubject') + const changePublished = this.set('PUBLISHED', 'published') + const changeAllowUnsubscribe = this.set('ALLOW_UNSUBSCRIBE', 'allowUnsubscribe') + const changeUnsubscribeNotification = this.set('UNSUBSCRIBE_NOTIFICATION', 'unsubscribeNotification') + const changeExtras = this.setIn('EXTRAS', ['content', 'extract']) + const changeHighlightKeywords = this.setIn('HIGHLIGHT_KEYWORDS', ['content', 'highlightKeywords', 'highlight']) + const changeShowSourceCountry = this.setIn('SHOW_SOURCE_COUNTRY', ['content', 'showInfo', 'sourceCountry']) + const changeShowUserComments = this.setIn('SHOW_USER_COMMENTS', ['content', 'showInfo', 'userComments']) + const changeThemeType = this.set('THEME_TYPE', 'themeType') + const changeSendWhenEmpty = this.set('SEND_WHEN_EMPTY', 'sendWhenEmpty') + const changeTimezone = this.set('TIMEZONE', 'timezone') + const changeSendUntil = this.set('SEND_UNTIL', 'sendUntil') + const toggleTimezone = this.toggle('TOGGLE_TIMEZONE', 'isEnabledTimezone') + const toggleSaveAsPopup = this.toggle('TOGGLE_SAVE_AS_POPUP', 'showSaveAsPopup') + const toggleHistory = this.toggleIn('TOGGLE_HISTORY', ['sendHistory', 'isOpen']) + // TODO reset options for every type + const changeScheduleType = this.setIn('SCHEDULE_TYPE', ['scheduling', 'newTime', 'type']) + const changeNewSchedule = this.mergeIn('CHANGE_NEW_SCHEDULE', ['scheduling', 'newTime']) + const clearForm = this.resetToInitialState('CLEAR') + + const addSource = this.createAction(ADD_SOURCE, source => source) + const removeSource = this.createAction(REMOVE_SOURCE, sourceId => sourceId) + const moveSource = this.createAction(MOVE_SOURCE, (sourceId, isUp) => ({sourceId, isUp})) + const addSchedule = this.createAction(ADD_SCHEDULE) + const removeSchedule = this.createAction(REMOVE_SCHEDULE, id => id) + const fillForm = this.createAction(FILL_FORM, (item, theme) => ({item, theme})) + const changeExistingSchedule = this.createAction('CHANGE_SCHEDULE', (item, id) => ({item, id})) + + this.setPending = this.set('LOADING', 'isLoading') + + // const historyPending = this.setIn(`${HISTORY} pending`, ['sendHistory', 'isPending']) + const getHistory = this.thunkAction(HISTORY, this.getHistory, true) + + return { + changeName, + changeRecipients, + changeSubject, + changeAutoSubject, + changeAllowUnsubscribe, + changeUnsubscribeNotification, + changeExtras, + changeHighlightKeywords, + changeShowSourceCountry, + changeShowUserComments, + changeThemeType, + changeSendWhenEmpty, + changePublished, + addSource, + removeSource, + addSchedule, + removeSchedule, + moveSource, + changeTimezone, + toggleTimezone, + changeScheduleType, + changeNewSchedule, + changeExistingSchedule, + changeSendUntil, + fillForm, + clearForm, + getRecipients: this.getRecipients, + toggleSaveAsPopup, + toggleHistory, + getHistory + } + + } + + defineReducers () { + + return { + [ADD_SOURCE]: (state, {payload: source}) => { + const sources = state.get('sources').push(fromJS(source)) + return state.set('sources', sources) + }, + + [REMOVE_SOURCE]: (state, {payload: sourceId}) => { + const sources = state.get('sources').filter(source => source.get('id') !== sourceId) + return state.set('sources', sources) + }, + + [MOVE_SOURCE]: (state, {payload}) => { + const { sourceId, isUp } = payload + let sources = state.get('sources') + + const fromIndex = sources.findIndex(source => source.get('id') === sourceId) + const toIndex = (isUp) ? fromIndex - 1 : fromIndex + 1 + + const isUpFail = isUp && (toIndex < 0) + const isDownFail = !isUp && (toIndex >= sources.size) + if (isUpFail || isDownFail) { + return state + } + + const source = sources.get(fromIndex) + sources = sources + .splice(fromIndex, 1) + .splice(toIndex, 0, source) + + return state.set('sources', sources) + }, + + [ADD_SCHEDULE]: (state) => { + const path = ['scheduling', 'times'] + const item = state.getIn(['scheduling', 'newTime']) + const items = state.getIn(path).push(item) + return state.setIn(path, items) + }, + + [REMOVE_SCHEDULE]: (state, {payload: id}) => { + const path = ['scheduling', 'times'] + const items = state.getIn(path).filter((item, index) => index !== id) + return state.setIn(path, items) + }, + + [FILL_FORM]: (state, {payload}) => { + const { item, theme } = payload + return this.normalize(item, theme) + }, + + [[`${HISTORY} fulfilled`]]: (state, {payload}) => { + const { data, limit, page, totalCount } = payload + const entities = state.getIn(['sendHistory', 'entities']).concat(data) + return state.mergeIn(['sendHistory'], { + entities, + limit, + page, + totalCount, + isLoadingCompleted: true + }) + }, + + [[`${HISTORY} pending`]]: (state, {payload}) => { + return state.mergeIn(['sendHistory'], { + isPending: payload.isPending + }) + }, + + [CHANGE_SCHEDULE]: (state, {payload: {item, id}}) => { + return state.mergeIn(['scheduling', 'times', id], { + item + }) + } + } + } + +} diff --git a/frontend/app/redux/modules/appState/share/forms/receiverForm.js b/frontend/app/redux/modules/appState/share/forms/receiverForm.js new file mode 100644 index 0000000..6d698c1 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/forms/receiverForm.js @@ -0,0 +1,108 @@ +import {ReduxModule} from '../../../abstract/reduxModule' +import {switchShareSubScreen} from '../tabs' + +export const FILL_FORM = 'Fill form' +const CHANGE_FIELD = 'Change field' +const CHOOSE_TAB = 'Choose tab' +const TOGGLE_ACTIVE = 'Toggle active' +const CLEAR = 'Clear form' +const TOGGLE_SUBSCRIPTION = 'Toggle subscription' +const TOGGLE_GROUP = 'Toggle group' +const TOGGLE_RECIPIENT = 'Toggle recipient' + +const CONFIRM_DELETE = 'Confirm delete' +const CANCEL_DELETE = 'Cancel delete' +const DELETE_ITEMS = 'Delete receiver' + +export class ReceiverForm extends ReduxModule { + + constructor (api) { + super() + this.api = api + } + + getTableTabs () { + //implement in subclasses + } + + getInitialState () { + const tableTabs = this.getTableTabs() + return { + active: true, + tabs: { + active: tableTabs[0], + all: tableTabs + }, + id: null, + isDeletePopupVisible: false + } + } + + toggleArrayField (actionName, fieldName) { + return this.createHandler(actionName, + (id, turnOn) => ({id, turnOn}), + (state, {payload: {id, turnOn}}) => { + let array = state.get(fieldName) //immutable! + if (turnOn) { + if (!array.includes(id)) { + array = array.push(id) + } + } else { + array = array.filter((item) => item !== id) + } + return state.set(fieldName, array) + } + ) + } + + /** Delete only current item in form, function name is for compatibility with DeletePopup **/ + deleteItems = (ids, {token, fulfilled, getState, dispatch}) => { + return this.api + .deleteItems(token, {ids}) + .then(() => { + dispatch(switchShareSubScreen('recipients', 'tables')) + dispatch(this.cancelDelete()) + }) + }; + + defineActions () { + + const chooseTableTab = this.setIn(CHOOSE_TAB, ['tabs', 'active']) + const toggleActive = this.toggle(TOGGLE_ACTIVE, 'active') + + const clearForm = this.resetToInitialState(CLEAR) + const fillForm = this.createAction(FILL_FORM, item => item) + + const changeField = this.setField(CHANGE_FIELD) + const toggleSubscription = this.toggleArrayField(TOGGLE_SUBSCRIPTION, 'subscriptions') + const toggleRecipient = this.toggleArrayField(TOGGLE_RECIPIENT, 'recipients') + const toggleGroup = this.toggleArrayField(TOGGLE_GROUP, 'groups') + + const confirmDelete = this.reset(CONFIRM_DELETE, 'isDeletePopupVisible', true) + this.cancelDelete = this.reset(CANCEL_DELETE, 'isDeletePopupVisible', false) + const deleteItems = this.thunkAction(DELETE_ITEMS, this.deleteItems) + + return { + clearForm, + changeField, + chooseTableTab, + toggleActive, + fillForm, + toggleSubscription, + toggleRecipient, + toggleGroup, + confirmDelete, + cancelDelete: this.cancelDelete, + deleteItems + } + } + + defineReducers () { + return { + [FILL_FORM]: (state, {payload: item}) => { + return this.normalize(item) + } + } + } + +} diff --git a/frontend/app/redux/modules/appState/share/forms/recipientForm.js b/frontend/app/redux/modules/appState/share/forms/recipientForm.js new file mode 100644 index 0000000..b069c09 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/forms/recipientForm.js @@ -0,0 +1,98 @@ +import {ReceiverForm} from './receiverForm' +import { + switchShareSubScreen, switchShareTable, RECEIVER_SUBSCREENS, RECEIVER_TABLES, RECIPIENT_FORM_TABLES +} from '../tabs' +import {fromJS} from 'immutable' +import * as api from '../../../../../api/recipientsApi' +import {addAlert} from '../../../common/alerts' + +const SAVE_RECIPIENT = 'Save recipient' + +export class RecipientForm extends ReceiverForm { + + constructor () { + super(api) + } + + getNamespace () { + return '[Recipient Form]' + } + + serialize (data) { + return { + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + active: data.active, + notifications: data.subscriptions, + groups: data.groups + } + }; + + normalize (data) { + let newState = this.getInitialState() + newState = { + ...newState, + id: data.id, + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + active: data.active, + groups: data.groups.map(group => group.id), + subscriptions: data.subscriptions.ids + } + return fromJS(newState) + }; + + saveRecipient = ({dispatch, fulfilled, getState, token}) => { + const data = getState().getIn(['appState', 'share', 'forms', 'recipient']).toJS() + const id = data.id + + if (data.firstName && data.lastName && data.email) { + const payload = this.serialize(data) + const apiRequest = id ? api.updateItem(token, payload, id) : api.createItem(token, payload) + return apiRequest.then(() => { + fulfilled() + dispatch(addAlert({type: 'notice', transKey: 'recipientSaved'})) + dispatch(switchShareSubScreen('recipients', RECEIVER_SUBSCREENS.TABLES)) + dispatch(switchShareTable('recipients', RECEIVER_TABLES.RECIPIENTS)) + }) + + } else { + dispatch(addAlert({type: 'error', transKey: 'recipientNamesEmpty'})) + } + }; + + defineActions () { + const saveReceiver = this.thunkAction(SAVE_RECIPIENT, this.saveRecipient, true) + + return { + ...super.defineActions(), + saveReceiver, + deleteReceiver: this.deleteRecipient + } + } + + getTableTabs () { + return [RECIPIENT_FORM_TABLES.SUBSCRIPTIONS, RECIPIENT_FORM_TABLES.GROUPS, RECIPIENT_FORM_TABLES.EMAIL_HISTORY] + } + + getInitialState () { + const state = super.getInitialState() + return { + ...state, + creationDate: '', + firstName: '', + lastName: '', + email: '', + active: true, + subscriptions: [], + groups: [] + } + } + +} + +const instance = new RecipientForm() +instance.init() +export default instance diff --git a/frontend/app/redux/modules/appState/share/shareForms.js b/frontend/app/redux/modules/appState/share/shareForms.js new file mode 100644 index 0000000..0f305b6 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/shareForms.js @@ -0,0 +1,130 @@ +import {tokenInject} from '../../../utils/common' +import { + NOTIFICATION_SUBSCREENS, RECEIVER_SUBSCREENS, switchShareSubScreen, + NOTIFICATION_TABLES +} from './tabs' +import { addAlert } from '../../common/alerts' +import { getDefaultTheme } from './emailThemes/themes' + +import myEmailsTable from './tables/myEmailsTable' +import publishedEmailsTable from './tables/publishedEmailsTable' +import emailsTable from './tables/emailsTable' + +import alertForm from './forms/alertForm' +import newsletterForm from './forms/newsletterForm' +import recipientForm from './forms/recipientForm' +import groupForm from './forms/groupForm' + +import * as notificationsApi from '../../../../api/notificationsApi' + +const tablePendingActions = { + [NOTIFICATION_TABLES.MY_EMAILS]: myEmailsTable.asyncActionPending, + [NOTIFICATION_TABLES.PUBLISHED]: publishedEmailsTable.asyncActionPending, + emails: emailsTable.asyncActionPending +} + +//**** ACTIONS ****// + +const loadTablePending = (type, isPending) => { + return tablePendingActions[type](isPending) +} + +const startEditNotification = (notification, table, tab = 'notifications') => { + return tokenInject((dispatch, getState, token) => { + if (notification.type === 'alert') { + + dispatch(loadTablePending(table, true)) + + dispatch(getDefaultTheme()) + .then((defaultTheme) => { + return notificationsApi.getItem(token, null, notification.id) + .then((fullNotification) => { + dispatch(loadTablePending(table, false)) + dispatch(alertForm.actions.fillForm(fullNotification, defaultTheme)) + dispatch(switchShareSubScreen(tab, NOTIFICATION_SUBSCREENS.ALERT_FORM)) + }) + }) + .catch((errors) => { + dispatch(loadTablePending(table, false)) + dispatch(addAlert(errors)) + }) + } + }) +} + +const startCreateNotification = (type, table, tab = 'notifications') => { + return (dispatch, getState) => { + + const recipient = getState().getIn(['common', 'auth', 'user', 'recipient']); + if (!recipient) { + return dispatch(addAlert('Please create at least one recipient from Manage Recipients to create an alert.')); + } + const myself = recipient.toJS() + const defaultRecipient = { + value: myself.id, + label: `${myself.firstName} ${myself.lastName} <${myself.email}>` + } + + dispatch(loadTablePending(table, true)) + dispatch(getDefaultTheme()) + .then(() => { + dispatch(loadTablePending(table, false)) + if (type === NOTIFICATION_SUBSCREENS.ALERT_FORM) { + dispatch(alertForm.actions.clearForm()) + dispatch(alertForm.actions.changeRecipients([defaultRecipient])) + dispatch(switchShareSubScreen(tab, NOTIFICATION_SUBSCREENS.ALERT_FORM)) + } + else if (type === NOTIFICATION_SUBSCREENS.NEWSLETTER_FORM) { + dispatch(newsletterForm.actions.clearForm()) + dispatch(switchShareSubScreen(tab, NOTIFICATION_SUBSCREENS.NEWSLETTER_FORM)) + } + }) + .catch((errors) => { + dispatch(loadTablePending(table, false)) + dispatch(addAlert(errors)) + }) + } +} + +const startCreateRecipient = () => { + return (dispatch) => { + dispatch(switchShareSubScreen('recipients', RECEIVER_SUBSCREENS.RECIPIENT_FORM)) + dispatch(recipientForm.actions.clearForm()) + } +} + +const startCreateGroup = () => { + return (dispatch) => { + dispatch(switchShareSubScreen('recipients', RECEIVER_SUBSCREENS.GROUP_FORM)) + dispatch(groupForm.actions.clearForm()) + } +} + +const startEditRecipient = (item) => { + console.log('start edit') + return (dispatch) => { + dispatch(switchShareSubScreen('recipients', RECEIVER_SUBSCREENS.RECIPIENT_FORM)) + dispatch(recipientForm.actions.clearForm()) + dispatch(recipientForm.actions.fillForm(item)) + } +} + +const startEditGroup = (item) => { + return (dispatch) => { + dispatch(switchShareSubScreen('recipients', RECEIVER_SUBSCREENS.GROUP_FORM)) + dispatch(groupForm.actions.clearForm()) + dispatch(groupForm.actions.fillForm(item)) + } +} + +export const actions = { + startEditNotification, + startCreateNotification, + startCreateRecipient, + startCreateGroup, + startEditRecipient, + startEditGroup +} + +//**** This module has no state (: ****// + diff --git a/frontend/app/redux/modules/appState/share/tables/emailFiltersTable.js b/frontend/app/redux/modules/appState/share/tables/emailFiltersTable.js new file mode 100644 index 0000000..34fcee6 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/tables/emailFiltersTable.js @@ -0,0 +1,44 @@ +import GenericTable from './genericTable' +import * as api from '../../../../../api/notificationsApi' + +class EmailFiltersTable extends GenericTable { + + constructor () { + super(api) + } + + getNamespace () { + return '[Email filters]' + } + + getItems = (token, payload) => { + return this.api.getFilters(token, payload) + }; + + getDataFromResponse (response) { + return response['filters'] + } + + getTableState (state) { + return state.getIn(['appState', 'share', 'tables', 'emailFilters']) + } + + getLoadTableRequestPayload (tableState) { + return { + ...super.getLoadTableRequestPayload(tableState), + type: tableState.filterType + } + } + + getInitialState () { + return { + ...super.getInitialState(), + filterType: 'owner' + } + } + +} + +const instance = new EmailFiltersTable() +instance.init() +export default instance diff --git a/frontend/app/redux/modules/appState/share/tables/emailsTable.js b/frontend/app/redux/modules/appState/share/tables/emailsTable.js new file mode 100644 index 0000000..e67b302 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/tables/emailsTable.js @@ -0,0 +1,67 @@ +import GenericTable from './genericTable' +import * as api from '../../../../../api/notificationsApi' + +const SET_FILTER = 'Set filter' +const CLEAR_FILTER = 'Clear filter' + +class EmailsTable extends GenericTable { + + constructor () { + super(api) + this.updateRestrictionsAfterDelete = true + } + + getNamespace () { + return '[Emails]' + } + + getTableState (state) { + return state.getIn(['appState', 'share', 'tables', 'emails']) + } + + getItems = (token, payload) => { + return this.api.getAllItems(token, payload) + } + + getLoadTableRequestPayload (tableState) { + const payload = super.getLoadTableRequestPayload(tableState) + if (tableState.filter) { + payload.filterId = tableState.filter.id + payload.filterType = tableState.filter.type + } + delete payload.filter + return payload + } + + defineActions () { + + const setFilter = this.createAction(SET_FILTER) + const clearFilter = this.createAction(CLEAR_FILTER) + + return { + ...super.defineActions(), + setFilter, + clearFilter + } + } + + getInitialState () { + return { + ...super.getInitialState(), + filter: null + } + } + + defineReducers () { + return { + ...super.defineReducers(), + [SET_FILTER]: this.setReducer('filter'), + [CLEAR_FILTER]: this.resetReducer('filter', null) + } + } + +} + +const instance = new EmailsTable() +instance.init() +export default instance diff --git a/frontend/app/redux/modules/appState/share/tables/genericTable.js b/frontend/app/redux/modules/appState/share/tables/genericTable.js new file mode 100644 index 0000000..3242605 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/tables/genericTable.js @@ -0,0 +1,268 @@ +import ReduxModule from '../../../abstract/reduxModule' +import { addAlert } from '../../../common/alerts' +import {tokenInject} from '../../../../utils/common' +import {getRestrictions} from '../../../common/auth' + +const SELECT_TABLE_ROW = 'Select table row' +const SELECT_TABLE_ALL_ROWS = 'Select all rows' +const SET_TABLE_PARAMS = 'Set table parameters' +const LOAD_TABLE = 'Load table' +const CONFIRM_DELETE = 'Confirm delete' +const CANCEL_DELETE = 'Cancel delete' +const CHANGE_FILTERED = 'Change filtered' +const TOGGLE_FIELD = 'Toggle field' +const ASYNC_ACTION = 'Async action' +const DELETE_ITEMS = 'Delete items' + +export default class GenericTableModule extends ReduxModule { + + constructor (api) { + super() + this.api = api + this.updateRestrictionsAfterDelete = false + } + + /* + state - full state + return - table state + */ + getTableState (state) { + //implement in subclasses + } + + asyncToggleFieldAction (apiMethod, optionName) { + return (ids, optionValue) => { + return tokenInject((dispatch, getState, token) => { + const payload = { + ids, + [optionName]: optionValue + } + + dispatch(this.asyncActionPending(true)) + apiMethod(token, payload) + .then(() => { + dispatch(this.toggleField(ids, optionName, optionValue)) + dispatch(this.asyncActionPending(false)) + }) + .catch((error) => { + dispatch(this.asyncActionPending(false)) + dispatch(addAlert(error)) + }) + + }) + } + } + + _fixForDeleteFromLastPage (dispatch, getState, ids) { + const tableState = this.getTableState(getState()).toJS() + const totalPages = Math.ceil(tableState.totalCount / tableState.limit) + if (tableState.page === totalPages && totalPages !== 1) { //we are on the last page + const itemsOnLastPage = tableState.totalCount % tableState.limit + const itemsToDelete = Object.keys(ids).length + if (itemsOnLastPage === itemsToDelete) { //and we are deleting everything + const newPage = tableState.page - 1 + dispatch(this.setTableParams({page: newPage})) + } + } + } + + deleteItems = (ids, {token, fulfilled, getState, dispatch}) => { + return this.api + .deleteItems(token, {ids}) + .then(() => { + this._fixForDeleteFromLastPage(dispatch, getState, ids) + const payload = this._getLoadPayload(getState) + this.updateRestrictionsAfterDelete && dispatch(getRestrictions()) + return this.api + .getItems(token, payload) + .then((response) => { + fulfilled(this.getDataFromResponse(response)) + }) + }) + }; + + _getLoadPayload (getState) { + const state = getState() + const tableState = this.getTableState(state).toJS() + return this.getLoadTableRequestPayload(tableState) + } + + getLoadTableRequestPayload (tableState) { + const payload = { + page: tableState.page, + limit: tableState.limit, + sortField: tableState.sortField, + sortDirection: tableState.sortDirection + } + if (tableState.filter) { + payload.filter = tableState.filter + } + return payload + } + + getDataFromResponse (response) { + return response['notifications'] + } + + getItems = (token, payload) => { + return this.api.getItems(token, payload) + } + + loadTable = (params, {dispatch, getState, token, fulfilled}) => { + if (params) { + dispatch(this.setTableParams(params)) + } + const payload = this._getLoadPayload(getState) + return this.getItems(token, payload) + .then((response) => { + fulfilled(this.getDataFromResponse(response)) + }) + }; + + defineActions () { + + const selectTableRow = this.createAction(SELECT_TABLE_ROW, (itemId) => itemId) + const selectTableAllRows = this.createAction(SELECT_TABLE_ALL_ROWS) + const confirmDelete = this.createAction(CONFIRM_DELETE) + const cancelDelete = this.createAction(CANCEL_DELETE) + const changeTableFiltered = this.createAction(CHANGE_FILTERED) + + const setTableParams = this.createAction(SET_TABLE_PARAMS, (params) => params) + this.setTableParams = setTableParams + const loadTable = this.thunkAction(LOAD_TABLE, this.loadTable, true) + const deleteItems = this.thunkAction(DELETE_ITEMS, this.deleteItems, true) + + this.asyncActionPending = this.createAction(`${ASYNC_ACTION} pending`, (value) => ({isPending: value})) + this.toggleField = this.createAction(TOGGLE_FIELD, (ids, fieldName, fieldValue) => ({ids, fieldName, fieldValue})) + + const toggleActive = this.asyncToggleFieldAction(this.api.activateItems, 'active') + const togglePublish = this.asyncToggleFieldAction(this.api.publishItems, 'published') + + return { + loadTable, + selectTableRow, + selectTableAllRows, + setTableParams, + confirmDelete, + cancelDelete, + changeTableFiltered, + toggleActive, + togglePublish, + deleteItems + } + } + + getInitialState () { + return { + page: 1, + limit: 10, + sortField: 'name', + sortDirection: 'asc', + data: [], + count: 0, + totalCount: 0, + isLoading: false, + isAllSelected: false, + selectedIds: [], + idsToDelete: {}, + isDeletePopupVisible: false + } + } + + /** REDUCERS **/ + + onSelectTableRow (state, {payload: itemId}) { + let selectedIds = state.get('selectedIds') + const isSelected = selectedIds.includes(itemId) + if (isSelected) { + selectedIds = selectedIds.filter(id => id !== itemId) + } + else { + selectedIds = selectedIds.push(itemId) + } + return state.merge({ + 'selectedIds': selectedIds, + 'isAllSelected': false + }) + } + + onSelectAllTableRows (state) { + const isAllSelected = state.get('isAllSelected') + if (isAllSelected) { //then deselect all + return state.merge({ + isAllSelected: false, + selectedIds: [] + }) + } + else { //select all currently loaded data + const selectedIds = state.get('data').map(item => item.get('id')) + return state.merge({ + isAllSelected: true, + selectedIds + }) + } + } + + onLoadFulfilled (state, {payload: response}) { + return state.merge({ + data: response.data, + count: response.count, + totalCount: response.totalCount + }) + } + + onDeleteItemsFulfilled (state, {payload: response}) { + return state.merge({ + data: response.data, + count: response.count, + totalCount: response.totalCount, + selectedIds: [] + }) + }; + + onConfirmDelete (state, {payload: ids}) { + return state.merge({ + isDeletePopupVisible: true, + idsToDelete: ids + }) + } + + onCancelDelete (state) { + return state.merge({ + isDeletePopupVisible: false, + idsToDelete: {} + }) + } + + onAsyncActionPending (state, {payload: {isPending}}) { + return state.merge({ + isLoading: isPending, + isDeletePopupVisible: false //dirty hack, need to think 'bout it + }) + } + + onToggleField (state, {payload: {ids, fieldName, fieldValue}}) { + let tableData = state.get('data') + const newValues = {[fieldName]: fieldValue} + tableData = tableData.map((item) => (ids.includes(item.get('id'))) ? item.merge(newValues) : item) + return state.set('data', tableData) + } + + defineReducers () { + return { + [SELECT_TABLE_ROW]: this.onSelectTableRow, + [SELECT_TABLE_ALL_ROWS]: this.onSelectAllTableRows, + [SET_TABLE_PARAMS]: this.mergeReducer(), + [`${LOAD_TABLE} pending`]: this.thunkPendingReducer('isLoading'), + [`${LOAD_TABLE} fulfilled`]: this.onLoadFulfilled, + [`${ASYNC_ACTION} pending`]: this.onAsyncActionPending, + [`${DELETE_ITEMS} pending`]: this.onAsyncActionPending, + [`${DELETE_ITEMS} fulfilled`]: this.onDeleteItemsFulfilled, + [TOGGLE_FIELD]: this.onToggleField, + [CONFIRM_DELETE]: this.onConfirmDelete, + [CANCEL_DELETE]: this.onCancelDelete, + [CHANGE_FILTERED]: this.setReducer('filtered') + } + } + +} diff --git a/frontend/app/redux/modules/appState/share/tables/groupsTable.js b/frontend/app/redux/modules/appState/share/tables/groupsTable.js new file mode 100644 index 0000000..d69564e --- /dev/null +++ b/frontend/app/redux/modules/appState/share/tables/groupsTable.js @@ -0,0 +1,34 @@ +import GenericTable from './genericTable' +import * as api from '../../../../../api/groupsApi' + +class GroupsTable extends GenericTable { + + constructor () { + super(api) + } + + getNamespace () { + return '[Groups table]' + } + + getInitialState () { + const state = super.getInitialState() + return { + ...state, + filter: '' + } + } + + getDataFromResponse (response) { + return response['groups'] + } + + getTableState (state) { + return state.getIn(['appState', 'share', 'tables', 'groups']) + } + +} + +const instance = new GroupsTable() +instance.init() +export default instance diff --git a/frontend/app/redux/modules/appState/share/tables/myEmailsTable.js b/frontend/app/redux/modules/appState/share/tables/myEmailsTable.js new file mode 100644 index 0000000..c0a6720 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/tables/myEmailsTable.js @@ -0,0 +1,30 @@ +import GenericTable from './genericTable' +import * as api from '../../../../../api/notificationsApi' + +class MyEmailsTable extends GenericTable { + + constructor () { + super(api) + this.updateRestrictionsAfterDelete = true + } + + getNamespace () { + return '[My Emails]' + } + + getTableState (state) { + return state.getIn(['appState', 'share', 'tables', 'myEmails']) + } + + getLoadTableRequestPayload (tableState) { + return { + ...super.getLoadTableRequestPayload(tableState), + onlyPublished: false + } + } + +} + +const instance = new MyEmailsTable() +instance.init() +export default instance diff --git a/frontend/app/redux/modules/appState/share/tables/publishedEmailsTable.js b/frontend/app/redux/modules/appState/share/tables/publishedEmailsTable.js new file mode 100644 index 0000000..b24b4d6 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/tables/publishedEmailsTable.js @@ -0,0 +1,38 @@ +import GenericTableModule from './genericTable' +import * as api from '../../../../../api/notificationsApi' + +class PublishedEmailsTable extends GenericTableModule { + + constructor () { + super(api) + this.updateRestrictionsAfterDelete = true + } + + getNamespace () { + return '[Published Emails]' + } + + getTableState (state) { + return state.getIn(['appState', 'share', 'tables', 'publishedEmails']) + } + + getLoadTableRequestPayload (tableState) { + return { + ...super.getLoadTableRequestPayload(tableState), + onlyPublished: true + } + } + + defineActions () { + const toggleSubscribe = this.asyncToggleFieldAction(this.api.subscribeItems, 'subscribed') + return { + ...super.defineActions(), + toggleSubscribe + } + } + +} + +const instance = new PublishedEmailsTable() +instance.init() +export default instance diff --git a/frontend/app/redux/modules/appState/share/tables/receiverForm/emailHistoryTable.js b/frontend/app/redux/modules/appState/share/tables/receiverForm/emailHistoryTable.js new file mode 100644 index 0000000..dabc9dd --- /dev/null +++ b/frontend/app/redux/modules/appState/share/tables/receiverForm/emailHistoryTable.js @@ -0,0 +1,34 @@ +import * as api from '../../../../../../api/receiversApi' +import GenericTableModule from '../genericTable' + +class EmailHistory extends GenericTableModule { + + constructor () { + super(api) + } + + getNamespace () { + return '[Email history]' + } + + getTableState (state) { + return state.getIn(['appState', 'share', 'tables', 'receiverForm', 'emailHistory']) + } + + loadTable = (params, receiver, {dispatch, getState, token, fulfilled}) => { + if (params) { + dispatch(this.setTableParams(params)) + } + const payload = this._getLoadPayload(getState) + return this.api + .getEmailHistory(token, payload, receiver.id) + .then((response) => { + fulfilled(response) + }) + }; + +} + +const instance = new EmailHistory() +instance.init() +export default instance diff --git a/frontend/app/redux/modules/appState/share/tables/receiverForm/receiverFormTable.js b/frontend/app/redux/modules/appState/share/tables/receiverForm/receiverFormTable.js new file mode 100644 index 0000000..f169f75 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/tables/receiverForm/receiverFormTable.js @@ -0,0 +1,84 @@ +import {fromJS} from 'immutable' +import GenericTableModule from '../genericTable' + +const TOGGLE_SUBSCRIBED = 'Toggle subscribed' +const TOGGLE_ENROLLED = 'Toggle enrolled' + +export default class ReceiverFormTable extends GenericTableModule { + + getDataFromResponse (response) { + //implement in subclasses + } + + getLoadTableRequestPayload (tableState, receiver) { + let payload = super.getLoadTableRequestPayload(tableState) + if (tableState.filter) { + payload.filter = tableState.filter + } + const statusFilter = tableState.statusFilter + if (statusFilter) { + payload.statusFilter = tableState.statusFilter + } + return payload + } + + _getLoadPayload (getState, receiver) { + const tableState = this.getTableState(getState()).toJS() + return this.getLoadTableRequestPayload(tableState, receiver) + } + + loadTable = (params, receiver, {dispatch, getState, token, fulfilled}) => { + if (params) { + dispatch(this.setTableParams(params)) + } + const payload = this._getLoadPayload(getState, receiver) // <-- difference from genericTable + return this.api + .getItems(token, payload) + .then((response) => { + fulfilled(this.getDataFromResponse(response, receiver)) + }) + }; + + addDataColumn (data, fieldName, ids = []) { + data.forEach((item) => { + item[fieldName] = ids.includes(item.id) + }) + }; + + toggleDataField (actionName, fieldName) { + return this.createHandler( + actionName, + (itemId, turnOn) => ({itemId, turnOn}), + (state, {payload: {itemId, turnOn}}) => { + const tableData = state.get('data').toJS() + tableData.forEach((item) => { + if (item.id === itemId) { + item[fieldName] = turnOn + } + }) + return state.set('data', fromJS(tableData)) + } + ) + } + + defineActions () { + const actions = super.defineActions() + const toggleSubscribed = this.toggleDataField(TOGGLE_SUBSCRIBED, 'subscribed') + const toggleEnrolled = this.toggleDataField(TOGGLE_ENROLLED, 'enrolled') + + return { + ...actions, + toggleSubscribed, + toggleEnrolled + } + } + + getInitialState () { + return { + ...super.getInitialState(), + filter: '', + statusFilter: 'all' + } + } + +} diff --git a/frontend/app/redux/modules/appState/share/tables/receiverForm/receiverGroupsTable.js b/frontend/app/redux/modules/appState/share/tables/receiverForm/receiverGroupsTable.js new file mode 100644 index 0000000..5561bd7 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/tables/receiverForm/receiverGroupsTable.js @@ -0,0 +1,38 @@ +import ReceiverFormTable from './receiverFormTable' +import * as api from '../../../../../../api/groupsApi' + +///api/v1/recipients/groups with recipientId + +class ReceiverGroupsTable extends ReceiverFormTable { + + constructor () { + super(api) + } + + getNamespace () { + return '[Recipient form groups table]' + } + + getTableState (state) { + return state.getIn(['appState', 'share', 'tables', 'receiverForm', 'groups']) + } + + getLoadTableRequestPayload (tableState, receiver) { + let payload = super.getLoadTableRequestPayload(tableState) + if (receiver) { + payload.recipientId = receiver.id + } + return payload + } + + getDataFromResponse (response, receiver) { + const data = response.groups + this.addDataColumn(data.data, 'enrolled', receiver.groups) + return data + } + +} + +const instance = new ReceiverGroupsTable() +instance.init() +export default instance diff --git a/frontend/app/redux/modules/appState/share/tables/receiverForm/receiverRecipientsTable.js b/frontend/app/redux/modules/appState/share/tables/receiverForm/receiverRecipientsTable.js new file mode 100644 index 0000000..4d42774 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/tables/receiverForm/receiverRecipientsTable.js @@ -0,0 +1,37 @@ +import ReceiverFormTable from './receiverFormTable' +import * as api from '../../../../../../api/recipientsApi' + +///api/v1/recipients with groupId + +class ReceiverRecipientsTable extends ReceiverFormTable { + + constructor () { + super(api) + } + + getNamespace () { + return '[Group form recipients table]' + } + + getTableState (state) { + return state.getIn(['appState', 'share', 'tables', 'receiverForm', 'recipients']) + } + + getLoadTableRequestPayload (tableState, receiver) { + let payload = super.getLoadTableRequestPayload(tableState) + if (receiver) { + payload.groupId = receiver.id + } + return payload + } + + getDataFromResponse (response, receiver) { + const data = response['recipients'] + this.addDataColumn(data.data, 'enrolled', receiver.recipients) + return data + } +} + +const instance = new ReceiverRecipientsTable() +instance.init() +export default instance diff --git a/frontend/app/redux/modules/appState/share/tables/receiverForm/receiverSubscriptionsTable.js b/frontend/app/redux/modules/appState/share/tables/receiverForm/receiverSubscriptionsTable.js new file mode 100644 index 0000000..93d03d2 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/tables/receiverForm/receiverSubscriptionsTable.js @@ -0,0 +1,36 @@ +import ReceiverFormTable from './receiverFormTable' +import * as api from '../../../../../../api/notificationsApi' + +class ReceiverSubscriptionsTable extends ReceiverFormTable { + + constructor () { + super(api) + } + + getNamespace () { + return '[Receiver form subscriptions table]' + } + + getTableState (state) { + return state.getIn(['appState', 'share', 'tables', 'receiverForm', 'subscriptions']) + } + + getLoadTableRequestPayload (tableState, receiver) { + let payload = super.getLoadTableRequestPayload(tableState) + if (receiver) { + payload.entityId = receiver.id + } + return payload + } + + getDataFromResponse (response, receiver) { + const data = response.notifications + this.addDataColumn(data.data, 'subscribed', receiver.subscriptions) + return data + } + +} + +const instance = new ReceiverSubscriptionsTable() +instance.init() +export default instance diff --git a/frontend/app/redux/modules/appState/share/tables/recipientsTable.js b/frontend/app/redux/modules/appState/share/tables/recipientsTable.js new file mode 100644 index 0000000..3898295 --- /dev/null +++ b/frontend/app/redux/modules/appState/share/tables/recipientsTable.js @@ -0,0 +1,34 @@ +import GenericTable from './genericTable' +import * as api from '../../../../../api/recipientsApi' + +class RecipientsTable extends GenericTable { + + constructor () { + super(api) + } + + getNamespace () { + return '[Recipients table]' + } + + getInitialState () { + const state = super.getInitialState() + return { + ...state, + filter: '' + } + } + + getTableState (state) { + return state.getIn(['appState', 'share', 'tables', 'recipients']) + } + + getDataFromResponse (response) { + return response['recipients'] + } + +} + +const instance = new RecipientsTable() +instance.init() +export default instance diff --git a/frontend/app/redux/modules/appState/share/tabs.js b/frontend/app/redux/modules/appState/share/tabs.js new file mode 100644 index 0000000..4beb33c --- /dev/null +++ b/frontend/app/redux/modules/appState/share/tabs.js @@ -0,0 +1,87 @@ +import { createAction, handleActions } from 'redux-actions' +import { fromJS } from 'immutable' + +//**** CONSTANTS ****// +const NS = '[Share tab]' +const SWITCH_SUBSCREEN = `${NS} Switch subscreen` +const SWITCH_TABLE = `${NS} Switch table` + +//**** ACTIONS ****// +export const switchShareSubScreen = createAction(SWITCH_SUBSCREEN, (type, subScreen) => ({type, subScreen})) +export const switchShareTable = createAction(SWITCH_TABLE, (type, table) => ({type, table})) + +export const actions = { + switchShareSubScreen, + switchShareTable +} + +/* TABS SUBSCREENS */ +export const NOTIFICATION_SUBSCREENS = { + TABLES: 'tables', + ALERT_FORM: 'alert', + NEWSLETTER_FORM: 'newsletter' +} + +export const RECEIVER_SUBSCREENS = { + TABLES: 'tables', + RECIPIENT_FORM: 'recipient', + GROUP_FORM: 'group' +} + +export const EMAILS_SUBSCREENS = { + EMAILS_TABLE: 'table', + ALERT_FORM: 'alert', + NEWSLETTER_FORM: 'newsletter', + FILTERS_TABLE: 'filters' +} + +/* TABLES IN 'tables' SUBSCREEN */ +export const NOTIFICATION_TABLES = { + MY_EMAILS: 'myEmails', + PUBLISHED: 'publishedEmails' +} + +export const RECEIVER_TABLES = { + RECIPIENTS: 'recipients', + GROUPS: 'groups' +} + +/* TABLES IN FORMS */ +export const RECIPIENT_FORM_TABLES = { + EMAIL_HISTORY: 'emailHistory', + SUBSCRIPTIONS: 'subscriptions', + GROUPS: 'groups' +} + +export const GROUP_FORM_TABLES = { + EMAIL_HISTORY: 'emailHistory', + SUBSCRIPTIONS: 'subscriptions', + RECIPIENTS: 'recipients' +} + +export const initialState = fromJS({ + notifications: { + subScreenVisible: NOTIFICATION_SUBSCREENS.TABLES, + tableVisible: 'myEmails' + }, + recipients: { + subScreenVisible: RECEIVER_SUBSCREENS.TABLES, + tableVisible: 'recipients' + }, + emails: { + subScreenVisible: EMAILS_SUBSCREENS.EMAILS_TABLE + } +}) + +//**** REDUCERS ****// +export default handleActions({ + [SWITCH_SUBSCREEN]: (state, {payload}) => { + const { type, subScreen } = payload + return state.setIn([type, 'subScreenVisible'], subScreen) + }, + + [SWITCH_TABLE]: (state, {payload}) => { + const { type, table } = payload + return state.setIn([type, 'tableVisible'], table) + } +}, initialState) diff --git a/frontend/app/redux/modules/appState/sidebar.js b/frontend/app/redux/modules/appState/sidebar.js new file mode 100644 index 0000000..10e3a3b --- /dev/null +++ b/frontend/app/redux/modules/appState/sidebar.js @@ -0,0 +1,325 @@ +import {createAction, handleActions} from 'redux-actions' +import {fromJS} from 'immutable' +import * as categoriesApi from '../../../api/sidebarCategoriesApi' +import * as feedsApi from '../../../api/feedsApi' +import {addAlert} from '../common/alerts' +import {thunkAction} from '../../utils/common' +import * as helpers from '../../utils/helpers/sidebar' +import {getRestrictions} from '../common/auth' +import {actions as searchActions} from './search' +import {actions as searchFiltersActions} from './searchByFilters' + +/* + * Constants + * */ +export const TYPES = { + FOLDER: 'folder', + FEED: 'feed', + CLIP_ARTICLE: 'clipArticle' +} + +const NS = '[Sidebar]' +const GET_SIDEBAR_CATEGORIES = `${NS} Get categories` +const ADD_CLIPPINGS_FEED = `${NS} Add clippings feed` +const ADD_CATEGORY = `${NS} Add category` +const RENAME_FEED = `${NS} Rename feed` +const RENAME_CATEGORY = `${NS} Rename category` +const TOGGLE_EXPORT_FEED = `${NS} Toggle export feed` +const TOGGLE_EXPORT_CATEGORY = `${NS} Toggle export category` +const MOVE_FEED = `${NS} Move feed` +const MOVE_CATEGORY = `${NS} Move category` +const DELETE_FEED = `${NS} Delete feed` +const DELETE_CATEGORY = `${NS} Delete category` +const SET_CATEGORIES = `${NS} Set categories` + +export const SET_FILTERED_CATEGORIES = 'SET_FILTERED_CATEGORIES' +export const CLEAR_FILTERED_CATEGORIES = 'CLEAR_FILTERED_CATEGORIES' + +export const SHOW_DELETE_POPUP = 'SHOW_DELETE_POPUP' +export const HIDE_DELETE_POPUP = 'HIDE_DELETE_POPUP' +export const SHOW_RENAME_POPUP = 'SHOW_RENAME_POPUP' +export const HIDE_RENAME_POPUP = 'HIDE_RENAME_POPUP' +export const SHOW_ADD_CATEGORY_POPUP = 'SHOW_ADD_CATEGORY_POPUP' +export const HIDE_ADD_CATEGORY_POPUP = 'HIDE_ADD_CATEGORY_POPUP' + +export const SHOW_ADD_CLIPPINGS_POPUP = 'SHOW_ADD_CLIPPINGS_POPUP' +export const HIDE_ADD_CLIPPINGS_POPUP = 'HIDE_ADD_CLIPPINGS_POPUP' + +/* + * Actions + * */ +export const getSidebarCategories = thunkAction(GET_SIDEBAR_CATEGORIES, ({token, fulfilled}) => { + return categoriesApi + .getCategories(token) + .then((categories) => { + fulfilled(categories) + }) +}) + +export const setFilteredCategories = createAction(SET_FILTERED_CATEGORIES, (filteredCategories) => filteredCategories) + +export const _clearFilteredCategories = createAction(CLEAR_FILTERED_CATEGORIES) + +export const clearFilteredCategories = () => { + return (dispatch, state) => { + document.getElementById('sidebar-search').value = '' + + dispatch(_clearFilteredCategories()) + } +} + +export const setCategories = createAction(SET_CATEGORIES, (changedCategories) => changedCategories) + +export const showDeletePopup = createAction(SHOW_DELETE_POPUP, (itemId, itemType, itemName, parentId) => { + return {itemId, itemType, itemName, parentId} +}) +export const hideDeletePopup = createAction(HIDE_DELETE_POPUP) + +export const showRenamePopup = createAction(SHOW_RENAME_POPUP, (itemId, itemType, itemName, parentId) => { + return {itemId, itemType, itemName, parentId} +}) +export const hideRenamePopup = createAction(HIDE_RENAME_POPUP) + +export const showAddCategoryPopup = createAction(SHOW_ADD_CATEGORY_POPUP, (parentId) => ({parentId})) +export const hideAddCategoryPopup = createAction(HIDE_ADD_CATEGORY_POPUP) + +export const showAddClippingsFeedPopup = createAction(SHOW_ADD_CLIPPINGS_POPUP, (parentId) => ({parentId})) +export const hideAddClippingsFeedPopup = createAction(HIDE_ADD_CLIPPINGS_POPUP) + +export const addCategory = thunkAction(ADD_CATEGORY, (name, parentId, {token, dispatch, getState, fulfilled}) => { + return categoriesApi + .addCategory(token, {name: name, parent: parentId}) + .then((newCategory) => { + fulfilled(newCategory) + const newCategories = helpers.addCategory(getState(), parentId, newCategory) + dispatch(setCategories(newCategories)) + dispatch(clearFilteredCategories()) + }) +}) + +export const moveCategory = thunkAction(MOVE_CATEGORY, (category, newCategoryId, {token, dispatch, fulfilled}) => { + const draggedCategoryId = category.id + const notCategoryItself = newCategoryId !== draggedCategoryId + const notCategoryInChild = !helpers.checkIfDraggedCategoryDragToItsChild(category.childes, newCategoryId) + // check if category trying to move to it's child or itself + if (notCategoryItself && notCategoryInChild) { + return categoriesApi + .moveCategory(token, undefined, draggedCategoryId, newCategoryId) + .then((response) => { + fulfilled() + const newCategories = fromJS(response.data) + dispatch(setCategories(newCategories)) + }) + } +}) + +export const renameCategory = thunkAction(RENAME_CATEGORY, (categoryId, categoryName, parentId, {token, dispatch, getState, fulfilled}) => { + return categoriesApi + .renameCategory(token, {name: categoryName, parent: parentId}, categoryId) + .then(() => { + fulfilled() + const newCategories = helpers.renameCategory(getState(), categoryId, categoryName) + dispatch(setCategories(newCategories)) + dispatch(clearFilteredCategories()) + }) +}) + +export const deleteCategory = thunkAction(DELETE_CATEGORY, (categoryId, {token, dispatch, getState, fulfilled}) => { + return categoriesApi + .deleteCategory(token, undefined, categoryId) + .then(() => { + fulfilled() + const newCategories = helpers.deleteCategory(getState(), categoryId) + dispatch(setCategories(newCategories)) + dispatch(clearFilteredCategories()) + }) +}) + +export const addClippingsFeed = thunkAction(ADD_CLIPPINGS_FEED, (feedName, categoryId, {token, dispatch, fulfilled, getState}) => { + const payload = { + feed: { + name: feedName, + category: categoryId, + subType: 'clip_feed' + } + } + return feedsApi + .createFeed(token, payload) + .then((newFeed) => { + fulfilled(newFeed) + const newCategories = helpers.addFeed(getState(), categoryId, newFeed) + dispatch(setCategories(newCategories)) + dispatch(getRestrictions()) + dispatch(addAlert({ + type: 'notice', + transKey: 'saveFeed', + id: 'saveFeed' + })) + }) +}) + +export const moveFeed = thunkAction(MOVE_FEED, (feedId, categoryId, {token, dispatch, fulfilled}) => { + return feedsApi + .moveFeed(token, undefined, feedId, categoryId) + .then((response) => { + fulfilled() + const newCategories = fromJS(response.data) + dispatch(setCategories(newCategories)) + }) +}) + +export const toggleExportFeed = thunkAction(TOGGLE_EXPORT_FEED, (feedId, isExported, {token, dispatch, fulfilled}) => { + return feedsApi + .toggleExportFeed(token, {export: isExported}, feedId) + .then(() => { + fulfilled() + dispatch(getSidebarCategories()) + dispatch(getRestrictions()) + }) +}) + +const toggleExportCategory = thunkAction(TOGGLE_EXPORT_CATEGORY, (categoryId, isExported, {token, dispatch, fulfilled}) => { + return feedsApi + .toggleExportCategory(token, {export: isExported}, categoryId) + .then(() => { + fulfilled() + dispatch(getSidebarCategories()) + }) +}) + +export const renameFeed = thunkAction(RENAME_FEED, (feedId, newName, parentId, {token, dispatch, fulfilled, getState}) => { + return feedsApi + .renameFeed(token, {name: newName}, feedId) + .then(() => { + fulfilled() + const newCategories = helpers.renameFeed(getState(), feedId, newName, parentId) + dispatch(setCategories(newCategories)) + }) +}) + +export const deleteFeed = thunkAction(DELETE_FEED, (feedId, categoryId, {token, dispatch, getState, fulfilled}) => { + const currentFeedId = getState().getIn(['appState', 'search', 'activeFeed', 'id']) + const isCurrent = currentFeedId && (parseInt(currentFeedId) === parseInt(feedId)) + return feedsApi + .deleteFeed(token, undefined, feedId) + .then(() => { + fulfilled() + if (isCurrent) { + dispatch(searchActions.setNewSearch()) + dispatch(searchFiltersActions.renewSearchBy()) + } + const newCategories = helpers.deleteFeed(getState(), categoryId, feedId) + dispatch(setCategories(newCategories)) + dispatch(getRestrictions()) + dispatch(clearFilteredCategories()) + }) +}) + +export const actions = { + getSidebarCategories, + setFilteredCategories, + clearFilteredCategories, + setCategories, + addCategory, + addClippingsFeed, + moveCategory, + moveFeed, + deleteFeed, + deleteCategory, + renameFeed, + renameCategory, + showDeletePopup, + hideDeletePopup, + showRenamePopup, + hideRenamePopup, + showAddCategoryPopup, + hideAddCategoryPopup, + showAddClippingsFeedPopup, + hideAddClippingsFeedPopup, + toggleExportFeed, + toggleExportCategory +} + +/* + * State + * */ +export const initialState = fromJS({ + areCategoriesLoaded: false, + categories: [], + filteredCategories: [], + areFeedsFiltered: false, + popupVisible: { + 'delete': false, + rename: false, + addCategory: false, + addClippingsFeed: false + }, + popupItems: { + 'delete': {}, //feed or category + rename: {}, //feed or category + addCategory: {}, //{parentId} + addClippingsFeed: {} //{parentId} + } +}) + +/* + * Reducers + * */ +const hidePopup = (type) => (state) => { + return state + .setIn(['popupVisible', type], false) + .setIn(['popupItems', type], {}) +} + +const showPopup = (type) => (state, {payload}) => { + return state + .setIn(['popupVisible', type], true) + .setIn(['popupItems', type], payload) +} + +export default handleActions({ + + [`${GET_SIDEBAR_CATEGORIES} fulfilled`]: (state, { payload }) => { + const response = payload.data + + return state.merge({ + 'categories': response, + 'areCategoriesLoaded': true + }) + }, + + [SET_FILTERED_CATEGORIES]: (state, { payload: filteredCategories }) => { + return state.merge({ + 'filteredCategories': filteredCategories, + 'areFeedsFiltered': true + }) + }, + + [CLEAR_FILTERED_CATEGORIES]: (state, { payload }) => { + return state.merge({ + 'filteredCategories': [], + 'areFeedsFiltered': false + }) + }, + + [SET_CATEGORIES]: (state, { payload: changedCategories }) => { + return state.set('categories', changedCategories) + }, + + [SHOW_DELETE_POPUP]: showPopup('delete'), + + [HIDE_DELETE_POPUP]: hidePopup('delete'), + + [SHOW_RENAME_POPUP]: showPopup('rename'), + + [HIDE_RENAME_POPUP]: hidePopup('rename'), + + [SHOW_ADD_CATEGORY_POPUP]: showPopup('addCategory'), + + [HIDE_ADD_CATEGORY_POPUP]: hidePopup('addCategory'), + + [SHOW_ADD_CLIPPINGS_POPUP]: showPopup('addClippingsFeed'), + + [HIDE_ADD_CLIPPINGS_POPUP]: hidePopup('addClippingsFeed') + +}, initialState) diff --git a/frontend/app/redux/modules/appState/sourcesState.js b/frontend/app/redux/modules/appState/sourcesState.js new file mode 100644 index 0000000..db8628d --- /dev/null +++ b/frontend/app/redux/modules/appState/sourcesState.js @@ -0,0 +1,594 @@ +import { createAction, handleActions } from 'redux-actions' +import { fromJS } from 'immutable' +import {thunkAction} from '../../utils/common' +import * as api from '../../../api/searchApi' +import { addAlert } from '../common/alerts' +import {filtersFromServerFormat, ADV_FILTERS_LIMIT} from '../../utils/helpers/advancedFilters' + +/* + * Constants + * */ +export const GET_SOURCE_INDEXES = 'GET_SOURCE_INDEXES' + +export const SET_SOURCE_INDEX_SEARCH_QUERY = 'SET_SOURCE_INDEX_SEARCH_QUERY' + +export const TOGGLE_SOURCE_INDEX = 'TOGGLE_SOURCE_INDEX' +export const TOGGLE_ALL_SOURCE_INDEXES = 'TOGGLE_ALL_SOURCE_INDEXES' + +export const GET_MAIN_SOURCE_LISTS = 'GET_MAIN_SOURCE_LISTS' +export const TOGGLE_ONLY_GLOBAL = 'TOGGLE_ONLY_GLOBAL' + +export const TOGGLE_ADD_SOURCE_TO_LIST_POPUP = 'TOGGLE_ADD_SOURCE_TO_LIST_POPUP' +export const ADD_SOURCE_LIST = 'ADD_SOURCE_LIST' +export const DELETE_SOURCE_LIST = 'DELETE_SOURCE_LIST' +export const RENAME_SOURCE_LIST = 'RENAME_SOURCE_LIST' +export const CLONE_SOURCE_LIST = 'CLONE_SOURCE_LIST' +export const ADD_SOURCES_TO_LIST = 'ADD_SOURCES_TO_LIST' +export const UPDATE_LIST_SOURCES = 'UPDATE_LIST_SOURCES' +export const SET_CHOSEN_LISTS_TO_ADD_SOURCES = 'SET_CHOSEN_LISTS_TO_ADD_SOURCES' + +export const SHOW_UPDATE_SOURCE_POPUP = 'SHOW_UPDATE_SOURCE_POPUP' +export const HIDE_UPDATE_SOURCE_POPUP = 'HIDE_UPDATE_SOURCE_POPUP' +export const SET_CHOSEN_LISTS_TO_UPDATE_SOURCES = 'SET_CHOSEN_LISTS_TO_UPDATE_SOURCES' + +export const TOGGLE_ADD_LIST_POPUP = 'TOGGLE_ADD_LIST_POPUP' + +export const TOGGLE_DELETE_LIST_POPUP = 'TOGGLE_DELETE_LIST_POPUP' + +export const TOGGLE_RENAME_LIST_POPUP = 'TOGGLE_RENAME_LIST_POPUP' + +export const TOGGLE_CLONE_LIST_POPUP = 'TOGGLE_CLONE_LIST_POPUP' + +export const TOGGLE_SOURCE_INFO_POPUP = 'TOGGLE_SOURCE_INFO_POPUP' + +export const GET_SOURCES_OF_LIST = 'GET_SOURCES_OF_LIST' +export const SHOW_SOURCES_OF_LIST = 'SHOW_SOURCES_OF_LIST' +export const HIDE_SOURCES_OF_LIST = 'HIDE_SOURCES_OF_LIST' +export const SET_SOURCES_OF_LIST_SEARCH_QUERY = 'SET_SOURCES_OF_LIST_SEARCH_QUERY' + +const SELECT_SOURCES_FILTER = 'SELECT_SOURCES_FILTER' +const CLEAR_SOURCES_FILTERS = 'CLEAR_SOURCES_FILTERS' +const CLEAR_ALL_SOURCES_FILTERS = 'CLEAR_ALL_SOURCES_FILTERS' +const LOAD_MORE_SOURCES_FILTERS = 'LOAD_MORE_SOURCES_FILTERS' +const LOAD_LESS_SOURCES_FILTERS = 'LOAD_LESS_SOURCES_FILTERS' + +const SHARE_SOURCE_LIST = 'SHARE_SOURCE_LIST' +const UNSHARE_SOURCE_LIST = 'UNSHARE_SOURCE_LIST' + +/* + * Actions + * */ +const getSourceIndexes = thunkAction(GET_SOURCE_INDEXES, (params, {token, getState, fulfilled}) => { + const sourceIndexesState = getState().getIn(['appState', 'sourcesState', 'sourceIndexesState']) + const query = sourceIndexesState.get('searchQuery') + const page = sourceIndexesState.get('page') + const limit = sourceIndexesState.get('limit') + const selectedFilters = sourceIndexesState.getIn(['advancedFilters', 'selected']) + let dataToSend = { + query, + page, + limit + } + if (Object.keys(selectedFilters).length > 0) { + dataToSend.advancedFilters = selectedFilters + } + if (params) { + Object.assign(dataToSend, params) + } + + return api.searchSources(token, dataToSend) + .then((response) => fulfilled(response)) +}, true) + +export const setSourceIndexSearchQuery = createAction(SET_SOURCE_INDEX_SEARCH_QUERY, (query) => query) + +export const toggleSourceIndex = createAction(TOGGLE_SOURCE_INDEX, itemId => itemId) +export const toggleAllSourceIndexes = createAction(TOGGLE_ALL_SOURCE_INDEXES, isChosen => isChosen) + +export const getMainSourceLists = thunkAction(GET_MAIN_SOURCE_LISTS, (dataToSend, {token, fulfilled}) => { + return api.getSourceLists(token, dataToSend) + .then((response) => fulfilled(response)) +}, true) + +export const toggleOnlyGlobal = createAction(TOGGLE_ONLY_GLOBAL) + +export const toggleAddSourceToListPopup = createAction(TOGGLE_ADD_SOURCE_TO_LIST_POPUP) + +export const setChosenListsToAddSources = createAction(SET_CHOSEN_LISTS_TO_ADD_SOURCES, (newLists) => newLists) + +const addSourcesToList = thunkAction(ADD_SOURCES_TO_LIST, (dataToSend, isAdd, {token, dispatch, getState}) => { + return api.addSourcesToLists(token, dataToSend) + .then(() => { + dispatch(getSourceIndexes(null)) + dispatch(addAlert({ + type: 'notice', + transKey: 'updateListsForSourceNotice', + id: 'updateListsForSourceNotice' + })) + isAdd + ? dispatch(toggleAddSourceToListPopup()) + : dispatch(hideUpdateSourcePopup()) + }) +}) + +const addSourceList = thunkAction(ADD_SOURCE_LIST, (name, {token, dispatch}) => { + return api.addSourceLists(token, name) + .then(() => { + dispatch(getMainSourceLists({})) + dispatch(addAlert({ + type: 'notice', + transKey: 'addSourceList', + parameters: { + name + } + })) + dispatch(toggleAddListPopup()) + }) +}) + +const deleteSourceList = thunkAction(DELETE_SOURCE_LIST, (data, {token, dispatch}) => { + return api.deleteSourceLists(token, data.id) + .then(() => { + dispatch(getMainSourceLists({})) + + dispatch(toggleDeleteListPopup()) + + dispatch(addAlert({ + type: 'notice', + transKey: 'deleteSourceList', + parameters: {name: data.name} + })) + + }) +}) + +const renameSourceList = thunkAction(RENAME_SOURCE_LIST, (data, oldName, {token, dispatch}) => { + return api.renameSourceLists(token, data) + .then(() => { + dispatch(getMainSourceLists({})) + dispatch(addAlert({ + type: 'notice', + transKey: 'renameSourceList', + parameters: oldName + })) + dispatch(toggleRenameListPopup()) + }) +}) + +const cloneSourceList = thunkAction(CLONE_SOURCE_LIST, (data, {token, dispatch}) => { + return api.cloneSourceLists(token, data) + .then(() => { + dispatch(getMainSourceLists({})) + dispatch(addAlert({ + type: 'notice', + transKey: 'cloneSourceList' + })) + dispatch(toggleCloneListPopup()) + }) +}) + +const updateListSources = thunkAction(UPDATE_LIST_SOURCES, (dataToSend, {token, dispatch, getState}) => { + return api.replaceSourceListsForSource(token, dataToSend) + .then(() => { + dispatch(addAlert({ + type: 'notice', + transKey: 'updateListsForSourceNotice', + id: 'updateListsForSourceNotice' + })) + + const sourcesOfListState = getState().getIn(['appState', 'sourcesState', 'sourcesOfListState']) + const query = sourcesOfListState.get('searchQuery') + const page = sourcesOfListState.get('page') + const limit = sourcesOfListState.get('limit') + const listId = sourcesOfListState.getIn(['visibleList', 'id']) + dispatch(getSourcesOfList(listId, {query, page, limit})) + }) +}) + +const shareSourceList = thunkAction(SHARE_SOURCE_LIST, (id, {token, dispatch}) => { + return api.shareSourceList(token, id) + .then(() => { + dispatch(getMainSourceLists({})) + dispatch(addAlert({ + type: 'notice', + transKey: 'shareSourceList' + })) + }) +}) + +const unshareSourceList = thunkAction(UNSHARE_SOURCE_LIST, (id, {token, dispatch}) => { + return api.unshareSourceList(token, id) + .then(() => { + dispatch(getMainSourceLists({})) + dispatch(addAlert({ + type: 'notice', + transKey: 'unshareSourceList' + })) + }) +}) + +export const showUpdateSourcePopup = createAction(SHOW_UPDATE_SOURCE_POPUP, (chosenSource) => chosenSource) +export const hideUpdateSourcePopup = createAction(HIDE_UPDATE_SOURCE_POPUP) +export const setChosenListsToUpdateSources = createAction(SET_CHOSEN_LISTS_TO_UPDATE_SOURCES, (newLists) => newLists) + +export const toggleAddListPopup = createAction(TOGGLE_ADD_LIST_POPUP) + +export const _toggleDeletePopup = createAction(TOGGLE_DELETE_LIST_POPUP, (type, list) => ({type, list})) + +export const toggleDeleteListPopup = (list) => (dispatch) => { + dispatch(_toggleDeletePopup('sourceListsState', list)) +} +export const toggleDeleteListIndexPopup = (list) => (dispatch) => { + dispatch(_toggleDeletePopup('sourcesOfListState', list)) +} + +export const toggleRenameListPopup = createAction(TOGGLE_RENAME_LIST_POPUP, (list) => list) + +export const toggleCloneListPopup = createAction(TOGGLE_CLONE_LIST_POPUP, (list) => list) + +const toggleInfoSourcePopup = createAction(TOGGLE_SOURCE_INFO_POPUP, (type, item) => ({type, item})) + +export const getSourcesOfList = thunkAction(GET_SOURCES_OF_LIST, (id, dataToSend, {token, fulfilled}) => { + return api + .getSourcesOfList(token, dataToSend, id) + .then((response) => fulfilled(response)) +}, true) + +export const showSourcesOfList = createAction(SHOW_SOURCES_OF_LIST, (list) => list) + +export const hideSourcesOfList = createAction(HIDE_SOURCES_OF_LIST) + +export const setSourcesOfListSearchQuery = createAction(SET_SOURCES_OF_LIST_SEARCH_QUERY, (query) => query) + +export const selectSourcesFilter = createAction(SELECT_SOURCES_FILTER, (groupName, filterValue) => { return {groupName, filterValue} }) +export const clearSourcesFilters = createAction(CLEAR_SOURCES_FILTERS) +export const clearAllSourcesFilters = createAction(CLEAR_ALL_SOURCES_FILTERS) +export const loadMoreSourcesFilters = createAction(LOAD_MORE_SOURCES_FILTERS) +export const loadLessSourcesFilters = createAction(LOAD_LESS_SOURCES_FILTERS) + +export const actions = { + getSourceIndexes, + setSourceIndexSearchQuery, + toggleSourceIndex, + toggleAllSourceIndexes, + getMainSourceLists, + toggleOnlyGlobal, + toggleAddSourceToListPopup, + addSourcesToList, + updateListSources, + setChosenListsToAddSources, + showUpdateSourcePopup, + hideUpdateSourcePopup, + setChosenListsToUpdateSources, + toggleInfoSourcePopup, + toggleAddListPopup, + toggleDeleteListPopup, + toggleDeleteListIndexPopup, + toggleRenameListPopup, + toggleCloneListPopup, + getSourcesOfList, + showSourcesOfList, + hideSourcesOfList, + setSourcesOfListSearchQuery, + selectSourcesFilter, + clearSourcesFilters, + clearAllSourcesFilters, + loadMoreSourcesFilters, + loadLessSourcesFilters, + addSourceList, + deleteSourceList, + renameSourceList, + cloneSourceList, + shareSourceList, + unshareSourceList +} + +/* + * State + * */ +export const initialState = fromJS({ + sourceIndexesState: { + searchQuery: '', + page: 1, + limit: 25, + sortByField: 'id', + sortDirection: 'asc', + data: [], + count: 0, + totalCount: 0, + isLoading: false, + isAddPopupVisible: false, + isUpdatePopupVisible: false, + infoPopup: { + visible: false, + item: null + }, + chosenListsToAddSources: [], + chosenSourceToUpdate: {}, // source id on which we click to add / remove it. + idsToDelete: [], + selectedIds: [], //map of ids of items that selected in table + isAllSelected: false, + advancedFilters: { + all: {}, + pages: {}, //{groupName1: {count: xx, totalCount: yy}, groupName2: ....} + selected: {}, // {groupName1: {value1: 0, value2: 1, ....}, groupName2: {}, ... } will be send to the server + pending: {} //which groups is not applied yet + } + }, + sourceListsState: { + page: 1, + limit: 25, + sortByField: 'id', + sortDirection: 'asc', + data: [], + count: 0, + totalCount: 0, + onlyGlobal: false, + isLoading: false, + isAddListPopupVisible: false, + isDeletePopupVisible: false, + isRenameListPopupVisible: false, + isCloneListPopupVisible: false, + listToEdit: {} + }, + sourcesOfListState: { + isSourcesOfListVisible: false, + visibleList: null, + searchQuery: '', + page: 1, + limit: 25, + sortByField: 'id', + sortDirection: 'asc', + data: [], + count: 0, + totalCount: 0, + infoPopup: { + visible: false, + item: null + }, + isDeletePopupVisible: false, + isLoading: false, + listToEdit: {} + } +}) + +/* + * Reducers + * */ +export default handleActions({ + + [`${GET_SOURCE_INDEXES} pending`]: (state, { payload }) => { + return state.setIn(['sourceIndexesState', 'isLoading'], payload.isPending) + }, + + [`${GET_SOURCE_INDEXES} fulfilled`]: (state, { payload: {sources, advancedFilters, meta} }) => { + + const {allFilters, pages} = filtersFromServerFormat(advancedFilters) + + return state.mergeIn(['sourceIndexesState'], { + 'data': sources.data, + 'isLoading': false, + 'page': sources.page, + 'limit': sources.limit, + 'count': sources.count, + 'totalCount': sources.totalCount, + 'sortByField': meta.sort.field || 'name', + 'sortDirection': meta.sort.direction || 'asc' + }).mergeIn(['sourceIndexesState', 'advancedFilters'], { + all: allFilters, + pages: pages, + selected: meta.advancedFilters, + pending: {} + }) + }, + + [SET_SOURCE_INDEX_SEARCH_QUERY]: (state, {payload: query}) => { + return state.setIn(['sourceIndexesState', 'searchQuery'], query) + }, + + [TOGGLE_SOURCE_INDEX]: (state, {payload: itemId}) => { + const path = ['sourceIndexesState', 'selectedIds'] + + let selectedIds = state.getIn(path) + const isSelected = selectedIds.includes(itemId) + if (isSelected) { + selectedIds = selectedIds.filter(id => id !== itemId) + } + else { + selectedIds = selectedIds.push(itemId) + } + return state.setIn(path, selectedIds) + }, + + [TOGGLE_ALL_SOURCE_INDEXES]: (state) => { + const type = 'sourceIndexesState' + const isAllSelected = state.getIn([type, 'isAllSelected']) + if (isAllSelected) { //then deselect all + return state.mergeIn([type], { + isAllSelected: false, + selectedIds: [] + }) + } + else { //select all currently loaded data + const selectedIds = state.getIn([type, 'data']).map(item => item.get('id')) + return state.mergeIn([type], { + isAllSelected: true, + selectedIds + }) + } + }, + + [`${GET_MAIN_SOURCE_LISTS} pending`]: (state, { payload }) => { + return state.setIn(['sourceListsState', 'isLoading'], payload.isPending) + }, + + [`${GET_MAIN_SOURCE_LISTS} fulfilled`]: (state, { payload }) => { + const response = payload.data + return state.mergeIn(['sourceListsState'], { + 'data': response, + 'isLoading': false, + 'page': payload.page, + 'limit': payload.limit, + 'count': payload.count, + 'totalCount': payload.totalCount, + 'sortByField': payload.sort.field || 'name', + 'sortDirection': payload.sort.direction || 'asc' + }) + }, + + [TOGGLE_ONLY_GLOBAL]: (state) => { + const onlyGlobal = state.getIn(['sourceListsState', 'onlyGlobal']) + return state.setIn(['sourceListsState', 'onlyGlobal'], !onlyGlobal) + }, + + [TOGGLE_ADD_SOURCE_TO_LIST_POPUP]: (state, { payload }) => { + const isVisible = !state.getIn(['sourceIndexesState', 'isAddPopupVisible']) + return state.mergeIn(['sourceIndexesState'], { + 'isAddPopupVisible': isVisible, + 'chosenListsToAddSources': [] + }) + }, + + [SET_CHOSEN_LISTS_TO_ADD_SOURCES]: (state, {payload: newSources}) => { + return state.setIn(['sourceIndexesState', 'chosenListsToAddSources'], newSources) + }, + + [SHOW_UPDATE_SOURCE_POPUP]: (state, { payload: chosenSource }) => { + return state.mergeIn(['sourceIndexesState'], { + 'isUpdatePopupVisible': true, + 'chosenSourceToUpdate': chosenSource + }) + }, + + [HIDE_UPDATE_SOURCE_POPUP]: (state, { payload }) => { + return state.mergeIn(['sourceIndexesState'], { + 'isUpdatePopupVisible': false, + 'chosenSourceToUpdate': {} + }) + }, + + [SET_CHOSEN_LISTS_TO_UPDATE_SOURCES]: (state, {payload: newSources}) => { + return state.setIn(['sourceIndexesState', 'chosenSourceToUpdate', 'listIds'], newSources) + }, + + [TOGGLE_ADD_LIST_POPUP]: (state, {payload}) => { + const isVisible = !state.getIn(['sourceListsState', 'isAddListPopupVisible']) + return state.setIn(['sourceListsState', 'isAddListPopupVisible'], isVisible) + }, + + [TOGGLE_DELETE_LIST_POPUP]: (state, {payload}) => { + const { type, list } = payload + const isVisible = !state.getIn([type, 'isDeletePopupVisible']) + return state.mergeIn([type], { + 'isDeletePopupVisible': isVisible, + 'listToEdit': isVisible ? list : {} + }) + }, + + [TOGGLE_RENAME_LIST_POPUP]: (state, {payload: list}) => { + const isVisible = !state.getIn(['sourceListsState', 'isRenameListPopupVisible']) + return state.mergeIn(['sourceListsState'], { + 'isRenameListPopupVisible': isVisible, + 'listToEdit': isVisible ? list : {} + }) + }, + + [TOGGLE_CLONE_LIST_POPUP]: (state, {payload: list}) => { + const isVisible = !state.getIn(['sourceListsState', 'isCloneListPopupVisible']) + return state.mergeIn(['sourceListsState'], { + 'isCloneListPopupVisible': isVisible, + 'listToEdit': isVisible ? list : {} + }) + }, + + [TOGGLE_SOURCE_INFO_POPUP]: (state, {payload}) => { + const { type, item } = payload + const popupPath = [type, 'infoPopup'] + const isVisible = !state.getIn(popupPath.concat('visible')) + return state.mergeIn(popupPath, { + visible: isVisible, + item: isVisible ? item : null + }) + }, + + [`${GET_SOURCES_OF_LIST} pending`]: (state, { payload }) => { + return state.setIn(['sourcesOfListState', 'isLoading'], payload.isPending) + }, + + [`${GET_SOURCES_OF_LIST} fulfilled`]: (state, { payload }) => { + const sources = payload.sources + return state.mergeIn(['sourcesOfListState'], { + 'data': sources.data, + 'isLoading': false, + 'page': sources.page, + 'limit': sources.limit, + 'count': sources.count, + 'totalCount': sources.totalCount, + 'sortByField': payload.sort.field || 'name', + 'sortDirection': payload.sort.direction || 'asc' + }) + }, + + [SHOW_SOURCES_OF_LIST]: (state, {payload: list}) => { + return state.mergeIn(['sourcesOfListState'], { + 'isSourcesOfListVisible': true, + 'visibleList': list + }) + }, + + [HIDE_SOURCES_OF_LIST]: (state, {payload}) => { + return state.mergeIn(['sourcesOfListState'], { + 'isSourcesOfListVisible': false, + 'visibleList': {}, + 'data': [] + }) + }, + + [SET_SOURCES_OF_LIST_SEARCH_QUERY]: (state, {payload: query}) => { + return state.setIn(['sourcesOfListState', 'searchQuery'], query) + }, + + [SELECT_SOURCES_FILTER]: (state, {payload: {groupName, filterValue}}) => { + const basePath = ['sourceIndexesState', 'advancedFilters'] + const selectionPath = [...basePath, 'selected', groupName] + if (groupName === 'articleDate') { + state = state.deleteIn(selectionPath) + } + //tri-state switch + const currentState = state.getIn([...selectionPath, filterValue]) + let newState + if (currentState === undefined) { + newState = 1 + } else if (currentState === 1) { + newState = -1 + } + return state.deleteIn([...basePath, 'pending', groupName]).setIn([...selectionPath, filterValue], newState) + }, + + [CLEAR_SOURCES_FILTERS]: (state, {payload: groupName}) => { + const basePath = ['sourceIndexesState', 'advancedFilters'] + return state.setIn([...basePath, 'pending', groupName], true).deleteIn([...basePath, 'selected', groupName]) + }, + + [CLEAR_ALL_SOURCES_FILTERS]: (state) => { + return state.mergeIn(['sourceIndexesState', 'advancedFilters'], { + selected: {}, + pending: {} + }) + }, + + [LOAD_LESS_SOURCES_FILTERS]: (state, {payload: groupName}) => { + const path = ['sourceIndexesState', 'advancedFilters', 'pages', groupName, 'count'] + const currentCount = state.getIn(path) + return state.setIn(path, Math.max(currentCount - ADV_FILTERS_LIMIT, ADV_FILTERS_LIMIT)) + }, + + [LOAD_MORE_SOURCES_FILTERS]: (state, {payload: groupName}) => { + const path = ['sourceIndexesState', 'advancedFilters', 'pages', groupName] + const currentCount = state.getIn([...path, 'count']) + console.log(currentCount) + const totalCount = state.getIn([...path, 'totalCount']) + return state.setIn([...path, 'count'], Math.min(currentCount + ADV_FILTERS_LIMIT, totalCount)) + } + +}, initialState) diff --git a/frontend/app/redux/modules/appState/themeOptions.js b/frontend/app/redux/modules/appState/themeOptions.js new file mode 100644 index 0000000..8b58315 --- /dev/null +++ b/frontend/app/redux/modules/appState/themeOptions.js @@ -0,0 +1,265 @@ +import sideBar6 from '../../../styles/utils/images/sidebar/city1.jpg' + +export const SET_ENABLE_BACKGROUND_IMAGE = + 'THEME_OPTIONS/SET_ENABLE_BACKGROUND_IMAGE' + +export const SET_ENABLE_MOBILE_MENU = 'THEME_OPTIONS/SET_ENABLE_MOBILE_MENU' +export const SET_ENABLE_MOBILE_MENU_SMALL = + 'THEME_OPTIONS/SET_ENABLE_MOBILE_MENU_SMALL' + +export const SET_ENABLE_FIXED_HEADER = 'THEME_OPTIONS/SET_ENABLE_FIXED_HEADER' +export const SET_ENABLE_HEADER_SHADOW = 'THEME_OPTIONS/SET_ENABLE_HEADER_SHADOW' +export const SET_ENABLE_SIDEBAR_SHADOW = + 'THEME_OPTIONS/SET_ENABLE_SIDEBAR_SHADOW' +export const SET_ENABLE_FIXED_SIDEBAR = 'THEME_OPTIONS/SET_ENABLE_FIXED_SIDEBAR' +export const SET_ENABLE_CLOSED_SIDEBAR = + 'THEME_OPTIONS/SET_ENABLE_CLOSED_SIDEBAR' +export const SET_ENABLE_FIXED_FOOTER = 'THEME_OPTIONS/SET_ENABLE_FIXED_FOOTER' + +export const SET_ENABLE_PAGETITLE_ICON = + 'THEME_OPTIONS/SET_ENABLE_PAGETITLE_ICON' +export const SET_ENABLE_PAGETITLE_SUBHEADING = + 'THEME_OPTIONS/SET_ENABLE_PAGETITLE_SUBHEADING' +export const SET_ENABLE_PAGE_TABS_ALT = 'THEME_OPTIONS/SET_ENABLE_PAGE_TABS_ALT' + +export const SET_BACKGROUND_IMAGE = 'THEME_OPTIONS/SET_BACKGROUND_IMAGE' +export const SET_BACKGROUND_COLOR = 'THEME_OPTIONS/SET_BACKGROUND_COLOR' +export const SET_COLOR_SCHEME = 'THEME_OPTIONS/SET_COLOR_SCHEME' +export const SET_BACKGROUND_IMAGE_OPACITY = + 'THEME_OPTIONS/SET_BACKGROUND_IMAGE_OPACITY' + +export const SET_HEADER_BACKGROUND_COLOR = + 'THEME_OPTIONS/SET_HEADER_BACKGROUND_COLOR' + +const setEnableBackgroundImage = (enableBackgroundImage) => ({ + type: SET_ENABLE_BACKGROUND_IMAGE, + enableBackgroundImage +}) + +const setEnableFixedHeader = (enableFixedHeader) => ({ + type: SET_ENABLE_FIXED_HEADER, + enableFixedHeader +}) + +const setEnableHeaderShadow = (enableHeaderShadow) => ({ + type: SET_ENABLE_HEADER_SHADOW, + enableHeaderShadow +}) + +const setEnableSidebarShadow = (enableSidebarShadow) => ({ + type: SET_ENABLE_SIDEBAR_SHADOW, + enableSidebarShadow +}) + +const setEnablePageTitleIcon = (enablePageTitleIcon) => ({ + type: SET_ENABLE_PAGETITLE_ICON, + enablePageTitleIcon +}) + +const setEnablePageTitleSubheading = (enablePageTitleSubheading) => ({ + type: SET_ENABLE_PAGETITLE_SUBHEADING, + enablePageTitleSubheading +}) + +const setEnablePageTabsAlt = (enablePageTabsAlt) => ({ + type: SET_ENABLE_PAGE_TABS_ALT, + enablePageTabsAlt +}) + +const setEnableFixedSidebar = (enableFixedSidebar) => ({ + type: SET_ENABLE_FIXED_SIDEBAR, + enableFixedSidebar +}) + +const setEnableClosedSidebar = (enableClosedSidebar) => ({ + type: SET_ENABLE_CLOSED_SIDEBAR, + enableClosedSidebar +}) + +const setEnableMobileMenu = (enableMobileMenu) => ({ + type: SET_ENABLE_MOBILE_MENU, + enableMobileMenu +}) + +const setEnableMobileMenuSmall = (enableMobileMenuSmall) => ({ + type: SET_ENABLE_MOBILE_MENU_SMALL, + enableMobileMenuSmall +}) + +const setEnableFixedFooter = (enableFixedFooter) => ({ + type: SET_ENABLE_FIXED_FOOTER, + enableFixedFooter +}) + +const setBackgroundColor = (backgroundColor) => ({ + type: SET_BACKGROUND_COLOR, + backgroundColor +}) + +const setHeaderBackgroundColor = (headerBackgroundColor) => ({ + type: SET_HEADER_BACKGROUND_COLOR, + headerBackgroundColor +}) + +const setColorScheme = (colorScheme) => ({ + type: SET_COLOR_SCHEME, + colorScheme +}) + +const setBackgroundImageOpacity = (backgroundImageOpacity) => ({ + type: SET_BACKGROUND_IMAGE_OPACITY, + backgroundImageOpacity +}) + +const setBackgroundImage = (backgroundImage) => ({ + type: SET_BACKGROUND_IMAGE, + backgroundImage +}) + +export const themeActions = { + setEnableBackgroundImage, + setEnableFixedHeader, + setEnableHeaderShadow, + setEnableSidebarShadow, + setEnablePageTitleIcon, + setEnablePageTitleSubheading, + setEnablePageTabsAlt, + setEnableFixedSidebar, + setEnableClosedSidebar, + setEnableMobileMenu, + setEnableMobileMenuSmall, + setEnableFixedFooter, + setBackgroundColor, + setHeaderBackgroundColor, + setColorScheme, + setBackgroundImageOpacity, + setBackgroundImage +} + +export default function ThemeOptions ( + state = { + backgroundColor: '', + headerBackgroundColor: '', + enableMobileMenuSmall: '', + enableBackgroundImage: false, + enableClosedSidebar: false, + enableFixedHeader: true, + enableHeaderShadow: true, + enableSidebarShadow: true, + enableFixedFooter: true, + enableFixedSidebar: true, + colorScheme: 'white', + backgroundImage: sideBar6, + backgroundImageOpacity: 'opacity-06', + enablePageTitleIcon: true, + enablePageTitleSubheading: true, + enablePageTabsAlt: true + }, + action +) { + switch (action.type) { + case SET_ENABLE_BACKGROUND_IMAGE: + return { + ...state, + enableBackgroundImage: action.enableBackgroundImage + } + + case SET_ENABLE_FIXED_HEADER: + return { + ...state, + enableFixedHeader: action.enableFixedHeader + } + + case SET_ENABLE_HEADER_SHADOW: + return { + ...state, + enableHeaderShadow: action.enableHeaderShadow + } + + case SET_ENABLE_SIDEBAR_SHADOW: + return { + ...state, + enableSidebarShadow: action.enableSidebarShadow + } + + case SET_ENABLE_PAGETITLE_ICON: + return { + ...state, + enablePageTitleIcon: action.enablePageTitleIcon + } + + case SET_ENABLE_PAGETITLE_SUBHEADING: + return { + ...state, + enablePageTitleSubheading: action.enablePageTitleSubheading + } + + case SET_ENABLE_PAGE_TABS_ALT: + return { + ...state, + enablePageTabsAlt: action.enablePageTabsAlt + } + + case SET_ENABLE_FIXED_SIDEBAR: + return { + ...state, + enableFixedSidebar: action.enableFixedSidebar + } + + case SET_ENABLE_MOBILE_MENU: + return { + ...state, + enableMobileMenu: action.enableMobileMenu + } + + case SET_ENABLE_MOBILE_MENU_SMALL: + return { + ...state, + enableMobileMenuSmall: action.enableMobileMenuSmall + } + + case SET_ENABLE_CLOSED_SIDEBAR: + return { + ...state, + enableClosedSidebar: action.enableClosedSidebar + } + + case SET_ENABLE_FIXED_FOOTER: + return { + ...state, + enableFixedFooter: action.enableFixedFooter + } + + case SET_BACKGROUND_COLOR: + return { + ...state, + backgroundColor: action.backgroundColor + } + + case SET_HEADER_BACKGROUND_COLOR: + return { + ...state, + headerBackgroundColor: action.headerBackgroundColor + } + + case SET_COLOR_SCHEME: + return { + ...state, + colorScheme: action.colorScheme + } + + case SET_BACKGROUND_IMAGE: + return { + ...state, + backgroundImage: action.backgroundImage + } + + case SET_BACKGROUND_IMAGE_OPACITY: + return { + ...state, + backgroundImageOpacity: action.backgroundImageOpacity + } + default: + } + return state +} diff --git a/frontend/app/redux/modules/common/alerts.js b/frontend/app/redux/modules/common/alerts.js new file mode 100644 index 0000000..87b89e8 --- /dev/null +++ b/frontend/app/redux/modules/common/alerts.js @@ -0,0 +1,122 @@ +import React, { Fragment } from 'react'; +import ReduxModule from '../abstract/reduxModule'; +import { toast } from 'react-toastify'; +import i18n from '../../../i18n'; +import { fromJS } from 'immutable'; +import { isLive } from '../../../common/constants'; + +const ADD_ALERT = 'Add alert'; +const REMOVE_ALERT = 'Remove alert'; +const REMOVE_ALL_ALERTS = 'Remove alert'; + +export class Alerts extends ReduxModule { + getNamespace() { + return '[Alert]'; + } + + defineActions() { + const addAlert = this.createAction(ADD_ALERT, (options) => options); + const removeAlert = this.createAction(REMOVE_ALERT, (id) => id); + const removeAllAlerts = this.createAction(REMOVE_ALL_ALERTS, () => {}); + + return { + addAlert, + removeAlert, + removeAllAlerts + }; + } + + getInitialState() { + return []; + } + + defineReducers() { + return { + [ADD_ALERT]: (state, { payload: options }) => { + showAlert(options); + + // handling breaking api errors (should be catch by backend) + const newOptions = Array.isArray(options) + ? options.map((v) => + typeof v === 'string' && v.startsWith('Error: ') && isLive + ? i18n.t('common:alerts.error.somethingWrong2') + : v + ) + : options; + + return state.concat(newOptions); + }, + [REMOVE_ALERT]: (state, { payload: id }) => { + return state.filter((alert) => alert.id !== id); + }, + [REMOVE_ALL_ALERTS]: () => { + return fromJS([]); + } + }; + } +} + +const alerts = new Alerts(); +alerts.init(); + +const backendErrs = ['Error: ', 'Can\'t exec search']; + +const showAlert = (alertMessages) => { + const alertsArr = Array.isArray(alertMessages) + ? alertMessages + : [alertMessages]; + + alertsArr + .map((alert) => { + return typeof alert === 'string' + ? { + message: + isLive && backendErrs.some((v) => alert.startsWith(v)) + ? i18n.t('common:alerts.error.somethingWrong2') + : alert + } // handling breaking api errors (should be catch by backend) + : alert; + }) + .map((alert) => { + const interpolateParameters = alert ? alert.parameters : {}; + const i18nKey = alert && `alerts.${alert.type}.${alert.transKey}`; + + if (alert) { + toast( + + {alert.type ? ( +

    + {i18n.t(`alerts.type.${oldValueMapping[alert.type]}`, { + defaultValue: oldValueMapping[alert.type] + })} +

    + ) : ( + '' + )} + {(i18nKey && + i18n.t(i18nKey, { + ...interpolateParameters, + defaultValue: alert.message || 'Unknown error' + })) || + alert.message || + 'Unknown error'} +
    , + { + type: oldValueMapping[alert.type || 'warning'] + } + ); + } else { + toast.warn('Unknown error'); + } + }); +}; + +const oldValueMapping = { + notice: 'success', + warning: 'warning', + error: 'error' +}; + +export const addAlert = alerts.actions.addAlert; + +export default alerts; diff --git a/frontend/app/redux/modules/common/auth.js b/frontend/app/redux/modules/common/auth.js new file mode 100644 index 0000000..a71422f --- /dev/null +++ b/frontend/app/redux/modules/common/auth.js @@ -0,0 +1,172 @@ +import * as api from '../../../api/loginApi'; +import $ from 'jquery'; +import reduxModule from '../abstract/reduxModule'; +import { tokenInject } from '../../utils/common'; +import { addAlert } from './alerts'; +import Cookies from 'cookies-js'; +import axios from 'axios'; +// import { TOGGLE_UPGRADE_PLAN } from './base'; + +const ACTIONS = { + PENDING: 'Login pending', + SAVE_USER_DATA: 'Save user data', + SET_FORM_ERROR: 'Set form error', + SET_RESTRICTIONS: 'Set user restrictions' +}; + +export const AuthNS = '[Auth]'; +export const USER_LOGOUT = 'Logout user'; +const REFRESH_TOKEN = 'refreshToken'; + +class Auth extends reduxModule { + getNamespace() { + return AuthNS; + } + + getInitialState() { + return { + form: { + error: '' + }, + isAuthPending: true, + token: '', + refreshToken: '', + user: {}, + userSubscription: '15d', + userSubscriptionDate: '2017-03-01' + }; + } + + saveRefreshToken(refreshToken, rememberMe) { + if (rememberMe) { + localStorage.setItem(REFRESH_TOKEN, refreshToken); + } else { + Cookies.set(REFRESH_TOKEN, refreshToken); + } + } + + clearRefreshToken() { + localStorage.removeItem(REFRESH_TOKEN); + delete axios.defaults.headers.common['Authorization']; + Cookies.expire(REFRESH_TOKEN); + } + + getRefreshToken() { + return Cookies.get(REFRESH_TOKEN) || localStorage.getItem(REFRESH_TOKEN); + } + + loginRequest(dispatch, promise, rememberMe) { + dispatch(this.loginPending(true)); + return promise + .then((data) => { + const { token, refreshToken, user } = data; + axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; // to call api with axios + this.saveRefreshToken(data.refreshToken, rememberMe); + dispatch(this.saveUserData({ token, refreshToken, user })); + dispatch(this.loginPending(false)); + dispatch(this.authSetError('')); + // history.replace(location); //rerun auth guards for routes + }) + .catch((error) => { + dispatch(this.authSetError(error.msg)); + dispatch(this.loginPending(false)); + delete axios.defaults.headers.common['Authorization']; + // history.replace(location); //rerun auth guards for routes + }); + } + + refreshLogin = () => { + return (dispatch) => { + const refreshToken = this.getRefreshToken(); + if (refreshToken) { + this.loginRequest(dispatch, api.loginRefresh(refreshToken)); + } else { + dispatch(this.loginPending(false)); + // history.replace(location); //rerun auth guards for routes + } + }; + }; + + login = (email, password, rememberMe) => { + return (dispatch) => { + const validateEmail = /^(([^<>()[\]\\.,;:\s@]+(\.[^<>()[\]\\.,;:\s@]+)*)|(.+))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + const isEmailValid = validateEmail.test(email); + if (isEmailValid) { + this.loginRequest(dispatch, api.login({ email, password }), rememberMe); + } else { + dispatch(this.loginPending(false)); + dispatch(this.authSetError('Please enter valid email address')); + } + }; + }; + + logout = () => { + return (dispatch) => { + this.clearRefreshToken(); + // dispatch(this.saveUserData({ token: '' })); + dispatch(this.userLogout(true)); + dispatch(this.loginPending(false)); + // history.push('/auth'); + }; + }; + + getRestrictions = () => + tokenInject((dispatch, getState, token) => { + api + .getRestrictions(token) + .then((data) => { + dispatch(this.setRestrictions(data)); + }) + .catch((errors) => { + dispatch(addAlert(errors)); + }); + }); + + handleErrors = () => (dispatch) => { + $(document).ajaxError((event, jqXHR, settings, thrownError) => { + if (jqXHR.status === 402) { + const response = jqXHR.responseJSON; + const failedRestriction = response.failedRestriction; + const restrictions = response.restrictions; + const limit = restrictions.limits[failedRestriction]; + if (limit) { + dispatch(this.setRestrictions(restrictions)); + // dispatch({ type: `[Base] ${TOGGLE_UPGRADE_PLAN}`, payload: true }); // uncomment when upgrade page is ready + dispatch( + addAlert({ + type: 'error', + transKey: 'restriction', + id: 'restriction' + }) + ); + } + } + }); + }; + + defineActions() { + this.loginPending = this.set(ACTIONS.PENDING, 'isAuthPending'); + this.saveUserData = this.merge(ACTIONS.SAVE_USER_DATA); + this.authSetError = this.setIn(ACTIONS.SET_FORM_ERROR, ['form', 'error']); + this.setRestrictions = this.mergeIn(ACTIONS.SET_RESTRICTIONS, [ + 'user', + 'restrictions' + ]); + this.userLogout = this.set(USER_LOGOUT, 'userLogout'); + + return { + login: this.login, + logout: this.logout, + refreshLogin: this.refreshLogin, + authSetError: this.authSetError, + handleErrors: this.handleErrors, + getRestrictions: this.getRestrictions + }; + } +} + +const auth = new Auth(); +auth.init(); + +export const getRestrictions = auth.actions.getRestrictions; +export default auth; diff --git a/frontend/app/redux/modules/common/base.js b/frontend/app/redux/modules/common/base.js new file mode 100644 index 0000000..c0333d9 --- /dev/null +++ b/frontend/app/redux/modules/common/base.js @@ -0,0 +1,157 @@ +import reduxModule from '../abstract/reduxModule'; +import dashboards, { LOAD_DASHBOARDS } from '../appState/dashboards'; +import * as api from '../../../api/usersApi'; + +export const TOGGLE_UPGRADE_PLAN = 'TOGGLE_UPGRADE_PLAN'; + +export class Base extends reduxModule { + getNamespace() { + return '[Base]'; + } + + changeUserPassword = (password, oldPassword, { token, dispatch }) => { + dispatch(this.setSettingsPopupError(null)); + return api + .changePassword(token, { password, oldPassword }) + .then(() => { + dispatch(this.hideUserSettingsPopup()); + }) + .catch((response) => { + dispatch(this.setSettingsPopupError(response[0].message)); + }); + }; + + defineActions() { + // const toggleLangsDrop = this.toggle('TOGGLE_LANGS_DROP', 'isLangsDropVisible') + // const hideLangsDrop = this.reset('HIDE_LANGS_DROP', 'isLangsDropVisible', false) + // const toggleSidebar = this.toggle('TOGGLE_SIDEBAR', 'isSidebarCollapsed') + const setDashboardTabs = this.createAction('SET_DASHBOARD_TABS'); + const chooseLanguage = this.createAction('CHOOSE_LANG'); + const showUserSettingsPopup = this.reset( + 'SHOW_USER_SETTINGS_DROP', + 'isSettingsPopupVisible', + true + ); + const hideUserSettingsPopup = (this.hideUserSettingsPopup = this.reset( + 'HIDE_USER_SETTINGS_DROP', + 'isSettingsPopupVisible', + false + )); + const changeUserPassword = this.thunkAction( + 'CHANGE_USER_PASSWORD', + this.changeUserPassword + ); + const setSettingsPopupError = (this.setSettingsPopupError = this.set( + 'SET_SETTINGS_POPUP_ERROR', + 'settingsPopupError' + )); + const toggleResponsiveMenu = this.toggle( + 'TOGGLE_RESPONSIVE_MENU', + 'responsiveMenuVisible' + ); + const toggleUpgradeModal = this.toggle( + TOGGLE_UPGRADE_PLAN, + 'isUpgradeVisible' + ); + const toggleWebTour = this.toggle('TOGGLE_WEBTOUR', 'isTourOpen'); + + return { + // toggleLangsDrop, + chooseLanguage, + // hideLangsDrop, + // toggleSidebar, + setDashboardTabs, + showUserSettingsPopup, + hideUserSettingsPopup, + changeUserPassword, + setSettingsPopupError, + toggleResponsiveMenu, + toggleUpgradeModal, + toggleWebTour + }; + } + + getInitialState() { + return { + tabs: { + /*'dashboard': { + items: [] + },*/ + search: { + items: [ + { title: 'search', url: 'search' }, + { title: 'sourceIndex', url: 'source-index' }, + { title: 'sourceLists', url: 'source-lists' } + ], + icon: 'pe-7s-search' + }, + analyze: { + items: [ + // {title: 'welcome', url: 'welcome'}, + { title: 'savedAnalysis', url: 'saved' }, + { title: 'createAnalysis', url: 'create' } + ], + icon: 'pe-7s-graph1' + }, + share: { + items: [ + { title: 'notifications', url: 'notifications' }, + { title: 'manageEmails', url: 'manage-emails', masterOnly: true }, + { + title: 'manageRecipients', + url: 'manage-recipients', + masterOnly: true + }, + { title: 'export', url: 'export' } + ], + icon: 'pe-7s-share' + } + }, + // isSidebarCollapsed: false, + isUserSettingsDropVisible: false, + // isLangsDropVisible: false, + isSettingsPopupVisible: false, + settingsPopupError: '', + isThereSomethingNew: true, + langs: ['en', 'ar', 'fr'], + // langs: ['en', 'ar', 'fr', 'es', 'de', , 'he', 'nl', 'pt'], + activeLang: '', + rtlLang: false, + responsiveMenuVisible: false, + isUpgradeVisible: false + }; + } + + defineReducers() { + this.addExternalReducer( + dashboards.ns(`${LOAD_DASHBOARDS} fulfilled`), + (state, { payload: dashboards }) => { + const dashboardTabs = dashboards.map((d) => ({ + url: d.id, + title: d.name + })); + return state.mergeIn(['tabs', 'dashboard', 'items'], dashboardTabs); + } + ); + + return { + CHOOSE_LANG: (state, { payload: lang }) => { + const langsAvailable = state.get('langs'); + const language = langsAvailable.includes(lang) ? lang : 'en'; + const rtlLanguages = ['ar', 'he']; + const rtlLang = rtlLanguages.includes(language); + const dir = rtlLang ? 'rtl' : 'ltr'; + document.documentElement.dir = dir; // set page direction + document.documentElement.lang = language; + return state.merge({ + activeLang: language, + rtlLang: rtlLang + }); + } + }; + } +} + +const instance = new Base(); +instance.init(); +export default instance; diff --git a/frontend/app/redux/modules/common/register.js b/frontend/app/redux/modules/common/register.js new file mode 100644 index 0000000..245fa29 --- /dev/null +++ b/frontend/app/redux/modules/common/register.js @@ -0,0 +1,168 @@ +import {createAction, handleActions} from 'redux-actions' +import {fromJS} from 'immutable' +import {thunkAction} from '../../utils/common' +import * as api from '../../../api/registrationApi' +import {push} from 'react-router-redux' +import { addAlert } from './alerts' +import i18n from '../../../i18n' +const NS = '[RESET PASS]' +/* const GET_BILLING_PLANS = `${NS} Get billing plans` +const SELECT_BILLING_PLAN = `${NS} Select billing plan` +const SEND_REGISTER_REQUEST = `${NS} Send register request` +const FINISH_REGISTER = `${NS} Finish register` +const SHOW_REGISTER_ERROR = `${NS} Show register error` */ +const REQUEST_PASSWORD_RESET = `${NS} Request password reset` +const CONFIRM_PASSWORD_RESET = `${NS} Confirm password reset` +const CLEAR_MESSAGES = `${NS} Clear messages` +/* +const getBillingPlans = thunkAction(GET_BILLING_PLANS, ({token, fulfilled}) => { + return api + .getBillingPlans(token) + .then((plans) => { + fulfilled(plans) + }) +}, true) + +const selectBillingPlan = createAction(SELECT_BILLING_PLAN, (billingPlan) => ({billingPlan})) + +const showRegisterError = createAction(SHOW_REGISTER_ERROR) + +const sendRegisterRequest = thunkAction(SEND_REGISTER_REQUEST, (formValues, {token, fulfilled, getState, dispatch}) => { + + const billingPlan = getState().getIn(['common', 'register', 'selectedBillingPlan']) + const privatePerson = Boolean(formValues.privatePerson) + + let payload = {} + Object.assign(payload, formValues, { + billingPlanId: billingPlan.id, + privatePerson: privatePerson + }) + + if (privatePerson) { + delete payload.organizationName + delete payload.organizationAddress + delete payload.organizationEmail + delete payload.organizationPhone + } + + return api + .sendRegistrationRequest(token, payload) + .then((response) => { + fulfilled(response) + dispatch(showRegisterError(null)) + dispatch(push('/auth/register-finish')) + }) + .catch((response) => { + dispatch(showRegisterError(response[0])) + throw response + }) + +}, true) + +const finishRegistration = thunkAction(FINISH_REGISTER, (formValues, {getState, token, fulfilled}) => { + let verificationCode = getState().getIn(['common', 'register', 'registrationCode']) + const payload = Object.assign({}, { + code: verificationCode, + card: { + creditCardNumber: formValues.creditCardNumber, + CVV: formValues.CVV, + expireMonth: formValues.expireMonth, + expireYear: formValues.expireYear, + address: { + country: formValues.country, + city: formValues.city, + street: formValues.street, + postalCode: formValues.postalCode + } + } + }) + return api + .finishRegistration(token, payload) + .then((response) => { + fulfilled(response) + }) +}, true) */ + +const requestPasswordReset = thunkAction(REQUEST_PASSWORD_RESET, (email, {fulfilled}) => { + return api + .requestPasswordReset(null, {email}) + .then(() => { + fulfilled( + i18n.t('loginApp:messages.forgotPasswordSubmit', { email: email }) + ); + }) +}) + +const confirmPasswordReset = thunkAction(REQUEST_PASSWORD_RESET, (confirmationToken, password, {dispatch}) => { + return api + .confirmPasswordReset(null, {confirmationToken, password}) + .then(() => { + dispatch(push('/auth/login')) + dispatch(addAlert({type: 'notice', message: i18n.t('loginApp:messages.passwordUpdated')})) + }) +}) + +const clearMessages = createAction(CLEAR_MESSAGES) + +export const actions = { + /* getBillingPlans, + selectBillingPlan, + sendRegisterRequest, + finishRegistration, */ + requestPasswordReset, + confirmPasswordReset, + // showRegisterError, + clearMessages +} + +export const initialState = fromJS({ + selectedBillingPlan: '', + billingPlans: [], + isLoading: false, + error: null, + registrationCode: null, + successMessage: null +}) +/* +const toggleLoading = (state, {payload: {isPending}}) => { + return state.set('isLoading', isPending) +} */ + +export default handleActions({ +/* + [`${GET_BILLING_PLANS} fulfilled`]: (state, {payload: plans}) => state.set('billingPlans', plans), + + [SELECT_BILLING_PLAN]: (state, {payload: {billingPlan}}) => { + console.log(billingPlan) + return state.set('selectedBillingPlan', billingPlan) + }, + + [`${SEND_REGISTER_REQUEST} pending`]: toggleLoading, + [`${SEND_REGISTER_REQUEST} fulfilled`]: (state, {payload: response}) => { + return state.merge({ + 'registrationCode': response.code, + 'successMessage': response.message + }) + }, + + [`${FINISH_REGISTER} pending`]: toggleLoading, + [`${FINISH_REGISTER} fulfilled`]: (state, {payload: response}) => { + return state.set('successMessage', response.message) + }, */ + [`${REQUEST_PASSWORD_RESET} fulfilled`]: (state, {payload: message}) => { + return state.set('successMessage', message) + }, + [`${CONFIRM_PASSWORD_RESET} fulfilled`]: (state, {payload: message}) => { + return state.set('successMessage', message) + }, + [CLEAR_MESSAGES]: (state) => { + return state.set('successMessage', null) + } +/* + [SHOW_REGISTER_ERROR]: (state, {payload: error}) => { + return state.set('error', error) + }, + + [`${GET_BILLING_PLANS} pending`]: toggleLoading */ + +}, initialState) diff --git a/frontend/app/redux/modules/routing/routing.js b/frontend/app/redux/modules/routing/routing.js new file mode 100644 index 0000000..6bb145c --- /dev/null +++ b/frontend/app/redux/modules/routing/routing.js @@ -0,0 +1,13 @@ +import { handleActions } from 'redux-actions' +import { fromJS } from 'immutable' +import { LOCATION_CHANGE } from 'react-router-redux' + +export const initialState = fromJS({ + locationBeforeTransitions: null +}) + +export default handleActions({ + [LOCATION_CHANGE]: (state, {payload}) => { + return state.set('locationBeforeTransitions', payload) + } +}, initialState) diff --git a/frontend/app/redux/root.js b/frontend/app/redux/root.js new file mode 100644 index 0000000..af2a0ba --- /dev/null +++ b/frontend/app/redux/root.js @@ -0,0 +1,160 @@ +import { combineReducers } from 'redux-immutable'; + +// App state and actions +import register, { + actions as registerActions +} from './modules/common/register'; +import routing from './modules/routing/routing'; + +import sidebar, { actions as sidebarActions } from './modules/appState/sidebar'; +import search, { actions as searchActions } from './modules/appState/search'; +import searchByFilters, { + actions as searchByFiltersActions +} from './modules/appState/searchByFilters'; +import sourcesState, { + actions as sourcesStateActions +} from './modules/appState/sourcesState'; +import articles, { + actions as articleActions +} from './modules/appState/articles'; +import shareTabs, { + actions as shareTabsActions, + NOTIFICATION_TABLES, + RECEIVER_TABLES, + RECIPIENT_FORM_TABLES, + GROUP_FORM_TABLES, + NOTIFICATION_SUBSCREENS, + RECEIVER_SUBSCREENS +} from './modules/appState/share/tabs'; +import { actions as shareFormsCommonActions } from './modules/appState/share/shareForms'; +import exportFeeds, { + actions as exportFeedsActions +} from './modules/appState/share/exportFeeds'; +import ThemeOptions, { themeActions } from './modules/appState/themeOptions'; + +//inherited from reduxModule +import auth, { AuthNS, USER_LOGOUT } from './modules/common/auth'; +import base from './modules/common/base'; +import alerts from './modules/common/alerts'; + +import dashboards from './modules/appState/dashboards'; + +import myEmailsTable from './modules/appState/share/tables/myEmailsTable'; +import publishedEmailsTable from './modules/appState/share/tables/publishedEmailsTable'; +import recipientsTable from './modules/appState/share/tables/recipientsTable'; +import groupsTable from './modules/appState/share/tables/groupsTable'; +import emailHistoryTable from './modules/appState/share/tables/receiverForm/emailHistoryTable'; +import receiverSubscriptionsTable from './modules/appState/share/tables/receiverForm/receiverSubscriptionsTable'; +import receiverGroupsTable from './modules/appState/share/tables/receiverForm/receiverGroupsTable'; +import receiverRecipientsTable from './modules/appState/share/tables/receiverForm/receiverRecipientsTable'; +import emailsTable from './modules/appState/share/tables/emailsTable'; +import emailFiltersTable from './modules/appState/share/tables/emailFiltersTable'; + +import alertForm from './modules/appState/share/forms/alertForm'; +import newsletterForm from './modules/appState/share/forms/newsletterForm'; +import recipientForm from './modules/appState/share/forms/recipientForm'; +import groupForm from './modules/appState/share/forms/groupForm'; + +import themes from './modules/appState/share/emailThemes/themes'; +import analyze, { analyzeActions } from './modules/appState/analyze/analyze'; + +const shareTables = combineReducers({ + [NOTIFICATION_TABLES.MY_EMAILS]: myEmailsTable.reducers, + [NOTIFICATION_TABLES.PUBLISHED]: publishedEmailsTable.reducers, + [RECEIVER_TABLES.RECIPIENTS]: recipientsTable.reducers, + [RECEIVER_TABLES.GROUPS]: groupsTable.reducers, + emails: emailsTable.reducers, + emailFilters: emailFiltersTable.reducers, + receiverForm: combineReducers({ + [RECIPIENT_FORM_TABLES.GROUPS]: receiverGroupsTable.reducers, + [RECIPIENT_FORM_TABLES.SUBSCRIPTIONS]: receiverSubscriptionsTable.reducers, + [RECIPIENT_FORM_TABLES.EMAIL_HISTORY]: emailHistoryTable.reducers, + [GROUP_FORM_TABLES.RECIPIENTS]: receiverRecipientsTable.reducers + }) +}); + +const shareForms = combineReducers({ + [NOTIFICATION_SUBSCREENS.ALERT_FORM]: alertForm.reducers, + [NOTIFICATION_SUBSCREENS.NEWSLETTER_FORM]: newsletterForm.reducers, + [RECEIVER_SUBSCREENS.RECIPIENT_FORM]: recipientForm.reducers, + [RECEIVER_SUBSCREENS.GROUP_FORM]: groupForm.reducers +}); + +const appReducers = combineReducers({ + routing, + common: combineReducers({ + base: base.reducers, + auth: auth.reducers, + alerts: alerts.reducers, + register + }), + appState: combineReducers({ + sidebar, + search, + searchByFilters, + sourcesState, + analyze, + articles, + dashboards: dashboards.reducers, + themeOptions: ThemeOptions, + share: combineReducers({ + tabs: shareTabs, + forms: shareForms, + tables: shareTables, + themes: themes.reducers, + exportFeeds + }) + }) +}); + +export function rootReducers(state, action) { + if (action.type === `${AuthNS} ${USER_LOGOUT}`) { + state = undefined; // to clear state when logout + } + + return appReducers(state, action); +} + +export const shareFormsActions = { + alert: alertForm.actions, + newsletter: alertForm.actions, + recipient: recipientForm.actions, + group: groupForm.actions +}; + +export const shareTablesActions = { + [NOTIFICATION_TABLES.MY_EMAILS]: myEmailsTable.actions, + [NOTIFICATION_TABLES.PUBLISHED]: publishedEmailsTable.actions, + [RECEIVER_TABLES.RECIPIENTS]: recipientsTable.actions, + [RECEIVER_TABLES.GROUPS]: groupsTable.actions, + emails: emailsTable.actions, + emailFilters: emailFiltersTable.actions, + receiverForm: { + [RECIPIENT_FORM_TABLES.GROUPS]: receiverGroupsTable.actions, + [RECIPIENT_FORM_TABLES.SUBSCRIPTIONS]: receiverSubscriptionsTable.actions, + [RECIPIENT_FORM_TABLES.EMAIL_HISTORY]: emailHistoryTable.actions, + [GROUP_FORM_TABLES.RECIPIENTS]: receiverRecipientsTable.actions + } +}; + +export const rootActions = Object.assign( + {}, + auth.actions, + alerts.actions, + base.actions, + registerActions, + sidebarActions, + searchActions, + analyzeActions, + searchByFiltersActions, + sourcesStateActions, + shareTabsActions, + shareFormsCommonActions, + themes.actions, + themeActions, + articleActions, + exportFeedsActions, + dashboards.actions, + { shareTables: shareTablesActions }, + { shareForms: shareFormsActions } +); diff --git a/frontend/app/redux/utils/DevTools.js b/frontend/app/redux/utils/DevTools.js new file mode 100644 index 0000000..b7d9828 --- /dev/null +++ b/frontend/app/redux/utils/DevTools.js @@ -0,0 +1,13 @@ +import React from 'react' +import { createDevTools } from 'redux-devtools' +import LogMonitor from 'redux-devtools-log-monitor' +import DockMonitor from 'redux-devtools-dock-monitor' + +export default createDevTools( + + + +) diff --git a/frontend/app/redux/utils/common.js b/frontend/app/redux/utils/common.js new file mode 100644 index 0000000..a6ade6c --- /dev/null +++ b/frontend/app/redux/utils/common.js @@ -0,0 +1,46 @@ +import {createAction} from 'redux-actions' +import {addAlert} from '../modules/common/alerts' + +export const tokenInject = (fn) => + (dispatch, getState) => + fn(dispatch, getState, getState().getIn(['common', 'auth', 'token'])) + +export const thunkAction = (actionName, actionMethod, emitPending = false, customPendingAction = false) => { + const fulfilledAction = createAction(`${actionName} fulfilled`) + const pendingAction = customPendingAction || + createAction(`${actionName} pending`, (isPending, success) => ({isPending, success})) + + return (...args) => { + return tokenInject((dispatch, getState, token) => { + + const fulfilled = (...fArgs) => { + dispatch(fulfilledAction(...fArgs)) + emitPending && dispatch(pendingAction(false, true)) + } + + const onError = (errors) => { + dispatch(addAlert(errors)) + emitPending && dispatch(pendingAction(false, false)) + } + + emitPending && dispatch(pendingAction(true)) + + let result + try { + result = actionMethod(...args, {dispatch, getState, token, fulfilled}) + } catch (e) { + console.error('Error in thunkAction()') + console.error(e) + throw e + } + if (result instanceof Promise) { + result.catch(onError) + } + return result + }) + } +} + +export const routerSelectLocationState = (state) => { + return state.get('routing').toJS() +} diff --git a/frontend/app/redux/utils/connect.js b/frontend/app/redux/utils/connect.js new file mode 100644 index 0000000..9f39bcb --- /dev/null +++ b/frontend/app/redux/utils/connect.js @@ -0,0 +1,48 @@ +import { connect } from 'react-redux' +import { rootActions } from '../root' + +//This is recursive version of standard redux bindActionCreators +function bindActionCreatorsRecursive (actions, dispatch) { + if (typeof dispatch !== 'function') { + throw new TypeError('Action wrapper needs a dispatch function') + } + return Object.keys(actions).reduce(function (acc, key) { + if (typeof actions[key] === 'function') { + acc[key] = function () { + return dispatch(actions[key].apply(null, arguments)) + } + } else if (actions[key] !== null && typeof actions[key] === 'object') { + acc[key] = bindActionCreatorsRecursive(actions[key], dispatch) + } + + return acc + }, {}) +} + +export default function reduxConnect (storePropName = 'store', storePath) { + return (component) => { + + const mapStateToProps = (state) => ({ + [storePropName]: storePath ? state.getIn(storePath).toJS() : state.toJS() + }) + + const mapDispatchToProps = (dispatch) => ({ + actions: bindActionCreatorsRecursive(rootActions, dispatch) + }) + + return connect(mapStateToProps, mapDispatchToProps)(component) + } + +} + +export function reduxActions () { + return (component) => { + const mapDispatchToProps = (dispatch) => ({ + actions: bindActionCreatorsRecursive(rootActions, dispatch) + }) + + return connect(null, mapDispatchToProps)(component) + } + +} + diff --git a/frontend/app/redux/utils/helpers/advancedFilters.js b/frontend/app/redux/utils/helpers/advancedFilters.js new file mode 100644 index 0000000..9f826ef --- /dev/null +++ b/frontend/app/redux/utils/helpers/advancedFilters.js @@ -0,0 +1,12 @@ +export const ADV_FILTERS_LIMIT = 7 //for advanced filters client-side paging + +export const filtersFromServerFormat = function (advancedFilters) { + const allFilters = {} + const pages = {} + for (let groupName in advancedFilters) { + let filters = advancedFilters[groupName].data + allFilters[groupName] = filters + pages[groupName] = {count: ADV_FILTERS_LIMIT, totalCount: filters.length} + } + return {pages, allFilters} +} diff --git a/frontend/app/redux/utils/helpers/search.js b/frontend/app/redux/utils/helpers/search.js new file mode 100644 index 0000000..d94ac32 --- /dev/null +++ b/frontend/app/redux/utils/helpers/search.js @@ -0,0 +1,84 @@ +import {fromJS} from 'immutable' +import {ADV_FILTERS_LIMIT} from '../../modules/appState/search' +/** + * Commenting helpers + */ + +export const indexById = (arr, id) => arr.findIndex((a) => a.id === id) + +const changeArticleComments = (fn) => { + return (articles, articleId, comment) => { + let result = articles.toJS() + const articleIndex = indexById(result, articleId) + if (articleIndex !== -1) { + fn(result[articleIndex].comments, comment) + } else { + console.error(`search.js - cannot find article with id ${articleId}`) + } + return fromJS(result) + } +} + +export const loadMoreComments = changeArticleComments((comments, newComments) => { + comments.data = comments.data.concat(newComments) + comments.count += newComments.length +}) + +export const addComment = changeArticleComments((comments, comment) => { + comments.data.unshift(comment) + comments.count++ + comments.totalCount++ +}) + +export const updateComment = changeArticleComments((comments, comment) => { + const commentIndex = indexById(comments.data, comment.id) + if (commentIndex !== -1) { + comments.data[commentIndex] = comment + } else { + console.error(`search.js::updateComment() cannot find comment with id ${comment.id}`) + } +}) + +export const deleteComment = changeArticleComments((comments, commentId) => { + const commentIndex = indexById(comments.data, commentId) + if (commentIndex !== -1) { + comments.data.splice(commentIndex, 1) + comments.count-- + comments.totalCount-- + } else { + console.error(`search.js::deleteComment() cannot find comment with id ${commentId}`) + } +}) + +//End commenting helpers + +//Ensure that "selectedFilters" is always in "allFilters" +//allFilters = {groupName: [{value: "", count: ""}] } +//selectedFilters = {groupName: {"value": "count"}} +export const mergeAdvancedFilters = (allFilters, selectedFilters, pages) => { + + const _insertFilter = (groupName, value, count) => { + let group = allFilters[groupName] || (allFilters[groupName] = []) + + const found = group.find((item) => item.value === value) + if (!found) { + group.unshift({value, count}) + let pageGroup = pages[groupName] || (pages[groupName] = {count: ADV_FILTERS_LIMIT, totalCount: 0}) + pageGroup.totalCount++ + } + } + + for (let groupName in selectedFilters) { + if (selectedFilters.hasOwnProperty(groupName)) { + let group = selectedFilters[groupName] + + for (let value in group) { + if (group.hasOwnProperty(value)) { + _insertFilter(groupName, value, group[value]) + } + } + + } + } + +} diff --git a/frontend/app/redux/utils/helpers/sidebar.js b/frontend/app/redux/utils/helpers/sidebar.js new file mode 100644 index 0000000..49f36ea --- /dev/null +++ b/frontend/app/redux/utils/helpers/sidebar.js @@ -0,0 +1,107 @@ +import {fromJS} from 'immutable' + +const categoriesFromState = (fn) => { + return (state, ...args) => { + const categories = state.getIn(['appState', 'sidebar', 'categories']).toJS() + return fromJS(fn(categories, ...args)) + } +} + +const walkCategories = (fn) => { + return (categories, ...args) => { + const walker = (array) => { + return array.map((category) => { + category.childes = walker(category.childes) + fn(category, ...args) + return category + }) + } + return walker(categories) + } +} + +const feedHelper = (fn) => categoriesFromState(walkCategories(fn)) + +//return categories without deleted feed +export const deleteFeed = feedHelper((category, feedCategoryId, feedId) => { + if (category.id === feedCategoryId) { + category.feeds = category.feeds.filter((feed) => feed.id !== feedId) + } +}) + +export const addFeed = feedHelper((category, feedCategoryId, feed) => { + if (category.id === feedCategoryId) { + category.feeds.push(feed) + } +}) + +export const renameFeed = feedHelper((category, feedId, newFeedName, feedCategoryId) => { + if (category.id === feedCategoryId) { + category.feeds = category.feeds.map((feed) => { + if (feed.id === feedId) { + feed.name = newFeedName + } + return feed + }) + } +}) + +const _deleteCategory = (categories, categoryId) => { + return categories.map((category) => { + if (category.childes.length > 0) { + const initChildesLength = category.childes.length + category.childes = category.childes.filter((childCategory) => { + return childCategory.id !== categoryId + }) + const childesLengthAfterFilter = category.childes.length + // if there wasn't deletion continue to search + if (childesLengthAfterFilter === initChildesLength) { + category.childes = _deleteCategory(category.childes, categoryId) + } + } + return category + }) +} + +//return categories without deleted category +export const deleteCategory = categoriesFromState(_deleteCategory) + +export const addCategory = feedHelper((category, parentId, newCategory) => { + if (category.id === parentId) { + category.childes.push(newCategory) + } +}) + +export const renameCategory = feedHelper((category, categoryId, newCategoryName) => { + if (category.id === categoryId) { + category.name = newCategoryName + } +}) + +export const checkIfDraggedCategoryDragToItsChild = (categories, newCategoryId) => { + return categories.find((category) => { + if (category.childes.length > 0) { + checkIfDraggedCategoryDragToItsChild(category.childes, newCategoryId) + } + + return category.id === newCategoryId + }) +} + +export const findFeedById = (categories, feedId) => { + let feed = null + categories.forEach((category) => { + if (!feed && category.feeds.length > 0) { + const findResult = category.feeds.filter(feed => feed.id === feedId) + feed = findResult[0] || null + if (feed) { + feed.category = category.id + } + + if (!feed && category.childes.length > 0) { + feed = findFeedById(category.childes, feedId) + } + } + }) + return feed +} diff --git a/frontend/app/routing/AppRoutes.js b/frontend/app/routing/AppRoutes.js new file mode 100644 index 0000000..4a7b7df --- /dev/null +++ b/frontend/app/routing/AppRoutes.js @@ -0,0 +1,81 @@ +import React, { useEffect } from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import reduxConnect from '../redux/utils/connect'; +import LoadersAdvanced from '../components/common/Loader/Loader'; + +import i18n from '../i18n'; +import { Slide, ToastContainer } from 'react-toastify'; +import UnauthenticatedRoute from './UnauthenticatedRoute'; +import AuthenticatedRoute from './AuthenticatedRoute'; +import usePageTracking from '../components/common/hooks/usePageTracking'; +import { getIP, setIP } from '../common/helper'; + +function AppRoutes(props) { + usePageTracking(); + const { + common: { auth, base } + } = props; + const authIsPending = auth.isAuthPending; + + useEffect(() => { + const { actions } = props; + actions.handleErrors(); + actions.refreshLogin(); + + if (!getIP()) { + setIP(); // set IP to use when submit HubSpot form + } + + //Set active language after load + const activeLang = i18n.language.slice(0, 2); + actions.chooseLanguage(activeLang); + }, []); + + return ( +
    + {authIsPending && } + + {!authIsPending && ( + + + + + + )} + + +
    + ); +} + +const CloseButton = ({ closeToast }) => ( + +); + +CloseButton.propTypes = { + closeToast: PropTypes.func.isRequired +}; + +AppRoutes.propTypes = { + common: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, + children: PropTypes.object +}; + +export default reduxConnect('common', ['common'])(AppRoutes); diff --git a/frontend/app/routing/AuthenticatedRoute.js b/frontend/app/routing/AuthenticatedRoute.js new file mode 100644 index 0000000..2121d47 --- /dev/null +++ b/frontend/app/routing/AuthenticatedRoute.js @@ -0,0 +1,94 @@ +import React, { Fragment, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { get } from 'lodash'; +import reduxConnect from '../redux/utils/connect'; +import { Route, Redirect, Switch } from 'react-router-dom'; +import App from '../components/App/App'; +import SearchTab from '../components/App/TabsContent/SearchTab/SearchTab'; +import ShareTab from '../components/App/TabsContent/ShareTab/ShareTab'; +import AnalyzeTab from '../components/App/TabsContent/AnalyzeNewTab/AnalyzeTab'; +import UserPlans from '../components/App/Account/Plans/UserPlans'; +import UpgradePlanModal from '../components/App/Account/Plans/UpgradePlanModal'; + +function AuthenticatedRoute(props) { + const { + common: { base, auth }, + match, + history, + actions + } = props; + const { isAuthPending, token: isLoggedIn, user } = auth; + + const isMaster = user && user.role === 'ROLE_MASTER_USER'; + const activeTab = match.params && match.params.activeTab; + + const allowAnalytics = get(user, [ + 'restrictions', + 'permissions', + 'analytics' + ]); + + useEffect(() => { + if (!isAuthPending && !isLoggedIn) { + history.push('/auth/login'); + return; + } + }, [isAuthPending, isLoggedIn]); + + const activeTabDetails = base.tabs[activeTab]; + let subTabs = activeTabDetails && activeTabDetails.items; + + if (subTabs) { + subTabs = subTabs.filter((tab) => { + return !tab.masterOnly || auth.user.role === 'ROLE_MASTER_USER'; + }); + } + + if (!isAuthPending && !isLoggedIn) { + return null; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +} + +AuthenticatedRoute.propTypes = { + actions: PropTypes.object.isRequired, + common: PropTypes.object.isRequired, + match: PropTypes.object.isRequired, + history: PropTypes.object.isRequired +}; + +export default reduxConnect('common', ['common'])(AuthenticatedRoute); diff --git a/frontend/app/routing/SiteScripts.js b/frontend/app/routing/SiteScripts.js new file mode 100644 index 0000000..62a6e9a --- /dev/null +++ b/frontend/app/routing/SiteScripts.js @@ -0,0 +1,38 @@ +import React, { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; +import { + fbPixelScript, + gtagScript, + gtagScriptURL, + hubspotTracking, + linkedInsightTag +} from '../common/scripts'; + +function SiteScripts() { + const [initHubSpot, setInitHubSpot] = useState(false); + + useEffect(() => { + /* Below pushes a setPath call into _hsq before the tracking code loads to set the URL that gets tracked for the first page view (the trackPageView function is called automatically when the tracking code is loaded). Subsequent calls are in usePageTracking.js + */ + const _hsq = (window._hsq = window._hsq || []); + _hsq.push(['setPath', window.location.pathname + window.location.search]); + setInitHubSpot(true); + }, []); + + return ( + + {gtagScriptURL} + {gtagScript} + + {fbPixelScript} + {/* noscript tag for fb pixel may not be required here */} + + {initHubSpot && hubspotTracking} + + {linkedInsightTag} + {/* noscript tag for insight tag may not be required here */} + + ); +} + +export default SiteScripts; diff --git a/frontend/app/routing/UnauthenticatedRoute.js b/frontend/app/routing/UnauthenticatedRoute.js new file mode 100644 index 0000000..4798a5a --- /dev/null +++ b/frontend/app/routing/UnauthenticatedRoute.js @@ -0,0 +1,53 @@ +import React, { useEffect } from 'react'; +import { Redirect, Switch, Route } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import ForgotPassword from '../components/LoginRegister/ForgotPassword'; +import Login from '../components/LoginRegister/Login'; +import ResetPassword from '../components/LoginRegister/ResetPassword'; +import reduxConnect from '../redux/utils/connect'; +// import Register from '../components/LoginRegister/Registration/Register'; +import RegisterSuccess from '../components/LoginRegister/Registration/RegisterSuccess'; +import RegisterConfirmEmail from '../components/LoginRegister/Registration/RegisterConfirmEmail'; +import RegisterFreeAccount from '../components/LoginRegister/Registration/RegisterFreeAccount'; +// import CostCalculator from '../components/LoginRegister/Registration/CostCalculator'; + +function UnauthenticatedRoute(props) { + const { auth, history } = props; + const { isAuthPending, token: isLoggedIn } = auth; + + useEffect(() => { + if (!isAuthPending && isLoggedIn) { + history.push('/app/search/search'); + return; + } + }, [isAuthPending, isLoggedIn]); + + if (!isAuthPending && isLoggedIn) { + return null; + } + + return ( + + + + {/* */} + + + + + {/* */} + + + ); +} + +UnauthenticatedRoute.propTypes = { + auth: PropTypes.object.isRequired, + match: PropTypes.object.isRequired, + history: PropTypes.object.isRequired +}; + +export default reduxConnect('auth', ['common', 'auth'])(UnauthenticatedRoute); diff --git a/frontend/app/static/.htaccess b/frontend/app/static/.htaccess new file mode 100644 index 0000000..a9f658b --- /dev/null +++ b/frontend/app/static/.htaccess @@ -0,0 +1,37 @@ +DirectoryIndex index.html + + + RewriteEngine On + + # Determine the RewriteBase automatically and set it as environment variable. + # If you are using Apache aliases to do mass virtual hosting or installed the + # project in a subdirectory, the base path will be prepended to allow proper + # resolution of the app.php file and to redirect to the correct URI. It will + # work in environments without path prefix as well, providing a safe, one-size + # fits all solution. But as you do not need it in this case, you can comment + # the following 2 lines to eliminate the overhead. + RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$ + RewriteRule ^(.*) - [E=BASE:%1] + + # Redirect to URI without front controller to prevent duplicate content + # (with and without `/app.php`). Only do this redirect on the initial + # rewrite by Apache and not on subsequent cycles. Otherwise we would get an + # endless redirect loop (request -> rewrite to front controller -> + # redirect -> request -> ...). + # So in case you get a "too many redirects" error or you always get redirected + # to the start page because your Apache does not expose the REDIRECT_STATUS + # environment variable, you have 2 choices: + # - disable this feature by commenting the following 2 lines or + # - use Apache >= 2.3.9 and replace all L flags by END flags and remove the + # following RewriteCond (best solution) + RewriteCond %{ENV:REDIRECT_STATUS} ^$ + RewriteRule ^index\.html(/(.*)|$) %{ENV:BASE}/$2 [R=301,L] + + # If the requested filename exists, simply serve it. + # We only want to let Apache serve files and not directories. + RewriteCond %{REQUEST_FILENAME} -f + RewriteRule .? - [L] + + # Rewrite all other queries to the front controller. + RewriteRule .? %{ENV:BASE}/index.html [L] + \ No newline at end of file diff --git a/frontend/app/static/favicon.ico b/frontend/app/static/favicon.ico new file mode 100644 index 0000000..ed134cc Binary files /dev/null and b/frontend/app/static/favicon.ico differ diff --git a/frontend/app/styles/applications/_applications-base.scss b/frontend/app/styles/applications/_applications-base.scss new file mode 100644 index 0000000..bd3ddc2 --- /dev/null +++ b/frontend/app/styles/applications/_applications-base.scss @@ -0,0 +1,144 @@ +// Applications + +$app-layout-sidebar-width : 270px; +$app-layout-aside-width : 60px; +$app-layout-header-height : 50px; + + + +.app-inner-layout { + + &.rm-sidebar { + .app-inner-layout__wrapper { + .app-inner-layout__content { + width: auto; + float: none; + } + } + } + + .app-inner-layout__header { + width: 100%; + padding: $layout-spacer-x; + text-align: start; + + .app-page-title { + margin: 0; + padding: 0; + background: transparent; + } + + border-bottom: $gray-200 solid 1px; + } + + .app-inner-layout__header-boxed { + padding: $layout-spacer-x; + + .app-inner-layout__header { + @include border-radius($border-radius-lg); + } + } + + .app-inner-layout__wrapper { + position: relative; + display: flex; + flex-direction: row; + min-height: 100vh; + + .app-inner-layout__content { + flex: 1; + display: flex; + + &.card { + box-shadow: 0 0 0 0 transparent; + border-radius: 0; + border: 0; + } + + .app-inner-layout__top-pane { + display: flex; + align-content: center; + align-items: center; + text-align: start; + padding: ($layout-spacer-x / 2) $layout-spacer-x; + } + + .pane-left { + display: flex; + align-items: center; + } + + .pane-right { + display: flex; + align-items: center; + margin-inline-start: auto; + } + + .app-inner-layout__bottom-pane { + padding: $layout-spacer-x; + border-top: $gray-200 solid 1px; + } + } + + .app-inner-layout__sidebar { + width: $app-layout-sidebar-width; + list-style: none; + text-align: start; + order: -1; + flex: 0 0 $app-layout-sidebar-width; + display: flex; + margin: 0; + position: relative; + + .dropdown-item { + white-space: normal; + } + + &.card { + box-shadow: 0 0 0 0 transparent; + border-radius: 0; + background: $gray-100; + border: 0; + border-right: $gray-200 solid 1px; + border-left: $gray-200 solid 1px; + } + + .app-inner-layout__sidebar-footer, + .app-inner-layout__sidebar-header { + background: $gray-100; + } + } + + .app-inner-layout__aside { + width: $app-layout-aside-width; + } + } + + .app-inner-layout__footer { + width: 100%; + height: $app-layout-header-height; + } +} + +.app-wrapper-footer { + .app-footer { + border-top: $gray-200 solid 1px; + + .app-footer__inner { + border-left: $gray-200 solid 1px; + } + } +} + +// Components + +@import "chat"; + +// Responsive + +.mobile-app-menu-btn { + display: none; + margin: 3px $layout-spacer-x 0 0; +} + +@import "responsive"; \ No newline at end of file diff --git a/frontend/app/styles/applications/_chat.scss b/frontend/app/styles/applications/_chat.scss new file mode 100644 index 0000000..bfe019c --- /dev/null +++ b/frontend/app/styles/applications/_chat.scss @@ -0,0 +1,35 @@ +// Chat +$app-layout-chat-sidebar-width: 360px; + +.chat-layout { + &.app-inner-layout { + .app-inner-layout__sidebar { + width: $app-layout-chat-sidebar-width; + flex: 0 0 $app-layout-chat-sidebar-width; + } + } + + .app-inner-layout__top-pane h4 { + font-size: $h5-font-size; + + div { + font-size: $font-size-base; + } + } + + .chat-box-wrapper { + padding: $layout-spacer-x; + } +} + +@include media-breakpoint-down(lg) { + .chat-layout { + &.app-inner-layout { + .app-inner-layout__sidebar { + .widget-content .widget-content-left .widget-subheading { + white-space: normal; + } + } + } + } +} \ No newline at end of file diff --git a/frontend/app/styles/applications/_responsive.scss b/frontend/app/styles/applications/_responsive.scss new file mode 100644 index 0000000..e5cd18b --- /dev/null +++ b/frontend/app/styles/applications/_responsive.scss @@ -0,0 +1,18 @@ +// Responsive Applications + +@include media-breakpoint-down(md) { + .app-inner-layout__sidebar { + display: none !important; + } + + .mobile-app-menu-btn { + display: block; + } + + .open-mobile-menu { + + .app-inner-layout__sidebar { + display: block !important; + } + } +} \ No newline at end of file diff --git a/frontend/app/styles/base/common.scss b/frontend/app/styles/base/common.scss new file mode 100644 index 0000000..d61abbf --- /dev/null +++ b/frontend/app/styles/base/common.scss @@ -0,0 +1,438 @@ +@import url('https://fonts.googleapis.com/css?family=Lato'); +@import url('https://fonts.googleapis.com/css?family=Roboto+Condensed:300,300i,400,400i,700,700i&subset=cyrillic,cyrillic-ext,greek,greek-ext,latin-ext,vietnamese'); +@import url('https://fonts.googleapis.com/css2?family=Tajawal:wght@200;300;400;700;800&display=swap'); + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + /* margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-size: 0.88rem; + font-weight: 400; + line-height: 1.5; + color: #495057; + text-align: left; + background-color: #fff; */ + + & p, + label { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', + 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + font-size: 0.88rem; + font-weight: 400; + line-height: 1.5; + } +} + +[dir='rtl'] body { + text-align: right; +} + +$font-family-arabic: 'Tajawal', sans-serif; + +:lang(ar) { + font-family: $font-family-arabic; +} + +html, +body, +#app-container, +#app-container > div, +.root-layout { + height: 100%; + min-height: 100%; + background: #f1f4f6; +} + +.root-layout { + //font-family: 'Lato', sans-serif; + // font-family: "Roboto Condensed", sans-serif; + // font-size: 13px; + // color: #373739; + // background: #f1f4f6; +} + +a { + text-decoration: none !important; + color: none !important; +} + +ul { + list-style: none; +} + +.clearfix:after { + content: ''; + display: block; + height: 0; + clear: both; +} + +.btn-focus.btn-shadow { + box-shadow: 0 0.125rem 0.625rem rgba(68, 64, 84, 0.4), + 0 0.0625rem 0.125rem rgba(68, 64, 84, 0.5); + color: #fff; + background-color: #444054; + border-color: #444054; +} + +.btn.btn-pill.btn-wide, +.btn.btn-pill { + border-top-left-radius: 50px; + border-bottom-left-radius: 50px; + border-top-right-radius: 50px; + border-bottom-right-radius: 50px; +} + +.btn-group .btn { + font-size: 0.8rem; + font-weight: 500; +} + +/* +.btn { + position: relative; + transition: color 0.15s, background-color 0.15s, border-color 0.15s, + box-shadow 0.15s; + + &-primary { + color: #fff; + background-color: #545cd8; + border-color: #545cd8; + } + + &-secondary { + border: 1px solid #444444; + border-bottom: 3px solid #2f2f2f; + background: #444444; + box-shadow: inset 0 0 0 0 #2f2f2f; + text-shadow: none; + + &:hover, + &:focus { + box-shadow: inset 0 -100px 0 0 #2f2f2f; + } + } + + &-default { + background: #0291ae; + text-shadow: none; + color: #fff; + + &:hover, + &:focus { + background: #007c8c; + } + } + + &-outline { + &-primary { + color: #82c4a9 !important; + border-color: #82c4a9 !important; + + &:hover, + &:active { + background: #82c4a9 !important; + color: #fff !important; + } + + &:focus { + box-shadow: 0 0 0 0.2rem rgba(58, 196, 125, 0.5) !important; + } + } + &-secondary { + color: #444444 !important; + border-color: #444444 !important; + + &:hover, + &:focus { + box-shadow: inset 0 -100px 0 0 #2f2f2f; + } + } + + &-default { + color: #0291ae !important; + border-color: #0291ae !important; + + &:hover, + &:focus { + background: #0291ae; + color: #fff !important; + } + } + } +} +*/ + +/* .custom-control { + &-input:checked ~ .custom-control-label::before { + color: #fff; + border-color: #ccc !important; + } + + &-input:checked ~ .custom-control-label::before { + background-color: #012160 !important; + } + + &-input:focus ~ .custom-control-label:before { + box-shadow: 0 0 0 0.2rem rgba(1, 33, 96, 0.25) !important; + } +} */ + +/* +.button { + color: #ffffff; + font-weight: 700; + font-size: 14px; + padding: 3px 17px; + text-align: center; + cursor: pointer; + transition: all ease 0.4s; + -webkit-font-smoothing: antialiased; + display: inline-block; + vertical-align: top; + border: none; + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; + height: 30px; + + &--primary { + background: #82c4a9; + } + + &--secondary { + border: 1px solid #444444; + border-bottom: 3px solid #2f2f2f; + background: #444444; + box-shadow: inset 0 0 0 0 #2f2f2f; + text-shadow: none; + + &:hover, + &:focus { + box-shadow: inset 0 -100px 0 0 #2f2f2f; + } + } + + &--default { + background: #0291ae; + text-shadow: none; + color: #fff; + + &:hover, + &:focus { + background: #007c8c; + } + } +} +*/ + +.tool-button { + border: 1px solid #bcbec0; + padding: 0 10px; + background: #fff; + height: 25px; + cursor: pointer; + white-space: nowrap; + color: #373739; + border-radius: 2px; + + &:hover { + border-color: #6d6e71; + } +} + +.checkbox-label { + display: inline-block; + vertical-align: bottom; + cursor: pointer; +} + +.checkbox-input-hidden { + display: none; +} + +input[type='checkbox'] + .checkbox-label i, +input[type='radio'] + .checkbox-label i { + font-size: 14px; + width: 10px; + vertical-align: middle; + margin-inline-end: 5px; +} + +input[type='checkbox'] + .checkbox-label i.selected, +input[type='radio'] + .checkbox-label i.selected { + display: none; +} + +input[type='checkbox']:checked + label i.unselected, +input[type='radio']:checked + label i.unselected { + display: none; +} + +input[type='checkbox']:checked + label i.selected, +input[type='radio']:checked + label i.selected { + display: inline-block; +} + +input[type='checkbox'] + label i.unselected, +input[type='radio'] + label i.unselected { + display: inline-block; +} + +input, +select, +textarea, +button { + font-family: inherit; +} + +textarea { + resize: none; + border: 1px solid #bcbec0; + border-radius: 0.25rem; +} + +.logo { + color: #fff; + font-size: 18px; + display: inline-block; +} + +.error-msg { + color: red; +} + +.main-loader { + position: fixed; + width: 100%; + height: 100%; + background: #fff url('./../images/loadingspinner.gif') no-repeat center center; +} + +.component-loader { + display: none; + position: absolute; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.7) url('./../images/loadingspinner.gif') + no-repeat center center; + z-index: 9999; +} +/* +.popup { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 10; + background: rgba(255, 255, 255, 0.7); + overflow: auto; + + &__content { + min-width: 400px; + position: absolute; + width: auto; + left: 50%; + top: 10%; + margin-bottom: 20px; + transform: translate(-50%, 0); + background: #fff; + border: 1px solid #bcbec0; + box-shadow: 0 1px 1px 1px #e9e9ea; + border-radius: 5px; + } + + &__col-50 { + display: inline-block; + width: 50%; + vertical-align: top; + + &:first-child { + padding-inline-end: 15px; + } + + &:last-child { + padding-inline-start: 15px; + } + } + + &__input-label { + display: block; + line-height: 24px; + font-weight: 700; + color: #6d6e71; + } + + &__select { + height: 25px; + width: 100%; + } + + p { + margin-bottom: 10px; + } + + &__head { + //border-top: 4px solid #ff9900; + color: #373739; + font-size: 18px; + min-height: 20px; + line-height: 20px; + padding: 10px; + border-bottom: 1px solid #bcbec0; + } + &__close-btn { + float: right; + color: #373739; + } + + &__body { + padding: 20px 12px 12px 12px; + display: block; + min-height: 40px; + white-space: nowrap; + border-bottom: 1px solid #bcbec0; + } + + &__checks-list { + li { + margin-bottom: 10px; + } + } + + &__footer { + min-height: 25px; + padding: 10px 10px 15px 10px; + text-align: end; + + .button { + margin-inline-start: 5px; + } + } +} */ + +//advanced filters +.advanced-filters { + width: 230px; + margin-inline-start: 20px; + margin-top: 40px; +} + +.info-link { + margin-inline-start: 5px; + color: #969697; +} + +.hidden { + display: none !important; +} diff --git a/frontend/app/styles/base/responsive.scss b/frontend/app/styles/base/responsive.scss new file mode 100644 index 0000000..9574adb --- /dev/null +++ b/frontend/app/styles/base/responsive.scss @@ -0,0 +1,561 @@ +/** +Middle +*/ + +@media (max-width: 1280px) and (min-width: 800px) { + .main-header { + height: 60px; + .logo-img { + background: url(../images/logo/logo-small.png) no-repeat; + -webkit-background-size: contain; + background-size: contain; + } + } + + .main-tabs-links__item { + width: 70px; + } + + .header-settings { + margin-inline-end: 15px; + &__item { + padding-inline-start: 8px; + padding-inline-end: 8px; + } + } +} + +/** +Mobile +*/ + +@media (max-width: 767px) { + .article { + display: block; + &__img { + margin: 20px 0 0; + img { + max-width: 100%; + } + } + } + .for-small { + display: none !important; + + &-p { + font-size: 12px !important; + } + } + .account-form { + width: 90%; + padding: 30px 35px; + &__title, + &__subtitle { + text-align: center; + } + + &__title { + margin-bottom: 10px; + } + + &__subtitle { + font-size: 16px; + } + + &__note { + margin-top: 25px; + } + + &__alt-title { + font-size: 18px; + font-weight: 500; + } + + button { + font-size: 16px; + padding: 10px 20px; + height: 40px; + } + } + + .login-register { + min-height: calc(100% - 100px); + input[type="email"], + input[type="password"], + input[type="text"] { + height: 40px; + } + } + + .footer { + height: auto; + padding: 5px 10px; + &__list { + flex-direction: column; + align-items: flex-start; + } + + &__link { + font-size: 13px; + line-height: 34px; + } + } + + .main-header { + height: 70px; + } + + .app-header { + .logo { + .logo-img { + height: 55px; + background: url(../images/logo/logo-small.png) no-repeat; + -webkit-background-size: contain; + background-size: contain; + } + } + } + + .login-header { + text-align: center; + .logo-img { + background: url(../images/logo/logo-small.png) no-repeat; + -webkit-background-size: contain; + background-size: contain; + } + } + + .app-header .logo { + margin-inline-start: 20px; + width: 80px; + padding-top: 10px; + img { + max-width: 100%; + } + } + + .main-tabs-links__item { + padding-top: 0; + width: 50px; + } + + .menu-opener { + position: absolute; + display: block; + right: 20px; + top: 25px; + width: 24px; + height: 24px; + background: none; + border: none; + cursor: pointer; + .icon-bar { + background: #ffffff; + height: 2px; + width: 100%; + display: block; + margin-bottom: 6px; + } + } + + body { + background-attachment: fixed !important; + &.register { + .account-form { + width: 95%; + } + + .account-form__content { + padding: 20px 20px; + } + } + } + + .account-form__column { + padding: 10px; + } + + .account-form__content__body { + display: block; + } + + .account-form__content { + h1 { + font-size: 26px; + font-weight: bold; + } + } + + .account-form__register-title { + a { + color: #fff; + } + } + + .plan-item { + margin-bottom: 40px; + } + + /***/ + + .main-tabs-links, + .header-settings { + display: none; + position: absolute; + right: 0; + background: #0291ae; + z-index: 99; + .menu-opened & { + display: block; + } + } + + .main-tabs-links { + top: 70px; + height: auto; + &__item { + height: auto; + display: block; + width: 200px; + text-align: end; + padding: 10px 20px 10px 10px; + margin: 0; + &.active { + border: none; + background: #ffffff; + color: #0291ae; + } + p { + display: inline-block; + vertical-align: top; + line-height: 20px; + width: 140px; + } + } + + &__icon { + display: inline-block; + vertical-align: top; + margin: 0 10px 0 0; + } + } + + .header-settings { + top: 196px; + height: auto; + right: 0; + margin-inline-end: 0; + width: 200px; + &__item { + padding: 10px 20px 10px 10px; + display: block; + float: none; + } + + .langs-settings__icon, + .user-settings__name, + .whats-new-btn { + display: block; + line-height: 20px; + text-align: end; + } + + .langs-settings__icon { + font-size: 20px; + } + + .whats-new-btn__text { + width: 140px; + display: inline-block; + vertical-align: top; + } + + .whats-new-btn__icon { + margin-inline-end: 8px; + font-size: 14px; + display: inline-block; + vertical-align: top; + line-height: 20px; + } + } + + /****/ + + .sub-tabs-links { + margin-inline-start: 0; + border: none; + } + + /**/ + + .tabs-content--short { + margin-inline-start: 15px; + } + + .app-sidebar { + position: fixed; + // top: 112px; + bottom: 0px; + z-index: 9; + + &.animation-disabled { + .sidebar-divider, + .sidebar-panel { + animation: none !important; + background: #2e3047; + + &:after { + content: ""; + display: block; + width: 100%; + height: 100%; + position: absolute; + left: 0; + top: 0; + background-color: rgba(#151f30, 0.5); + transition: 250ms background-color; + } + } + } + } + + .sidebar-divider { + left: 0; + + .sidebar-opened & { + animation: openSidebarHandle 1s; + left: 0; + } + .sidebar-closed & { + animation: closeSidebarHandle 1s; + left: $sidebar-width; + } + + &__arrow-right { + .sidebar-opened & { + display: block; + } + } + + &__arrow-right { + .sidebar-closed & { + display: none; + } + } + } + + @keyframes openSidebarHandle { + 0% { + left: $sidebar-width; + } + 100% { + left: 0; + } + } + @keyframes closeSidebarHandle { + 0% { + left: 0; + } + 100% { + left: $sidebar-width; + } + } + + .sidebar-panel { + .sidebar-opened & { + animation: openSidebar 1s; + width: 0; + overflow: hidden; + } + .sidebar-closed & { + animation: closeSidebar 1s; + width: $sidebar-width; + } + + &__wrap { + width: $sidebar-width; + padding: 0 15px 0 30px; + } + } + + @keyframes closeSidebar { + 0% { + width: 0; + overflow: hidden; + } + 99% { + overflow: hidden; + } + 100% { + width: $sidebar-width; + overflow: visible; + } + } + @keyframes openSidebar { + 0% { + width: $sidebar-width; + } + 100% { + width: 0; + } + } + + .welcome-container { + width: 100%; + &__block { + display: block; + width: 100%; + margin: 20px 0 30px; + height: auto; + } + } + + /**/ + .searching-block { + display: block; + } + + .button { + font-weight: normal; + font-size: 12px; + padding: 3px 8px; + } + + .searching-block__btns .button { + margin: 7px 7px 0 0; + } + + .sources-table { + font-size: 12px; + } + + .table-pager__pages { + float: none; + width: 100%; + margin-bottom: 10px; + } + + .table-pager__page { + margin-inline-end: 5px; + height: 27px; + line-height: 24px; + width: 27px; + } + + .table-pager__limits { + font-size: 12px; + width: 100%; + float: none; + text-align: start; + } + + .table-pager__limit { + margin-inline-end: 5px; + height: 22px; + width: 22px; + font-size: 8px; + } + + .search-content { + display: block; + .filters-table-container { + width: 100%; + margin-inline-start: 0; + } + } +/* + .source-lists-head:after { + clear: both; + content: ""; + display: table; + } + + .source-lists-head__title { + float: none; + width: 100%; + margin-top: 10px; + } */ + + .billing-plans__title { + margin-bottom: 20px; + h1 { + font-size: 34px; + } + } + + .plan-item { + margin-bottom: 50px; + &__title { + padding: 10px; + font-size: 16px; + } + + &__desc { + font-size: 16px; + } + + .button { + padding: 10px 50px; + } + } + + .welcome-container__subtext { + text-align: center; + } + + .welcome-container__h1 { + font-size: 24px; + text-align: center; + } + + .welcome-container__h2 { + margin-bottom: 10px; + text-align: center; + } + + .button { + font-size: 14px; + padding: 5px 9px; + } + + .tabs-content { + margin: 35px 15px 20px; + } + + .notifications-topbar { + height: auto; + &:after { + clear: both; + content: ""; + display: table; + } + + .notifications-buttons { + .button { + margin-inline-start: 0; + margin-top: 0; + margin-inline-end: 5px; + } + } + } + + .paginated-table-top-panel { + flex-wrap: wrap; + height: auto; + padding-inline-start: 0; + } + + .paginated-table-top-panel__buttons { + button { + margin-bottom: 5px; + } + } +/* + .popup__content { + min-width: 300px; + } + + .popup { + z-index: 999; + } */ +} diff --git a/frontend/app/styles/components/alerts.scss b/frontend/app/styles/components/alerts.scss new file mode 100644 index 0000000..0743882 --- /dev/null +++ b/frontend/app/styles/components/alerts.scss @@ -0,0 +1,55 @@ +/* Removed from core css file */ + +.alerts-container { + bottom: 20px; + right: 20px; + position: fixed; + width: 200px; + z-index: 5000; +} + +.alert { + background: #fff; + border: 1px solid #bcbec0; + margin-bottom: 10px; + width: 200px; + + &.notice { + border-top: 4px solid #77b800; + } + + &.warning { + border-top: 4px solid #ff9900; + } + + &.error { + border-top: 4px solid #ff0000; + } + + &__head { + border-bottom: 1px solid #bcbec0; + display: flex; + padding: 8px; + } + + &__close-btn { + color: #000; + } + + &__title { + flex-grow: 1; + font-size: 18px; + margin-inline-end: 15px; + text-transform: uppercase; + } + + &__body { + min-height: 100px; + padding: 8px; + } + + &__text { + font-size: 14px; + } +} + diff --git a/frontend/app/styles/components/app/analyzeTab.scss b/frontend/app/styles/components/app/analyzeTab.scss new file mode 100644 index 0000000..deb4b47 --- /dev/null +++ b/frontend/app/styles/components/app/analyzeTab.scss @@ -0,0 +1,199 @@ +.welcome-container { + margin: auto; + width: 830px; + position: relative; + overflow: hidden; + + &__h1 { + font-size: 30px; + margin-bottom: 30px; + } + + &__h2 { + font-size: 24px; + margin-bottom: 2px; + } + + &__subtext { + color: #939598; + } + + &__block { + border: 1px solid #bcbec0; + display: flex; + flex-flow: column nowrap; + float: left; + font-size: 18px; + height: 320px; + margin: 48px 0 0 25px; + padding: 20px 0 10px; + text-align: center; + width: 260px; + } + + &__block:first-of-type { + margin-inline-start: 0; + } + + &__blockcontent { + justify-content: center; + align-content: center; + align-items: center; + display: flex; + flex: 1; + -moz-box-pack: center; + -ms-flex-pack: center; + } + + &__blockcontent span { + font-size: 13px; + } + + &__icon { + font-size: 55px; + } + + &__icon:before { + font-style: normal; + font-weight: 400; + speak: none; + display: inline-block; + text-decoration: inherit; + width: 1em; + margin-inline-end: 0.2em; + text-align: center; + font-variant: normal; + text-transform: none; + line-height: 1em; + margin-inline-start: 0.2em; + -webkit-font-smoothing: antialiased; + } + + .icon-chart-bar { + color: #8ac224; + } + + .icon-glasses { + color: #8f2b8c; + } + + .icon-floppy { + color: #ed9838; + } + + &__links { + align-self: center; + text-align: start; + width: 100%; + padding-inline-start: 10px; + } + + &__link { + font-size: 19px; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border: none; + color: #009ddb; + // font-family: Arial,sans-serif; + padding: 3px 11px; + } +} + +$sidebar-divider-left: 222px; + +.saved-analyses-sidebar { + position: fixed; + //top: 100px; + top: 172px; + bottom: 25px; + left: 5px; + + &.animation-disabled &__panel, + &.animation-disabled .sidebar-divider { + animation: none !important; + } + + &__h2 { + } + + &.sidebar-opened .sidebar-divider { + left: $sidebar-divider-left; + animation: openAnalyzeSidebarHandle 1s; + } + + &.sidebar-closed .sidebar-divider { + animation: closeAnalyzeSidebarHandle 1s; + left: 0; + } + + .sidebar-divider { + border-left: 2px solid #009ddb; + + &__handle { + background: #009ddb; + } + } + + &__panel { + zoom: 1; + overflow: auto; + position: absolute; + top: 0; + bottom: 0; + + .sidebar-opened & { + animation: openSidebar 1s; + } + .sidebar-closed & { + animation: closeSidebar 1s; + overflow: hidden; + width: 0; + } + } + + &__wrap { + width: 200px; + padding: 0 5px 0 10px; + } +} + +@keyframes openAnalyzeSidebarHandle { + 0% { + left: 0; + } + 100% { + left: $sidebar-divider-left; + } +} +@keyframes closeAnalyzeSidebarHandle { + 0% { + left: $sidebar-divider-left; + } + 100% { + left: 0; + } +} + +.saved-analyses-content { + transition: margin 1s; + margin-inline-start: 10px; + margin-inline-end: 20px; + + &--short { + transition: margin 1s; + margin-inline-start: 250px; + margin-inline-end: 20px; + } + + &__header { + display: block; + height: 50px; + padding: 5px; + + button { + float: right; + } + } +} diff --git a/frontend/app/styles/components/app/app.scss b/frontend/app/styles/components/app/app.scss new file mode 100644 index 0000000..ca367a4 --- /dev/null +++ b/frontend/app/styles/components/app/app.scss @@ -0,0 +1,74 @@ +$sidebar-width: $app-sidebar-width; + +.app { + min-height: calc(100% - 65px); + + input[type="text"], + input[type="email"], + input[type="password"], + select { + color: #666; + padding: 0.375rem 0.75rem; + border: 1px solid #bcbec0; + width: 100%; + background-color: white; + border-radius: 0.25rem; + } + + &_sub-header { + background: #fff; + border-bottom: 1px solid rgba(32, 39, 140, 0.125); + height: 60px; + } +} + +.tabs-content { + transition: margin 1s; + margin-inline-start: 20px; + margin-top: 25px; + margin-inline-end: 10px; + + .full { + margin: 2rem !important; + } + + &--short { + // margin-inline-start: 270px; + } + + &-wrap { + margin: 2rem 2rem 2rem 280px; + background: #fff; + border-radius: 5px; + box-shadow: 0 0.46875rem 2.1875rem rgba(8, 10, 37, 0.03), + 0 0.9375rem 1.40625rem rgba(8, 10, 37, 0.03), + 0 0.25rem 0.53125rem rgba(8, 10, 37, 0.05), + 0 0.125rem 0.1875rem rgba(8, 10, 37, 0.03); + border-width: 0; + transition: all 0.2s; + padding: 1rem; + transform: translateY(60px); + + &_full { + margin: 6rem 2rem 2rem 2rem !important; + background: #fff; + border-radius: 5px; + box-shadow: 0 0.46875rem 2.1875rem rgba(8, 10, 37, 0.03), + 0 0.9375rem 1.40625rem rgba(8, 10, 37, 0.03), + 0 0.25rem 0.53125rem rgba(8, 10, 37, 0.05), + 0 0.125rem 0.1875rem rgba(8, 10, 37, 0.03); + border-width: 0; + transition: all 0.2s; + padding: 1rem; + } + } +} + +.btn-icon-wrapper { + margin-inline-start: 0.5rem; + margin-inline-end: 0; +} + +.primary-color { + background-color: #1299af; +} diff --git a/frontend/app/styles/components/app/emailPopup.scss b/frontend/app/styles/components/app/emailPopup.scss new file mode 100644 index 0000000..b7e167a --- /dev/null +++ b/frontend/app/styles/components/app/emailPopup.scss @@ -0,0 +1,166 @@ +.email-popup { + &__articles { + border: 2px dashed #ccc; + padding: 10px; + } + + &__form-row { + margin-bottom: 10px; + } + + &__label { + width: 6%; + display: inline-block; + } + + &__input { + width: 92% !important; + margin-inline-start: 2%; + display: inline-block; + height: 32px; + } + + .Select-control { + border-radius: 0; + height: 34px; + } + + &__article { + margin-bottom: 10px; + } + + .article__desc { + white-space: normal; + } + + .email-editor { + // font-family: Roboto Condensed, sans-serif; + font-size: 14px; + } + + //custom font families labels in dropdown + .ql-snow .ql-picker.ql-font .ql-picker-label::before, + .ql-snow .ql-picker.ql-font .ql-picker-item::before { + content: "Roboto Condensed"; + // font-family: Roboto Condensed, sans-serif; + } + + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value="lato"]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value="lato"]::before { + content: "Lato"; + // font-family: Lato, sans-serif; + } + + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value="times"]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value="times"]::before { + content: "Times New Roman"; + // font-family: Times New Roman, serif; + } + + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value="arial"]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value="arial"]::before { + content: "Arial"; + // font-family: Arial, sans-serif; + } + + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value="courier"]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value="courier"]::before { + content: "Courier New"; + // font-family: Courier New, monospace; + } + + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value="georgia"]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value="georgia"]::before { + content: "Georgia"; + // font-family: Georgia, serif; + } + + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value="trebuchet"]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value="trebuchet"]::before { + content: "Trebuchet MS"; + // font-family: "Trebuchet MS", sans-serif; + } + + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value="verdana"]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value="verdana"]::before { + content: "Verdana"; + // font-family: Verdana, sans-serif; + } + + //custom font-families + .ql-font-lato { + // font-family: "Lato", sans-serif; + } + .ql-font-times { + // font-family: "Times New Roman", serif; + } + .ql-font-arial { + // font-family: "Arial", sans-serif; + } + .ql-font-courier { + // font-family: "Courier New", monospace; + } + .ql-font-georgia { + // font-family: "Georgia", serif; + } + .ql-font-trebuchet { + // font-family: "Trebuchet MS", sans-serif; + } + .ql-font-verdana { + // font-family: "Verdana", sans-serif; + } + + //custom font-sizes labels + .ql-snow .ql-picker.ql-size .ql-picker-label::before, + .ql-snow .ql-picker.ql-size .ql-picker-item::before { + content: "14px"; //default font-size + } + + .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="10px"]::before, + .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="10px"]::before { + content: "10px"; + } + + .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="12px"]::before, + .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="12px"]::before { + content: "12px"; + } + + .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="14px"]::before, + .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="14px"]::before { + content: "14px"; + } + + .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="16px"]::before, + .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="16px"]::before { + content: "16px"; + } + + .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="18px"]::before, + .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="18px"]::before { + content: "18px"; + } + + .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="24px"]::before, + .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="24px"]::before { + content: "24px"; + } + + .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="32px"]::before, + .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="32px"]::before { + content: "32px"; + } + + .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="48px"]::before, + .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="48px"]::before { + content: "48px"; + } + + .ql-snow .ql-picker.ql-font { + width: auto; + } + + .ql-snow .ql-font .ql-picker-label { + padding-inline-end: 18px; + } +} diff --git a/frontend/app/styles/components/app/header.scss b/frontend/app/styles/components/app/header.scss new file mode 100644 index 0000000..0d3a5f8 --- /dev/null +++ b/frontend/app/styles/components/app/header.scss @@ -0,0 +1,1187 @@ +$nav-link-padding-y: 0.5rem !default; +$nav-link-padding-x: 1rem !default; +$spacer: 1rem !default; +$dropdown-padding-y: 0.5rem !default; +$border-radius: 0.25rem !default; +$dropdown-border-radius: $border-radius !default; +$black: #000 !default; +$gray-600: #6c757d !default; +$gray-300: #dee2e6 !default; +$dropdown-border-color: rgba($black, 0.15) !default; +$font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default; +$font-family-base: $font-family-sans-serif !default; +$font-size-base: 1rem !default; +$h5-font-size: $font-size-base * 1.25 !default; +$dropdown-spacer: 0.125rem !default; + +.btn:focus, +.btn.focus { + outline: 0; + box-shadow: none !important; +} + +.app-header { + position: fixed; + width: 100%; + top: 0; + height: 60px; + display: flex; + align-items: center; + align-content: center; + z-index: 10; + transition: all 0.2s; + background: #fafbfc; + + .dots { + opacity: 0; + } + + & .header-shadow { + box-shadow: 0 0.46875rem 2.1875rem rgba(8, 10, 37, 0.03), + 0 0.9375rem 1.40625rem rgba(8, 10, 37, 0.03), + 0 0.25rem 0.53125rem rgba(8, 10, 37, 0.05), + 0 0.125rem 0.1875rem rgba(8, 10, 37, 0.03); + } + + $widget-spacer: 1rem !default; + + .widget-content { + padding: $widget-spacer; + flex-direction: row; + align-items: center; + + .widget-content-wrapper { + display: flex; + flex: 1; + position: relative; + align-items: center; + } + + .widget-content-left { + .widget-heading { + opacity: 0.8; + font-weight: bold; + } + + .widget-subheading { + opacity: 0.5; + } + } + + .widget-content-right { + margin-inline-start: auto; + } + + .widget-numbers { + font-weight: bold; + font-size: 1.8rem; + display: block; + } + + .widget-content-outer { + display: flex; + flex: 1; + flex-direction: column; + } + + .widget-progress-wrapper { + margin-top: $widget-spacer; + + .progress-sub-label { + margin-top: ($widget-spacer / 3); + opacity: 0.5; + display: flex; + align-content: center; + align-items: center; + left: 50%; + top: 4.2rem; + color: #000; + + .sub-label-left { + } + + .sub-label-right { + margin-inline-start: auto; + } + } + } + + .widget-content-right { + &.widget-content-actions { + visibility: hidden; + opacity: 0; + transition: opacity 0.2s; + } + } + + &:hover { + .widget-content-right { + &.widget-content-actions { + visibility: visible; + opacity: 1; + } + } + } + } + + .scrollbar-container { + position: relative; + height: 100%; + } + + // Scroll Areas + + .scroll-area { + overflow-x: hidden; + height: 400px; + } + + .scroll-area-xs { + height: 150px; + overflow-x: hidden; + } + + .scroll-area-sm { + height: 200px; + overflow-x: hidden; + } + + .scroll-area-md { + height: 300px; + overflow-x: hidden; + } + + .scroll-area-lg { + height: 400px; + overflow-x: hidden; + } + + .scroll-area-x { + overflow-x: auto; + width: 100%; + max-width: 100%; + } + + .shadow-overflow { + position: relative; + + &::after, + &::before { + width: 100%; + bottom: auto; + top: 0; + left: 0; + height: 1.5rem; + position: absolute; + z-index: 10; + content: ""; + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 1) 20%, + rgba(255, 255, 255, 0) 100% + ); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#00ffffff', GradientType=0); + } + + &::after { + bottom: 0; + top: auto; + + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 1) 80% + ); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ffffff', endColorstr='#ffffff', GradientType=0); + } + } + + // Dropdown + .dropdown-menu { + box-shadow: 0 0.46875rem 2.1875rem rgba(darken(#007bff, 50%), 0.03), + 0 0.9375rem 1.40625rem rgba(darken(#007bff, 50%), 0.03), + 0 0.25rem 0.53125rem rgba(darken(#007bff, 50%), 0.05), + 0 0.125rem 0.1875rem rgba(darken(#007bff, 50%), 0.03); + font-size: 0.88rem; + + margin: 0.125rem; + + .bg-focus { + background-color: #444054 !important; + } + + &.dropdown-menu-right { + right: 0 !important; + } + + .dropdown-header { + text-transform: uppercase; + font-size: $font-size-base / 1.2; + color: #007bff; + font-weight: bold; + } + + .dropdown-item { + font-size: $font-size-base; + display: flex; + align-items: center; + transition: background-color 0.3s ease, color 0.3s ease; + cursor: pointer; + z-index: 6; + position: relative; + + .dropdown-icon { + font-size: 1rem; + margin-inline-end: ($dropdown-padding-y / 2); + width: 30px; + text-align: center; + opacity: 0.3; + margin-inline-start: -10px; + } + + &:hover { + .dropdown-icon { + opacity: 0.7; + } + } + } + + &.dropdown-menu-shadow { + box-shadow: 0 0.66875rem 2.3875rem rgba(darken(#007bff, 50%), 0.03), + 0 1.1375rem 1.60625rem rgba(darken(#007bff, 50%), 0.03), + 0 0.45rem 0.73125rem rgba(darken(#007bff, 50%), 0.05), + 0 0.325rem 0.3875rem rgba(darken(#007bff, 50%), 0.03); + } + } + + .dropdown-menu-rounded { + border-radius: 10px; + padding: $dropdown-padding-y; + + .dropdown-item { + border-radius: 30px; + } + + .dropdown-divider { + margin-inline-start: -$dropdown-padding-y; + margin-inline-end: -$dropdown-padding-y; + } + + .dropdown-menu-header { + margin-inline-start: -$dropdown-padding-y; + margin-inline-end: -$dropdown-padding-y; + border-top-left-radius: 10px; + border-top-left-radius: 10px; + } + + .menu-header-image, + .dropdown-menu-header-inner { + border-top-left-radius: 10px; + border-top-right-radius: 10px; + } + } + + .dropdown-menu-hover-link { + .dropdown-item { + &:hover { + background: none; + color: #007bff; + } + } + } + + .dropdown-menu-hover-primary { + .dropdown-item { + &:hover { + background: #007bff; + color: #fff; + } + } + } + + .dropdown-menu { + &.dropdown-menu-lg { + min-width: 22rem; + } + + &.dropdown-menu-xl { + min-width: 25rem; + } + } + + // Dropdown header + + .dropdown-menu { + min-width: 15rem; + .dropdown-menu-header, + .menu-header-image, + .dropdown-menu-header-inner { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; + } + } + + .dropdown-menu-header { + color: #fff; + margin-top: -$dropdown-padding-y; + margin-bottom: $dropdown-padding-y; + position: relative; + z-index: 6; + + .dropdown-menu-header-inner { + margin: -1px -1px 0; + padding: ($spacer * 1.5) ($spacer / 2); + position: relative; + } + + .menu-header-image { + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 100%; + z-index: 8; + opacity: 0.25; + filter: grayscale(80%); + background-size: cover; + } + + .menu-header-content { + text-align: center; + position: relative; + z-index: 10; + + &.text-left { + padding-inline-start: ($spacer / 2); + } + + &.btn-pane-right { + padding-inline-start: ($spacer / 2); + padding-inline-end: ($spacer / 2); + display: flex; + align-content: center; + align-items: center; + text-align: start; + + .menu-header-btn-pane { + margin: 0 0 0 auto; + } + } + + .menu-header-btn-pane { + margin-top: 10px; + margin-bottom: 3px; + } + } + + & + .grid-menu { + margin-top: -$dropdown-padding-y; + } + } + + .menu-header-title { + font-weight: 500; + font-size: $h5-font-size; + margin: 0; + } + + .menu-header-subtitle { + font-size: $font-family-base; + margin: 5px 0 0; + opacity: 0.8; + } + + .dropdown-menu { + .grid-menu { + margin-bottom: -$dropdown-padding-y; + padding: 1px; + + [class*="col-"] { + padding: $dropdown-padding-y; + } + } + + .grid-menu-xl { + margin-bottom: -($dropdown-padding-y / 1.35); + + [class*="col-"] { + padding: 0; + } + } + } + + .dropdown-menu { + .dropdown-menu-header, + .menu-header-image, + .dropdown-menu-header-inner { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; + } + } + + .dropdown-menu-header { + color: #fff; + margin-top: -$dropdown-padding-y; + margin-bottom: $dropdown-padding-y; + position: relative; + z-index: 6; + + .dropdown-menu-header-inner { + margin: -1px -1px 0; + padding: ($spacer * 1.5) ($spacer / 2); + position: relative; + } + + .menu-header-image { + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 100%; + z-index: 8; + opacity: 0.25; + filter: grayscale(80%); + background-size: cover; + } + + .menu-header-content { + text-align: center; + position: relative; + z-index: 10; + + &.text-left { + padding-inline-start: ($spacer / 2); + } + + &.btn-pane-right { + padding-inline-start: ($spacer / 2); + padding-inline-end: ($spacer / 2); + display: flex; + align-content: center; + align-items: center; + text-align: start; + + .menu-header-btn-pane { + margin: 0 0 0 auto; + } + } + + .menu-header-btn-pane { + margin-top: 10px; + margin-bottom: 3px; + } + } + + & + .grid-menu { + margin-top: -$dropdown-padding-y; + } + } + + .menu-header-title { + font-weight: 500; + font-size: $h5-font-size; + margin: 0; + } + + .menu-header-subtitle { + font-size: $font-family-base; + margin: 5px 0 0; + opacity: 0.8; + } + + .dropdown-menu { + .grid-menu { + margin-bottom: -$dropdown-padding-y; + padding: 1px; + + [class*="col-"] { + padding: $dropdown-padding-y; + } + } + + .grid-menu-xl { + margin-bottom: -($dropdown-padding-y / 1.35); + + [class*="col-"] { + padding: 0; + } + } + } + + // Dropdown toggle + + .dropdown-toggle { + &::after { + position: relative; + top: 2px; + opacity: 0.8; + margin-inline-start: 5px; + } + } + + .dropdown-toggle-split { + &::after { + margin-inline-start: 0; + } + } + + .dropright { + .dropdown-toggle { + &::after { + top: 0; + } + } + } + + .dropdown-toggle-split { + border-left: rgba(255, 255, 255, 0.1) solid 2px; + } + + // Dropdown Indicators + + .dropdown-menu { + &::before, + &::after { + position: absolute; + width: 14px; + height: 14px; + left: 5px; + top: -5px; + transform: rotate(45deg); + border-radius: 4px; + content: ""; + display: block; + z-index: 5; + } + + &::before { + background: #fff; + z-index: 5; + } + + &::after { + top: -6px; + background: $dropdown-border-color; + z-index: 4; + } + + &[data-placement="top-start"] { + &::before, + &::after { + top: auto; + bottom: -5px; + } + + &::after { + bottom: -6px; + } + } + + &[data-placement="left-start"] { + &::before, + &::after { + left: auto; + right: -5px; + top: 5px; + } + + &::after { + top: 5px; + right: -6px; + } + } + + &[data-placement="right-start"] { + &::before, + &::after { + right: auto; + left: -5px; + top: 5px; + } + + &::after { + top: 5px; + left: -6px; + } + } + + &.dropdown-menu-right { + &::before, + &::after { + left: auto; + right: 6px; + } + + &::after { + right: 6px; + } + } + + &.rm-pointers { + &::before, + &::after { + display: none; + } + } + } + + .nav-item { + &.nav-item-header { + text-transform: uppercase; + font-size: $font-size-base / 1.2; + color: $gray-600; + font-weight: bold; + padding: $nav-link-padding-y $nav-link-padding-x; + } + + &.nav-item-btn { + padding: $nav-link-padding-y $nav-link-padding-x; + } + + &.nav-item-divider { + margin: $nav-link-padding-y 0; + height: 1px; + overflow: hidden; + background: $gray-300; + } + } + + // Dropdown Mega Menu + + .dropdown-mega-menu { + width: 56rem; + padding: $spacer; + + .nav-item.nav-item-header { + text-transform: none; + font-size: 1rem; + padding-top: 0; + font-weight: normal; + } + + .grid-menu { + margin-bottom: 0; + } + } + + .dropdown-mega-menu-sm { + width: 40rem; + } + + // Dropdown Inline + + body .dropdown-menu.dropdown-menu-inline { + border: 0; + position: static !important; + box-shadow: 0 0 0 transparent; + background: transparent; + border-radius: 0; + display: inline-block; + float: none; + left: 0 !important; + top: 0 !important; + width: 100% !important; + transform: translateY(0) !important; + + &::before, + &::after { + display: none; + } + } + + &__logo { + visibility: visible; + } + + &__logo { + background: rgba(250, 251, 252, 0.1); + visibility: visible; + padding: 0 1rem; + height: 60px; + width: 280px; + display: flex; + align-items: center; + transition: width 0.2s; + + button:focus { + outline: none !important; + } + } + + .logo-src { + height: 39px; + width: 170px; + background: url(../images/logo/logo-small.png) no-repeat; + background-size: contain; + + &_closed { + height: 40px; + width: 40px; + background: url(../images/logo/logo-square-small.png) + no-repeat; + background-size: contain; + } + } + + .app-header__content { + display: flex; + align-items: center; + align-content: center; + flex: 1 1; + padding: 0 1.5rem; + height: 60px; + + .icon-wrapper { + display: flex; + align-content: center; + align-items: center; + } + + .app-header-left { + display: flex; + align-items: center; + } + + .app-header-right { + align-items: center; + display: flex; + margin-inline-start: auto; + + .header-dots { + margin-inline-start: auto; + display: flex; + + & > .dropdown { + display: flex; + align-content: center; + } + + .icon-wrapper-alt { + margin: 0; + height: 44px; + width: 44px; + text-align: center; + overflow: visible; + + .language-icon { + border-radius: 30px; + position: relative; + z-index: 4; + width: 27px; + height: 27px; + overflow: hidden; + margin: 0 auto; + + img { + position: relative; + top: 50%; + left: 50%; + margin: -22px 0 0 -20px; + } + } + + .bg-focus { + background-color: #444054 !important; + } + + .icon-wrapper-bg { + opacity: 0.1; + transition: opacity 0.2s; + border-radius: 40px; + position: absolute; + height: 100%; + width: 100%; + z-index: 3; + // opacity: 0.2; + } + + svg { + margin: 0 auto; + } + + @-moz-document url-prefix() { + svg { + width: 50%; + } + } + + i { + font-size: 1.3rem; + } + + &:hover { + cursor: pointer; + + .icon-wrapper-bg { + opacity: 0.2; + } + } + } + } + } + } + + .search-wrapper { + position: relative; + margin-inline-end: 0.66667rem; + + .input-holder { + height: 42px; + width: 42px; + overflow: hidden; + position: relative; + transition: all 0.3s ease-in-out; + + .search-input { + width: 100%; + padding: 0 70px 0 20px; + opacity: 0; + position: absolute; + top: 0; + left: 0; + background: transparent; + box-sizing: border-box; + border: none; + outline: none; + transform: translate(0, 60px); + transition: all 0.3s cubic-bezier(0, 0.105, 0.035, 1.57); + transition-delay: 0.3s; + font-size: 0.88rem; + } + + .search-icon { + width: 42px; + height: 42px; + border: none; + padding: 0; + outline: none; + position: relative; + z-index: 2; + float: right; + cursor: pointer; + transition: all 0.3s ease-in-out; + background: rgba(0, 0, 0, 0.06); + border-radius: 30px; + + span { + width: 22px; + height: 22px; + display: inline-block; + vertical-align: middle; + position: relative; + transform: rotate(45deg); + transition: all 0.4s cubic-bezier(0.65, -0.6, 0.24, 1.65); + } + + span::before { + position: absolute; + content: ""; + width: 4px; + height: 11px; + left: 9px; + top: 13px; + border-radius: 2px; + background: #597ea9; + } + + span::after { + position: absolute; + content: ""; + width: 14px; + height: 14px; + left: 4px; + top: 0; + border-radius: 16px; + border: 2px solid #597ea9; + } + } + } + } +} + +.header-btn-lg { + padding: 0 0 0 1.5rem; + margin-inline-start: 1.5rem; + display: flex; + align-items: center; + position: relative; + + &::before { + position: absolute; + left: -1px; + top: 50%; + background: #dee2e6; + width: 1px; + height: 30px; + margin-top: -15px; + content: ""; + } +} + +.main-header { + height: 118px; + background: linear-gradient( + 90deg, + rgba(116, 191, 171, 1) 30%, + rgba(16, 158, 182, 1) 84% + ); +} + +.menu-opener { + display: none; +} + +.header-megamenu { + &.nav { + & > li > .nav-link { + color: #868e96; + padding-inline-start: ($nav-link-padding-x / 1.5); + padding-inline-end: ($nav-link-padding-x / 1.5); + + .badge-pill { + padding: 5px 7px; + } + + &:hover, &.active { + color: #333; + } + + svg { + margin-top: 1px; + } + } + .nav-link { + display: flex; + align-items: center; + transition: background-color 0.3s ease, color 0.3s ease; + cursor: pointer; + } + } +} + +.sub-header { + background: #fff; + border-bottom: 1px solid rgba(32, 39, 140, 0.125); + height: 60px; +} + +.sub-tabs-links { + padding-inline-start: 20px; + border-left: 2px solid #fff; + + &__item { + font-size: 13px; + margin-top: 19px; + line-height: 28px; + margin-inline-end: 1px; + display: inline-block; + cursor: pointer; + padding: 0 10px; + text-transform: uppercase; + color: #0291ae; + font-size: 0.88rem; + padding: 5px 10px; + + & a { + text-decoration: none; + } + + &.active { + border-bottom: 4px solid #0291ae; + -webkit-transition: all 0.2s; + -o-transition: all 0.2s; + transition: all 0.2s; + text-decoration: none; + } + } +} + +.header-settings { + float: right; + margin-inline-end: 20px; + height: 100%; + + &__item { + display: inline-block; + padding: 67px 20px 5px; + //height: 100%; + //padding: 0 10px; + } + + .micon-news { + display: inline-block; + height: 14px; + background: url(../images/news-icon.svg) no-repeat; + width: 17px; + -webkit-background-size: contain; + background-size: contain; + vertical-align: middle; + margin-inline-end: 8px; + } + + .micon-globe { + display: inline-block; + height: 22px; + background: url(../images/globe-icon.svg) no-repeat; + width: 22px; + -webkit-background-size: contain; + background-size: contain; + } +} + +.whats-new-btn { + line-height: 42px; + font-size: 13px; + color: #fff; + + &__text { + margin-inline-start: 5px; + display: inline-block; + vertical-align: middle; + line-height: 14px; + } +} + +.user-settings { + display: inline-block; + position: relative; + + &__menu { + position: absolute; + top: 100%; + right: 0; + background: #0291ae; + z-index: 2; + -webkit-border-radius: 0 0 3px 3px; + -moz-border-radius: 0 0 3px 3px; + border-radius: 0 0 3px 3px; + -webkit-box-shadow: 0 0 2px rgba(0, 0, 0, 0.2); + -moz-box-shadow: 0 0 2px rgba(0, 0, 0, 0.2); + box-shadow: 0 0 2px rgba(0, 0, 0, 0.2); + text-align: end; + -webkit-transition: all 0.4s; + -moz-transition: all 0.4s; + -ms-transition: all 0.4s; + -o-transition: all 0.4s; + transition: all 0.4s; + } + + &__item { + padding: 9px 27px 9px 27px; + white-space: nowrap; + + a { + display: block; + color: #fff; + } + + &:hover { + a { + color: #ddd; + } + } + } + + &__name { + color: #fff; + font-size: 13px; + line-height: 42px; + } +} + +.langs-settings { + display: inline-block; + position: relative; + + &__menu { + position: absolute; + top: 100%; + right: 0; + width: 200px; + background: #ffffff; + z-index: 10; + } + + &__item { + border-bottom: 1px solid #e6e6e6; + padding: 10px 30px; + cursor: pointer; + position: relative; + } + + &__icon { + color: #fff; + font-size: 24px; + line-height: 42px; + } + + &__selected-icon { + display: none; + margin-inline-start: 10px; + margin-inline-end: 6px; + color: #ff9900; + position: absolute; + left: 0; + top: 50%; + transform: translate(0, -50%); + + .active & { + display: inline-block; + } + } +} + +.user-settings-popup { + &__row { + margin-bottom: 10px; + label { + display: block; + font-weight: bold; + } + input { + display: block; + } + } + &__error { + color: red; + } +} + +@media (max-width: 768px) { + .sub-tabs-links__item { + font-size: 6px; + } + + .app-header { + & __content { + padding: 0 !important; + } + .dots { + opacity: 1 !important; + position: absolute; + right: 2%; + } + + &__content .app-header-left { + display: none !important; + } + } + + .widget-content-left { + .widget-heading { + opacity: 0 !important; + } + } + + .header-megamenu { + opacity: 0; + } + + .app-header__content { + .header-btn-lg { + padding: 0 0 0 12px; + } + } +} \ No newline at end of file diff --git a/frontend/app/styles/components/app/searchBy.scss b/frontend/app/styles/components/app/searchBy.scss new file mode 100644 index 0000000..d4c7a23 --- /dev/null +++ b/frontend/app/styles/components/app/searchBy.scss @@ -0,0 +1,74 @@ +.search-by-container { + position: relative; +} + +.search-by { + position: relative; + transition-timing-function: linear; + transition-duration: 0.75s; + + .nav-tabs .nav-item { + .nav-link { + &.active { + border: 0; + border-bottom: 2px solid $primary; + } + } + } + + .visible & { + max-height: 500px; // change for responsive + } + + .closed & { + overflow: hidden; + max-height: 34px; + + &:before { + content: ''; + position: absolute; + background: linear-gradient(0deg, white, rgba(white, 0)); + top: 0; + left: 0; + width: 100%; + height: 100%; + } + } + + .search-by-lang { + columns: 7; + -webkit-columns: 7; + + @media (max-width: 991px) { + columns: 5; + -webkit-columns: 5; + } + + @media (max-width: 575px) { + columns: 2; + -webkit-columns: 2; + } + + .custom-checkbox { + margin-bottom: 0.25rem; + } + } + + .source-table-wrap { + max-height: 300px; + overflow: auto; + + .clickable { + cursor: pointer; + + &:hover { + color: $primary; + } + } + + .active { + cursor: unset; + color: $primary; + } + } +} diff --git a/frontend/app/styles/components/app/searchTab.scss b/frontend/app/styles/components/app/searchTab.scss new file mode 100644 index 0000000..54450de --- /dev/null +++ b/frontend/app/styles/components/app/searchTab.scss @@ -0,0 +1,419 @@ +.searching-block { + display: flex; + padding: 8px 0; + + & input { + width: 99%; + min-height: 38px; + border-radius: 3px; + } + + &__input-wrap { + flex-grow: 1; + } + + &__search-box { + height: 34px; + font-size: 15px; + } + + &__btns { + .button { + height: 34px; + margin-inline-start: 7px; + } + } +} + +.search-last-dates { + display: flex; + justify-content: space-between; + border-bottom: 1px solid #bcbec0; + + &__item { + display: inline-block; + padding: 0 5px; + height: 30px; + line-height: 30px; + color: #009ddb; + font-size: 13px; + font-weight: 700; + cursor: pointer; + border: 1px solid transparent; + + &:hover { + border-color: #009ddb; + } + + &.active { + border-color: #009ddb; + background: #009ddb; + color: #fff; + } + + &.disabled { + color: #d2d4d5; + cursor: default; + border-color: transparent !important; + } + } +} + +//search page content +.search-content { + display: flex; +} + +.search-results { + flex-grow: 1; +} + +.search-results-block { + position: relative; +} + +.article-share-menu { + top: 40px; + right: 0; + position: absolute; + background-color: white; + border: solid 1px rgb(188, 190, 192); + box-shadow: 0 1px 2px 1px rgba(0, 0, 0, 0.2); + + a { + line-height: 15px; + padding: 5px; + display: block; + color: #373739; + cursor: pointer; + + &:hover { + background: #d9f0fa; + } + } +} + +[dir='rtl'] .article-share-menu { + left: 0; + right: auto; +} + +/* +.recent-feed { + padding: 3px 5px 3px 24px; + margin: 1px; + background: #e9e9ea; + cursor: pointer; + position: relative; + + &:after { + content: ''; + width: 16px; + height: 100%; + position: absolute; + top: 0; + left: 5px; + background-position: center center; + background-repeat: no-repeat; + } + + &.feed-type-mixed:after { + background-image: url('../images/feed-type-mixed.png'); + } + + &.feed-type-videos:after { + background-image: url('../images/feed-type-videos.png'); + } + + &.feed-type-blogs:after { + background-image: url('../images/feed-type-blogs.png'); + } + + &.feed-type-socials:after { + background-image: url('../images/feed-type-socials.png'); + } + + &.feed-type-news:after { + background-image: url('../images/feed-type-news.png'); + } + + &.feed-type-forums:after { + background-image: url('../images/feed-type-forums.png'); + } + + &.feed-type-clippings:after { + background-image: url('../images/feed-type-clippings.png'); + } + + &.feed-type-prints:after { + background-image: url('../images/feed-type-prints.png'); + } + + &.feed-type-user-comments:after { + background-image: url('../images/feed-type-user-comments.png'); + } + + &.feed-type-user-added:after { + background-image: url('../images/feed-type-user-added.png'); + } +} + */ + +.feed-icon { + &:before { + font-family: 'Pe-icon-7-stroke'; + display: inline-block; + padding-inline-end: 6px; + vertical-align: middle; + font-size: 1.1704rem; + line-height: 0.75em; + vertical-align: -15%; + } + + &.feed-type-mixed:before { + content: '\e645'; + } + + &.feed-type-videos:before { + content: '\e604'; + } + + &.feed-type-blogs:before { + content: '\e65d'; + } + + &.feed-type-socials:before { + content: '\e693'; + } + + &.feed-type-news:before { + content: '\e62e'; + } + + &.feed-type-forums:before { + content: '\e69e'; + } + + &.feed-type-clippings:before { + content: '\e697'; + } + + &.feed-type-prints:before { + content: '\e61f'; + } + + &.feed-type-user-comments:before { + content: '\e668'; + } + + &.feed-type-user-added:before { + content: '\e6c8'; + } +} + +.refine-panel { + // width: 100%; + min-width: 240px; + max-width: 240px; +} + +@media (max-width: 767px) { + .refine-panel { + width: 100%; + max-width: unset; + } +} + +/* new */ +.search-tab { + .search-input-field { + .form-control { + height: 3rem; + padding: 1rem; + } + } +} + +.post { + position: relative; + + &__title { + display: inline-block; + font-size: 16px; + margin-inline-end: 24px; + margin-bottom: 0.25rem; + overflow-wrap: anywhere; // not supported in safari + } + + &__menu { + position: absolute; + right: 0; + top: 0; + } + + &_middlepart { + display: flex; + flex-direction: column; + flex-grow: 1; + padding: 1rem; + } + + &__content { + display: flex; + flex-grow: 1; + flex-direction: row; + align-items: flex-start; + } + + &__icons { + display: flex; + flex-direction: column; + padding: 1rem 1rem 1rem; + padding-inline-end: 0.5rem; + border-right: 1px solid $gray-300; + // opacity: 0.7; + } + + &__extras { + border-right: 1px solid $gray-300; + min-width: 140px; + max-width: 140px; + } + + &__icons-wrapper { + // d-flex flex-row flex-wrap justify-content-around + display: flex; + flex-direction: column; + flex-wrap: wrap; + } + + &__icon-metrics { + display: inline-flex; + flex-direction: row; + align-items: center; + padding: 0.3rem; + margin-inline-end: 0.5rem; + // border: 1px solid $gray-300; + border-radius: 5px; + + p { + color: $text-muted; + } + } + + &__tags { + color: $text-muted; + font-size: $font-size-xs; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; /* number of lines to show */ + -webkit-box-orient: vertical; + } + + &__about-info { + text-align: right; + font-size: 12px; + } + + &__desc { + font-size: 14px; + overflow-wrap: anywhere; + word-break: break-word; + // white-space: pre-wrap; + + .cw-keyword--highlight { + color: $primary; + } + } + + &__desc-link { + color: $gray-700; + } + + &__img { + width: 180px; + height: auto; + background-size: cover; + } + + &__commentor { + font-style: normal; + font-size: 13px; + } + + &__cmttime { + font-style: normal; + font-size: 12px; + } + + &__cmt-content { + font-size: 12px; + padding: 10px; + border-radius: 5px; + color: $secondary; + background-color: $gray-100; + line-height: normal; + word-break: break-word; + white-space: pre-wrap; + } +} + +.draggable { + &-container { + padding: 1rem; + } + + &-item { + cursor: move; + display: inline-flex; + align-items: center; + border-radius: 5px; + padding: 0.3rem 0.5rem; + box-shadow: rgba(0, 0, 0, 0.12) 0px 1px 3px, rgba(0, 0, 0, 0.24) 0px 1px 2px; + border: 1px solid rgba($primary, 0.5); + } +} + +.drag-handle { + margin-inline-end: 10px; + display: inline-block; + width: 16px; + height: 8px; + transform: rotate(90deg); + + &:before, + &:after { + content: ''; + display: block; + width: 100%; + height: 50%; + background-image: radial-gradient($gray-500 40%, transparent 40%); + background-size: 4px 4px; + background-position: 0 100%; + background-repeat: repeat-x; + } +} + +[dir='rtl'] .post { + &__menu { + position: absolute; + left: 0; + right: unset; + top: 0; + } + + &__icons { + border-left: 1px solid $gray-300; + border-right: unset; + } + + &__extras { + border-left: 1px solid $gray-300; + border-right: unset; + } + + &__about-info { + text-align: left; + } +} diff --git a/frontend/app/styles/components/app/shareTab.scss b/frontend/app/styles/components/app/shareTab.scss new file mode 100644 index 0000000..2fd4588 --- /dev/null +++ b/frontend/app/styles/components/app/shareTab.scss @@ -0,0 +1,104 @@ +.notifications-topbar { + display: flex; + justify-content: space-between; + margin-bottom: 1rem; + + &_buttons_wrap { + display: block; + width: 100%; + } + + .notifications-buttons { + float: right; + + .button { + margin: 5px; + } + } + + .middle-text { + text-align: center; + font-size: 18px; + color: #6c757d; + margin: 10px; + } + + .left { + float: left; + + & > div { + display: inline-block; + font-size: 16px; + margin-inline-end: 5px; + } + + .Select { + display: inline-block; + width: 100px; + vertical-align: middle; + } + } +} + +[dir='rtl'] .notifications-topbar { + .notifications-buttons { + float: left; + } + + .left { + float: right; + } +} + +.share-tab-form-container { + .schedule-options, + .schedule-options__group, + .new-schedule { + display: flex; + align-items: center; + flex-wrap: wrap; + } + + .schedule-select-field { + margin: 0 5px; + min-width: 130px; + } + + .schedule-select-field--time { + min-width: 155px; + } + + .schedule-select-field--hour, + .schedule-select-field--minute { + min-width: 60px; + } + + .share-tab-form-body { + display: flex; + + .recipient-form { + width: 20%; + + .recipient-form__title { + margin-bottom: 15px; + } + + .form-field { + display: flex; + flex-direction: column; + margin-bottom: 15px; + } + } + } +} + +@media (max-width: 768px) { + .notifications-topbar { + flex-direction: column; + + .notifications-buttons { + float: right; + margin-top: 0.5rem; + } + } +} diff --git a/frontend/app/styles/components/app/sidebar.scss b/frontend/app/styles/components/app/sidebar.scss new file mode 100644 index 0000000..ceab091 --- /dev/null +++ b/frontend/app/styles/components/app/sidebar.scss @@ -0,0 +1,622 @@ +.app-sidebar { + // position: fixed; + // top: 60px; + // bottom: 0px; + // z-index: 9; + + &.animation-disabled { + .sidebar-divider, + .sidebar-panel { + animation: none !important; + background: #2e3047; + } + } +} + +.sidebar-divider { + border-left: 1px solid #74bfab; + top: 0; + right: auto; + bottom: 0; + position: absolute; + z-index: 1; + //left: 222px; + left: $sidebar-width; + + .sidebar-opened & { + animation: openSidebarHandle 1s; + } + .sidebar-closed & { + animation: closeSidebarHandle 1s; + left: 0; + } + + &__handle { + cursor: pointer; + height: 70px; + width: 11px; + overflow: hidden; + position: absolute; + top: 45%; + border-radius: 0 2px 2px 0; + background: #74bfab; + } + + &__arrow-right, + &__arrow-left { + line-height: 70px; + color: #fff; + font-size: 14px; + padding-inline-start: 2px; + } + + &__arrow-right { + .sidebar-opened & { + display: none; + } + } + + &__arrow-left { + .sidebar-opened & { + display: block; + } + } +} + +@keyframes openSidebarHandle { + 0% { + left: 0; + } + 100% { + left: $sidebar-width; + } +} +@keyframes closeSidebarHandle { + 0% { + left: $sidebar-width; + } + 100% { + left: 0; + } +} + +.sidebar-panel { + zoom: 1; + position: absolute; + top: 0; + bottom: 0; + overflow: auto; + background: #2e3047; + + $search-box-size: 42px; + + .search-wrapper { + margin: 1rem; + position: relative; + margin-inline-end: ($nav-link-padding-x / 1.5); + + .input-holder { + height: $search-box-size; + width: $search-box-size; + overflow: hidden; + position: relative; + transition: all 0.3s ease-in-out; + + .search-input { + width: 100%; + padding: 0 70px 0 20px; + opacity: 0; + position: absolute; + top: 0; + left: 0; + background: transparent; + box-sizing: border-box; + border: none; + outline: none; + transform: translate(0, 60px); + transition: all 0.3s cubic-bezier(0, 0.105, 0.035, 1.57); + transition-delay: 0.3s; + font-size: $font-size-base; + } + + .search-icon { + width: $search-box-size; + height: $search-box-size; + border: none; + padding: 0; + outline: none; + position: relative; + z-index: 2; + float: right; + cursor: pointer; + transition: all 0.3s ease-in-out; + background: aliceblue; + border-radius: 30px; + + span { + width: 22px; + height: 22px; + display: inline-block; + vertical-align: middle; + position: relative; + transform: rotate(45deg); + transition: all 0.4s cubic-bezier(0.65, -0.6, 0.24, 1.65); + + &::before, + &::after { + position: absolute; + content: ''; + } + + &::before { + width: 4px; + height: 11px; + left: 9px; + top: 13px; + border-radius: 2px; + background: #007bff; + } + + &::after { + width: 14px; + height: 14px; + left: 4px; + top: 0; + border-radius: 16px; + border: 2px solid #007bff; + } + } + } + } + + .close { + position: absolute; + z-index: 1; + top: 50%; + left: 0; + width: 20px; + height: 20px; + margin-top: -10px; + cursor: pointer; + opacity: 0 !important; + transform: rotate(-180deg); + transition: all 0.2s cubic-bezier(0.285, -0.45, 0.935, 0.11); + transition-delay: 0.1s; + + &::before, + &::after { + position: absolute; + content: ''; + background: #007bff; + border-radius: 2px; + } + + &::before { + width: 2px; + height: 20px; + left: 9px; + top: 0; + } + + &::after { + width: 20px; + height: 2px; + left: 0; + top: 9px; + } + } + + &.active { + width: 330px; + + .input-holder { + width: 190px; + border-radius: 50px; + background: #ddd; + transition: all 0.5s cubic-bezier(0, 0.105, 0.035, 1.57); + + .search-input { + opacity: 1; + transform: translate(0, 11px); + } + + .search-icon { + width: $search-box-size; + height: $search-box-size; + margin: 0; + border-radius: 30px; + + span { + transform: rotate(-45deg); + } + } + } + + .close { + left: 150px; + opacity: 0.6 !important; + transform: rotate(45deg); + transition: all 0.6s cubic-bezier(0, 0.105, 0.035, 1.57); + transition-delay: 0.5s; + + &:hover { + opacity: 1 !important; + } + } + + & + .header-megamenu { + opacity: 0; + } + } + } + + .search-wrapper { + position: relative; + margin-inline-end: 0.66667rem; + + .input-holder { + height: 42px; + width: 42px; + overflow: hidden; + position: relative; + transition: all 0.3s ease-in-out; + + .search-input { + width: 100%; + padding: 0 70px 0 20px; + opacity: 0; + position: absolute; + top: 0; + left: 0; + background: transparent; + box-sizing: border-box; + border: none; + outline: none; + transform: translate(0, 60px); + transition: all 0.3s cubic-bezier(0, 0.105, 0.035, 1.57); + transition-delay: 0.3s; + font-size: 0.88rem; + } + + .search-icon { + width: 42px; + height: 42px; + border: none; + padding: 0; + outline: none; + position: relative; + z-index: 2; + float: right; + cursor: pointer; + transition: all 0.3s ease-in-out; + background: aliceblue; + border-radius: 30px; + + span { + width: 22px; + height: 22px; + display: inline-block; + vertical-align: middle; + position: relative; + transform: rotate(45deg); + transition: all 0.4s cubic-bezier(0.65, -0.6, 0.24, 1.65); + } + + span::before { + position: absolute; + content: ''; + width: 4px; + height: 11px; + left: 9px; + top: 13px; + border-radius: 2px; + background: #597ea9; + } + + span::after { + position: absolute; + content: ''; + width: 14px; + height: 14px; + left: 4px; + top: 0; + border-radius: 16px; + border: 2px solid #597ea9; + } + } + } + } + + .sidebar-opened & { + animation: openSidebar 1s; + } + .sidebar-closed & { + animation: closeSidebar 1s; + overflow: hidden; + width: 0; + } + + &__wrap { + width: $sidebar-width; + padding: 0 15px 0 30px; + } +} + +@keyframes openSidebar { + 0% { + width: 0; + overflow: hidden; + } + 99% { + overflow: hidden; + } + 100% { + width: $sidebar-width; + overflow: visible; + } +} +@keyframes closeSidebar { + 0% { + width: $sidebar-width; + } + 100% { + width: 0; + } +} + +.app { + .sidebar-search { + position: relative; + margin-top: 12px; + margin-bottom: 12px; + + &__input { + height: 26px !important; + // border: 1px solid #3bba9c !important; + border-radius: 16px; + background: none !important; + color: #0291ae; + padding: 0 32px 0 10px !important; + + &:hover, + &:focus { + outline: none; + } + + &:focus { + border-color: #66afe9; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), + 0 0 8px rgba(102, 175, 233, 0.6); + } + &[type='text'] { + color: #f9f7f0; + } + } + + &__btn { + width: 32px; + height: 26px; + line-height: 24px; + position: absolute; + right: 0; + top: 0; + text-align: center; + color: #fff; + background: transparent; + font-size: 14px; + border: none; + cursor: pointer; + + &:focus { + outline: none; + } + + &--clear { + cursor: pointer; + } + } + } +} + +.sidebar-categories { + //border-bottom: 1px solid #bcbec0; +} + +.sidebar-category { + &.active-category { + position: relative; + } + + &.active-category::before { + content: ''; + height: 100%; + opacity: 1; + width: 2px; + background: hsla(0, 0%, 100%, 0.2); + position: absolute; + left: 10px; + top: 0; + border-radius: 15px; + } + + &__head { + position: relative; + //border-bottom: 1px solid #bcbec0; + padding: 5px 20px 5px 25px; + margin-top: 3px; + text-align: start; + font-size: 14px; + cursor: pointer; + color: #f9f7f0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__head:hover { + background: rgba(255, 255, 255, 0.3); + } + + &__closed-icon, + &__open-icon { + height: 21px; + line-height: 20px; + top: 3px; + right: auto; + bottom: auto; + left: 7px; + position: absolute; + } + + &__closed-icon { + display: inline-block; + + .active-category > * > & { + display: none; + } + } + + &__open-icon { + display: none; + + .active-category > * > & { + display: inline-block; + } + } + + &__menu-btn { + position: absolute; + top: 50%; + right: 0; + margin-top: -10px; + display: block; + height: 20px; + width: 20px; + text-align: center; + cursor: pointer; + color: #787878; + line-height: 20px; + } + + &__body { + display: none; + margin-inline-start: 15px; + + .active-category > & { + display: block; + } + } + + &__dropdown { + position: fixed; + //top: 0; + left: 220px; + border: 1px solid #bcbec0; + width: 170px; + z-index: 9; + box-shadow: 0 1px 2px 1px rgba(0, 0, 0, 0.2); + background: #fff; + + a { + display: block; + white-space: nowrap; + font-size: 13px; + height: 30px; + line-height: 30px; + padding: 0 10px; + color: #373739; + + &:hover { + background: #d9f0fa; + } + } + } + + &__icon { + margin-inline-end: 5px; + } +} +/* +.sidebar-feed { + position: relative; + padding: 3px 5px 3px 24px; + height: 2.3em; + line-height: 2.3em; + color: #fff; + margin: 0 0 3px; + //cursor: pointer; + //border-right: 1px solid #bcbec0; + //border-left: 1px solid #bcbec0; + + &:after { + content: ""; + width: 16px; + height: 100%; + position: absolute; + top: 0; + left: 5px; + background-position: center center; + background-repeat: no-repeat; + } + + &.feed-type-mixed:after { + background-image: url("../images/feed-type-mixed.png"); + } + + &.feed-type-videos:after { + background-image: url("../images/feed-type-videos.png"); + } + + &.feed-type-blogs:after { + background-image: url("../images/feed-type-blogs.png"); + } + + &.feed-type-socials:after { + background-image: url("../images/feed-type-socials.png"); + } + + &.feed-type-news:after { + background-image: url("../images/feed-type-news.png"); + } + + &.feed-type-forums:after { + background-image: url("../images/feed-type-forums.png"); + } + + &.feed-type-clippings:after { + background-image: url("../images/feed-type-clippings.png"); + } + + &.feed-type-prints:after { + background-image: url("../images/feed-type-prints.png"); + } + + &.feed-type-user-comments:after { + background-image: url("../images/feed-type-user-comments.png"); + } + + &.feed-type-user-added:after { + background-image: url("../images/feed-type-user-added.png"); + } + + &__link { + color: #999; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + display: block; + padding-inline-end: 15px; + + &:hover { + color: #188ca5; + } + } +} + */ + +[dir='rtl'] .sidebar-category { + &__dropdown { + left: unset; + right: 220px; + } +} diff --git a/frontend/app/styles/components/block-loading/_block-loading.scss b/frontend/app/styles/components/block-loading/_block-loading.scss new file mode 100644 index 0000000..aee1803 --- /dev/null +++ b/frontend/app/styles/components/block-loading/_block-loading.scss @@ -0,0 +1,67 @@ +.loading-indicator { + text-align: center; +} + +.loading-bullet { + display: inline-block; + opacity: 0; + font-size: 2em; + color: #02a17c; +} + +.block-ui { + position: relative; + min-height: 3em; +} + +.block-ui-container { + position: absolute; + z-index: 1010; + top: 0; + right: 0; + bottom: 0; + left: 0; + height: 100%; + min-height: 2em; + cursor: wait; + overflow: hidden; +} + +.block-ui-container:focus { + outline: none; +} + +.block-ui-overlay { + width: 100%; + height: 100%; + opacity: 0.5; + filter: alpha(opacity=50); + background-color: white; +} + +.block-ui-message-container { + position: absolute; + top: 50%; + left: 0; + width: 100%; + right: 0; + text-align: center; + transform: translateY(-50%); + z-index: 10001; +} + +.block-ui-message { + color: #333; + background: none; + z-index: 1011; + + display: flex; + align-items: center; + justify-content: center; +} + +.block-overlay-dark { + .block-ui-overlay { + background: rgba(0,0,0,1); + } +} \ No newline at end of file diff --git a/frontend/app/styles/components/bootstrap4/_alert.scss b/frontend/app/styles/components/bootstrap4/_alert.scss new file mode 100644 index 0000000..da2a98a --- /dev/null +++ b/frontend/app/styles/components/bootstrap4/_alert.scss @@ -0,0 +1,51 @@ +// +// Base styles +// + +.alert { + position: relative; + padding: $alert-padding-y $alert-padding-x; + margin-bottom: $alert-margin-bottom; + border: $alert-border-width solid transparent; + @include border-radius($alert-border-radius); +} + +// Headings for larger alerts +.alert-heading { + // Specified to prevent conflicts of changing $headings-color + color: inherit; +} + +// Provide class for links that match alerts +.alert-link { + font-weight: $alert-link-font-weight; +} + + +// Dismissible alerts +// +// Expand the right padding and account for the close button's positioning. + +.alert-dismissible { + padding-right: $close-font-size + $alert-padding-x * 2; + + // Adjust close link position + .close { + position: absolute; + top: 0; + right: 0; + padding: $alert-padding-y $alert-padding-x; + color: inherit; + } +} + + +// Alternate styles +// +// Generate contextual modifier classes for colorizing the alert. + +@each $color, $value in $theme-colors { + .alert-#{$color} { + @include alert-variant(theme-color-level($color, $alert-bg-level), theme-color-level($color, $alert-border-level), theme-color-level($color, $alert-color-level)); + } +} diff --git a/frontend/app/styles/components/bootstrap4/_badge.scss b/frontend/app/styles/components/bootstrap4/_badge.scss new file mode 100644 index 0000000..2082f05 --- /dev/null +++ b/frontend/app/styles/components/bootstrap4/_badge.scss @@ -0,0 +1,54 @@ +// Base class +// +// Requires one of the contextual, color modifier classes for `color` and +// `background-color`. + +.badge { + display: inline-block; + padding: $badge-padding-y $badge-padding-x; + @include font-size($badge-font-size); + font-weight: $badge-font-weight; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + @include border-radius($badge-border-radius); + @include transition($badge-transition); + + @at-root a#{&} { + @include hover-focus { + text-decoration: none; + } + } + + // Empty badges collapse automatically + &:empty { + display: none; + } +} + +// Quick fix for badges in buttons +.btn .badge { + position: relative; + top: -1px; +} + +// Pill badges +// +// Make them extra rounded with a modifier to replace v3's badges. + +.badge-pill { + padding-right: $badge-pill-padding-x; + padding-left: $badge-pill-padding-x; + @include border-radius($badge-pill-border-radius); +} + +// Colors +// +// Contextual variations (linked badges get darker on :hover). + +@each $color, $value in $theme-colors { + .badge-#{$color} { + @include badge-variant($value); + } +} diff --git a/frontend/app/styles/components/bootstrap4/_breadcrumb.scss b/frontend/app/styles/components/bootstrap4/_breadcrumb.scss new file mode 100644 index 0000000..be30950 --- /dev/null +++ b/frontend/app/styles/components/bootstrap4/_breadcrumb.scss @@ -0,0 +1,41 @@ +.breadcrumb { + display: flex; + flex-wrap: wrap; + padding: $breadcrumb-padding-y $breadcrumb-padding-x; + margin-bottom: $breadcrumb-margin-bottom; + list-style: none; + background-color: $breadcrumb-bg; + @include border-radius($breadcrumb-border-radius); +} + +.breadcrumb-item { + // The separator between breadcrumbs (by default, a forward-slash: "/") + + .breadcrumb-item { + padding-left: $breadcrumb-item-padding; + + &::before { + display: inline-block; // Suppress underlining of the separator in modern browsers + padding-right: $breadcrumb-item-padding; + color: $breadcrumb-divider-color; + content: $breadcrumb-divider; + } + } + + // IE9-11 hack to properly handle hyperlink underlines for breadcrumbs built + // without `
    +
    diff --git a/src/AdminBundle/Resources/views/User/Admin/edit.html.twig b/src/AdminBundle/Resources/views/User/Admin/edit.html.twig new file mode 100644 index 0000000..7100fec --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Admin/edit.html.twig @@ -0,0 +1,5 @@ +{% extends 'AdminBundle:User/Layout:edit_layout.html.twig' %} + +{% block edit_title %} + Administrator edit +{% endblock edit_title %} diff --git a/src/AdminBundle/Resources/views/User/Admin/index.html.twig b/src/AdminBundle/Resources/views/User/Admin/index.html.twig new file mode 100644 index 0000000..a51e714 --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Admin/index.html.twig @@ -0,0 +1,5 @@ +{% extends 'AdminBundle:User/Layout:index_layout.html.twig' %} + +{% block index_title %} + Administrators list +{% endblock index_title %} \ No newline at end of file diff --git a/src/AdminBundle/Resources/views/User/Admin/new.html.twig b/src/AdminBundle/Resources/views/User/Admin/new.html.twig new file mode 100644 index 0000000..8319f0d --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Admin/new.html.twig @@ -0,0 +1,5 @@ +{% extends 'AdminBundle:User/Layout:new_layout.html.twig' %} + +{% block new_title %} + Administrator creation +{% endblock new_title %} diff --git a/src/AdminBundle/Resources/views/User/Admin/show.html.twig b/src/AdminBundle/Resources/views/User/Admin/show.html.twig new file mode 100644 index 0000000..463311e --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Admin/show.html.twig @@ -0,0 +1,24 @@ +{% extends 'AdminBundle:User/Layout:show_layout.html.twig' %} + +{% block show_title %} + Administrator +{% endblock show_title %} + +{% block show_content %} + + + + + + + + + + + + + + + +
    Id{{ user.id }}
    Firstname{{ user.firstName }}
    Lastname{{ user.lastName }}
    +{% endblock show_content %} diff --git a/src/AdminBundle/Resources/views/User/Layout/edit_layout.html.twig b/src/AdminBundle/Resources/views/User/Layout/edit_layout.html.twig new file mode 100644 index 0000000..351629a --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Layout/edit_layout.html.twig @@ -0,0 +1,20 @@ +{% extends 'AdminBundle::layout-inner.html.twig' %} + +{% block inner_content %} +

    + {%- block edit_title -%} + {%- endblock edit_title -%} +

    + +
    +{% endblock %} diff --git a/src/AdminBundle/Resources/views/User/Layout/index_layout.html.twig b/src/AdminBundle/Resources/views/User/Layout/index_layout.html.twig new file mode 100644 index 0000000..c2cd789 --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Layout/index_layout.html.twig @@ -0,0 +1,92 @@ +{% extends 'AdminBundle::layout-inner.html.twig' %} + +{% block inner_content %} +

    + {%- block index_title -%} + {%- endblock index_title -%} +

    + +
    +
    + {{ form_start(search) }} +
    + + + + +
    + {{ form_end(search, {'render_rest': false}) }} +
    +
    + + + + + + + + + + + {% for user in users %} + + + + + + + {% endfor %} + +
    {{ knp_pagination_sortable(users, 'Email', 'User.email') }}{{ knp_pagination_sortable(users, 'First Name', 'User.firstName') }}{{ knp_pagination_sortable(users, 'Last Name', 'User.lastName') }}Actions
    {{ user.email }}{{ user.firstName }}{{ user.lastName }} + {%- block index_row_buttons -%} + Show + Edit + + + {%- endblock index_row_buttons -%} +
    + + + + {%- block inner_footer -%} + + {%- endblock inner_footer -%} + +{% endblock %} + +{% block javascripts %} + {{- parent() -}} + + +{% endblock javascripts %} \ No newline at end of file diff --git a/src/AdminBundle/Resources/views/User/Layout/new_layout.html.twig b/src/AdminBundle/Resources/views/User/Layout/new_layout.html.twig new file mode 100644 index 0000000..4949dd7 --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Layout/new_layout.html.twig @@ -0,0 +1,20 @@ +{% extends 'AdminBundle::layout-inner.html.twig' %} + +{% block inner_content %} +

    + {%- block new_title -%} + {%- endblock new_title -%} +

    + +
    + {{ form_start(form) }} + {{ form_widget(form) }} +
    + +
    + {{ form_end(form) }} +
    +{% endblock %} diff --git a/src/AdminBundle/Resources/views/User/Layout/show_layout.html.twig b/src/AdminBundle/Resources/views/User/Layout/show_layout.html.twig new file mode 100644 index 0000000..15684bb --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Layout/show_layout.html.twig @@ -0,0 +1,23 @@ +{% extends 'AdminBundle::layout-inner.html.twig' %} + +{% block inner_content %} +

    + {%- block show_title -%} + {%- endblock show_title -%} +

    + + {%- block show_content -%} + {%- endblock show_content -%} + +
    + {%- block show_buttons -%} + +
    + Edit + Delete +
    + {%- endblock show_buttons -%} +
    +{% endblock inner_content %} diff --git a/src/AdminBundle/Resources/views/User/Master/edit.html.twig b/src/AdminBundle/Resources/views/User/Master/edit.html.twig new file mode 100644 index 0000000..8c7f299 --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Master/edit.html.twig @@ -0,0 +1,10 @@ +{% extends 'AdminBundle:User/Layout:edit_layout.html.twig' %} + +{% block edit_title %} + Master User edit +{% endblock edit_title %} + +{% block javascripts %} + {{ parent() }} + +{% endblock javascripts %} diff --git a/src/AdminBundle/Resources/views/User/Master/index.html.twig b/src/AdminBundle/Resources/views/User/Master/index.html.twig new file mode 100644 index 0000000..d3d2957 --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Master/index.html.twig @@ -0,0 +1,15 @@ +{% extends 'AdminBundle:User/Layout:index_layout.html.twig' %} + +{% block index_title %} + Master User list +{% endblock index_title %} + +{% block index_row_buttons %} + {{- parent() -}} + +{% endblock index_row_buttons %} \ No newline at end of file diff --git a/src/AdminBundle/Resources/views/User/Master/new.html.twig b/src/AdminBundle/Resources/views/User/Master/new.html.twig new file mode 100644 index 0000000..232a848 --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Master/new.html.twig @@ -0,0 +1,10 @@ +{% extends 'AdminBundle:User/Layout:new_layout.html.twig' %} + +{% block new_title %} + Master User creation +{% endblock new_title %} + +{% block javascripts %} + {{ parent() }} + +{% endblock javascripts %} \ No newline at end of file diff --git a/src/AdminBundle/Resources/views/User/Master/show.html.twig b/src/AdminBundle/Resources/views/User/Master/show.html.twig new file mode 100644 index 0000000..66b6c51 --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Master/show.html.twig @@ -0,0 +1,185 @@ +{% extends 'AdminBundle:User/Layout:show_layout.html.twig' %} + +{% form_theme subscriberForm _self %} + +{%- set billingType = user.billingSubscription.subscriptionType -%} + +{% block show_title %} + Master User +{% endblock show_title %} + +{% block show_content %} +

    + User information +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    First name{{ user.firstName }}
    Last name{{ user.lastName }}
    + Expiration Day + {{ user.getExpirationDay|date("m/d/Y") }}
    Number of subscribers + {{- user.billingSubscription.getPlan.subscriberAccounts }} / + {{ user.billingSubscription.subscriberAccounts -}} +
    Number of saved feeds allowed + {{- user.billingSubscription.getPlan.savedFeeds }} / + {{ user.billingSubscription.savedFeeds -}} +
    Number of alerts allowed + {{- user.billingSubscription.getPlan.alerts }} / + {{ user.billingSubscription.alerts -}} +
    Number of newsletters allowed + {{- user.billingSubscription.getPlan.newsletters }} / + {{ user.billingSubscription.newsletters -}} +
    Number of ad-hoc searches per day allowed + {{- user.billingSubscription.getPlan.searchesPerDay }} / + {{ user.billingSubscription.searchesPerDay -}} +
    + Billing subscription type + + {{- billingType.value is constant('ORGANIZATION', billingType) ? 'organization' : 'personal' -}} +
    + + {%- if billingType.value is constant('ORGANIZATION', billingType) -%} +

    + Organization information +

    + + + + + + + + + + + + + + + + + + + +
    + Name + + {{- user.billingSubscription.getOrganization.name -}} +
    + Department address + + {{- user.billingSubscription.organizationAddress -}} +
    + Department email + + {{- user.billingSubscription.organizationEmail -}} +
    + Department phone + + {{- user.billingSubscription.organizationPhone -}} +
    + {%- endif -%} + +

    + Subscribers +

    + + + + {# Add subscribers on MasterUser page Modal #} + + +{% endblock show_content %} + +{% block show_buttons %} + {{- parent() -}} +
    + +
    +{% endblock show_buttons %} + +{% block javascripts %} + {{ parent() }} + +{% endblock javascripts %} + +{% block form_errors -%} + +
      + {%- for error in errors -%} +
    • {{ error.message }}
    • + {%- endfor -%} +
    +
    +{%- endblock form_errors %} + +{%- block hidden_widget -%} +
    + {%- set type = type|default('hidden') -%} + {{ block('form_widget_simple') }} +
    +{%- endblock hidden_widget -%} \ No newline at end of file diff --git a/src/AdminBundle/Resources/views/User/Subscriber/edit.html.twig b/src/AdminBundle/Resources/views/User/Subscriber/edit.html.twig new file mode 100644 index 0000000..d626d6b --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Subscriber/edit.html.twig @@ -0,0 +1,5 @@ +{% extends 'AdminBundle:User/Layout:edit_layout.html.twig' %} + +{% block edit_title %} + Subscriber edit +{% endblock edit_title %} diff --git a/src/AdminBundle/Resources/views/User/Subscriber/index.html.twig b/src/AdminBundle/Resources/views/User/Subscriber/index.html.twig new file mode 100644 index 0000000..4c03b65 --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Subscriber/index.html.twig @@ -0,0 +1,15 @@ +{% extends 'AdminBundle:User/Layout:index_layout.html.twig' %} + +{% block index_title %} + Subscribers list +{% endblock index_title %} + +{% block index_row_buttons %} + {{- parent() -}} + +{% endblock index_row_buttons %} \ No newline at end of file diff --git a/src/AdminBundle/Resources/views/User/Subscriber/new.html.twig b/src/AdminBundle/Resources/views/User/Subscriber/new.html.twig new file mode 100644 index 0000000..6acc0ac --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Subscriber/new.html.twig @@ -0,0 +1,5 @@ +{% extends 'AdminBundle:User/Layout:new_layout.html.twig' %} + +{% block new_title %} + Subscriber creation +{% endblock new_title %} diff --git a/src/AdminBundle/Resources/views/User/Subscriber/show.html.twig b/src/AdminBundle/Resources/views/User/Subscriber/show.html.twig new file mode 100644 index 0000000..a225cf3 --- /dev/null +++ b/src/AdminBundle/Resources/views/User/Subscriber/show.html.twig @@ -0,0 +1,129 @@ +{% extends 'AdminBundle:User/Layout:show_layout.html.twig' %} + +{%- set billingType = user.billingSubscription.subscriptionType -%} + +{% block show_title %} + Subscriber +{% endblock show_title %} + +{% block show_content %} +

    + User information +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Firstname{{ user.firstName }}
    Lastname{{ user.lastName }}
    + Position + + {{ user.position }} +
    + Phone Number + + {{ user.phoneNumber }} +
    Number of saved feeds allowed + {{- user.billingSubscription.getPlan.savedFeeds }} / + {{ user.billingSubscription.savedFeeds -}} +
    Number of alerts allowed + {{- user.billingSubscription.getPlan.alerts }} / + {{ user.billingSubscription.alerts -}} +
    Number of newsletters allowed + {{- user.billingSubscription.getPlan.newsletters }} / + {{ user.billingSubscription.newsletters -}} +
    Number of ad-hoc searches per day allowed + {{- user.billingSubscription.getPlan.searchesPerDay }} / + {{ user.billingSubscription.searchesPerDay -}} +
    + Billing subscription type + + {{- billingType.value is constant('ORGANIZATION', billingType) ? 'organization' : 'personal' -}} +
    + Master + + + {{- user.masterUser.email -}} + +
    + + {%- if billingType.value is constant('ORGANIZATION', billingType) -%} +

    + Organization information +

    + + + + + + + + + + + + + + + + + + + +
    + Name + + {{- user.billingSubscription.getOrganization.name -}} +
    + Department address + + {{- user.billingSubscription.organizationAddress -}} +
    + Department email + + {{- user.billingSubscription.organizationEmail -}} +
    + Department phone + + {{- user.billingSubscription.organizationPhone -}} +
    + {%- endif -%} +{% endblock show_content %} diff --git a/src/AdminBundle/Resources/views/User/SuperAdmin/edit.html.twig b/src/AdminBundle/Resources/views/User/SuperAdmin/edit.html.twig new file mode 100644 index 0000000..e3ff2c8 --- /dev/null +++ b/src/AdminBundle/Resources/views/User/SuperAdmin/edit.html.twig @@ -0,0 +1,12 @@ +{% extends 'AdminBundle::layout-inner.html.twig' %} + +{% block inner_content %} +

    Administrator edit

    + +
    + {{ form_start(form) }} + {{ form_widget(form) }} + + {{ form_end(form) }} +
    +{% endblock %} \ No newline at end of file diff --git a/src/AdminBundle/Resources/views/UserVerification/index.html.twig b/src/AdminBundle/Resources/views/UserVerification/index.html.twig new file mode 100644 index 0000000..428ff4b --- /dev/null +++ b/src/AdminBundle/Resources/views/UserVerification/index.html.twig @@ -0,0 +1,40 @@ +{% extends 'AdminBundle::layout-inner.html.twig' %} + +{% block inner_content %} +

    + Not verified users +

    + + + + + + + + + + + + {% for user in users %} + + + + + + + {% else %} + + + + {% endfor %} + +
    {{ knp_pagination_sortable(users, 'Email', 'User.email') }}{{ knp_pagination_sortable(users, 'First Name', 'User.firstName') }}{{ knp_pagination_sortable(users, 'Last Name', 'User.lastName') }}Actions
    {{ user.email }}{{ user.firstName }}{{ user.lastName }} + Show +
    No data.
    + + +{% endblock inner_content %} \ No newline at end of file diff --git a/src/AdminBundle/Resources/views/UserVerification/show.html.twig b/src/AdminBundle/Resources/views/UserVerification/show.html.twig new file mode 100644 index 0000000..281f60b --- /dev/null +++ b/src/AdminBundle/Resources/views/UserVerification/show.html.twig @@ -0,0 +1,46 @@ +{% extends 'AdminBundle::layout-inner.html.twig' %} + +{% block inner_content %} +

    + Verify user {{ user.fullName }} +

    + +

    + User information +

    +
    +
    Full name: {{ user.fullName }}
    +
    Email: {{ user.email }}
    +
    Phone: {{ user.phoneNumber }}
    +
    + +

    + Billing information +

    +
    +
    Subscription type: {{ subscription.subscriptionType.value }}
    +
    Plan: {{ plan.name }}
    +
    + + {%- if organization is not null -%} +

    + Organization information +

    + +
    +
    Name: {{ organization.name }}
    +
    Address: {{ subscription.organizationAddress }}
    +
    Email: {{ subscription.organizationEmail }}
    +
    Phone: {{ subscription.organizationPhone }}
    +
    + {%- endif -%} + +
    + +
    +
    + + +
    +
    +{% endblock inner_content %} \ No newline at end of file diff --git a/src/AdminBundle/Resources/views/layout-inner.html.twig b/src/AdminBundle/Resources/views/layout-inner.html.twig new file mode 100644 index 0000000..53866be --- /dev/null +++ b/src/AdminBundle/Resources/views/layout-inner.html.twig @@ -0,0 +1,66 @@ +{% extends "AdminBundle::layout.html.twig" %} + +{% block content %} + +
    + +
    + {%- for message in app.session.flashBag.get('admin_success') -%} +
    + {{- message -}} +
    + {%- endfor -%} + + + {%- for message in app.session.flashBag.get('admin_error') -%} +
    + {{- message -}} +
    + {%- endfor -%} + + {%- block inner_content -%} + {%- endblock -%} +
    +
    + +{% endblock %} diff --git a/src/AdminBundle/Resources/views/layout.html.twig b/src/AdminBundle/Resources/views/layout.html.twig new file mode 100644 index 0000000..5c9768f --- /dev/null +++ b/src/AdminBundle/Resources/views/layout.html.twig @@ -0,0 +1,24 @@ +{% extends "::base.html.twig" %} + +{%- block stylesheets -%} + {{ parent() }} + + + +{%- endblock -%} + +{% block body %} +
    + {% block content %} + {% endblock %} +
    +{% endblock %} + +{% block javascripts %} + + +{% endblock javascripts %} \ No newline at end of file diff --git a/src/AdminBundle/Twig/AdminTwigExtension.php b/src/AdminBundle/Twig/AdminTwigExtension.php new file mode 100644 index 0000000..77ba807 --- /dev/null +++ b/src/AdminBundle/Twig/AdminTwigExtension.php @@ -0,0 +1,49 @@ + 'Admin', + UserRoleEnum::MASTER_USER => 'Master User', + UserRoleEnum::SUPER_ADMIN => 'Super Admin', + UserRoleEnum::SUBSCRIBER => 'Subscriber', + ]; + + /** + * Returns a list of filters to add to the existing list. + * + * @return \Twig_SimpleFilter[] + */ + public function getFilters() + { + return [ + new \Twig_SimpleFilter('humanReadableRoles', function (User $user) { + $roles = $user->getRoles(); + $result = []; + foreach ($roles as $role) { + if (array_key_exists($role, self::$rolesMap)) { + $result[$role] = self::$rolesMap[$role]; + } + } + + return $result; + }), + ]; + } +} diff --git a/src/ApiBundle/ApiBundle.php b/src/ApiBundle/ApiBundle.php new file mode 100644 index 0000000..b07a173 --- /dev/null +++ b/src/ApiBundle/ApiBundle.php @@ -0,0 +1,30 @@ +addCompilerPass(new InspectorsCollectCompilerPass()); + } +} diff --git a/src/ApiBundle/ApiBundleServices.php b/src/ApiBundle/ApiBundleServices.php new file mode 100644 index 0000000..2a9234b --- /dev/null +++ b/src/ApiBundle/ApiBundleServices.php @@ -0,0 +1,19 @@ +container = $container; + } + + /** + * Get service from container. + * + * @param string $id The service identifier. + * + * @return object + */ + protected function get($id) + { + return $this->container->get($id); + } + + /** + * Gets a container configuration parameter by its name. + * + * @param string $name The parameter name. + * + * @return mixed + */ + protected function getParameter($name) + { + return $this->container->getParameter($name); + } + + /** + * Creates and returns a Form instance from the type of the form. + * + * @param string $type The fully qualified class name of the form type. + * @param mixed $data The initial data for the form. + * @param array $options Options for the form. + * + * @return FormInterface + */ + protected function createForm($type, $data = null, array $options = []) + { + return $this->container->get('form.factory') + ->create($type, $data, $options); + } + + /** + * Return default entity manager. + * + * @return ObjectManager + */ + protected function getManager() + { + /** @var Registry $doctrine */ + $doctrine = $this->container->get('doctrine'); + + $manager = $doctrine->getManager(); + + if (! $manager instanceof ObjectManager) { + throw new \LogicException('Should be instance of '. ObjectManager::class); + } + + return $manager; + } + + /** + * Get current User entity instance. + * + * @return \UserBundle\Entity\User + */ + protected function getCurrentUser() + { + /** @var \Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface $storage */ + $storage = $this->container->get('security.token_storage'); + + return $storage->getToken()->getUser(); + } + + /** + * @param string $action Action name. + * @param object|object[] $entity A Entity instance or array of entity instances. + * + * @return string[] Array of restriction reasons. + */ + protected function checkAccess($action, $entity) + { + /** @var AccessCheckerInterface $checker */ + $checker = $this->container->get(ApiBundleServices::ACCESS_CHECKER); + + if ($entity instanceof \Traversable) { + $entity = iterator_to_array($entity); + } elseif (is_object($entity)) { + $entity = [ $entity ]; + } + + if (! is_array($entity)) { + throw new \InvalidArgumentException('Expects single object or array of objects.'); + } + + $grantChecker = \nspl\f\partial([ $checker, 'isGranted' ], $action); + + return \nspl\a\flatten(\nspl\a\map($grantChecker, $entity)); + } + + /** + * Generate proper server response. + * + * @param mixed $data A data sent to client. + * @param integer $code Response http code. + * @param array $groups Serialization groups. + * + * @return \ApiBundle\Response\ViewInterface + */ + protected function generateResponse( + $data = null, + $code = null, + array $groups = [] + ) { + return new View($data, $groups, $code); + } + + /** + * Paginate given data. + * + * @param Request $request A Request instance. + * @param mixed $results Any values which have proper pagination listener. + * @param integer $defaultLimit Default limit. + * + * @return \Knp\Component\Pager\Pagination\PaginationInterface + */ + protected function paginate(Request $request, $results, $defaultLimit = self::DEFAULT_LIMIT) + { + /** @var PaginatorInterface $paginator */ + $paginator = $this->get('knp_paginator'); + + $page = $request->query->getInt('page', self::DEFAULT_PAGE); + $limit = $request->query->getInt('limit', $defaultLimit); + + return $paginator->paginate($results, $page, $limit); + } + + /** + * Forwards the request to another controller. + * + * @param string $controller The controller name (a string like BlogBundle:Post:index). + * @param array $path An array of path parameters. + * @param array $query An array of query parameters. + * + * @return \Symfony\Component\HttpFoundation\Response A Response instance + */ + protected function forward($controller, array $path = [], array $query = []) + { + $request = $this->container->get('request_stack')->getCurrentRequest(); + $path['_forwarded'] = $request->attributes; + $path['_controller'] = $controller; + $subRequest = $request->duplicate($query, null, $path); + + return $this->container + ->get('http_kernel') + ->handle($subRequest, HttpKernelInterface::SUB_REQUEST); + } +} diff --git a/src/ApiBundle/Controller/AbstractCRUDController.php b/src/ApiBundle/Controller/AbstractCRUDController.php new file mode 100644 index 0000000..7a31e4f --- /dev/null +++ b/src/ApiBundle/Controller/AbstractCRUDController.php @@ -0,0 +1,265 @@ +entity = $entity; + } + + /** + * Create new entity. + * + * @param Request $request A Request instance. + * @param ManageableEntityInterface $entity A ManageableEntityInterface + * instance. + * + * @return \ApiBundle\Entity\ManageableEntityInterface|\ApiBundle\Response\ViewInterface + */ + protected function createEntity(Request $request, ManageableEntityInterface $entity) + { + $form = $this->createForm($entity->getCreateFormClass(), $entity); + + // Submit data into form. + $form->submit($request->request->all()); + if ($form->isValid()) { + // Check that current user can create this entity. + // If user don't have rights to create this entity we should send all + // founded restrictions to client. + $reasons = $this->checkAccess(InspectorInterface::CREATE, $entity); + if (count($reasons) > 0) { + // User don't have rights to create this entity so send all + // founded restriction reasons to client. + return $this->generateResponse($reasons, 403); + } + + $em = $this->getManager(); + $em->persist($entity); + $em->flush(); + + return $entity; + } + + // Client send invalid data. + return $this->generateResponse($form, 400); + } + + /** + * Get information about single entity. + * + * @param integer|ManageableEntityInterface|null $id A entity id. + * + * @return \ApiBundle\Entity\ManageableEntityInterface|\ApiBundle\Response\ViewInterface + */ + protected function getEntity($id) + { + $foundedEntity = $id; + if (is_numeric($id)) { + $repository = $this->getManager()->getRepository($this->entity); + + $foundedEntity = $repository->find($id); + } + + if ($foundedEntity === null) { + $name = \app\c\getShortName($this->entity); + // Remove 'Abstract' prefix if it exists. + if (strpos($name, 'Abstract') !== false) { + $name = substr($name, 8); + } + + return $this->generateResponse("Can't find {$name} with id {$id}.", 404); + } + + // Check that current user can read this entity. + // If user don't have rights to read this entity we should send all + // founded restrictions to client. + $reasons = $this->checkAccess(InspectorInterface::READ, $foundedEntity); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + return $foundedEntity; + } + + /** + * Update entity. + * + * @param Request $request A Request instance. + * @param integer|ManageableEntityInterface|null $entity A entity id. + * + * @return \ApiBundle\Entity\ManageableEntityInterface|\ApiBundle\Response\ViewInterface + */ + protected function putEntity(Request $request, $entity) + { + $em = $this->getManager(); + + $foundedEntity = $entity; + if (is_numeric($entity)) { + $repository = $em->getRepository($this->entity); + /** @var \ApiBundle\Entity\ManageableEntityInterface $entity */ + $foundedEntity = $repository->find($entity); + } + + if ($foundedEntity === null) { + $name = \app\c\getShortName($this->entity); + // Remove 'Abstract' prefix if it exists. + if (strpos($name, 'Abstract') !== false) { + $name = substr($name, 8); + } + + return $this->generateResponse("Can't find {$name} with id {$entity}.", 404); + } + + $form = $this->createForm($foundedEntity->getUpdateFormClass(), $foundedEntity, [ + 'method' => 'PUT', + ]); + $form->submit($request->request->all()); + if ($form->isValid()) { + // Check that current user can update this entity. + // If user don't have rights to update this entity we should send all + // founded restrictions to client. + $reasons = $this->checkAccess(InspectorInterface::UPDATE, $foundedEntity); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + $em->persist($foundedEntity); + $em->flush(); + + return $foundedEntity; + } + + return $this->generateResponse($form, 400); + } + + /** + * Delete entity. + * + * @param integer|ManageableEntityInterface|null $entity A entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + protected function deleteEntity($entity) + { + $em = $this->getManager(); + + $foundedEntity = $entity; + if (is_numeric($entity)) { + $repository = $em->getRepository($this->entity); + /** @var \ApiBundle\Entity\ManageableEntityInterface $entity */ + $foundedEntity = $repository->find($entity); + } + + if ($foundedEntity === null) { + $name = \app\c\getShortName($this->entity); + // Remove 'Abstract' prefix if it exists. + if (strpos($name, 'Abstract') !== false) { + $name = substr($name, 8); + } + + return $this->generateResponse("Can't find {$name} with id {$entity}.", 404); + } + // Check that current user can delete this entity. + // If user don't have rights to delete this entity we should send all + // founded restrictions to client. + $reasons = $this->checkAccess(InspectorInterface::DELETE, $foundedEntity); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + $em->remove($foundedEntity); + $em->flush(); + + return $this->generateResponse(); + } + + /** + * @param Request $request A Request instance. + * @param string|callable $permission A requested permission. + * @param string $formClass Form class fqcn. + * @param callable $processor Function which process founded entities. + * + * @return \ApiBundle\Response\ViewInterface + */ + protected function batchProcessing( + Request $request, + $permission, + $formClass, + callable $processor + ) { + $this->checkFormClass($formClass); + + $form = $this->createForm($formClass, null, [ 'class' => $this->entity ]); + + $form->submit($request->request->all()); + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + + if (is_callable($permission)) { + $permission = call_user_func_array($permission, $data); + } + + if (! is_string($permission)) { + throw new \InvalidArgumentException('$permission should be string or callable'); + } + + $reasons = $this->checkAccess($permission, $data['entities']); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + $response = call_user_func_array($processor, $data); + if ($response === null) { + $response = $this->generateResponse(); + } + + return $response; + } + + return $this->generateResponse($form, 400); + } + + /** + * @param string $formClass Form class fqcn. + * + * @return void + */ + private function checkFormClass($formClass) + { + if (! is_string($formClass) || ! class_exists($formClass)) { + throw new \InvalidArgumentException('$formClass should be fqcn'); + } + + if (($formClass !== EntitiesBatchType::class) + && ! in_array(EntitiesBatchType::class, class_parents($formClass), true)) { + throw new \InvalidArgumentException('Invalid form class '. $formClass); + } + } +} diff --git a/src/ApiBundle/Controller/Annotation/Roles.php b/src/ApiBundle/Controller/Annotation/Roles.php new file mode 100644 index 0000000..b7aba94 --- /dev/null +++ b/src/ApiBundle/Controller/Annotation/Roles.php @@ -0,0 +1,43 @@ +roles = (array) $this->roles; + } +} diff --git a/src/ApiBundle/DependencyInjection/Compiler/InspectorsCollectCompilerPass.php b/src/ApiBundle/DependencyInjection/Compiler/InspectorsCollectCompilerPass.php new file mode 100644 index 0000000..c5531ce --- /dev/null +++ b/src/ApiBundle/DependencyInjection/Compiler/InspectorsCollectCompilerPass.php @@ -0,0 +1,67 @@ +hasDefinition('api.inspector_factory')) { + // Work only if we have definition of inspector factory. + return; + } + + $factory = $container->getDefinition('api.inspector_factory'); + if ($factory->getClass() !== LazyInspectorFactory::class) { + // Works only for lazy inspector factory. + return; + } + + // Get all tagged inspectors and create map between supported class and + // inspector service id. + $inspectorsIds = []; + $inspectors = $container->findTaggedServiceIds('socialhose.inspector'); + $inspectors = array_keys($inspectors); + + foreach ($inspectors as $id) { + /** @var InspectorInterface $class */ + $class = $container->getDefinition($id)->getClass(); + + $reflection = new \ReflectionClass($class); + if (! $reflection->implementsInterface(InspectorInterface::class)) { + // Tagged service not implements inspector interface. + $message = "Inspector {$id} must implements " + . InspectorInterface::class; + throw new \InvalidArgumentException($message); + } + + $supported = (array) $class::supportedClass(); + foreach ($supported as $item) { + $inspectorsIds[$item] = $id; + } + } + + // Inject founded inspectors into factory. + $factory->replaceArgument(1, $inspectorsIds); + } +} diff --git a/src/ApiBundle/Entity/ManageableEntityInterface.php b/src/ApiBundle/Entity/ManageableEntityInterface.php new file mode 100644 index 0000000..f5cc9a1 --- /dev/null +++ b/src/ApiBundle/Entity/ManageableEntityInterface.php @@ -0,0 +1,29 @@ +reader = $reader; + } + + /** + * @param FilterControllerEvent $event A FilterControllerEvent instance. + * + * @return void + */ + public function handle(FilterControllerEvent $event) + { + $controller = $event->getController(); + + $className = ClassUtils::getClass($controller[0]); + $class = new \ReflectionClass($className); + $method = $class->getMethod($controller[1]); + + $classAnnotation = $this + ->getAnnotations($this->reader->getClassAnnotations($class)); + $methodAnnotation = $this + ->getAnnotations($this->reader->getMethodAnnotations($method)); + + $annotations = array_merge($classAnnotation, $methodAnnotation); + $request = $event->getRequest(); + foreach ($annotations as $key => $annotation) { + $request->attributes->set($key, $annotation); + } + } + + /** + * Get annotations from array of fetched annotations. + * + * @param array $annotations Array of fetched annotations. + * + * @return array + */ + protected function getAnnotations(array $annotations) + { + $cwAnnotation = []; + foreach ($annotations as $annotation) { + if ($annotation instanceof AppAnnotationInterface) { + $key = '_'. strtolower(\app\c\getShortName($annotation)); + $cwAnnotation[$key] = $annotation; + } + } + + return $cwAnnotation; + } +} diff --git a/src/ApiBundle/EventListener/ApiSubscriber.php b/src/ApiBundle/EventListener/ApiSubscriber.php new file mode 100644 index 0000000..f7c2120 --- /dev/null +++ b/src/ApiBundle/EventListener/ApiSubscriber.php @@ -0,0 +1,235 @@ +normalizer = $normalizer; + $this->logger = $logger; + } + + /** + * Returns an array of event names this subscriber wants to listen to. + * + * The array keys are event names and the value can be: + * + * * The method name to call (priority defaults to 0) + * * An array composed of the method name to call and the priority + * * An array of arrays composed of the method names to call and + * respective + * priorities, or 0 if unset + * + * For instance: + * + * * array('eventName' => 'methodName') + * * array('eventName' => array('methodName', $priority)) + * * array('eventName' => array(array('methodName1', $priority), + * array('methodName2'))) + * + * @return array The event names to listen to + */ + public static function getSubscribedEvents() + { + return [ + KernelEvents::REQUEST => [ 'onRequest', 100 ], + KernelEvents::VIEW => 'onView', + KernelEvents::EXCEPTION => 'onException', + ]; + } + + /** + * Process application/json request. + * Fetch json data, parse and store them as request parameters. + * + * @param GetResponseEvent $event A GetResponseEvent instance. + * + * @return void + * + * @throws HttpException Then receive invalid json. + */ + public function onRequest(GetResponseEvent $event) + { + $request = $event->getRequest(); + $uri = $request->getUri(); + + $isApiMethod = (strpos($uri, '/api') !== false) + || (strpos($uri, '/security/') !== false); + $content = trim($request->getContent()); + + if ($isApiMethod && (strlen($content) > 0)) { + // Make transformation only for api methods. + $content = json_decode($content, true); + if (isset($content['_format'])) { + unset($content['_format']); + } + + // Check json parse error. + $code = json_last_error(); + if ($code !== JSON_ERROR_NONE) { + $event->setResponse(AppResponse::badRequest( + 'Invalid json ('. $code .'): '. json_last_error_msg() + )); + + return; + } + + $request->request->replace($content); + } + } + + /** + * @param GetResponseForControllerResultEvent $event A + * GetResponseForControllerResultEvent + * instance. + * + * @return void + */ + public function onView(GetResponseForControllerResultEvent $event) + { + // Works only if current response returned by one of api controllers. + $uri = $event->getRequest()->getUri(); + + $isApiEndpoint = (strpos($uri, '/api') !== false) || (strpos($uri, '/security') !== false); + if ($isApiEndpoint) { + $result = $event->getControllerResult(); + + if ($result instanceof ViewInterface) { + $event->setResponse($result->serialize($this->normalizer)); + } else { + $result = $this->normalize($result); + $event->setResponse(AppResponse::create($result)); + } + } + } + + /** + * @param GetResponseForExceptionEvent $event A + * GetResponseForExceptionEvent + * instance. + * + * @return void + */ + public function onException(GetResponseForExceptionEvent $event) + { + $exception = $event->getException(); + $request = $event->getRequest(); + $uri = $request->getUri(); + + if (strpos($uri, '/api') !== false) { + if ($exception instanceof HttpException) { + // + // Handle throwException which have status code. + // We just get error message and status code, form it in proper + // structure and send to client. + // + // But for 'no route found' message we create 405 response instead + // of 404. + // + if ($exception->getPrevious() + && ($exception->getPrevious() instanceof ResourceNotFoundException)) { + $response = AppResponse::create('Unknown method.', 405); + } else { + $response = AppResponse::create() + ->setStatusCode($exception->getStatusCode()) + ->setData($exception->getMessage()); + } + + $event->setResponse($response); + } else { + // + // If throwException occurred in one of api methods, we log it and send + // some message to client. + // + $response = AppResponse::create(null, 500); + + $message = $exception->getMessage() . ' in ' + . $exception->getFile() . ' at ' . $exception->getLine() + . ' occurred while processing ' + . $request->attributes->get('_controller') . ':' + . $request->attributes->get('_action'); + + $response->setData($message); + + // To log message we also add serialized request. + $this->logger->error($message, [ + 'trace' => $exception->getTrace(), + ]); + + $event->setResponse($response); + } + } + } + + /** + * Normalize response data. + * + * @param mixed $data Response data. + * + * @return array + */ + private function normalize($data) + { + $groups = []; + + switch (true) { + case is_array($data): + return array_map([ $this, 'normalize' ], $data); + + case $data instanceof AbstractPagination: + $entity = $data->current(); + if ($entity instanceof NormalizableEntityInterface) { + $groups = $entity->defaultGroups(); + } + + return $this->normalizer->normalize($data, null, $groups); + + case is_object($data): + if ($data instanceof NormalizableEntityInterface) { + $groups = $data->defaultGroups(); + } + + return $this->normalizer->normalize($data, null, $groups); + } + + return $data; + } +} diff --git a/src/ApiBundle/EventListener/RolesListener.php b/src/ApiBundle/EventListener/RolesListener.php new file mode 100644 index 0000000..8009f8c --- /dev/null +++ b/src/ApiBundle/EventListener/RolesListener.php @@ -0,0 +1,69 @@ +storage = $storage; + $this->hierarchy = $hierarchy; + } + + /** + * @param FilterControllerEvent $event A FilterControllerEvent instance. + * + * @return void + */ + public function handle(FilterControllerEvent $event) + { + $request = $event->getRequest(); + $roles = $request->attributes->get('_roles'); + if (! $roles instanceof Roles) { + return; + } + + $token = $this->storage->getToken(); + + $expected = (array) $roles->roles; + $actual = array_map(function (RoleInterface $role) { + return $role->getRole(); + }, $this->hierarchy->getReachableRoles($token->getRoles())); + + if (count(array_diff($expected, $actual)) > 0) { + $message = 'You don\'t have enough roles to call this method. Required ' + . implode(', ', $expected) .'.'; + throw new AccessDeniedHttpException($message); + } + } +} diff --git a/src/ApiBundle/Form/ActivatedEntitiesBatchType.php b/src/ApiBundle/Form/ActivatedEntitiesBatchType.php new file mode 100644 index 0000000..47537b6 --- /dev/null +++ b/src/ApiBundle/Form/ActivatedEntitiesBatchType.php @@ -0,0 +1,34 @@ +add('active', CheckboxType::class); + } +} diff --git a/src/ApiBundle/Form/EntitiesBatchType.php b/src/ApiBundle/Form/EntitiesBatchType.php new file mode 100644 index 0000000..c005f24 --- /dev/null +++ b/src/ApiBundle/Form/EntitiesBatchType.php @@ -0,0 +1,94 @@ +add('ids', EntityType::class, [ + 'class' => $options['class'], + 'multiple' => true, + ]) + ->setDataMapper($this); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setRequired('class'); + } + + /** + * Maps properties of some data to a list of forms. + * + * @param mixed $data Structured data. + * @param FormInterface[]|array $forms A list of {@link FormInterface} instances. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function mapDataToForms($data, $forms) + { + // Do nothing because it's not necessary. + } + + /** + * Maps the data of a list of forms into the properties of some data. + * + * @param FormInterface[]|\Traversable $forms A list of {@link FormInterface} + * instances. + * @param mixed $data Structured data. + * + * @return void + */ + public function mapFormsToData($forms, &$data) + { + /** @var FormInterface[] $forms */ + $forms = iterator_to_array($forms); + + $data = []; + $data['entities'] = $forms['ids']->getData(); + unset($forms['ids']); + + foreach ($forms as $form) { + $data[$form->getName()] = $form->getData(); + } + } +} diff --git a/src/ApiBundle/Form/NotificationSubscribeBatchType.php b/src/ApiBundle/Form/NotificationSubscribeBatchType.php new file mode 100644 index 0000000..94c0fcb --- /dev/null +++ b/src/ApiBundle/Form/NotificationSubscribeBatchType.php @@ -0,0 +1,46 @@ +get('ids')->getOptions(); + $idsOptions['query_builder'] = function (NotificationRepository $repository) { + return $repository->getQueryBuilderForSubscription(); + }; + + $builder->remove('ids')->add('ids', EntityType::class, $idsOptions); + $builder->add('subscribed', CheckboxType::class); + } +} diff --git a/src/ApiBundle/Form/PublishedEntitiesBatchType.php b/src/ApiBundle/Form/PublishedEntitiesBatchType.php new file mode 100644 index 0000000..2896945 --- /dev/null +++ b/src/ApiBundle/Form/PublishedEntitiesBatchType.php @@ -0,0 +1,34 @@ +add('published', CheckboxType::class); + } +} diff --git a/src/ApiBundle/Form/SubscribeToNotificationsBatchType.php b/src/ApiBundle/Form/SubscribeToNotificationsBatchType.php new file mode 100644 index 0000000..98b0e09 --- /dev/null +++ b/src/ApiBundle/Form/SubscribeToNotificationsBatchType.php @@ -0,0 +1,67 @@ +storage = $storage; + } + + /** + * Builds the form. + * + * This method is called for each type in the hierarchy starting from the + * top most type. Type extensions can further modify the form. + * + * @see FormTypeExtensionInterface::buildForm() + * + * @param FormBuilderInterface $builder The form builder. + * @param array $options The options. + * + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + parent::buildForm($builder, $options); + + $builder->remove('ids')->add('ids', EntityType::class, [ + 'class' => Notification::class, + 'multiple' => true, + 'query_builder' => function (NotificationRepository $repository) { + $user = \app\op\invokeIf($this->storage->getToken(), 'getUser'); + if ($user instanceof User) { + return $repository->getQueryBuilderForForm($user); + } + + return $repository->createQueryBuilder('Notification'); + }, + ]); + $builder->add('subscribe', CheckboxType::class); + } +} diff --git a/src/ApiBundle/Resources/config/controllers.yml b/src/ApiBundle/Resources/config/controllers.yml new file mode 100644 index 0000000..a30c497 --- /dev/null +++ b/src/ApiBundle/Resources/config/controllers.yml @@ -0,0 +1,19 @@ +services: + # + # Base api controllers. + # + api.controller.abstract: + class: 'ApiBundle\Controller\AbstractApiController' + arguments: + - '@service_container' + abstract: true + + # + # Base CRUD controllers. + # + api.controller.abstract_crud: + class: 'ApiBundle\Controller\AbstractCRUDController' + arguments: + - '@service_container' + - # injects by concrete class. + abstract: true \ No newline at end of file diff --git a/src/ApiBundle/Resources/config/inspector.yml b/src/ApiBundle/Resources/config/inspector.yml new file mode 100644 index 0000000..3e255b3 --- /dev/null +++ b/src/ApiBundle/Resources/config/inspector.yml @@ -0,0 +1,18 @@ +services: + # + # Inspectors factory. + # + api.inspector_factory: + class: 'ApiBundle\Security\Inspector\Factory\LazyInspectorFactory' + arguments: + - '@service_container' + - # injected by compiler pass. + + # + # Access checker. + # + api.access_checker: + class: 'ApiBundle\Security\AccessChecker\AccessChecker' + arguments: + - '@security.token_storage' + - '@api.inspector_factory' \ No newline at end of file diff --git a/src/ApiBundle/Resources/config/normalizers.yml b/src/ApiBundle/Resources/config/normalizers.yml new file mode 100644 index 0000000..7a82b95 --- /dev/null +++ b/src/ApiBundle/Resources/config/normalizers.yml @@ -0,0 +1,56 @@ +services: + # + # Entity normalizer. + # + api.normalizer.entity: + class: 'ApiBundle\Serializer\Normalizer\EntityNormalizer' + tags: + - { name: serializer.normalizer } + + # + # Enum normalizer. + # + api.normalizer.enum: + class: 'ApiBundle\Serializer\Normalizer\EnumNormalizer' + tags: + - { name: serializer.normalizer } + + # + # ThemeOptionFont normalizer. + # + api.normalizer.theme_option_font: + class: 'ApiBundle\Serializer\Normalizer\ThemeOptionFontNormalizer' + tags: + - { name: serializer.normalizer } + + # + # Form normalizer. + # + api.normalizer.form: + class: 'ApiBundle\Serializer\Normalizer\FormNormalizer' + tags: + - { name: serializer.normalizer } + + # + # Paginator normalizer. + # + api.normalizer.paginator: + class: 'ApiBundle\Serializer\Normalizer\PaginationNormalizer' + tags: + - { name: serializer.normalizer } + + # + # Index document normalizer. + # + api.normalizer.index_document: + class: 'ApiBundle\Serializer\Normalizer\DocumentNormalizer' + tags: + - { name: serializer.normalizer } + + # + # Empty object normalizer. + # + api.normalizer.empty_object: + class: 'ApiBundle\Serializer\Normalizer\EmptyObjectNormalizer' + tags: + - { name: serializer.normalizer } \ No newline at end of file diff --git a/src/ApiBundle/Resources/config/services.yml b/src/ApiBundle/Resources/config/services.yml new file mode 100644 index 0000000..d541b6f --- /dev/null +++ b/src/ApiBundle/Resources/config/services.yml @@ -0,0 +1,62 @@ +imports: + - { resource: controllers.yml } + - { resource: inspector.yml } + - { resource: normalizers.yml } + +services: + # + # Subscriber for api events. + # + api.listeners.api: + class: 'ApiBundle\EventListener\ApiSubscriber' + arguments: + - '@serializer' + - '@monolog.logger.api_error' + tags: + - { name: kernel.event_subscriber } + + # + # App annotation fetch listener. + # + api.listeners.annotation: + class: 'ApiBundle\EventListener\AnnotationFetchListener' + arguments: + - '@annotation_reader' + tags: + - + name: kernel.event_listener + event: kernel.controller + method: handle + + + # + # Roles annotation checker listener. + # + api.listeners.security: + class: 'ApiBundle\EventListener\RolesListener' + arguments: + - '@security.token_storage' + - '@security.role_hierarchy' + tags: + - + name: kernel.event_listener + event: kernel.controller + method: handle + priority: -10 + + # + # Formatter for api errors log. + # + api.log_formatter.api_errors: + class: Monolog\Formatter\LineFormatter + arguments: + - "[%%datetime%%] %%message%%\n%%context.headers%%\n%%context.request%%\n\n" + - ~ + - true + + api.form.subscribe_to_notifications: + class: 'ApiBundle\Form\SubscribeToNotificationsBatchType' + arguments: + - '@security.token_storage' + tags: + - { name: form.type } diff --git a/src/ApiBundle/Response/View.php b/src/ApiBundle/Response/View.php new file mode 100644 index 0000000..087d51e --- /dev/null +++ b/src/ApiBundle/Response/View.php @@ -0,0 +1,87 @@ +data = $data; + $this->groups = $groups; + $this->code = $code ?: AppResponse::HTTP_OK; + } + + /** + * Serialize this response into proper response. + * + * @param NormalizerInterface $normalizer A NormalizerInterface instance. + * + * @return AppResponse + */ + public function serialize(NormalizerInterface $normalizer) + { + if (($this->data === null) + || (is_array($this->data) && (count($this->data) === 0))) { + // We got empty response, send without serialization. + return AppResponse::create(null, $this->code); + } + + if (is_array($this->data) || is_object($this->data)) { + // + // TODO: refactor it. Low priority. + // + if (($this->code >= 400) && ! is_array($this->data) + && (! $this->data instanceof FormInterface)) { + $this->data = [ $this->data ]; + } + + return AppResponse::create( + $normalizer->normalize($this->data, null, $this->groups), + $this->code + ); + } + + // Scalar values we just return. + return AppResponse::create($this->data, $this->code); + } +} diff --git a/src/ApiBundle/Response/ViewInterface.php b/src/ApiBundle/Response/ViewInterface.php new file mode 100644 index 0000000..080db47 --- /dev/null +++ b/src/ApiBundle/Response/ViewInterface.php @@ -0,0 +1,23 @@ +storage = $storage; + $this->factory = $factory; + } + + /** + * Checks that current user can make given action with specified entity. + * + * @param string $action Action name. + * @param object $entity A Entity instance. + * + * @return string[] Array of restriction reasons. + */ + public function isGranted($action, $entity) + { + $inspector = $this->factory->create($entity); + $user = $this->storage->getToken()->getUser(); + + return $inspector->inspect($user, $entity, $action); + } +} diff --git a/src/ApiBundle/Security/AccessChecker/AccessCheckerInterface.php b/src/ApiBundle/Security/AccessChecker/AccessCheckerInterface.php new file mode 100644 index 0000000..1bcfe7a --- /dev/null +++ b/src/ApiBundle/Security/AccessChecker/AccessCheckerInterface.php @@ -0,0 +1,23 @@ +reasons = []; + switch ($action) { + case self::CREATE: + $this->canCreate($user, $entity); + break; + + case self::READ: + $this->canRead($user, $entity); + break; + + case self::UPDATE: + $this->canUpdate($user, $entity); + break; + + case self::DELETE: + $this->canDelete($user, $entity); + break; + } + + return $this->reasons; + } + + /** + * @param string $reason Restriction reason. + * + * @return AbstractInspector + */ + protected function addReason($reason) + { + $this->reasons[] = $reason; + + return $this; + } + + /** + * Add reason only if condition is true. + * + * @param string $reason Restriction reason. + * @param boolean $condition Some boolean condition. + * + * @return AbstractInspector + */ + protected function addReasonIf($reason, $condition) + { + if ($condition) { + $this->reasons[] = $reason; + } + + return $this; + } + + /** + * Check that user can create specified entity. + * + * @param User $user A user who try to create entity. + * @param object $entity A Entity instance. + * + * @return void + */ + abstract protected function canCreate(User $user, $entity); + + /** + * Check that user can read specified entity. + * + * @param User $user A user who try to read entity. + * @param object $entity A Entity instance. + * + * @return void + */ + abstract protected function canRead(User $user, $entity); + + /** + * Check that user can update specified entity. + * + * @param User $user A user who try to update entity. + * @param object $entity A Entity instance. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + abstract protected function canUpdate(User $user, $entity); + + /** + * Check that user can delete specified entity. + * + * @param User $user A user who try to delete entity. + * @param object $entity A Entity instance. + * + * @return void + */ + abstract protected function canDelete(User $user, $entity); +} diff --git a/src/ApiBundle/Security/Inspector/Factory/InspectorFactoryInterface.php b/src/ApiBundle/Security/Inspector/Factory/InspectorFactoryInterface.php new file mode 100644 index 0000000..d7961e4 --- /dev/null +++ b/src/ApiBundle/Security/Inspector/Factory/InspectorFactoryInterface.php @@ -0,0 +1,24 @@ +container = $container; + $this->inspectorsIds = $inspectorsIds; + } + + /** + * Create proper inspector for given entity instance. + * + * @param object|string $class A Entity instance or fqcn. + * + * @return InspectorInterface + */ + public function create($class) + { + if (is_object($class)) { + $class = get_class($class); + } + $class = ClassUtils::getRealClass($class); + + if (!is_string($class) || ! class_exists($class)) { + throw new \InvalidArgumentException('Expects object or valid fqcn.'); + } + + if (! array_key_exists($class, $this->inspectorsIds)) { + $message = "Can't find inspector for entity '{$class}'"; + throw new \InvalidArgumentException($message); + } + + $inspector = $this->container->get($this->inspectorsIds[$class]); + if (! $inspector instanceof InspectorInterface) { + $message = 'Inspector must implements '. InspectorInterface::class; + throw new \RuntimeException($message); + } + + return $inspector; + } +} diff --git a/src/ApiBundle/Security/Inspector/InspectorInterface.php b/src/ApiBundle/Security/Inspector/InspectorInterface.php new file mode 100644 index 0000000..d5ba268 --- /dev/null +++ b/src/ApiBundle/Security/Inspector/InspectorInterface.php @@ -0,0 +1,38 @@ +fqcn = $fqcn; + $this->properties = $properties; + } + + /** + * @return string + */ + public function getFqcn() + { + return $this->fqcn; + } + + /** + * @param string $interfaceName Full qualified interface name. + * + * @return boolean + */ + public function implementsInterface($interfaceName) + { + return in_array($interfaceName, class_implements($this->fqcn), true); + } + + /** + * Get all properties metadata or filter by it specified groups if it's set. + * + * @param array|string|null $groups A serialized group. + * + * @return PropertyMetadata[] + */ + public function getProperties($groups = null) + { + if ($groups === null) { + return $this->properties; + } + + $groups = (array) $groups; + return array_filter( + $this->properties, + function (PropertyMetadata $metadata) use ($groups) { + return count(array_intersect($metadata->getGroups(), $groups)) > 0; + } + ); + } + + /** + * Add to this metadata properties from specified metadata which not exists + * in this. + * + * @param Metadata $metadata A Metadata instance. + * + * @return Metadata + */ + public function admix(Metadata $metadata) + { + $registeredProperties = array_map(function (PropertyMetadata $property) { + return $property->getName(); + }, $this->properties); + + $filter = function (PropertyMetadata $property) use ($registeredProperties) { + return ! in_array($property->getName(), $registeredProperties, true); + }; + + $uniqueProperties = array_filter($metadata->getProperties(), $filter); + $this->properties = array_merge($this->properties, $uniqueProperties); + + return $this; + } + + /** + * @param array $metadataList Array of Metadata instances. + * + * @return Metadata + */ + public function admixList(array $metadataList) + { + /** @var Metadata $metadata */ + foreach ($metadataList as $metadata) { + $this->admix($metadata); + } + + return $this; + } +} diff --git a/src/ApiBundle/Serializer/Metadata/PropertyMetadata.php b/src/ApiBundle/Serializer/Metadata/PropertyMetadata.php new file mode 100644 index 0000000..f5b7884 --- /dev/null +++ b/src/ApiBundle/Serializer/Metadata/PropertyMetadata.php @@ -0,0 +1,514 @@ +name = $name; + $this->type = $type; + $this->field = $field; + $this->groups = array_map('trim', $groups); + } + + /** + * Create property metadata for integer field. + * + * @param string $name Property name. + * @param array $groups Serialized group names. + * + * @return PropertyMetadata + */ + public static function createInteger($name, array $groups) + { + return new PropertyMetadata($name, self::TYPE_INTEGER, $name, $groups); + } + + /** + * Create property metadata for string field. + * + * @param string $name Property name. + * @param array $groups Serialized group names. + * + * @return PropertyMetadata + */ + public static function createString($name, array $groups) + { + return new PropertyMetadata($name, self::TYPE_STRING, $name, $groups); + } + + /** + * Create property metadata for string field. + * + * @param string $name Property name. + * @param array $groups Serialized group names. + * + * @return PropertyMetadata + */ + public static function createDouble($name, array $groups) + { + return new PropertyMetadata($name, self::TYPE_DOUBLE, $name, $groups); + } + + /** + * Create property metadata for object field. + * + * @param string $name Property name. + * @param string $actualType Entity fqcn. + * @param array $groups Serialized group names. + * + * @return PropertyMetadata + */ + public static function createEntity($name, $actualType, array $groups) + { + $property = new PropertyMetadata($name, self::TYPE_ENTITY, $name, $groups); + + return $property->setActualType($actualType); + } + + /** + * Create property metadata for string field. + * + * @param string $name Property name. + * @param string $enumClass Enum class. + * @param array $groups Serialized group names. + * + * @return PropertyMetadata + */ + public static function createEnum($name, $enumClass, array $groups) + { + $property = new PropertyMetadata($name, self::TYPE_ENUM, $name, $groups); + + return $property->setActualType($enumClass); + } + + /** + * Create property metadata for array field. + * + * @param string $name Property name. + * @param string $actualType Entity fqcn. + * @param array $groups Serialized group names. + * + * @return PropertyMetadata + */ + public static function createCollection($name, $actualType, array $groups) + { + $property = new PropertyMetadata($name, self::TYPE_COLLECTION, $name, $groups); + + return $property->setActualType($actualType); + } + + /** + * Create property metadata for array field. + * + * @param string $name Property name. + * @param array $groups Serialized group names. + * + * @return PropertyMetadata + */ + public static function createArray($name, array $groups) + { + return new PropertyMetadata($name, self::TYPE_ARRAY, $name, $groups); + } + + /** + * Create property metadata for boolean field. + * + * @param string $name Property name. + * @param array $groups Serialized group names. + * + * @return PropertyMetadata + */ + public static function createBoolean($name, array $groups) + { + return new PropertyMetadata($name, self::TYPE_BOOLEAN, $name, $groups); + } + + /** + * Create property metadata for date field. + * + * @param string $name Property name. + * @param array $groups Serialized group names. + * + * @return PropertyMetadata + */ + public static function createDate($name, array $groups) + { + return new PropertyMetadata($name, self::TYPE_DATE, $name, $groups); + } + + /** + * Create property metadata for date field. + * + * @param string $name Property name. + * @param array $groups Serialized group names. + * + * @return PropertyMetadata + */ + public static function createObject($name, array $groups) + { + return new PropertyMetadata($name, self::TYPE_OBJECT, $name, $groups); + } + + /** + * Create property metadata for date field. + * + * @param string $name Property name. + * @param array $subProperties Sub properties metadata. + * @param array $groups Serialized group names. + * + * @return PropertyMetadata + */ + public static function groupProperties($name, array $subProperties, array $groups) + { + $instance = new PropertyMetadata($name, self::TYPE_GROUP, null, $groups); + + return $instance->setSubProperties($subProperties); + } + + /** + * Set name. + * + * @param string $name Property name. + * + * @return PropertyMetadata + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * Get name. + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set type. + * + * @param string $type Property type. + * + * @return PropertyMetadata + */ + public function setType($type) + { + $this->type = $type; + + return $this; + } + + /** + * Get type. + * + * @return string + */ + public function getType() + { + return $this->type; + } + + /** + * Set actual type. + * + * @param string $actualType Actual property type. + * + * @return PropertyMetadata + */ + public function setActualType($actualType) + { + $this->actualType = $actualType; + + return $this; + } + + /** + * Get actual type. + * + * @return string + */ + public function getActualType() + { + return $this->actualType; + } + + /** + * @return boolean + */ + public function isScalar() + { + return ($this->type !== self::TYPE_COLLECTION) + && ($this->type !== self::TYPE_ENTITY) + && ($this->type !== self::TYPE_GROUP) + && ($this->type !== self::TYPE_OBJECT); + } + + /** + * Set field. + * + * @param \Closure|callable|string $field Entity field name or function for + * fetching data. + * + * @return PropertyMetadata + */ + public function setField($field) + { + $this->field = $field; + + return $this; + } + + /** + * Get field. + * + * @return \Closure|callable|string + */ + public function getField() + { + return $this->field; + } + + /** + * @param object $object Object instance on with we need getter. + * + * @return \Closure + */ + public function getGetter($object) + { + $getter = $this->field; + + if (is_string($getter)) { + // + // We got concrete field name. + // Create getter for it. + // + $getter = function () use ($getter) { + $value = $this->{$getter}; + + if ($value instanceof Collection) { + // Convert doctrine collection into array. + $value = $value->toArray(); + } elseif ($value instanceof \DateTimeInterface) { + // Format date time instances. + $value = $value->format('c'); + } + + return $value; + }; + } elseif ($this->type === self::TYPE_GROUP) { + // + // For object we should iterate other sub properties and get values + // from it. + // + // We should store current sub properties in order to inject them + // into closure. + // + $subProperties = $this->subProperties; + $getter = function () use ($object, $subProperties) { + $results = []; + + foreach ($subProperties as $subProperty) { + $getter = $subProperty->getGetter($object); + $results[$subProperty->getName()] = $getter(); + } + + return $results; + }; + } + + // Field is function. + return $getter->bindTo($object, $object); + } + + /** + * Set groups. + * + * @param array $groups Serialization groups. + * + * @return PropertyMetadata + */ + public function setGroups(array $groups) + { + $this->groups = $groups; + + return $this; + } + + /** + * Get groups. + * + * @return array + */ + public function getGroups() + { + return $this->groups; + } + + /** + * Set nullable. + * + * @param boolean $nullable Can property value be null. + * + * @return PropertyMetadata + */ + public function setNullable($nullable) + { + $this->nullable = $nullable; + + return $this; + } + + /** + * Get nullable + * + * @return boolean + */ + public function isNullable() + { + return $this->nullable; + } + + /** + * Set sub properties + * + * @param array $properties Array of PropertyMetadata instances. + * + * @return PropertyMetadata + */ + public function setSubProperties(array $properties) + { + $this->subProperties = $properties; + + return $this; + } + + /** + * Get sub properties + * + * @return PropertyMetadata[] + */ + public function getSubProperties() + { + return $this->subProperties; + } +} diff --git a/src/ApiBundle/Serializer/Normalizer/DocumentNormalizer.php b/src/ApiBundle/Serializer/Normalizer/DocumentNormalizer.php new file mode 100644 index 0000000..b6e6e7f --- /dev/null +++ b/src/ApiBundle/Serializer/Normalizer/DocumentNormalizer.php @@ -0,0 +1,77 @@ +addNormalizerListener(function (array $data) use ($format) { + $data['comments'] = [ + 'data' => $this->normalizer->normalize($data['comments'], $format, [ + 'id', + 'comment', + ]), + 'count' => count($data['comments']), + 'totalCount' => $data['commentsCount'], + 'limit' => CommentManagerInterface::NEW_COMMENT_POOL_SIZE, + ]; + unset($data['commentsCount']); + + return \nspl\a\map(function ($value) { + if ($value instanceof \DateTimeInterface) { + $value = $value->format('c'); + } + + return $value; + }, $data); + }); + } + + return $object->getNormalizedData(); + } +} diff --git a/src/ApiBundle/Serializer/Normalizer/EmptyObjectNormalizer.php b/src/ApiBundle/Serializer/Normalizer/EmptyObjectNormalizer.php new file mode 100644 index 0000000..bf33a1d --- /dev/null +++ b/src/ApiBundle/Serializer/Normalizer/EmptyObjectNormalizer.php @@ -0,0 +1,52 @@ +getMetadata()->getProperties($context); + $result = []; + + // If we normalize entity we should add 'type' property to it. + if ($object instanceof EntityInterface) { + $result['type'] = $object->getEntityType(); + } + + foreach ($metadata as $property) { + $getter = $property->getGetter($object); + $value = $getter(); + + // + // Normalize non scalar value such as: + // - Collection of associated entity. + // - Single associated entity. + // - Array of scalar values. Just in case. + // + if (! $property->isScalar()) { + $value = $this->normalizer->normalize($value, $format, $context); + if ($property->getType() === PropertyMetadata::TYPE_OBJECT) { + $value = (object) $value; + } + } elseif ($property->getType() === PropertyMetadata::TYPE_ENUM) { + $value = (string) $value; + } + + $result[$property->getName()] = $value; + } + + return $result; + } +} diff --git a/src/ApiBundle/Serializer/Normalizer/EnumNormalizer.php b/src/ApiBundle/Serializer/Normalizer/EnumNormalizer.php new file mode 100644 index 0000000..4dbbd95 --- /dev/null +++ b/src/ApiBundle/Serializer/Normalizer/EnumNormalizer.php @@ -0,0 +1,47 @@ +getValue(); + } +} diff --git a/src/ApiBundle/Serializer/Normalizer/FormNormalizer.php b/src/ApiBundle/Serializer/Normalizer/FormNormalizer.php new file mode 100644 index 0000000..82d1ea2 --- /dev/null +++ b/src/ApiBundle/Serializer/Normalizer/FormNormalizer.php @@ -0,0 +1,328 @@ + [ + 'key' => 'Lower', + 'valueName' => 'than', + ], + Constraints\GreaterThan::class => [ + 'key' => 'LowerOrEqual', + 'valueName' => 'than', + ], + Constraints\LessThan::class => [ + 'key' => 'GreaterOrEqual', + 'valueName' => 'than', + ], + Constraints\LessThanOrEqual::class => [ + 'key' => 'Greater', + 'valueName' => 'than', + ], + ]; + + /** + * Checks whether the given class is supportedClass for normalization by this + * normalizer. + * + * @param mixed $data Data to normalize. + * @param string $format The format being (de-)serialized from or into. + * + * @return boolean + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, $format = null) + { + return $data instanceof FormInterface; + } + + /** + * Normalizes an object into a set of arrays/scalars. + * + * @param object|FormInterface $object Object to normalize. + * @param string $format Format the normalization result will + * be encoded as. + * @param array $context Context options for the normalizer. + * + * @return array + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function normalize($object, $format = null, array $context = []) + { + if (! $object->isSubmitted()) { + return [ 'Form not submitted.' ]; + } + + return array_map(function (FormError $error) { + return $this->explainError($error); + }, iterator_to_array($object->getErrors(true, true))); + } + + /** + * Generate form key in camelCase. + * + * @param FormInterface $form A FormInterface instance. + * + * @return string + */ + private function getTransKey(FormInterface $form) + { + $hash = spl_object_hash($form); + + if (! isset($this->transKeysCache[$hash])) { + /** @var TransKeyGeneratorInterface $generator */ + $generator = $form->getConfig()->getOption('key'); + $this->transKeysCache[$hash] = $generator->generate($form); + } + + return $this->transKeysCache[$hash]; + } + + /** + * Explain occurred form error. + * + * @param FormError $error A FormError instance. + * + * @return array + */ + private function explainError(FormError $error) + { + $form = $error->getOrigin(); + $cause = $error->getCause(); + + // + // Prepare current translation key and translation parameters. + // + $transKey = $this->getTransKey($form); + $parameters = []; + + // + // Check cause. + // + if ($cause instanceof ConstraintViolation) { + // + // We got constraint violation so we should check that we known about + // constrain which spawned this violation and make proper explanation. + // + $isOrder = is_numeric($form->getName()); + if ($isOrder || ($form->getParent() && is_numeric(\app\op\invokeIf($form->getParent(), 'getName')))) { + // + // Entry type of CollectionType had order number instead of name + // and we should store this number in parameters and send to + // client. + // + $order = (int) ($isOrder ? $form->getName() : \app\op\invokeIf($form->getParent(), 'getName')); + $parameters['order'] = $order; + } + + $parameters = array_merge($parameters, $this->getParametersForViolation( + $cause, + $form, + $transKey + )); + } + + return [ + 'message' => $error->getMessage(), + 'transKey' => $transKey, + // This hardcoded by requesting from Frontend Developers. + 'type' => 'error', + 'parameters' => $parameters, + ]; + } + + /** + * @param ConstraintViolation $violation Founded violation. + * @param FormInterface $form Form on which violation found. + * @param string $transKey Translation key for this form. + * + * @return array + */ + private function getParametersForViolation( + ConstraintViolation $violation, + FormInterface $form, + &$transKey + ) { + $parameters = $this->getParametersForViolationByCode($violation, $form, $transKey); + if ($parameters === null) { + $parameters = $this->getParametersForViolationByClass($violation, $transKey); + } + + return $parameters !== null ? $parameters : []; + } + + /** + * @param ConstraintViolation $violation Founded violation. + * @param FormInterface $form Form on which violation found. + * @param string $transKey Translation key for this form. + * + * @return array|null + */ + private function getParametersForViolationByCode( + ConstraintViolation $violation, + FormInterface $form, + &$transKey + ) { + $code = $violation->getCode(); + $current = $violation->getInvalidValue(); + + $parameters = null; + switch (true) { + // + // Firstly we should check possible form constraint errors. + // + case $code === FormConstraint::NO_SUCH_FIELD_ERROR: + $transKey .= 'UnknownField'; + $parameters = [ + 'name' => key($current), + ]; + break; + + case $code === FormConstraint::NOT_SYNCHRONIZED_ERROR: + $transKey .= 'Invalid'; + $parameters = [ + 'current' => $current, + ]; + + if ($this->isChoiceType($form)) { + // We should return available choice values and also should + // return only invalid values. + $choices = $form->getConfig()->getOption('choices'); + + $parameters['available'] = $choices; + if (is_array($choices) && $form->getConfig()->getOption('multiple')) { + $parameters['invalid'] = array_diff($current, $choices); + } + } + + break; + + // + // Finally we should check UniqueEntity constraint. + // For some reasons violation with 'Form::NO_SUCH_FIELD_ERROR' + // code has UniqueEntity constraint so we should check check + // 'Form::NO_SUCH_FIELD_ERROR' first to avoid strange error + // messages. + // + case $code === UniqueEntity::NOT_UNIQUE_ERROR: + $transKey .= 'NotUnique'; + $parameters = [ + 'current' => $current, + ]; + break; + } + + return $parameters; + } + + /** + * @param ConstraintViolation $violation Founded violation. + * @param string $transKey Translation key for this form. + * + * @return array|null + */ + private function getParametersForViolationByClass( + ConstraintViolation $violation, + &$transKey + ) { + $constraint = $violation->getConstraint(); + $class = get_class($constraint); + $current = $violation->getInvalidValue(); + + $parameters = null; + + switch (true) { + // + // Length constraint. + // + case $constraint instanceof Constraints\Length: + $transKey .= 'TooShort'; + $parameters = [ + 'min' => $constraint->min, + ]; + break; + + // + // Next we check comparison errors. + // For comparison error we also should check that we hav config + // for it. + // + case ($constraint instanceof AbstractComparison) + && isset(self::$comparisonConfig[$class]): + $config = self::$comparisonConfig[$class]; + $transKey .= $config['key']; + $parameters = [ + 'current' => $current, + $config['valueName'] => $constraint->value, + ]; + break; + + // + // Required field is blank. + // + case ($constraint instanceof Constraints\NotBlank): + $transKey .= 'Empty'; + $parameters = [ + 'current' => $current, + ]; + break; + } + + return $parameters; + } + + /** + * Check that specified form is choice type or extend it. + * + * @param FormInterface $form A FormInterface instance. + * + * @return boolean + */ + private function isChoiceType(FormInterface $form) + { + $type = $form->getConfig()->getType(); + $innerType = $type->getInnerType(); + + return ($type instanceof ChoiceType) + || ($innerType instanceof ChoiceType) + || ($innerType->getParent() === ChoiceType::class); + } +} diff --git a/src/ApiBundle/Serializer/Normalizer/PaginationNormalizer.php b/src/ApiBundle/Serializer/Normalizer/PaginationNormalizer.php new file mode 100644 index 0000000..b191a8e --- /dev/null +++ b/src/ApiBundle/Serializer/Normalizer/PaginationNormalizer.php @@ -0,0 +1,72 @@ + $this->normalizer->normalize($data, $format, $context), + 'count' => count($data), + 'totalCount' => count($object), + 'limit' => $object->getQuery()->getMaxResults(), + ]; + } elseif ($object instanceof SlidingPagination) { + $normalizedData = [ + 'data' => $this->normalizer + ->normalize(iterator_to_array($object), $format, $context), + 'count' => count($object), + 'totalCount' => $object->getTotalItemCount(), + 'page' => $object->getCurrentPageNumber(), + 'limit' => $object->getItemNumberPerPage(), + ]; + } else { + throw new \InvalidArgumentException('Expect one of paginator.'); + } + + return $normalizedData; + } +} diff --git a/src/ApiBundle/Serializer/Normalizer/ThemeOptionFontNormalizer.php b/src/ApiBundle/Serializer/Normalizer/ThemeOptionFontNormalizer.php new file mode 100644 index 0000000..c5c2319 --- /dev/null +++ b/src/ApiBundle/Serializer/Normalizer/ThemeOptionFontNormalizer.php @@ -0,0 +1,57 @@ + $object->getFamily(), + 'size' => $object->getSize(), + 'style' => $this->normalizer->normalize($object->getStyle()), + ]; + } +} diff --git a/src/ApiDocBundle/ApiDocBundle.php b/src/ApiDocBundle/ApiDocBundle.php new file mode 100644 index 0000000..f66d492 --- /dev/null +++ b/src/ApiDocBundle/ApiDocBundle.php @@ -0,0 +1,23 @@ +get('nelmio_api_doc.extractor.api_doc_extractor'); + /** @var AbstractFormatter $formatter */ + $formatter = $this->get('api_doc.formatter.html'); + + $extractedDoc = $extractor->all($view); + $htmlContent = $formatter->format($extractedDoc); + + return new Response($htmlContent, 200, ['Content-Type' => 'text/html']); + } +} diff --git a/src/ApiDocBundle/DependencyInjection/ApiDocExtension.php b/src/ApiDocBundle/DependencyInjection/ApiDocExtension.php new file mode 100644 index 0000000..0e4dac8 --- /dev/null +++ b/src/ApiDocBundle/DependencyInjection/ApiDocExtension.php @@ -0,0 +1,39 @@ +load('services.yml'); + } +} diff --git a/src/ApiDocBundle/Extractor/Handler/RolesHandler.php b/src/ApiDocBundle/Extractor/Handler/RolesHandler.php new file mode 100644 index 0000000..20791ba --- /dev/null +++ b/src/ApiDocBundle/Extractor/Handler/RolesHandler.php @@ -0,0 +1,48 @@ +setAuthentication(true); + + $annotation->setAuthenticationRoles($annot->roles); + $annotation->addStatusCode(401, 'JWT token not provided, expired or invalid.'); + $annotation->addHeader('Authorization', [ + 'description' => 'Bearer authorizations through JWT token.', + 'required' => true, + ]); + } + } + } +} diff --git a/src/ApiDocBundle/Formatter/AppHtmlFormatter.php b/src/ApiDocBundle/Formatter/AppHtmlFormatter.php new file mode 100644 index 0000000..9766321 --- /dev/null +++ b/src/ApiDocBundle/Formatter/AppHtmlFormatter.php @@ -0,0 +1,91 @@ + $info) { + $format = $this->getParameters($info, 'format'); + $choices = []; + if ($format !== null) { + $choices = json_decode($format, true); + if ($choices === null) { + $choices = []; + } + } + + $newParams[$name] = [ + 'dataType' => $info['dataType'], + 'readonly' => $this->getParameters($info, 'readonly'), + 'required' => $this->getParameters($info, 'required', true), + 'default' => $this->getParameters($info, 'default'), + 'description' => $this->getParameters($info, 'description'), + 'format' => $format, + 'sinceVersion' => $this->getParameters($info, 'sinceVersion'), + 'untilVersion' => $this->getParameters($info, 'untilVersion'), + 'actualType' => $this->getParameters($info, 'actualType'), + 'subType' => $this->getParameters($info, 'subType'), + 'choices' => $choices, + ]; + + if (isset($info['children']) && (!$info['readonly'] || !$ignoreNestedReadOnly)) { + foreach ($this->compressNestedParameters($info['children'], $name, $ignoreNestedReadOnly) as $nestedItemName => $nestedItemData) { + $newParams[$name]['children'][$nestedItemName] = $nestedItemData; + } + } + } + + return $newParams; + } + + /** + * @param array|mixed $annotation A normalized ApiDoc Annotation. + * + * @return array + */ + protected function processAnnotation($annotation) + { + $result = parent::processAnnotation($annotation); + + // Sort status codes. + if (isset($result['statusCodes'])) { + ksort($result['statusCodes']); + } + + return $result; + } + + /** + * @param array $info Info array. + * @param string $parameter Parameter name. + * @param mixed $default Default value if parameter not found. + * + * @return mixed + */ + private function getParameters(array $info, $parameter, $default = null) + { + return array_key_exists($parameter, $info) ? $info[$parameter] : $default; + } +} diff --git a/src/ApiDocBundle/Parser/ArrayParser.php b/src/ApiDocBundle/Parser/ArrayParser.php new file mode 100644 index 0000000..294df40 --- /dev/null +++ b/src/ApiDocBundle/Parser/ArrayParser.php @@ -0,0 +1,105 @@ +parser = $parser; + } + + /** + * Return true/false whether this class supports parsing the given class. + * + * @param array $item Containing the following fields: class, groups. + * Of which groups is optional. + * + * @return boolean + */ + public function supports(array $item) + { + if (! isset($item['class'], $item['groups']) + || strpos($item['class'], 'Array') === false) { + return false; + } + + return $this->parser->supports([ + 'class' => $this->getInnerClass($item['class']), + 'groups' => $item['groups'], + ]); + } + + /** + * Returns an array of class property metadata where each item is a key (the property name) and + * an array of data with the following keys: + * - dataType string + * - required boolean + * - description string + * - readonly boolean + * - children (optional) array of nested property names mapped to arrays + * in the format described here + * - class (optional) the fully-qualified class name of the item, if + * it is represented by an object + * + * @param array $item The string type of input to parse. + * + * @return array + */ + public function parse(array $item) + { + $innerClass = $this->getInnerClass($item['class']); + + return [ + '' => [ + 'description' => 'Requested entities.', + 'dataType' => 'Collection of '. \app\c\getShortName($innerClass), + 'actualType' => DataTypes::COLLECTION, + 'subType' => $innerClass, + 'required' => true, + 'readonly' => true, + 'children' => $this->parser->parse([ + 'class' => $innerClass, + 'groups' => $item['groups'], + ]), + ], + ]; + } + + /** + * @param string $class Class field from $item. + * + * @return string + */ + private function getInnerClass($class) + { + $openPos = strpos($class, '<'); + $closePos = strrpos($class, '>'); + + if (($openPos === false) || ($closePos === false)) { + return ''; + } + + return substr($class, $openPos + 1, $closePos - $openPos - 1); + } +} diff --git a/src/ApiDocBundle/Parser/CustomOutputParser.php b/src/ApiDocBundle/Parser/CustomOutputParser.php new file mode 100644 index 0000000..6b32491 --- /dev/null +++ b/src/ApiDocBundle/Parser/CustomOutputParser.php @@ -0,0 +1,112 @@ +metadataParser = $metadataParser; + $this->paginationParser = $paginationParser; + } + + /** + * Return true/false whether this class supports parsing the given class. + * + * @param array $item Containing the following fields: class, groups. + * Of which groups is optional. + * + * @return boolean + */ + public function supports(array $item) + { + return isset($item['data']); + } + + /** + * Returns an array of class property metadata where each item is a key (the property name) and + * an array of data with the following keys: + * - dataType string + * - required boolean + * - description string + * - readonly boolean + * - children (optional) array of nested property names mapped to arrays + * in the format described here + * - class (optional) the fully-qualified class name of the item, if + * it is represented by an object + * + * @param array $item The string type of input to parse. + * + * @return array + */ + public function parse(array $item) + { + $result = array_map(function ($annotation) { + switch (true) { + // + // Check that current property supported by EntityMetadataParser. + // + case $this->metadataParser->supports($annotation): + $shortName = \app\c\getShortName($annotation['class']); + + $annotation = [ + 'dataType' => $shortName. ' entity', + 'actualType' => DataTypes::MODEL, + 'subType' => $annotation['class'], + 'required' => (isset($annotation['nullable'])) + ? $annotation['nullable'] + : false, + 'readonly' => true, + 'children' => $this->metadataParser->parse($annotation), + ]; + break; + + // + // Check that current property supported by PaginatorParser. + // + case $this->paginationParser->supports($annotation): + $annotation = [ + 'dataType' => 'Paginated data.', + 'actualType' => DataTypes::MODEL, + 'required' => true, + 'readonly' => true, + 'children' => $this->paginationParser->parse($annotation), + ]; + break; + } + + return $annotation; + }, $item['data']); + + return $result; + } +} diff --git a/src/ApiDocBundle/Parser/EntityMetadataParser.php b/src/ApiDocBundle/Parser/EntityMetadataParser.php new file mode 100644 index 0000000..4ebbd1b --- /dev/null +++ b/src/ApiDocBundle/Parser/EntityMetadataParser.php @@ -0,0 +1,333 @@ + DataTypes::INTEGER, + PropertyMetadata::TYPE_BOOLEAN => DataTypes::BOOLEAN, + PropertyMetadata::TYPE_STRING => DataTypes::STRING, + PropertyMetadata::TYPE_DOUBLE => DataTypes::FLOAT, + PropertyMetadata::TYPE_ARRAY => DataTypes::COLLECTION, + PropertyMetadata::TYPE_DATE => DataTypes::DATETIME, + PropertyMetadata::TYPE_ENTITY => DataTypes::MODEL, + PropertyMetadata::TYPE_COLLECTION => DataTypes::COLLECTION, + PropertyMetadata::TYPE_OBJECT => DataTypes::MODEL, + ]; + + /** + * EntityMetadataParser constructor. + * + * @param ClassMetadataFactory $metadataFactory A ClassMetadataFactory + * instance. + */ + public function __construct(ClassMetadataFactory $metadataFactory) + { + $this->metadataFactory = $metadataFactory; + } + + /** + * Return true/false whether this class supports parsing the given class. + * + * @param array $item Containing the following fields: class, groups. + * Of which groups is optional. + * + * @return boolean + */ + public function supports(array $item) + { + try { + $className = $item['class']; + $reflection = new \ReflectionClass($className); + } catch (\Exception $e) { + return false; + } + + return $reflection->implementsInterface(NormalizableEntityInterface::class) + && isset($item['groups']); + } + + /** + * Returns an array of class property metadata where each item is a key (the property name) and + * an array of data with the following keys: + * - dataType string + * - required boolean + * - description string + * - readonly boolean + * - children (optional) array of nested property names mapped to arrays + * in the format described here + * - class (optional) the fully-qualified class name of the item, if + * it is represented by an object + * + * @param array $item The string type of input to parse. + * + * @return array + */ + public function parse(array $item) + { + $this->processed = []; + + return $this->process($item['class'], $item['groups']); + } + + /** + * Recursively process specified class for given serialization groups. + * + * @param string $class Entity fqcn. + * @param array $groups Serialization groups. + * @param integer $level Current nesting level. + * + * @return array + */ + protected function process($class, array $groups, $level = 0) + { + // Create new instance of entity in order to get metadata properties. + $reflection = new \ReflectionClass($class); + + if ($reflection->isAbstract()) { + // Process abstract class. + return $this->processAbstractClass($class, $groups, $level); + } + + // Process concrete class. + return $this->processClass($class, $groups, $level); + } + + /** + * Process founded abstract class. + * Check that it have discriminator column mapping and process all mapped + * entity one by one and merge all parsed data in one. + * + * @param string $class Entity fqcn. + * @param array $groups Serialization groups. + * @param integer $level Current nesting level. + * + * @return array + */ + protected function processAbstractClass($class, array $groups, $level) + { + /** @var ClassMetadataInfo $doctrineMetadata */ + $doctrineMetadata = $this->metadataFactory->getMetadataFor($class); + $map = $doctrineMetadata->discriminatorMap; + + if (! is_array($map) || (count($map) === 0)) { + // Parsed abstract class don't has discriminator column. + $message = 'Abstract class without discriminator column not allowed'; + throw new \InvalidArgumentException($message); + } + + // Parse each mapped entity. + $result = []; + foreach ($map as $fqcn) { + // We process all mapped entities at the same level where we process + // current abstract class. + $result[] = $this->process($fqcn, $groups, $level); + } + + return call_user_func_array('array_merge', $result); + } + + /** + * Process class. + * Get all serialization metadata for specified groups and prepare proper + * result structure for api doc. + * + * @param string $class Entity fqcn. + * @param array $groups Serialization groups. + * @param integer $level Current nesting level. + * + * @return array + */ + protected function processClass($class, array $groups, $level) + { + // Create new instance of entity in order to get metadata properties. + $reflection = new \ReflectionClass($class); + + /** @var NormalizableEntityInterface $entity */ + $entity = $reflection->newInstanceWithoutConstructor(); + $normalizationMetadata = $entity->getMetadata(); + + // Add information about 'type' property if current class is entity class. + if ($reflection->implementsInterface(EntityInterface::class)) { + $result['type'] = [ + 'dataType' => DataTypes::STRING, + 'actualType' => null, + 'subType' => null, + 'required' => true, + 'readonly' => true, + ]; + } + + // Get all properties and form in proper api doc structure. + $result = []; + $properties = $normalizationMetadata->getProperties($groups); + foreach ($properties as $property) { + $result[$property->getName()] = $this + ->processProperty($property, $groups, $level); + } + + return $result; + } + + /** + * Process concrete property. + * + * @param PropertyMetadata $property A PropertyMetadata instance. + * @param array $groups Serialization groups. + * @param integer $level Current nesting level. + * + * @return array + */ + private function processProperty( + PropertyMetadata $property, + array $groups, + $level + ) { + // Attach processed data to result. + $actualType = $property->getActualType(); + + if ($actualType !== null) { + $this->processed[$actualType] = true; + } + + // Check property type. + switch ($property->getType()) { + // Process associated entity and associated entities collection + // metadata. + case PropertyMetadata::TYPE_COLLECTION: + case PropertyMetadata::TYPE_ENTITY: + case PropertyMetadata::TYPE_OBJECT: + $processed = $this->processComplexProperty($property, $groups, $level); + break; + + case PropertyMetadata::TYPE_ENUM: + /** @var AbstractEnum $class */ + $class = $property->getActualType(); + + $processed = [ + 'dataType' => DataTypes::STRING, + 'required' => ! $property->isNullable(), + 'readonly' => true, + 'choices' => $class::getAvailables(), + ]; + break; + + case PropertyMetadata::TYPE_GROUP: + $subProperties = $property->getSubProperties(); + $children = []; + + foreach ($subProperties as $subProperty) { + $children[$subProperty->getName()] = $this + ->processProperty($subProperty, $groups, $level + 1); + } + + $processed = [ + 'dataType' => DataTypes::MODEL, + 'required' => ! $property->isNullable(), + 'readonly' => true, + 'children' => $children, + ]; + break; + + // Scalar values. + default: + $processed = [ + 'dataType' => self::$typeMap[$property->getType()], + 'actualType' => null, + 'subType' => null, + 'required' => ! $property->isNullable(), + 'readonly' => true, + ]; + } + + return $processed; + } + + /** + * Process concrete entity or collection property. + * + * @param PropertyMetadata $property A PropertyMetadata instance. + * @param array $groups Serialization groups. + * @param integer $level Current nesting level. + * + * @return array + */ + private function processComplexProperty( + PropertyMetadata $property, + array $groups, + $level + ) { + $actualType = $property->getActualType(); + $shortName = \app\c\getShortName($property->getActualType()); + + switch ($property->getType()) { + case PropertyMetadata::TYPE_COLLECTION: + $dataType = 'Collection of '. $shortName; + break; + + case PropertyMetadata::TYPE_OBJECT: + case PropertyMetadata::TYPE_ENTITY: + $dataType = $shortName .' entity'; + break; + + default: + throw new \InvalidArgumentException('Invalid property type: '. $property->getType()); + } + + // Add main information. + $processed = [ + 'dataType' => $dataType, + 'actualType' => self::$typeMap[$property->getType()], + 'subType' => $property->getActualType(), + 'required' => ! $property->isNullable(), + 'readonly' => true, + ]; + + $supported = $this->supports([ + 'class' => $actualType, + 'groups' => $groups, + ]); + $canProcess = ! isset($this->processed[$actualType]) + || ($level === 0); + + if ($canProcess && $supported) { + // Process associated entity only if it not processed + // already. + $this->processed[$actualType] = true; + $processed['children'] = $this + ->process($actualType, $groups, $level + 1); + } + + return $processed; + } +} diff --git a/src/ApiDocBundle/Parser/PaginationParser.php b/src/ApiDocBundle/Parser/PaginationParser.php new file mode 100644 index 0000000..fe47131 --- /dev/null +++ b/src/ApiDocBundle/Parser/PaginationParser.php @@ -0,0 +1,129 @@ +parser = $parser; + } + + /** + * Return true/false whether this class supports parsing the given class. + * + * @param array $item Containing the following fields: class, groups. + * Of which groups is optional. + * + * @return boolean + */ + public function supports(array $item) + { + if (! isset($item['class'], $item['groups']) + || strpos($item['class'], 'Pagination') === false) { + return false; + } + + return $this->parser->supports([ + 'class' => $this->getInnerClass($item['class']), + 'groups' => $item['groups'], + ]); + } + + /** + * Returns an array of class property metadata where each item is a key (the property name) and + * an array of data with the following keys: + * - dataType string + * - required boolean + * - description string + * - readonly boolean + * - children (optional) array of nested property names mapped to arrays + * in the format described here + * - class (optional) the fully-qualified class name of the item, if + * it is represented by an object + * + * @param array $item The string type of input to parse. + * + * @return array + */ + public function parse(array $item) + { + $innerClass = $this->getInnerClass($item['class']); + + return [ + 'data' => [ + 'description' => 'Requested entities.', + 'dataType' => 'Collection of '. \app\c\getShortName($innerClass), + 'actualType' => DataTypes::COLLECTION, + 'subType' => $innerClass, + 'required' => true, + 'readonly' => true, + 'children' => $this->parser->parse([ + 'class' => $innerClass, + 'groups' => $item['groups'], + ]), + ], + 'count' => [ + 'description' => 'Count of requested entities on current page.', + 'dataType' => DataTypes::INTEGER, + 'required' => true, + 'readonly' => true, + ], + 'totalCount' => [ + 'description' => 'Total count of founded entities.', + 'dataType' => DataTypes::INTEGER, + 'required' => true, + 'readonly' => true, + ], + 'page' => [ + 'description' => 'Current page.', + 'dataType' => DataTypes::INTEGER, + 'required' => true, + 'readonly' => true, + ], + 'limit' => [ + 'description' => 'Max entities per page.', + 'dataType' => DataTypes::INTEGER, + 'required' => true, + 'readonly' => true, + ], + ]; + } + + /** + * @param string $class Class field from $item. + * + * @return string + */ + private function getInnerClass($class) + { + $openPos = strpos($class, '<'); + $closePos = strrpos($class, '>'); + + if (($openPos === false) || ($closePos === false)) { + return ''; + } + + return substr($class, $openPos + 1, $closePos - $openPos - 1); + } +} diff --git a/src/ApiDocBundle/Resources/config/services.yml b/src/ApiDocBundle/Resources/config/services.yml new file mode 100644 index 0000000..ccdf384 --- /dev/null +++ b/src/ApiDocBundle/Resources/config/services.yml @@ -0,0 +1,53 @@ +services: + # + # Nelmio Api Doc parser for entity which implements NormalizableEntityInterface. + # + api_doc.parser.entity: + class: 'ApiDocBundle\Parser\EntityMetadataParser' + arguments: [ "@=service('doctrine.orm.default_entity_manager').getMetadataFactory()" ] + tags: + - { name: nelmio_api_doc.extractor.parser } + + # + # Allow use custom response. + # + api_doc.parser.custom: + class: 'ApiDocBundle\Parser\CustomOutputParser' + arguments: + - '@api_doc.parser.entity' + - '@api_doc.parser.pagination' + tags: + - { name: nelmio_api_doc.extractor.parser } + + # + # Pagination response parser. + # + api_doc.parser.pagination: + class: 'ApiDocBundle\Parser\PaginationParser' + arguments: [ '@api_doc.parser.entity' ] + tags: + - { name: nelmio_api_doc.extractor.parser } + + # + # Array response parser. + # + api_doc.parser.array: + class: 'ApiDocBundle\Parser\ArrayParser' + arguments: [ '@api_doc.parser.entity' ] + tags: + - { name: nelmio_api_doc.extractor.parser } + + # + # Roles extractor. + # + api_doc.extractor_handler.roles: + class: 'ApiDocBundle\Extractor\Handler\RolesHandler' + tags: + - { name: nelmio_api_doc.extractor.handler } + + # + # Override default Nelmio Api Doc Bundle HtmlFormatter. + # + api_doc.formatter.html: + class: 'ApiDocBundle\Formatter\AppHtmlFormatter' + parent: nelmio_api_doc.formatter.html_formatter diff --git a/src/ApiDocBundle/Resources/views/method.html.twig b/src/ApiDocBundle/Resources/views/method.html.twig new file mode 100644 index 0000000..1fc4382 --- /dev/null +++ b/src/ApiDocBundle/Resources/views/method.html.twig @@ -0,0 +1,370 @@ +
  • +
    +

    + + {{ data.method|upper }} + + + {% if data.deprecated %} + + DEPRECATED + + {% endif %} + + {% if data.https %} + + {% endif %} + {% if data.authentication %} + + {% endif %} + + + {% if data.host is defined -%} + {{ data.https ? 'https://' : 'http://' -}} + {{ data.host -}} + {% endif -%} + {{ data.uri }} + + {% if data.tags is defined %} + {% for tag, color_code in data.tags %} + {{ tag }} + {% endfor %} + {% endif %} +

    +
      + {% if data.description is defined %} +
    • {{ data.description }}
    • + {% endif %} +
    +
    + +
    +
      + {% if enableSandbox %} +
    • Documentation
    • +
    • Sandbox
    • + {% endif %} +
    + +
    +
    + {% if data.documentation is defined and data.documentation is not empty %} +

    Documentation

    +
    {{ data.documentation|extra_markdown }}
    + {% endif %} + + {% if data.link is defined and data.link is not empty %} +

    Link

    + + {% endif %} + + {%- if data.authentication and data.authenticationRoles|length > 0 -%} +

    Authorization roles:

    +
    +
      + {% for role in data.authenticationRoles %} +
    • {{- role -}}
    • + {% endfor %} +
    +
    + {%- endif -%} + + {% if data.requirements is defined and data.requirements is not empty %} +

    Requirements

    + + + + + + + + + + + {% for name, infos in data.requirements %} + + + + + + + {% endfor %} + +
    NameRequirementTypeDescription
    {{ name }}{{ infos.requirement is defined ? infos.requirement : ''}}{{ infos.dataType is defined ? infos.dataType : ''}}{{ infos.description is defined ? infos.description : ''}}
    + {% endif %} + + {% if data.filters is defined and data.filters is not empty %} +

    Filters

    + + + + + + + + + {% for name, infos in data.filters %} + + + + + {% endfor %} + +
    NameInformation
    {{ name }} + + {% for key, value in infos %} + + + + + {% endfor %} +
    {{ key|title }}{{ value|json_encode(constant('JSON_UNESCAPED_UNICODE'))|replace({'\\\\': '\\'})|trim('"') }}
    +
    + {% endif %} + + {% if data.parameters is defined and data.parameters is not empty %} +
    +
    +

    Parameters

    +
    Expect json with next structure:
    +
    +
    +
    + {%- include 'NelmioApiDocBundle::parameters.html.twig' with { + parameters: data.parameters + } -%} +
    +
    + {% endif %} + + + {% if data.headers is defined and data.headers is not empty %} +

    Headers

    + + + + + + + + + + {% for name, infos in data.headers %} + + + + + + {% endfor %} + +
    NameRequired?Description
    {{ name }}{{ infos.required is defined and infos.required == 'true' ? 'true' : 'false'}}{{ infos.description is defined ? infos.description|trans : ''}}
    + {% endif %} + + {% if data.parsedResponseMap is defined and data.parsedResponseMap is not empty %} +

    Return

    + {% for status_code, response in data.parsedResponseMap %} + +

    + {{ status_code }} + {% if data.statusCodes[status_code] is defined %} + - {{ data.statusCodes[status_code]|join(', ') }} + {% endif %} +

    + {%- include 'NelmioApiDocBundle::parameters.html.twig' with { + parameters: response.model + } -%} + {% endfor %} + {% endif %} + + {% if data.statusCodes is defined and data.statusCodes is not empty %} +

    Status Codes

    + + + + + + + + + {% for status_code, descriptions in data.statusCodes %} + + + + + {% endfor %} + +
    Status CodeDescription
    {{ status_code }} +
      + {% for description in descriptions %} +
    • {{ description }}
    • + {% endfor %} +
    +
    + {% endif %} + + {% if data.cache is defined and data.cache is not empty %} +

    Cache

    +
    {{ data.cache }}s
    + {% endif %} + +
    + + {% if enableSandbox %} +
    + {% if app.request is not null and data.https and app.request.secure != data.https %} + Please reload the documentation using the scheme {% if data.https %}HTTPS{% else %}HTTP{% endif %} if you want to use the sandbox. + {% else %} +
    +
    + Input + {% if data.requirements is defined %} +

    Requirements

    + {% for name, infos in data.requirements %} +

    + + = + - +

    + {% endfor %} + {% endif %} + {% if data.filters is defined %} +

    Filters

    + {% for name, infos in data.filters %} +

    + + = + - +

    + {% endfor %} + {% endif %} + {% if data.parameters is defined %} +

    Parameters

    + {% for name, infos in data.parameters %} + {% if not infos.readonly %} +

    + + = + + - +

    + {% endif %} + {% endfor %} + + {% endif %} + +
    + +
    + {% set methods = data.method|upper|split('|') %} + {% if methods|length > 1 %} + Method + + {% else %} + + {% endif %} + + Headers + + {% if acceptType %} +

    + + = + - +

    + {% endif %} + + {% if data.headers is defined %} + + {% for name, infos in data.headers %} +

    + + = + - +

    + {% endfor %} + + {% endif %} + +

    + + = + - +

    + + +
    + +
    + Content + + + +

    + + = + + Replaces header if set +

    +
    + +
    + +
    +
    + + + + + + +
    +

    Request URL

    +
    
    +
    +                            

    Request body

    +
    
    +
    +                            

    Response Headers [Expand] [Profiler]

    +
    
    +
    +                            

    Response Body [Raw]

    +
    
    +
    +                            

    Curl Command Line

    +
    
    +                        
    + {% endif %} +
    + {% endif %} +
    +
    +
  • diff --git a/src/ApiDocBundle/Resources/views/parameters.html.twig b/src/ApiDocBundle/Resources/views/parameters.html.twig new file mode 100644 index 0000000..930e336 --- /dev/null +++ b/src/ApiDocBundle/Resources/views/parameters.html.twig @@ -0,0 +1,56 @@ +{# + Draw documentation input parameters and result. +#} + +{% import _self as self %} + +{%- spaceless -%} + + + + + + + + + + {{- self.renderRow(parameters) -}} + +
    NameTypeDescription
    +{%- endspaceless -%} + +{%- macro renderRow(parameters, level = 0) -%} + {% import _self as self %} + + {%- for name, options in parameters -%} + + + {%- if level > 0 -%} + {%- for i in range(0, level - 1) -%} +     + {%- endfor -%} + + {%- endif -%} + {{- name -}} + + + {{- options.dataType -}} + + + {{- options.description -}} + {%- if (options.choices is defined) and (options.choices is iterable) and (options.choices|length > 0) -%} +
    + Available choices: +
      + {%- for value in options.choices -%} +
    • {{- value -}}
    • + {%- endfor -%} +
    + {%- endif -%} + + + {%- if options.children is defined -%} + {{- self.renderRow(options.children, level + 1) -}} + {%- endif -%} + {%- endfor -%} +{%- endmacro -%} \ No newline at end of file diff --git a/src/AppBundle/AdvancedFilters/AFResolver.php b/src/AppBundle/AdvancedFilters/AFResolver.php new file mode 100644 index 0000000..814e593 --- /dev/null +++ b/src/AppBundle/AdvancedFilters/AFResolver.php @@ -0,0 +1,118 @@ +aggregator = $aggregator; + $this->factory = $factory; + } + + /** + * Get Available values for specified filter or for all. + * + * @param SearchRequestInterface $request A SearchRequestInterface instance. + * + * @return array + */ + public function getAvailables(SearchRequestInterface $request) + { + // + // Return assoc array for all available filters with its values. + // + return array_map(function ($values) { + return [ 'data' => $values ]; + }, $this->aggregator->getValues($request)); + } + + /** + * Generate proper FilterInterface instance for specified filter name. + * + * @param array $AFConfig Advanced filters configuration. + * @param string $name Filter name. + * @param AdvancedFilterParameters $params Filter value or value label. + * + * @return \IndexBundle\Filter\FilterInterface + */ + public function generateFilter(array $AFConfig, $name, AdvancedFilterParameters $params) + { + if (! isset($AFConfig[$name])) { + throw new \InvalidArgumentException("Unknown filter '{$name}'."); + } + $config = $AFConfig[$name]; + $fieldName = $config['field_name']; + + switch ($config['type']) { + // + // Additional query. + // + case AFTypeEnum::QUERY: + $filter = $params->createQueryFilter($config['names'], $this->factory); + break; + + // + // Filter by range. + // + case AFTypeEnum::RANGE: + $ranges = $config['ranges']; + $filter = $params->createRangeFilter($fieldName, $ranges, $this->factory); + break; + + // + // Filter by single value. + // + case AFTypeEnum::SIMPLE: + // + // NOTICE: + // + // We not validate given value so client may provide valid value + // for current filtered field but not existing in available filter + // values for current search request. + // + // In this case client will receive zero documents, so maybe + // validating is not necessary. + // + $filter = $params->createSimpleFilter($fieldName, $this->factory); + break; + + default: + throw new \RuntimeException("Unsupported type {$config['type']}"); + } + + return $filter; + } +} diff --git a/src/AppBundle/AdvancedFilters/AFResolverInterface.php b/src/AppBundle/AdvancedFilters/AFResolverInterface.php new file mode 100644 index 0000000..688c6b7 --- /dev/null +++ b/src/AppBundle/AdvancedFilters/AFResolverInterface.php @@ -0,0 +1,43 @@ + [ + DocumentsAFNameEnum::ADDITIONAL_QUERY => [ + 'type' => AFTypeEnum::QUERY, + 'description' => 'Additional specifying query.', + 'field_name' => '', + 'names' => [ + FieldNameEnum::MAIN, + FieldNameEnum::TITLE, + ], + ], + DocumentsAFNameEnum::SOURCE => [ + 'type' => AFTypeEnum::SIMPLE, + 'field_name' => FieldNameEnum::SOURCE_TITLE, + 'description' => 'Filter documents by source title.', + ], + DocumentsAFNameEnum::ARTICLE_DATE => [ + 'type' => AFTypeEnum::RANGE, + 'field_name' => FieldNameEnum::PUBLISHED, + 'ranges' => [ + '15 Minutes' => [ 'from' => 'now-15m', 'key' => '15 Minutes' ], + '30 Minutes' => [ 'from' => 'now-30m', 'key' => '30 Minutes' ], + '1 Hour' => [ 'from' => 'now-1H', 'key' => '1 Hour' ], + '24 Hour' => [ 'from' => 'now-1d', 'key' => '24 Hour' ], + '7 Days' => [ 'from' => 'now-7d', 'key' => '7 Days' ], + ], + 'description' => 'Filter documents by found date.', + ], + DocumentsAFNameEnum::SOURCE_COUNTRY => [ + 'type' => AFTypeEnum::SIMPLE, + 'field_name' => FieldNameEnum::COUNTRY, + 'description' => 'Filter documents by source country.', + ], + DocumentsAFNameEnum::SOURCE_STATE => [ + 'type' => AFTypeEnum::SIMPLE, + 'field_name' => FieldNameEnum::STATE, + 'description' => 'Filter documents by source state.', + ], + DocumentsAFNameEnum::SOURCE_CITY => [ + 'type' => AFTypeEnum::SIMPLE, + 'field_name' => FieldNameEnum::CITY, + 'description' => 'Filter documents by source city.', + ], +// DocumentsAFNameEnum::SOURCE_SECTION => [ +// 'type' => AFTypeEnum::SIMPLE, +// 'field_name' => FieldNameEnum::SECTION, +// 'description' => 'Filter documents by source section.', +// ], + DocumentsAFNameEnum::ARTICLE_LANGUAGE => [ + 'type' => AFTypeEnum::SIMPLE, + 'field_name' => FieldNameEnum::LANG, + 'description' => 'Filter documents by language.', + ], + DocumentsAFNameEnum::AUTHOR => [ + 'type' => AFTypeEnum::SIMPLE, + 'field_name' => FieldNameEnum::AUTHOR_NAME, + 'description' => 'Filter documents by author name.', + ], + DocumentsAFNameEnum::PUBLISHER => [ + 'type' => AFTypeEnum::SIMPLE, + 'field_name' => FieldNameEnum::PUBLISHER, + 'description' => 'Filter documents by publisher.', + ], + DocumentsAFNameEnum::REACH => [ + 'type' => AFTypeEnum::RANGE, + 'field_name' => FieldNameEnum::VIEWS, + 'ranges' => [ + '0+' => [ 'from' => 0, 'to' => 1000 ], + '1000+' => [ 'from' => 1000, 'to' => 5000 ], + '5000+' => [ 'from' => 5000, 'to' => 10000 ], + '10000+' => [ 'from' => 10000, 'to' => 25000 ], + '25000+' => [ 'from' => 25000, 'to' => 50000 ], + '50000+' => [ 'from' => 50000, 'to' => 100000 ], + '100000+' => [ 'from' => 100000, 'to' => 250000 ], + '250000+' => [ 'from' => 250000, 'to' => 500000 ], + '500000+' => [ 'from' => 500000, 'to' => 1000000 ], + '1000000+' => [ 'from' => 1000000, 'to' => 2500000 ], + '2500000+' => [ 'from' => 2500000, 'to' => 5000000 ], + '5000000+' => [ 'from' => 5000000, 'to' => 10000000 ], + '10000000+' => [ 'from' => 10000000 ], + ], + 'description' => 'Filter documents by views count.', + ], + DocumentsAFNameEnum::SENTIMENT => [ + 'type' => AFTypeEnum::SIMPLE, + 'field_name' => FieldNameEnum::SENTIMENT, + 'description' => 'Filter documents by sentiment.', + ], + ], + // + // Configuration of advanced filter for sources. + // + AFSourceEnum::SOURCE => [ + SourcesAFNameEnum::LANG => [ + 'type' => AFTypeEnum::SIMPLE, + 'field_name' => FieldNameEnum::LANG, + 'description' => 'Filter sources by language.', + ], + SourcesAFNameEnum::COUNTRY => [ + 'type' => AFTypeEnum::SIMPLE, + 'field_name' => FieldNameEnum::COUNTRY, + 'description' => 'Filter sources by country.', + ], + SourcesAFNameEnum::STATE => [ + 'type' => AFTypeEnum::SIMPLE, + 'field_name' => FieldNameEnum::STATE, + 'description' => 'Filter sources by state.', + ], + SourcesAFNameEnum::CITY => [ + 'type' => AFTypeEnum::SIMPLE, + 'field_name' => FieldNameEnum::CITY, + 'description' => 'Filter sources by city.', + ], + SourcesAFNameEnum::SECTION => [ + 'type' => AFTypeEnum::SIMPLE, + 'field_name' => FieldNameEnum::SECTION, + 'description' => 'Filter sources by section.', + ], + SourcesAFNameEnum::MEDIA_TYPE => [ + 'type' => AFTypeEnum::SIMPLE, + 'field_name' => FieldNameEnum::SOURCE_PUBLISHER_TYPE, + 'description' => 'Filter sources by media type.', + ], + ], + ]; + + /** + * Get configuration for specified document's source. + * + * @param string $name One of available constants from AFSourceEnum. + * + * @return array[] + * + * @see AFSourceEnum + */ + public static function getConfig($name) + { + if (! isset(self::$configs[$name])) { + throw new \InvalidArgumentException("Unknown config '{$name}'"); + } + + return self::$configs[$name]; + } + + /** + * Get default value for specified source. + * + * @param string $name One of available constants from AFSourceEnum. + * + * @return array[] + * + * @see AFSourceEnum + */ + public static function getDefault($name) + { + switch ($name) { + case AFSourceEnum::FEED: + return [ + DocumentsAFNameEnum::SOURCE => [ 'data' => [] ], + DocumentsAFNameEnum::ARTICLE_DATE => [ 'data' => [] ], + DocumentsAFNameEnum::SOURCE_COUNTRY => [ 'data' => [] ], + DocumentsAFNameEnum::SOURCE_STATE => [ 'data' => [] ], + DocumentsAFNameEnum::SOURCE_CITY => [ 'data' => [] ], +// DocumentsAFNameEnum::SOURCE_SECTION => [ 'data' => [] ], + DocumentsAFNameEnum::ARTICLE_LANGUAGE => [ 'data' => [] ], + DocumentsAFNameEnum::AUTHOR => [ 'data' => [] ], + DocumentsAFNameEnum::PUBLISHER => [ 'data' => [] ], + DocumentsAFNameEnum::REACH => [ 'data' => [] ], + DocumentsAFNameEnum::SENTIMENT => [ 'data' => [] ], + ]; + + case AFSourceEnum::SOURCE: + return [ + SourcesAFNameEnum::LANG => [ 'data' => [] ], + SourcesAFNameEnum::COUNTRY => [ 'data' => [] ], + SourcesAFNameEnum::STATE => [ 'data' => [] ], + SourcesAFNameEnum::CITY => [ 'data' => [] ], + SourcesAFNameEnum::SECTION => [ 'data' => [] ], + SourcesAFNameEnum::MEDIA_TYPE => [ 'data' => [] ], + ]; + + default: + throw new \InvalidArgumentException('Unknown source '. $name); + } + } +} diff --git a/src/AppBundle/AdvancedFilters/Aggregator/AFAggregatorInterface.php b/src/AppBundle/AdvancedFilters/Aggregator/AFAggregatorInterface.php new file mode 100644 index 0000000..da58b39 --- /dev/null +++ b/src/AppBundle/AdvancedFilters/Aggregator/AFAggregatorInterface.php @@ -0,0 +1,25 @@ +index = $index; + } + + /** + * Return available filters values for specified request. + * + * @param SearchRequestInterface $request A SearchRequestInterface instance. + * + * @return array + * + * @see AFSourceEnum + */ + public function getValues(SearchRequestInterface $request) + { + $aggregations = []; + $AFConfig = $this->getAggregationConfig(); + + foreach ($AFConfig as $aggregationName => $config) { + if ($config['type'] !== AFTypeEnum::QUERY) { + $aggregations[] = $this + ->createAggregation($aggregationName, $config); + } + } + + // Create new builder for aggregation only. + $builder = $this->index->createRequestBuilder(); + $response = $builder + ->fromSearchRequest($request) + ->setAggregation($aggregations) + ->setLimit(0) // We don't need any founded documents, only aggregations + // results. + ->build() + ->execute(); + + // Normalize aggregation results. + $results = $response->getAggregationResults(); + $values = []; + + foreach ($results as $name => $body) { + // + // Normalize concrete filter aggregation. + // + // For some reasons ElasticSearch invert aggregation data in buckets + // when we try to aggregate filed with 'date' type and our custom + // names from config are assigned to invalid values. So for 'articleDate' + // filter we invert all values in bucket. + // + if ($name === DocumentsAFNameEnum::ARTICLE_DATE) { + $body = array_reverse($body); + } + + $values[$name] = []; + foreach ($body as $value) { + // + // Normalize concrete filter aggregation result. + // + $valueName = $value['value']; + + $values[$name][] = [ + 'value' => $valueName, + 'count' => $value['count'], + ]; + } + } + + // + // Get not founded advanced filters values and force it into response. + // + $notFounded = array_diff(array_keys($this->getDefaultValue()), array_keys($results)); + foreach ($notFounded as $filter) { + $values[$filter] = []; + } + + return $values; + } + + /** + * Create new Aggregation instance from specified config. + * + * @param string $name Aggregation name. + * @param array $config Aggregation config. + * + * @return AggregationInterface + */ + private function createAggregation($name, array $config) + { + $factory = $this->index->getAggregationFactory(); + $aggregation = $this->index->getAggregation(); + + // Get aggregation type and convert to proper ElasticSearch aggregation type. + $type = ($config['type'] === AFTypeEnum::SIMPLE) ? 'terms' : 'range'; + + $params = [ + 'type' => $type, + 'field_name' => $config['field_name'], + ]; + + if ($type === AFTypeEnum::RANGE) { + // We should get only ranges without names. + $params['ranges'] = array_values($config['ranges']); + } + + // Create new aggregation. + return $aggregation->getAggregation($name, $factory->{$type}($params)); + } + + /** + * Aggregation config. + * + * @return array + */ + abstract protected function getAggregationConfig(); + + /** + * Get default value for this aggregation results. + * + * @return array + */ + abstract protected function getDefaultValue(); +} diff --git a/src/AppBundle/AdvancedFilters/Aggregator/ArticleAFAggregator.php b/src/AppBundle/AdvancedFilters/Aggregator/ArticleAFAggregator.php new file mode 100644 index 0000000..874a9c2 --- /dev/null +++ b/src/AppBundle/AdvancedFilters/Aggregator/ArticleAFAggregator.php @@ -0,0 +1,35 @@ +cache = $cache; + $this->internal = $internal; + } + + /** + * Return available filters values for specified request. + * + * @param SearchRequestInterface $request A SearchRequestInterface instance. + * + * @return array + */ + public function getValues(SearchRequestInterface $request) + { + $cachedValues = $this->cache->getItem($request->getHash()); + if (! $cachedValues->isHit()) { + $cachedValues = new CacheItem( + $request->getHash(), + $this->internal->getValues($request), + self::LIFETIME + ); + + $this->cache->save($cachedValues); + } + + return $cachedValues->get(); + } +} diff --git a/src/AppBundle/AdvancedFilters/Aggregator/SourceAFAggregator.php b/src/AppBundle/AdvancedFilters/Aggregator/SourceAFAggregator.php new file mode 100644 index 0000000..5856fa4 --- /dev/null +++ b/src/AppBundle/AdvancedFilters/Aggregator/SourceAFAggregator.php @@ -0,0 +1,40 @@ +em = $em; + } + + /** + * Returns a Cache Item representing the specified key. + * + * This method must always return a CacheItemInterface object, even in case + * of a cache miss. It MUST NOT return null. + * + * @param string $key The key for which to return the corresponding Cache Item. + * + * @return CacheItemInterface + */ + public function getItem($key) + { + $item = $this->em->find(CacheItem::class, $key); + if (! $item instanceof CacheItem) { + $item = new CacheItem($key, null, null, false); + } elseif (time() > $item->getExpiresAt()) { + $this->em->remove($item); + $this->em->flush($item); + + $item = new CacheItem($key, null, null, false); + } + + $this->garbageCollector(); + + return $item; + } + + /** + * Returns a traversable set of cache items. + * + * @param string[] $keys An indexed array of keys of items to retrieve. + * + * @return array|\Traversable + */ + public function getItems(array $keys = []) + { + $items = $this->em->getRepository(CacheItem::class) + ->findBy([ 'key' => $keys ]); + + $this->garbageCollector(); + + // + // todo add proper code here! + // + + return $items; + } + + /** + * Confirms if the cache contains specified cache item. + * + * Note: This method MAY avoid retrieving the cached value for performance + * reasons. This could result in a race condition with + * CacheItemInterface::get(). To avoid such situation use + * CacheItemInterface::isHit() instead. + * + * @param string $key The key for which to check existence. + * + * @return boolean + */ + public function hasItem($key) + { + return $this->getItem($key)->isHit(); + } + + /** + * Deletes all items in the pool. + * + * @return boolean + */ + public function clear() + { + /** @var EntityRepository $repository */ + $repository = $this->em->getRepository(CacheItem::class); + + $repository->createQueryBuilder('Item') + ->delete() + ->getQuery() + ->execute(); + + return true; + } + + /** + * Removes the item from the pool. + * + * @param string $key The key to delete. + * + * @return boolean + */ + public function deleteItem($key) + { + $this->garbageCollector(); + + return $this->deleteItems([ $key ]); + } + + /** + * Removes multiple items from the pool. + * + * @param string[] $keys An array of keys that should be removed from the pool. + * + * @return boolean + */ + public function deleteItems(array $keys) + { + /** @var EntityRepository $repository */ + $repository = $this->em->getRepository(CacheItem::class); + + $repository->createQueryBuilder('Item') + ->delete() + ->where('Item.key IN ('. implode(', ', \nspl\a\map(function ($key) { + return "'{$key}'"; + }, $keys)) .')') + ->getQuery() + ->execute(); + + $this->garbageCollector(); + + return true; + } + + /** + * Persists a cache item immediately. + * + * @param CacheItemInterface $item The cache item to save. + * + * @return boolean + */ + public function save(CacheItemInterface $item) + { + if (! $item instanceof CacheItem) { + return false; + } + + $this->em->persist($item); + $this->em->flush($item); + + $this->garbageCollector(); + + return true; + } + + /** + * Sets a cache item to be persisted later. + * + * @param CacheItemInterface $item The cache item to save. + * + * @return boolean + */ + public function saveDeferred(CacheItemInterface $item) + { + $this->deferred[] = $item; + + return true; + } + + /** + * Persists any deferred cache items. + * + * @return boolean + */ + public function commit() + { + foreach ($this->deferred as $item) { + $this->em->persist($item); + } + $this->em->flush($this->deferred); + $this->deferred = []; + + $this->garbageCollector(); + + return true; + } + + /** + * @return void + */ + private function garbageCollector() + { + if (mt_rand(0, 10) <= 1) { + $this->em->createQueryBuilder() + ->delete() + ->from(CacheItem::class, 'Item') + ->where('Item.expiresAt < CURRENT_TIMESTAMP()') + ->getQuery() + ->execute(); + } + } +} diff --git a/src/AppBundle/Command/AbstractSingleCopyCommand.php b/src/AppBundle/Command/AbstractSingleCopyCommand.php new file mode 100644 index 0000000..f18ad4d --- /dev/null +++ b/src/AppBundle/Command/AbstractSingleCopyCommand.php @@ -0,0 +1,90 @@ +logger = $logger; + } + + /** + * Executes the current command. + * + * This method is not abstract because you can use this class + * as a concrete class. In this case, instead of defining the + * execute() method, you set the code to execute by passing + * a Closure to the setCode() method. + * + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer null or 0 if everything went fine, or an error code. + * + * @see setCode() + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $lock = new LockHandler($this->getName()); + if (!$lock->lock()) { + $output->writeln(sprintf( + 'Command \'%s\' is already executing.', + $this->getName() + )); + + return 1; + } + + try { + $result = $this->doExecute($input, $output); + } catch (\Exception $exception) { + $this->logger->critical(sprintf( + 'Command \'%s\' got exception \'%s\' while executing. %s', + $this->getName(), + get_class($exception), + $exception->getMessage() + )); + $result = 127; + } finally { + $lock->release(); + } + + return $result; + } + + /** + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer + */ + abstract protected function doExecute(InputInterface $input, OutputInterface $output); +} diff --git a/src/AppBundle/Command/FetchSourcesCommand.php b/src/AppBundle/Command/FetchSourcesCommand.php new file mode 100644 index 0000000..15b689a --- /dev/null +++ b/src/AppBundle/Command/FetchSourcesCommand.php @@ -0,0 +1,66 @@ +manager = $manager; + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure() + { + $this->setDescription('Fetch all sources from external index.'); + } + + /** + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function doExecute(InputInterface $input, OutputInterface $output) + { + $this->manager->pullFromExternal(); + + return 0; + } +} diff --git a/src/AppBundle/Command/GenerateCommand.php b/src/AppBundle/Command/GenerateCommand.php new file mode 100644 index 0000000..96bae6d --- /dev/null +++ b/src/AppBundle/Command/GenerateCommand.php @@ -0,0 +1,97 @@ +index = $index; + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure() + { + $this + ->setDescription('Generate random documents. Use only for development.') + ->addOption( + 'count', + 'c', + InputOption::VALUE_REQUIRED, + 'How much document create', + 10 + ); + } + + /** + * Executes the current command. + * + * This method is not abstract because you can use this class + * as a concrete class. In this case, instead of defining the + * execute() method, you set the code to execute by passing + * a Closure to the setCode() method. + * + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer null or 0 if everything went fine, or an error code. + * + * @throws \LogicException When this abstract method is not implemented. + * + * @see setCode() + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $generator = new ExternalDocumentGenerator(); + + $count = $input->getOption('count'); + + $documents = []; + for ($i = 0; $i < $count; ++$i) { + $documents[] = $generator->generate(); + } + + $this->index->index($documents); + + return 0; + } +} diff --git a/src/AppBundle/Command/LoadDataFixturesCommand.php b/src/AppBundle/Command/LoadDataFixturesCommand.php new file mode 100644 index 0000000..449c870 --- /dev/null +++ b/src/AppBundle/Command/LoadDataFixturesCommand.php @@ -0,0 +1,278 @@ +kernel = $kernel; + $this->em = $em; + $this->externalIndex = $externalIndex; + $this->internalIndex = $internalIndex; + $this->sourceIndex = $sourceIndex; + $this->container = $container; + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure() + { + $this + ->setName(self::NAME) + ->setDescription('Load database and index fixtures.') + ->addOption('force', null, InputOption::VALUE_NONE) + ->addOption( + 'without-index', + null, + InputOption::VALUE_NONE, + 'Do not load index fixtures.' + ) + ->addOption( + 'without-database', + null, + InputOption::VALUE_NONE, + 'Do not load database fixtures.' + ); + } + + /** + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer + */ + protected function doExecute(InputInterface $input, OutputInterface $output) + { + $withoutDatabase = $input->getOption('without-database'); + $withoutIndex = $input->getOption('without-index'); + + if ($withoutDatabase && $withoutIndex) { + return 0; + } + + if (! $input->getOption('force')) { + // Because this command can rebuild all index we use this option as + // as flag in order to prevent accidental run. + $message = 'Provide --force option if you really want to initialize index.'; + $output->writeln($message); + return 0; + } + + if (! $input->getOption('no-interaction') && ! $this->confirm($input, $output)) { + return 0; + } + + // Get list of available data fixtures paths. + $paths = $this->getFixturesPaths(); + + // Load database fixtures. + if (! $withoutDatabase) { + $this->loadDatabase($output, $paths); + } + + // Load index fixtures. + if (! $withoutIndex) { + $this->loadIndex($output, $paths); + } + + return 0; + } + + /** + * @return array + */ + private function getFixturesPaths() + { + $paths = []; + foreach ($this->kernel->getBundles() as $bundle) { + $path = $bundle->getPath() . '/DataFixtures/'; + if (is_dir($path)) { + $paths[] = $path; + } + } + + return $paths; + } + + /** + * @param InputInterface $input A InputInterface instance. + * @param OutputInterface $output A OutputInterface instance. + * + * @return boolean + */ + private function confirm(InputInterface $input, OutputInterface $output) + { + /** @var \Symfony\Component\Console\Helper\SymfonyQuestionHelper $helper */ + $helper = $this->getHelper('question'); + + $output->writeln(''); + $output->writeln('All data will be purged.'); + $question = new ConfirmationQuestion('Are you sure (y/N)? ', false); + + return (boolean) $helper->ask($input, $output, $question); + } + + /** + * @param OutputInterface $output A OutputInterface instance. + * @param array $paths Array of fixtures directories path. + * + * @return void + */ + private function loadDatabase(OutputInterface $output, array $paths) + { + // + // Purge internal tables. + // + $this->em->getConnection()->executeQuery('TRUNCATE internal_notification_scheduling'); + + $output->writeln('Load database fixtures:'); + + $loader = new DataFixturesLoader($this->container); + foreach ($paths as $path) { + $loader->loadFromDirectory($path); + } + + $fixtures = $loader->getFixtures(); + if (!$fixtures) { + throw new \InvalidArgumentException( + sprintf('Could not find any fixtures to load in: %s', "\n\n- " . implode("\n- ", $paths)) + ); + } + + $purger = new TruncateORMPurger(new ORMPurger($this->em)); + $purger->purge(); + + $executor = new ORMExecutor($this->em); + $executor->setLogger(function ($message) use ($output) { + $output->writeln(sprintf(' > %s', $message)); + }); + $executor->execute($fixtures, true); + } + + /** + * @param OutputInterface $output A OutputInterface instance. + * @param array $paths Array of fixtures directories path. + * + * @return void + */ + private function loadIndex(OutputInterface $output, array $paths) + { + $output->writeln('Load index fixtures:'); + + $loader = new IndexFixtureLoader($this->container); + foreach ($paths as $path) { + $loader->loadFromDirectory($path); + } + + $fixtures = $loader->getFixtures(); + + // Purge indexes. + ExternalIndexInitializer::initialize($this->externalIndex); + InternalIndexInitializer::initialize($this->internalIndex); + SourceIndexInitializer::initialize($this->sourceIndex); + + $executorFactory = new IndexFixtureExecutorFactory(); + $executorFactory->external($this->externalIndex) + ->setLogger(function ($message) use ($output) { + $output->writeln(sprintf(' > %s', $message)); + }) + ->execute($fixtures); + $executorFactory->internal($this->internalIndex) + ->setLogger(function ($message) use ($output) { + $output->writeln(sprintf(' > %s', $message)); + }) + ->execute($fixtures); + $executorFactory->source($this->sourceIndex) + ->setLogger(function ($message) use ($output) { + $output->writeln(sprintf(' > %s', $message)); + }) + ->execute($fixtures); + } +} diff --git a/src/AppBundle/Command/ReindexDocumentsCommand.php b/src/AppBundle/Command/ReindexDocumentsCommand.php new file mode 100644 index 0000000..632f84f --- /dev/null +++ b/src/AppBundle/Command/ReindexDocumentsCommand.php @@ -0,0 +1,273 @@ +host = $host; + $this->port = $port; + $this->varPath = realpath($varPath); + + if ($this->varPath === false) { + throw new \InvalidArgumentException(sprintf( + '$varPath value \'%s\' is invalid path.', + $varPath + )); + } + + if (! is_dir($this->varPath)) { + throw new \InvalidArgumentException(sprintf( + '$varPath value \'%s\' is not a directory.', + $varPath + )); + } + + if (! is_writable($this->varPath)) { + throw new \InvalidArgumentException(sprintf( + '$varPath value \'%s\' is not available for writing.', + $varPath + )); + } + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure() + { + $this + ->setDescription('Migrate documents from one document index to another') + ->addArgument('src', InputArgument::REQUIRED, 'Source index name') + ->addArgument('dest', InputArgument::REQUIRED, 'Destination index name'); + } + + /** + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer + * + * @throws \Exception If can't reindex documents. + */ + protected function doExecute(InputInterface $input, OutputInterface $output) + { + $currentBucketIdx = null; + + try { + $destIndex = $input->getArgument('dest'); + + $client = ClientBuilder::create() + ->setHosts([ + [ + 'host' => $this->host, + 'port' => $this->port, + ], + ]) + ->build(); + + $response = $client->search([ + 'body' => ['query' => ['match_all' => (object) []]], + 'index' => $input->getArgument('src'), + 'type' => 'document', + 'scroll' => '1m', + 'size' => self::BUCKET_SIZE, + ]); + + $totalBucketCount = $response['hits']['total'] / self::BUCKET_SIZE; + + $scrollId = $response['_scroll_id']; + $currentBucketIdx = $this->getCurrentBucketIdx(); + + // + // Scroll required number of bucket. + // + // We can't use offset 'cause ElasticSearch are limiting max allowed + // offset value. Also because of it we use scroll api instead of just + // make search with offset. + // + for ($i = 0; $i < $currentBucketIdx; ++$i) { + $response = $client->scroll([ 'scroll_id' => $scrollId ]); + } + + // + // Reindex all documents. + // + while ($this->indexDocuments($response['hits']['hits'], $client, $destIndex)) { + $output->writeln(sprintf( + 'Process %d from %d buckets', + $currentBucketIdx, + $totalBucketCount + )); + + $response = $client->scroll([ + 'scroll_id' => $scrollId, + 'scroll' => '1m', + ]); + $currentBucketIdx++; + } + + if (file_exists($this->varPath . DIRECTORY_SEPARATOR . self::FAILED_IDX_FILE)) { + unlink($this->varPath . DIRECTORY_SEPARATOR . self::FAILED_IDX_FILE); + } + } catch (\Exception $exception) { + if ($currentBucketIdx !== null) { + file_put_contents( + $this->varPath . DIRECTORY_SEPARATOR . self::FAILED_IDX_FILE, + $currentBucketIdx + ); + } + throw $exception; + } + + return 0; + } + + /** + * @param array $documents Array of raw documents. + * @param Client $client A ElasticSearch Client instance. + * @param string $index A index name. + * + * @return boolean + */ + private function indexDocuments(array $documents, Client $client, $index) + { + if (count($documents) === 0) { + return false; + } + + // We should split documents into several buckets 'cause we may exceed allowed + // request size for ElasticSearch (10mb for AWS instance). + $buckets = []; + + $idx = 0; + $count = 0; + foreach ($documents as $document) { + if (++$count > self::BUCKET_SIZE / 2) { + $idx++; + $count = 0; + } + + $data = $document['_source']; + if (isset($data['collection_id'])) { + $data[FieldNameEnum::COLLECTION_ID] = $data['collection_id']; + $data[FieldNameEnum::COLLECTION_TYPE] = $data['collection_type']; + } + + if (isset($data['deleted_from'])) { + $data[FieldNameEnum::DELETE_FROM] = $data['deleted_from']; + } + + if (! isset($data[FieldNameEnum::DELETE_FROM])) { + $data[FieldNameEnum::DELETE_FROM] = []; + } + + $buckets[$idx][] = [ + 'index' => [ + '_index' => $index, + '_type' => 'document', + ], + ]; + $buckets[$idx][] = $this->getStrategy()->getIndexableData($data); + } + + foreach ($buckets as $bucket) { + $client->bulk(['body' => $bucket]); + } + + return true; + } + + /** + * @return HoseIndexStrategy + */ + private function getStrategy() + { + if ($this->strategy === null) { + $this->strategy = new HoseIndexStrategy(); + } + + return $this->strategy; + } + + /** + * @return integer + */ + private function getCurrentBucketIdx() + { + $filePath = $this->varPath . DIRECTORY_SEPARATOR . self::FAILED_IDX_FILE; + + if (file_exists($filePath)) { + return (int) file_get_contents($filePath); + } + + return 0; + } +} diff --git a/src/AppBundle/Command/SyncSiteConfigCommand.php b/src/AppBundle/Command/SyncSiteConfigCommand.php new file mode 100644 index 0000000..42f6f3f --- /dev/null +++ b/src/AppBundle/Command/SyncSiteConfigCommand.php @@ -0,0 +1,72 @@ +configuration = $configuration; + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure() + { + $this->setDescription('Sync site settings with base'); + } + + /** + * Executes the current command. + * + * This method is not abstract because you can use this class + * as a concrete class. In this case, instead of defining the + * execute() method, you set the code to execute by passing + * a Closure to the setCode() method. + * + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer null or 0 if everything went fine, or an error code. + * + * @throws \LogicException When this abstract method is not implemented. + * + * @see setCode() + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->configuration->syncWithDefinitions(); + + return 0; + } +} diff --git a/src/AppBundle/Command/UpdateStoredQueriesCommand.php b/src/AppBundle/Command/UpdateStoredQueriesCommand.php new file mode 100644 index 0000000..23674e7 --- /dev/null +++ b/src/AppBundle/Command/UpdateStoredQueriesCommand.php @@ -0,0 +1,106 @@ +em = $em; + $this->producer = $producer; + } + + + /** + * Configures the current command. + * + * @return void + */ + protected function configure() + { + $this->setDescription('Update stored queries.'); + } + + /** + * Executes the current command. + * + * This method is not abstract because you can use this class + * as a concrete class. In this case, instead of defining the + * execute() method, you set the code to execute by passing + * a Closure to the setCode() method. + * + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer null or 0 if everything went fine, or an error code. + * + * @throws \LogicException When this abstract method is not implemented. + * + * @see setCode() + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $queries = $this->getQueries(); + + /** @var StoredQuery $query */ + foreach ($queries as $query) { + $this->producer->publish($query->getId()); + } + + return 0; + } + + /** + * @return \Generator + */ + private function getQueries() + { + /** @var StoredQueryRepository $repository */ + $repository = $this->em->getRepository(StoredQuery::class); + + $iterate = $repository->getForUpdating()->getQuery()->iterate(); + + foreach ($iterate as $query) { + $query = $query[0]; + yield $query; + $this->em->detach($query); + } + } +} diff --git a/src/AppBundle/Configuration/AbstractConfiguration.php b/src/AppBundle/Configuration/AbstractConfiguration.php new file mode 100644 index 0000000..8bcbfbc --- /dev/null +++ b/src/AppBundle/Configuration/AbstractConfiguration.php @@ -0,0 +1,212 @@ +syncParameters(); + $this->definitions = $definitions; + } + + /** + * Get parameter value by name. + * + * @param string $name Parameter name. + * @param mixed $default Default value if parameter not found. + * + * @return mixed + */ + public function getParameter($name, $default = null) + { + if (! isset($this->map[$name]) || isset($this->removed[$name])) { + return $default; + } + + $param = $this->map[$name]; + if ($param === null) { + return $default; + } + + $value = $param->getValue(); + settype($value, $this->definitions->getDefinition($name)['type']); + + return $value; + } + + /** + * Sync current parameters with database. + * + * @return void + */ + public function syncParameters() + { + $params = $this->loadData(); + + foreach ($params as $param) { + $this->map[$param->getName()] = $param; + } + } + + /** + * Get all available parameters. + * + * @return ConfigurationParameterInterface[] + */ + public function getParameters() + { + return $this->map; + } + + /** + * Get parameter value by name. + * + * @param string $name Parameter name. + * @param mixed $value New parameter value. + * + * @return void + */ + public function setParameter($name, $value) + { + $this->map[$name]->setValue($this->definitions->normalize($name, $value)); + $this->changed[$name] = true; + } + + /** + * Set parameters. + * + * @param array $params Array where key is parameter name and value is new + * value. + * + * @return void + */ + public function setParameters(array $params) + { + foreach ($params as $name => $newValue) { + $this->setParameter($name, $newValue); + } + } + + /** + * Sync configuration with storage. + * + * @return void + */ + public function sync() + { + $changed = \nspl\a\filter(function (ConfigurationParameterInterface $parameter) { + return isset($this->changed[$parameter->getName()]); + }, $this->map); + + $removed = \nspl\a\filter(function (ConfigurationParameterInterface $parameter) { + return isset($this->removed[$parameter->getName()]); + }, $this->map); + + if ((count($changed) === 0) && (count($removed) === 0)) { + return; + } + + $this->doSync($changed, $removed); + + $this->map = \nspl\a\filter(function (ConfigurationParameterInterface $parameter) { + return ! isset($this->removed[$parameter->getName()]); + }, $this->map); + + $this->changed = []; + $this->removed = []; + } + + /** + * Sync parameters with list of available. + * + * @return void + */ + public function syncWithDefinitions() + { + $notExists = array_flip(ParametersName::getAvailables()); + + /** @var ConfigurationParameterMutableInterface $parameter */ + foreach ($this->map as $name => $parameter) { + if (! ParametersName::isExists($name)) { + $this->removed[$name] = $parameter; + } else { + $definition = $this->definitions->getDefinition($name); + $parameter + ->setTitle($definition['title']) + ->setSection($definition['section']); + $this->changed[$name] = $parameter; + unset($notExists[$name]); + } + } + + $notExists = array_keys($notExists); + foreach ($notExists as $name) { + $this->map[$name] = $this->createParameter($name); + $this->changed[$name] = true; + } + + $this->sync(); + } + + /** + * Create default parameter from config. + * + * @param string $name Parameter name. + * + * @return ConfigurationParameterInterface + */ + abstract protected function createParameter($name); + + /** + * Load configuration from storage. + * + * @return ConfigurationParameterInterface[] + */ + abstract protected function loadData(); + + /** + * @param ConfigurationParameterInterface[]|array $changed Array of changed + * instances. + * @param ConfigurationParameterInterface[]|array $removed Array of removed + * parameter names. + * + * @return void + */ + abstract protected function doSync(array $changed, array $removed); +} diff --git a/src/AppBundle/Configuration/ConfigurationDefinitionMap.php b/src/AppBundle/Configuration/ConfigurationDefinitionMap.php new file mode 100644 index 0000000..8376ae4 --- /dev/null +++ b/src/AppBundle/Configuration/ConfigurationDefinitionMap.php @@ -0,0 +1,324 @@ +definitions = [ + ParametersName::MAILER_ADDRESS => [ + 'section' => 'Mailer', + 'title' => 'support@socialhose.io', + 'type' => 'string', + 'formType' => null, + 'default' => 'support@socialhose.io', + 'normalizer' => null, + 'denormalizer' => null, + 'constrains' => new Type([ 'type' => 'string' ]), + ], + ParametersName::MAILER_SENDER_NAME => [ + 'section' => 'Mailer', + 'title' => 'Socialhose', + 'type' => 'string', + 'formType' => null, + 'default' => 'Socialhose', + 'normalizer' => null, + 'denormalizer' => null, + 'constrains' => new Type([ 'type' => 'string' ]), + ], + ParametersName::NOTIFICATION_COMMENTS_PER_DOCUMENT => [ + 'section' => 'Notification', + 'title' => 'Max comments per document', + 'type' => 'integer', + 'formType' => null, + 'default' => 5, + 'normalizer' => null, + 'denormalizer' => null, + 'constrains' => new Type([ 'type' => 'numeric' ]), + ], + ParametersName::NOTIFICATION_DOCUMENT_PER_FEED => [ + 'section' => 'Notification', + 'title' => 'Max documents per feed in notification', + 'type' => 'integer', + 'formType' => null, + 'default' => 10, + 'normalizer' => null, + 'denormalizer' => null, + 'constrains' => new Type([ 'type' => 'numeric' ]), + ], + ParametersName::NOTIFICATION_START_EXTRACT_LENGTH => [ + 'section' => 'Search', + 'title' => 'Number of character for \'Start of text extract\'', + 'type' => 'integer', + 'formType' => null, + 'default' => 400, + 'normalizer' => null, + 'denormalizer' => null, + 'constrains' => new Type([ 'type' => 'numeric' ]), + ], + ParametersName::NOTIFICATION_CONTEXT_EXTRACT_LENGTH => [ + 'section' => 'Search', + 'title' => 'Numbers of character before and after first search keyword', + 'type' => 'integer', + 'formType' => null, + 'default' => 150, + 'normalizer' => null, + 'denormalizer' => null, + 'constrains' => new Type([ 'type' => 'numeric' ]), + ], + ParametersName::SEARCH_DOCUMENTS_FROM_FUTURE => [ + 'section' => 'Search', + 'title' => 'What we should do if documents published date in future', + 'type' => 'string', + 'formType' => ChoiceType::class, + 'choices' => [ + 'Exclude' => 'exclude', + 'Fix date' => 'fix_date', + ], + 'default' => 'exclude', + 'normalizer' => null, + 'denormalizer' => null, + 'constrains' => new Type([ 'type' => 'numeric' ]), + ], + ParametersName::NOTIFICATION_EMPTY_MESSAGE => [ + 'section' => 'Notification', + 'title' => 'Empty notification message', + 'type' => 'string', + 'formType' => CKEditorType::class, + 'default' => '

    We have not found any mentions for your search criteria today.

    ', + 'normalizer' => null, + 'denormalizer' => null, + 'constrains' => new Type([ 'type' => 'string' ]), + ], + ParametersName::NOTIFICATION_SEND_HISTORY_MODIFY => [ + 'section' => 'Notification', + 'title' => 'How long we story notification history', + 'type' => 'string', + 'formType' => null, + 'default' => '-3 months', + 'normalizer' => function ($value) { + return preg_replace('/(\d+)/', '-$1', str_replace('-', '', $value)); + }, + 'denormalizer' => function ($value) { + return str_replace('-', '', $value); + }, + 'constrains' => [ + new Type([ 'type' => 'string' ]), + new Callback([ $this, 'validateHistoryLifetime' ]), + ], + ], + + ParametersName::REGISTRATION_PAYMENT_AWAITING => [ + 'section' => 'Registration', + 'title' => 'Message after user provide billing information', + 'type' => 'string', + 'formType' => CKEditorType::class, + 'default' => '

    Thanks for submitting the form. Your payment is processing. When it done, you will receive email with passwird.

    ', + 'normalizer' => null, + 'denormalizer' => null, + 'constrains' => new Type([ 'type' => 'string' ]), + ], + + ParametersName::MAIL_PASSWORD => [ + 'section' => 'Email', + 'title' => 'Password email content', + 'type' => 'string', + 'formType' => CKEditorType::class, + 'default' => '

    Hello {{ user.firstName }} {{ user.lastName }}!

    You new password is {{ password }}

    Regards, the Team.

    ', + 'normalizer' => null, + 'denormalizer' => null, + 'constrains' => new Type([ 'type' => 'string' ]), + ], + ParametersName::MAIL_VERIFICATION_SUCCESS => [ + 'section' => 'Email', + 'title' => 'Verification success email content', + 'type' => 'string', + 'formType' => CKEditorType::class, + 'default' => '

    Hello {{ user.firstName }} {{ user.lastName }}!

    You registration is verified and you may proceed login with you credentials

    Email: {{ user.email }} Password: {{ password }}

    Regards, the Team.

    ', + 'normalizer' => null, + 'denormalizer' => null, + 'constrains' => new Type([ 'type' => 'string' ]), + ], + ParametersName::MAIL_VERIFICATION_REJECT => [ + 'section' => 'Email', + 'title' => 'Verification success email content', + 'type' => 'string', + 'formType' => CKEditorType::class, + 'default' => '

    Hello {{ user.firstName }} {{ user.lastName }}!

    Unfortunately you registration is rejected. Payments will be refund.

    Regards, the Team.

    ', + 'normalizer' => null, + 'denormalizer' => null, + 'constrains' => new Type([ 'type' => 'string' ]), + ], + ParametersName::MAIL_RESETTING_CONFIRMATION => [ + 'section' => 'Email', + 'title' => 'Password resetting email content', + 'type' => 'string', + 'formType' => CKEditorType::class, + 'default' => '

    Hello {{ user.firstName }} {{ user.lastName }}!

    To reset your password - please visit {{ confirmationUrl }}

    Regards, the Team.

    ', + 'normalizer' => null, + 'denormalizer' => null, + 'constrains' => new Type([ 'type' => 'string' ]), + ], + ParametersName::MAIL_UNSUBSCRIBE => [ + 'section' => 'Email', + 'title' => 'Unsubscribe email content', + 'type' => 'string', + 'formType' => CKEditorType::class, + 'default' => '

    {{ user.firstName}} {{ user.lastName }} has unsubscribed from your notification

    ', + 'normalizer' => null, + 'denormalizer' => null, + 'constrains' => new Type([ 'type' => 'string' ]), + ], + ]; + } + + /** + * Get definition for specified parameter. + * + * @param string $name Parameter name. + * + * @return array + */ + public function getDefinition($name) + { + if (! isset($this->definitions[$name])) { + throw new \InvalidArgumentException('Unknown '. $name); + } + + return $this->definitions[$name]; + } + + /** + * Normalize value. + * + * @param string $name Parameter name. + * @param mixed $value Raw value. + * + * @return mixed + */ + public function normalize($name, $value) + { + $definition = $this->getDefinition($name); + + if (isset($definition['normalizer'])) { + $value = $definition['normalizer']($value); + } + + return $value; + } + + /** + * Denormalize value. + * + * @param string $name Parameter name. + * @param mixed $value Normalized value. + * + * @return mixed + */ + public function denormalize($name, $value) + { + $definition = $this->getDefinition($name); + + if (isset($definition['denormalizer'])) { + $value = $definition['denormalizer']($value); + } + + return $value; + } + + /** + * Retrieve an external iterator. + * + * @return Traversable An instance of an object implementing \Iterator or + * \Traversable + */ + public function getIterator() + { + return new \ArrayIterator($this->definitions); + } + + /** + * @param string $value Raw value from user. + * @param ExecutionContextInterface $context A ExecutionContextInterface instance. + * + * @return void + */ + public function validateHistoryLifetime($value, ExecutionContextInterface $context) + { + // + // Split expiration time on groups + // + $valid = true; + $matches = []; + $result = preg_match_all('/(\d+\s?[A-Za-z]+)/', $value, $matches); + + if (($result === 0) || ($result === false)) { + $valid = false; + } else { + $matches = $matches[0]; + + foreach ($matches as $match) { + $match = trim($match); + $parts = explode(' ', $match); + + $count = 1; + $period = $parts[0]; + if (count($parts) === 2) { + list($count, $period) = $parts; + } + + if (! is_numeric($count) || !in_array($period, self::$availablePeriods, true)) { + $valid = false; + break; + } + } + } + + if (! $valid) { + $context->buildViolation('Invalid expiration time, should be space separated string where each element id match to patter "number year(s)|month(s)|week(s)|day(s)|hour(s)|minute(s)|second(s)"') + ->atPath('expirationTime') + ->addViolation(); + } + } +} diff --git a/src/AppBundle/Configuration/ConfigurationImmutableInterface.php b/src/AppBundle/Configuration/ConfigurationImmutableInterface.php new file mode 100644 index 0000000..effc289 --- /dev/null +++ b/src/AppBundle/Configuration/ConfigurationImmutableInterface.php @@ -0,0 +1,28 @@ +em = $em; + + parent::__construct($definitions); + } + + /** + * Create default parameter from config. + * + * @param string $name Parameter name. + * + * @return ConfigurationParameterInterface + */ + protected function createParameter($name) + { + $config = $this->definitions->getDefinition($name); + + return SiteSettings::create() + ->setSection($config['section']) + ->setName($name) + ->setTitle($config['title']) + ->setValue($config['default']); + } + + /** + * Load configuration from storage. + * + * @return ConfigurationParameterInterface[] + */ + protected function loadData() + { + return $this->em->getRepository(SiteSettings::class)->findAll(); + } + + /** + * @param ConfigurationParameterInterface[]|array $changed Array of changed + * instances. + * @param ConfigurationParameterInterface[]|array $removed Array of removed + * parameter names. + * + * @return void + */ + protected function doSync(array $changed, array $removed) + { + foreach ($changed as $parameter) { + $this->em->persist($parameter); + } + + foreach ($removed as $parameter) { + $this->em->remove($parameter); + } + + $this->em->flush(); + } +} diff --git a/src/AppBundle/Configuration/ParametersName.php b/src/AppBundle/Configuration/ParametersName.php new file mode 100644 index 0000000..907087d --- /dev/null +++ b/src/AppBundle/Configuration/ParametersName.php @@ -0,0 +1,85 @@ +em = $em; + $this->feedFormatter = $feedFormatter; + } + + /** + * @Route("/feed/{id}.{format}") + * + * @param Request $request A HTTP Request instance. + * @param integer $id A Feed entity id. + * @param string $format A format name. + * + * @return Response + */ + public function exportFeedAction(Request $request, $id, $format) + { + /** @var CommonFeedRepository $repository */ + $repository = $this->em->getRepository(AbstractFeed::class); + + $feed = $repository->find($id); + if ((! $feed instanceof AbstractFeed) || ! $feed->getExported()) { + throw $this->createNotFoundException(); + } + + $format = strtolower(trim($format)); + if (! FormatNameEnum::isValid($format)) { + throw new BadRequestHttpException('Unknown format '. $format); + } + + $data = $this->feedFormatter->formatFeed($feed, new FormatterOptions( + new FormatNameEnum($format), + $this->getNumber($request), + $this->getExtract($request), + $this->getShowImage($request), + $this->getAsPlain($request) + )); + + return Response::create($data->getData(), 200, [ + 'Content-Type' => $data->getMime(), + ]); + } + + /** + * @Route( + * "/{part}", + * methods={ "GET" }, + * requirements={ "part"=".*" }, + * defaults={ "part"="" } + * ) + * @Template("AppBundle::index.html.twig") + * + * @return array + */ + public function indexAction() + { + return []; + } + + /** + * @param Request $request A HTTP Request instance. + * + * @return integer + */ + private function getNumber(Request $request) + { + $number = $request->query->getInt('n', 30); + + if (($number < 1) || ($number > 200)) { + throw new BadRequestHttpException("'n' should be integer between 1 and 200."); + } + + return $number; + } + + /** + * @param Request $request A HTTP Request instance. + * + * @return ThemeOptionExtractEnum + */ + private function getExtract(Request $request) + { + $extract = strtolower(trim($request->query->get('ext', 'n'))); + + switch ($extract) { + case 's': + $extract = ThemeOptionExtractEnum::start(); + break; + + case 'sc': + $extract = ThemeOptionExtractEnum::context(); + break; + + case 'n': + $extract = ThemeOptionExtractEnum::no(); + break; + + default: + throw new BadRequestHttpException("'ext' should be one of: s, sc, n."); + } + + return $extract; + } + + /** + * @param Request $request A Request instance. + * + * @return boolean + */ + private function getShowImage(Request $request) + { + $showImage = $request->query->get('img', '0'); + + if (($showImage !== '0') && ($showImage !== '1')) { + throw new BadRequestHttpException("'img' should be 0 or 1."); + } + + return $showImage === '1'; + } + + /** + * @param Request $request A Request instance. + * + * @return boolean + */ + private function getAsPlain(Request $request) + { + $textFormat = strtolower(trim($request->query->get('text_format'))); + + if (($textFormat !== '') && ($textFormat !== 'text')) { + throw new BadRequestHttpException("'text_format' should not be defined or contains 'text' value."); + } + + return $textFormat === 'text'; + } +} diff --git a/src/AppBundle/Controller/Traits/AccessCheckerTrait.php b/src/AppBundle/Controller/Traits/AccessCheckerTrait.php new file mode 100644 index 0000000..c0368f4 --- /dev/null +++ b/src/AppBundle/Controller/Traits/AccessCheckerTrait.php @@ -0,0 +1,43 @@ +accessChecker, 'isGranted' ], $action); + + return \nspl\a\flatten(\nspl\a\map($grantChecker, $entity)); + } +} diff --git a/src/AppBundle/Controller/Traits/FormFactoryAwareTrait.php b/src/AppBundle/Controller/Traits/FormFactoryAwareTrait.php new file mode 100644 index 0000000..03db59a --- /dev/null +++ b/src/AppBundle/Controller/Traits/FormFactoryAwareTrait.php @@ -0,0 +1,50 @@ +formFactory->create($type, $data, $options); + } + + /** + * Creates and returns a form builder instance. + * + * @param mixed $data The initial data for the form. + * @param array $options Options for the form. + * + * @return FormBuilderInterface + */ + protected function createFormBuilder($data = null, array $options = array()) + { + return $this->formFactory->createBuilder(FormType::class, $data, $options); + } +} diff --git a/src/AppBundle/Controller/Traits/TokenStorageAwareTrait.php b/src/AppBundle/Controller/Traits/TokenStorageAwareTrait.php new file mode 100644 index 0000000..c38b01a --- /dev/null +++ b/src/AppBundle/Controller/Traits/TokenStorageAwareTrait.php @@ -0,0 +1,39 @@ +tokenStorage->getToken(); + + if ($token !== null) { + $user = $token->getUser(); + } + + return $user; + } +} diff --git a/src/AppBundle/Controller/V1/AbstractV1Controller.php b/src/AppBundle/Controller/V1/AbstractV1Controller.php new file mode 100644 index 0000000..48fabbb --- /dev/null +++ b/src/AppBundle/Controller/V1/AbstractV1Controller.php @@ -0,0 +1,97 @@ + $data->getDocuments(), + 'count' => count($data), + 'totalCount' => $data->getTotalCount(), + 'page' => $page, + 'limit' => $limit, + ]; + } elseif ($data instanceof QueryBuilder) { + $data + ->setMaxResults($limit) + ->setFirstResult(($page - 1) * $limit); + + $paginator = new Paginator($data); + $data = iterator_to_array($paginator); + + return [ + 'data' => $data, + 'count' => count($data), + 'totalCount' => $paginator->count(), + 'page' => $page, + 'limit' => $limit, + ]; + } + + return [];// TODO add code for over paginated data. + } + + /** + * Returns a NotFoundHttpException. + * + * This will result in a 404 response code. Usage example: + * + * throw $this->createNotFoundException('Page not found!'); + * + * @param string $message A message. + * @param \Exception|null $previous The previous exception. + * + * @return NotFoundHttpException + */ + protected function createNotFoundException($message = 'Not Found', \Exception $previous = null) + { + return new NotFoundHttpException($message, $previous); + } +} diff --git a/src/AppBundle/Controller/V1/AbstractV1CrudController.php b/src/AppBundle/Controller/V1/AbstractV1CrudController.php new file mode 100644 index 0000000..6caeae5 --- /dev/null +++ b/src/AppBundle/Controller/V1/AbstractV1CrudController.php @@ -0,0 +1,286 @@ +formFactory = $formFactory; + $this->accessChecker = $accessChecker; + $this->em = $em; + $this->entity = $entity; + } + + /** + * Create new entity. + * + * @param Request $request A Request instance. + * @param ManageableEntityInterface $entity A ManageableEntityInterface + * instance. + * + * @return \ApiBundle\Entity\ManageableEntityInterface|\ApiBundle\Response\ViewInterface + */ + protected function createEntity(Request $request, ManageableEntityInterface $entity) + { + $form = $this->createForm($entity->getCreateFormClass(), $entity); + + // Submit data into form. + $form->submit($request->request->all()); + if ($form->isValid()) { + // Check that current user can create this entity. + // If user don't have rights to create this entity we should send all + // founded restrictions to client. + $reasons = $this->checkAccess(InspectorInterface::CREATE, $entity); + if (count($reasons) > 0) { + // User don't have rights to create this entity so send all + // founded restriction reasons to client. + return $this->generateResponse($reasons, 403); + } + + $this->em->persist($entity); + $this->em->flush(); + + return $entity; + } + + // Client send invalid data. + return $this->generateResponse($form, 400); + } + + /** + * Get information about single entity. + * + * @param integer|ManageableEntityInterface|null $id A entity id. + * + * @return \ApiBundle\Entity\ManageableEntityInterface|\ApiBundle\Response\ViewInterface + */ + protected function getEntity($id) + { + $foundedEntity = $id; + if (is_numeric($id)) { + $repository = $this->em->getRepository($this->entity); + + $foundedEntity = $repository->find($id); + } + + if ($foundedEntity === null) { + $name = \app\c\getShortName($this->entity); + // Remove 'Abstract' prefix if it exists. + if (strpos($name, 'Abstract') !== false) { + $name = substr($name, 8); + } + + return $this->generateResponse("Can't find {$name} with id {$id}.", 404); + } + + // Check that current user can read this entity. + // If user don't have rights to read this entity we should send all + // founded restrictions to client. + $reasons = $this->checkAccess(InspectorInterface::READ, $foundedEntity); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + return $foundedEntity; + } + + /** + * Update entity. + * + * @param Request $request A Request instance. + * @param integer|ManageableEntityInterface|null $entity A entity id. + * + * @return \ApiBundle\Entity\ManageableEntityInterface|\ApiBundle\Response\ViewInterface + */ + protected function putEntity(Request $request, $entity) + { + $foundedEntity = $entity; + if (is_numeric($entity)) { + $repository = $this->em->getRepository($this->entity); + /** @var \ApiBundle\Entity\ManageableEntityInterface $entity */ + $foundedEntity = $repository->find($entity); + } + + if ($foundedEntity === null) { + $name = \app\c\getShortName($this->entity); + // Remove 'Abstract' prefix if it exists. + if (strpos($name, 'Abstract') !== false) { + $name = substr($name, 8); + } + + return $this->generateResponse("Can't find {$name} with id {$entity}.", 404); + } + + $form = $this->createForm($foundedEntity->getUpdateFormClass(), $foundedEntity, [ + 'method' => 'PUT', + ]); + $form->submit($request->request->all()); + if ($form->isValid()) { + // Check that current user can update this entity. + // If user don't have rights to update this entity we should send all + // founded restrictions to client. + $reasons = $this->checkAccess(InspectorInterface::UPDATE, $foundedEntity); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + $this->em->persist($foundedEntity); + $this->em->flush(); + + return $foundedEntity; + } + + return $this->generateResponse($form, 400); + } + + /** + * Delete entity. + * + * @param integer|ManageableEntityInterface|null $entity A entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + protected function deleteEntity($entity) + { + $foundedEntity = $entity; + if (is_numeric($entity)) { + $repository = $this->em->getRepository($this->entity); + /** @var \ApiBundle\Entity\ManageableEntityInterface $entity */ + $foundedEntity = $repository->find($entity); + } + + if ($foundedEntity === null) { + $name = \app\c\getShortName($this->entity); + // Remove 'Abstract' prefix if it exists. + if (strpos($name, 'Abstract') !== false) { + $name = substr($name, 8); + } + + return $this->generateResponse("Can't find {$name} with id {$entity}.", 404); + } + // Check that current user can delete this entity. + // If user don't have rights to delete this entity we should send all + // founded restrictions to client. + $reasons = $this->checkAccess(InspectorInterface::DELETE, $foundedEntity); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + $this->em->remove($foundedEntity); + $this->em->flush(); + + return $this->generateResponse(); + } + + /** + * @param Request $request A Request instance. + * @param string|callable $permission A requested permission. + * @param string $formClass Form class fqcn. + * @param callable $processor Function which process founded entities. + * + * @return \ApiBundle\Response\ViewInterface + */ + protected function batchProcessing( + Request $request, + $permission, + $formClass, + callable $processor + ) { + $this->checkFormClass($formClass); + + $form = $this->createForm($formClass, null, [ 'class' => $this->entity ]); + + $form->submit($request->request->all()); + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + + if (is_callable($permission)) { + $permission = call_user_func_array($permission, $data); + } + + if (! is_string($permission)) { + throw new \InvalidArgumentException('$permission should be string or callable'); + } + + $reasons = $this->checkAccess($permission, $data['entities']); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + $response = call_user_func_array($processor, $data); + if ($response === null) { + $response = $this->generateResponse(); + } + + return $response; + } + + return $this->generateResponse($form, 400); + } + + /** + * @param string $formClass Form class fqcn. + * + * @return void + */ + private function checkFormClass($formClass) + { + if (! is_string($formClass) || ! class_exists($formClass)) { + throw new \InvalidArgumentException('$formClass should be fqcn'); + } + + if (($formClass !== EntitiesBatchType::class) + && ! in_array(EntitiesBatchType::class, class_parents($formClass), true)) { + throw new \InvalidArgumentException('Invalid form class '. $formClass); + } + } +} diff --git a/src/AppBundle/Controller/V1/AnalyticController.php b/src/AppBundle/Controller/V1/AnalyticController.php new file mode 100644 index 0000000..134d3db --- /dev/null +++ b/src/AppBundle/Controller/V1/AnalyticController.php @@ -0,0 +1,288 @@ +getCurrentUser(); + $this->analyticFactory = $this->get('cache.analytic_factory'); + + $form = $this->createForm(AnalyticType::class) + ->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var AnalyticDTO $dto */ + $dto = $form->getData(); + + try { + $analytic = $this->analyticFactory->createAnalytic($dto, $user); + } catch (NotAllowedException $exception) { + return $this->generateResponse('You not allowed to make analytics'); + } + + $this->getManager()->persist($analytic); + $this->getManager()->flush(); + + return $this->generateResponse($analytic, 200, ['id', 'analytic']); + } + + return $this->generateResponse($form, 400); + } + + /** + * Get specified analytic by id. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/{id}", + * requirements={ "id"="\d+" }, + * methods={ "GET" } + * ) + * @AppApiDoc( + * resource=true, + * section="Analytic", + * output={ + * "class"="CacheBundle\Entity\Analytic\Analytic", + * "groups"={"id"} + * }, + * statusCodes={ + * 200="Analytics successfully returned.", + * 403="You don't have permissions to view this analytics.", + * 404="Can't find analytic by specified id." + * } + * ) + * + * @param integer $id Analytic entity id. + * + * @return \CacheBundle\Entity\Analytic\Analytic|\ApiBundle\Response\ViewInterface + */ + public function getAction($id) + { + return parent::getEntity($id); + } + + + /** + * Delete specified analytic. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/{id}", + * requirements={ "id"="\d+" }, + * methods={ "DELETE" } + * ) + * @AppApiDoc( + * resource=true, + * section="Analytic", + * statusCodes={ + * 204="Analytic successfully deleted.", + * 403="You don't have permissions to delete this analytic.", + * 404="Can't find analytic by specified id." + * } + * ) + * + * @param integer $id A Analytic entity id. + * + * @return array|\ApiBundle\Response\ViewInterface + */ + public function deleteAction($id) + { + $repository = $this->getManager()->getRepository($this->entity); + /** @var Analytic $analytic */ + $analytic = $repository->find($id); + + if (!$analytic instanceof Analytic) { + return $this->generateResponse("Can't find analytic with id {$id}.", 404); + } + $reasons = $this->checkAccess(InspectorInterface::DELETE, $analytic); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + $analyticContext = $analytic->getContext(); + + if (isset($analyticContext)) { + ($analyticContext->getAnalytics()->count() == 1) ? $this->getManager()->remove($analyticContext) : ""; + } + + $this->getManager()->remove($analytic); + $this->getManager()->flush(); + + return $this->generateResponse(); + } + + /** + * Update analytic. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route("/{id}", methods={ "PUT" }, requirements={ "id"="\d+" }) + * @AppApiDoc( + * section="Analytic", + * resource=true, + * input={ + * "class"="CacheBundle\Form\AnalyticType", + * "name"=false + * }, + * output={ + * "class"="CacheBundle\Entity\Analytic\Analytic", + * "groups"={ "analytic", "id" } + * }, + * statusCodes={ + * 200="Analytics successfully updated.", + * 400="Invalid data provided." + * } + * ) + * + * @param Request $request A Request instance. + * @param integer $id Analytic entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function putAction(Request $request, $id) + { + $user = $this->getCurrentUser(); + $this->analyticFactory = $this->get('cache.analytic_factory'); + + /** @var AnalyticRepository $analyticRepository */ + $repository = $this->getManager()->getRepository($this->entity); + /** @var Analytic $analytic */ + $analytic = $repository->find($id); + + if (!$analytic instanceof Analytic) { + return $this->generateResponse("Can't find analytic with id {$id}.", 404); + } + $feeds = $analytic->getContext()->getFeeds(); + $feedsId = []; + foreach ($feeds as $feedsVal) { + $feedsId[] = $feedsVal->getId(); + } + + $analyticDto = new AnalyticDTO($feedsId, null, $analytic->getContext()->getFilters(), $analytic->getContext()->getRawFilters()); + $form = $this->createForm(AnalyticType::class, $analyticDto); + $form->submit($request->request->all()); + if ($form->isValid()) { + /** @var AnalyticDTO $dto */ + $dto = $form->getData(); + + try { + $analyticContext = $analytic->getContext(); + if (isset($analyticContext)) { + ($analyticContext->getAnalytics()->count() == 1) ? $this->getManager()->remove($analyticContext) : ""; + } + $analytic = $this->analyticFactory->updateAnalytic($dto, $user, $analytic); + + } catch (NotAllowedException $exception) { + return $this->generateResponse('You not allowed to update analytics'); + } + + $this->getManager()->persist($analytic); + $this->getManager()->flush(); + + return $this->generateResponse($analytic, 200, ['id', 'analytic']); + } + + return $this->generateResponse($form, 400); + } + + + /** + * Get list of categories for current user. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route(methods={ "GET" }) + * @AppApiDoc( + * section="Analytic", + * output={ + * "class"="Pagination", + * "groups"={ "analytic", "id","context" } + * }, + * statusCodes={ + * 200="List of analytic successfully returned." + * } + * ) + * + * @param Request $request + * @return array|\ApiBundle\Response\ViewInterface + */ + public function listAction(Request $request) + { + /** @var AnalyticRepository $repository */ + $repository = $this->getManager()->getRepository(Analytic::class); + + $user = $this->getCurrentUser(); + + $pagination = $this->paginate( + $request, + $repository->getList($user->getId()) + ); + + // Simulate pagination serialization. + return $this->generateResponse([ + $pagination + ], 200, [ + 'analytic', + 'id', + 'context' + ]); + } +} diff --git a/src/AppBundle/Controller/V1/AnalyticGraphController.php b/src/AppBundle/Controller/V1/AnalyticGraphController.php new file mode 100644 index 0000000..7f3f8a0 --- /dev/null +++ b/src/AppBundle/Controller/V1/AnalyticGraphController.php @@ -0,0 +1,250 @@ +request->get('isAuthorType', false); + $groupByField = 'source_hashcode'; + if ($isAuthorType === true) { + $groupByField = 'author_name'; + } + + /** @var AnalyticRepository $analyticRepository */ + $repository = $this->getManager()->getRepository($this->entity); + /** @var Analytic $analytic */ + $analytic = $repository->find($id); + + if (!$analytic instanceof Analytic) { + return $this->generateResponse("Can't find analytic with id {$id}.", 404); + } + $analyticContext = $analytic->getContext(); + $feeds = $analyticContext->getFeeds(); + $queryId = []; + $clipFeedId = []; + foreach ($feeds as $feedsVal) { + if ($feedsVal->getSubType() == 'query_feed') { + $queryId[] = $feedsVal->getQuery()->getId(); + } else { + $clipFeedId[] = $feedsVal->getId(); + } + } + $filters = $analyticContext->getFilters(); + $influenceData = []; + if (count($filters) > 0) { + if (array_key_exists('date', $filters)) { + + $startDt = $filters['date']->getFilters()[0]->getValue()->format('Y-m-d'); + $endDt = $filters['date']->getFilters()[1]->getValue()->format('Y-m-d'); + $repository = $this->getManager()->getRepository(Document::class); + $documents = $repository->getByQuery($queryId); + $clipDocuments = $repository->getByClip($clipFeedId); + + foreach ($feeds as $key => $feedsVal) { + $influenceData[$key]['name'] = $feedsVal->getName(); + $influenceData[$key]['data'] = []; + foreach ($documents as $document) { + if ($feedsVal->getSubType() == 'query_feed') { + if ($feedsVal->getQuery()->getId() == $document['id']) { + $publishDate = substr($document['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + if (array_key_exists($groupByField, $document['data'])) { + $engagementCount = 0; + if (array_key_exists("likes", $document['data'])) { + $engagementCount = $document['data']['likes']; + } + if (array_key_exists("dislikes", $document['data'])) { + $engagementCount += $document['data']['dislikes']; + } + if (array_key_exists("comments", $document['data'])) { + $engagementCount += $document['data']['comments']; + } + if (array_key_exists("shares", $document['data'])) { + $engagementCount += $document['data']['shares']; + } + $tempInfluenceData = $influenceData[$key]['data']; + if (count($tempInfluenceData) > 0) { + $sourceHashCodeKey = array_search($document['data'][$groupByField], array_column($tempInfluenceData, $groupByField)); + if ($sourceHashCodeKey === false) { + $tempData = [$groupByField => $document['data'][$groupByField], 'influence' => $document['data']['source_link'], + 'source_type' => $document['data']['source_publisher_type'], 'engagement' => $engagementCount, 'totalSentiment' => 0]; + if (array_key_exists("sentiment", $document['data'])) { + $sentiment = 1; + $tempData['totalSentiment'] = $sentiment; + $tempData[$document['data']['sentiment']] = $sentiment; + } + array_push($tempInfluenceData, $tempData); + $influenceData[$key]['data'] = $tempInfluenceData; + } else { + if (array_key_exists("sentiment", $document['data'])) { + $influenceData[$key]['data'][$sourceHashCodeKey]['totalSentiment'] += 1; + if (array_key_exists($document['data']['sentiment'], $influenceData[$key]['data'][$sourceHashCodeKey])) { + $influenceData[$key]['data'][$sourceHashCodeKey][$document['data']['sentiment']] += 1; + } else { + $influenceData[$key]['data'][$sourceHashCodeKey][$document['data']['sentiment']] = 1; + } + + } + $influenceData[$key]['data'][$sourceHashCodeKey]['engagement'] += $engagementCount; + } + } else { + $tempData = [$groupByField => $document['data'][$groupByField], 'influence' => $document['data']['source_link'], + 'source_type' => $document['data']['source_publisher_type'], 'engagement' => $engagementCount, 'totalSentiment' => 0]; + if (array_key_exists("sentiment", $document['data'])) { + $sentiment = 1; + $tempData['totalSentiment'] = $sentiment; + $tempData[$document['data']['sentiment']] = $sentiment; + } + $influenceData[$key]['data'][0] = $tempData; + } + } + } + } + } + } + + foreach ($clipDocuments as $clipDocument) { + if ($feedsVal->getSubType() == 'clip_feed') { + if ($feedsVal->getId() == $clipDocument['clipFeedId']) { + $publishDate = substr($clipDocument['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + if (array_key_exists($groupByField, $clipDocument['data'])) { + $engagementCount = 0; + if (array_key_exists("likes", $clipDocument['data'])) { + $engagementCount = $clipDocument['data']['likes']; + } + if (array_key_exists("dislikes", $clipDocument['data'])) { + $engagementCount += $clipDocument['data']['dislikes']; + } + if (array_key_exists("comments", $clipDocument['data'])) { + $engagementCount += $clipDocument['data']['comments']; + } + if (array_key_exists("shares", $clipDocument['data'])) { + $engagementCount += $clipDocument['data']['shares']; + } + $tempInfluenceData = $influenceData[$key]['data']; + if (count($tempInfluenceData) > 0) { + $sourceHashCodeKey = array_search($clipDocument['data'][$groupByField], array_column($tempInfluenceData, $groupByField)); + if ($sourceHashCodeKey === false) { + $tempData = [$groupByField => $clipDocument['data'][$groupByField], 'influence' => $clipDocument['data']['source_link'], + 'source_type' => $clipDocument['data']['source_publisher_type'], 'engagement' => $engagementCount, 'totalSentiment' => 0]; + if (array_key_exists("sentiment", $clipDocument['data'])) { + $sentiment = 1; + $tempData['totalSentiment'] = $sentiment; + $tempData[$clipDocument['data']['sentiment']] = $sentiment; + } + array_push($tempInfluenceData, $tempData); + $influenceData[$key]['data'] = $tempInfluenceData; + } else { + if (array_key_exists("sentiment", $clipDocument['data'])) { + $influenceData[$key]['data'][$sourceHashCodeKey]['totalSentiment'] += 1; + if (array_key_exists($clipDocument['data']['sentiment'], $influenceData[$key]['data'][$sourceHashCodeKey])) { + $influenceData[$key]['data'][$sourceHashCodeKey][$clipDocument['data']['sentiment']] += 1; + } else { + $influenceData[$key]['data'][$sourceHashCodeKey][$clipDocument['data']['sentiment']] = 1; + } + } + $influenceData[$key]['data'][$sourceHashCodeKey]['engagement'] += $engagementCount; + } + } else { + + $tempData = [$groupByField => $clipDocument['data'][$groupByField], 'influence' => $clipDocument['data']['source_link'], + 'source_type' => $clipDocument['data']['source_publisher_type'], 'engagement' => $engagementCount, 'totalSentiment' => 0]; + if (array_key_exists("sentiment", $clipDocument['data'])) { + $sentiment = 1; + $tempData['totalSentiment'] = $sentiment; + $tempData[$clipDocument['data']['sentiment']] = $sentiment; + } + $influenceData[$key]['data'][0] = $tempData; + } + } + } + } + } + } + } + } + } + + foreach ($influenceData as $key => $influenceDataVal) { + usort($influenceData[$key]['data'], function ($a, $b) { + return $b['totalSentiment'] <=> $a['totalSentiment']; + }); + } + + foreach ($influenceData as $key => $dataVal) { + $influenceData[$key]['data'] = array_slice($dataVal['data'], 0, 10); + } + + return $this->generateResponse([ + 'data' => $influenceData + ], 200, []); + } + + + /** + * @param $filters + * + * @return array + * + * @throws \Exception + */ + public function getDuration($filters) + { + $duration = []; + if (count($filters) > 0) { + if (array_key_exists('date', $filters)) { + $period = new DatePeriod( + $filters['date']->getFilters()[0]->getValue(), + new DateInterval('P1D'), + $filters['date']->getFilters()[1]->getValue() + ); + foreach ($period as $key => $value) { + $duration[$value->format('Y-m-d')] = 0; + } + } + } + + return $duration; + } +} diff --git a/src/AppBundle/Controller/V1/AnalyticMentionGraphController.php b/src/AppBundle/Controller/V1/AnalyticMentionGraphController.php new file mode 100644 index 0000000..22c728e --- /dev/null +++ b/src/AppBundle/Controller/V1/AnalyticMentionGraphController.php @@ -0,0 +1,1108 @@ +getManager()->getRepository($this->entity); + /** @var Analytic $analytic */ + $analytic = $repository->find($id); + + if (!$analytic instanceof Analytic) { + return $this->generateResponse("Can't find analytic with id {$id}.", 404); + } + $analyticContext = $analytic->getContext(); + $feeds = $analyticContext->getFeeds(); + $data = []; + $queryId = []; + $clipFeedId = []; + foreach ($feeds as $feedsVal) { + if ($feedsVal->getSubType() == 'query_feed') { + $queryId[] = $feedsVal->getQuery()->getId(); + } else { + $clipFeedId[] = $feedsVal->getId(); + } + } + + $filters = $analyticContext->getFilters(); + + $duration = $this->getDuration($filters); + + if (count($filters) > 0) { + if (array_key_exists('date', $filters)) { + $startDt = $filters['date']->getFilters()[0]->getValue()->format('Y-m-d'); + $endDt = $filters['date']->getFilters()[1]->getValue()->format('Y-m-d'); + $repository = $this->getManager()->getRepository(Document::class); + $documents = $repository->getByQuery($queryId); + $clipDocuments = $repository->getByClip($clipFeedId); + foreach ($feeds as $key => $feedsVal) { + $data[$key]['name'] = $feedsVal->getName(); + $data[$key]['data'] = $duration; + foreach ($documents as $document) { + if ($feedsVal->getSubType() == 'query_feed') { + if ($feedsVal->getQuery()->getId() == $document['id']) { + $publishDate = substr($document['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + if (array_key_exists($publishDate, $data[$key]['data'])) { + $data[$key]['data'][$publishDate] += 1; + } else { + $data[$key]['data'][$publishDate] = 1; + } + } + } + } + } + + foreach ($clipDocuments as $clipDocument) { + if ($feedsVal->getSubType() == 'clip_feed') { + if ($feedsVal->getId() == $clipDocument['clipFeedId']) { + $publishDate = substr($clipDocument['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + if (array_key_exists($publishDate, $data[$key]['data'])) { + $data[$key]['data'][$publishDate] += 1; + } else { + $data[$key]['data'][$publishDate] = 1; + } + } + } + } + } + } + } + } + + return $this->generateResponse([ + 'data' => $data, + ], 200, []); + } + + + /** + * Get data for mention pie graph + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/mention-pie-graph/{id}", + * requirements={ + * "id": "\d+", + * }, + * methods={ "POST" } + * ) + * @param $id + * + * @return \ApiBundle\Response\ViewInterface + * + * @throws \Exception + */ + public function getMentionPieGraphAction($id) + { + /** @var AnalyticRepository $analyticRepository */ + $repository = $this->getManager()->getRepository($this->entity); + /** @var Analytic $analytic */ + $analytic = $repository->find($id); + + if (!$analytic instanceof Analytic) { + return $this->generateResponse("Can't find analytic with id {$id}.", 404); + } + $analyticContext = $analytic->getContext(); + $feeds = $analyticContext->getFeeds(); + $queryId = []; + $clipFeedId = []; + foreach ($feeds as $feedsVal) { + if ($feedsVal->getSubType() == 'query_feed') { + $queryId[] = $feedsVal->getQuery()->getId(); + } else { + $clipFeedId[] = $feedsVal->getId(); + } + } + $filters = $analyticContext->getFilters(); + $data = []; + if (count($filters) > 0) { + if (array_key_exists('date', $filters)) { + $startDt = $filters['date']->getFilters()[0]->getValue()->format('Y-m-d'); + $endDt = $filters['date']->getFilters()[1]->getValue()->format('Y-m-d'); + $repository = $this->getManager()->getRepository(Document::class); + $documents = $repository->getByQuery($queryId); + $clipDocuments = $repository->getByClip($clipFeedId); + foreach ($feeds as $key => $feedsVal) { + $data[$feedsVal->getName()] = 0; + foreach ($documents as $document) { + if ($feedsVal->getSubType() == 'query_feed') { + if ($feedsVal->getQuery()->getId() == $document['id']) { + $publishDate = substr($document['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + $data[$feedsVal->getName()] += 1; + } + } + } + + } + + foreach ($clipDocuments as $clipDocument) { + if ($feedsVal->getSubType() == 'clip_feed') { + if ($feedsVal->getId() == $clipDocument['clipFeedId']) { + $publishDate = substr($clipDocument['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + $data[$feedsVal->getName()] += 1; + } + } + } + } + } + } + } + + return $this->generateResponse([ + 'data' => $data, + ], 200, []); + } + + /** + * Get data for mention pie graph according to type param + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/mention-over-time-pie-graph/{id}", + * requirements={ + * "id": "\d+", + * }, + * methods={ "POST" } + * ) + * @param Request $request + * @param $id + * + * @return \ApiBundle\Response\ViewInterface + * + */ + public function getMentionOverTimePieGraphAction(Request $request, $id) + { + if (!$request->request->has('type')) { + return $this->generateResponse("Missing type parameter.", 404); + } else { + $type = $request->request->get('type'); + if (!isset($type) || trim($type) === '') { + return $this->generateResponse("Invalid value for type parameter.", 404); + } else { + $defaultKey = []; + switch ($type) { + case "sentiment": + $groupByField = 'sentiment'; + $defaultKey = ['POSITIVE' => 0, 'NEUTRAL' => 0, 'NEGATIVE' => 0]; + break; + case "language": + $groupByField = 'lang'; + break; + case "country": + $groupByField = 'geo_country'; + break; + case "media": + $groupByField = 'source_publisher_subtype'; + break; + case "gender": + $groupByField = 'author_gender'; + break; + default: + return $this->generateResponse("Invalid value for type parameter.", 404); + } + /** @var AnalyticRepository $analyticRepository */ + $repository = $this->getManager()->getRepository($this->entity); + /** @var Analytic $analytic */ + $analytic = $repository->find($id); + + if (!$analytic instanceof Analytic) { + return $this->generateResponse("Can't find analytic with id {$id}.", 404); + } + + $analyticContext = $analytic->getContext(); + $feeds = $analyticContext->getFeeds(); + $queryId = []; + $clipFeedId = []; + + foreach ($feeds as $feedsVal) { + if ($feedsVal->getSubType() == 'query_feed') { + $queryId[] = $feedsVal->getQuery()->getId(); + } else { + $clipFeedId[] = $feedsVal->getId(); + } + } + + $filters = $analyticContext->getFilters(); + $data = []; + + if (count($filters) > 0) { + if (array_key_exists('date', $filters)) { + $startDt = $filters['date']->getFilters()[0]->getValue()->format('Y-m-d'); + $endDt = $filters['date']->getFilters()[1]->getValue()->format('Y-m-d'); + $repository = $this->getManager()->getRepository(Document::class); + $documents = $repository->getByQuery($queryId); + $clipDocuments = $repository->getByClip($clipFeedId); + + foreach ($feeds as $key => $feedsVal) { + $data[$feedsVal->getName()] = $defaultKey; + foreach ($documents as $document) { + if ($feedsVal->getSubType() == 'query_feed') { + if ($feedsVal->getQuery()->getId() == $document['id']) { + $publishDate = substr($document['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt) && array_key_exists($groupByField, $document['data'])) { + if ($groupByField == 'author_gender' && $document['data'][$groupByField] == 'UNKNOWN') { + continue; + } + if (array_key_exists($document['data'][$groupByField], $data[$feedsVal->getName()])) { + $data[$feedsVal->getName()][$document['data'][$groupByField]] += 1; + } else { + $data[$feedsVal->getName()][$document['data'][$groupByField]] = 1; + } + } + } + } + } + foreach ($clipDocuments as $clipDocument) { + if ($feedsVal->getSubType() == 'clip_feed') { + if ($feedsVal->getId() == $clipDocument['clipFeedId']) { + $publishDate = substr($clipDocument['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt) && array_key_exists($groupByField, $clipDocument['data'])) { + if ($groupByField == 'author_gender' && $clipDocument['data'][$groupByField] == 'UNKNOWN') { + continue; + } + if (array_key_exists($clipDocument['data'][$groupByField], $data[$feedsVal->getName()])) { + $data[$feedsVal->getName()][$clipDocument['data'][$groupByField]] += 1; + } else { + $data[$feedsVal->getName()][$clipDocument['data'][$groupByField]] = 1; + } + } + } + } + } + } + } + } + + + if ($type == 'media') { + foreach ($data as $dataKey => $dataValue) { + foreach ($dataValue as $mediaKey => $mediaVal) { + $data[$dataKey][$this->determineMediaType($mediaKey)] = $mediaVal; + unset($data[$dataKey][$mediaKey]); + } + + } + } + + return $this->generateResponse([ + 'data' => $data, + ], 200, []); + + } + } + } + + /** + * Get data for mention bar graph according to type param + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/mention-over-time-bar-graph/{id}", + * requirements={ + * "id": "\d+", + * }, + * methods={ "POST" } + * ) + * @param Request $request + * @param $id + * + * @return \ApiBundle\Response\ViewInterface + * + * @throws \Exception + */ + public function getMentionOverTimeBarGraphAction(Request $request, $id) + { + + if (!$request->request->has('type')) { + return $this->generateResponse("Missing type parameter.", 404); + } else { + $type = $request->request->get('type'); + if (!isset($type) || trim($type) === '') { + return $this->generateResponse("Invalid value for type parameter.", 404); + } else { + $defaultKey = []; + switch ($type) { + case "sentiment": + $groupByField = 'sentiment'; + $defaultKey = ['POSITIVE' => 0, 'NEUTRAL' => 0, 'NEGATIVE' => 0]; + break; + case "language": + $groupByField = 'lang'; + break; + case "country": + $groupByField = 'geo_country'; + break; + case "media": + $groupByField = 'source_publisher_subtype'; + break; + default: + return $this->generateResponse("Invalid value for type parameter.", 404); + } + + /** @var AnalyticRepository $analyticRepository */ + $repository = $this->getManager()->getRepository($this->entity); + /** @var Analytic $analytic */ + $analytic = $repository->find($id); + + if (!$analytic instanceof Analytic) { + return $this->generateResponse("Can't find analytic with id {$id}.", 404); + } + $analyticContext = $analytic->getContext(); + $feeds = $analyticContext->getFeeds(); + $data = []; + $queryId = []; + $clipFeedId = []; + foreach ($feeds as $feedsVal) { + if ($feedsVal->getSubType() == 'query_feed') { + $queryId[] = $feedsVal->getQuery()->getId(); + } else { + $clipFeedId[] = $feedsVal->getId(); + } + } + + $filters = $analyticContext->getFilters(); + + $duration = $this->getDuration($filters); + foreach ($duration as $key => $value) { + $duration[$key] = $defaultKey; + } + if (count($filters) > 0) { + if (array_key_exists('date', $filters)) { + $startDt = $filters['date']->getFilters()[0]->getValue()->format('Y-m-d'); + $endDt = $filters['date']->getFilters()[1]->getValue()->format('Y-m-d'); + $repository = $this->getManager()->getRepository(Document::class); + $documents = $repository->getByQuery($queryId); + $clipDocuments = $repository->getByClip($clipFeedId); + foreach ($feeds as $key => $feedsVal) { + $data[$key]['name'] = $feedsVal->getName(); + $data[$key]['data'] = $duration; + + foreach ($documents as $document) { + if ($feedsVal->getSubType() == 'query_feed') { + if ($feedsVal->getQuery()->getId() == $document['id']) { + $publishDate = substr($document['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt) && array_key_exists($groupByField, $document['data'])) { + if (array_key_exists($document['data'][$groupByField], $data[$key]['data'][$publishDate])) { + $data[$key]['data'][$publishDate][$document['data'][$groupByField]] += 1; + } else { + $data[$key]['data'][$publishDate][$document['data'][$groupByField]] = 1; + } + } + } + } + } + + foreach ($clipDocuments as $clipDocument) { + if ($feedsVal->getSubType() == 'clip_feed') { + if ($feedsVal->getId() == $clipDocument['clipFeedId']) { + $publishDate = substr($clipDocument['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt) && array_key_exists($groupByField, $clipDocument['data'])) { + if (array_key_exists($clipDocument['data'][$groupByField], $data[$key]['data'][$publishDate])) { + $data[$key]['data'][$publishDate][$clipDocument['data'][$groupByField]] += 1; + } else { + $data[$key]['data'][$publishDate][$clipDocument['data'][$groupByField]] = 1; + } + + } + } + } + } + } + } + } + + if ($type == 'media') { + foreach ($data as $dataKey => $dataValue) { + foreach ($dataValue['data'] as $dateKey => $dateValue) { + foreach ($dateValue as $mediaKey => $mediaVal) { + $data[$dataKey]['data'][$dateKey][$this->determineMediaType($mediaKey)] = $mediaVal; + unset($data[$dataKey]['data'][$dateKey][$mediaKey]); + } + } + } + } + + + return $this->generateResponse([ + 'data' => $data, + ], 200, []); + } + } + } + + /** + * Get data for engagement pie graph + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/engagement-over-time-pie-graph/{id}", + * requirements={ + * "id": "\d+", + * }, + * methods={ "POST" } + * ) + * @param Request $request + * @param $id + * + * @return \ApiBundle\Response\ViewInterface + * + * @throws \Exception + */ + public function getEngagementOverTimePieGraphAction(Request $request, $id) + { + + /** @var AnalyticRepository $analyticRepository */ + $repository = $this->getManager()->getRepository($this->entity); + /** @var Analytic $analytic */ + $analytic = $repository->find($id); + + if (!$analytic instanceof Analytic) { + return $this->generateResponse("Can't find analytic with id {$id}.", 404); + } + $analyticContext = $analytic->getContext(); + $feeds = $analyticContext->getFeeds(); + $queryId = []; + $clipFeedId = []; + foreach ($feeds as $feedsVal) { + if ($feedsVal->getSubType() == 'query_feed') { + $queryId[] = $feedsVal->getQuery()->getId(); + } else { + $clipFeedId[] = $feedsVal->getId(); + } + } + $filters = $analyticContext->getFilters(); + $data = []; + if (count($filters) > 0) { + if (array_key_exists('date', $filters)) { + $startDt = $filters['date']->getFilters()[0]->getValue()->format('Y-m-d'); + $endDt = $filters['date']->getFilters()[1]->getValue()->format('Y-m-d'); + $repository = $this->getManager()->getRepository(Document::class); + $documents = $repository->getByQuery($queryId); + $clipDocuments = $repository->getByClip($clipFeedId); + foreach ($feeds as $key => $feedsVal) { + $data[$feedsVal->getName()] = 0; + foreach ($documents as $document) { + if ($feedsVal->getSubType() == 'query_feed') { + if ($feedsVal->getQuery()->getId() == $document['id']) { + $publishDate = substr($document['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + if (array_key_exists("likes", $document['data'])) { + $data[$feedsVal->getName()] += $document['data']['likes']; + } + if (array_key_exists("dislikes", $document['data'])) { + $data[$feedsVal->getName()] += $document['data']['dislikes']; + } + if (array_key_exists("comments", $document['data'])) { + $data[$feedsVal->getName()] += $document['data']['comments']; + } + if (array_key_exists("shares", $document['data'])) { + $data[$feedsVal->getName()] += $document['data']['shares']; + } + } + } + } + + } + + foreach ($clipDocuments as $clipDocument) { + if ($feedsVal->getSubType() == 'clip_feed') { + if ($feedsVal->getId() == $clipDocument['clipFeedId']) { + $publishDate = substr($clipDocument['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + if (array_key_exists("likes", $clipDocument['data'])) { + $data[$feedsVal->getName()] += $clipDocument['data']['likes']; + } + if (array_key_exists("dislikes", $clipDocument['data'])) { + $data[$feedsVal->getName()] += $clipDocument['data']['dislikes']; + } + if (array_key_exists("comments", $clipDocument['data'])) { + $data[$feedsVal->getName()] += $clipDocument['data']['comments']; + } + if (array_key_exists("shares", $clipDocument['data'])) { + $data[$feedsVal->getName()] += $clipDocument['data']['shares']; + } + + } + } + } + } + } + } + } + + return $this->generateResponse([ + 'data' => $data, + ], 200, []); + } + + /** + * Get data for engagement bar graph + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/engagement-over-time-bar-graph/{id}", + * requirements={ + * "id": "\d+", + * }, + * methods={ "POST" } + * ) + * @param $id + * + * @return \ApiBundle\Response\ViewInterface + * + * @throws \Exception + */ + public function getEngagementOverTimeBarGraphAction($id) + { + /** @var AnalyticRepository $analyticRepository */ + $repository = $this->getManager()->getRepository($this->entity); + /** @var Analytic $analytic */ + $analytic = $repository->find($id); + + if (!$analytic instanceof Analytic) { + return $this->generateResponse("Can't find analytic with id {$id}.", 404); + } + $analyticContext = $analytic->getContext(); + $feeds = $analyticContext->getFeeds(); + $data = []; + $queryId = []; + $clipFeedId = []; + foreach ($feeds as $feedsVal) { + if ($feedsVal->getSubType() == 'query_feed') { + $queryId[] = $feedsVal->getQuery()->getId(); + } else { + $clipFeedId[] = $feedsVal->getId(); + } + } + + $filters = $analyticContext->getFilters(); + + $duration = $this->getDuration($filters); + + if (count($filters) > 0) { + if (array_key_exists('date', $filters)) { + $startDt = $filters['date']->getFilters()[0]->getValue()->format('Y-m-d'); + $endDt = $filters['date']->getFilters()[1]->getValue()->format('Y-m-d'); + $repository = $this->getManager()->getRepository(Document::class); + $documents = $repository->getByQuery($queryId); + $clipDocuments = $repository->getByClip($clipFeedId); + foreach ($feeds as $key => $feedsVal) { + $data[$key]['name'] = $feedsVal->getName(); + $data[$key]['data'] = $duration; + foreach ($documents as $document) { + if ($feedsVal->getSubType() == 'query_feed') { + if ($feedsVal->getQuery()->getId() == $document['id']) { + $publishDate = substr($document['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + if (array_key_exists("likes", $document['data'])) { + $data[$key]['data'][$publishDate] += $document['data']['likes']; + } + if (array_key_exists("dislikes", $document['data'])) { + $data[$key]['data'][$publishDate] += $document['data']['dislikes']; + } + if (array_key_exists("comments", $document['data'])) { + $data[$key]['data'][$publishDate] += $document['data']['comments']; + } + if (array_key_exists("shares", $document['data'])) { + $data[$key]['data'][$publishDate] += $document['data']['shares']; + } + + } + } + } + } + + foreach ($clipDocuments as $clipDocument) { + if ($feedsVal->getSubType() == 'clip_feed') { + if ($feedsVal->getId() == $clipDocument['clipFeedId']) { + $publishDate = substr($clipDocument['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + if (array_key_exists("likes", $clipDocument['data'])) { + $data[$key]['data'][$publishDate] += $clipDocument['data']['likes']; + } + if (array_key_exists("dislikes", $clipDocument['data'])) { + $data[$key]['data'][$publishDate] += $clipDocument['data']['dislikes']; + } + if (array_key_exists("comments", $clipDocument['data'])) { + $data[$key]['data'][$publishDate] += $clipDocument['data']['comments']; + } + if (array_key_exists("shares", $clipDocument['data'])) { + $data[$key]['data'][$publishDate] += $clipDocument['data']['shares']; + } + } + } + } + } + } + } + } + + return $this->generateResponse([ + 'data' => $data, + ], 200, []); + } + + /** + * @param $filters + * + * @return array + * + * @throws \Exception + */ + public function getDuration($filters) + { + $duration = []; + if (count($filters) > 0) { + if (array_key_exists('date', $filters)) { + $period = new DatePeriod( + $filters['date']->getFilters()[0]->getValue(), + new DateInterval('P1D'), + $filters['date']->getFilters()[1]->getValue() + ); + foreach ($period as $key => $value) { + $duration[$value->format('Y-m-d')] = 0; + } + } + } + + return $duration; + } + + /** + * Get data for theme bar graph + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/theme-over-time-bar-graph/{id}", + * requirements={ + * "id": "\d+", + * }, + * methods={ "POST" } + * ) + * @param $id + * + * @return \ApiBundle\Response\ViewInterface + * + * @throws \Exception + */ + public function getThemeOverTimeBarGraphAction($id) + { + + /** @var AnalyticRepository $analyticRepository */ + $repository = $this->getManager()->getRepository($this->entity); + /** @var Analytic $analytic */ + $analytic = $repository->find($id); + + if (!$analytic instanceof Analytic) { + return $this->generateResponse("Can't find analytic with id {$id}.", 404); + } + $analyticContext = $analytic->getContext(); + $feeds = $analyticContext->getFeeds(); + $data = []; + $queryId = []; + $clipFeedId = []; + foreach ($feeds as $feedsVal) { + if ($feedsVal->getSubType() == 'query_feed') { + $queryId[] = $feedsVal->getQuery()->getId(); + } else { + $clipFeedId[] = $feedsVal->getId(); + } + } + + $filters = $analyticContext->getFilters(); + + $duration = $this->getDuration($filters); + + if (count($filters) > 0) { + if (array_key_exists('date', $filters)) { + $startDt = $filters['date']->getFilters()[0]->getValue()->format('Y-m-d'); + $endDt = $filters['date']->getFilters()[1]->getValue()->format('Y-m-d'); + $repository = $this->getManager()->getRepository(Document::class); + $documents = $repository->getByQuery($queryId); + $clipDocuments = $repository->getByClip($clipFeedId); + foreach ($feeds as $key => $feedsVal) { + $data[$key]['name'] = $feedsVal->getName(); + $data[$key]['data'] = []; + + foreach ($documents as $document) { + if ($feedsVal->getSubType() == 'query_feed') { + if ($feedsVal->getQuery()->getId() == $document['id']) { + $publishDate = substr($document['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + if (array_key_exists("tags", $document['data'])) { + foreach ($document['data']['tags'] as $val) { + $tempTagData = $data[$key]['data']; + if (count($tempTagData) > 0) { + + $tagKey = array_search($val, array_column($tempTagData, 'name')); + if ($tagKey === false) { + $tempTagDataCount = count($tempTagData); + $tempTagData[$tempTagDataCount]['name'] = $val; + $tempTagData[$tempTagDataCount]['data'] = $duration; + $tempTagData[$tempTagDataCount]['data'][$publishDate] = 1; + + } else { + $tempTagData[$tagKey]['data'][$publishDate] += 1; + } + } else { + $tempTagDataCount = count($tempTagData); + $tempTagData[$tempTagDataCount]['name'] = $val; + $tempTagData[$tempTagDataCount]['data'] = $duration; + $tempTagData[$tempTagDataCount]['data'][$publishDate] = 1; + } + $data[$key]['data'] = $tempTagData; + } + } + } + } + } + } + + foreach ($clipDocuments as $clipDocument) { + if ($feedsVal->getSubType() == 'clip_feed') { + if ($feedsVal->getId() == $clipDocument['clipFeedId']) { + $publishDate = substr($clipDocument['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + if (array_key_exists("tags", $clipDocument['data'])) { + foreach ($clipDocument['data']['tags'] as $val) { + $tempTagData = $data[$key]['data']; + if (count($tempTagData) > 0) { + $tagKey = array_search($val, array_column($tempTagData, 'name')); + if ($tagKey === false) { + $tempTagDataCount = count($tempTagData); + $tempTagData[$tempTagDataCount]['name'] = $val; + $tempTagData[$tempTagDataCount]['data'] = $duration; + $tempTagData[$tempTagDataCount]['data'][$publishDate] = 1; + } else { + $tempTagData[$tagKey]['data'][$publishDate] += 1; + } + } else { + $tempTagDataCount = count($tempTagData); + $tempTagData[$tempTagDataCount]['name'] = $val; + $tempTagData[$tempTagDataCount]['data'] = $duration; + $tempTagData[$tempTagDataCount]['data'][$publishDate] = 1; + } + $data[$key]['data'] = $tempTagData; + } + } + } + } + } + } + } + } + } + + return $this->generateResponse([ + 'data' => $data, + ], 200, []); + } + + + /** + * Get data for theme bar graph + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/theme-over-time-pie-graph/{id}", + * requirements={ + * "id": "\d+", + * }, + * methods={ "POST" } + * ) + * @param $id + * + * @return \ApiBundle\Response\ViewInterface + * + * @throws \Exception + */ + public function getThemeOverTimePieGraphAction($id) + { + /** @var AnalyticRepository $analyticRepository */ + $repository = $this->getManager()->getRepository($this->entity); + /** @var Analytic $analytic */ + $analytic = $repository->find($id); + + if (!$analytic instanceof Analytic) { + return $this->generateResponse("Can't find analytic with id {$id}.", 404); + } + $analyticContext = $analytic->getContext(); + $feeds = $analyticContext->getFeeds(); + $data = []; + $queryId = []; + $clipFeedId = []; + foreach ($feeds as $feedsVal) { + if ($feedsVal->getSubType() == 'query_feed') { + $queryId[] = $feedsVal->getQuery()->getId(); + } else { + $clipFeedId[] = $feedsVal->getId(); + } + } + + $filters = $analyticContext->getFilters(); + + if (count($filters) > 0) { + if (array_key_exists('date', $filters)) { + $startDt = $filters['date']->getFilters()[0]->getValue()->format('Y-m-d'); + $endDt = $filters['date']->getFilters()[1]->getValue()->format('Y-m-d'); + $repository = $this->getManager()->getRepository(Document::class); + $documents = $repository->getByQuery($queryId); + $clipDocuments = $repository->getByClip($clipFeedId); + foreach ($feeds as $key => $feedsVal) { + $data[$key]['name'] = $feedsVal->getName(); + $data[$key]['data'] = []; + + foreach ($documents as $document) { + if ($feedsVal->getSubType() == 'query_feed') { + if ($feedsVal->getQuery()->getId() == $document['id']) { + $publishDate = substr($document['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + if (array_key_exists("tags", $document['data'])) { + foreach ($document['data']['tags'] as $val) { + $tempTagData = $data[$key]['data']; + if (count($tempTagData) > 0) { + if (array_key_exists($val, $tempTagData)) { + $tempTagData[$val] += 1; + } else { + $tempTagData[$val] = 1; + } + } else { + $tempTagData[$val] = 1; + } + $data[$key]['data'] = $tempTagData; + } + } + } + } + } + } + + foreach ($clipDocuments as $clipDocument) { + if ($feedsVal->getSubType() == 'clip_feed') { + if ($feedsVal->getId() == $clipDocument['clipFeedId']) { + $publishDate = substr($clipDocument['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + if (array_key_exists("tags", $clipDocument['data'])) { + foreach ($clipDocument['data']['tags'] as $val) { + $tempTagData = $data[$key]['data']; + if (count($tempTagData) > 0) { + + if (array_key_exists($val, $tempTagData)) { + $tempTagData[$val] += 1; + } else { + $tempTagData[$val] = 1; + } + } else { + $tempTagData[$val] = 1; + } + $data[$key]['data'] = $tempTagData; + } + } + } + } + } + } + } + } + } + + return $this->generateResponse([ + 'data' => $data, + ], 200, []); + } + + /** + * Get data for world map + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/world-map/{id}", + * requirements={ + * "id": "\d+", + * }, + * methods={ "POST" } + * ) + * @param $id + * + * @return \ApiBundle\Response\ViewInterface + * + * @throws \Exception + */ + public function getWorldMapAction($id) + { + /** @var AnalyticRepository $analyticRepository */ + $repository = $this->getManager()->getRepository($this->entity); + /** @var Analytic $analytic */ + $analytic = $repository->find($id); + + if (!$analytic instanceof Analytic) { + return $this->generateResponse("Can't find analytic with id {$id}.", 404); + } + $analyticContext = $analytic->getContext(); + $feeds = $analyticContext->getFeeds(); + $data = []; + $queryId = []; + $clipFeedId = []; + foreach ($feeds as $feedsVal) { + if ($feedsVal->getSubType() == 'query_feed') { + $queryId[] = $feedsVal->getQuery()->getId(); + } else { + $clipFeedId[] = $feedsVal->getId(); + } + } + + $filters = $analyticContext->getFilters(); + if (count($filters) > 0) { + if (array_key_exists('date', $filters)) { + $startDt = $filters['date']->getFilters()[0]->getValue()->format('Y-m-d'); + $endDt = $filters['date']->getFilters()[1]->getValue()->format('Y-m-d'); + $repository = $this->getManager()->getRepository(Document::class); + $documents = $repository->getByQuery($queryId); + $clipDocuments = $repository->getByClip($clipFeedId); + foreach ($feeds as $key => $feedsVal) { + $data[$key]['name'] = $feedsVal->getName(); + $data[$key]['data'] = []; + + foreach ($documents as $document) { + if ($feedsVal->getSubType() == 'query_feed') { + if ($feedsVal->getQuery()->getId() == $document['id']) { + $publishDate = substr($document['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + if (array_key_exists("sentiment", $document['data']) && array_key_exists("geo_country", $document['data']) + && array_key_exists("geo_point", $document['data'])) { + $tempData = $data[$key]['data']; + $mapDataKey = array_search($document['data']['geo_country'], array_column($tempData, 'name')); + if ($mapDataKey === false) { + $count = count($tempData); + $tempData[$count]['name'] = $document['data']['geo_country']; + $tempData[$count]['POSITIVE'] = 0; + $tempData[$count]['NEUTRAL'] = 0; + $tempData[$count]['NEGATIVE'] = 0; + $tempData[$count][$document['data']['sentiment']] += 1; + $tempData[$count]['LatLng'] = $document['data']['geo_point']; + } else { + $tempData[$mapDataKey][$document['data']['sentiment']] += 1; + } + $data[$key]['data'] = $tempData; + } + } + } + } + } + + foreach ($clipDocuments as $clipDocument) { + if ($feedsVal->getSubType() == 'clip_feed') { + if ($feedsVal->getId() == $clipDocument['clipFeedId']) { + $publishDate = substr($clipDocument['data']['published'], 0, 10); + $publishDate = date('Y-m-d', strtotime($publishDate)); + if (($publishDate >= $startDt) && ($publishDate <= $endDt)) { + if (array_key_exists("sentiment", $clipDocument['data']) && array_key_exists("geo_country", $clipDocument['data']) + && array_key_exists("geo_point", $clipDocument['data'])) { + $tempData = $data[$key]['data']; + $mapDataKey = array_search($clipDocument['data']['geo_country'], array_column($tempData, 'name')); + if ($mapDataKey === false) { + $count = count($tempData); + $tempData[$count]['name'] = $clipDocument['data']['geo_country']; + $tempData[$count]['POSITIVE'] = 0; + $tempData[$count]['NEUTRAL'] = 0; + $tempData[$count]['NEGATIVE'] = 0; + $tempData[$count][$clipDocument['data']['sentiment']] += 1; + $tempData[$count]['LatLng'] = $clipDocument['data']['geo_point']; + } else { + $tempData[$mapDataKey][$clipDocument['data']['sentiment']] += 1; + } + $data[$key]['data'] = $tempData; + } + } + } + } + } + } + } + } + + return $this->generateResponse([ + 'data' => $data + ], 200, []); + } + + /** + * @param $type + * @return mixed + */ + public function determineMediaType($type) + { + $mediaType = ['WEBLOG' => 'Blogs', 'MAINSTREAM_NEWS' => 'News', 'reddit' => 'Reddit', 'twitter' => 'Twitter', 'instagram' => 'Instagram']; + if (array_key_exists($type, $mediaType)) { + return $mediaType[$type]; + } + + return $type; + } +} diff --git a/src/AppBundle/Controller/V1/CategoryController.php b/src/AppBundle/Controller/V1/CategoryController.php new file mode 100644 index 0000000..29b5760 --- /dev/null +++ b/src/AppBundle/Controller/V1/CategoryController.php @@ -0,0 +1,329 @@ +", + * "groups"={ "id", "category_tree", "feed_tree" } + * }, + * statusCodes={ + * 200="List of updated categories successfully returned.", + * 400="Invalid data provided.", + * 403="You don't have permissions to move this category.", + * 404="Can't find moved or destination category." + * } + * ) + * + * @param integer $movedId A moved Category entity id. + * @param integer $destinationId A Category entity id where the category is + * moved. + * + * @return \ApiBundle\Response\ViewInterface|\Symfony\Component\HttpFoundation\Response + */ + public function moveAction($movedId, $destinationId) + { + $movedId = (integer) $movedId; + $destinationId = (integer) $destinationId; + $userId = \app\op\invokeIf($this->getCurrentUser(), 'getId'); + + /** @var CategoryRepository $repository */ + $repository = $this->getManager()->getRepository(Category::class); + + $moved = $repository->get($movedId, $userId); + if (! $moved instanceof Category) { + return $this->generateResponse("Can't find category with id {$movedId}.", 404); + } + + // + // Check that user don't try to move internal category. + // + if ($moved->isInternal()) { + return $this->generateResponse('Can\'t move internal category.', 403); + } + + // + // We should don't make any changes if client try to move category into + // the same category. + // + + if ($moved->getParent()->getId() !== $destinationId) { + $destination = $repository->get($destinationId, $userId, [ + Category::TYPE_CUSTOM, + Category::TYPE_MY_CONTENT, + ]); + if (! $destination instanceof Category) { + return $this->generateResponse("Can't find category with id {$destinationId}.", 404); + } + + // + // All ok, now we need to validate destination category id. + // + $moved->setParent($destination); + + /** @var ValidatorInterface $validator */ + $validator = $this->get('validator'); + $errors = $validator->validate($moved); + + if (count($errors) > 0) { + // + // Get all violation errors and send it to client. + // + $errors = array_map(function (ConstraintViolationInterface $violation) { + return $violation->getMessage(); + }, iterator_to_array($errors)); + + return $this->generateResponse($errors, 400); + } + + // + // Validation passed, update entity. + // + $this->getManager()->persist($moved); + $this->getManager()->flush(); + } + + return $this->forward('app.controller.category:listAction'); + } + + /** + * Create new category for current user. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route(methods={ "POST" }) + * @AppApiDoc( + * resource=true, + * section="Category", + * input={ + * "class"="CacheBundle\Form\CategoryType", + * "name"=false + * }, + * output={ + * "class"="CacheBundle\Entity\Category", + * "groups"={ "id", "category" } + * }, + * statusCodes={ + * 200="Category successfully created.", + * 400="Invalid data provided.", + * 403="You don't have permissions to create category." + * } + * ) + * + * @param Request $request A Request instance. + * + * @return \CacheBundle\Entity\Category|\ApiBundle\Response\ViewInterface + */ + public function createAction(Request $request) + { + return parent::createEntity($request, new Category($this->getCurrentUser())); + } + + /** + * Get list of categories for current user. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route(methods={ "GET" }) + * @AppApiDoc( + * section="Category", + * output={ + * "class"="Pagination", + * "groups"={ "id", "category_tree", "feed_tree" } + * }, + * statusCodes={ + * 200="List of categories successfully returned." + * } + * ) + * + * @return ViewInterface + */ + public function listAction() + { + /** @var CategoryRepository $repository */ + $repository = $this->getManager()->getRepository(Category::class); + + $user = $this->getCurrentUser(); + $categories = $repository->getList($user->getId()); + $count = count($categories); + + // Simulate pagination serialization. + return $this->generateResponse([ + 'data' => $categories, + 'count' => $count, + 'totalCount' => $count, + 'page' => 1, + 'limit' => $count, + ], 200, [ + 'id', + 'category_tree', + 'feed_tree', + ]); + } + + /** + * Get specified category by id. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/{id}", + * requirements={ "id"="\d+" }, + * methods={ "GET" } + * ) + * @AppApiDoc( + * resource=true, + * section="Category", + * output={ + * "class"="CacheBundle\Entity\Category", + * "groups"={ "id", "category", "feed_tree" } + * }, + * statusCodes={ + * 200="Category successfully returned.", + * 403="You don't have permissions to view this category.", + * 404="Can't find category by specified id." + * } + * ) + * + * @param integer $id A Category entity id. + * + * @return \CacheBundle\Entity\Category|\ApiBundle\Response\ViewInterface + */ + public function getAction($id) + { + return parent::getEntity($id); + } + + /** + * Update specified category. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/{id}", + * requirements={ "id"="\d+" }, + * methods={ "PUT" } + * ) + * @AppApiDoc( + * resource=true, + * section="Category", + * input={ + * "class"="CacheBundle\Form\CategoryType", + * "name"=false + * }, + * output={ + * "class"="CacheBundle\Entity\Category", + * "groups"={ "id", "category" } + * }, + * statusCodes={ + * 200="Category successfully updated.", + * 400="Invalid data provided.", + * 403="You don't have permissions to update this category.", + * 404="Can't find category by specified id." + * } + * ) + * + * @param Request $request A Request instance. + * @param integer $id A Category entity id. + * + * @return \CacheBundle\Entity\Category|\ApiBundle\Response\ViewInterface + */ + public function putAction(Request $request, $id) + { + return parent::putEntity($request, $id); + } + + /** + * Delete specified category. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/{id}", + * requirements={ "id"="\d+" }, + * methods={ "DELETE" } + * ) + * @AppApiDoc( + * resource=true, + * section="Category", + * statusCodes={ + * 204="Category successfully deleted.", + * 403="You don't have permissions to delete this category.", + * 404="Can't find category by specified id." + * } + * ) + * + * @param integer $id A Category entity id. + * + * @return array|\ApiBundle\Response\ViewInterface + */ + public function deleteAction($id) + { + /** @var CategoryRepository $repository */ + $repository = $this->getManager()->getRepository($this->entity); + $category = $repository->find($id); + + if ($category === null) { + return $this->generateResponse("Can't find category with id {$id}.", 404); + } + + $reasons = $this->checkAccess(InspectorInterface::DELETE, $category); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + // + // Update restriction limit for current user only if deleted category has + // some feeds. + // + $feedCount = $repository->computeFeedCounts($id); + if ($feedCount > 0) { + $user = $this->getCurrentUser(); + $user->releaseLimit(AppLimitEnum::feeds(), $feedCount); + + $this->getManager()->persist($user); + } + + $this->getManager()->remove($category); + $this->getManager()->flush(); + + return $this->generateResponse(); + } +} diff --git a/src/AppBundle/Controller/V1/CommentController.php b/src/AppBundle/Controller/V1/CommentController.php new file mode 100644 index 0000000..5d86a58 --- /dev/null +++ b/src/AppBundle/Controller/V1/CommentController.php @@ -0,0 +1,111 @@ +em->getRepository(Comment::class)->find($commentId); + + if ($entity === null) { + return $this->generateResponse("Can't find comment with id {$commentId}.", 404); + } + + $reasons = $this->checkAccess(InspectorInterface::DELETE, $entity); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + $this->em->getRepository(Document::class) + ->createQueryBuilder('Document') + ->update() + ->set('Document.commentsCount', 'Document.commentsCount - 1') + ->where('Document.id = :id') + ->setParameter('id', \app\op\invokeIf($entity->getDocument(), 'getId')) + ->getQuery() + ->execute(); + + $this->em->remove($entity); + $this->em->flush(); + + return $this->generateResponse(); + } +} diff --git a/src/AppBundle/Controller/V1/DocumentController.php b/src/AppBundle/Controller/V1/DocumentController.php new file mode 100644 index 0000000..209c61f --- /dev/null +++ b/src/AppBundle/Controller/V1/DocumentController.php @@ -0,0 +1,277 @@ +tokenStorage = $tokenStorage; + $this->formFactory = $formFactory; + $this->accessChecker = $accessChecker; + $this->em = $em; + $this->commentManager = $commentManager; + $this->emailProducer = $emailProducer; + } + + /** + * Create new comment for specified document. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/{documentId}/comments", + * requirements={ "documentId"="\d+" }, + * methods={ "POST" } + * ) + * @AppApiDoc( + * section="Document", + * resource=true, + * input={ + * "class"="CacheBundle\Form\CommentType", + * "name"=false + * }, + * output={ + * "class"="CacheBundle\Entity\Comment", + * "groups"={ "comment", "id" } + * }, + * statusCodes={ + * 200="Comment successfully saved.", + * 400="Invalid data provided." + * } + * ) + * + * @param Request $request A Request instance. + * @param integer $documentId Commented Document entity id. + * + * @return \ApiBundle\Entity\ManageableEntityInterface|\ApiBundle\Response\ViewInterface + */ + public function createCommentAction(Request $request, $documentId) + { + $document = $this->em->getRepository(Document::class)->find($documentId); + + if (! $document instanceof Document) { + return $this->generateResponse([[ + 'message' => 'Document not found', + 'transKey' => 'commentDocumentInvalidDocument', + 'type' => 'error', + 'parameters' => [ 'current' => $documentId ], + ], ], 404); + } + + $comment = new Comment($this->getCurrentUser(), ''); + + $form = $this->createForm($comment->getCreateFormClass(), $comment); + + // Submit data into form. + $form->submit($request->request->all()); + if ($form->isValid()) { + // + // Check that current user can create this entity. + // If user don't have rights to create this entity we should send all + // founded restrictions to client. + // + $reasons = $this->checkAccess(InspectorInterface::CREATE, $comment); + if (count($reasons) > 0) { + // + // User don't have rights to create this entity so send all + // founded restriction reasons to client. + // + return $this->generateResponse($reasons, 403); + } + + $this->commentManager->addComment($comment, $document); + + $this->em->persist($comment); + $this->em->flush(); + + return $comment; + } + + // Client send invalid data. + return $this->generateResponse($form, 400); + } + + /** + * Get list of comments for specified document. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/{documentId}/comments", + * requirements={ "documentId"="\d+" }, + * methods={ "GET" } + * ) + * @AppApiDoc( + * section="Document", + * resource=true, + * filters={ + * { + * "name"="offset", + * "dataType"="integer", + * "description"="Offset from beginning of collection, start from 1", + * "requirements"="\d+", + * "default"="1" + * }, + * { + * "name"="limit", + * "dataType"="integer", + * "description"="Max entities per page, default 10", + * "requirements"="\d+", + * "default"="10" + * }, + * }, + * output={ + * "class"="Paginated", + * "groups"={ "comment", "id" } + * }, + * statusCodes={ + * 200="List of comments returned.", + * 404="Invalid document id." + * } + * ) + * @param Request $request A Request instance. + * @param integer $documentId A Document entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function getCommentsAction(Request $request, $documentId) + { + $document = $this->em->getRepository(Document::class)->find($documentId); + + if (! $document instanceof Document) { + return $this->generateResponse([[ + 'message' => 'Document not found', + 'transKey' => 'getDocumentCommentsInvalidDocument', + 'type' => 'error', + 'parameters' => [ 'current' => $documentId ], + ], ], 404); + } + + /** @var CommentRepository $repository */ + $repository = $this->em->getRepository(Comment::class); + $qb = $repository->getListForDocument($documentId); + + $offset = $request->query->getInt('offset', CommentManagerInterface::NEW_COMMENT_POOL_SIZE); + $limit = $request->query->getInt('limit', 10); + + $qb + ->setFirstResult($offset) + ->setMaxResults($limit); + + return $this->generateResponse(new Paginator($qb), 200, [ 'id', 'comment' ]); + } + + /** + * Send specified documents content to recipients. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/email", + * methods={ "POST" } + * ) + * @AppApiDoc( + * section="Document", + * resource=true, + * input={ + * "class"="AppBundle\Form\EmailedDocumentType", + * "name"=false + * }, + * statusCodes={ + * 204="Email's sent.", + * 400="Invalid data." + * } + * ) + * @param Request $request A Request instance. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function emailAction(Request $request) + { + $emailedDocument = new EmailedDocument(); + $form = $this->createForm(EmailedDocumentType::class, $emailedDocument); + + $form->submit($request->request->all()); + if ($form->isSubmitted() && $form->isValid()) { + $this->em->persist($emailedDocument); + $this->em->flush(); + + $this->emailProducer->publish($emailedDocument->getId()); + + return $this->generateResponse(); + } + + return $this->generateResponse($form, 400); + } +} diff --git a/src/AppBundle/Controller/V1/FeedController.php b/src/AppBundle/Controller/V1/FeedController.php new file mode 100644 index 0000000..dd9eae8 --- /dev/null +++ b/src/AppBundle/Controller/V1/FeedController.php @@ -0,0 +1,1164 @@ +tokenStorage = $tokenStorage; + $this->feedManager = $feedManager; + $this->documentExtractor = $documentExtractor; + $this->feedFetcherFactory = $feedFetcherFactory; + $this->kernel = $kernel; + $this->storedQueryManager = $storedQueryManager; + $this->fetchProducer = $fetchProducer; + $this->feedDocumentLimit = $feedDocumentLimit; + } + + /** + * Create feed. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route("", methods={ "POST" }) + * @AppApiDoc( + * section="Feed", + * resource=true, + * input={ + * "class"="AppBundle\Form\FeedType", + * "name"=false + * }, + * output={ + * "class"="CacheBundle\Entity\Feed\AbstractFeed", + * "groups"={ "feed", "id" } + * }, + * statusCodes={ + * 200="Feed successfully saved.", + * 400="Invalid data provided." + * } + * ) + * + * @param Request $request A Request instance. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function createAction(Request $request) + { + $form = $this->createForm(FeedType::class) + ->submit($request->request->all()); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var \CacheBundle\Entity\Feed\QueryFeed $feed */ + $feed = $form->get('feed')->getData(); + /** @var SearchRequestBuilderInterface $builder */ + $builder = $form->get('search')->getData(); + + $user = $this->getCurrentUser(); + $exceptionLimitResponse = $this->generateResponse([ + 'failedRestriction' => AppLimitEnum::FEEDS, + 'restrictions' => $user->getRestrictions(), + ], 402); + + // + //Check that feed contains < $this->feedDocumentLimit records + // + try { + $user->checkLimit(AppLimitEnum::feeds()); + } catch (LimitExceedException $exception) { + return $exceptionLimitResponse; + } + if (!$feed instanceof ClipFeed) { + $searchData = $request->request->get('search'); + $total = $this->storedQueryManager->getTotal( + $builder, + isset($searchData['filters']) ? $searchData['filters'] : [], + isset($searchData['advancedFilters']) ? $searchData['advancedFilters'] : [] + ); + if ($total > $this->feedDocumentLimit) { + return $this->generateResponse(['Your request is too broad. Please narrow it down.'], 401); + } + } + + + + try { + $user->useLimit(AppLimitEnum::feeds()); + } catch (LimitExceedException $exception) { + return $exceptionLimitResponse; + } + + $this->em->persist($user); + + $feed->setUser($this->getCurrentUser()); + + if ($feed instanceof ClipFeed) { + $builder->getFilters(); + $feed->setFilters($builder->build()->getFilters()); + + $this->em->persist($feed); + $this->em->flush(); + } else { + $this->createQueryFeed($request, $builder, $feed); + } + + if (count($feed->getExcludedDocuments()) > 0) { + // + // We may already have documents so we should remove it here. + // + $this->feedManager->deleteDocuments($feed, \nspl\a\map( + \nspl\op\methodCaller('getSequence'), + $feed->getExcludedDocuments() + )); + } + + return $this->generateResponse($feed, 200, [ + 'feed', + 'id', + ]); + } + + return $this->generateResponse($form, 400); + } + + /** + * Rename feed. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route("/{id}/rename", methods={ "PUT" }, requirements={ "id":"\d+" }) + * @AppApiDoc( + * section="Feed", + * resource=true, + * input={ + * "class"="", + * "data"={ + * "name"={ + * "dataType"="string", + * "description"="new feed name", + * "required"=true, + * "readonly"=false + * } + * } + * }, + * statusCodes={ + * 204="Feed successfully renamed.", + * 400="Invalid data provided." + * } + * ) + * + * @param Request $request A Request instance. + * @param integer $id A Feed entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function renameAction(Request $request, $id) + { + /** @var User $user */ + $user = $this->getCurrentUser(); + + $newName = trim($request->request->get('name')); + if ($newName === '') { + return $this->generateResponse('Name not provided.', 400); + } + + /** @var CommonFeedRepository $feedRepository */ + $feedRepository = $this->em->getRepository(AbstractFeed::class); + + $feed = $feedRepository->getOne($id, $user->getId()); + if (! $feed instanceof AbstractFeed) { + return $this->generateResponse("Can't find feed with id {$id}.", 404); + } + + $feed->setName($newName); + $this->em->persist($feed); + $this->em->flush(); + + return $this->generateResponse(); + } + + + /** + * Update feed. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route("/{id}", methods={ "PUT" }, requirements={ "id"="\d+" }) + * @AppApiDoc( + * section="Feed", + * resource=true, + * input={ + * "class"="AppBundle\Form\FeedType", + * "name"=false + * }, + * output={ + * "class"="CacheBundle\Entity\Feed\AbstractFeed", + * "groups"={ "feed", "id" } + * }, + * statusCodes={ + * 200="Feed successfully updated.", + * 400="Invalid data provided." + * } + * ) + * + * @param Request $request A Request instance. + * @param integer $id A Feed entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function putAction(Request $request, $id) + { + /** @var User $user */ + $user = $this->getCurrentUser(); + /** @var CommonFeedRepository $feedRepository */ + $feedRepository = $this->em->getRepository(AbstractFeed::class); + + $feed = $feedRepository->getOne($id, $user->getId()); + if (! $feed instanceof AbstractFeed) { + return $this->generateResponse("Can't find feed with id {$id}.", 404); + } + + $form = $this->createForm(FeedType::class, [ + 'feed' => $feed, + ]); + + $form->submit($request->request->all()); + if ($form->isValid()) { + /** @var \CacheBundle\Entity\Feed\QueryFeed $feed */ + $feed = $form->get('feed')->getData(); + /** @var SearchRequestBuilderInterface $builder */ + $builder = $form->get('search')->getData(); + + if ($feed instanceof ClipFeed) { + $builder->getFilters(); + $feed->setFilters($builder->build()->getFilters()); + + $this->em->persist($feed); + $this->em->flush(); + } else { + $this->createQueryFeed($request, $builder, $feed); + } + + return $this->generateResponse($feed, 200, [ + 'feed', + 'id', + ]); + } + + return $this->generateResponse($form, 400); + } + + /** + * Get documents for specified feeds. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route("/{id}/documents", methods={ "POST" }, requirements={ "id": "\d+" }) + * @AppApiDoc( + * section="Feed", + * resource="Documents", + * input={ + * "class"="AppBundle\Form\FeedDocumentSearchType", + * "name"=false + * }, + * statusCodes={ + * 200="Stored query successfully saved.", + * 404="Invalid feed id." + * } + * ) + * + * @param Request $request A Request instance. + * @param integer $id A AbstractFeed id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function documentsAction(Request $request, $id) + { + /** @var CommonFeedRepository $repository */ + $repository = $this->em->getRepository(AbstractFeed::class); + $feed = $repository->getOne($id, \app\op\invokeIf($this->getCurrentUser(), 'getId')); + + if ($feed === null) { + return $this->generateResponse([ + 'message' => 'Feed not found', + 'transKey' => 'getFeedDocumentsInvalidFeed', + 'type' => 'error', + 'parameters' => [ 'current' => $id ], + ], 404); + } + + $form = $this->createForm(FeedDocumentSearchType::class) + ->submit($request->request->all()); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var SearchRequestBuilderInterface $builder */ + $builder = $form->getData(); + + /** @var FeedFetcherFactoryInterface $factory */ + $result = $this->feedFetcherFactory->get(get_class($feed))->fetch($feed, $builder); + + $response = $result->getResponse(); + + $query = ''; + if ($feed instanceof QueryFeed) { + $query = $feed->getQuery()->getRaw(); + } + + $response->mapDocuments(function (ArticleDocumentInterface $document) use ($query) { + return $document->mapNormalizedData(function (array $data) use ($query) { + $result = $this->documentExtractor->extract( + $data['content'], + $query, + ThemeOptionExtractEnum::start(), + true + ); + + $data['content'] = $result->getText() . ( + mb_strlen($data['content']) > $result->getLength() + ? '...' + : '' + ); + + return $data; + }); + }); + + return $this->generateResponse([ + 'feed' => $id, + 'meta' => $result->getMeta($request), + 'documents' => $this->paginate($response, $builder->getPage(), $builder->getLimit()), + 'advancedFilters' => $result->getAdvancedFilters(), + ], 200, [ + 'comment', + 'document', + 'id', + ]); + } + + return $this->generateResponse($form, 400); + } + + /** + * Clip documents into specified clip feed. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route("/{id}/documents/clip", methods={ "POST" }, requirements={ "id": "\d+" }) + * @AppApiDoc( + * section="Feed", + * resource="Documents", + * input={ + * "class"="", + * "data"={ + * "ids"={ + * "dataType"="Array of document ids", + * "actualType"="collection", + * "subtype"="string", + * "required"=true, + * "readonly"=true + * } + * } + * }, + * statusCodes={ + * 204="Document successfully clipped.", + * 400="Invalid document ids.", + * 404="Invalid feed id." + * } + * ) + * + * @param Request $request A Request instance. + * @param integer $id A ClipFeed entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function clipAction(Request $request, $id) + { + $ids = $request->request->get('ids', []); + $currentUserId = \app\op\invokeIf($this->getCurrentUser(), 'getId'); + + if (count($ids) <= 0) { + return $this->generateResponse([ + 'message' => 'ids: Should not be blank', + 'transKey' => 'clipDocumentsIdsBlank', + 'type' => 'error', + 'parameters' => [ 'current' => $ids ], + ], 400); + } + + /** @var CommonFeedRepository $repository */ + $repository = $this->em->getRepository(AbstractFeed::class); + $feed = $repository->getOne($id, $currentUserId); + + if (! $feed instanceof ClipFeed) { + return $this->generateResponse([ + 'message' => 'Feed not found', + 'transKey' => 'clipDocumentsInvalidFeed', + 'type' => 'error', + 'parameters' => [ 'current' => $id ], + ], 404); + } + + /** @var DocumentRepository $repository */ + $repository = $this->em->getRepository(Document::class); + $notExists = $repository->checkIds($ids); + + if (count($notExists) > 0) { + return $this->generateResponse([ + 'message' => 'Invalid documents', + 'transKey' => 'clipDocumentsInvalidDocuments', + 'type' => 'error', + 'parameters' => [ 'current' => $ids, 'unknown' => $notExists ], + ], 400); + } + + $ids = $repository->sanitizeIds($feed->getId(), CollectionTypeEnum::FEED, $ids); + + // + // Add documents to clip feed. + // + $this->feedManager->clip($feed, $ids); + + /** @var RecentlyUsedFeedRepository $repository */ + $repository = $this->em->getRepository(RecentlyUsedFeed::class); + $recentlyUsed = $repository->getAlreadyUsed($currentUserId, $feed->getId()); + + if ($recentlyUsed instanceof RecentlyUsedFeed) { + $recentlyUsed->setUsedAt(new \DateTime()); + $this->em->persist($recentlyUsed); + $this->em->flush(); + } else { + $repository->addRecentlyUsedFor($this->getCurrentUser(), $feed); + } + + return $this->generateResponse(); + } + + /** + * Clip documents into specified clip feed. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route("/readLater/{documentId}", methods={ "POST" }, requirements={ "documentId": "\d+" }) + * @AppApiDoc( + * section="Feed", + * resource="Documents", + * statusCodes={ + * 204="Document successfully clipped.", + * 400="Invalid document id." + * } + * ) + * + * @param integer $documentId A Document entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function readLaterAction($documentId) + { + /** @var ClipFeedRepository $repository */ + $repository = $this->em->getRepository(ClipFeed::class); + $user = $this->getCurrentUser(); + + $feed = $repository->getReadLater($user->getId()); + + if (! $feed instanceof ClipFeed) { + try { + $user->useLimit(AppLimitEnum::feeds()); + } catch (LimitExceedException $exception) { + return $this->generateResponse([ + 'failedRestriction' => AppLimitEnum::FEEDS, + 'restrictions' => $user->getRestrictions(), + ], 402); + } + + $this->em->persist($user); + $this->em->flush(); + + $feed = $repository->createReadLater($user->getId()); + } + + /** @var DocumentRepository $repository */ + $repository = $this->em->getRepository(Document::class); + $notExists = $repository->checkIds([ $documentId ]); + + if (count($notExists) > 0) { + return $this->generateResponse([ + 'message' => 'Invalid document', + 'transKey' => 'readLaterDocumentInvalidDocument', + 'type' => 'error', + 'parameters' => [ 'current' => $documentId ], + ], 400); + } + + $ids = $repository->sanitizeIds( + $feed->getId(), + CollectionTypeEnum::FEED, + [ $documentId ] + ); + + // + // Add documents to clip feed. + // + $this->feedManager->clip($feed, $ids); + + return $this->generateResponse(); + } + + /** + * Get recently used feeds for clipping. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/recentClip", + * methods={ "GET" } + * ) + * @AppApiDoc( + * section="Feed", + * resource=true, + * output={ + * "class"="Pagination", + * "groups"={ "id", "category_tree", "feed_tree" } + * }, + * statusCodes={ + * 200="List of recent feeds used for clipping successfully returned." + * } + * ) + * + * @return \ApiBundle\Response\ViewInterface + */ + public function recentClipFeedAction() + { + /** @var RecentlyUsedFeedRepository $repository */ + $repository = $this->em->getRepository(RecentlyUsedFeed::class); + $recentlyUsedFeeds = $repository->getRecentlyUsedFor(\app\op\invokeIf($this->getCurrentUser(), 'getId')); + + return $this->generateResponse($recentlyUsedFeeds, 200, [ + 'id', + 'feed', + ]); + } + + /** + * Move specified feed to another category. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/{feedId}/move_to/{categoryId}", + * requirements={ + * "feedId": "\d+", + * "categoryId": "\d+" + * }, + * methods={ "POST" } + * ) + * @AppApiDoc( + * section="Feed", + * resource=true, + * output={ + * "class"="Pagination", + * "groups"={ "id", "category_tree", "feed_tree" } + * }, + * statusCodes={ + * 200="List of updated categories successfully returned." + * } + * ) + * + * @param Request $request A http Request instance. + * @param integer $feedId A moving Feed entity id. + * @param integer $categoryId A Category entity id where the feed is moved. + * + * @return \ApiBundle\Response\ViewInterface|\Symfony\Component\HttpFoundation\Response + */ + public function moveAction(Request $request, $feedId, $categoryId) + { + $feedId = (integer) $feedId; + $categoryId = (integer) $categoryId; + $userId = \app\op\invokeIf($this->getCurrentUser(), 'getId'); + + /** @var CommonFeedRepository $feedRepository */ + $feedRepository = $this->em->getRepository(AbstractFeed::class); + /** @var CategoryRepository $categoryRepository */ + $categoryRepository = $this->em->getRepository(Category::class); + + $feed = $feedRepository->getOne($feedId, $userId); + if (! $feed instanceof AbstractFeed) { + return $this->generateResponse("Can't find feed with id {$feedId}.", 404); + } + + // + // We should don't make any changes if client try to move feed into the + // same category. + // + + if ($feed->getCategory()->getId() !== $categoryId) { + $category = $categoryRepository->get($categoryId, $userId, [ + Category::TYPE_CUSTOM, + Category::TYPE_MY_CONTENT, + ]); + if (! $category instanceof Category) { + return $this->generateResponse("Can't find category with id {$categoryId}.", 404); + } + + // + // All ok, we had valid feed and category entity so we just update + // feed. + // + + $feed->setCategory($category); + + $this->em->persist($feed); + $this->em->flush(); + } + + $path['_forwarded'] = $request->attributes; + $path['_controller'] = 'app.controller.category:listAction'; + $subRequest = $request->duplicate([], null, []); + + return $this->kernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST); + } + + /** + * Get information about feed by id. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/{id}", + * requirements={ "id": "\d+" }, + * methods={ "GET" } + * ) + * @AppApiDoc( + * section="Feed", + * resource=true, + * output={ + * "class"="CacheBundle\Entity\Feed\AbstractFeed", + * "groups"={ "feed", "id" } + * }, + * statusCodes={ + * 200="Feed successfully returned.", + * 403="You don't have permissions to view this feed.", + * 404="Can't find feed by specified id." + * } + * ) + * + * @param integer $id A one of feed entity id. + * + * @return \CacheBundle\Entity\Feed\AbstractFeed|\ApiBundle\Response\ViewInterface + */ + public function getAction($id) + { + return parent::getEntity($id); + } + + /** + * Delete specified feed by id. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/{id}", + * requirements={ "id": "\d+" }, + * methods={ "DELETE" } + * ) + * @AppApiDoc( + * section="Feed", + * resource=true, + * statusCodes={ + * 204="Feed successfully deleted.", + * 403="You don't have permissions to delete this feed.", + * 404="Can't find feed by specified id." + * } + * ) + * + * @param integer $id A one of feed entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function deleteAction($id) + { + $repository = $this->em->getRepository($this->entity); + + // + // Get entity by id and check whether it exists or not. + // Send proper error message if not. + // + $entity = $repository->find($id); + if (! $entity instanceof AbstractFeed) { + $name = \app\c\getShortName($this->entity); + // Remove 'Abstract' prefix if it exists. + if (strpos($name, 'Abstract') !== false) { + $name = substr($name, 8); + } + + return $this->generateResponse("Can't find {$name} with id {$id}.", 404); + } + // + // Check that current user can delete this entity. + // If user don't have rights to delete this entity we should send all + // founded restrictions to client. + // + $reasons = $this->checkAccess(InspectorInterface::DELETE, $entity); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + // + // Remove deleted feed from recently used. + // + /** @var RecentlyUsedFeedRepository $repository */ + $repository = $this->em->getRepository(RecentlyUsedFeed::class); + $repository->removeForFeed($entity->getId()); + + // + // Remove associations between this feed and all documents which it has. + // We should remove it before deleting entities 'cause after that id of + // entity will be null. + // + $this->feedManager->deleteDocuments($entity); + $user = $this->getCurrentUser(); + $user->releaseLimit(AppLimitEnum::feeds()); + + $this->em->remove($entity); + $this->em->persist($user); + $this->em->flush(); + + return $this->generateResponse(); + } + + /** + * Delete document from feed. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route( + * "/{id}/documents/delete", + * requirements={ "id": "\d+" }, + * methods={ "POST" } + * ) + * @AppApiDoc( + * section="Feed", + * resource="Documents", + * input={ + * "class"="", + * "data"={ + * "ids"={ + * "dataType"="Array of document ids", + * "actualType"="collection", + * "subtype"="string", + * "required"=true, + * "readonly"=true + * } + * } + * }, + * statusCodes={ + * 204="Documents from feeds successfully deleted.", + * 400="Invalid data." + * } + * ) + * + * @param Request $request A Request instance. + * @param string $id A Feed entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function deleteDocumentsAction(Request $request, $id) + { + $ids = $request->request->get('ids', []); + + if (count($ids) <= 0) { + return $this->generateResponse([ + 'message' => 'ids: Should not be blank', + 'transKey' => 'deleteDocumentsIdsBlank', + 'type' => 'error', + 'parameters' => [ 'current' => $ids ], + ], 400); + } + + /** @var CommonFeedRepository $repository */ + $repository = $this->em->getRepository(AbstractFeed::class); + $feed = $repository->getOne($id, \app\op\invokeIf($this->getCurrentUser(), 'getId')); + + if (! $feed instanceof AbstractFeed) { + return $this->generateResponse([ + 'message' => 'Feed not found', + 'transKey' => 'deleteDocumentsInvalidFeed', + 'type' => 'error', + 'parameters' => [ 'current' => $id ], + ], 404); + } + + /** @var DocumentRepository $repository */ + $repository = $this->em->getRepository(Document::class); + $notExists = $repository->checkIds($ids); + + if (count($notExists) > 0) { + return $this->generateResponse([ + 'message' => 'Invalid documents', + 'transKey' => 'deleteDocumentsInvalidDocuments', + 'type' => 'error', + 'parameters' => [ 'current' => $ids, 'unknown' => $notExists ], + ], 400); + } + + $this->feedManager->deleteDocuments($feed, $ids); + + return $this->generateResponse(); + } + + /** + * Export feed + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route("/{id}/toggleExport", methods={ "PUT" }) + * @AppApiDoc( + * section="Feed", + * resource="Feed", + * input={ + * "class"="", + * "data"={ + * "export"={ + * "dataType"="Exported boolean status", + * "actualType"="boolean", + * "subtype"="integer", + * "required"=true, + * "readonly"=true + * } + * } + * }, + * statusCodes={ + * 204="Feeds is exported or not exported.", + * 400="Invalid data." + * } + * ) + * + * @param Request $request A Request instance. + * @param string $id A Feed entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function toggleExportAction(Request $request, $id) + { + $exported = $request->request->get('export'); + /** @var CommonFeedRepository $repository */ + $repository = $this->em->getRepository(AbstractFeed::class); + $feed = $repository->find($id); + if (! $feed instanceof AbstractFeed) { + return $this->generateResponse([ + 'message' => 'Feed not found', + 'transKey' => 'deleteDocumentsInvalidFeed', + 'type' => 'error', + 'parameters' => [ 'current' => $id ], + ], 404); + } + $feed->setExported($exported); + $this->em->flush($feed); + + return $this->generateResponse(); + } + + /** + * Export Feeds in specified category + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route("/toggleExport/{category}", methods={ "PUT" }) + * @AppApiDoc( + * section="Feed", + * resource="Feed", + * input={ + * "class"="", + * "data"={ + * "export"={ + * "dataType"="Exported boolean status", + * "actualType"="boolean", + * "subtype"="integer", + * "required"=true, + * "readonly"=true + * } + * } + * }, + * statusCodes={ + * 204="Feeds is exported or not exported.", + * 400="Invalid data." + * } + * ) + * @param Request $request A http Request instance. + * @param string $category A Category entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function toggleExportInCategoryAction(Request $request, $category) + { + $exported = $request->request->getBoolean('export'); + /** @var CategoryRepository $repository */ + $repository = $this->em->getRepository(Category::class); + $category = $repository->find($category); + if (! $category instanceof Category) { + return $this->generateResponse([ + 'message' => 'Category not found', + 'transKey' => 'toggleExportsInCategoryInvalidCategory', + 'type' => 'error', + 'parameters' => [ 'current' => $category ], + ], 404); + } + + $category->setExported($exported); + $repository->exportFeedsIn($category->getId(), $exported); + + $this->em->persist($category); + $this->em->flush(); + + return $this->generateResponse(); + } + + /** + * Exported Feeds. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route("/exported", methods={ "GET" }) + * @AppApiDoc( + * section="Feed", + * resource="Documents", + * statusCodes={ + * 204="Exported feeds.", + * 400="Invalid data." + * } + * ) + * + * @return \ApiBundle\Response\ViewInterface + */ + public function exportedAction() + { + /** @var CommonFeedRepository $repository */ + $repository = $this->em->getRepository(AbstractFeed::class); + $feed = $repository->findBy( + ['exported' => true] + ); + + return $this->generateResponse($feed, 200, [ + 'id', + 'feed', + ]); + } + + /** + * @param Request $request A HTTP Request instance. + * @param SearchRequestBuilderInterface $builder A SearchRequestBuilderInterface + * instance. + * @param QueryFeed $feed A QueryFeed entity instance. + * + * @return \ApiBundle\Response\ViewInterface|null + */ + private function createQueryFeed( + Request $request, + SearchRequestBuilderInterface $builder, + QueryFeed $feed + ) { + $searchData = $request->request->get('search'); + + $query = $this->storedQueryManager->createQuery( + $builder, + isset($searchData['filters']) ? $searchData['filters'] : [], + isset($searchData['advancedFilters']) ? $searchData['advancedFilters'] : [] + ); + $isNewQuery = $query->getId() === null; + + $filter = $this->getPublisherFilter($builder->getFilters()); + if ($filter !== null) { + $feed->setPublisherTypes($filter->getValue()); + } + + //Fixed published in query for feed + $filters = $this->getFiltersWithoutPublishedLte($query->getFilters()); + $query->setFilters($filters); + + $feed + ->setUser($this->getCurrentUser()) + ->setQuery($query); + + $this->em->persist($query); + $this->em->persist($feed); + $this->em->flush(); + + if ($isNewQuery) { + $this->fetchProducer->publish($query->getId()); + } + + return null; + } + + /** + * Get publisher filter from specified filters. + * + * @param array $filters Array of FilterInterface's. + * + * @return SingleFilterInterface|null + */ + private function getPublisherFilter(array $filters) + { + foreach ($filters as $filter) { + if ($filter instanceof SingleFilterInterface) { + if ($filter->getFieldName() === FieldNameEnum::SOURCE_PUBLISHER_TYPE) { + return $filter; + } elseif ($filter instanceof GroupFilterInterface) { + return $this->getPublisherFilter($filter->getFilters()); + } elseif ($filter instanceof NotFilter) { + return $this->getPublisherFilter([ $filter->getFilter() ]); + } + } + } + + return null; + } + + /** + * Get filters without published conditions from specified filters. + * + * @param array $filters Array of FilterInterface's. + * + * @return array + */ + private function getFiltersWithoutPublishedLte(array $filters) + { + return array_map(function ($filter) { //first level array + if ($filter instanceof AndFilter) { + $andFilters = $filter->getFilters(); + foreach ($andFilters as $kItem => $item) { //second level array + if ($item instanceof LteFilter and $item->getFieldName() === FieldNameEnum::PUBLISHED ) { + unset($andFilters[$kItem]); + } + } + $filter->setFilters($andFilters); + } + return $filter; + }, $filters); + } +} diff --git a/src/AppBundle/Controller/V1/QueryController.php b/src/AppBundle/Controller/V1/QueryController.php new file mode 100644 index 0000000..78dd0ce --- /dev/null +++ b/src/AppBundle/Controller/V1/QueryController.php @@ -0,0 +1,269 @@ +formFactory = $formFactory; + $this->em = $em; + $this->tokenStorage = $tokenStorage; + $this->sourceManager = $sourceManager; + $this->queryManager = $queryManager; + $this->extractor = $extractor; + } + + /** + * Make simple search without saving query in database. + * Fetched documents are cached but not indexed. + * + * @Roles("ROLE_SUBSCRIBER") + * + * @Route("/search", methods={ "POST" }) + * @ApiDoc( + * resource="Search", + * section="Query", + * input={ + * "class"="AppBundle\Form\SearchRequest\SimpleQuerySearchRequestType", + * "name"=false + * }, + * output={ + * "class"="", + * "data"={ + * "documents"={ + * "class"="Pagination", + * "groups"={ "document" } + * }, + * "advancedFilters"={ + * "dataType"="array", + * "required"=true, + * "readonly"=true, + * "description"="Array of advanced filters values for this search request." + * }, + * "stats"={ + * "dataType"="object", + * "required"=true, + * "readonly"=true, + * "description"="Internal statistics, showed only in staging and local developers machine.", + * "children"={ + * "totalOnPage"={ + * "dataType"="integer", + * "required"=true, + * "readonly"=true, + * "description"="Total founded document on current page." + * }, + * "newDocuments"={ + * "dataType"="integer", + * "required"=true, + * "readonly"=true, + * "description"="Number of documents that were not in our database." + * }, + * "alreadyExistsDocuments"={ + * "dataType"="integer", + * "required"=true, + * "readonly"=true, + * "description"="Number of documents that already in our database." + * }, + * "fromCache"={ + * "dataType"="boolean", + * "required"=true, + * "readonly"=true, + * "description"="Flag, all documents fetched from our internal cache if set." + * }, + * "expiresAt"={ + * "dataType"="datetime", + * "required"=true, + * "readonly"=true, + * "description"="When this query is expired." + * } + * } + * } + * } + * }, + * statusCodes={ + * 200="Search completed.", + * 400="Invalid data provided" + * } + * ) + * + * @param Request $request A Request instance. + * + * @return array|\ApiBundle\Response\ViewInterface + */ + public function searchAction(Request $request) + { + $form = $this->createForm(SimpleQuerySearchRequestType::class); + + $form->submit($request->request->all()); + if ($form->isValid()) { + $user = $this->getCurrentUser(); + + try { + $user->useLimit(AppLimitEnum::searches()); + } catch (LimitExceedException $exception) { + return $this->generateResponse([ + 'failedRestriction' => AppLimitEnum::SEARCHES, + 'restrictions' => $user->getRestrictions(), + ], 402); + } + + $this->em->persist($user); + $this->em->flush(); + + /** @var SearchRequestBuilderInterface $builder */ + $builder = $form->getData(); + + $searchRequest = $builder + ->setFields([ + FieldNameEnum::TITLE, + FieldNameEnum::MAIN, + ]) + ->addSort(FieldNameEnum::PUBLISHED, 'desc') + ->build(); + + $response = $this->queryManager->searchAndCache( + $searchRequest, + $request->request->get('filters', []), + $request->request->get('advancedFilters', []) + ); + + $query = $response->getQuery(); + + $response->mapDocuments(function (ArticleDocumentInterface $document) use ($query) { + return $document->mapNormalizedData(function (array $data) use ($query) { + $result = $this->extractor->extract( + $data['content'], + $query->getRaw(), + ThemeOptionExtractEnum::start(), + true + ); + + $data['content'] = $result->getText() . ( + mb_strlen($data['content']) > $result->getLength() + ? '...' + : '' + ); + + return $data; + }); + }); + + $result = [ + 'documents' => $this->paginate($response, $builder->getPage(), $builder->getLimit()), + 'advancedFilters' => $searchRequest->getAvailableAdvancedFilters() ?: (object) [], + ]; + + // + // Return internal statistic. + // + $result['stats'] = [ + 'newDocuments' => $response->getUniqueCount(), + 'alreadyExistsDocuments' => $response->count() - $response->getUniqueCount(), + 'fromCache' => $response->isFromCache(), + 'expiresAt' => $query->getExpirationDate()->format('c'), + ]; + + // + // Return meta information about query. + // + $sources = $this->sourceManager->getSourcesForQuery($query, [ 'id', 'title', 'type' ]); + $sourceLists = $this->sourceManager->getSourceListsForQuery($query, [ 'id', 'name' ]); + + $result['meta'] = [ + 'type' => 'query', + 'status' => 'synced', + 'search' => [ + 'query' => $query->getRaw(), + 'filters' => $query->getRawFilters() ?: (object) [], + 'advancedFilters' => $query->getRawAdvancedFilters() ?: (object) [], + ], + 'sources' => $sources, + 'sourceLists' => $sourceLists, + ]; + + return $this->generateResponse($result); + } + + return $this->generateResponse($form, 400); + } +} diff --git a/src/AppBundle/Controller/V1/SourceIndexController.php b/src/AppBundle/Controller/V1/SourceIndexController.php new file mode 100644 index 0000000..643e487 --- /dev/null +++ b/src/AppBundle/Controller/V1/SourceIndexController.php @@ -0,0 +1,313 @@ +tokenStorage = $tokenStorage; + $this->formFactory = $formFactory; + $this->sourceManager = $sourceManager; + $this->em = $em; + } + + + /** + * Fetch all sources from our cache. + * + * @Route("/", methods={ "POST" }) + * + * @ApiDoc( + * resource=true, + * section="Source Index", + * input={ + * "class"="CacheBundle\Form\Sources\SourceSearchType", + * "name"=false + * }, + * output={ + * "class"="Pagination", + * "groups"={ "id", "source" } + * } + * ) + * + * @param Request $request A Request instance. + * + * @return \Knp\Component\Pager\Pagination\PaginationInterface|\ApiBundle\Response\ViewInterface + */ + public function listAction(Request $request) + { + $form = $this->createForm(SourceSearchType::class); + + $form->submit($request->request->all()); + + if ($form->isValid()) { + /** @var SearchRequestBuilder $searchRequestBuilder */ + $searchRequestBuilder = $form->getData(); + $searchRequestBuilder->setUser($this->getCurrentUser()); + + $response = $this->sourceManager->find($searchRequestBuilder); + + $advancedFilters = $this->sourceManager->getAvailableFilters($searchRequestBuilder); + + $sort = $searchRequestBuilder->getSorts(); + $sort = [ + 'field' => array_search(key($sort), SourceSearchType::$fields), + 'direction' => current($sort), + ]; + + return $this->generateResponse([ + 'sources' => $this->paginate($response, $searchRequestBuilder->getPage(), $searchRequestBuilder->getLimit()), + 'advancedFilters' => $advancedFilters ?: (object) [], + 'meta' => [ + 'query' => $request->request->get('query'), + 'advancedFilters' => $request->request->get('advancedFilters', [])?: (object) [], + 'sort' => $sort, + ], + ], 200, [ 'id', 'source' ]); + } + + return $this->generateResponse($form, 400); + } + + /** + * Replace source lists for specified source. + * + * @Route("/{id}/list", methods={ "POST" }) + * @Roles("ROLE_SUBSCRIBER") + * + * @ApiDoc( + * resource=true, + * section="Source Index", + * parameters={ + * "sourceList"={ + * "name"="sourceLists", + * "dataType"="array", + * "description"="Array of source lists ids." + * } + * } + * ) + * + * @param Request $request A Request instance. + * @param integer $id A Source entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function replaceListAction(Request $request, $id) + { + if (count($this->sourceManager->getIndex()->has($id)) > 0) { + return $this->generateResponse([ [ + 'message' => "Can't find source with id {$id}", + 'transKey' => 'replaceSourceUnknown', + 'type' => 'error', + 'parameters' => [ 'current' => $id ], + ], ], 404); + } + + $user = $this->getCurrentUser(); + $sourceLists = $request->request->get('sourceLists'); + + if (! is_array($sourceLists)) { + return $this->generateResponse([[ + 'message' => 'sourceLists: This value should not be empty.', + 'transKey' => 'replaceSourceListsEmpty', + 'type' => 'error', + 'parameters' => [ 'current' => null ], + ], ], 400); + } + + /** @var SourceListRepository $repository */ + $repository = $this->em->getRepository(SourceList::class); + $foundedIds = $repository->sanitizeIds($sourceLists, $user->getId()); + + if (count($foundedIds) !== count($sourceLists)) { + // + // Some of provided id is not found or not owned by current user. + // + return $this->generateResponse([ [ + 'message' => 'sourceLists: This value is invalid.', + 'transKey' => 'replaceSourceListInvalid', + 'type' => 'error', + 'parameters' => [ + 'current' => $sourceLists, + 'invalid' => array_diff($sourceLists, $foundedIds), + ], + ], ], 400); + } + + $this->sourceManager->replaceRelation($id, $foundedIds); + + return $this->generateResponse(); + } + + /** + * Add Sources to Sources lists + * + * @Route("/add-to-sources-list", methods={ "POST" }) + * + * @Roles("ROLE_SUBSCRIBER") + * + * @ApiDoc( + * resource=true, + * section="Source Index", + * parameters={ + * "sources"={ + * "name"="sources", + * "dataType"="integer", + * "actualType"="collection", + * "required"=true, + * "description"="Array of Source id." + * }, + * "sourceLists"={ + * "name"="sourceLists", + * "dataType"="integer", + * "actualType"="collection", + * "required"=true, + * "description"="Array of Sources Lists id." + * }, + * } + * ) + * + * @param Request $request A Request instance. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function addToSourceListAction(Request $request) + { + $sources = (array) $request->request->get('sources', []); + $sourceLists = (array) $request->request->get('sourceLists', []); + + // + // Check that all fields are provided. + // + if (count($sources) === 0) { + return $this->generateResponse( + [ + [ + 'message' => 'Sources should be selected.', + 'transKey' => 'sourceToListsSourcesEmpty', + 'type' => 'error', + 'parameters' => [ + 'current' => $sources, + ], + ], + ], + 400 + ); + } + + if (count($sourceLists) === 0) { + return $this->generateResponse( + [ + [ + 'message' => 'Source lists should be selected.', + 'transKey' => 'sourceToListsSourceListsEmpty', + 'type' => 'error', + 'parameters' => [ + 'current' => $sourceLists, + ], + ], + ], + 400 + ); + } + + $user = $this->getCurrentUser(); + + // + // Validate specified sources and source lists ids. + // + /** @var SourceListRepository $repository */ + $repository = $this->em->getRepository('CacheBundle:SourceList'); + + $existsSources = $this->sourceManager->getIndex()->get($sources, 'id'); + $existsSources = array_map(function (SourceDocument $document) { + return $document['id']; + }, $existsSources); + if (count($sources) !== count($existsSources)) { + return $this->generateResponse( + [ + [ + 'message' => 'sources: This value is invalid.', + 'transKey' => 'sourceToListsSourcesInvalid', + 'type' => 'error', + 'parameters' => $sources, + ], + ], + 400 + ); + } + + $existsSourceLists = $repository->sanitizeIds($sourceLists, $user->getId()); + if (count($sourceLists) !== count($existsSourceLists)) { + return $this->generateResponse( + [ + [ + 'message' => 'sourceLists: This value is invalid.', + 'transKey' => 'sourceToListsSourceListsInvalid', + 'type' => 'error', + 'parameters' => $sourceLists, + ], + ], + 400 + ); + } + + $this->sourceManager->bindSourcesToLists($user, $sources, $sourceLists); + + return $this->generateResponse(); + } +} diff --git a/src/AppBundle/Controller/V1/SourceListController.php b/src/AppBundle/Controller/V1/SourceListController.php new file mode 100644 index 0000000..743b6a5 --- /dev/null +++ b/src/AppBundle/Controller/V1/SourceListController.php @@ -0,0 +1,486 @@ +", + * "groups"={ "id", "source_list" } + * } + * ) + * + * @param Request $request A Request instance. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function indexAction(Request $request) + { + $form = $this->createForm(SourceListSearchType::class); + $form->submit($request->request->all()); + + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + + $page = (int) $data['page']; + $limit = (int) $data['limit']; + $onlyShared = (boolean) $data['onlyShared']; + + $user = $this->getCurrentUser(); + $em = $this->getManager(); + + /** @var SourceListRepository $sourceListRepository */ + $sourceListRepository = $em->getRepository(SourceList::class); + /** @var PaginatorInterface $paginator */ + $paginator = $this->get('knp_paginator'); + + $qb = $sourceListRepository->getSourcesListsQB($user->getId(), $data['sort'], $onlyShared); + $pagination = $paginator->paginate( + $qb, + $page, + $limit + ); + + $sort = $data['sort'] ; + $sort = [ + 'field' => array_search(key($sort), SourceListSearchType::$fields), + 'direction' => current($sort), + ]; + + /** @var NormalizerInterface $normalizer */ + $normalizer = $this->get('serializer'); + $result = $normalizer->normalize($pagination, null, ['id', 'source_list']); + $result['sort'] = $sort; + + return $this->generateResponse($result, 200); + } + + return $this->generateResponse($form, 400); + } + + + /** + * Create a source list + * + * @Route("/", methods={ "POST" }) + * + * @Roles("ROLE_SUBSCRIBER") + * + * @ApiDoc( + * resource=true, + * section="Source List", + * input={ + * "class"="CacheBundle\Form\Sources\SourceListType", + * "name"=false + * }, + * output={ + * "class"="CacheBundle\Entity\SourceList", + * } + * ) + * + * @param Request $request A Request entity instance. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function createAction(Request $request) + { + $sourceList = new SourceList(); + $sourceList->setUser($this->getCurrentUser()); + + $form = $this->createForm(SourceListType::class, $sourceList); + + $form->submit($request->request->all()); + if ($form->isValid()) { + $em = $this->getManager(); + $em->persist($sourceList); + $em->flush(); + + return $this->generateResponse($sourceList, 200, [ + 'source_list', + 'id', + ]); + } + + return $this->generateResponse($form, 400); + } + + /** + * Rename a source list + * + * @Route( + * "/{id}", + * requirements={ "id": "\d+" }, + * methods={ "PUT" } + * ) + * + * @Roles("ROLE_SUBSCRIBER") + * + * @ApiDoc( + * resource=true, + * section="Source List", + * parameters={ + * "name"={ + * "name"="name", + * "dataType"="string", + * "required"="true", + * "description"="A new name of the source list" + * } + * } + * ) + * + * @param Request $request A HTTP Request instance. + * @param SourceList $sourceList A updated SourceList instance. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function updateAction(Request $request, SourceList $sourceList) + { + $form = $this->createForm(SourceListType::class, $sourceList); + + $form->submit($request->request->all()); + if ($form->isValid()) { + $em = $this->getManager(); + + $reasons = $this->checkAccess(InspectorInterface::UPDATE, $sourceList); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + $sourceList->setUpdatedBy($this->getCurrentUser()); + + $em->persist($sourceList); + $em->flush(); + + return $this->generateResponse($sourceList, 200, [ + 'source_list', + 'id', + ]); + } + + return $this->generateResponse($form, 400); + } + + /** + * Delete a source list + * + * @Route("/{id}", + * requirements={ "id": "\d+" }, + * methods={ "DELETE" } + * ) + * + * @Roles("ROLE_SUBSCRIBER") + * + * @ApiDoc( + * resource=true, + * section="Source List", + * parameters={ + * "id"={ + * "name"="id", + * "dataType"="integer", + * "required"="true", + * "description"="Id of the source list which changing" + * } + * } + * ) + * + * @param SourceList $sourceList A deleted SourceList instance. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function deleteAction(SourceList $sourceList) + { + $reasons = $this->checkAccess(InspectorInterface::DELETE, $sourceList); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + // + // Remove this source list from all sources which is contains in it. + // + /** @var SourceManagerInterface $sourceManager */ + $sourceManager = $this->container->get(CacheBundleServices::SOURCE_CACHE); + $sourceManager->unbindSourcesFromLists($sourceList->getId()); + + $em = $this->getManager(); + $em->remove($sourceList); + $em->flush(); + + return $this->generateResponse(); + } + + /** + * Get list of sources for specified source list. + * + * @Route("/{id}/sources/search", + * requirements={ "id": "\d+" }, + * methods={ "POST" } + * ) + * + * @Roles("ROLE_SUBSCRIBER") + * + * @ApiDoc( + * resource="Sources of specified source list", + * section="Source List", + * input={ + * "class"="CacheBundle\Form\Sources\SourceSearchType", + * "name"=false + * } + * ) + * + * @param Request $request A Request instance. + * @param integer $id A SourceList entity id. + * + * @return \Knp\Component\Pager\Pagination\PaginationInterface|\ApiBundle\Response\ViewInterface + */ + public function sourcesAction(Request $request, $id) + { + $user = $this->getCurrentUser(); + /** @var SourceListRepository $repository */ + $repository = $this->getManager()->getRepository('CacheBundle:SourceList'); + $sourceList = $repository->getSourcesLists($id, $user->getId()); + + if ($sourceList === null) { + return $this->generateResponse("Can't find source list with id $id", 404); + } + + $form = $this->createForm(SourceSearchType::class); + $form->submit($request->request->all()); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var SearchRequestBuilder $searchRequestBuilder */ + $searchRequestBuilder = $form->getData(); + /** @var SourceManagerInterface $manager */ + $manager = $this->get(AppBundleServices::SOURCE_MANAGER); + $searchRequestBuilder->setUser($user); + + $response = $manager->find($searchRequestBuilder, $sourceList); + + /** @var PaginatorInterface $paginator */ + $paginator = $this->get('knp_paginator'); + $pagination = $paginator->paginate( + $response, + $searchRequestBuilder->getPage(), + $searchRequestBuilder->getLimit() + ); + + $sort = $searchRequestBuilder->getSorts(); + $sort = [ + 'field' => array_search(key($sort), SourceSearchType::$fields), + 'direction' => current($sort), + ]; + + return $this->generateResponse([ + 'sources' => $pagination, + 'filters' => $request->request->get('filters', (object) []), + 'sort' => $sort, + ], 200, [ 'id', 'source' ]); + } + + return $this->generateResponse($form, 400); + } + + /** + * Clone current list. + * + * @Route("/{id}/clone", + * requirements={ "id": "\d+" }, + * methods={ "POST" } + * ) + * @Roles("ROLE_SUBSCRIBER") + * + * @ApiDoc( + * resource="Clone specified source list", + * section="Source List", + * parameters={ + * { + * "name"="name", + * "dataType"="string", + * "required"="true" + * } + * }, + * output={ + * "class"="CacheBundle\Entity\SourceList", + * "groups"={ "id", "source_list" } + * } + * ) + * + * @param Request $request A Request instance. + * @param integer $id A SourceList entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function cloneAction(Request $request, $id) + { + $user = $this->getCurrentUser(); + /** @var SourceListRepository $repository */ + $repository = $this->getManager()->getRepository('CacheBundle:SourceList'); + $sourceList = $repository->getSourcesLists($id, $user->getId()); + $em = $this->getManager(); + + $name = $request->request->get('name'); + + if ($name === null) { + return $this->generateResponse('Required field \'name\' is not provided or empty.'); + } + + if ($sourceList === null) { + return $this->generateResponse("Can't find source list with id $id", 404); + } + + $clone = $sourceList->cloneList(); + $clone->setName($name); + /** @var SourceManagerInterface $sourceManager */ + $sourceManager = $this->get(CacheBundleServices::SOURCE_CACHE); + + $sources = $sourceList->getSources()->map(function (SourceToSourceList $source) { + return $source->getSource(); + })->toArray(); + + $em->persist($clone); + $em->flush(); + + // + // We should add and original id 'cause otherwise he lost his binding. + // + $sourceManager->bindSourcesToLists($user, $sources, [ $sourceList->getId(), $clone->getId() ]); + + return $this->generateResponse($clone, 200, [ + 'source_list', + 'id', + ]); + } + + /** + * Share specified source list. + * + * @Route("/{id}/share", + * requirements={ "id": "\d+" }, + * methods={ "POST" } + * ) + * @Roles("ROLE_SUBSCRIBER") + * + * @ApiDoc( + * resource="Sharing", + * section="Source List" + * ) + * + * @param string $id A SourceList entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function shareAction($id) + { + $user = $this->getCurrentUser(); + $em = $this->getManager(); + + /** @var SourceListRepository $repository */ + $repository = $em->getRepository('CacheBundle:SourceList'); + $sourceList = $repository->getSourcesLists($id, $user->getId()); + + if ($sourceList === null) { + return $this->generateResponse("Can't find source list with id $id", 404); + } + + $reasons = $this->checkAccess(SourceListInspector::SHARE, $sourceList); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + if (! $sourceList->getIsGlobal()) { + $sourceList + ->setUpdatedBy($this->getCurrentUser()) + ->setIsGlobal(true); + $em->persist($sourceList); + $em->flush(); + } + + return $this->generateResponse(); + } + + /** + * Unshare specified source list. + * + * @Route("/{id}/unshare", + * requirements={ "id": "\d+" }, + * methods={ "POST" } + * ) + * @Roles("ROLE_SUBSCRIBER") + * + * @ApiDoc( + * resource="Sharing", + * section="Source List" + * ) + * + * @param string $id A SourceList entity id. + * + * @return \ApiBundle\Response\ViewInterface + */ + public function unshareAction($id) + { + $user = $this->getCurrentUser(); + $em = $this->getManager(); + + /** @var SourceListRepository $repository */ + $repository = $em->getRepository('CacheBundle:SourceList'); + $sourceList = $repository->getSourcesLists($id, $user->getId()); + + if ($sourceList === null) { + return $this->generateResponse("Can't find source list with id $id", 404); + } + + $reasons = $this->checkAccess(SourceListInspector::UNSHARE, $sourceList); + if (count($reasons) > 0) { + return $this->generateResponse($reasons, 403); + } + + if ($sourceList->getIsGlobal()) { + $sourceList + ->setUpdatedBy($this->getCurrentUser()) + ->setIsGlobal(false); + $em->persist($sourceList); + $em->flush(); + } + + return $this->generateResponse(); + } +} diff --git a/src/AppBundle/DataFixtures/AbstractExternalFixture.php b/src/AppBundle/DataFixtures/AbstractExternalFixture.php new file mode 100644 index 0000000..d1d0f40 --- /dev/null +++ b/src/AppBundle/DataFixtures/AbstractExternalFixture.php @@ -0,0 +1,44 @@ +generator = new ExternalDocumentGenerator(); + } + + /** + * Return index type for this fixture. + * + * @return string + */ + public function getIndex() + { + return self::INDEX_EXTERNAL; + } +} diff --git a/src/AppBundle/DataFixtures/AbstractFixture.php b/src/AppBundle/DataFixtures/AbstractFixture.php new file mode 100644 index 0000000..c536d6b --- /dev/null +++ b/src/AppBundle/DataFixtures/AbstractFixture.php @@ -0,0 +1,32 @@ +generator = new InternalDocumentGenerator(); + } + + /** + * Return index type for this fixture. + * + * @return string + */ + public function getIndex() + { + return self::INDEX_INTERNAL; + } +} diff --git a/src/AppBundle/DataFixtures/AbstractSourceIndexFixture.php b/src/AppBundle/DataFixtures/AbstractSourceIndexFixture.php new file mode 100644 index 0000000..d5624fd --- /dev/null +++ b/src/AppBundle/DataFixtures/AbstractSourceIndexFixture.php @@ -0,0 +1,44 @@ +generator = new SourceDocumentGenerator(); + } + + /** + * Return index type for this fixture. + * + * @return string + */ + public function getIndex() + { + return self::INDEX_SOURCE; + } +} diff --git a/src/AppBundle/DataFixtures/BaseFixtureTrait.php b/src/AppBundle/DataFixtures/BaseFixtureTrait.php new file mode 100644 index 0000000..2e242a9 --- /dev/null +++ b/src/AppBundle/DataFixtures/BaseFixtureTrait.php @@ -0,0 +1,50 @@ +container->getParameter('kernel.environment'); + + return in_array($environment, $expected, true); + } + + /** + * @return Generator + */ + protected function getFaker() + { + if ($this->faker === null) { + $this->faker = Factory::create(); + } + + return $this->faker; + } +} diff --git a/src/AppBundle/DataFixtures/Index/ExternalFixture.php b/src/AppBundle/DataFixtures/Index/ExternalFixture.php new file mode 100644 index 0000000..0d6c2e6 --- /dev/null +++ b/src/AppBundle/DataFixtures/Index/ExternalFixture.php @@ -0,0 +1,232 @@ +checkEnvironment('prod')) { + return; + } + + if (! $index instanceof InternalHoseIndex) { + throw new \LogicException(sprintf( + 'External fixtures should be loaded into \'%s\' but \'%s\' given', + InternalHoseIndex::class, + get_class($index) + )); + } + + $patches = [ + [ + 'sequence' => '1', + 'title' => 'Al Kodmani Crime Family stole millions', + 'lang' => 'en', + 'geo_country' => 'US', + 'geo_state' => 'Arizona', + 'geo_city' => 'Amazing City', + 'published' => date_create()->modify('- 10 days')->format('Y-m-d\TH:i:s\Z'), + 'source_title' => 'CNN', + 'duplicates_count' => 0, + 'image_src' => 'http://lorempixel.com/120/100/', + 'views' => 12012312, + ], + [ + 'sequence' => '2', + 'title' => 'Amazing cat', + 'lang' => 'en', + 'geo_country' => 'US', + 'geo_state' => 'Maryland', + 'published' => date_create()->modify('- 15 days')->format('Y-m-d\TH:i:s\Z'), + 'source_title' => 'Asharq Al Awsat', + 'duplicates_count' => 0, + 'image_src' => '', + 'views' => 112, + 'section' => 'Lifestyle', + ], + [ + 'sequence' => '3', + 'title' => 'More about cats', + 'lang' => 'ru', + 'geo_country' => 'US', + 'geo_state' => 'Louisiana', + 'published' => date_create()->modify('- 10 days')->format('Y-m-d\TH:i:s\Z'), + 'source_title' => 'AAAE', + 'duplicates_count' => 0, + 'image_src' => null, + 'views' => 10001, + ], + [ + 'sequence' => '4', + 'title' => 'Cat and dog market', + 'lang' => 'en', + 'geo_country' => 'RU', + 'published' => date_create()->modify('- 1 months')->format('Y-m-d\TH:i:s\Z'), + 'source_title' => 'Aaj TV', + 'duplicates_count' => 0, + 'image_src' => 'http://lorempixel.com/120/100/', + 'views' => 123, + ], + [ + 'sequence' => '5', + 'title' => 'Dogs are the best', + 'lang' => 'en', + 'published' => date_create()->modify('- 1 year')->format('Y-m-d\TH:i:s\Z'), + 'source_title' => 'AACSB', + 'duplicates_count' => 20, + 'image_src' => 'http://lorempixel.com/120/100/', + 'views' => 1123312, + ], + [ + 'sequence' => '6', + 'title' => 'Fish', + 'lang' => 'af', + 'geo_country' => 'US', + 'geo_state' => 'Maryland', + 'published' => date_create()->modify('- 25 days')->format('Y-m-d\TH:i:s\Z'), + 'source_title' => 'Armenian Assembly of America', + 'duplicates_count' => 0, + 'image_src' => 'http://lorempixel.com/120/100/', + 'views' => 100237312, + ], + [ + 'sequence' => '7', + 'title' => 'Cat and fish', + 'lang' => 'en', + 'geo_country' => 'US', + 'geo_state' => 'Arizona', + 'published' => date_create()->modify('- 3 days')->format('Y-m-d\TH:i:s\Z'), + 'source_title' => '4A\'s', + 'duplicates_count' => 10, + 'image_src' => null, + 'views' => 1543312, + 'author_name' => 'Gracie Pfeffer', + 'publisher' => 'msnbc', + ], + [ + 'sequence' => '8', + 'title' => 'Some', + 'main' => 'Cat', + 'lang' => 'af', + 'geo_country' => 'US', + 'geo_state' => 'Louisiana', + 'published' => date_create()->modify('- 15 minutes')->format('Y-m-d\TH:i:s\Z'), + 'source_title' => 'Asian American Press', + 'duplicates_count' => 5, + 'image_src' => '', + 'views' => 10312, + ], + [ + 'sequence' => '9', + 'title' => 'Some', + 'main' => 'Cat', + 'lang' => 'af', + 'geo_country' => 'US', + 'geo_state' => 'Louisiana', + 'published' => date_create()->modify('- 1 hours')->format('Y-m-d\TH:i:s\Z'), + 'source_title' => 'CNN', + 'duplicates_count' => 5, + 'image_src' => '', + 'views' => 10012, + ], + ]; + + /** @var ArticleDocumentInterface[] $documents */ + $documents = []; + $max = $this->checkEnvironment('stage') ? self::MAX : count($patches); + foreach (range(0, $max) as $idx) { + $document = $this->generator->generate(10 + $idx); + + if (isset($patches[$idx])) { + $document = $this->applyPatch($document, $patches[$idx]); + } + $documents[] = $document; + } + + $index->index($documents); + + // + // Some documents we should persist into our database in order to add + // comments for it. + // + /** @var EntityManagerInterface $em */ + $em = $this->container->get('doctrine.orm.default_entity_manager'); + $users = [ + $em->getReference(User::class, 1), + $em->getReference(User::class, 2), + $em->getReference(User::class, 3), + ]; + $faker = $this->getFaker(); + + if (! $this->checkEnvironment('test')) { + foreach (range(0, $max, 5) as $idx) { + $commentsCount = random_int(15, 35); + + $entity = $documents[$idx]->toDocumentEntity() + ->setCommentsCount($commentsCount + 1); + $em->persist($entity); + + foreach (range(0, $commentsCount) as $commentIdx) { + $comment = new Comment( + $faker->randomElement($users), + $faker->realText(), + $faker->boolean() ? 'Comment ' . $commentIdx : '' + ); + + $comment + ->setCreatedAt(date_create()->modify('- ' . ($commentIdx + 1) . ' minutes')) + ->setNew($commentIdx < CommentManagerInterface::NEW_COMMENT_POOL_SIZE) + ->setDocument($entity); + $em->persist($comment); + } + } + } + + $em->flush(); + } + + /** + * @param DocumentInterface $document A IndexDocumentInterface instance. + * @param array $path Array of patched properties with new values. + * + * @return DocumentInterface + */ + private function applyPatch(DocumentInterface $document, array $path) + { + return $document->mapRawData(function (array $data) use ($path) { + foreach ($path as $name => $value) { + $data[$name] = $value; + } + + return $data; + }); + } +} diff --git a/src/AppBundle/DataFixtures/Index/InternalFixture.php b/src/AppBundle/DataFixtures/Index/InternalFixture.php new file mode 100644 index 0000000..a03b7ea --- /dev/null +++ b/src/AppBundle/DataFixtures/Index/InternalFixture.php @@ -0,0 +1,57 @@ +checkEnvironment([ 'dev', 'test' ])) { + return; + } + + $documentManager = new InternalDocumentGenerator(); + + $documents = []; + for ($i = 0; $i < 100; ++$i) { + $document = $documentManager->generate(); + $document['sequence'] = $i; + $document['title'] = 'About cat '.$i; + $documents[] = $document; + } + + $index->index($documents); + } + + + /** + * Return index type for this fixture. + * + * @return string + */ + public function getIndex() + { + return self::INDEX_INTERNAL; + } +} diff --git a/src/AppBundle/DataFixtures/Index/SourceFixture.php b/src/AppBundle/DataFixtures/Index/SourceFixture.php new file mode 100644 index 0000000..c1f62c6 --- /dev/null +++ b/src/AppBundle/DataFixtures/Index/SourceFixture.php @@ -0,0 +1,98 @@ +checkEnvironment('prod')) { + return; + } + + // Wait to insure that all external documents was indexed. + sleep(5); + + /** @var SourceManagerInterface $manager */ + $manager = $this->container->get('app.source_manager'); + $manager->pullFromExternal(); + + // Wait to insure that all sources was indexed. + sleep(5); + + /** @var \Doctrine\ORM\EntityManagerInterface $em */ + $em = $this->container->get('doctrine.orm.default_entity_manager'); + $lists = $em->getRepository(SourceList::class)->findAll(); + $response = $index->createRequestBuilder()->setLimit(1000)->build()->execute(); + + $min = (int) floor($response->getTotalCount() / 6); + $max = (int) floor($response->getTotalCount() / 2); + + foreach ($lists as $list) { + $ids = $this->uniqueRandomIds($response->getDocuments(), mt_rand($min, $max)); + $manager->addSourcesToList($ids, $list->getId()); + + // Wait to insure that all changes will be accepted and applied. + sleep(1); + } + } + + /** + * Fetch unique random ids from sources. + * + * @param array $sources Source array. + * @param integer $count How much elements get. + * + * @return array + */ + private function uniqueRandomIds(array $sources, $count) + { + $alreadyFetched = []; + $fetchedCount = 0; + $sourceCount = count($sources); + $result = []; + + while ($fetchedCount < $count) { + $idx = mt_rand(0, $sourceCount - 1); + if (in_array($idx, $alreadyFetched, true)) { + continue; + } + + $alreadyFetched[] = $idx; + $fetchedCount++; + + $result[] = $sources[$idx]['id']; + } + + return $result; + } +} diff --git a/src/AppBundle/DataFixtures/ORM/NotificationFixtures.php b/src/AppBundle/DataFixtures/ORM/NotificationFixtures.php new file mode 100644 index 0000000..3942c24 --- /dev/null +++ b/src/AppBundle/DataFixtures/ORM/NotificationFixtures.php @@ -0,0 +1,401 @@ + [ + ThemeTypeEnum::PLAIN => [ + 'content.extract' => [ + ThemeOptionExtractEnum::NO, + ThemeOptionExtractEnum::START, + ThemeOptionExtractEnum::CONTEXT, + ], + 'content.highlightKeywords.highlight' => [ + true, + false, + ], + 'content.showInfo.sectionDivider' => [ + true, + false, + ], + 'content.showInfo.sourceCountry' => [ + true, + false, + ], + 'content.showInfo.userComments' => [ + true, + false, + ], + ], + ThemeTypeEnum::ENHANCED => [ + 'content.extract' => [ + ThemeOptionExtractEnum::NO, + ThemeOptionExtractEnum::START, + ThemeOptionExtractEnum::CONTEXT, + ], + 'content.highlightKeywords.highlight' => [ + true, + false, + ], + 'content.showInfo.articleSentiment' => [ + true, + false, + ], + 'content.showInfo.sourceCountry' => [ + true, + false, + ], + 'content.showInfo.userComments' => [ + true, + false, + ], + ], + ], + ]; + + /** + * Load data fixtures with the passed EntityManager + * + * @param ObjectManager $manager A ObjectManager instance. + * + * @return void + */ + public function load(ObjectManager $manager) + { + switch (true) { + case $this->checkEnvironment('dev'): + $this->loadForDevelopment($manager); + break; + + case $this->checkEnvironment('test'): + $this->loadForTesting($manager); + break; + } + } + + /** + * Get the order of this fixture + * + * @return integer + */ + public function getOrder() + { + return 5; + } + + /** + * @param ObjectManager $manager A ObjectManager instance. + * + * @return void + */ + private function loadForDevelopment(ObjectManager $manager) + { + /** @var User $testUser */ + $testUser = $this->getReference('test@email.com'); + /** @var User $masterUser */ + $masterUser = $this->getReference('master@email.com'); + + $feeds = []; + for ($i = 0; $this->hasReference('feed_'. $i); $i++) { + $feeds[] = $this->getReference('feed_'. $i); + } + + $this->createNotifications($testUser, $feeds, $manager); + $this->createNotifications($masterUser, $feeds, $manager); + + $manager->flush(); + } + + /** + * @param ObjectManager $manager A ObjectManager instance. + * + * @return void + */ + private function loadForTesting(ObjectManager $manager) + { + /** @var User $testUser */ + $testUser = $this->getReference('test@email.com'); + /** @var User $masterUser */ + $masterUser = $this->getReference('master@email.com'); + + /** @var NotificationTheme $theme */ + $theme = $this->getReference('default_notification_theme'); + + /** @var QueryFeed $feedTest1 */ + $feedTest1 = $this->getReference('feed_test1'); + /** @var QueryFeed $feedTest3 */ + $feedTest3 = $this->getReference('feed_test3'); + + $notification = Notification::create() + ->setName('TestUser Notification1') + ->setSubject('TestUser Notification1 Subject') + ->setTimezone(new \DateTimeZone($this->getFaker()->timezone)) + ->setOwner($testUser) + ->setBillingSubscription($testUser->getBillingSubscription()) + ->setTheme($theme) + ->setNotificationType(NotificationTypeEnum::alert()) + ->setThemeType(ThemeTypeEnum::plain()) + ->setAutomatedSubject(false) + ->setPublished() + ->setActive() + ->setAllowUnsubscribe(false) + ->setUnsubscribeNotification(true) + ->setSendWhenEmpty(false) + ->addFeed($feedTest1) + ->setSourcesCount(1); + $manager->persist($notification); + + $notification = Notification::create() + ->setName('MasterUser Notification1') + ->setSubject('MasterUser Notification1 Subject') + ->setTimezone(new \DateTimeZone($this->getFaker()->timezone)) + ->setOwner($masterUser) + ->setBillingSubscription($testUser->getBillingSubscription()) + ->setTheme($theme) + ->setNotificationType(NotificationTypeEnum::alert()) + ->setThemeType(ThemeTypeEnum::plain()) + ->setAutomatedSubject(false) + ->setPublished() + ->setActive() + ->setAllowUnsubscribe(false) + ->setUnsubscribeNotification(true) + ->setSendWhenEmpty(false) + ->addFeed($feedTest3) + ->setSourcesCount(1); + $manager->persist($notification); + + $manager->flush(); + } + + /** + * Create notification for specified user. + * + * @param User $user A User entity instance. + * @param array $feeds Array of feeds. + * @param ObjectManager $manager A ObjectManager instance. + * + * @return void + */ + private function createNotifications( + User $user, + array $feeds, + ObjectManager $manager + ) { + $userFeeds = \app\a\select(\nspl\op\methodCaller('isOwnedBy', [ $user ]), $feeds); + $userFeedsCount = count($userFeeds); + + if ($userFeedsCount === 0) { + return; + } + + $faker = $this->getFaker(); + + /** @var NotificationTheme $theme */ + $theme = $this->getReference('default_notification_theme'); + $allowedCount = ceil($user->getAllowedLimit(AppLimitEnum::alerts()) / 2); + + $repository = $manager->getRepository(AbstractRecipient::class); + $recipients = $repository->findBy([ 'owner' => $user->getId() ]); + $recipientsCount = count($recipients); + + for ($i = 0; $i < $allowedCount; ++$i) { + $feeds = $faker->randomElements($userFeeds, random_int(1, ceil($userFeedsCount / 2))); + $notificationType = NotificationTypeEnum::alert(); + $themeType = new ThemeTypeEnum($faker->randomElement(ThemeTypeEnum::getAvailables())); + $count = random_int(0, $recipientsCount); + + $notification = Notification::create() + ->setName($faker->realText($faker->numberBetween(10, 15))) + ->setSubject($faker->realText(50)) + ->setTimezone(new \DateTimeZone($this->getFaker()->timezone)) + ->setOwner($user) + ->setBillingSubscription($user->getBillingSubscription()) + ->setTheme($theme) + ->setNotificationType($notificationType) + ->setThemeType($themeType) + + ->setAutomatedSubject($faker->boolean(35)) + ->setPublished($faker->boolean(45)) + ->setActive($faker->boolean(85)) + ->setAllowUnsubscribe($faker->boolean(85)) + ->setUnsubscribeNotification($faker->boolean(15)) + ->setSendWhenEmpty($faker->boolean(15)); + + if ($themeType->is(ThemeTypeEnum::plain())) { + $notification->setPlainThemeOptionsDiff( + $this->generateDiff($notificationType, $themeType) + ); + } + + for ($j = 0; $j < $count; ++$j) { + $notification->addRecipient($recipients[$j]); + } + + if ($faker->boolean(60)) { + $notification->setSendUntil($faker->dateTimeBetween('+ 10 days', '+ 1 months')); + } + + foreach ($feeds as $feed) { + $notification->addFeed($feed); + } + + $notification->setSourcesCount(count($feeds)); + + $schedules = $this->generateSchedules($faker->numberBetween(1, 4)); + + foreach ($schedules as $schedule) { + $notification->addSchedule($schedule); + $manager->persist($schedule); + } + + $user->useLimit(AppLimitEnum::alerts()); + + $manager->persist($notification); + $manager->persist($user); + } + } + + /** + * @param NotificationTypeEnum $notificationType A NotificationTypeEnum instance. + * @param ThemeTypeEnum $themeType A ThemeTypeEnum instance. + * + * @return array + */ + private function generateDiff( + NotificationTypeEnum $notificationType, + ThemeTypeEnum $themeType + ) { + $faker = $this->getFaker(); + + $available = self::$availableDiffMap[(string) $notificationType][(string) $themeType]; + $availableCount = count($available); + + /** + * @return \Generator + */ + $generatorFn = function () use ($available, $availableCount, $faker) { + $availableKeys = array_keys($available); + $used = []; + $usedCount = 0; + + while ($availableCount > $usedCount) { + do { + $parameter = $faker->randomElement($availableKeys); + } while (in_array($parameter, $used, true)); + + $used[] = $parameter; + $usedCount++; + + yield $parameter; + } + }; + + $parameters = $generatorFn(); + + $count = random_int(0, $availableCount - 1); + $diff = []; + + for ($i = 0; $i < $count; ++$i) { + $parameter = $parameters->current(); + $diff[$parameter] = $faker->randomElement($available[$parameter]); + + $parameters->next(); + } + + return $diff; + } + + /** + * Generate NotificationSchedule entity. + * + * @param integer $count How many schedules we need. + * + * @return \Generator + */ + private function generateSchedules($count) + { + $faker = $this->getFaker(); + + for ($i = 0; $i < $count; $i++) { + $class = $faker->randomElement(self::$scheduleClasses); + $methodName = 'create'. \app\c\getShortName($class); + yield $this->{$methodName}(); + } + } + + /** + * @return DailyNotificationSchedule + * + * Actually we call it. + * @SuppressWarnings(PHPMD.UnusedPrivateMethod) + */ + private function createDailyNotificationSchedule() + { + $faker = $this->getFaker(); + + return DailyNotificationSchedule::create() + ->setDays($faker->randomElement(DailyNotificationSchedule::getAvailableDays())) + ->setTime($faker->randomElement(DailyNotificationSchedule::getAvailableTime())); + } + + /** + * @return WeeklyNotificationSchedule + * + * Actually we call it. + * @SuppressWarnings(PHPMD.UnusedPrivateMethod) + */ + private function createWeeklyNotificationSchedule() + { + $faker = $this->getFaker(); + + return WeeklyNotificationSchedule::create() + ->setPeriod($faker->randomElement(WeeklyNotificationSchedule::getAvailablePeriod())) + ->setDay($faker->randomElement(WeeklyNotificationSchedule::getAvailableDay())) + ->setHour($faker->randomElement(range(0, 23))) + ->setMinute($faker->randomElement(range(0, 55, 5))); + } + + /** + * @return MonthlyNotificationSchedule + * + * Actually we call it. + * @SuppressWarnings(PHPMD.UnusedPrivateMethod) + */ + private function createMonthlyNotificationSchedule() + { + $faker = $this->getFaker(); + + return MonthlyNotificationSchedule::create() + ->setDay($faker->randomElement(MonthlyNotificationSchedule::getAvailableDay())) + ->setHour($faker->randomElement(range(0, 23))) + ->setMinute($faker->randomElement(range(0, 55, 5))); + } +} diff --git a/src/AppBundle/DataFixtures/ORM/NotificationHistoryFixtures.php b/src/AppBundle/DataFixtures/ORM/NotificationHistoryFixtures.php new file mode 100644 index 0000000..edb75f4 --- /dev/null +++ b/src/AppBundle/DataFixtures/ORM/NotificationHistoryFixtures.php @@ -0,0 +1,77 @@ +checkEnvironment('dev')) { + return; + } + + $notifications = $manager->getRepository(Notification::class) + ->createQueryBuilder('Notification') + ->select('Notification, Schedule') + ->join('Notification.schedules', 'Schedule') + ->getQuery() + ->getResult(); + + $faker = $this->getFaker(); + + /** @var Notification $notification */ + foreach ($notifications as $notification) { + $max = random_int(15, 40); + for ($i = 0; $i < $max; ++$i) { + $schedule = $notification->getSchedules()->toArray(); + + $historySchedule = array_map(function (AbstractNotificationSchedule $schedule) { + $historySchedule = clone $schedule; + $historySchedule->setNotification(null); + + return $historySchedule; + }, $faker->randomElements($schedule, random_int(1, count($schedule)))); + + $history = new NotificationSendHistory( + $notification, + $historySchedule + ); + $history->setDate($faker->dateTimeBetween('- 1 year')); + + $manager->persist($history); + } + + $manager->flush(); + } + } + + /** + * Get the order of this fixture + * + * @return integer + */ + public function getOrder() + { + return 6; + } +} diff --git a/src/AppBundle/DataFixtures/ORM/NotificationThemeFixtures.php b/src/AppBundle/DataFixtures/ORM/NotificationThemeFixtures.php new file mode 100644 index 0000000..f6b3200 --- /dev/null +++ b/src/AppBundle/DataFixtures/ORM/NotificationThemeFixtures.php @@ -0,0 +1,43 @@ +setName('Socialhose theme') + ->setEnhanced($defaultOptions) + ->setPlain($defaultOptions) + ->setDefault(true); + + $this->addReference('default_notification_theme', $default); + + $manager->persist($default); + $manager->flush(); + } +} diff --git a/src/AppBundle/DataFixtures/ORM/OrganizationFixture.php b/src/AppBundle/DataFixtures/ORM/OrganizationFixture.php new file mode 100644 index 0000000..16138fe --- /dev/null +++ b/src/AppBundle/DataFixtures/ORM/OrganizationFixture.php @@ -0,0 +1,38 @@ +checkEnvironment('prod')) { + return; + } + + $organization = Organization::create() + ->setName('Test Organization'); + $this->setReference('organization', $organization); + $manager->persist($organization); + + $manager->flush(); + } +} diff --git a/src/AppBundle/DataFixtures/ORM/PaymentFixture.php b/src/AppBundle/DataFixtures/ORM/PaymentFixture.php new file mode 100644 index 0000000..dae95db --- /dev/null +++ b/src/AppBundle/DataFixtures/ORM/PaymentFixture.php @@ -0,0 +1,63 @@ +checkEnvironment('dev')) { + return; + } + + /** @var AbstractSubscription[] $subscriptions */ + $subscriptions = [ + $this->getReference('first_subscription'), + $this->getReference('second_subscription'), + $this->getReference('personal_subscription'), + ]; + + $faker = $this->getFaker(); + for ($i = 0; $i < 50; $i++) { + $payment = Payment::create() + ->setGateway(PaymentGatewayEnum::paypal()) + ->setAmount(new Money($faker->randomFloat(2, 10, 20), 'USD')) + ->setStatus(new PaymentStatusEnum($faker->randomElement(PaymentStatusEnum::getAvailables()))) + ->setSubscription($faker->randomElement($subscriptions)) + ->setTransactionId($faker->md5); + $manager->persist($payment); + } + + $manager->flush(); + } + + /** + * Get the order of this fixture. + * + * @return integer + */ + public function getOrder() + { + return 3; + } +} diff --git a/src/AppBundle/DataFixtures/ORM/PlanFixture.php b/src/AppBundle/DataFixtures/ORM/PlanFixture.php new file mode 100644 index 0000000..a5c04d9 --- /dev/null +++ b/src/AppBundle/DataFixtures/ORM/PlanFixture.php @@ -0,0 +1,111 @@ +setTitle('Social Starter') + ->setInnerName('social_starter') + ->setSearchesPerDay(500) + ->setSavedFeeds(15) + ->setMasterAccounts(1) + ->setSubscriberAccounts(5) + ->setAlerts(5) + ->setNewsletters(1) + ->setAnalytics(false) + ->setNews(true) + ->setBlog(false) + ->setReddit(false) + ->setInstagram(true) + ->setIsDefault(true) + ->setTwitter(false) + ->setPrice(160.0); + $manager->persist($plan); + $this->setReference('starter_plan', $plan); + + + $plan = Plan::create() + ->setTitle('Pr Starter') + ->setInnerName('pr_starter') + ->setSearchesPerDay(1000) + ->setSavedFeeds(20) + ->setMasterAccounts(1) + ->setSubscriberAccounts(20) + ->setAlerts(20) + ->setNewsletters(10) + ->setAnalytics(true) + ->setNews(true) + ->setBlog(false) + ->setIsDefault(true) + ->setReddit(false) + ->setInstagram(true) + ->setTwitter(false) + ->setPrice(190.0); + $manager->persist($plan); + $this->setReference('pr_starter', $plan); + + + $plan = Plan::create() + ->setTitle('The Works') + ->setInnerName('the_works') + ->setSearchesPerDay(2000) + ->setSavedFeeds(25) + ->setMasterAccounts(1) + ->setSubscriberAccounts(25) + ->setAlerts(30) + ->setNewsletters(20) + ->setAnalytics(true) + ->setNews(true) + ->setBlog(true) + ->setReddit(true) + ->setInstagram(true) + ->setIsDefault(true) + ->setTwitter(true) + ->setPrice(325.0); + $manager->persist($plan); + $this->setReference('the_works', $plan); + + + + $plan = Plan::create() + ->setTitle('Free') + ->setInnerName('free') + ->setSearchesPerDay(100) + ->setSavedFeeds(0) + ->setMasterAccounts(1) + ->setSubscriberAccounts(0) + ->setAlerts(5) + ->setNewsletters(5) + ->setAnalytics(true) + ->setNews(true) + ->setBlog(true) + ->setReddit(true) + ->setInstagram(true) + ->setTwitter(true) + ->setIsDefault(true) + ->setPrice(0.0); + $manager->persist($plan); + $this->setReference('free', $plan); + + $manager->flush(); + } +} diff --git a/src/AppBundle/DataFixtures/ORM/QueryFeedFixture.php b/src/AppBundle/DataFixtures/ORM/QueryFeedFixture.php new file mode 100644 index 0000000..0719fa9 --- /dev/null +++ b/src/AppBundle/DataFixtures/ORM/QueryFeedFixture.php @@ -0,0 +1,198 @@ +checkEnvironment('dev'): + $this->loadForDevelopment($manager); + break; + + case $this->checkEnvironment('test'): + $this->loadForTesting($manager); + break; + } + } + + /** + * Get the order of this fixture. + * + * @return integer + */ + public function getOrder() + { + return 3; + } + + /** + * @param ObjectManager $manager A ObjectManager instance. + * + * @return void + */ + private function loadForDevelopment(ObjectManager $manager) + { + /** @var User[] $users */ + $users = [ + $this->getReference('test@email.com'), + $this->getReference('master@email.com'), + ]; + + for ($i = 0; $i < self::MAX_FEEDS; $i++) { + $raw = strtolower($this->getFaker()->word); + + $index = random_int(0, count($users) - 1); + + $user = $users[$index]; + + try { + $user->useLimit(AppLimitEnum::feeds()); + } catch (LimitExceedException $exception) { + continue; + } + + $categoriesCount = count($user->getCategories()); + + $query = $this->createQuery($raw); + $category = $this->getCategory($user, $categoriesCount); + $feed = $this->createQueryFeed($query, 'test'. $raw, $user, $category); + + $manager->persist($query); + $manager->persist($feed); + $manager->persist($user); + $this->addReference('feed_'. $i, $feed); + } + + $manager->flush(); + } + + /** + * @param ObjectManager $manager A ObjectManager instance. + * + * @return void + */ + private function loadForTesting(ObjectManager $manager) + { + /** @var User $testUser */ + $testUser = $this->getReference('test@email.com'); + /** @var User $masterUser */ + $masterUser = $this->getReference('master@email.com'); + + $testUser->useLimit(AppLimitEnum::feeds(), 2); + + $query = $this->createQuery('test1'); + $feed = $this->createQueryFeed($query, 'test1', $testUser, $testUser->getCategories()->first()); + + $manager->persist($query); + $manager->persist($feed); + $this->addReference('feed_test1', $feed); + + $query = $this->createQuery('test2'); + $feed = $this->createQueryFeed($query, 'test2', $testUser, $testUser->getCategories()->first()); + + $manager->persist($query); + $manager->persist($feed); + $this->addReference('feed_test2', $feed); + + $masterUser->useLimit(AppLimitEnum::feeds()); + + $query = $this->createQuery('test3'); + $feed = $this->createQueryFeed($query, 'test3', $masterUser, $masterUser->getCategories()->first()); + + $manager->persist($query); + $manager->persist($feed); + $this->addReference('feed_test3', $feed); + + $manager->persist($testUser); + $manager->persist($masterUser); + $manager->flush(); + } + + /** + * @param User $user A User entity instance. + * @param integer $count Total count of categories. + * + * @return \CacheBundle\Entity\Category + */ + private function getCategory(User $user, $count) + { + static $i = 0; + + if ($i >= $count) { + $i = 0; + } + + return $user->getCategories()[$i++]; + } + + /** + * @param string $searchString That keyword is used for searching. + * + * @return StoredQuery + */ + private function createQuery($searchString) + { + return StoredQuery::create() + ->setRaw($searchString) + ->setFields([ + FieldNameEnum::TITLE, + FieldNameEnum::MAIN, + ]) + ->setTotalCount($this->getFaker()->randomNumber()) + ->setDate(date_create()) + ->setNormalized($searchString) + ->setHash($this->getFaker()->md5); + } + + /** + * @param StoredQuery $query A StoredQuery instance. + * @param string $name Feed name. + * @param User $user Feed owner. + * @param Category $category In which category place feed. + * + * @return AbstractFeed + */ + private function createQueryFeed(StoredQuery $query, $name, User $user, Category $category) + { + return QueryFeed::create() + ->setPublisherTypes($this->getFaker() + ->randomElements(PublisherTypeEnum::getAvailables(), random_int(1, 2))) + ->setQuery($query) + ->setCategory($category) + ->setName($name) + ->setUser($user); + } +} diff --git a/src/AppBundle/DataFixtures/ORM/RecipientFixture.php b/src/AppBundle/DataFixtures/ORM/RecipientFixture.php new file mode 100644 index 0000000..41a5853 --- /dev/null +++ b/src/AppBundle/DataFixtures/ORM/RecipientFixture.php @@ -0,0 +1,90 @@ +checkEnvironment('dev')) { + return; + } + + /** @var User[] $users */ + $users = [ + $this->getReference('test@email.com'), + $this->getReference('master@email.com'), + ]; + + $faker = $this->getFaker(); + + foreach ($users as $user) { + if ($user->hasRole(UserRoleEnum::MASTER_USER)) { + $personCount = random_int(4, 10); + $groupCount = random_int(3, 5); + $persons = []; + + for ($i = 0; $i < $personCount; ++$i) { + $person = PersonRecipient::create() + ->setFirstName($faker->firstName) + ->setLastName($faker->lastName) + ->setEmail($faker->email) + ->setOwner($user); + + $persons[] = $person; + + $manager->persist($person); + } + + for ($i = 0; $i < $groupCount; ++$i) { + $groupPersons = $faker->randomElements($persons, random_int(2, 4)); + + $group = GroupRecipient::create() + ->setName($faker->word) + ->setDescription($faker->realText()) + ->setOwner($user); + + foreach ($groupPersons as $person) { + $group->addRecipient($person); + } + + $manager->persist($group); + } + + $manager->persist($user); + $manager->flush(); + } + } + } + + /** + * Get the order of this fixture. + * + * @return integer + */ + public function getOrder() + { + return 3; + } +} diff --git a/src/AppBundle/DataFixtures/ORM/RefreshTokenFixture.php b/src/AppBundle/DataFixtures/ORM/RefreshTokenFixture.php new file mode 100644 index 0000000..b82b1e8 --- /dev/null +++ b/src/AppBundle/DataFixtures/ORM/RefreshTokenFixture.php @@ -0,0 +1,40 @@ +checkEnvironment('test')) { + return; + } + + $token = new RefreshToken(); + $token + ->setRefreshToken('user1_token') + ->setUsername('test@email.com') + ->setValid(date_create()->modify('+ 10 days')); + + $manager->persist($token); + $manager->flush(); + } +} diff --git a/src/AppBundle/DataFixtures/ORM/SiteConfigurationFixture.php b/src/AppBundle/DataFixtures/ORM/SiteConfigurationFixture.php new file mode 100644 index 0000000..384d186 --- /dev/null +++ b/src/AppBundle/DataFixtures/ORM/SiteConfigurationFixture.php @@ -0,0 +1,30 @@ +container->get(AppBundleServices::CONFIGURATION) + ->syncWithDefinitions(); + } +} diff --git a/src/AppBundle/DataFixtures/ORM/SourceListFixture.php b/src/AppBundle/DataFixtures/ORM/SourceListFixture.php new file mode 100644 index 0000000..cc14681 --- /dev/null +++ b/src/AppBundle/DataFixtures/ORM/SourceListFixture.php @@ -0,0 +1,62 @@ +checkEnvironment('dev')) { + return; + } + + $users = [ + $this->getReference('test@email.com'), + $this->getReference('master@email.com'), + ]; + + for ($i = 1; $i <= 25; $i++) { + $user = $users[$i % 2]; + + $sourceList = new SourceList(); + $sourceList->setName('Source list '. $i); + $sourceList->setUser($user); + + if ($this->getFaker()->boolean()) { + $sourceList + ->setUpdatedBy($user) + ->setUpdatedAt(new \DateTime()); + } + + $manager->persist($sourceList); + } + + $manager->flush(); + } + + /** + * Get the order of this fixture + * + * @return integer + */ + public function getOrder() + { + return 5; + } +} diff --git a/src/AppBundle/DataFixtures/ORM/UserFixture.php b/src/AppBundle/DataFixtures/ORM/UserFixture.php new file mode 100644 index 0000000..995071c --- /dev/null +++ b/src/AppBundle/DataFixtures/ORM/UserFixture.php @@ -0,0 +1,237 @@ +container->get('fos_user.user_manager'); + + if ($this->checkEnvironment('prod')) { + /** @var User $superAdmin */ + $superAdmin = $userManager->createUser(); + $superAdmin + ->setFirstName('Super') + ->setLastName('Admin') + ->setEmail('super_admin@socialhose.io') + ->setPhoneNumber('44444444444') + ->setVerified() + ->setEnabled(true) + ->addRole(UserRoleEnum::SUPER_ADMIN) + ->setPlainPassword('FEvJNcKGk2rVDMVL'); + + $userManager->updateUser($superAdmin); + $this->setReference('super_admin@socialhose.io', $superAdmin); + + return; + } + + // + // Create organization subscription and users. + // + /** @var Organization $organization */ + $organization = $this->getReference('organization'); + /** @var Plan $businessPlan */ + $businessPlan = $this->getReference('starter_plan'); + + /** @var Plan $basicPlan */ + $basicPlan = $this->getReference('pr_starter'); + + // + // First department. + // + + $firstSubscription = OrganizationSubscription::create() + ->setGateway(PaymentGatewayEnum::paypal()) + ->setPayed(true) + ->setPlan($businessPlan) + ->setOrganization($organization) + ->setOrganizationAddress('First department address') + ->setOrganizationEmail('first_department.organization@email.com') + ->setOrganizationPhone('111111111'); + + /** @var User $master */ + $master = $userManager->createUser(); + $master + ->setFirstName('John') + ->setLastName('Smith') + ->setEmail('test@email.com') + ->setPhoneNumber('11111111111') + ->setVerified() + ->setEnabled(true) + ->setBillingSubscription($firstSubscription) + ->addRole(UserRoleEnum::MASTER_USER) + ->setPlainPassword('test'); + $main = Category::createMainCategory($master); + Category::createSharedCategory($master); + Category::createTrashCategory($master); + $subMain = Category::createChild($main, $master, 'Sub main'); + Category::createChild($subMain, $master, 'Sub main sub 1'); + Category::createChild($subMain, $master, 'Sub main sub 2'); + $subMainSub3 = Category::createChild($subMain, $master, 'Sub main sub 3'); + Category::createChild($subMainSub3, $master, 'Test'); + + $firstSubscription->setOwner($master); + + $userManager->updateUser($master); + $this->setReference('test@email.com', $master); + $this->setReference('first_subscription', $firstSubscription); + + /** @var User $user */ + $user = $userManager->createUser(); + $user + ->setFirstName('John') + ->setLastName('Smith') + ->setEmail('test_subscriber@email.com') + ->setPhoneNumber('11111111112') + ->setVerified() + ->setEnabled(true) + ->setBillingSubscription($firstSubscription) + ->addRole(UserRoleEnum::SUBSCRIBER) + ->setMasterUser($master) + ->setPlainPassword('test'); + + + $manager->persist($firstSubscription); + $userManager->updateUser($user); + $this->setReference('test_subscriber@email.com', $user); + + // + // Second department. + // + + $secondSubscription = OrganizationSubscription::create() + ->setGateway(PaymentGatewayEnum::paypal()) + ->setPayed(true) + ->setPlan($basicPlan) + ->setOrganization($organization) + ->setOrganizationAddress('Second department address') + ->setOrganizationEmail('second_department.organization@email.com') + ->setOrganizationPhone('222222222'); + + /** @var User $user */ + $user = $userManager->createUser(); + $user + ->setFirstName('Master') + ->setLastName('Smith') + ->setEmail('master@email.com') + ->setPhoneNumber('22222222222') + ->setVerified() + ->setEnabled(true) + ->setBillingSubscription($secondSubscription) + ->addRole(UserRoleEnum::MASTER_USER) + ->setPlainPassword('test'); + $main = Category::createMainCategory($user); + Category::createSharedCategory($user); + Category::createTrashCategory($user); + Category::createChild($main, $user, 'Sub main'); + + $secondSubscription->setOwner($user); + $manager->persist($secondSubscription); + + $userManager->updateUser($user); + $this->setReference('master@email.com', $user); + $this->setReference('second_subscription', $secondSubscription); + + // + // Individual subscription. + // + $personSubscription = PersonalSubscription::create() + ->setGateway(PaymentGatewayEnum::paypal()) + ->setPayed(true) + ->setPlan($basicPlan); + + $user = $userManager->createUser(); + $user + ->setFirstName('Jane') + ->setLastName('Smith') + ->setEmail('jane@person.com') + ->setPhoneNumber('33333333333') + ->setVerified() + ->setEnabled(true) + ->setBillingSubscription($personSubscription) + ->addRole(UserRoleEnum::MASTER_USER) + ->setPlainPassword('test'); + Category::createMainCategory($user); + Category::createTrashCategory($user); + + $userManager->updateUser($user); + $this->setReference('jane@person.com', $user); + $this->setReference('personal_subscription', $personSubscription); + + $personSubscription->setOwner($user); + $manager->persist($personSubscription); + + // + // Admins. + // + + /** @var User $superAdmin */ + $superAdmin = $userManager->createUser(); + $superAdmin + ->setFirstName('Super') + ->setLastName('Admin') + ->setEmail('super_admin@socialhose.com') + ->setPhoneNumber('44444444444') + ->setVerified() + ->setEnabled(true) + ->addRole(UserRoleEnum::SUPER_ADMIN) + ->setPlainPassword('test'); + + $userManager->updateUser($superAdmin); + $this->setReference('super_admin@socialhose.com', $superAdmin); + + /** @var User $admin */ + $admin = $userManager->createUser(); + $admin + ->setFirstName('Just') + ->setLastName('Admin') + ->setEmail('admin@socialhose.com') + ->setPhoneNumber('55555555555') + ->setVerified() + ->setEnabled(true) + ->addRole(UserRoleEnum::ADMIN) + ->setPlainPassword('test'); + + $userManager->updateUser($admin); + $this->setReference('admin@socialhose.com', $admin); + } + + /** + * Get the order of this fixture. + * + * @return integer + */ + public function getOrder() + { + return 2; + } +} diff --git a/src/AppBundle/DependencyInjection/AppExtension.php b/src/AppBundle/DependencyInjection/AppExtension.php new file mode 100644 index 0000000..1661cb9 --- /dev/null +++ b/src/AppBundle/DependencyInjection/AppExtension.php @@ -0,0 +1,48 @@ +getParameter('kernel.environment'); + if ($environment === 'prod') { + // + // Inject NelmioApiDocBundle form extensions 'cause we don't load full + // nelmio configuration on production and we got error. + // + $loader->load('nelmio_form.yml'); + } + + $loader->load('services.yml'); + } +} diff --git a/src/AppBundle/Doctrine/DBAL/Types/AbstractEnumType.php b/src/AppBundle/Doctrine/DBAL/Types/AbstractEnumType.php new file mode 100644 index 0000000..4ab1df7 --- /dev/null +++ b/src/AppBundle/Doctrine/DBAL/Types/AbstractEnumType.php @@ -0,0 +1,99 @@ +getVarcharTypeDeclarationSQL($fieldDeclaration); + } + + /** + * Converts a value from its PHP representation to its database + * representation of this type. + * + * @param mixed $value The value to convert. + * @param AbstractPlatform $platform The currently used database platform. + * + * @return mixed The database representation of the value. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function convertToDatabaseValue($value, AbstractPlatform $platform) + { + if (is_string($value)) { + $class = $this->getClass(); + $value = new $class($value); + } + + if (! $value instanceof AbstractEnum) { + throw new \InvalidArgumentException( + 'Invalid value, must be instance of '. AbstractEnum::class + .' but '. (is_object($value) ? get_class($value) : gettype($value)) + .' given' + ); + } + + return $value->getValue(); + } + + /** + * Return concrete enum class + * + * @return string + */ + abstract protected function getClass(); + + /** + * Converts a value from its database representation to its PHP + * representation of this type. + * + * @param mixed $value The value to convert. + * @param AbstractPlatform $platform The currently used database platform. + * + * @return mixed The PHP representation of the value. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function convertToPHPValue($value, AbstractPlatform $platform) + { + $class = $this->getClass(); + + if ($value !== null) { + $value = new $class($value); + } + + return $value; + } + + /** + * Gets the default length of this type. + * + * @param AbstractPlatform $platform The currently used database platform. + * + * @return integer|null + */ + public function getDefaultLength(AbstractPlatform $platform) + { + return $platform->getVarcharDefaultLength(); + } +} diff --git a/src/AppBundle/Doctrine/DBAL/Types/DateTimeZoneType.php b/src/AppBundle/Doctrine/DBAL/Types/DateTimeZoneType.php new file mode 100644 index 0000000..fea7c45 --- /dev/null +++ b/src/AppBundle/Doctrine/DBAL/Types/DateTimeZoneType.php @@ -0,0 +1,96 @@ +getVarcharTypeDeclarationSQL($fieldDeclaration); + } + + /** + * Gets the default length of this type. + * + * @param AbstractPlatform $platform The currently used database platform. + * + * @return integer|null + */ + public function getDefaultLength(AbstractPlatform $platform) + { + return $platform->getVarcharDefaultLength(); + } + + /** + * Converts a value from its PHP representation to its database + * representation of this type. + * + * @param mixed $value The value to convert. + * @param AbstractPlatform $platform The currently used database platform. + * + * @return mixed The database representation of the value. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function convertToDatabaseValue($value, AbstractPlatform $platform) + { + if (! $value instanceof \DateTimeZone) { + throw new \InvalidArgumentException( + 'Expects \DateTimeZone, but got '. gettype($value) + ); + } + + return $value->getName(); + } + + /** + * Converts a value from its database representation to its PHP + * representation of this type. + * + * @param mixed $value The value to convert. + * @param AbstractPlatform $platform The currently used database platform. + * + * @return mixed The PHP representation of the value. + * + * @throws ConversionException If can't convert from database to php value. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function convertToPHPValue($value, AbstractPlatform $platform) + { + $val = new \DateTimeZone($value); + + if (! $val instanceof \DateTimeZone) { + throw ConversionException::conversionFailed($value, $this->getName()); + } + + return $val; + } +} diff --git a/src/AppBundle/Doctrine/ORM/BaseEntityRepository.php b/src/AppBundle/Doctrine/ORM/BaseEntityRepository.php new file mode 100644 index 0000000..73a563f --- /dev/null +++ b/src/AppBundle/Doctrine/ORM/BaseEntityRepository.php @@ -0,0 +1,42 @@ +_entityName) { + throw new \InvalidArgumentException(sprintf( + '%s: \'$entity\' should be instance of \'%s\' but \'%s\' given', + __CLASS__, + $this->_entityName, + \app\op\getPrintableType($entity) + )); + } + + $this->_em->persist($entity); + + if ($flush) { + $this->_em->flush($entity); + } + } +} diff --git a/src/AppBundle/Entity/ActivateAwareEntityTrait.php b/src/AppBundle/Entity/ActivateAwareEntityTrait.php new file mode 100644 index 0000000..bd69918 --- /dev/null +++ b/src/AppBundle/Entity/ActivateAwareEntityTrait.php @@ -0,0 +1,42 @@ +active = $active; + + return $this; + } + + /** + * Get active + * + * @return boolean + */ + public function isActive() + { + return $this->active; + } +} diff --git a/src/AppBundle/Entity/BaseEntityTrait.php b/src/AppBundle/Entity/BaseEntityTrait.php new file mode 100644 index 0000000..92e2086 --- /dev/null +++ b/src/AppBundle/Entity/BaseEntityTrait.php @@ -0,0 +1,54 @@ +id; + } + + /** + * Create entity instance for fluid interface access. + * + * @return static + */ + public static function create() + { + return new static(); + } + + /** + * Get entity type + * + * @return string + */ + public function getEntityType() + { + return \app\op\camelCaseToUnderscore(\app\c\getShortName(static::class)); + } +} diff --git a/src/AppBundle/Entity/CacheItem.php b/src/AppBundle/Entity/CacheItem.php new file mode 100644 index 0000000..35dd12a --- /dev/null +++ b/src/AppBundle/Entity/CacheItem.php @@ -0,0 +1,257 @@ +key = $key; + $this->value = $value; + $this->lifetime = $lifetime; + $this->expiresAt = time() + $lifetime; + $this->isHit = $isHit; + } + + /** + * @return string + */ + public function getKey() + { + return $this->key; + } + + /** + * @param string $key Item key. + * + * @return CacheItem + */ + public function setKey($key) + { + $this->key = $key; + + return $this; + } + + /** + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + /** + * @param mixed $value A cached value. + * + * @return CacheItem + */ + public function setValue($value) + { + $this->value = $value; + + return $this; + } + + /** + * Retrieves the value of the item from the cache associated with this + * object's key. + * + * The value returned must be identical to the value originally stored by + * set(). + * + * If isHit() returns false, this method MUST return null. Note that null + * is a legitimate cached value, so the isHit() method SHOULD be used to + * differentiate between "null value was found" and "no value was found." + * + * @return mixed + * The value corresponding to this cache item's key, or null if not + * found. + */ + public function get() + { + return $this->value; + } + + /** + * Sets the value represented by this cache item. + * + * The $value argument may be any item that can be serialized by PHP, + * although the method of serialization is left up to the Implementing + * Library. + * + * @param mixed $value The value to be stored. + * + * @return static + * The invoked object. + */ + public function set($value) + { + $this->value = $value; + + return $this; + } + + /** + * Confirms if the cache item lookup resulted in a cache hit. + * + * Note: This method MUST NOT have a race condition between calling isHit() + * and calling get(). + * + * @return boolean + * True if the request resulted in a cache hit. False otherwise. + */ + public function isHit() + { + return $this->isHit; + } + + /** + * @return integer + */ + public function getLifetime() + { + return $this->lifetime; + } + + /** + * @param integer $lifetime How long this item is valid in seconds. + * + * @return CacheItem + */ + public function setLifetime($lifetime) + { + $this->lifetime = $lifetime; + + return $this; + } + + /** + * @return integer + */ + public function getExpiresAt() + { + return $this->expiresAt; + } + + /** + * Sets the expiration time for this cache item. + * + * @param \DateTimeInterface|null $expiration The point in time after which + * the item MUST be considered expired. + * If null is passed explicitly, + * a default value MAY be used. + * If none is set, the value should + * be stored permanently or for as + * long as the implementation allows. + * + * @return static + */ + public function expiresAt($expiration) + { + if (null === $expiration) { + $this->expiresAt = $this->lifetime > 0 ? time() + $this->lifetime : null; + } elseif ($expiration instanceof \DateTimeInterface) { + $this->expiresAt = (int) $expiration->format('U'); + } else { + throw new \InvalidArgumentException(sprintf( + 'Expiration date must implement DateTimeInterface or be null, "%s" given', + is_object($expiration) ? get_class($expiration) : gettype($expiration) + )); + } + + return $this; + } + + /** + * Sets the expiration time for this cache item. + * + * @param integer|\DateInterval|null $time The period of time from the present + * after which the item MUST be considered + * expired. An integer parameter is + * understood to be the time in seconds + * until expiration. If null is passed + * explicitly, a default value MAY be + * used. If none is set, the value + * should be stored permanently or for + * as long as the implementation allows. + * + * @return static + */ + public function expiresAfter($time) + { + if (null === $time) { + $this->expiresAt = $this->lifetime > 0 ? time() + $this->lifetime : null; + } elseif ($time instanceof \DateInterval) { + $this->expiresAt = (int) \DateTime::createFromFormat('U', time())->add($time)->format('U'); + } elseif (is_int($time)) { + $this->expiresAt = $time + time(); + } else { + throw new \InvalidArgumentException(sprintf( + 'Expiration date must be an integer, a DateInterval or null, "%s" given', + is_object($time) ? get_class($time) : gettype($time) + )); + } + + return $this; + } +} diff --git a/src/AppBundle/Entity/EmailedDocument.php b/src/AppBundle/Entity/EmailedDocument.php new file mode 100644 index 0000000..0cd9300 --- /dev/null +++ b/src/AppBundle/Entity/EmailedDocument.php @@ -0,0 +1,148 @@ +emailTo = (array) $emailTo; + + return $this; + } + + /** + * Get emailTo + * + * @return array + */ + public function getEmailTo() + { + return $this->emailTo; + } + + /** + * Set emailReplyTo + * + * @param string $emailReplyTo Reply to. + * + * @return EmailedDocument + */ + public function setEmailReplyTo($emailReplyTo) + { + $this->emailReplyTo = $emailReplyTo; + + return $this; + } + + /** + * Get emailReplyTo + * + * @return string + */ + public function getEmailReplyTo() + { + return $this->emailReplyTo; + } + + /** + * Set subject + * + * @param string $subject Email subject. + * + * @return EmailedDocument + */ + public function setSubject($subject) + { + $this->subject = $subject; + + return $this; + } + + /** + * Get subject + * + * @return string + */ + public function getSubject() + { + return $this->subject; + } + + /** + * Set content + * + * @param string $content Email content. + * + * @return EmailedDocument + */ + public function setContent($content) + { + $this->content = $content; + + return $this; + } + + /** + * Get content + * + * @return string + */ + public function getContent() + { + return $this->content; + } +} diff --git a/src/AppBundle/Entity/EntityInterface.php b/src/AppBundle/Entity/EntityInterface.php new file mode 100644 index 0000000..465f695 --- /dev/null +++ b/src/AppBundle/Entity/EntityInterface.php @@ -0,0 +1,25 @@ +owner = $owner; + + return $this; + } + + /** + * Get owner + * + * @return User + */ + public function getOwner() + { + return $this->owner; + } + + /** + * Checks that this entity is owned by specified user. + * + * @param User $user A User entity instance. + * + * @return boolean + */ + public function isOwnedBy(User $user) + { + return $this->owner->getId() === $user->getId(); + } +} diff --git a/src/AppBundle/Enum/AbstractEnum.php b/src/AppBundle/Enum/AbstractEnum.php new file mode 100644 index 0000000..739cb77 --- /dev/null +++ b/src/AppBundle/Enum/AbstractEnum.php @@ -0,0 +1,171 @@ +value = $value; + } + + /** + * Get all available enum values. + * + * @return AbstractEnum[] + */ + public static function getValues() + { + $values = []; + + foreach (static::getAvailables() as $available) { + // + // Code sniffer says that: 'Use parentheses when instantiating classes' + // but, obviously, we did it. + // + // @codingStandardsIgnoreStart + $values[] = new static($available); + // @codingStandardsIgnoreEnd + } + + return $values; + } + + /** + * Get available constants values. + * + * @return string[] + */ + public static function getAvailables() + { + $class = static::class; + + if (! isset(self::$cache[$class])) { + $reflection = new \ReflectionClass($class); + self::$cache[$class] = $reflection->getConstants(); + } + + return self::$cache[$class]; + } + + /** + * Checks that specified value is valid for current enum. + * + * @param mixed $value Maybe one of enum value. + * + * @return boolean + */ + public static function isValid($value) + { + return in_array($value, self::getAvailables(), true); + } + + /** + * @param string $name Method name. + * @param mixed $arguments Method arguments. + * + * @return static + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public static function __callStatic($name, $arguments) + { + // camelCase to underscore. + $name = strtoupper(preg_replace('/([^A-Z-])([A-Z])/', '$1_$2', $name)); + $availables = self::getAvailables(); + + if (array_key_exists($name, $availables)) { + // + // Code sniffer says that: 'Use parentheses when instantiating classes' + // but, obviously, we did it. + // + // @codingStandardsIgnoreStart + return new static($availables[$name]); + // @codingStandardsIgnoreEnd + } + + throw new \RuntimeException("Unknown enum value '{$name}'"); + } + + /** + * Checks that current value is equal to specified. + * + * @param AbstractEnum|string $enum One of availables enum values. + * + * @return boolean + * + * @SuppressWarnings(PHPMD.ShortMethodName) + */ + public function is($enum) + { + if (is_scalar($enum)) { + // + // Code sniffer says that: 'Use parentheses when instantiating classes' + // but, obviously, we did it. + // + // @codingStandardsIgnoreStart + $enum = new static($enum); + // @codingStandardsIgnoreEnd + } + + if (! $enum instanceof static) { + throw new \InvalidArgumentException(sprintf( + 'Unknown value %s for enum %s. Expects one of %s', + $enum, + static::class, + implode(', ', self::getAvailables()) + )); + } + + return $this->value === $enum->getValue(); + } + + /** + * Get value. + * + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + /** + * @return string + */ + public function __toString() + { + return (string) $this->value; + } +} diff --git a/src/AppBundle/Enum/UnhandledEnumException.php b/src/AppBundle/Enum/UnhandledEnumException.php new file mode 100644 index 0000000..2edb715 --- /dev/null +++ b/src/AppBundle/Enum/UnhandledEnumException.php @@ -0,0 +1,33 @@ +getValue()); + } +} diff --git a/src/AppBundle/EventListener/ResponsePagination.php b/src/AppBundle/EventListener/ResponsePagination.php new file mode 100644 index 0000000..dfee150 --- /dev/null +++ b/src/AppBundle/EventListener/ResponsePagination.php @@ -0,0 +1,59 @@ + 'methodName') + * * array('eventName' => array('methodName', $priority)) + * * array('eventName' => array(array('methodName1', $priority), + * array('methodName2'))) + * + * @return array The event names to listen to + */ + public static function getSubscribedEvents() + { + return [ 'knp_pager.items' => [ 'handle', 0 ] ]; + } + + /** + * @param ItemsEvent $event A ItemsEvent instance. + * + * @return void + */ + public function handle(ItemsEvent $event) + { + $response = $event->target; + if (! $response instanceof SearchResponseInterface) { + return; + } + $event->stopPropagation(); + + $event->items = $response->getDocuments(); + $event->count = $response->getTotalCount(); + } +} diff --git a/src/AppBundle/Exception/LimitExceedException.php b/src/AppBundle/Exception/LimitExceedException.php new file mode 100644 index 0000000..3db261d --- /dev/null +++ b/src/AppBundle/Exception/LimitExceedException.php @@ -0,0 +1,114 @@ +getId(), + $appLimit->getValue(), + $currValue, + $requested, + $max + )); + + $this->user = $user; + $this->limit = $appLimit; + $this->currValue = $currValue; + $this->requested = $requested; + $this->max = $max; + } + + /** + * @return User + */ + public function getUser() + { + return $this->user; + } + + /** + * @return AppLimitEnum + */ + public function getLimit() + { + return $this->limit; + } + + /** + * @return integer + */ + public function getCurrValue() + { + return $this->currValue; + } + + /** + * @return integer + */ + public function getRequested() + { + return $this->requested; + } + + /** + * @return integer + */ + public function getMax() + { + return $this->max; + } +} diff --git a/src/AppBundle/Exception/NotAllowedException.php b/src/AppBundle/Exception/NotAllowedException.php new file mode 100644 index 0000000..ee4b383 --- /dev/null +++ b/src/AppBundle/Exception/NotAllowedException.php @@ -0,0 +1,61 @@ +getId(), + $appPermission->getValue() + )); + + $this->user = $user; + $this->permission = $appPermission; + } + + /** + * @return User + */ + public function getUser() + { + return $this->user; + } + + /** + * @return AppPermissionEnum + */ + public function getPermission() + { + return $this->permission; + } +} diff --git a/src/AppBundle/Form/AbstractConnectionAwareType.php b/src/AppBundle/Form/AbstractConnectionAwareType.php new file mode 100644 index 0000000..4593153 --- /dev/null +++ b/src/AppBundle/Form/AbstractConnectionAwareType.php @@ -0,0 +1,64 @@ +index = $index; + $this->perPage = $perPage; + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefault('data_class', SearchRequestBuilderInterface::class); + } + + /** + * Create initial search request builder + * + * @return \IndexBundle\SearchRequest\SearchRequestBuilderInterface + */ + protected function createSearchRequestBuilder() + { + return $this->index + ->createRequestBuilder() + ->setLimit($this->perPage); + } +} diff --git a/src/AppBundle/Form/EmailedDocumentType.php b/src/AppBundle/Form/EmailedDocumentType.php new file mode 100644 index 0000000..ca92ece --- /dev/null +++ b/src/AppBundle/Form/EmailedDocumentType.php @@ -0,0 +1,57 @@ +add('emailTo', CollectionType::class, [ + 'entry_type' => EmailType::class, + 'allow_add' => true, + ]) + ->add('emailReplyTo') + ->add('subject', null, [ 'required' => false ]) + ->add('content'); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefault('data_class', EmailedDocument::class); + } +} diff --git a/src/AppBundle/Form/Factory/FilterFactoryAwareTypeFactory.php b/src/AppBundle/Form/Factory/FilterFactoryAwareTypeFactory.php new file mode 100644 index 0000000..ae2d26b --- /dev/null +++ b/src/AppBundle/Form/Factory/FilterFactoryAwareTypeFactory.php @@ -0,0 +1,61 @@ +index = $index; + $this->perPage = $perPage; + } + + /** + * Create search type instance. + * + * @param string $class Concrete form fqcn. + * + * @return AbstractConnectionAwareType + */ + public function create($class) + { + $form = new $class($this->index, $this->perPage); + + if (! $form instanceof AbstractConnectionAwareType) { + $message = 'Invalid form class, expects: ' + . AbstractConnectionAwareType::class; + throw new \InvalidArgumentException($message); + } + + return $form; + } +} diff --git a/src/AppBundle/Form/FeedDocumentSearchType.php b/src/AppBundle/Form/FeedDocumentSearchType.php new file mode 100644 index 0000000..bc26b71 --- /dev/null +++ b/src/AppBundle/Form/FeedDocumentSearchType.php @@ -0,0 +1,90 @@ +add('page', null, [ + 'description' => 'Requested page number. Default value is 1.', + 'empty_data' => 1, + ]) + // Collection of available advanced filters. + ->add('advancedFilters', AdvancedFiltersType::class, [ + 'description' => 'Advanced filters.', + 'config' => AdvancedFiltersConfig::getConfig(AFSourceEnum::FEED), + 'empty_data' => [], + 'connection' => $this->index, + 'required' => false, + ]) + ->setEmptyData($this->createSearchRequestBuilder()) + ->setDataMapper($this); + } + + /** + * Maps properties of some data to a list of forms. + * + * @param mixed $data Structured data. + * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of + * {@link FormInterface} + * instances. + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function mapDataToForms($data, $forms) + { + // Do nothing because it's senseless. + } + + /** + * Maps the data of a list of forms into the properties of some data. + * + * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of + * {@link FormInterface} + * instances. + * @param mixed|SearchRequestBuilderInterface $data Structured data. + * + * @return void + */ + public function mapFormsToData($forms, &$data) + { + $forms = iterator_to_array($forms); + + $data + ->setPage($forms['page']->getData()) + ->setFilters($forms['advancedFilters']->getData()); + } +} diff --git a/src/AppBundle/Form/FeedType.php b/src/AppBundle/Form/FeedType.php new file mode 100644 index 0000000..51442f9 --- /dev/null +++ b/src/AppBundle/Form/FeedType.php @@ -0,0 +1,58 @@ +add('feed', FeedInfoType::class, [ + 'constraints' => new Valid(), + ]) + ->add('search', StoredQuerySearchRequestType::class) + ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) { + $data = $event->getData(); + $form = $event->getForm()->get('search'); + + if (! isset($data['feed']['subType'])) { + return; + } + + if ($data['feed']['subType'] === ClipFeed::getSubType()) { + $form + ->remove('query') + ->remove('filters'); + } + }); + } +} diff --git a/src/AppBundle/Form/SearchRequest/AbstractSearchRequestType.php b/src/AppBundle/Form/SearchRequest/AbstractSearchRequestType.php new file mode 100644 index 0000000..8c68435 --- /dev/null +++ b/src/AppBundle/Form/SearchRequest/AbstractSearchRequestType.php @@ -0,0 +1,177 @@ + [ + 'type' => QueryFilter\HeadlineFilterType::class, + 'description' => 'Addition title filtering', + ], + 'publisher' => [ + 'type' => QueryFilter\PublisherFilterType::class, + 'description' => 'Filter by publisher type.', + ], + 'source' => [ + 'type' => QueryFilter\SourceFilterType::class, + 'description' => 'Filter by source.', + ], + 'sourceList' => [ + 'type' => QueryFilter\SourceListFilterType::class, + 'description' => 'Filter by source lists.', + ], + 'language' => [ + 'type' => QueryFilter\LanguageFilterType::class, + 'description' => 'Filter by language, use ISO 639-1 two-letters codes.', + ], + 'country' => [ + 'type' => QueryFilter\CountryFilterType::class, + 'description' => 'Filter by countries, ISO 3166-1 Alpha-2 two-letters codes.', + ], + 'state' => [ + 'type' => QueryFilter\StateFilterType::class, + 'description' => 'Filter by US states, ANSI standard INCITS 38:2009 two-letters codes.', + ], + 'date' => [ + 'type' => QueryFilter\DateFilterType::class, + 'description' => 'Filter by date, may be two types', + ], + 'hasImage' => [ + 'type' => QueryFilter\HasImageFilterType::class, + 'description' => 'Boolean flag, if true get document only with image.', + ], + ]; + + /** + * Builds the form. + * + * This method is called for each type in the hierarchy starting from the + * top most type. Type extensions can further modify the form. + * + * @see FormTypeExtensionInterface::buildForm() + * + * @param FormBuilderInterface $builder The form builder. + * @param array $options The options. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + // Raw search query typed by user. + ->add('query', null, [ + 'constraints' => [ + new NotBlank(), + new Length([ 'min' => 3 ]), + ], + 'required' => true, + 'description' => 'Search query.', + ]) + + // Collection of available filters. + ->add('filters', FiltersType::class, [ + 'filter_factory' => $this->index->getFilterFactory(), + 'description' => 'Search filters.', + 'empty_data' => [], + 'filters' => self::$filters, + 'required' => false, + ]) + + // Collection of available advanced filters. + ->add('advancedFilters', AdvancedFiltersType::class, [ + 'description' => 'Advanced filters.', + 'config' => AdvancedFiltersConfig::getConfig(AFSourceEnum::FEED), + 'empty_data' => [], + 'connection' => $this->index, + 'required' => false, + ]) + ->setEmptyData($this->createSearchRequestBuilder()) + ->setDataMapper($this); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'data_class' => SearchRequestBuilderInterface::class, + 'key' => 'createFeed', + ]); + } + + /** + * Maps properties of some data to a list of forms. + * + * @param mixed $data Structured data. + * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of + * {@link FormInterface} + * instances. + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function mapDataToForms($data, $forms) + { + // Do nothing because it's senseless. + } + + /** + * Maps the data of a list of forms into the properties of some data. + * + * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of + * {@link FormInterface} + * instances. + * @param mixed|SearchRequestBuilderInterface $data Structured data. + * + * @return void + */ + public function mapFormsToData($forms, &$data) + { + $forms = iterator_to_array($forms); + + if (isset($forms['query'])) { + $data->setQuery($forms['query']->getData()); + } + + $filters = $forms['advancedFilters']->getData() ?: []; + if (isset($forms['filters'])) { + $filters = array_merge($filters, $forms['filters']->getData()); + } + $data->setFilters($filters); + } +} diff --git a/src/AppBundle/Form/SearchRequest/ClipFeedSearchRequestType.php b/src/AppBundle/Form/SearchRequest/ClipFeedSearchRequestType.php new file mode 100644 index 0000000..3cdf3a2 --- /dev/null +++ b/src/AppBundle/Form/SearchRequest/ClipFeedSearchRequestType.php @@ -0,0 +1,72 @@ +remove('query') + ->remove('filters'); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'cascade_validation' => true, + 'key' => 'search', + ]); + } + + /** + * Maps the data of a list of forms into the properties of some data. + * + * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of + * {@link FormInterface} + * instances. + * @param mixed|SearchRequestBuilderInterface $data Structured data. + * + * @return void + */ + public function mapFormsToData($forms, &$data) + { + $forms = iterator_to_array($forms); + + $data->setFilters($forms['advancedFilters']->getData()); + } +} diff --git a/src/AppBundle/Form/SearchRequest/SimpleQuerySearchRequestType.php b/src/AppBundle/Form/SearchRequest/SimpleQuerySearchRequestType.php new file mode 100644 index 0000000..917ad1b --- /dev/null +++ b/src/AppBundle/Form/SearchRequest/SimpleQuerySearchRequestType.php @@ -0,0 +1,76 @@ +add('page', null, [ + 'description' => 'Requested page number. Default value is 1.', + 'empty_data' => 1, + ]); + parent::buildForm($builder, $options); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'cascade_validation' => true, + 'key' => 'search', + ]); + } + + /** + * Maps the data of a list of forms into the properties of some data. + * + * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of + * {@link FormInterface} + * instances. + * @param mixed|SearchRequestBuilderInterface $data Structured data. + * + * @return void + */ + public function mapFormsToData($forms, &$data) + { + parent::mapFormsToData($forms, $data); + + $forms = iterator_to_array($forms); + $data->setPage($forms['page']->getData()); + } +} diff --git a/src/AppBundle/Form/SearchRequest/StoredQuerySearchRequestType.php b/src/AppBundle/Form/SearchRequest/StoredQuerySearchRequestType.php new file mode 100644 index 0000000..1128778 --- /dev/null +++ b/src/AppBundle/Form/SearchRequest/StoredQuerySearchRequestType.php @@ -0,0 +1,27 @@ +setDefault('key', 'createFeed'); + } +} diff --git a/src/AppBundle/Form/Transformer/OnlyReverseTransformer.php b/src/AppBundle/Form/Transformer/OnlyReverseTransformer.php new file mode 100644 index 0000000..6a96644 --- /dev/null +++ b/src/AppBundle/Form/Transformer/OnlyReverseTransformer.php @@ -0,0 +1,25 @@ +included = (array) $included; + $this->excluded = (array) $excluded; + } + + /** + * @param string $value Additional query. + * + * @return AdvancedFilterParameters + */ + public static function queryFilterParameters($value) + { + // @codingStandardsIgnoreStart + return new static([ $value ], []); + // @codingStandardsIgnoreEnd + } + + /** + * Get array of values which MUST be present in the search result. + * + * @return string[] + */ + public function getIncluded() + { + return $this->included; + } + + /** + * Get array of values which MUST NOT be present in the search result. + * + * @return string[] + */ + public function getExcluded() + { + return $this->excluded; + } + + /** + * @param array $names Array of used field names. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return FilterInterface + */ + public function createQueryFilter(array $names, FilterFactoryInterface $factory) + { + return $factory->orX(array_map(function ($name) use ($factory) { + return $factory->eq($name, current($this->included)); + }, $names)); + } + + /** + * Create filter for range advanced filter. + * + * @param string $name A Field name. + * @param array $ranges Available field ranges. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return FilterInterface + */ + public function createRangeFilter($name, array $ranges, FilterFactoryInterface $factory) + { + $availables = array_keys($ranges); + (($value = current($this->included)) !== false) || ($value = current($this->excluded)); + + if (! in_array($value, $availables, true)) { + throw new \InvalidArgumentException(sprintf( + 'Invalid value \'%s\'. Expects one of %s.', + $value, + implode(', ', $availables) + )); + } + + // + // Get start and end bound for given value. + // + $start = $ranges[$value]['from']; + $end = isset($ranges[$value]['to']) ? $ranges[$value]['to'] : null; + + // Firstly create 'gte' filter. + $filter = $factory->gte($name, $start); + + if ($end !== null) { + // + // We have end bound, so we should use 'lte' filter and wrap + // both into 'andX'. + // + $filter = $factory->andX([ + $filter, + $factory->lte($name, $end), + ]); + } + + return $filter; + } + + /** + * Create filter for simple advanced filter. + * + * @param string $name A Field name. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return FilterInterface|null + */ + public function createSimpleFilter($name, FilterFactoryInterface $factory) + { + $eqFactory = \nspl\f\partial([ $factory, 'eq' ], $name); + $eqFilters = \nspl\a\map($eqFactory, $this->included); + + // + // If we got some positive statements we should use only them. + // + if (count($eqFilters) > 0) { + return $factory->orX($eqFilters); + } + + $neqFactory = \nspl\f\compose( + [ $factory, 'not' ], + $eqFactory + ); + $neqFilters = \nspl\a\map($neqFactory, $this->excluded); + + if (count($neqFilters) > 0) { + return $factory->andX($neqFilters); + } + + return null; + } +} diff --git a/src/AppBundle/Form/Type/AdvancedFilter/AdvancedFilterType.php b/src/AppBundle/Form/Type/AdvancedFilter/AdvancedFilterType.php new file mode 100644 index 0000000..fc9b971 --- /dev/null +++ b/src/AppBundle/Form/Type/AdvancedFilter/AdvancedFilterType.php @@ -0,0 +1,107 @@ +getData(); + if ($values instanceof AdvancedFilterParameters) { + $values = $event->getForm()->getExtraData(); + } + + $include = []; + $exclude = []; + + switch (true) { + case is_string($values): + $data = AdvancedFilterParameters::queryFilterParameters($values); + break; + + case is_array($values): + if (count($choices) > 0) { + // Range type. + $include = key($values); + } else { + // Simple type. + foreach ($values as $value => $type) { + switch ($type) { + case 1: + $include[] = $value; + break; + + case -1: + $exclude[] = $value; + break; + + default: + throw new \RuntimeException('Invalid value type, should be 1 or -1'); + } + } + } + $data = new AdvancedFilterParameters($include, $exclude); + break; + + default: + $event->getForm()->addError(new FormError('Invalid value, expects object or string')); + return; + } + + $event->setData($data); + }; + + $builder->addEventListener(FormEvents::SUBMIT, $transformer); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => AdvancedFilterParameters::class, + 'empty_data' => new AdvancedFilterParameters([], []), + 'allow_extra_fields' => true, + 'choices' => [], + 'compound' => true, + ]); + } +} diff --git a/src/AppBundle/Form/Type/AdvancedFiltersType.php b/src/AppBundle/Form/Type/AdvancedFiltersType.php new file mode 100644 index 0000000..d9a06f0 --- /dev/null +++ b/src/AppBundle/Form/Type/AdvancedFiltersType.php @@ -0,0 +1,118 @@ + $params) { + $parameters = []; + + if ($params['type'] === AFTypeEnum::RANGE) { + $parameters['choices'] = array_keys($params['ranges']); + } elseif ($params['type'] === AFTypeEnum::QUERY) { + $parameters['compound'] = false; + } + + $parameters['description'] = $params['description']; + $parameters['constraints'] = new NotBlank(); + + $builder->add($name, AdvancedFilterType::class, $parameters); + } + + /** + * Transform filter names and values to concrete filters. + * + * @param array $filters Array of advanced filters values. + * + * @return \IndexBundle\Filter\FilterInterface[] + */ + $transformationFn = function (array $filters) use ($index, $config) { + $resolver = $index->getAFResolver(); + $resolvedFilters = []; + + /** @var AdvancedFilterParameters $params */ + foreach ($filters as $name => $params) { + try { + $resolvedFilters[] = $resolver->generateFilter($config, $name, $params); + } catch (\Exception $exception) { + throw new TransformationFailedException($exception->getMessage(), $exception->getCode(), $exception); + } + } + + return $resolvedFilters; + }; + + $builder + ->addEventListener(FormEvents::PRE_SUBMIT, [ $this, 'clean' ]) + ->addModelTransformer(new OnlyReverseTransformer($transformationFn)); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver + ->setRequired('connection') + ->setRequired('config') + ->setAllowedTypes('connection', IndexInterface::class) + ->setAllowedTypes('config', 'array') + ->setDefaults([ + 'allow_extra_fields' => true, // We handle this situation in pre + // submit listener and make more + // detailed error message. + 'connection' => null, + ]); + } +} diff --git a/src/AppBundle/Form/Type/EnumType.php b/src/AppBundle/Form/Type/EnumType.php new file mode 100644 index 0000000..b41f9ed --- /dev/null +++ b/src/AppBundle/Form/Type/EnumType.php @@ -0,0 +1,85 @@ +addModelTransformer(new OnlyReverseTransformer(function ($value) use ($class) { + if ($value === null) { + return null; + } + + return new $class($value); + })); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver + ->setRequired('enum_class') + ->setAllowedTypes('enum_class', 'string') + ->setDefault('choices', function (Options $options) { + $class = $options['enum_class']; + + if (! class_exists($class)) { + throw new \InvalidArgumentException('Can\'t find class: '. $class); + } + + $reflection = new \ReflectionClass($class); + + if ($reflection->isSubclassOf(AbstractEnum::class)) { + return $class::getAvailables(); + } + + return []; + }); + } + + /** + * Returns the name of the parent type. + * + * @return string|null The name of the parent type if any, null otherwise. + */ + public function getParent() + { + return ChoiceType::class; + } +} diff --git a/src/AppBundle/Form/Type/Extension/LocalizationTypeExtension.php b/src/AppBundle/Form/Type/Extension/LocalizationTypeExtension.php new file mode 100644 index 0000000..1f5e9d3 --- /dev/null +++ b/src/AppBundle/Form/Type/Extension/LocalizationTypeExtension.php @@ -0,0 +1,56 @@ +setDefault('key', new RecursiveTransKeyGenerator()) + ->addAllowedTypes('key', [ 'string', TransKeyGeneratorInterface::class ]) + ->setNormalizer('key', function (Options $options, $key) { + // + // We should insure that key is always be an instance of trans key + // generator. + // + if (is_string($key)) { + $key = new ConstTransKeyGenerator($key); + } + + return $key; + }); + } + + /** + * Returns the name of the type being extended. + * + * @return string The name of the type being extended. + */ + public function getExtendedType() + { + return FormType::class; + } +} diff --git a/src/AppBundle/Form/Type/Filter/AbstractFilterType.php b/src/AppBundle/Form/Type/Filter/AbstractFilterType.php new file mode 100644 index 0000000..9a2f78d --- /dev/null +++ b/src/AppBundle/Form/Type/Filter/AbstractFilterType.php @@ -0,0 +1,90 @@ +addModelTransformer(new OnlyReverseTransformer(function ($value) use ($factory) { + return $this->transform($value, $factory); + })) + ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) { + $this->preSubmit($event); + }); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver + ->setRequired('filter_factory') + ->setAllowedTypes('filter_factory', FilterFactoryInterface::class); + } + + /** + * Make some manipulation with form and data before submit. + * + * @param FormEvent $event A FormEvent instance. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function preSubmit(FormEvent $event) + { + // do nothing. + } + + /** + * Transform input values into proper filters. + * + * @param mixed $value Value to be transformed. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return \IndexBundle\Filter\FilterInterface|null + */ + abstract protected function transform($value, FilterFactoryInterface $factory); +} diff --git a/src/AppBundle/Form/Type/Filter/CountryFilterType.php b/src/AppBundle/Form/Type/Filter/CountryFilterType.php new file mode 100644 index 0000000..21ccfc2 --- /dev/null +++ b/src/AppBundle/Form/Type/Filter/CountryFilterType.php @@ -0,0 +1,80 @@ +add('include', ChoiceType::class, [ + 'choices' => CountryEnum::getAvailables(), + 'multiple' => true, + 'description' => 'Get document within specified countries', + ]) + ->add('exclude', ChoiceType::class, [ + 'choices' => CountryEnum::getAvailables(), + 'multiple' => true, + 'description' => 'Get document not within specified countries', + ]); + } + + /** + * Transform input values into proper filters. + * + * @param mixed $value Value to be transformed. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return \IndexBundle\Filter\FilterInterface|null + */ + protected function transform($value, FilterFactoryInterface $factory) + { + if (! isset($value['include'], $value['exclude'])) { + // + // One of value is not valid, don't make any transformations. + // + return null; + } + + $include = $value['include']; + $exclude = $value['exclude']; + + $condition = $factory->andX(); + + if (count($include) > 0) { + $condition->add($factory->in(FieldNameEnum::COUNTRY, $include)); + } + + if (count($exclude) > 0) { + $condition->add($factory->not($factory->in(FieldNameEnum::COUNTRY, $exclude))); + } + + return $condition; + } +} diff --git a/src/AppBundle/Form/Type/Filter/DateFilterType.php b/src/AppBundle/Form/Type/Filter/DateFilterType.php new file mode 100644 index 0000000..0cfc389 --- /dev/null +++ b/src/AppBundle/Form/Type/Filter/DateFilterType.php @@ -0,0 +1,215 @@ +add('type', ChoiceType::class, [ + 'choices' => [ 'last', 'between' ], + 'description' => 'Date filter type.', + ]) + ->add('days', IntegerType::class, [ + 'constraints' => [ + new GreaterThan([ + 'value' => 0, + 'message' => 'This value should be integer greater than 0.', + ]), + new NotBlank(), + ], + 'invalid_message' => 'This value should be integer greater than 0.', + 'description' => 'How many days ago document found. Used only for \'last\' type.', + ]) + ->add('start', DateType::class, [ + 'widget' => 'single_text', + 'input' => 'string', + 'description' => 'Start of searched period, format: \'YYYY-MM-DD\'. Used only for \'between\' type.', + 'constraints' => new NotBlank(), + ]) + ->add('end', DateType::class, [ + 'widget' => 'single_text', + 'input' => 'string', + 'description' => 'End of searched period, format: \'YYYY-MM-DD\'. Used only for \'between\' type.', + 'constraints' => new NotBlank(), + ]); + } + + /** + * Change form based on selected type. + * + * @param FormEvent $event A FormEvent instance. + * + * @return void + */ + protected function preSubmit(FormEvent $event) + { + $data = $event->getData(); + $form = $event->getForm(); + + switch (true) { + // + // Stop processing because type not set. + // Remove all fields to avoid unnecessary validation errors. + // + case ! isset($data['type']) || (($data['type'] !== 'last') + && ($data['type'] !== 'between')): + $form + ->remove('days') + ->remove('start') + ->remove('end'); + break; + + // + // For last 'type' we should remove 'start' and 'end' field because + // its unnecessary. + // + case $data['type'] === 'last': + $form + ->remove('start') + ->remove('end'); + break; + + // + // Make additional validation for 'between' type. + // + // Try to convert 'start' and 'end' values into datetime instances + // and check that 'start' not greater than 'end' and if it so add + // proper error message to 'start' field. + // + default: + if (isset($data['start'], $data['end'])) { + $start = date_create_from_format('Y-m-d', $data['start'])->setTime(0, 0); + $end = date_create_from_format('Y-m-d', $data['end'])->setTime(0, 0); + + if ((($start !== false) && ($end !== false)) && ($start > $end)) { + $form->addError(new FormError( + '\'start\' value should be less than \'end\'.' + )); + } + } + + $form->remove('days'); + } + } + + /** + * Transform input values into proper filters. + * + * @param mixed $value Value to be transformed. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return \IndexBundle\Filter\FilterInterface|null + */ + protected function transform($value, FilterFactoryInterface $factory) + { + // + // Don't make any transformations if 'type' is not set. + // + if (! is_array($value) || ! isset($value['type'])) { + return null; + } + + // + // Because transformation occurred before validation we should check all + // values and if some of them is invalid we just return null. + // + // All validation error messages will be added in form validation + // listener so we should'nt worry about it. + // + + switch ($value['type']) { + case 'last': + return $this->transformLast($value, $factory); + + case 'between': + return $this->transformBetween($value, $factory); + } + + throw new \LogicException(sprintf( + 'Unhandled date filter type \'%s\'', + $value['type'] + )); + } + + /** + * @param array $value Date filter value. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return \IndexBundle\Filter\Filters\AndFilter|null + */ + private function transformLast(array $value, FilterFactoryInterface $factory) + { + if (! isset($value['days']) || ($value['days'] < 0)) { + return null; + } + + return $factory->andX([ + $factory->gte(FieldNameEnum::PUBLISHED, date_create(sprintf( + '- %d days 00:00:00', + $value['days'] + ))), + $factory->lte(FieldNameEnum::PUBLISHED, date_create('23:59:59')), + ]); + } + + /** + * @param array $value Date filter value. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return \IndexBundle\Filter\Filters\AndFilter|null + */ + private function transformBetween(array $value, FilterFactoryInterface $factory) + { + if (! isset($value['start'], $value['end'])) { + return null; + } + + // Try to create datetime instances from 'start' and 'end' fields and + // if we got error we should stop further transformations and return + // null. + $start = date_create_from_format('Y-m-d', $value['start'])->setTime(0, 0); + $end = date_create_from_format('Y-m-d', $value['end'])->setTime(23, 59, 59); + + if (($start === false) || ($end === false)) { + return null; + } + + return $factory->andX([ + $factory->gte(FieldNameEnum::PUBLISHED, $start), + $factory->lte(FieldNameEnum::PUBLISHED, $end), + ]); + } +} diff --git a/src/AppBundle/Form/Type/Filter/HasImageFilterType.php b/src/AppBundle/Form/Type/Filter/HasImageFilterType.php new file mode 100644 index 0000000..10352ae --- /dev/null +++ b/src/AppBundle/Form/Type/Filter/HasImageFilterType.php @@ -0,0 +1,64 @@ +getData()); + + if (($data !== '0') && ($data !== '') && ($data !== '1')) { + $event->getForm()->addError( + new FormError('This value should be of type boolean.') + ); + } + } + + /** + * Transform input values into proper filters. + * + * @param mixed $value Value to be transformed. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return \IndexBundle\Filter\FilterInterface|null + */ + protected function transform($value, FilterFactoryInterface $factory) + { + return $value === true + ? $factory->eq(FieldNameEnum::IMAGE_SRC, '/.+/') + : null; + } +} diff --git a/src/AppBundle/Form/Type/Filter/HeadlineFilterType.php b/src/AppBundle/Form/Type/Filter/HeadlineFilterType.php new file mode 100644 index 0000000..cbf04a2 --- /dev/null +++ b/src/AppBundle/Form/Type/Filter/HeadlineFilterType.php @@ -0,0 +1,74 @@ +add('include', null, [ + 'description' => 'Comma separated list of words which should be in title.', + ]) + ->add('exclude', null, [ + 'description' => 'Comma separated list of words which should not be in title.', + ]); + } + + /** + * Transform input values into proper filters. + * + * @param mixed $value Value to be transformed. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return \IndexBundle\Filter\FilterInterface|null + */ + protected function transform($value, FilterFactoryInterface $factory) + { + if (! isset($value['include']) && !isset($value['exclude'])) { + // + // One of value is not valid, don't make any transformations. + // + return null; + } + + $include = array_filter(array_map('trim', explode(',', $value['include']))); + $exclude = array_filter(array_map('trim', explode(',', $value['exclude']))); + + $condition = $factory->andX(); + + if (count($include) > 0) { + $condition->add($factory->in(FieldNameEnum::TITLE, $include)); + } + + if (count($exclude) > 0) { + $condition->add($factory->not($factory->in(FieldNameEnum::TITLE, $exclude))); + } + + return $condition; + } +} diff --git a/src/AppBundle/Form/Type/Filter/LanguageFilterType.php b/src/AppBundle/Form/Type/Filter/LanguageFilterType.php new file mode 100644 index 0000000..e0700f1 --- /dev/null +++ b/src/AppBundle/Form/Type/Filter/LanguageFilterType.php @@ -0,0 +1,61 @@ +setDefaults([ + 'choices' => LanguageEnum::getAvailables(), + 'multiple' => true, + ]); + } + + /** + * Returns the name of the parent type. + * + * @return string|null The name of the parent type if any, null otherwise. + */ + public function getParent() + { + return ChoiceType::class; + } + + /** + * Transform input values into proper filters. + * + * @param mixed $value Value to be transformed. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return \IndexBundle\Filter\FilterInterface|null + */ + protected function transform($value, FilterFactoryInterface $factory) + { + if (! is_array($value) || (count($value) === 0)) { + return null; + } + + return $factory->in(FieldNameEnum::LANG, $value); + } +} diff --git a/src/AppBundle/Form/Type/Filter/PublisherFilterType.php b/src/AppBundle/Form/Type/Filter/PublisherFilterType.php new file mode 100644 index 0000000..7bc69f6 --- /dev/null +++ b/src/AppBundle/Form/Type/Filter/PublisherFilterType.php @@ -0,0 +1,89 @@ +add('source', ChoiceType::class, [ + 'choices' => PublisherTypeEnum::getAvailables(), + 'multiple' => true, + 'description' => 'Get document within specified publisher', + ]) + ->add('domain', ChoiceType::class, [ + 'choices' => ['Reddit' => 'reddit.com', + 'Twitter' => 'twitter.com', + 'Instagram' => 'instagram.com', + 'Flickr' => 'flickr.com', + 'Youtube' => 'youtube.com', + 'vimeo' => 'vimeo.com'], + 'multiple' => true, + 'description' => 'Get document not within specified domain', + ]); + } + + + /** + * Transform input values into proper filters. + * + * @param mixed $value Value to be transformed. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return \IndexBundle\Filter\FilterInterface|null + */ + protected function transform($value, FilterFactoryInterface $factory) + { + if (count($value['source']) > 0 && count($value['domain']) > 0) { + $condition = $factory->orX([ + $factory->in(FieldNameEnum::SOURCE_PUBLISHER_TYPE, + $value['source']), + + $factory->orX($factory->in(FieldNameEnum::DOMAIN, $value['domain'])), + ]); + return $condition; + } else { + if (count($value['source']) > 0) { + return $factory->in( + FieldNameEnum::SOURCE_PUBLISHER_TYPE, + $value['source'] + ); + } else { + return $factory->in( + FieldNameEnum::DOMAIN, + $value['domain'] + ); + } + + } + + return null; + } +} diff --git a/src/AppBundle/Form/Type/Filter/SourceFilterType.php b/src/AppBundle/Form/Type/Filter/SourceFilterType.php new file mode 100644 index 0000000..d95b970 --- /dev/null +++ b/src/AppBundle/Form/Type/Filter/SourceFilterType.php @@ -0,0 +1,105 @@ +add('type', ChoiceType::class, [ + 'choices' => [ 'include', 'exclude' ], + 'description' => 'Source filter type.', + ]) + ->add('ids', CollectionType::class, [ + 'entry_type' => TextType::class, + 'allow_add' => true, + 'constraints' => new Count([ + 'min' => 1, + 'minMessage' => 'Expects at least one source title.', + ]), + 'description' => 'Array of source\'s id\'s', + ]); + } + + /** + * Make some manipulation with form and data before submit. + * + * @param FormEvent $event A FormEvent instance. + * + * @return void + */ + protected function preSubmit(FormEvent $event) + { + $data = $event->getData(); + $form = $event->getForm(); + + // Remove 'sources' field to avoid unnecessary validation errors if + // client not provide 'type' or provide invalid. + if (! isset($data['type']) + || (($data['type'] !== 'include') && ($data['type'] !== 'exclude'))) { + $form->remove('ids'); + } + } + + /** + * Transform input values into proper filters. + * + * @param mixed $value Value to be transformed. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return \IndexBundle\Filter\FilterInterface|null + */ + protected function transform($value, FilterFactoryInterface $factory) + { + // + // Don't make any transformation if 'type' is not provided or it's + // invalid or 'sources' not provided or it's not an array. + // + // All validation errors will be added in form ValidationListener. + // + if (! isset($value['type'], $value['ids']) + || ! is_array($value['ids']) + || (($value['type'] !== 'include') && ($value['type'] !== 'exclude')) + ) { + return null; + } + + // + // $value['sources'] is array of source's id's assigned from index. + // We use 'source_hashcode' field for it. + // + $filter = $factory->in(FieldNameEnum::SOURCE_HASHCODE, $value['ids']); + + return ($value['type'] === 'exclude') ? $factory->not($filter) : $filter; + } +} diff --git a/src/AppBundle/Form/Type/Filter/SourceListFilterType.php b/src/AppBundle/Form/Type/Filter/SourceListFilterType.php new file mode 100644 index 0000000..7840df0 --- /dev/null +++ b/src/AppBundle/Form/Type/Filter/SourceListFilterType.php @@ -0,0 +1,129 @@ +add('include', CollectionType::class, [ + 'entry_type' => CurrentUserOwnedEntityType::class, + 'entry_options' => [ 'class' => SourceList::class ], + 'allow_add' => true, + 'description' => 'Array of source list entities ids.', + ]) + ->add('exclude', CollectionType::class, [ + 'entry_type' => CurrentUserOwnedEntityType::class, + 'entry_options' => [ 'class' => SourceList::class ], + 'allow_add' => true, + 'description' => 'Array of source list entities ids.', + ]); + } + + /** + * Custom validations. + * + * @param FormEvent $event A FormEvent instance. + * + * @return void + */ + protected function preSubmit(FormEvent $event) + { + $data = $event->getData(); + + if (count($data) === 0) { + $event->getForm()->addError( + new FormError('Provide at least one of \'include\' or \'exclude\' parameter.') + ); + } + } + + /** + * Transform SourceList entities into array of source titles. + * + * @param array $sourceLists Array of SourceList entities. + * + * @return array + */ + protected function getIds(array $sourceLists) + { + $listTitles = array_map(function (SourceList $list) { + return array_map(function (SourceToSourceList $source) { + return $source->getSource(); + }, $list->getSources()->toArray()); + }, $sourceLists); + + if (count($listTitles) === 0) { + // To avoid passing empty arguments into 'array_merge' function. + return []; + } + + return call_user_func_array('array_merge', $listTitles); + } + + /** + * Transform input values into proper filters. + * + * @param mixed $value Value to be transformed. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return \IndexBundle\Filter\FilterInterface|null + */ + protected function transform($value, FilterFactoryInterface $factory) + { + $include = $this->getIds($value['include']); + $exclude = $this->getIds($value['exclude']); + + $includeCount = count($include); + $excludeCount = count($exclude); + + if (($includeCount === 0) && ($excludeCount === 0)) { + // Don't make any transformations if client not provide any + // parameters. + return null; + } + + $condition = $factory->andX(); + + if ($includeCount > 0) { + $condition->add($factory->in(FieldNameEnum::SOURCE_HASHCODE, $include)); + } + + if ($excludeCount > 0) { + $condition->add($factory->not($factory->in(FieldNameEnum::SOURCE_HASHCODE, $exclude))); + } + + return $condition; + } +} diff --git a/src/AppBundle/Form/Type/Filter/StateFilterType.php b/src/AppBundle/Form/Type/Filter/StateFilterType.php new file mode 100644 index 0000000..0527142 --- /dev/null +++ b/src/AppBundle/Form/Type/Filter/StateFilterType.php @@ -0,0 +1,80 @@ +add('include', ChoiceType::class, [ + 'choices' => StateEnum::getAvailables(), + 'multiple' => true, + 'description' => 'Get document within specified states', + ]) + ->add('exclude', ChoiceType::class, [ + 'choices' => StateEnum::getAvailables(), + 'multiple' => true, + 'description' => 'Get document not within specified states', + ]); + } + + /** + * Transform input values into proper filters. + * + * @param mixed $value Value to be transformed. + * @param FilterFactoryInterface $factory A FilterFactoryInterface instance. + * + * @return \IndexBundle\Filter\FilterInterface|null + */ + protected function transform($value, FilterFactoryInterface $factory) + { + if (! isset($value['include'], $value['exclude'])) { + // One of value is not valid, don't make any transformations. + return null; + } + + $include = $value['include']; + $exclude = $value['exclude']; + + $condition = $factory->andX(); + + if (count($include) > 0) { + $condition->add($factory->in(FieldNameEnum::STATE, $include)); + } + + if (count($exclude) > 0) { + $condition->add($factory->not( + $factory->in(FieldNameEnum::STATE, $exclude) + )); + } + + return $condition; + } +} diff --git a/src/AppBundle/Form/Type/FiltersType.php b/src/AppBundle/Form/Type/FiltersType.php new file mode 100644 index 0000000..74d3997 --- /dev/null +++ b/src/AppBundle/Form/Type/FiltersType.php @@ -0,0 +1,87 @@ + $params) { + $builder->add($name, $params['type'], [ + 'filter_factory' => $options['filter_factory'], + 'description' => $params['description'], + ]); + } + + /** + * Normalize filters. + * + * @param FormEvent $event A FormEvent instance. + * + * @return void + */ + $postSubmit = function (FormEvent $event) { + $filters = $event->getData(); + + if (count($filters)) { + $filters = array_values(array_filter($filters)); + $event->setData($filters); + } + }; + + $builder + ->addEventListener(FormEvents::PRE_SUBMIT, [ $this, 'clean' ]) + ->addEventListener(FormEvents::POST_SUBMIT, $postSubmit); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + $resolver + ->setRequired([ 'filter_factory', 'filters' ]) + ->setAllowedTypes('filter_factory', FilterFactoryInterface::class) + ->setAllowedTypes('filters', 'array') + ->setDefault('cascade_validation', true); + } +} diff --git a/src/AppBundle/Form/Type/Traits/CleanFormTrait.php b/src/AppBundle/Form/Type/Traits/CleanFormTrait.php new file mode 100644 index 0000000..c86ecef --- /dev/null +++ b/src/AppBundle/Form/Type/Traits/CleanFormTrait.php @@ -0,0 +1,69 @@ +getData(); + $form = $event->getForm(); + + $available = array_keys(iterator_to_array($form)); + + if (is_array($data)) { + $exists = array_keys($event->getData()); + $unknown = array_diff($exists, $available); + + if (count($unknown) > 0) { + // + // Remove all filters to avoid unnecessary validation error + // messages. + // + foreach ($available as $filter) { + $form->remove($filter); + } + + $form->addError(new FormError(sprintf( + 'Unknowns fields: %s.', + implode(', ', $unknown) + ))); + + return; + } + + // + // Remove filters which not exists in request. + // + $notUsed = array_diff($available, $exists); + foreach ($notUsed as $filter) { + $form->remove($filter); + } + } else { + // + // Remove all filters to avoid unnecessary validation error + // messages. + // + $event->setData([]); + foreach ($available as $filter) { + $form->remove($filter); + } + } + } +} diff --git a/src/AppBundle/HttpFoundation/AppMergeableResponse.php b/src/AppBundle/HttpFoundation/AppMergeableResponse.php new file mode 100644 index 0000000..7970d1d --- /dev/null +++ b/src/AppBundle/HttpFoundation/AppMergeableResponse.php @@ -0,0 +1,51 @@ +isOriginalPriority = $flag; + + return $this; + } + + /** + * Sets the data to be sent as JSON. + * + * @param mixed $data New data. + * + * @return AppMergeableResponse + */ + public function setData($data = []) + { + if ($this->isOriginalPriority) { + $data = $this->data + (array) $data; + } else { + $data = (array) $data + $this->data; + } + parent::setData($data); + + return $this; + } +} diff --git a/src/AppBundle/HttpFoundation/AppResponse.php b/src/AppBundle/HttpFoundation/AppResponse.php new file mode 100644 index 0000000..9d2542b --- /dev/null +++ b/src/AppBundle/HttpFoundation/AppResponse.php @@ -0,0 +1,114 @@ +data = []; + + if ($data === null) { + $data = []; + } elseif (is_scalar($data)) { + $data = [ $data ]; + } + $headers['Content-Type'] = 'application/json'; + + parent::__construct($data, $status, $headers); + $this->update(); + } + + /** + * Sets the data to be sent as JSON. + * + * @param mixed $data Response data. + * + * @return AppResponse + */ + public function setData($data = []) + { + // Do not serialize data into json here, but do it in update method. + if ($data === null) { + $data = []; + } elseif (is_scalar($data)) { + $data = [ $data ]; + } + $this->data = $data; + $this->update(); + + return $this; + } + + /** + * Updates the content and headers according to the JSON data and callback. + * + * @return AppResponse + */ + protected function update() + { + $data = $this->data; + + if (($this->statusCode >= 400) && ($this->statusCode !== 402)) { + $data = [ + 'errors' => $data, + ]; + } + + return $this->setContent(json_encode($data)); + } + + /** + * Create response with 400 HTTP code. + * + * @param mixed $data The response data. + * + * @return static + */ + public static function badRequest($data = 'Bad Request') + { + return static::create($data, self::HTTP_BAD_REQUEST); + } + + /** + * Create response with 401 HTTP code. + * + * @param string $data The response data. + * + * @return static + */ + public static function unauthorized($data = 'Unauthorized') + { + return static::create($data, self::HTTP_UNAUTHORIZED); + } + + /** + * Sends HTTP headers and content. + * + * @return AppResponse + */ + public function send() + { + $this->update(); + parent::send(); + + return $this; + } +} diff --git a/src/AppBundle/Manager/AbstractQueryManager.php b/src/AppBundle/Manager/AbstractQueryManager.php new file mode 100644 index 0000000..f4a2717 --- /dev/null +++ b/src/AppBundle/Manager/AbstractQueryManager.php @@ -0,0 +1,237 @@ +em = $em; + } + + /** + * @param SearchResponseInterface $response A SearchResponseInterface + * instance.. + * @param DocumentCollectionInterface $collection A DocumentCollectionInterface + * entity instance. + * @param IndexStrategyInterface $strategy A DocumentNormalizerInterface + * instance. + * @param integer $pageNumber A requested page number. + * + * @return SearchResponseInterface + */ + protected function cache( + SearchResponseInterface $response, + DocumentCollectionInterface $collection, + IndexStrategyInterface $strategy, + $pageNumber + ) { + if (!$this->em->isOpen()) { + $this->em = $this->em->create( + $this->em->getConnection(), + $this->em->getConfiguration() + ); + } + // + // Persist just in case it has not been done yet. + // + $this->em->persist($collection); + + // Obviously, we should not process response which return zero results. + $responseCount = count($response); + if ($responseCount <= 0) { + return $response; + } + + // Array of all founded document ids. + $ids = []; + // Map between document id and index in response. + $documentIndex = []; + + // + // Fetch all documents ids from response and create map between id and + // index. + // + $index = 0; + /** @var ArticleDocumentInterface[] $documents */ + $documents = $response->getDocuments(); + foreach ($documents as $articleDocument) { + $id = $articleDocument->getId(); + + $ids[$id] = true; + $documentIndex[$id] = $index++; + } + + // + // Insure that we don't get same document more then once. + // + $ids = array_keys($ids); + + // + // Check which documents we already have and which we didn't have. For + // the firsts we create new page entity. For the seconds we created new + // document and also create new page. + // + /** @var DocumentRepository $repository */ + $repository = $this->em->getRepository(Document::class); + + $nonExistsIds = $repository->checkIds($ids); + // Get ids of exists documents. + $existsIds = array_diff($ids, $nonExistsIds); + + // + // Parse and create documents entities and pages entities for each + // document which we don't find in our database. + // + $newDocuments = []; + try { + foreach ($nonExistsIds as $id) { + // + // For parsing we use search response from external index and + // document index from response which we got from map. + // + $document = $documents[$documentIndex[$id]]; + $document = $this->persistEntityBucket($document->toDocumentEntity()); + $newDocuments[] = $document; + + $page = $this->persistEntityBucket($collection->createPage($pageNumber)); + $document->addPage($page); + } + } catch (\Exception $exception) { + throw new \RuntimeException(sprintf( + 'Got error while persisting not exists document with id \'%s\'. %s', + $id, + $exception->getMessage() + ), 0, $exception); + } + + // + // Create new page for already exists documents. + // + $existsDocuments = []; + if (count($existsIds) > 0) { + $existsDocuments = array_map(function ($id) { + return $this->em->getReference(Document::class, $id); + }, $existsIds); + } + + try { + foreach ($existsDocuments as $document) { + $page = $this->persistEntityBucket($collection->createPage($pageNumber)); + $document->addPage($page); + } + } catch (\Exception $exception) { + throw new \RuntimeException(sprintf( + 'Got error while binding exists document with id \'%s\' to new page. %s', + $document->getId(), + $exception->getMessage() + ), 0, $exception); + } + + $this->flushRemain(); + $documents = array_merge($newDocuments, $existsDocuments); + usort($documents, [$this, 'dateCompare']); + + return new SearchResponse( + $this->articleDocumentsFromEntities($documents, $strategy), + [], + $collection->getTotalCount(), + count($nonExistsIds) + ); + } + + /** + * @param array $entities Array of converted entities. + * @param IndexStrategyInterface $strategy Used index strategy. + * + * @return ArticleDocumentInterface[] + */ + protected function articleDocumentsFromEntities(array $entities, IndexStrategyInterface $strategy) + { + return \nspl\a\map(function (Document $document) use ($strategy) { + $data = $document->getData(); + $data['__comments'] = $document->getComments(); + $data['__commentsCount'] = $document->getCommentsCount(); + + return $strategy->createDocument($data); + }, $entities); + } + + /** + * @param object $entity Persisted entity. + * + * @return object + */ + private function persistEntityBucket($entity) + { + $this->bucket[] = $entity; + $this->em->persist($entity); + + if (count($this->bucket) >= self::FLUSH_BUCKET_SIZE) { + $this->em->flush(); + $this->bucket = []; + } + + return $entity; + } + + /** + * @return void + */ + private function flushRemain() + { + if (count($this->bucket) > 0) { + $this->em->flush(); + $this->bucket = []; + } + } + + /** + * @param Document $doc1 + * @param Document $doc2 + * @return false|int + */ + public static function dateCompare(Document $doc1, Document $doc2) { + $data1 = $doc1->getData(); + $data2 = $doc2->getData(); + $datetime1 = strtotime($data1['published']); + $datetime2 = strtotime($data2['published']); + return $datetime2 - $datetime1; + } +} diff --git a/src/AppBundle/Manager/Feed/FeedManager.php b/src/AppBundle/Manager/Feed/FeedManager.php new file mode 100644 index 0000000..15a82dc --- /dev/null +++ b/src/AppBundle/Manager/Feed/FeedManager.php @@ -0,0 +1,167 @@ +em = $em; + $this->index = $index; + } + + /** + * Clip document to specified feed. + * + * @param AbstractFeed $feed A AbstractFeed entity instance. + * @param array $ids Array for Document entities ids. + * + * @return void + */ + public function clip(AbstractFeed $feed, array $ids) + { + if (! $feed instanceof ClipFeed) { + throw new \InvalidArgumentException('Can clip only to ' . ClipFeed::class); + } + + if (count($ids) === 0) { + return; + } + + // + // Create new page for already exists documents. + // + /** @var DocumentRepository $repository */ + $repository = $this->em->getRepository(Document::class); + + $documents = []; + if (count($ids) > 0) { + $documents = $repository->getByIds($ids); + } + + $articleDocuments = []; + foreach ($documents as $document) { + $page = $feed->createPage(0); + $document->addPage($page); + + $this->em->persist($page); + + $articleDocuments[] = $this->index->getStrategy() + ->createDocument($document->getData()) + ->mapRawData(function (array $data) use ($feed) { + $data[FieldNameEnum::COLLECTION_ID] = $feed->getCollectionId(); + $data[FieldNameEnum::COLLECTION_TYPE] = $feed->getCollectionType()->getValue(); + + return $data; + }); + } + + $this->index->index($articleDocuments); + + $feed->setTotalCount($feed->getTotalCount() + count($ids)); + // Persist query just in case it has not been done yet. + $this->em->persist($feed); + $this->em->flush(); + } + + /** + * Delete documents from specified feed. + * + * @param AbstractFeed $feed A AbstractFeed entity instance. + * @param array $ids Array of Document entities ids. + * + * @return void + */ + public function deleteDocuments(AbstractFeed $feed, array $ids = []) + { + $factory = $this->index->getFilterFactory(); + $builder = $this->index->createRequestBuilder(); + + if (count($ids) > 0) { + // + // Remove only specified document document. + // + $builder->addFilter($factory->in(FieldNameEnum::SEQUENCE, $ids)); + } + + $response = $builder + ->addFilter($factory->eq(FieldNameEnum::COLLECTION_ID, $feed->getCollectionId())) + ->addFilter($factory->eq(FieldNameEnum::COLLECTION_TYPE, $feed->getCollectionType())) + ->setSources(['_id']) + ->build() + ->execute() + ->getDocuments(); + + $realIds = \nspl\a\map(\nspl\op\itemGetter('sequence'), $response); + + if (count($realIds) === 0) { + return; + } + + if ($feed instanceof ClipFeed) { + // + // For clip feeds we remove documents from index 'cause we copy all + // documents for each clip feed. + // + $this->index->remove($realIds); + } elseif ($feed instanceof QueryFeed) { + $config = []; + $script = sprintf( + 'if (ctx._source.%s.contains(%d)) { ctx.op = "none"} else {ctx._source.%s.add(%d)}', + FieldNameEnum::DELETE_FROM, + $feed->getId(), + FieldNameEnum::DELETE_FROM, + $feed->getId() + ); + + foreach ($realIds as $id) { + $config[$id] = ['script' => $script]; + } + + $this->index->updateBulk($config); + } + } + + /** + * Get index used by feed manager. + * + * @return InternalIndexInterface + */ + public function getIndex() + { + return $this->index; + } +} diff --git a/src/AppBundle/Manager/Feed/FeedManagerInterface.php b/src/AppBundle/Manager/Feed/FeedManagerInterface.php new file mode 100644 index 0000000..71918ae --- /dev/null +++ b/src/AppBundle/Manager/Feed/FeedManagerInterface.php @@ -0,0 +1,42 @@ +lifetime = $lifetime; + } + + /** + * @param SearchRequestInterface $request A SearchRequestInterface + * instance. + * @param array $rawFilters A filters as is. + * @param array $rawAdvancedFilters A advanced filters as is. + * + * @return SearchAndCacheResponse + * + * todo remove $rawFilters and $rawAdvancedFilters 'cause it should be computed. + */ + public function searchAndCache( + SearchRequestInterface $request, + array $rawFilters, + array $rawAdvancedFilters + ) { + /** @var SimpleQueryRepository $queryRepository */ + $queryRepository = $this->em->getRepository(SimpleQuery::class); + + // + // Try to get simple query for given request in our cache. + // + $query = $queryRepository->get($request->getHash()); + $response = null; + if ($query instanceof SimpleQuery) { + if ($query->getTotalCount() === 0) { + $response = new SearchResponse(); + } else { + // + // Get documents from cache. + // + /** @var DocumentRepository $repository */ + $repository = $this->em->getRepository(Document::class); + + $response = new SearchResponse( + $this->articleDocumentsFromEntities( + $repository->getForQuery($query->getId(), $request->getPage()), + $request->getIndex()->getStrategy() + ), + [], + $query->getTotalCount() + ); + if (count($response) === 0) { + // + // We don't have documents for requested query, so we + // should make request to index. + // + $response = null; + } + } + } + + // + // We need to make request to index because no one of users did not make + // similar request or specified page don't requested yet. + // + if ($response === null) { + $response = $request->execute(); + + if (! $query instanceof SimpleQuery) { + // + // Create new query entity instance for current search request. + // + $query = SimpleQuery::fromSearchRequest($request) + ->setTotalCount($response->getTotalCount()) + ->setExpirationDate($this->lifetime) + ->setRawFilters($rawFilters) // todo should be computed from request + ->setRawAdvancedFilters($rawAdvancedFilters); // todo should be computed from request + } + + $cacheResponse = $this->cache( + $response, + $query, + $request->getIndex()->getStrategy(), + $request->getPage() + ); + $this->em->persist($query); + $this->em->flush(); + + $response = new SearchResponse( + $cacheResponse->getDocuments(), + [], + $response->getTotalCount(), + $cacheResponse->getUniqueCount() + ); + } + + return SearchAndCacheResponse::fromSearchResponse($query, $response); + } +} diff --git a/src/AppBundle/Manager/SimpleQuery/SimpleQueryManagerInterface.php b/src/AppBundle/Manager/SimpleQuery/SimpleQueryManagerInterface.php new file mode 100644 index 0000000..b5db453 --- /dev/null +++ b/src/AppBundle/Manager/SimpleQuery/SimpleQueryManagerInterface.php @@ -0,0 +1,31 @@ +sourceIndex = $sourceIndex; + $this->externalIndex = $externalIndex; + $this->em = $em; + $this->logger = $logger; + $this->pathToDateFile = realpath(rtrim($varDir, DIRECTORY_SEPARATOR)) + . DIRECTORY_SEPARATOR . self::UPDATE_FILE_NAME; + $this->pathToLastProcessedFile = realpath(rtrim($varDir, DIRECTORY_SEPARATOR)) + . DIRECTORY_SEPARATOR . self::LAST_PROCESSED_FILE_NAME; + } + + /** + * Find all sources matched to specified builder. + * If $sourceList is not null that make additional filter by specified + * source list id. + * + * @param SearchRequestBuilderInterface $builder A + * SearchRequestBuilderInterface + * instance. + * @param SourceList $sourceList A SourceList entity + * instance. + * + * @return SearchResponse + */ + public function find( + SearchRequestBuilderInterface $builder, + SourceList $sourceList = null + ) { + // + // Convert specified search request builder into source cache search + // builder. + // + $builder = $this->sourceIndex->createRequestBuilder() + ->fromSearchRequestBuilder($builder) + ->setQuery($this->prepareQuery($builder->getQuery())) + ->setFields([ + FieldNameEnum::SOURCE_TITLE, + FieldNameEnum::SOURCE_LINK, + ]); + + // + // Get sources from specified source list if it requested. + // + if ($sourceList !== null) { + $filterFactory = $builder->getFilterFactory(); + $builder->addFilter($filterFactory->eq('listIds', $sourceList->getId())); + } + + $response = $builder->build()->execute(); + + // + // Now we should clean all source id's and left only exists but only + // if we got specified user. + // + $sources = $response->getDocuments(); + if (count($sources) && ($builder->getUser() instanceof User)) { + // Get all used source ids. + $ids = array_keys(array_flip(\nspl\a\flatten(\nspl\a\map( + \nspl\op\propertyGetter('listIds'), + $sources + )))); + + // Get only ids which exists. + if (count($ids) > 0) { + /** @var SourceListRepository $sourceListRepository */ + $sourceListRepository = $this->em->getRepository(SourceList::class); + $ids = array_map(function (array $row) { + return $row['id']; + }, $sourceListRepository->createQueryBuilder('List') + ->select('List.id') + ->where('List.id in (' . implode(',', $ids) . ') AND List.user = :id') + ->setParameter('id', $builder->getUser()->getId()) + ->getQuery() + ->getArrayResult()); + } + + // + // Filter all source document's and left only exists list ids in + // listIds field. + // + $sources = array_map(function (SourceDocument $document) use ($ids) { + $document->listIds = array_values(array_intersect($document->listIds, $ids)); + + return $document; + }, $sources); + } + + return new SearchResponse( + $sources, + $response->getAggregationResults(), + $response->getTotalCount(), + $response->getUniqueCount() + ); + } + + /** + * Place all specified sources into specified lists. + * + * @param User $user A User entity instance. + * @param string|string[] $sources Array of source id or single id. + * @param integer|integer[] $lists Array of SourceList entity id or single + * id. + * + * @return void + */ + public function bindSourcesToLists(User $user, $sources, $lists) + { + /** @var SourceDocument[] $sourceDocuments */ + $sourceDocuments = $this->sourceIndex->get($sources, [ 'listIds' ]); + $conn = $this->em->getConnection(); + $lists = (array) $lists; + + // + // Get source list ids for current user. + // + /** @var SourceListRepository $repository */ + $repository = $this->em->getRepository(SourceList::class); + $availableIds = $repository->getAvailableIdsForUser($user->getId()); + + // + // Now we should iterate through each source document and find out which + // lists we should add to it. + // + foreach ($sourceDocuments as $sourceDocument) { + $sourceId = $sourceDocument['id']; + $sourceLists = $sourceDocument->listIds; + + // + // Remove all ids which presents in available list from actual list + // ids of current source document and add new one. + // + $cleaned = array_diff($sourceLists, $availableIds); + $new = array_merge($cleaned, $lists); + + // Add to index. + $this->sourceIndex->update($sourceId, [ + 'listIds' => $new, + ]); + + // + // Remove old relations and add new. + // + $conn->exec(" + DELETE FROM cross_sources_source_lists WHERE source = '{$sourceId}' + "); + $conn->exec( + 'INSERT INTO cross_sources_source_lists (source, list_id) VALUES ' + . implode(',', array_map(function ($list) use ($sourceId) { + return "('{$sourceId}', $list)"; + }, $new)) + ); + + // Recompute count of sources for each updated list. + $this->recomputeCount(array_merge($sourceLists, $lists)); + } + } + + /** + * Add specified sources to specific source list. + * + * @param array $sources Array of updates sources ids. + * @param integer $id A SourceList entity id. + * + * @return void + */ + public function addSourcesToList(array $sources, $id) + { + $conn = $this->em->getConnection(); + + $filterFactory = $this->sourceIndex->getFilterFactory(); + $request = $this->sourceIndex->createRequestBuilder() + ->addFilter($filterFactory->in('id', $sources)) + ->build(); + + // + // Add list into all specified sources. + // If source already has association with specified list then we don't + // duplicate. + // + $this->sourceIndex->updateByQuery( + $request, + 'ctx._source.listIds.removeIf(item -> item == params.id);ctx._source.listIds.add(params.id)', + [ 'id' => $id ] + ); + + // + // Add relations in database. + // + $conn->exec(sprintf( + 'INSERT INTO cross_sources_source_lists (source, list_id) VALUE %s', + implode(',', \nspl\a\map(function ($source) use ($id) { + return "('{$source}', {$id})"; + }, $sources)) + )); + + // + // Recompute count of sources for updated list. + // + $this->recomputeCount([ $id ]); + } + + /** + * Get all source's which used in filter's of specified query. + * + * @param AbstractQuery $query A AbstractQuery entity instance. + * @param array $fields Array of requested fields. + * + * @return array[] + */ + public function getSourcesForQuery(AbstractQuery $query, array $fields) + { + if (isset($query->getRawFilters()['source'])) { + $ids = $query->getRawFilters()['source']['ids']; + + return array_map( + function (DocumentInterface $document) use ($fields) { + $result = []; + + foreach ($fields as $field) { + $result[$field] = $document[$field]; + } + + return $result; + }, + $this->sourceIndex->get($ids, $fields) + ); + } + + return []; + } + + /** + * Get all source list's which used in filter's of specified query. + * + * @param AbstractQuery $query A AbstractQuery entity instance. + * @param array $fields Array of requested fields. + * + * @return array[] + */ + public function getSourceListsForQuery(AbstractQuery $query, array $fields) + { + if (isset($query->getRawFilters()['sourceList'])) { + $sourceLists = $query->getRawFilters()['sourceList']; + $ids = []; + + if (isset($sourceLists['include'])) { + $ids[] = $sourceLists['include']; + } + + if (isset($sourceLists['exclude'])) { + $ids[] = $sourceLists['exclude']; + } + + /** @var SourceListRepository $sourceListRepository */ + $sourceListRepository = $this->em->getRepository(SourceList::class); + + return $sourceListRepository->createQueryBuilder('List') + ->select('partial List.{'. implode(',', $fields) .'}') + ->where('List.id in ('. implode(',', \nspl\a\flatten($ids)) .')') + ->getQuery() + ->getArrayResult(); + } + + return []; + } + + /** + * Get available advanced filters. + * + * @param SearchRequestBuilderInterface $builder A + * SearchRequestBuilderInterface + * instance. + * @param SourceList|null $sourceList A SourceList entity + * instance. + * + * @return mixed + */ + public function getAvailableFilters( + SearchRequestBuilderInterface $builder, + SourceList $sourceList = null + ) { + $request = $this->sourceIndex->createRequestBuilder() + ->fromSearchRequestBuilder($builder) + ->setQuery($this->prepareQuery($builder->getQuery())) + ->setFields([ + FieldNameEnum::SOURCE_TITLE, + FieldNameEnum::SOURCE_LINK, + ]) + ->build(); + + if ($sourceList !== null) { + $filterFactory = $builder->getFilterFactory(); + $builder->addFilter($filterFactory->eq('listIds', $sourceList->getId())); + } + + return $request->getAvailableAdvancedFilters(); + } + + /** + * Make relation between specified source and source lists. + * All exists source relation will be overridden. + * + * @param integer $source Source id. + * @param array $lists Array of SourceList entity ids. + * + * @return void + */ + public function replaceRelation($source, array $lists) + { + $oldLists = current($this->sourceIndex->get($source, 'listIds'))->listIds; + + $this->sourceIndex->update($source, [ + 'listIds' => $lists, + ]); + + $this->em->getConnection()->transactional(function (Connection $conn) use ($source, $oldLists, $lists) { + // Remove all old relations between source and source lists. + $conn->exec(" + DELETE FROM cross_sources_source_lists + WHERE source = '{$source}' + "); + + // Add new relations. + $conn->exec( + 'INSERT INTO cross_sources_source_lists (source, list_id) VALUES ' + . implode(',', array_map(function ($list) use ($source) { + return "('{$source}', {$list})"; + }, $lists)) + ); + + // Recompute count of sources for each updated list. + $this->recomputeCount(array_merge($oldLists, $lists)); + }); + } + + /** + * Unbind all binded sources from specified lists. + * + * @param integer|integer[] $lists Array of SourceList entity id or single id. + * + * @return void + */ + public function unbindSourcesFromLists($lists) + { + $lists = (array) $lists; + + $filterFactory = $this->sourceIndex->getFilterFactory(); + $request = $this->sourceIndex->createRequestBuilder() + ->setFilters($filterFactory->in('listIds', $lists)) + ->setSources([ '_id' ]) + ->build(); + + // + // See https://docs.oracle.com/javase/8/docs/api/java/util/Collection.html#removeIf%2Djava.util.function.Predicate%2D + // + $this->sourceIndex->updateByQuery($request, ' + ctx._source.listIds.removeIf(item -> params.lists.contains(item)) + ', [ 'lists' => $lists ]); + } + + /** + * Update source cache. + * + * Fetch source from external index and store it into our cache. If we + * already got sources in our cache se try to get source occurred after + * oldest source. + * + * If our source cache is empty, we just get all available source. This + * should be done in background. + * + * @return void + */ + public function pullFromExternal() + { + $lastUpdate = null; + if (is_file($this->pathToDateFile)) { + $lastUpdate = new \DateTime(file_get_contents($this->pathToDateFile)); + } + + // + // We should fetch all sources if we don't has any sources in our cache. + // + if ($lastUpdate === null) { + if ($this->externalIndex instanceof InternalHoseIndex) { + $this->fetchSourcesFromFake(); + } else { + $this->fetchSources(); + } + + file_put_contents($this->pathToDateFile, date_create()->format('c')); + } else { + // + // Do nothing for now. + // todo uncomment it when we are ready for updating sources. + // + // $this->fetchSources($lastUpdate); + throw new \RuntimeException('Unimplemented'); + } + } + + /** + * @return SourceIndexInterface + */ + public function getIndex() + { + return $this->sourceIndex; + } + + /** + * Create proper aggregation for fetching unique source. + * + * @param IndexInterface $index A IndexInterface instance. + * @param array $sources Array of requested fields. + * + * @return \IndexBundle\Aggregation\AggregationInterface + */ + private function createAggregation(IndexInterface $index, array $sources) + { + $aggrFactory = $index->getAggregationFactory(); + $aggr = $index->getAggregation(); + + // + // Get unique documents by source_hashcode. + // + $hashAggr = $aggr->getAggregation('hash', $aggrFactory->terms([ + 'field_name' => FieldNameEnum::SOURCE_HASHCODE, + 'size' => 1000000, // We should get all available results for + // this aggregation. + ])); + + // + // Fetch required fields from founded unique douments. + // + $documentAggr = $aggr->getAggregation('document', $aggrFactory->topHits([ + 'size' => 1, // 'cause we need to get content of sources and we have + // already made sure that source are unique. + 'sources' => $sources, + ])); + + return $hashAggr->addAggregation( + $documentAggr + ); + } + + /** + * @param \DateTime $lastUpdate Last update date. + * + * @return void + */ + private function fetchSources(\DateTime $lastUpdate = null) + { + $filterFactory = $this->externalIndex->getFilterFactory(); + + $aggregations = $this->createAggregation($this->externalIndex, [ + FieldNameEnum::SOURCE_HASHCODE, + FieldNameEnum::SOURCE_TITLE, + FieldNameEnum::SOURCE_FEED_TITLE, + FieldNameEnum::SOURCE_PUBLISHER_TYPE, + FieldNameEnum::SOURCE_LINK, + FieldNameEnum::COUNTRY, + FieldNameEnum::STATE, + FieldNameEnum::CITY, + FieldNameEnum::SECTION, + FieldNameEnum::LANG, + ]); + + $lastValue = null; + if (is_file($this->pathToLastProcessedFile)) { + $lastValue = trim(file_get_contents($this->pathToLastProcessedFile)); + } + + // + // We should split request to small pieces by filtering source hashcode + // by first three symbols from '000' to 'zzz'. We got 46655 unique + // combinations. + // + $valueGenerator = $this->generateSourceHashFilterValue($lastValue); + + foreach ($valueGenerator as $value) { + sleep(1); + $this->logger->info(sprintf( + 'Fetching results for sources with hashcode started with \'%s\'', + $value + )); + try { + $condition = $filterFactory->andX([ + // + // Get only documents + // + $filterFactory->eq(FieldNameEnum::SOURCE_HASHCODE, "({$value}*)"), + // + // We should not get documents which language is unknown. + // + $filterFactory->not($filterFactory->eq(FieldNameEnum::LANG, LanguageEnum::UNKNOWN)), + ]); + + // + // Add filter by date if it necessary. + // + if ($lastUpdate !== null) { + $condition->add( + $filterFactory->gt(FieldNameEnum::DATE_FOUND, $lastUpdate) + ); + } + + $results = $this->externalIndex->createRequestBuilder() + ->setLimit(0)// 'cause we need only result of aggregations. + ->addFilter($condition) + ->setAggregation($aggregations) + ->build() + ->execute() + ->getAggregationResults(); + + // + // Get result of first aggregations. + // + $results = is_array($results) ? current($results) : []; + $results = is_array($results) ? $results : []; + $bucket = []; + + $this->logger->info(sprintf( + 'Got response from external index with %s new sources', + count($results) + )); + $this->logger->info('Memory usage ' . memory_get_usage()); + + foreach ($results as $hashCodeAggr) { + $data = current($hashCodeAggr['sub']['document']); + + /** @var ArticleDocumentInterface $document */ + $document = $this->externalIndex->getStrategy() + ->createDocument($data); + + $bucket[] = $this->sourceIndex->getStrategy()->createDocument( + $document->toSourceDocumentData() + ); + + if (count($bucket) >= self::SOURCE_FETCH_BUCKET_SIZE) { + $this->sourceIndex->index($bucket); + $bucket = []; + gc_collect_cycles(); + } + } + + // + // Index remain document in bucket. + // + if (count($bucket) > 0) { + $this->sourceIndex->index($bucket); + unset($bucket); + gc_collect_cycles(); + } + } catch (\Exception $exception) { + throw new \RuntimeException(sprintf( + 'Got \'%s\' exception while fetching results for \'%s\' part of source hashcode', + $exception->getMessage(), + $value + )); + } finally { + file_put_contents($this->pathToLastProcessedFile, $value); + } + } + } + + /** + * Fetch sources from fake index. + * + * @param \DateTime|null $lastUpdate Last update date. + * + * @return void + */ + private function fetchSourcesFromFake(\DateTime $lastUpdate = null) + { + $filterFactory = $this->externalIndex->getFilterFactory(); + + $aggregations = $this->createAggregation($this->externalIndex, [ + FieldNameEnum::SOURCE_HASHCODE, + FieldNameEnum::SOURCE_TITLE, + FieldNameEnum::SOURCE_FEED_TITLE, + FieldNameEnum::SOURCE_PUBLISHER_TYPE, + FieldNameEnum::SOURCE_LINK, + FieldNameEnum::COUNTRY, + FieldNameEnum::STATE, + FieldNameEnum::CITY, + FieldNameEnum::SECTION, + FieldNameEnum::LANG, + ]); + + try { + $condition = $filterFactory->andX(); + + // + // Add filter by date if it necessary. + // + if ($lastUpdate !== null) { + $condition->add( + $filterFactory->gt(FieldNameEnum::DATE_FOUND, $lastUpdate) + ); + } + + $results = $this->externalIndex->createRequestBuilder() + ->setLimit(0)// 'cause we need only result of aggregations. + ->addFilter($condition) + ->setAggregation($aggregations) + ->build() + ->execute() + ->getAggregationResults(); + + // + // Get result of first aggregations. + // + $results = is_array($results) ? current($results) : []; + $results = is_array($results) ? $results : []; + $bucket = []; + + $this->logger->info(sprintf( + 'Got response from external index with %s new sources', + count($results) + )); + + foreach ($results as $hashCodeAggr) { + $data = current($hashCodeAggr['sub']['document']); + + /** @var ArticleDocumentInterface $document */ + $document = $this->externalIndex->getStrategy() + ->createDocument($data); + + $bucket[] = $this->sourceIndex->getStrategy()->createDocument( + $document->toSourceDocumentData() + ); + + if (count($bucket) >= self::SOURCE_FETCH_BUCKET_SIZE) { + $this->sourceIndex->index($bucket); + $bucket = []; + gc_collect_cycles(); + } + } + + // + // Index remain document in bucket. + // + if (count($bucket) > 0) { + $this->sourceIndex->index($bucket); + unset($bucket); + gc_collect_cycles(); + } + } catch (\Exception $exception) { + throw new \RuntimeException(sprintf( + 'Got \'%s\' exception while fetching sources', + $exception->getMessage() + )); + } + } + + /** + * @param string|null $startValue Value from which we should start. + * + * @return \Generator + */ + private function generateSourceHashFilterValue($startValue = null) + { + // + // Return first generated key. + // + $value = $startValue !== null ? $startValue : '000'; + yield $value; + do { + // + // Use 36 number base for generating next value. + // + $value = sprintf('%\'.03s', base_convert(base_convert($value, 36, 10) + 1, 10, 36)); + yield $value; + } while ($value !== 'zzz'); + } + + // + // TODO uncomment and rewrite if updating is required. + // +// /** +// * Remove duplicate source from cache. +// * +// * @return void +// */ +// private function removeDuplicates() +// { +// $response = $this->sourceIndex->createRequestBuilder() +// ->setLimit(0) +// ->setAggregation( +// $this->createAggregation($this->sourceIndex, [ 'listIds' ]) +// ) +// ->build() +// ->execute(); +// +// $typeGrouping = current($response->getAggregationResults()); +// +// foreach ($typeGrouping as $group1) { +// $langGroup = $group1['sub']['group_by_lang']; +// $ids = []; +// +// foreach ($langGroup as $group2) { +// $titleGroup = $group2['sub']['group_by_title']; +// +// foreach ($titleGroup as $group3) { +// $hits = array_map(function (array $hit) { +// return $hit['_id']; +// }, array_filter($group3['sub']['top_hits'], function (array $hit) { +// return count($hit['listIds']) === 0; +// })); +// +// if (count($hits) > 1) { +// // We should not remove single source with empty source +// // list. +// $ids = array_merge($ids, $hits); +// } +// } +// } +// +// if (count($ids) > 0) { +// $this->sourceIndex->remove($ids); +// } +// } +// } + + /** + * Prepare search query. + * + * @param string $query A raw search query. + * + * @return string + */ + private function prepareQuery($query) + { + // + // We should wrap all words in query with asterisk's in order to make + // partial word search. + // + // Also we surround whole query by bracket's for we same reasons. + // + $tokens = array_filter(array_map('trim', explode(' ', $query))); + if (count($tokens) === 0) { + return ''; + } + + return '('. implode(' ', array_map(function ($token) { + return "*{$token}*"; + }, $tokens)) .')'; + } + + /** + * Recompute count of sources on specified lists. + * + * @param array $ids Array of SourceList entity ids. + * + * @return void + */ + private function recomputeCount(array $ids) + { + $this->em->getConnection()->exec(' + UPDATE source_list sl + RIGHT JOIN + ( + SELECT list_id, COUNT(source) as count + FROM cross_sources_source_lists + WHERE list_id IN ('. implode(',', $ids) .') + GROUP BY list_id + ) x ON x.list_id = sl.id + SET + sl.source_number = x.count + '); + } +} diff --git a/src/AppBundle/Manager/Source/SourceManagerInterface.php b/src/AppBundle/Manager/Source/SourceManagerInterface.php new file mode 100644 index 0000000..097ca85 --- /dev/null +++ b/src/AppBundle/Manager/Source/SourceManagerInterface.php @@ -0,0 +1,130 @@ +externalIndex = $externalIndex; + $this->internalIndex = $internalIndex; + $this->pageSize = $pageSize; + } + + /** + * Fetch documents for specified stored query. + * + * @param StoredQuery $query A StoredQuery entity instance for which we + * should fetch documents. + * + * @return StoredQuery + */ + public function fetchDocuments(StoredQuery $query) + { + // + // Set total count to 0 because when we cache founded results they counts + // automatically will be sum with query total count. + // + $query->setTotalCount(0); + + // + // Make first request in order to fetch real total count. + // + $request = $this->externalIndex + ->createRequestBuilder() + ->setQuery($query->getRaw()) + ->addSort(FieldNameEnum::PUBLISHED, 'desc') + ->setLimit($this->pageSize) + ->setFilters($query->getFilters()) + ->setFields([ + FieldNameEnum::TITLE, + FieldNameEnum::MAIN, + ]); + + if ($query->isInStatus(StoredQueryStatusEnum::SYNCED)) { + $factory = $this->externalIndex->getFilterFactory(); + $request->addFilter($factory->gte(FieldNameEnum::PUBLISHED, $query->getLastUpdateAt()->format('c'))); + } + + $request = $request->build(); + + foreach ($this->externalIndex->fetchAll($request) as $response) { + $this->cache($response, $query, $request->getIndex()->getStrategy(), 1); + + // + // Remove all previously fetched values. + // + // Need in order to insure that memory will be free before fetch new + // part of data. + // + unset($response); + gc_collect_cycles(); + } + + return $query->setStatus(StoredQueryStatusEnum::SYNCED); + } + + /** + * Create new stored query. + * + * @param SearchRequestBuilderInterface $builder A SearchRequestBuilderInterface + * instance. + * @param array $rawFilters A raw filters. + * @param array $rawAdvancedFilters A raw advanced filters. + * + * @return StoredQuery + */ + public function createQuery( + SearchRequestBuilderInterface $builder, + array $rawFilters, + array $rawAdvancedFilters + ) { + $externalBuilder = $this->externalIndex->createRequestBuilder() + ->fromSearchRequestBuilder($builder); + + // Set necessary request builder parameters. + $searchRequest = $builder + ->setFields([ + FieldNameEnum::TITLE, + FieldNameEnum::MAIN, + ]) + ->setPage(1) + ->setLimit($this->pageSize) + ->build(); + + $externalSearchRequest = $externalBuilder + ->setFields([ + FieldNameEnum::TITLE, + FieldNameEnum::MAIN, + ]) + ->setPage(1) + ->setLimit($this->pageSize) + ->build(); + + // Try to find stored query with same hash. + /** @var StoredQueryRepository $repository */ + $repository = $this->em->getRepository(StoredQuery::class); + $query = $repository->get($searchRequest->getHash()); + + if ($query === null) { + // + // We have unique stored search query. + // Make request in order to get and check total document count. + // + $response = $externalSearchRequest->execute(); + + // + // Fill internal query part. + // + /** @var StoredQuery $query */ + $query = StoredQuery::create() + ->setRaw($builder->getQuery()) + ->setFields($builder->getFields()) + ->setNormalized($searchRequest->getNormalizedQuery()) + ->setFilters($searchRequest->getFilters()) + ->setRawFilters($rawFilters) + ->setRawAdvancedFilters($rawAdvancedFilters) + ->setTotalCount($response->getTotalCount()) + ->setHash($searchRequest->getHash()); + + // TODO maybe we should store first page right here and fetch documents from second page? + $this->em->persist($query); + } + + return $query; + } + + /** + * @param SearchRequestBuilderInterface $builder + * @param array $rawFilters + * @param array $rawAdvancedFilters + * @return integer + */ + public function getTotal( + SearchRequestBuilderInterface $builder, + array $rawFilters, + array $rawAdvancedFilters + ) { + $externalBuilder = $this->externalIndex->createRequestBuilder() + ->fromSearchRequestBuilder($builder); + + $externalSearchRequest = $externalBuilder + ->setFields([ + FieldNameEnum::TITLE, + FieldNameEnum::MAIN, + ]) + ->build(); + return $externalSearchRequest->getIndex()->getTotal($externalSearchRequest); + } + + + /** + * Get documents from cache. + * + * @param User $user User who requested documents. + * @param StoredQuery $query A StoredQuery entity instance. + * @param SearchRequestBuilderInterface $builder A SearchRequestBuilderInterface + * instance. + * + * @return SearchResponseInterface + */ + public function get( + User $user, + StoredQuery $query, + SearchRequestBuilderInterface $builder + ) { + $factory = $this->internalIndex->getFilterFactory(); + $filters = $query->getFilters(); + $filters = array_merge($filters, $builder->getFilters()); + + $request = $this->internalIndex->createRequestBuilder() + ->setUser($user) + ->setQuery($query->getRaw()) + ->setFields([ + FieldNameEnum::TITLE, + FieldNameEnum::MAIN, + ]) + ->setFilters($filters) + ->addFilter($factory->eq(FieldNameEnum::COLLECTION_ID, $query->getId())) + ->addFilter($factory->eq(FieldNameEnum::COLLECTION_TYPE, CollectionTypeEnum::QUERY)) + ->setPage($builder->getPage()) + ->setLimit($this->pageSize) + ->addSort(FieldNameEnum::PUBLISHED, 'desc') + ->build(); + + return $request->execute(); + } + + /** + * Get documents from cache. + * + * Get all matched document but from specified date. + * + * @param User $user User who requested documents. + * @param StoredQuery $query A StoredQuery entity instance. + * + * @return SearchRequestBuilderInterface + */ + public function createRequestBuilder(User $user, StoredQuery $query) + { + $factory = $this->internalIndex->getFilterFactory(); + $filters = $query->getFilters(); + $filters[] = $factory->eq(FieldNameEnum::COLLECTION_ID, $query->getId()); + $filters[] = $factory->eq(FieldNameEnum::COLLECTION_TYPE, CollectionTypeEnum::QUERY); + + return $this->internalIndex->createRequestBuilder() + ->setUser($user) + ->setQuery($query->getRaw()) + ->setFields([ + FieldNameEnum::TITLE, + FieldNameEnum::MAIN, + ]) + ->setFilters($filters); + } + + /** + * Get advanced filters for specified query. + * + * @param StoredQuery $query A StoredQuery entity instance. + * @param SearchRequestBuilderInterface $builder A SearchRequestBuilderInterface + * instance. + * + * @return array + */ + public function getAdvancedFilters( + StoredQuery $query, + SearchRequestBuilderInterface $builder + ) { + $searchBuilder = $this->internalIndex->createRequestBuilder() + ->fromQueryEntity($query); + + $searchBuilder + ->setFilters(array_merge($searchBuilder->getFilters(), $builder->getFilters())); + + return $searchBuilder->build()->getAvailableAdvancedFilters(); + } + + /** + * @param SearchResponseInterface $response A SearchResponseInterface + * instance.. + * @param DocumentCollectionInterface $collection A DocumentCollectionInterface + * entity instance. + * @param IndexStrategyInterface $strategy A DocumentNormalizerInterface + * instance. + * @param integer $pageNumber A requested page number. + * + * @return SearchResponseInterface + */ + protected function cache( + SearchResponseInterface $response, + DocumentCollectionInterface $collection, + IndexStrategyInterface $strategy, + $pageNumber + ) { + $response = parent::cache($response, $collection, $strategy, $pageNumber); + + $response->mapDocuments(function (DocumentInterface $document) use ($collection) { + return $document->mapRawData(function (array $data) use ($collection) { + $data[FieldNameEnum::COLLECTION_ID] = $collection->getCollectionId(); + $data[FieldNameEnum::COLLECTION_TYPE] = $collection->getCollectionType()->getValue(); + + return $data; + }); + }); + + $this->internalIndex->index($response->getDocuments()); + $collection->setTotalCount($collection->getTotalCount() + count($response)); + + return $response; + } +} diff --git a/src/AppBundle/Manager/StoredQuery/StoredQueryManagerInterface.php b/src/AppBundle/Manager/StoredQuery/StoredQueryManagerInterface.php new file mode 100644 index 0000000..8877733 --- /dev/null +++ b/src/AppBundle/Manager/StoredQuery/StoredQueryManagerInterface.php @@ -0,0 +1,96 @@ +fieldName = trim($fieldName); + if ($this->fieldName === '') { + $this->fieldName = trim($defaultFieldName); + } + + $sortDirection = strtolower(trim($sortDirection)); + if (($sortDirection !== 'asc') && ($sortDirection !== 'desc')) { + throw new \InvalidArgumentException('\'$sortDirection\' should be \'asc\' or \'desc\''); + } + + $this->sortDirection = $sortDirection; + } + + /** + * Create instance from request. + * + * @param Request $request A Request instance. + * @param string $defaultFieldName Default field name. + * + * @return SortingOptions + */ + public static function fromRequest(Request $request, $defaultFieldName) + { + $sortField = $request->query->get('sortField'); + $sortDirection = $request->query->get('sortDirection', 'asc'); + + return new static($sortField, $sortDirection, $defaultFieldName); + } + + /** + * @return string + */ + public function getFieldName() + { + return $this->fieldName; + } + + /** + * @return string + */ + public function getSortDirection() + { + return $this->sortDirection; + } + + /** + * Specify data which should be serialized to JSON. + * + * @return mixed data which can be serialized by json_encode, which is a value + * of any type other than a resource. + */ + public function jsonSerialize() + { + return [ + 'field' => $this->fieldName, + 'direction' => $this->sortDirection, + ]; + } +} diff --git a/src/AppBundle/Monolog/Formatter/TraceLogFormatter.php b/src/AppBundle/Monolog/Formatter/TraceLogFormatter.php new file mode 100644 index 0000000..9bf1d36 --- /dev/null +++ b/src/AppBundle/Monolog/Formatter/TraceLogFormatter.php @@ -0,0 +1,83 @@ + 0) { + $output .= "\n Trace:\n\n"; + $idx = 0; + foreach ($trace as $step) { + $output .= sprintf( + " #%d %s::%s()\n", + $idx++, + isset($step['file']) ? $step['file'].':'.$step['line'] : $step['class'], + $step['function'] + ); + + $args = isset($step['args']) ? $step['args'] : []; + if (count($args) > 0) { + $output .= " Arguments:\n"; + foreach ($args as $arg) { + $output .= " {$this->processArgument($arg)}\n"; + } + } + + $output .= "\n"; + } + } + + return $output; + } + + /** + * @param mixed $argument Trace arguments. + * + * @return string + */ + private function processArgument($argument) + { + switch (true) { + case is_array($argument): + $result = ''; + + foreach ($argument as $key => $item) { + $result .= "{$key} => {$this->processArgument($item)},"; + } + + return "[{$result}]"; + + case is_object($argument): + return get_class($argument); + + default: + return var_export($argument, true); + } + } +} diff --git a/src/AppBundle/Resources/config/commands.yml b/src/AppBundle/Resources/config/commands.yml new file mode 100644 index 0000000..bb7718e --- /dev/null +++ b/src/AppBundle/Resources/config/commands.yml @@ -0,0 +1,53 @@ +services: + app.command.fetch_sources: + class: 'AppBundle\Command\FetchSourcesCommand' + arguments: + - '@app.source_manager' + - '@monolog.logger.queue_command' + tags: + - { name: console.command } + + app.command.generate: + class: 'AppBundle\Command\GenerateCommand' + arguments: + - '@index.external.internal_hose' + tags: + - { name: console.command } + + app.command.reindex_documents: + class: 'AppBundle\Command\ReindexDocumentsCommand' + arguments: + - '@monolog.logger.queue_command' + - '%cache_index.host%' + - '%cache_index.port%' + - '%kernel.root_dir%/../var' + tags: + - { name: console.command } + + app.command.load_data_fixtures: + class: 'AppBundle\Command\LoadDataFixturesCommand' + arguments: + - '@kernel' + - '@doctrine.orm.default_entity_manager' + - '@index.external.internal_hose' + - '@index.articles' + - '@index.sources' + - '@service_container' + - '@monolog.logger.queue_command' + tags: + - { name: console.command } + + app.command.sync_site_config: + class: 'AppBundle\Command\SyncSiteConfigCommand' + arguments: + - '@app.configuration' + tags: + - { name: console.command } + + app.command.update_stored_queries: + class: 'AppBundle\Command\UpdateStoredQueriesCommand' + arguments: + - '@doctrine.orm.default_entity_manager' + - '@queue.producer.documents_fetch' + tags: + - { name: console.command } \ No newline at end of file diff --git a/src/AppBundle/Resources/config/configuration.yml b/src/AppBundle/Resources/config/configuration.yml new file mode 100644 index 0000000..a1d5d6f --- /dev/null +++ b/src/AppBundle/Resources/config/configuration.yml @@ -0,0 +1,19 @@ +services: + app.configuration_definitions: + class: 'AppBundle\Configuration\ConfigurationDefinitionMap' + public: false + + app.configuration.abstract: + class: 'AppBundle\Configuration\AbstractConfiguration' + arguments: + - '@app.configuration_definitions' + abstract: true + + app.configuration.orm: + class: 'AppBundle\Configuration\ORMConfiguration' + parent: 'app.configuration.abstract' + arguments: + - '@doctrine.orm.default_entity_manager' + lazy: true + + app.configuration: '@app.configuration.orm' \ No newline at end of file diff --git a/src/AppBundle/Resources/config/controllers.yml b/src/AppBundle/Resources/config/controllers.yml new file mode 100644 index 0000000..3cd7236 --- /dev/null +++ b/src/AppBundle/Resources/config/controllers.yml @@ -0,0 +1,90 @@ +services: + app.controller.abstract_v1_crud: + class: 'AppBundle\Controller\V1\AbstractV1CrudController' + arguments: + - '@form.factory' + - '@api.access_checker' + - '@doctrine.orm.default_entity_manager' + abstract: true + + app.controller.index: + class: 'AppBundle\Controller\IndexController' + arguments: + - '@doctrine.orm.default_entity_manager' + - '@cache.feed_formatter' + + app.controller.category: + class: 'AppBundle\Controller\V1\CategoryController' + parent: 'api.controller.abstract_crud' + arguments: + index_1: 'CacheBundle\Entity\Category' + + app.controller.query: + class: 'AppBundle\Controller\V1\QueryController' + arguments: + - '@form.factory' + - '@doctrine.orm.default_entity_manager' + - '@security.token_storage' + - '@app.source_manager' + - '@app.simple_query_manager' + - '@cache.document_content_extractor' + + app.controller.feed: + class: 'AppBundle\Controller\V1\FeedController' + parent: app.controller.abstract_v1_crud + arguments: + - 'CacheBundle\Entity\Feed\AbstractFeed' + - '@security.token_storage' + - '@app.feed_manager' + - '@cache.document_content_extractor' + - '@cache.feed_fetcher_factory' + - '@kernel' + - '@app.stored_query_manager' + - '@queue.producer.documents_fetch' + - '%feed.limit%' + + + app.controller.analytic: + class: 'AppBundle\Controller\V1\AnalyticController' + parent: api.controller.abstract_crud + arguments: + index_1: 'CacheBundle\Entity\Analytic\Analytic' + + app.controller.comment: + class: 'AppBundle\Controller\V1\CommentController' + parent: app.controller.abstract_v1_crud + arguments: + - 'CacheBundle\Entity\Comment' + + app.controller.document: + class: 'AppBundle\Controller\V1\DocumentController' + arguments: + - '@security.token_storage' + - '@form.factory' + - '@api.access_checker' + - '@doctrine.orm.default_entity_manager' + - '@cache.comment_manager' + - '@queue.producer.documents_email' + + app.controller.source-index: + class: 'AppBundle\Controller\V1\SourceIndexController' + arguments: + - '@security.token_storage' + - '@form.factory' + - '@app.source_manager' + - '@doctrine.orm.default_entity_manager' + + app.controller.source-list: + class: 'AppBundle\Controller\V1\SourceListController' + parent: api.controller.abstract + + app.controller.analytic-graph: + class: 'AppBundle\Controller\V1\AnalyticGraphController' + parent: api.controller.abstract_crud + arguments: + index_1: 'CacheBundle\Entity\Analytic\Analytic' + app.controller.analytic-mention-graph: + class: 'AppBundle\Controller\V1\AnalyticMentionGraphController' + parent: api.controller.abstract_crud + arguments: + index_1: 'CacheBundle\Entity\Analytic\Analytic' diff --git a/src/AppBundle/Resources/config/forms.yml b/src/AppBundle/Resources/config/forms.yml new file mode 100644 index 0000000..6330735 --- /dev/null +++ b/src/AppBundle/Resources/config/forms.yml @@ -0,0 +1,42 @@ +services: + app.form_factory.filter_factory_aware.external: + class: 'AppBundle\Form\Factory\FilterFactoryAwareTypeFactory' + arguments: + - '@index.external' + - '%search.page_size%' + public: false + + app.form_factory.filter_factory_aware.internal: + class: 'AppBundle\Form\Factory\FilterFactoryAwareTypeFactory' + arguments: + - '@index.articles' + - '%search.page_size%' + public: false + + app.form.simple_query: + class: 'AppBundle\Form\SearchRequest\SimpleQuerySearchRequestType' + factory: [ '@app.form_factory.filter_factory_aware.external', 'create' ] + arguments: [ 'AppBundle\Form\SearchRequest\SimpleQuerySearchRequestType' ] + tags: + - { name: form.type } + + app.form.stored_query: + class: 'AppBundle\Form\SearchRequest\StoredQuerySearchRequestType' + factory: [ '@app.form_factory.filter_factory_aware.internal', 'create' ] + arguments: [ 'AppBundle\Form\SearchRequest\StoredQuerySearchRequestType' ] + tags: + - { name: form.type } + + app.form.clip_feed: + class: 'AppBundle\Form\SearchRequest\ClipFeedSearchRequestType' + factory: [ '@app.form_factory.filter_factory_aware.internal', 'create' ] + arguments: [ 'AppBundle\Form\SearchRequest\ClipFeedSearchRequestType' ] + tags: + - { name: form.type } + + app.form.feed_document_search: + class: 'AppBundle\Form\FeedDocumentSearchType' + factory: [ '@app.form_factory.filter_factory_aware.internal', 'create' ] + arguments: [ 'AppBundle\Form\FeedDocumentSearchType' ] + tags: + - { name: form.type } diff --git a/src/AppBundle/Resources/config/managers.yml b/src/AppBundle/Resources/config/managers.yml new file mode 100644 index 0000000..aa85e1f --- /dev/null +++ b/src/AppBundle/Resources/config/managers.yml @@ -0,0 +1,29 @@ +services: + app.simple_query_manager: + class: 'AppBundle\Manager\SimpleQuery\SimpleQueryManager' + arguments: + - '@doctrine.orm.default_entity_manager' + - '%cache.query_lifetime%' + + app.stored_query_manager: + class: 'AppBundle\Manager\StoredQuery\StoredQueryManager' + arguments: + - '@index.external' + - '@index.articles' + - '@doctrine.orm.default_entity_manager' + - '%search.page_size%' + + app.source_manager: + class: 'AppBundle\Manager\Source\SourceManager' + arguments: + - '@index.sources' + - '@index.external' + - '@doctrine.orm.default_entity_manager' + - '@monolog.logger.queue_command' + - '%kernel.root_dir%/../var' + + app.feed_manager: + class: 'AppBundle\Manager\Feed\FeedManager' + arguments: + - '@doctrine.orm.default_entity_manager' + - '@index.articles' \ No newline at end of file diff --git a/src/AppBundle/Resources/config/nelmio_form.yml b/src/AppBundle/Resources/config/nelmio_form.yml new file mode 100644 index 0000000..3b94f5e --- /dev/null +++ b/src/AppBundle/Resources/config/nelmio_form.yml @@ -0,0 +1,12 @@ +# +# Hack for avoiding errors related to 'unknown description field' on production +# where we don't load NelmioApiDocBundle +# +services: + nelmio_api_doc.form.extension.description_form_type_extension: + class: 'Nelmio\ApiDocBundle\Form\Extension\DescriptionFormTypeExtension' + tags: + - + name: 'form.type_extension' + alias: 'form' + extended_type: 'Symfony\Component\Form\Extension\Core\Type\FormType' diff --git a/src/AppBundle/Resources/config/routing/v1.yml b/src/AppBundle/Resources/config/routing/v1.yml new file mode 100644 index 0000000..c479430 --- /dev/null +++ b/src/AppBundle/Resources/config/routing/v1.yml @@ -0,0 +1,3 @@ +v1: + resource: '@AppBundle/Controller/V1' + type: annotation \ No newline at end of file diff --git a/src/AppBundle/Resources/config/services.yml b/src/AppBundle/Resources/config/services.yml new file mode 100644 index 0000000..b6f3fd2 --- /dev/null +++ b/src/AppBundle/Resources/config/services.yml @@ -0,0 +1,28 @@ +imports: + - { resource: controllers.yml } + - { resource: forms.yml } + - { resource: configuration.yml } + - { resource: commands.yml } + - { resource: managers.yml } + +services: + app.cache: + class: 'AppBundle\Cache\DoctrineCacheItemPool' + arguments: + - '@doctrine.orm.default_entity_manager' + + app.pagination_listener.results: + class: 'AppBundle\EventListener\ResponsePagination' + tags: + - { name: knp_paginator.subscriber } + + app.form_extension.localization: + class: 'AppBundle\Form\Type\Extension\LocalizationTypeExtension' + tags: + - + name: 'form.type_extension' + alias: 'form' + extended_type: 'Symfony\Component\Form\Extension\Core\Type\FormType' + + app.formatter.trace: + class: 'AppBundle\Monolog\Formatter\TraceLogFormatter' diff --git a/src/AppBundle/Resources/views/index.html.twig b/src/AppBundle/Resources/views/index.html.twig new file mode 100644 index 0000000..199a96c --- /dev/null +++ b/src/AppBundle/Resources/views/index.html.twig @@ -0,0 +1,23 @@ +{% extends '::base.html.twig' %} + +{%- block title -%} + Social Listening Platform | Social Analytics | SOCIALHOSE.IO App +{%- endblock -%} + +{% block body %} +
    +{% endblock body %} + +{% block javascripts %} + + + +{% endblock javascripts %} + +{% block stylesheets %} + +{% endblock stylesheets %} diff --git a/src/AppBundle/Response/SearchAndCacheResponse.php b/src/AppBundle/Response/SearchAndCacheResponse.php new file mode 100644 index 0000000..153ca96 --- /dev/null +++ b/src/AppBundle/Response/SearchAndCacheResponse.php @@ -0,0 +1,65 @@ +query = $query; + } + + /** + * @param SimpleQuery $query A SimpleQuery entity. + * @param SearchResponse $response A SearchResponse instance. + * + * @return SearchAndCacheResponse + */ + public static function fromSearchResponse(SimpleQuery $query, SearchResponse $response) + { + return new self( + $query, + $response->getDocuments(), + $response->getAggregationResults(), + $response->getTotalCount(), + $response->getUniqueCount() + ); + } + + /** + * @return SimpleQuery + */ + public function getQuery() + { + return $this->query; + } +} diff --git a/src/AppBundle/Response/SearchResponse.php b/src/AppBundle/Response/SearchResponse.php new file mode 100644 index 0000000..f6cdb4c --- /dev/null +++ b/src/AppBundle/Response/SearchResponse.php @@ -0,0 +1,193 @@ +documents = $documents; + $this->aggregationResults = $aggregationResults; + $this->totalCount = $totalCount; + $this->uniqueCount = $uniqueCount; + $this->fromCache = $fromCache; + } + + /** + * Get documents from response. + * + * @return DocumentInterface[] + */ + public function getDocuments() + { + return $this->documents; + } + + /** + * @param callable|\Closure $callback Mapper callback. + * + * @return SearchResponse + */ + public function mapDocuments($callback) + { + $this->documents = array_map($callback, $this->documents); + + return $this; + } + + /** + * Get response aggregation results. + * + * @return array + */ + public function getAggregationResults() + { + return $this->aggregationResults; + } + + /** + * Get total count of available results. + * + * @return integer + */ + public function getTotalCount() + { + return $this->totalCount; + } + + /** + * Get unique documents count. + * + * @return integer + */ + public function getUniqueCount() + { + return $this->uniqueCount; + } + + /** + * @return boolean + */ + public function isFromCache() + { + return $this->fromCache; + } + + /** + * Return count of results in current response. + * + * @return integer + */ + public function count() + { + return count($this->documents); + } + + /** + * @param integer $offset The offset to retrieve. + * + * @return boolean true on success or false on failure. + */ + public function offsetExists($offset) + { + return isset($this->results[$offset]); + } + + /** + * @param mixed $offset The offset to retrieve. + * + * @return mixed Can return all value types. + */ + public function offsetGet($offset) + { + return $this->documents[$offset]; + } + + /** + * @param mixed $offset The offset to assign the value to. + * @param mixed $value The value to set. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function offsetSet($offset, $value) + { + throw new \RuntimeException('Can\'t change result set.'); + } + + /** + * @param mixed $offset The offset to unset. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function offsetUnset($offset) + { + throw new \RuntimeException('Can\'t change result set.'); + } + + /** + * Retrieve an external iterator. + * + * @return \Traversable + */ + public function getIterator() + { + return new \ArrayIterator($this->documents); + } +} diff --git a/src/AppBundle/Response/SearchResponseInterface.php b/src/AppBundle/Response/SearchResponseInterface.php new file mode 100644 index 0000000..ff9b338 --- /dev/null +++ b/src/AppBundle/Response/SearchResponseInterface.php @@ -0,0 +1,57 @@ +purger = $purger; + $purger->setPurgeMode(ORMPurger::PURGE_MODE_TRUNCATE); + } + + + /** + * Purge the data from the database for the given EntityManager. + * + * @return void + */ + public function purge() + { + $connection = $this->purger->getObjectManager()->getConnection(); + $connection->exec('SET FOREIGN_KEY_CHECKS = 0'); + $this->purger->purge(); + $connection->exec('SET FOREIGN_KEY_CHECKS = 1'); + } +} diff --git a/src/AppBundle/Utils/TransKey/ConstTransKeyGenerator.php b/src/AppBundle/Utils/TransKey/ConstTransKeyGenerator.php new file mode 100644 index 0000000..d363c94 --- /dev/null +++ b/src/AppBundle/Utils/TransKey/ConstTransKeyGenerator.php @@ -0,0 +1,42 @@ +key = $key; + } + + /** + * Generate proper transformation key for specified form. + * + * @param FormInterface $form A FormInterface instance. + * + * @return string + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function generate(FormInterface $form) + { + return $this->key; + } +} diff --git a/src/AppBundle/Utils/TransKey/RecursiveTransKeyGenerator.php b/src/AppBundle/Utils/TransKey/RecursiveTransKeyGenerator.php new file mode 100644 index 0000000..08cd182 --- /dev/null +++ b/src/AppBundle/Utils/TransKey/RecursiveTransKeyGenerator.php @@ -0,0 +1,73 @@ +getParent(); + if ($parent === null) { + // + // We should'nt include parent form name in key. Instead of it + // we add action name which we generate from HTTP method and also + // managed entity short name. + // + switch ($form->getConfig()->getMethod()) { + case Request::METHOD_POST: + $action = 'create'; + break; + + case Request::METHOD_GET: + $action = 'get'; + break; + + case Request::METHOD_PUT: + $action = 'update'; + break; + + case Request::METHOD_DELETE: + $action = 'delete'; + break; + + default: + throw new \InvalidArgumentException( + "Unsupported method {$form->getConfig()->getMethod()}" + ); + } + + return $action . \app\c\getShortName($form->getConfig()->getDataClass()); + } + + // + // We should use concrete field key generator. + // + $generator = $parent->getConfig()->getOption('key'); + $parentKey = $generator->generate($parent); + $name = $form->getName(); + if (is_numeric($name)) { + // + // Entry type of CollectionType had order number instead of name and + // we should'nt add it to translation key. + // + $name = ''; + } + + return ($parentKey === '') ? $name : $parentKey.ucfirst($name); + } +} diff --git a/src/AppBundle/Utils/TransKey/TransKeyGeneratorInterface.php b/src/AppBundle/Utils/TransKey/TransKeyGeneratorInterface.php new file mode 100644 index 0000000..a289798 --- /dev/null +++ b/src/AppBundle/Utils/TransKey/TransKeyGeneratorInterface.php @@ -0,0 +1,22 @@ + $element) { + $groupKey = $callback($element, $index, $sequence); + + if (!isset($groups[$groupKey])) { + $groups[$groupKey] = array(); + } + + $groups[$groupKey][$index] = $element; + } + + return $groups; +} + +/** + * Looks through each element in the list, returning an array of all the elements + * that pass a truthy test (callback). + * + * @param callable $callback Callback used for selecting elements. + * @param array|\Traversable $sequence Traversable collection of items. + * + * @return array + */ +// @codingStandardsIgnoreStart +function select(callable $callback, $sequence) +{ +// @codingStandardsIgnoreEnd + args\expects(args\traversable, $sequence); + + $aggregation = array(); + + foreach ($sequence as $index => $element) { + if ($callback($element, $index, $sequence)) { + $aggregation[$index] = $element; + } + } + + return $aggregation; +} + +/** + * Binary search. + * Specified collection should be already sorted in ascending order. + * + * @param \Traversable|array $sequence A collection. + * @param mixed $searched Searched value. + * @param callable|string $callback Number of elements pop from collection. + * By default try to get 'id' property. + * + * @return integer|false Searched item index or false if can't find. + */ +// @codingStandardsIgnoreStart +function binarySearch($sequence, $searched, $callback = null) +{ +// @codingStandardsIgnoreEnd + + args\expects(args\traversable, $sequence); + + if (is_string($callback)) { + $callback = \nspl\op\propertyGetter($callback); + } + + $search = function ($start, $end) use ($sequence, $callback, $searched, &$search) { + if ($start > $end) { + return false; + } + + $middle = $start + (int) floor(($end - $start) / 2); + + $value = $sequence[$middle]; + if ($callback) { + $value = $callback($value); + } + + if ($searched < $value) { + return $search($start, $middle - 1); + } elseif ($searched > $value) { + return $search($middle + 1, $end); + } + + return $middle; + }; + + // Make first call with initial bounds. + + $start = 0; + $end = count($sequence) - 1; + return $search($start, $end); +} diff --git a/src/AppFunctional/classes.php b/src/AppFunctional/classes.php new file mode 100644 index 0000000..378f5d9 --- /dev/null +++ b/src/AppFunctional/classes.php @@ -0,0 +1,46 @@ +getExtension('security'); + $extension->addSecurityListenerFactory(new AuthenticationFactory()); + } +} diff --git a/src/AuthenticationBundle/Controller/TokenController.php b/src/AuthenticationBundle/Controller/TokenController.php new file mode 100644 index 0000000..ffece84 --- /dev/null +++ b/src/AuthenticationBundle/Controller/TokenController.php @@ -0,0 +1,129 @@ +get('gesdinet.jwtrefreshtoken'); + + if (! $request->request->get('refreshToken')) { + return AppResponse::badRequest('refreshToken: This value should not be null.'); + } + + return $refresher->refresh($request); + } +} diff --git a/src/AuthenticationBundle/DependencyInjection/Security/Factory/AuthenticationFactory.php b/src/AuthenticationBundle/DependencyInjection/Security/Factory/AuthenticationFactory.php new file mode 100644 index 0000000..7a99d27 --- /dev/null +++ b/src/AuthenticationBundle/DependencyInjection/Security/Factory/AuthenticationFactory.php @@ -0,0 +1,97 @@ +setDefinition( + $providerId, + new DefinitionDecorator('security.authentication.provider.dao') + ) + ->replaceArgument(0, new Reference($userProvider)) + ->replaceArgument(1, new Reference('security.user_checker')) + ->replaceArgument(2, $id); + + $listenerId = 'security.authentication.listener.get.jwt.'.$id; + $container + ->setDefinition( + $listenerId, + new DefinitionDecorator('authentication_bundle.authentication.listener') + ) + ->replaceArgument(4, $id); + + return [ $providerId, $listenerId, $defaultEntryPoint ]; + } + + /** + * Defines the position at which the provider is called. + * Possible values: pre_auth, form, http, and remember_me. + * + * @return string + */ + public function getPosition() + { + return 'pre_auth'; + } + + /** + * Defines the configuration key used to reference the provider + * in the firewall configuration. + * + * @return string + */ + public function getKey() + { + return 'socialhose_auth'; + } + + /** + * @param NodeDefinition $builder A NodeDefinition instance. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function addConfiguration(NodeDefinition $builder) + { + // Do nothing. + } +} diff --git a/src/AuthenticationBundle/EventListener/AuthenticationSubscriber.php b/src/AuthenticationBundle/EventListener/AuthenticationSubscriber.php new file mode 100644 index 0000000..a13c2b8 --- /dev/null +++ b/src/AuthenticationBundle/EventListener/AuthenticationSubscriber.php @@ -0,0 +1,92 @@ + 'methodName') + * * array('eventName' => array('methodName', $priority)) + * * array('eventName' => array(array('methodName1', $priority), + * array('methodName2'))) + * + * @return array The event names to listen to + */ + public static function getSubscribedEvents() + { + return [ + Events::AUTHENTICATION_SUCCESS => 'onAuthSuccess', + Events::JWT_EXPIRED => 'onExpired', + Events::JWT_INVALID => 'onInvalid', + ]; + } + + /** + * @param AuthenticationSuccessEvent $event A AuthenticationSuccessEvent + * instance. + * + * @return void + */ + public function onAuthSuccess(AuthenticationSuccessEvent $event) + { + $data = $event->getData(); + + // + // Change refresh token name to camelCase. + // + if (isset($data['refresh_token'])) { + $data['refreshToken'] = $data['refresh_token']; + unset($data['refresh_token']); + } + + $event->setData($data); + } + + /** + * Change default response about expired token to proper response. + * + * @param JWTExpiredEvent $event A JWTExpiredEvent instance. + * + * @return void + */ + public function onExpired(JWTExpiredEvent $event) + { + $event->setResponse(AppResponse::unauthorized('Expired JWT Token.')); + } + + /** + * Change default response about invalid token to proper response. + * + * @param JWTInvalidEvent $event A JWTInvalidEvent instance. + * + * @return void + */ + public function onInvalid(JWTInvalidEvent $event) + { + $event->setResponse(AppResponse::unauthorized('Invalid JWT Token.')); + } +} diff --git a/src/AuthenticationBundle/Resources/config/services.yml b/src/AuthenticationBundle/Resources/config/services.yml new file mode 100644 index 0000000..7890e29 --- /dev/null +++ b/src/AuthenticationBundle/Resources/config/services.yml @@ -0,0 +1,66 @@ +services: + # + # Override refresh token authenticator. + # We did this because all our api methods except parameters names in + # camelCase but original authenticator expect 'refresh_token' instead of + # 'refreshToken' ... + # + gesdinet.jwtrefreshtoken.authenticator: + class: 'AuthenticationBundle\Security\Http\Authentication\RefreshTokenAuthenticator' + + # + # Override default FOS user provider. + # Add user role checker. + # + authentication_bundle.user_provider: + class: 'AuthenticationBundle\Security\Core\User\EmailUserProvider' + arguments: + - '@fos_user.user_manager' + - '@user.role_checker' + public: false + + authentication_bundle.controller.token: + class: 'AuthenticationBundle\Controller\TokenController' + parent: api.controller.abstract + + authentication_bundle.event_listener.authentication: + class: 'AuthenticationBundle\EventListener\AuthenticationSubscriber' + tags: + - { name: kernel.event_subscriber } + + # + # Setup authentication handlers. + # + authentication_bundle.authentication_handler.success: + class: 'AuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler' + arguments: + - '@lexik_jwt_authentication.jwt_manager' + - '@event_dispatcher' + - '@serializer' + - '@fos_user.user_manager' + tags: + - { name: monolog.logger, channel: security } + # Setup alias for refresh token service. + lexik_jwt_authentication.handler.authentication_success: '@authentication_bundle.authentication_handler.success' + + authentication_bundle.authentication_handler.failure: + class: 'AuthenticationBundle\Security\Http\Authentication\AuthenticationFailureHandler' + arguments: + - '@event_dispatcher' + tags: + - { name: monolog.logger, channel: security } + # Setup alias for refresh token service. + lexik_jwt_authentication.handler.authentication_failure: '@authentication_bundle.authentication_handler.failure' + + authentication_bundle.authentication.listener: + class: 'AuthenticationBundle\Security\Http\Firewall\AuthenticationListener' + arguments: + - '@security.token_storage' + - '@security.authentication.manager' + - '@authentication_bundle.authentication_handler.success' + - '@authentication_bundle.authentication_handler.failure' + - # provider key injected by security factory + + authentication_bundle.user_checker: + class: 'AuthenticationBundle\Security\Core\User\UserChecker' + security.user_checker: '@authentication_bundle.user_checker' \ No newline at end of file diff --git a/src/AuthenticationBundle/Security/Core/Exception/NotVerifiedException.php b/src/AuthenticationBundle/Security/Core/Exception/NotVerifiedException.php new file mode 100644 index 0000000..125f31c --- /dev/null +++ b/src/AuthenticationBundle/Security/Core/Exception/NotVerifiedException.php @@ -0,0 +1,24 @@ +roleChecker = $roleChecker; + } + + /** + * Finds a user by email. + * + * @param string $email User email. + * + * @return UserInterface|null + */ + protected function findUser($email) + { + $user = $this->userManager->findUserByEmail($email); + + // Restrict super admin and ordinal admin's login to front side. + if (($user instanceof User) + && $this->roleChecker->has($user, UserRoleEnum::SUBSCRIBER)) { + return $user; + } + + return null; + } +} diff --git a/src/AuthenticationBundle/Security/Core/User/UserChecker.php b/src/AuthenticationBundle/Security/Core/User/UserChecker.php new file mode 100644 index 0000000..20799f6 --- /dev/null +++ b/src/AuthenticationBundle/Security/Core/User/UserChecker.php @@ -0,0 +1,50 @@ +isVerified()) { + $exception = new NotVerifiedException('User account is not verified.'); + $exception->setUser($user); + throw $exception; + } + + if ($user->hasRole(UserRoleEnum::SUBSCRIBER) && ! $user->getBillingSubscription()->isPayed()) { + $exception = new PaymentRequiredException('Billing subscription not paid.'); + $exception->setUser($user); + throw $exception; + } + } +} diff --git a/src/AuthenticationBundle/Security/Http/Authentication/AuthenticationFailureHandler.php b/src/AuthenticationBundle/Security/Http/Authentication/AuthenticationFailureHandler.php new file mode 100644 index 0000000..43730d4 --- /dev/null +++ b/src/AuthenticationBundle/Security/Http/Authentication/AuthenticationFailureHandler.php @@ -0,0 +1,46 @@ +getMessage()); + + $event = new AuthenticationFailureEvent($exception, $response); + $this->dispatcher->dispatch(Events::AUTHENTICATION_FAILURE, $event); + + return $event->getResponse(); + } +} diff --git a/src/AuthenticationBundle/Security/Http/Authentication/AuthenticationSuccessHandler.php b/src/AuthenticationBundle/Security/Http/Authentication/AuthenticationSuccessHandler.php new file mode 100644 index 0000000..b62ebc2 --- /dev/null +++ b/src/AuthenticationBundle/Security/Http/Authentication/AuthenticationSuccessHandler.php @@ -0,0 +1,107 @@ +normalizer = $normalizer; + $this->userManager = $userManager; + } + + /** + * This is called when an interactive authentication attempt succeeds. This + * is called by authentication listeners inheriting from + * AbstractAuthenticationListener. + * + * @param Request $request A Request instance. + * @param TokenInterface $token A TokenInterface instance. + * + * @return \Symfony\Component\HttpFoundation\Response The response to return, + * never null + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function onAuthenticationSuccess(Request $request, TokenInterface $token) + { + $user = $token->getUser(); + + // Update use last login. + // + // We not flush changes right now because we also generate refresh token + // and put it too into database. + // + $user->setLastLogin(new \DateTime()); + $this->userManager->updateUser($user, false); + + $jwtToken = $this->jwtManager->create($user); + + $response = AppMergeableResponse::create() + ->setData([ + 'user' => $this->normalizer->normalize($user, null, [ + 'id', + 'user', + 'recipient', + 'restrictions', + ]), + 'token' => $jwtToken, + ]) + ->setOriginalPriority(true); + + $event = new AuthenticationSuccessEvent( + [ 'token' => $jwtToken ], + $user, + $response + ); + $this->dispatcher->dispatch(Events::AUTHENTICATION_SUCCESS, $event); + + return $response->setData($event->getData()); + } +} diff --git a/src/AuthenticationBundle/Security/Http/Authentication/RefreshTokenAuthenticator.php b/src/AuthenticationBundle/Security/Http/Authentication/RefreshTokenAuthenticator.php new file mode 100644 index 0000000..5aa5e05 --- /dev/null +++ b/src/AuthenticationBundle/Security/Http/Authentication/RefreshTokenAuthenticator.php @@ -0,0 +1,32 @@ +request->get('refreshToken'); + + return new PreAuthenticatedToken( + '', + $refreshTokenString, + $providerKey + ); + } +} diff --git a/src/AuthenticationBundle/Security/Http/Firewall/AuthenticationListener.php b/src/AuthenticationBundle/Security/Http/Firewall/AuthenticationListener.php new file mode 100644 index 0000000..0572108 --- /dev/null +++ b/src/AuthenticationBundle/Security/Http/Firewall/AuthenticationListener.php @@ -0,0 +1,135 @@ +storage = $storage; + $this->manager = $manager; + $this->successHandler = $successHandler; + $this->failureHandler = $failureHandler; + $this->providerKey = $providerKey; + } + + /** + * This interface must be implemented by firewall listeners. + * + * @param GetResponseEvent $event A GetResponseEvent instance. + * + * @return void + */ + public function handle(GetResponseEvent $event) + { + $request = $event->getRequest(); + + // Check request method. + // We allow only post request to authentication endpoint. + if (! $request->isMethod('POST')) { + $event->setResponse(AppResponse::create( + 'Invalid method.', + AppResponse::HTTP_METHOD_NOT_ALLOWED + )); + return; + } + + // Try to decode request body as json. + $payload = json_decode($request->getContent(), true); + if (json_last_error() !== JSON_ERROR_NONE) { + $event->setResponse(AppResponse::badRequest( + 'Json decode: '. json_last_error_msg() .'.' + )); + return; + } + + // Check payload. + if (! isset($payload['email'], $payload['password'])) { + $event->setResponse(AppResponse::badRequest( + 'Credentials not provided.' + )); + return; + } + + // Try to authenticate token. + try { + $token = new UsernamePasswordToken( + trim($payload['email']), + trim($payload['password']), + $this->providerKey + ); + $token = $this->manager->authenticate($token); + $this->storage->setToken($token); + + $response = $this->successHandler + ->onAuthenticationSuccess($request, $token); + } catch (AuthenticationException $e) { + $response = $this->failureHandler + ->onAuthenticationFailure($request, $e); + } + + $event->setResponse($response); + } +} diff --git a/src/CacheBundle/CacheBundle.php b/src/CacheBundle/CacheBundle.php new file mode 100644 index 0000000..e427025 --- /dev/null +++ b/src/CacheBundle/CacheBundle.php @@ -0,0 +1,29 @@ +addCompilerPass(new CollectFeedFetchersCompilerPass()); + } +} diff --git a/src/CacheBundle/CacheBundleServices.php b/src/CacheBundle/CacheBundleServices.php new file mode 100644 index 0000000..295a562 --- /dev/null +++ b/src/CacheBundle/CacheBundleServices.php @@ -0,0 +1,41 @@ +em = $em; + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure() + { + $this->setDescription('Remove old queries from cache'); + } + + /** + * Executes the current command. + * + * This method is not abstract because you can use this class + * as a concrete class. In this case, instead of defining the + * execute() method, you set the code to execute by passing + * a Closure to the setCode() method. + * + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer null or 0 if everything went fine, or an error code. + * + * @throws \LogicException When this abstract method is not implemented. + * + * @see setCode() + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + /** @var SimpleQueryRepository $queryRepository */ + $queryRepository = $this->em->getRepository(SimpleQuery::class); + /** @var EntityRepository $pageRepository */ + $pageRepository = $this->em->getRepository(Page::class); + + $expr = $this->em->getExpressionBuilder(); + + $ids = $queryRepository->getOld(); + $pageRepository->createQueryBuilder('Page') + ->delete() + ->where($expr->in('Page.query', $ids)) + ->getQuery() + ->execute(); + + $queryRepository->createQueryBuilder('Query') + ->delete() + ->where($expr->in('Query.id', $ids)) + ->getQuery() + ->execute(); + + return 0; + } +} diff --git a/src/CacheBundle/Comment/Manager/CommentManager.php b/src/CacheBundle/Comment/Manager/CommentManager.php new file mode 100644 index 0000000..fbd81ee --- /dev/null +++ b/src/CacheBundle/Comment/Manager/CommentManager.php @@ -0,0 +1,55 @@ +em = $em; + } + + /** + * Add new comment to specified entity. + * + * @param Comment $comment A Comment entity instance. + * @param Document $document A Document entity instance. + * + * @return Comment + */ + public function addComment(Comment $comment, Document $document) + { + $comment->setDocument($document); + $document->incCommentsCount(); + + $this->em->persist($document); + $this->em->persist($comment); + $this->em->flush(); + + /** @var CommentRepository $repository */ + $repository = $this->em->getRepository(Comment::class); + $repository->updateCommentMarks($document->getId(), self::NEW_COMMENT_POOL_SIZE); + + return $comment; + } +} diff --git a/src/CacheBundle/Comment/Manager/CommentManagerInterface.php b/src/CacheBundle/Comment/Manager/CommentManagerInterface.php new file mode 100644 index 0000000..5715f0d --- /dev/null +++ b/src/CacheBundle/Comment/Manager/CommentManagerInterface.php @@ -0,0 +1,29 @@ +feeds = $feeds; + $this->owner = $owner; + $this->filters = $filters; + $this->rawFilters = $rawFilters; + } +} diff --git a/src/CacheBundle/DependencyInjection/Compiler/CollectFeedFetchersCompilerPass.php b/src/CacheBundle/DependencyInjection/Compiler/CollectFeedFetchersCompilerPass.php new file mode 100644 index 0000000..1162d84 --- /dev/null +++ b/src/CacheBundle/DependencyInjection/Compiler/CollectFeedFetchersCompilerPass.php @@ -0,0 +1,65 @@ +has(self::LAZY_FACTORY_ID)) { + $this->throwException('Lazy factory not registered.'); + } + + $lazyFactory = $container->getDefinition(self::LAZY_FACTORY_ID); + if ($lazyFactory->getClass() !== LazyFeedFetcherFactory::class) { + $this->throwException( + 'Invalid factory, expected '. LazyFeedFetcherFactory::class + .' but got '. $lazyFactory->getClass() + ); + } + + $fetchers = array_keys($container->findTaggedServiceIds('socialhose.feed_fetcher')); + + $map = []; + foreach ($fetchers as $id) { + $class = $container->getDefinition($id)->getClass(); + $reflection = new \ReflectionClass($class); + if (! $reflection->implementsInterface(FeedFetcherInterface::class)) { + $this->throwException(''); + } + + $map[$class::support()] = $id; + } + + $lazyFactory->replaceArgument(1, $map); + } + + /** + * @param string $message A additional exception message. + * + * @return void + */ + private function throwException($message = '') + { + throw new \RuntimeException('Can\'t register feed fetchers in lazy factory. '. $message); + } +} diff --git a/src/CacheBundle/Document/Extractor/BasicDocumentContentExtractor.php b/src/CacheBundle/Document/Extractor/BasicDocumentContentExtractor.php new file mode 100644 index 0000000..04bd67a --- /dev/null +++ b/src/CacheBundle/Document/Extractor/BasicDocumentContentExtractor.php @@ -0,0 +1,271 @@ +startExtractLen = $startExtractLen; + $this->contextExtractLen = $contextExtractLen; + } + + /** + * @param string $content The document contents. + * @param string $query Search query. + * @param ThemeOptionExtractEnum $extract Extract type. + * @param boolean $highlight Should highlight matched keywords + * or not. + * + * @return ExtractionResult + */ + public function extract( + $content, + $query, + ThemeOptionExtractEnum $extract, + $highlight = false + ) { + switch ($extract->getValue()) { + // + // We should not extract document content. + // + case ThemeOptionExtractEnum::NO: + $text = ''; + $offset = ''; + $extractedLength = ''; + break; + + // + // Extract specific numbers of characters from start of document. + // + case ThemeOptionExtractEnum::START: + $text = mb_substr($content, 0, $this->startExtractLen); + $offset = 0; + $extractedLength = $this->startExtractLen; + break; + + // + // Extract specified number of character before and after first + // matched keyword. + // + case ThemeOptionExtractEnum::CONTEXT: + $keywords = $this->splitQueryOnKeywords($query); + list ($offset, $keywordLength) = $this->getNearestKeyword($keywords, $content); + + if ($offset === -1) { + // + // We don't find any of search keywords. It maybe when matched + // keyword is found in another document property, like 'title'. + // + // In this case we fallback to 'start' extractor. + // + $text = mb_substr($content, 0, $this->startExtractLen); + $offset = 0; + $extractedLength = $this->startExtractLen; + } else { + // + // Convert current offset into proper UTF value and extract + // text. + // + $converter = $this->createOffsetConverter($content); + $offset = $converter($offset); + + // + // Compute start index and length of extract. + // + $extractStart = $offset - $this->contextExtractLen; + + $overplus = 0; + if ($extractStart < 0) { + $overplus = abs($extractStart); + $extractStart = 0; + } + + $extractedLength = $keywordLength + ($this->contextExtractLen * 2) - $overplus; + + $text = mb_substr($content, $extractStart, $extractedLength, 'UTF-8'); + } + break; + + default: + throw UnhandledEnumException::fromInstance($extract); + } + + if ($highlight) { + // termporarily disable highlighting because of how it appears in emails + // ~me 20200425 + // $text = $this->highlight($text, $query); + } + + return new ExtractionResult($text, $offset, $extractedLength); + } + + /** + * @param string $query Search query. + * + * @return array + */ + private function splitQueryOnKeywords($query) + { + // + // Cache all splitted keywords in order to speedup processing. + // + if (! isset($this->keywordsCache[$query])) { + $query = str_replace(self::$unnecessaryWords, '', $query); + $query = preg_replace(self::$unnecessaryRegexp, '', $query); + + $this->keywordsCache[$query] = array_filter(\nspl\a\map('trim', mb_split(' ', $query))); + } + + return $this->keywordsCache[$query]; + } + + /** + * @param array $keywords Array of keywords. + * @param string $content A ArticleDocument content. + * + * @return array + */ + private function getNearestKeyword(array $keywords, $content) + { + $offset = -1; + $keywordLength = 0; + foreach ($keywords as $keyword) { + $matched = []; + preg_match('/(' . $keyword . ')/i', $content, $matched, PREG_OFFSET_CAPTURE); + if (isset($matched[0]) && (($offset === -1) || ($offset > $matched[0][1]))) { + $offset = $matched[0][1]; + $keywordLength = mb_strlen($matched[0][0], 'UTF-8'); + } + } + + return [ $offset, $keywordLength ]; + } + + /** + * @param string $content Document content. + * + * @return \Closure + */ + private function createOffsetConverter($content) + { + // + // Save all created converters in order to speedup processing. + // + $hash = sha1($content); + if (! isset($this->converters[$hash])) { + $contentLength = mb_strlen($content); + $utfMap = []; + + for ($offset = 0; $offset < $contentLength; $offset++) { + // + // Single unicode character in ANSI format may have one and more + // 'characters' (character codes). So for proper offset computation + // we should get current character, compute it length in ANSI format + // and create proper map between ANSI offset and Unicode offset. + // + $char = mb_substr($content, $offset, 1); + $nonUtfLength = strlen($char); + + for ($charOffset = 0; $charOffset < $nonUtfLength; $charOffset++) { + $utfMap[] = $offset; + } + } + + $this->converters[$hash] = static function ($offset) use ($utfMap) { + return $utfMap[$offset]; + }; + } + + return $this->converters[$hash]; + } + + /** + * @param string $text Highlighted text. + * @param string $query Query. + * + * @return string + */ + private function highlight($text, $query) + { + $keywords = $this->splitQueryOnKeywords($query); + + foreach ($keywords as $keyword) { + // this is a very dumb line, but somewhere there is an issue with highlighting + // where when "in' is present in a string, it only highlights it. E.g. if a search + // string is: "building in public" the result is that it only highlights the word + // "in". + // [Need a real fix] + if (!preg_match("/\bin\b/i", $keyword)) { + $text = preg_replace('/('. $keyword .')/i', '$1', $text); + } + } + + return $text; + } +} diff --git a/src/CacheBundle/Document/Extractor/DocumentContentExtractorInterface.php b/src/CacheBundle/Document/Extractor/DocumentContentExtractorInterface.php new file mode 100644 index 0000000..4b22012 --- /dev/null +++ b/src/CacheBundle/Document/Extractor/DocumentContentExtractorInterface.php @@ -0,0 +1,30 @@ +text = $text; + $this->start = $start; + $this->length = $length; + } + + /** + * @return string + */ + public function getText() + { + return $this->text; + } + + /** + * @return integer + */ + public function getStart() + { + return $this->start; + } + + /** + * @return integer + */ + public function getLength() + { + return $this->length; + } +} diff --git a/src/CacheBundle/Entity/Analytic/Analytic.php b/src/CacheBundle/Entity/Analytic/Analytic.php new file mode 100644 index 0000000..665906c --- /dev/null +++ b/src/CacheBundle/Entity/Analytic/Analytic.php @@ -0,0 +1,248 @@ +owner = $owner; + $this->context = $context; + $this->createdAt = new \DateTime(); + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @param string $name A saved analytic name. + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * @return User + */ + public function getOwner() + { + return $this->owner; + } + + /** + * @param User $owner Saved analytic owner. + * + * @return $this + */ + public function setOwner(User $owner = null) + { + $this->owner = $owner; + + return $this; + } + + /** + * @return AnalyticContext + */ + public function getContext() + { + return $this->context; + } + + /** + * @param AnalyticContext $context Used context. + * + * @return $this + */ + public function setContext(AnalyticContext $context) + { + $this->context = $context; + + return $this; + } + + /** + * Return metadata for current entity. + * + * @return \ApiBundle\Serializer\Metadata\Metadata + */ + public function getMetadata() + { + return new Metadata(static::class, [ + PropertyMetadata::createInteger('id', [ 'id' ]), + PropertyMetadata::createDate('createdAt', [ 'analytic' ]), + PropertyMetadata::createDate('updatedAt', [ 'analytic' ]), + PropertyMetadata::createEntity('context',AnalyticContext::class,['context']), + ]); + } + + /** + * Return default normalization groups. + * + * @return array + */ + public function defaultGroups() + { + return [ 'id', 'analytic','context']; + } + + /** + * Return fqcn of form used for creating this entity. + * + * @return string + */ + public function getCreateFormClass() + { + return AnalyticType::class; + } + /** + * Return fqcn of form used for updating this entity. + * + * @return string + */ + public function getUpdateFormClass() + { + return AnalyticType::class; + } + /** + * Check whether specified user owner. + * + * @param User $user A User entity instance. + * + * @return boolean + */ + public function isOwnedBy(User $user) + { + return $user->getId() === $this->owner->getId(); + } + + /** + * Get createdAt + * + * @return \DateTime + */ + public function getCreatedAt() + { + return $this->createdAt; + } + + /** + * @ORM\PreUpdate() + * + * @return void + * @throws \Exception + */ + public function onUpdate() + { + $this->setUpdatedAt(new \DateTime()); + } + + /** + * Set updatedAt + * + * @param \DateTime $updatedAt When this analytic is updated. + * + * @return $Analytic + */ + public function setUpdatedAt(\DateTime $updatedAt = null) + { + $this->updatedAt = $updatedAt; + + return $this; + } + + /** + * Get updatedAt + * + * @return \DateTime + */ + public function getUpdatedAt() + { + return $this->updatedAt; + } + +} diff --git a/src/CacheBundle/Entity/Analytic/AnalyticContext.php b/src/CacheBundle/Entity/Analytic/AnalyticContext.php new file mode 100644 index 0000000..d0a1383 --- /dev/null +++ b/src/CacheBundle/Entity/Analytic/AnalyticContext.php @@ -0,0 +1,274 @@ +hash = $hash; + $this->feeds = new ArrayCollection($feeds); + $this->filters = $filters; + $this->rawFilters = $rawFilters; + } + + /** + * Get id + * + * @return mixed + */ + public function getId() + { + return $this->hash; + } + + /** + * @return string + */ + public function getHash() + { + return $this->hash; + } + + /** + * @param string $hash Analytic hash. + * + * @return $this + */ + public function setHash($hash) + { + $this->hash = $hash; + + return $this; + } + + /** + * Get entity type + * + * @return string + */ + public function getEntityType() + { + return 'analytic'; + } + + /** + * @param AbstractFeed $feed A added feed. + * + * @return $this + */ + public function addFeed(AbstractFeed $feed) + { + $this->feeds[] = $feed; + + return $this; + } + + /** + * @param AbstractFeed $feed A removed feed. + * + * @return $this + */ + public function removeFeed(AbstractFeed $feed) + { + $this->feeds->removeElement($feed); + + return $this; + } + + /** + * @return Collection + */ + public function getFeeds() + { + return $this->feeds; + } + + /** + * @return array + */ + public function getFilters() + { + return $this->filters; + } + + /** + * @param array $filters Array of normalized filters. + * + * @return $this + */ + public function setFilters(array $filters) + { + $this->filters = $filters; + + return $this; + } + + /** + * @return array + */ + public function getRawFilters() + { + return $this->rawFilters; + } + + /** + * @param array $rawFilters Array of filters as is. + * + * @return $this + */ + public function setRawFilters(array $rawFilters) + { + $this->rawFilters = $rawFilters; + + return $this; + } + /** + * @return mixed + */ + public function getAnalytics() + { + return $this->analytics; + } + + /** + * @param mixed $analytics + */ + public function setAnalytics($analytics): void + { + $this->analytics = $analytics; + } + + /** + * Return metadata for current entity. + * + * @return Metadata + */ + public function getMetadata() + { + return new Metadata(static::class, [ + PropertyMetadata::createString('hash', [ 'context' ]), + PropertyMetadata::createObject('filters', [ 'context' ]), + PropertyMetadata::createArray('rawFilters', [ 'context' ]), + PropertyMetadata::createArray('feeds', [ 'context' ]) + ->setField(function () { + $feeds = $this->feeds->map(function (AbstractFeed $feed) { + return [ + 'id' => $feed->getId(), + 'name' => $feed->getName(), + ]; + })->toArray(); + + return $feeds; + }), + ]); + } + + /** + * Return default normalization groups. + * + * @return array + */ + public function defaultGroups() + { + return [ 'context']; + } + + +} diff --git a/src/CacheBundle/Entity/Category.php b/src/CacheBundle/Entity/Category.php new file mode 100644 index 0000000..0637736 --- /dev/null +++ b/src/CacheBundle/Entity/Category.php @@ -0,0 +1,511 @@ +addCategory($this); + + $this->name = $name; + $this->feeds = new ArrayCollection(); + $this->childes = new ArrayCollection(); + } + + /** + * @param Category $parent A parent Category entity instance. + * @param User $user A User entity instance, who create this category. + * @param string $name Category name. + * + * @return Category + */ + public static function createChild(Category $parent, User $user, $name) + { + $category = new Category($user, $name); + $parent->addChild($category); + + return $category; + } + + /** + * Create main category for specified user. + * + * @param User $user A User entity instance. + * + * @return Category + */ + public static function createMainCategory(User $user) + { + $category = new Category($user, self::NAME_MY_CONTENT); + + return $category->setType(self::TYPE_MY_CONTENT); + } + + /** + * Create main category for specified user. + * + * @param User $user A User entity instance. + * + * @return Category + */ + public static function createSharedCategory(User $user) + { + $category = new Category($user, self::NAME_SHARED_CONTENT); + + return $category->setType(self::TYPE_SHARED_CONTENT); + } + + /** + * Create trash category for specified user. + * + * @param User $user A User entity instance. + * + * @return Category + */ + public static function createTrashCategory(User $user) + { + $category = new Category($user, self::NAME_DELETED_CONTENT); + + return $category->setType(self::TYPE_DELETED_CONTENT); + } + + /** + * Add query + * + * @param AbstractFeed $feed A AbstractFeed entity instance. + * + * @return Category + */ + public function addFeed(AbstractFeed $feed) + { + $this->feeds[] = $feed; + $feed->setCategory($this); + + return $this; + } + + /** + * Remove query + * + * @param AbstractFeed $feed A AbstractFeed entity instance. + * + * @return Category + */ + public function removeFeed(AbstractFeed $feed) + { + $this->feeds->removeElement($feed); + $feed->setCategory(null); + + return $this; + } + + /** + * Get queries + * + * @return \Doctrine\Common\Collections\Collection + */ + public function getFeeds() + { + return $this->feeds; + } + + /** + * Set user + * + * @param User $user A User entity instance. + * + * @return static + */ + public function setUser(User $user = null) + { + $this->user = $user; + + return $this; + } + + /** + * Get user + * + * @return User + */ + public function getUser() + { + return $this->user; + } + + /** + * Check whether specified user owner. + * + * @param User $user A User entity instance. + * + * @return boolean + */ + public function isOwnedBy(User $user) + { + return $user->getId() === $this->user->getId(); + } + + /** + * Set name + * + * @param string $name Category name. + * + * @return Category + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * Get name + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set type + * + * @param string $type Category type. + * + * @return Category + */ + public function setType($type) + { + $this->type = $type; + + return $this; + } + + /** + * Get type + * + * @return string + */ + public function getType() + { + return $this->type; + } + + /** + * Return true if current category is my content, deleted content or shared + * content. + * + * @return boolean + */ + public function isInternal() + { + return $this->type !== self::TYPE_CUSTOM; + } + + /** + * Return fqcn of form used for creating this entity. + * + * @return string + */ + public function getCreateFormClass() + { + return CategoryType::class; + } + + /** + * Return fqcn of form used for updating this entity. + * + * @return string + */ + public function getUpdateFormClass() + { + return CategoryType::class; + } + + /** + * Add child + * + * @param Category $child A child Category entity instance. + * + * @return Category + */ + public function addChild(Category $child) + { + if ($child === $this) { + $message = 'Try to put category inside itself.'; + throw new \InvalidArgumentException($message); + } + + $this->childes[] = $child; + $child->setParent($this); + + return $this; + } + + /** + * Remove child + * + * @param Category $child A child Category entity instance. + * + * @return Category + */ + public function removeChild(Category $child) + { + $this->childes->removeElement($child); + $child->setParent(null); + + return $this; + } + + /** + * Get childs + * + * @return \Doctrine\Common\Collections\Collection|array + */ + public function getChildes() + { + return $this->childes; + } + + /** + * Set parent + * + * @param Category $parent A parent Category entity instance. + * + * @return Category + */ + public function setParent(Category $parent = null) + { + $this->parent = $parent; + + return $this; + } + + /** + * Get parent + * + * @return Category + */ + public function getParent() + { + return $this->parent; + } + + /** + * @return boolean + */ + public function isExported() + { + return $this->exported; + } + + /** + * @param boolean $exported Is this feed exported or not. + * + * @return static + */ + public function setExported($exported) + { + $this->exported = $exported; + + return $this; + } + + /** + * Return metadata for current entity. + * + * @return Metadata + */ + public function getMetadata() + { + return new Metadata(static::class, [ + PropertyMetadata::createInteger('id', [ 'id' ]), + PropertyMetadata::createString('name', [ 'category', 'category_tree' ]), + PropertyMetadata::createString('subType', [ 'category', 'category_tree' ]) + ->setField('type'), + PropertyMetadata::createCollection('childes', Category::class, [ + 'category', + 'category_tree', + ]), + PropertyMetadata::createBoolean('exported', [ 'category', 'category_tree' ]), + PropertyMetadata::createCollection('feeds', AbstractFeed::class, [ + 'feed_tree', + ]), + ]); + } + + /** + * Return default normalization groups. + * + * @return array + */ + public function defaultGroups() + { + return [ 'feed_tree', 'category', 'id' ]; + } + + /** + * Get entity type + * + * @return string + */ + public function getEntityType() + { + return 'directory'; + } + + /** + * @Assert\Callback(groups={ "unique" }) + * + * @param ExecutionContextInterface $context A ExecutionContextInterface instance. + */ + public function validateUnique(ExecutionContextInterface $context) + { + $categoriesWithSameName = $this->getParent()->getChildes()->filter(function (Category $category) { + return $category->getName() === $this->getName(); + }); + + if (count($categoriesWithSameName) > 0) { + $context->buildViolation('Category with name \'{{ value }}\' is already exists') + ->setParameter('{{ value }}', $this->getName()) + ->addViolation(); + } + } +} diff --git a/src/CacheBundle/Entity/Comment.php b/src/CacheBundle/Entity/Comment.php new file mode 100644 index 0000000..c14b196 --- /dev/null +++ b/src/CacheBundle/Entity/Comment.php @@ -0,0 +1,282 @@ +setAuthor($user) + ->setContent($content) + ->setTitle($title); + + $this->createdAt = new \DateTime(); + } + + /** + * Set title + * + * @param string $title Comment title. + * + * @return Comment + */ + public function setTitle($title) + { + $this->title = trim($title); + + return $this; + } + + /** + * Get title + * + * @return string + */ + public function getTitle() + { + return $this->title; + } + + /** + * Set content + * + * @param string $content Comment content. + * + * @return Comment + */ + public function setContent($content) + { + $this->content = trim($content); + + return $this; + } + + /** + * Get content + * + * @return string + */ + public function getContent() + { + return $this->content; + } + + /** + * Set createdAt + * + * @param \DateTime $createdAt When comment was created. + * + * @return Comment + */ + public function setCreatedAt(\DateTime $createdAt = null) + { + $this->createdAt = $createdAt; + + return $this; + } + + /** + * Get createdAt + * + * @return \DateTime + */ + public function getCreatedAt() + { + return $this->createdAt; + } + + /** + * Set document + * + * @param Document $document A Document entity instance. + * + * @return Comment + */ + public function setDocument(Document $document = null) + { + $this->document = $document; + + return $this; + } + + /** + * Get document + * + * @return Document + */ + public function getDocument() + { + return $this->document; + } + + /** + * Set author + * + * @param User $author A User entity instance. + * + * @return Comment + */ + public function setAuthor(User $author = null) + { + $this->author = $author; + + return $this; + } + + /** + * Get author + * + * @return User + */ + public function getAuthor() + { + return $this->author; + } + + /** + * Set new + * + * @param boolean $new Flag, true if this comment is new. + * + * @return Comment + */ + public function setNew($new = true) + { + $this->new = $new; + + return $this; + } + + /** + * Is new + * + * @return boolean + */ + public function isNew() + { + return $this->new; + } + + /** + * Return metadata for current entity. + * + * @return \ApiBundle\Serializer\Metadata\Metadata + */ + public function getMetadata() + { + return new Metadata(static::class, [ + PropertyMetadata::createInteger('id', [ 'id' ]), + PropertyMetadata::createString('title', [ 'comment' ]), + PropertyMetadata::createString('content', [ 'comment' ]), + PropertyMetadata::createEntity('author', User::class, [ 'comment' ]), + PropertyMetadata::createDate('createdAt', [ 'comment' ]), + ]); + } + + /** + * Return default normalization groups. + * + * @return array + */ + public function defaultGroups() + { + return [ 'id', 'comment' ]; + } + + /** + * Return fqcn of form used for creating this entity. + * + * @return string + */ + public function getCreateFormClass() + { + return CommentType::class; + } + + /** + * Return fqcn of form used for updating this entity. + * + * @return string + */ + public function getUpdateFormClass() + { + return CommentType::class; + } +} diff --git a/src/CacheBundle/Entity/Document.php b/src/CacheBundle/Entity/Document.php new file mode 100644 index 0000000..05cc380 --- /dev/null +++ b/src/CacheBundle/Entity/Document.php @@ -0,0 +1,383 @@ + 'main_length', + 'dateFound' => 'date_found', + 'sourceHashcode' => 'source_hashcode', + 'sourceLink' => 'source_link', + 'sourcePublisherType' => 'source_publisher_type', + 'sourcePublisherSubtype' => 'source_publisher_subtype', + 'sourceDateFound' => 'source_date_found', + 'sourceTitle' => 'source_title', + 'sourceDescription' => 'source_description', + 'sourceLocation' => 'source_location', + 'summaryText' => 'summary_text', + 'htmlLength' => 'html_length', + 'authorName' => 'author_name', + 'authorLink' => 'author_link', + 'authorGender' => 'author_gender', + 'imageSrc' => 'image_src', + 'country' => 'geo_country', + 'state' => 'geo_state', + 'city' => 'geo_city', + 'point' => 'geo_point', + 'duplicatesCount' => 'duplicates_count', + ]; + + /** + * Constructor + */ + public function __construct() + { + $this->pages = new ArrayCollection(); + $this->comments = new ArrayCollection(); + } + + /** + * @return static + */ + public static function create() + { + return new static(); + } + + /** + * Get id + * + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * @param string $id New document id. + * + * @return Document + */ + public function setId($id) + { + $this->id = $id; + + return $this; + } + + /** + * @return string + */ + public function getPlatform() + { + return $this->platform; + } + + /** + * @param string $platform Platform name from which we get document. + * + * @return Document + */ + public function setPlatform($platform) + { + $this->platform = $platform; + + return $this; + } + + /** + * @return array + */ + public function getData() + { + return $this->data; + } + + /** + * @param array $data A index document data. + * + * @return Document + */ + public function setData(array $data) + { + $this->data = $data; + + return $this; + } + + /** + * Add page + * + * @param Page $page A Page entity instance. + * + * @return Document + */ + public function addPage(Page $page) + { + $this->pages[] = $page; + $page->setDocument($this); + + return $this; + } + + /** + * Remove page + * + * @param Page $page A Page entity instance. + * + * @return Document + */ + public function removePage(Page $page) + { + $this->pages->removeElement($page); + $page->setDocument(null); + + return $this; + } + + /** + * Get pages + * + * @return Collection + */ + public function getPages() + { + return $this->pages; + } + + /** + * Get entity type + * + * @return string + */ + public function getEntityType() + { + return 'document'; + } + + /** + * Add comment + * + * @param Comment $comment A Comment entity instance. + * + * @return Document + */ + public function addComment(Comment $comment) + { + $this->comments[] = $comment; + $comment->setDocument($this); + + return $this; + } + + /** + * Remove comment + * + * @param Comment $comment A Comment entity instance. + * + * @return Document + */ + public function removeComment(Comment $comment) + { + $this->comments->removeElement($comment); + $comment->setDocument(null); + + return $this; + } + + /** + * Set comments + * + * @param Comment[]|ArrayCollection $comments Array of Document entities. + * + * @return Document + */ + public function setComments($comments) + { + if (is_array($comments)) { + $comments = new ArrayCollection($comments); + } + + $this->comments = $comments; + + return $this; + } + + /** + * Get comments + * + * @return Collection + */ + public function getComments() + { + return $this->comments; + } + + /** + * Set commentsCount + * + * @param integer $count Comments count. + * + * @return Document + */ + public function setCommentsCount($count) + { + $this->commentsCount = $count; + + return $this; + } + + /** + * Get commentsCount + * + * @return integer + */ + public function getCommentsCount() + { + return $this->commentsCount; + } + + /** + * Increment comments counts for this document + * + * @return Document + */ + public function incCommentsCount() + { + $this->commentsCount++; + + return $this; + } + + /** + * Decrement comments counts for this document + * + * @return Document + */ + public function decCommentsCount() + { + $this->commentsCount--; + + return $this; + } + + /** + * Return metadata for current entity. + * + * @return \ApiBundle\Serializer\Metadata\Metadata + */ + public function getMetadata() + { + return new Metadata(static::class, [ + PropertyMetadata::createString('id', [ 'id' ]), + PropertyMetadata::createString('title', [ 'document' ]), + PropertyMetadata::createDate('dateFound', [ 'document' ]), + PropertyMetadata::createDate('published', [ 'document' ]), + PropertyMetadata::createString('permalink', [ 'document' ]), + PropertyMetadata::createString('content', [ 'document' ]), + PropertyMetadata::createString('language', [ 'document' ]), + PropertyMetadata::createString('publisher', [ 'document' ]), + PropertyMetadata::groupProperties('source', [ + PropertyMetadata::createString('title', [ 'document' ]), + PropertyMetadata::createString('type', [ 'document' ]), + PropertyMetadata::createString('link', [ 'document' ]), + PropertyMetadata::createString('section', [ 'document' ]), + PropertyMetadata::createString('country', [ 'document' ]), + PropertyMetadata::createString('state', [ 'document' ]), + PropertyMetadata::createString('city', [ 'document' ]), + ], [ 'document' ]), + PropertyMetadata::groupProperties('author', [ + PropertyMetadata::createString('name', [ 'document' ]), + PropertyMetadata::createString('link', [ 'document' ]), + ], [ 'document' ]), + PropertyMetadata::createInteger('duplicates', [ 'document' ]), + PropertyMetadata::createString('image', [ 'document' ])->setNullable(true), + PropertyMetadata::createInteger('views', [ 'document' ]), + PropertyMetadata::createString('sentiment', [ 'document' ]), + PropertyMetadata::groupProperties('comments', [ + PropertyMetadata::createCollection('comments', Comment::class, [ 'document' ]) + ->setName('data'), + PropertyMetadata::createInteger('count', [ 'document' ]), + PropertyMetadata::createInteger('limit', [ 'document' ]), + ], [ 'document' ]), + ]); + } + + /** + * Return default normalization groups. + * + * @return array + */ + public function defaultGroups() + { + return [ 'id', 'document' ]; + } +} diff --git a/src/CacheBundle/Entity/DocumentCollectionInterface.php b/src/CacheBundle/Entity/DocumentCollectionInterface.php new file mode 100644 index 0000000..e512689 --- /dev/null +++ b/src/CacheBundle/Entity/DocumentCollectionInterface.php @@ -0,0 +1,52 @@ +totalCount = $totalCount; + + return $this; + } + + /** + * Get totalCount + * + * @return integer + */ + public function getTotalCount() + { + return $this->totalCount; + } +} diff --git a/src/CacheBundle/Entity/Feed/AbstractFeed.php b/src/CacheBundle/Entity/Feed/AbstractFeed.php new file mode 100644 index 0000000..fa39209 --- /dev/null +++ b/src/CacheBundle/Entity/Feed/AbstractFeed.php @@ -0,0 +1,327 @@ +excludedDocuments = new ArrayCollection(); + } + + /** + * Set name + * + * @param string $name Feed name. + * + * @return AbstractFeed + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * Get name + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set user + * + * @param User $user A User entity instance. + * + * @return static + */ + public function setUser(User $user = null) + { + $this->user = $user; + + return $this; + } + + /** + * Get user + * + * @return User + */ + public function getUser() + { + return $this->user; + } + + /** + * Check whether specified user owner. + * + * @param User $user A User entity instance. + * + * @return boolean + */ + public function isOwnedBy(User $user) + { + return $user->getId() === $this->user->getId(); + } + + /** + * Set category + * + * @param Category $category A Category entity instance. + * + * @return AbstractFeed + */ + public function setCategory(Category $category = null) + { + $this->category = $category; + + return $this; + } + + /** + * Get category + * + * @return Category + */ + public function getCategory() + { + return $this->category; + } + + /** + * @return boolean + */ + public function isExported() + { + return $this->exported; + } + + /** + * @return boolean + * + * @deprecated + * @see AbstractFeed::isExported() + */ + public function getExported() + { + return $this->exported; + } + + /** + * @param boolean $exported Is this feed exported or not. + * + * @return static + */ + public function setExported($exported) + { + $this->exported = $exported; + + return $this; + } + + /** + * Return fqcn of form used for creating this entity. + * + * @return string + */ + public function getCreateFormClass() + { + // All derived feed's will be created in different ways. + return ''; + } + + /** + * Return fqcn of form used for updating this entity. + * + * @return string + */ + public function getUpdateFormClass() + { + return null; + } + + /** + * Get entity type + * + * @return string + */ + public function getEntityType() + { + // For all feeds we shouldn't return specific types, only 'feed'. + return 'feed'; + } + + /** + * @param string $subType A feed subtype. + * + * @return AbstractFeed + */ + public static function createBySubType($subType) + { + switch ($subType) { + case ClipFeed::getSubType(): + return new ClipFeed(); + + case QueryFeed::getSubType(): + return new QueryFeed(); + + default: + throw new \InvalidArgumentException('Unknown sub type.'); + } + } + + /** + * Get concrete feed type. + * + * @param AbstractFeed $feed A AbstractFeed entity instance. + * + * @return string + */ + public static function getSubType(AbstractFeed $feed = null) + { + return \app\op\camelCaseToUnderscore(\app\c\getShortName($feed ?: static::class)); + } + + /** + * Get specific feed type. + * + * Used by frontend. + * + * @return string + */ + abstract public function getSpecificType(); + + /** + * @return integer + */ + abstract public function getCollectionId(); + + /** + * @return CollectionTypeEnum + */ + abstract public function getCollectionType(); + + /** + * Add excludedDocument + * + * @param Document $excludedDocument Excluded Document entity instance. + * + * @return static + */ + public function addExcludedDocument(Document $excludedDocument) + { + $this->excludedDocuments[] = $excludedDocument; + + return $this; + } + + /** + * Remove excludedDocument + * + * @param Document $excludedDocument Removed Document entity instance. + * + * @return static + */ + public function removeExcludedDocument(Document $excludedDocument) + { + $this->excludedDocuments->removeElement($excludedDocument); + + return $this; + } + + /** + * Get excludedDocuments + * + * @return Collection + */ + public function getExcludedDocuments() + { + return $this->excludedDocuments; + } +} diff --git a/src/CacheBundle/Entity/Feed/ClipFeed.php b/src/CacheBundle/Entity/Feed/ClipFeed.php new file mode 100644 index 0000000..c464574 --- /dev/null +++ b/src/CacheBundle/Entity/Feed/ClipFeed.php @@ -0,0 +1,235 @@ +pages = new ArrayCollection(); + } + + /** + * Set filters + * + * @param array $filters Array of filters. + * + * @return static + */ + public function setFilters(array $filters) + { + $this->filters = $filters; + + return $this; + } + + /** + * Get filters + * + * @return array + */ + public function getFilters() + { + return $this->filters; + } + + /** + * Set rawFilters + * + * @param array $rawFilters Raw filters. + * + * @return static + */ + public function setRawFilters(array $rawFilters) + { + $this->rawFilters = $rawFilters; + + return $this; + } + + /** + * Get rawFilters + * + * @return array + */ + public function getRawFilters() + { + return $this->rawFilters; + } + + /** + * Get specific feed type. + * + * Used by frontend. + * + * @return string + */ + public function getSpecificType() + { + return 'feed-type-clippings'; + } + + /** + * Return metadata for current entity. + * + * @return Metadata + */ + public function getMetadata() + { + return new Metadata(static::class, [ + PropertyMetadata::createInteger('id', [ 'id' ]), + PropertyMetadata::createString('name', [ 'feed', 'feed_tree' ]), + PropertyMetadata::createString('subType', [ 'feed', 'feed_tree' ]) + ->setField(function () { + return static::getSubType(); + }), + PropertyMetadata::createString('class', [ 'feed', 'feed_tree' ]) + ->setField(function () { + return $this->getSpecificType(); + }), + PropertyMetadata::createBoolean('exported', [ 'feed', 'feed_tree' ]) + ->setField(function () { + return $this->getExported(); + }), + PropertyMetadata::createEntity('category', Category::class, [ 'feed' ]), + PropertyMetadata::createEntity('user', User::class, [ 'feed' ]), + ]); + } + + /** + * Return default normalization groups. + * + * @return array + */ + public function defaultGroups() + { + return [ 'feed', 'id' ]; + } + + /** + * Add page + * + * @param Page $page A page entity instance. + * + * @return ClipFeed + */ + public function addPage(Page $page) + { + $this->pages[] = $page; + $page->setClipFeed($this); + + return $this; + } + + /** + * Remove page + * + * @param Page $page A Page entity instance. + * + * @return ClipFeed + */ + public function removePage(Page $page) + { + $this->pages->removeElement($page); + $page->setClipFeed(null); + + return $this; + } + + /** + * Get pages + * + * @return \Doctrine\Common\Collections\Collection + */ + public function getPages() + { + return $this->pages; + } + + /** + * Create proper Page entity instance for binding document and current + * collection. + * + * @param integer $number Page number. + * + * @return Page + */ + public function createPage($number) + { + return Page::create() + ->setClipFeed($this) + ->setNumber($number); + } + + /** + * @return integer + */ + public function getCollectionId() + { + return $this->id; + } + + /** + * @return CollectionTypeEnum + */ + public function getCollectionType() + { + return CollectionTypeEnum::feed(); + } +} diff --git a/src/CacheBundle/Entity/Feed/QueryFeed.php b/src/CacheBundle/Entity/Feed/QueryFeed.php new file mode 100644 index 0000000..5697ef1 --- /dev/null +++ b/src/CacheBundle/Entity/Feed/QueryFeed.php @@ -0,0 +1,160 @@ +query = $query; + + return $this; + } + + /** + * Get query + * + * @return StoredQuery + */ + public function getQuery() + { + return $this->query; + } + + /** + * Set publisherTypes + * + * @param array|string $publisherTypes Query publisher type. + * + * @return QueryFeed + */ + public function setPublisherTypes($publisherTypes) + { + $this->publisherTypes = (array) $publisherTypes; + + return $this; + } + + /** + * Get publisherTypes + * + * @return array + */ + public function getPublisherTypes() + { + return $this->publisherTypes; + } + + /** + * Get specific feed type. + * + * Used by frontend. + * + * @return string + */ + public function getSpecificType() + { + if (is_array($this->publisherTypes) && count($this->publisherTypes) === 1) { + return 'feed-type-' + . strtolower(current($this->publisherTypes)); + } + + return 'feed-type-mixed'; + } + + /** + * Return metadata for current entity. + * + * @return Metadata + */ + public function getMetadata() + { + return new Metadata(static::class, [ + PropertyMetadata::createInteger('id', [ 'id' ]), + PropertyMetadata::createInteger('query', [ 'feed', 'feed_tree' ]) + ->setField(function () { + return $this->query->getId(); + }), + PropertyMetadata::createString('name', [ 'feed', 'feed_tree' ]), + PropertyMetadata::createString('subType', [ 'feed', 'feed_tree' ]) + ->setField(function () { + return static::getSubType(); + }), + PropertyMetadata::createString('class', [ 'feed', 'feed_tree' ]) + ->setField(function () { + return $this->getSpecificType(); + }), + PropertyMetadata::createBoolean('exported', [ 'feed', 'feed_tree' ]), +// ->setField(function () { +// return $this->isExported(); +// }), + PropertyMetadata::createEntity('category', Category::class, [ 'feed' ]), + PropertyMetadata::createEntity('user', User::class, [ 'feed' ]), + ]); + } + + /** + * Return default normalization groups. + * + * @return array + */ + public function defaultGroups() + { + return [ 'feed', 'id' ]; + } + + /** + * @return integer + */ + public function getCollectionId() + { + return $this->query->getId(); + } + + /** + * @return CollectionTypeEnum + */ + public function getCollectionType() + { + return CollectionTypeEnum::query(); + } +} diff --git a/src/CacheBundle/Entity/Page.php b/src/CacheBundle/Entity/Page.php new file mode 100644 index 0000000..2b47f3b --- /dev/null +++ b/src/CacheBundle/Entity/Page.php @@ -0,0 +1,177 @@ +number = $number; + + return $this; + } + + /** + * Get number + * + * @return integer + */ + public function getNumber() + { + return $this->number; + } + + /** + * Set query + * + * @param AbstractQuery $query A AbstractQuery entity instance. + * + * @return Page + */ + public function setQuery(AbstractQuery $query = null) + { + $this->query = $query; + + return $this; + } + + /** + * Get query + * + * @return AbstractQuery + */ + public function getQuery() + { + return $this->query; + } + + /** + * Set document + * + * @param Document $document A Document entity instance. + * + * @return Page + */ + public function setDocument(Document $document = null) + { + $this->document = $document; + + return $this; + } + + /** + * Get document + * + * @return Document + */ + public function getDocument() + { + return $this->document; + } + + /** + * Set clipFeed + * + * @param ClipFeed $clipFeed A ClipFeed entity instance. + * + * @return Page + */ + public function setClipFeed(ClipFeed $clipFeed = null) + { + $this->clipFeed = $clipFeed; + + return $this; + } + + /** + * Get clipFeed + * + * @return ClipFeed + */ + public function getClipFeed() + { + return $this->clipFeed; + } + + /** + * Get associated document collection type. + * + * @return string + */ + public function getCollectionType() + { + return $this->query ? CollectionTypeEnum::QUERY : CollectionTypeEnum::FEED; + } + + /** + * Get associated document collection entity id. + * + * @return string + */ + public function getCollectionId() + { + return \app\op\invokeIf($this->query, 'getId') ?: $this->clipFeed->getId(); + } +} diff --git a/src/CacheBundle/Entity/Query/AbstractQuery.php b/src/CacheBundle/Entity/Query/AbstractQuery.php new file mode 100644 index 0000000..54d24d9 --- /dev/null +++ b/src/CacheBundle/Entity/Query/AbstractQuery.php @@ -0,0 +1,399 @@ +pages = new ArrayCollection(); + $this->date = new \DateTime(); + } + + /** + * Set raw + * + * @param string $raw Raw query string typed by user. + * + * @return static + */ + public function setRaw($raw) + { + $this->raw = $raw; + + return $this; + } + + /** + * Get raw + * + * @return string + */ + public function getRaw() + { + return $this->raw; + } + + /** + * Set rawFilters + * + * @param array $rawFilters Raw filters. + * + * @return static + */ + public function setRawFilters(array $rawFilters) + { + $this->rawFilters = $rawFilters; + + return $this; + } + + /** + * Get rawFilters + * + * @return array + */ + public function getRawFilters() + { + return $this->rawFilters; + } + + /** + * Set rawAdvancedFilters + * + * @param array $rawAdvancedFilters Raw filters. + * + * @return static + */ + public function setRawAdvancedFilters(array $rawAdvancedFilters) + { + $this->rawAdvancedFilters = $rawAdvancedFilters; + + return $this; + } + + /** + * Get rawAdvancedFilters + * + * @return array + */ + public function getRawAdvancedFilters() + { + return $this->rawAdvancedFilters; + } + + /** + * Set fields + * + * @param array $fields Array of field involved in search. + * + * @return static + */ + public function setFields(array $fields = []) + { + $this->fields = $fields; + + return $this; + } + + /** + * Get fields + * + * @return array + */ + public function getFields() + { + return $this->fields; + } + + /** + * Set normalized + * + * @param string $normalized Normalized query string. + * + * @return static + */ + public function setNormalized($normalized) + { + $this->normalized = $normalized; + + return $this; + } + + /** + * Get normalized + * + * @return string + */ + public function getNormalized() + { + return $this->normalized; + } + + /** + * Set hash + * + * @param string $hash Query hash. + * + * @return static + */ + public function setHash($hash) + { + $this->hash = $hash; + + return $this; + } + + /** + * Get hash + * + * @return string + */ + public function getHash() + { + return $this->hash; + } + + /** + * Set date + * + * @param \DateTime $date When query was requested. + * + * @return static + */ + public function setDate(\DateTime $date) + { + $this->date = $date; + + return $this; + } + + /** + * Get date + * + * @return \DateTime + */ + public function getDate() + { + return $this->date; + } + + /** + * Add page + * + * @param Page $page A Page entity instance. + * + * @return static + */ + public function addPage(Page $page) + { + $this->pages[] = $page; + $page->setQuery($this); + + return $this; + } + + /** + * Remove page + * + * @param Page $page A Page entity instance. + * + * @return static + */ + public function removePage(Page $page) + { + $this->pages->removeElement($page); + $page->setQuery(null); + + return $this; + } + + /** + * Get pages + * + * @return \Doctrine\Common\Collections\Collection + */ + public function getPages() + { + return $this->pages; + } + + /** + * Set filters + * + * @param array $filters Array of filters. + * + * @return static + */ + public function setFilters(array $filters) + { + $this->filters = $filters; + + return $this; + } + + /** + * Get filters + * + * @return array + */ + public function getFilters() + { + return $this->filters; + } + + /** + * Create query entity instance from search request instance. + * + * @param SearchRequestInterface $searchRequest A SearchRequestInterface + * instance. + * + * @return static + */ + public static function fromSearchRequest(SearchRequestInterface $searchRequest) + { + $instance = new static(); + + return $instance + ->setFilters($searchRequest->getFilters()) + ->setFields($searchRequest->getFields()) + ->setNormalized($searchRequest->getNormalizedQuery()) + ->setRaw($searchRequest->getQuery()) + ->setHash($searchRequest->getHash()); + } + + /** + * Create proper Page entity instance for binding document and current + * collection. + * + * @param integer $number Page number. + * + * @return Page + */ + public function createPage($number) + { + return Page::create() + ->setQuery($this) + ->setNumber($number); + } + + /** + * @return integer + */ + public function getCollectionId() + { + return $this->id; + } + + /** + * @return CollectionTypeEnum + */ + public function getCollectionType() + { + return CollectionTypeEnum::query(); + } +} diff --git a/src/CacheBundle/Entity/Query/SimpleQuery.php b/src/CacheBundle/Entity/Query/SimpleQuery.php new file mode 100644 index 0000000..ad51aa0 --- /dev/null +++ b/src/CacheBundle/Entity/Query/SimpleQuery.php @@ -0,0 +1,64 @@ +date; + $expirationDate = $date->modify($expirationDate); + } + + $this->expirationDate = $expirationDate; + + return $this; + } + + /** + * Get expirationDate + * + * @return \DateTime + */ + public function getExpirationDate() + { + return $this->expirationDate; + } + + /** + * Check that this simple query is still fresh. + * + * @return boolean + */ + public function isFresh() + { + return $this->expirationDate >= date_create(); + } +} diff --git a/src/CacheBundle/Entity/Query/StoredQuery.php b/src/CacheBundle/Entity/Query/StoredQuery.php new file mode 100644 index 0000000..4027614 --- /dev/null +++ b/src/CacheBundle/Entity/Query/StoredQuery.php @@ -0,0 +1,197 @@ +lastUpdateAt = new \DateTime(); + } + + /** + * Set limitExceed + * + * @param boolean $limitExceed Flag, if true current stored query exceed + * limit. + * + * @return StoredQuery + */ + public function setLimitExceed($limitExceed) + { + $this->limitExceed = $limitExceed; + + return $this; + } + + /** + * Get limitExceed + * + * @return boolean + */ + public function isLimitExceed() + { + return $this->limitExceed; + } + + /** + * Set lastUpdateAt + * + * @param \DateTime $lastUpdateAt Date of last updated of this query. + * + * @return StoredQuery + */ + public function setLastUpdateAt(\DateTime $lastUpdateAt) + { + $this->lastUpdateAt = $lastUpdateAt; + + return $this; + } + + /** + * Get lastUpdateAt + * + * @return \DateTime + */ + public function getLastUpdateAt() + { + return $this->lastUpdateAt; + } + + /** + * Set status + * + * @param string $status Stored query status. + * + * @return StoredQuery + */ + public function setStatus($status) + { + $this->status = $status; + + return $this; + } + + /** + * Get status + * + * @return string + */ + public function getStatus() + { + return $this->status; + } + + /** + * Checks that this stored query is in specified status. + * + * @param string|string[] $status Stored query status. + * + * @return boolean + */ + public function isInStatus($status) + { + if (is_string($status)) { + $status = [ $status ]; + } + + return \nspl\a\any($status, \nspl\f\partial('\nspl\op\idnt', $this->status)); + } + + /** + * Get limitExceed + * + * @return boolean + */ + public function getLimitExceed() + { + return $this->limitExceed; + } + + /** + * Add feed + * + * @param QueryFeed $feed A QueryFeed instance. + * + * @return StoredQuery + */ + public function addFeed(QueryFeed $feed) + { + $this->feeds[] = $feed; + $feed->setQuery($this); + + return $this; + } + + /** + * Remove feed + * + * @param QueryFeed $feed A QueryFeed instance. + * + * @return StoredQuery + */ + public function removeFeed(QueryFeed $feed) + { + $this->feeds->removeElement($feed); + $feed->setQuery(null); + + return $this; + } + + /** + * Get queries + * + * @return \Doctrine\Common\Collections\Collection + */ + public function getFeeds() + { + return $this->feeds; + } +} diff --git a/src/CacheBundle/Entity/SourceList.php b/src/CacheBundle/Entity/SourceList.php new file mode 100644 index 0000000..507abcc --- /dev/null +++ b/src/CacheBundle/Entity/SourceList.php @@ -0,0 +1,373 @@ +sources = new ArrayCollection(); + $this->createdAt = new \DateTime(); + } + + /** + * @return SourceList + */ + public function cloneList() + { + $clone = clone $this; + + $clone + ->setSourceNumber(0) + ->setUpdatedAt(null) + ->setUpdatedBy(null); + $clone->sources = new ArrayCollection(); + + return $clone; + } + /** + * Return metadata for current entity. + * + * @return \ApiBundle\Serializer\Metadata\Metadata + */ + public function getMetadata() + { + return new Metadata(static::class, [ + PropertyMetadata::createInteger('id', [ 'id' ]), + PropertyMetadata::createInteger('sourceNumber', [ 'source_list' ]), + PropertyMetadata::createBoolean('shared', [ 'source_list' ]) + ->setField('isGlobal'), + PropertyMetadata::createString('name', [ 'source_list' ]), + PropertyMetadata::createEntity('user', User::class, [ 'source_list' ]), + PropertyMetadata::createDate('createdAt', [ 'source_list' ]), + PropertyMetadata::createDate('updatedAt', [ 'source_list' ]), + PropertyMetadata::createEntity('updatedBy', User::class, [ 'source_list' ]), + ]); + } + + /** + * Return default normalization groups. + * + * @return array + */ + public function defaultGroups() + { + return [ 'source_list', 'id' ]; + } + + /** + * @ORM\PreUpdate() + * + * @return void + */ + public function onUpdate() + { + $this->setUpdatedAt(new \DateTime()); + } + + /** + * Set title + * + * @param string $name Source list name. + * + * @return SourceList + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * Get title + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set createdAt + * + * @param \DateTime $createdAt When this list is created. + * + * @return SourceList + */ + public function setCreatedAt(\DateTime $createdAt = null) + { + $this->createdAt = $createdAt; + + return $this; + } + + /** + * Get createdAt + * + * @return \DateTime + */ + public function getCreatedAt() + { + return $this->createdAt; + } + + /** + * Set updatedAt + * + * @param \DateTime $updatedAt When this list is updated. + * + * @return SourceList + */ + public function setUpdatedAt(\DateTime $updatedAt = null) + { + $this->updatedAt = $updatedAt; + + return $this; + } + + /** + * Get updatedAt + * + * @return \DateTime + */ + public function getUpdatedAt() + { + return $this->updatedAt; + } + + /** + * Set updatedBy + * + * @param User $user A User entity instance. + * + * @return SourceList + */ + public function setUpdatedBy(User $user = null) + { + $this->updatedBy = $user; + + return $this; + } + + /** + * Get updatedBy + * + * @return User + */ + public function getUpdatedBy() + { + return $this->updatedBy; + } + + /** + * Set user + * + * @param User $user A owner User entity instance. + * + * @return SourceList + */ + public function setUser(User $user = null) + { + $this->user = $user; + + return $this; + } + + /** + * Get user + * + * @return User + */ + public function getUser() + { + return $this->user; + } + + /** + * Set isGlobal + * + * @param boolean $isGlobal Is global source list or not. + * + * @return SourceList + */ + public function setIsGlobal($isGlobal) + { + $this->isGlobal = $isGlobal; + + return $this; + } + + /** + * Get isGlobal + * + * @return boolean + */ + public function getIsGlobal() + { + return $this->isGlobal; + } + + /** + * Check whether specified user owner. + * + * @param User $user A User entity instance. + * + * @return boolean + */ + public function isOwnedBy(User $user) + { + return $user->getId() === $this->user->getId(); + } + + /** + * Set sourceNumber + * + * @param integer $sourceNumber Sources count. + * + * @return SourceList + */ + public function setSourceNumber($sourceNumber) + { + $this->sourceNumber = $sourceNumber; + + return $this; + } + + /** + * Get sourceNumber + * + * @return integer + */ + public function getSourceNumber() + { + return $this->sourceNumber; + } + + /** + * Add source + * + * @param SourceToSourceList $source A SourceToSourceList entity instance. + * + * @return SourceList + */ + public function addSource(SourceToSourceList $source) + { + $this->sources[] = $source; + + return $this; + } + + /** + * Remove source + * + * @param SourceToSourceList $source A SourceToSourceList entity instance. + * + * @return SourceList + */ + public function removeSource(SourceToSourceList $source) + { + $this->sources->removeElement($source); + + return $this; + } + + /** + * Get sources + * + * @return \Doctrine\Common\Collections\Collection + */ + public function getSources() + { + return $this->sources; + } +} diff --git a/src/CacheBundle/Entity/SourceToSourceList.php b/src/CacheBundle/Entity/SourceToSourceList.php new file mode 100644 index 0000000..584684e --- /dev/null +++ b/src/CacheBundle/Entity/SourceToSourceList.php @@ -0,0 +1,105 @@ +setSource($source) + ->setList($list); + } + + /** + * Set source + * + * @param string $source Source id from cache index. + * + * @return SourceToSourceList + */ + public function setSource($source) + { + $this->source = $source; + + return $this; + } + + /** + * Get source + * + * @return string + */ + public function getSource() + { + if (! is_string($this->source)) { + // + // Because of Doctrine. + // + $this->source = stream_get_contents($this->source); + } + + return $this->source; + } + + /** + * Set list + * + * @param SourceList $list A SourceList entity instance. + * + * @return SourceToSourceList + */ + public function setList(SourceList $list) + { + $this->list = $list; + + return $this; + } + + /** + * Get list + * + * @return SourceList + */ + public function getList() + { + return $this->list; + } +} diff --git a/src/CacheBundle/Feed/Fetcher/ClipFeedFetcher.php b/src/CacheBundle/Feed/Fetcher/ClipFeedFetcher.php new file mode 100644 index 0000000..d22f5d9 --- /dev/null +++ b/src/CacheBundle/Feed/Fetcher/ClipFeedFetcher.php @@ -0,0 +1,124 @@ +em = $em; + $this->index = $index; + } + + /** + * Fetch information for specified feed + * + * @param AbstractFeed $feed A AbstractFeed entity + * instance. + * @param SearchRequestBuilderInterface $builder A SearchRequestBuilderInterface + * instance. + * + * @return FeedResponseInterface + */ + public function fetch(AbstractFeed $feed, SearchRequestBuilderInterface $builder) + { + if (! $feed instanceof ClipFeed) { + throw new \InvalidArgumentException( + 'Expect '. ClipFeed::class . ' but got '. get_class($feed) + ); + } + + $factory = $this->index->getFilterFactory(); + $request = $this->index->createRequestBuilder() + ->setFilters($builder->getFilters()) + ->addFilter($factory->eq(FieldNameEnum::COLLECTION_ID, $feed->getId())) + ->addFilter($factory->eq(FieldNameEnum::COLLECTION_TYPE, CollectionTypeEnum::FEED)) + ->build(); + + return new FeedResponse( + $request->execute(), + $request->getAvailableAdvancedFilters(), // AFSourceEnum::FEED + [ + 'type' => 'clip_feed', + 'status' => 'synced', + 'search' => [ + 'advancedFilters' => count($feed->getRawFilters()) > 0 ? $feed->getRawFilters() : (object) [], + ], + ] + ); + } + + /** + * Create search builder for specified feed. + * + * @param AbstractFeed $feed A AbstractFeed entity instance. + * + * @return SearchRequestBuilderInterface|null + */ + public function createRequestBuilder(AbstractFeed $feed) + { + if (! $feed instanceof ClipFeed) { + throw new \InvalidArgumentException( + 'Expect '. ClipFeed::class . ' but got '. get_class($feed) + ); + } + + $factory = $this->index->getFilterFactory(); + $filters = $feed->getFilters(); + $filters[] = $factory->eq(FieldNameEnum::COLLECTION_ID, $feed->getId()); + $filters[] = $factory->eq(FieldNameEnum::COLLECTION_TYPE, CollectionTypeEnum::FEED); + + return $this->index->createRequestBuilder() + ->setFilters($filters) + ->setFields([ + FieldNameEnum::TITLE, + FieldNameEnum::MAIN, + ]); + } + + /** + * Return supported feed fqcn. + * + * @return string + */ + public static function support() + { + return ClipFeed::class; + } +} diff --git a/src/CacheBundle/Feed/Fetcher/Factory/FeedFetcherFactoryInterface.php b/src/CacheBundle/Feed/Fetcher/Factory/FeedFetcherFactoryInterface.php new file mode 100644 index 0000000..30bc113 --- /dev/null +++ b/src/CacheBundle/Feed/Fetcher/Factory/FeedFetcherFactoryInterface.php @@ -0,0 +1,26 @@ +container = $container; + $this->map = $map; + } + + /** + * Get feed fetcher for specified feed. + * + * @param string|AbstractFeed $feedClass Feed fqcn. + * + * @return FeedFetcherInterface + */ + public function get($feedClass) + { + if (is_object($feedClass)) { + $feedClass = get_class($feedClass); + } + + if (! is_string($feedClass)) { + throw new \InvalidArgumentException( + 'Invalid parameter feedClass. Should be string or instance of AbstractFeed.' + ); + } + + $fetcher = $this->container->get($this->map[$feedClass]); + if (! $fetcher instanceof FeedFetcherInterface) { + throw new \RuntimeException('Got invalid fetcher.'); + } + + return $fetcher; + } +} diff --git a/src/CacheBundle/Feed/Fetcher/FeedFetcherInterface.php b/src/CacheBundle/Feed/Fetcher/FeedFetcherInterface.php new file mode 100644 index 0000000..56f5756 --- /dev/null +++ b/src/CacheBundle/Feed/Fetcher/FeedFetcherInterface.php @@ -0,0 +1,46 @@ +em = $em; + $this->manager = $manager; + $this->sourceManager = $sourceManager; + } + + /** + * Fetch information for specified feed + * + * @param AbstractFeed $feed A AbstractFeed entity + * instance. + * @param SearchRequestBuilderInterface $builder A SearchRequestBuilderInterface + * instance. + * + * @return FeedResponseInterface + */ + public function fetch(AbstractFeed $feed, SearchRequestBuilderInterface $builder) + { + if (! $feed instanceof QueryFeed) { + throw new \InvalidArgumentException( + 'Expect '. QueryFeed::class . ' but got '. get_class($feed) + ); + } + + // + // Get proper stored query for fetched feed. + // + /** @var StoredQueryRepository $repository */ + $repository = $this->em->getRepository('CacheBundle:Query\StoredQuery'); + $query = $repository->getByFeed($feed->getId()); + + // + // Collect information. + // + $queryStatus = $query->isInStatus([ + StoredQueryStatusEnum::INITIALIZE, + StoredQueryStatusEnum::DELETED, + ]); + $response = new SearchResponse(); + + $sources = $this->sourceManager->getSourcesForQuery($query, [ 'id', 'title', 'type' ]); + $sourceLists = $this->sourceManager->getSourceListsForQuery($query, [ 'id', 'name' ]); + + $meta = [ + 'type' => 'query_feed', + 'status' => $queryStatus ? 'not_synced' : 'synced', + 'search' => [ + 'query' => $query->getRaw(), + 'filters' => $query->getRawFilters(), + 'advancedFilters' => count($query->getRawAdvancedFilters()) > 0 ? $query->getRawAdvancedFilters() : (object) [], + ], + 'sources' => $sources, + 'sourceLists' => $sourceLists, + ]; + + $advancedFilters = AdvancedFiltersConfig::getDefault(AFSourceEnum::FEED); + if (! $query->isInStatus([ + StoredQueryStatusEnum::INITIALIZE, + StoredQueryStatusEnum::DELETED, + ])) { + $factory = $builder->getIndex()->getFilterFactory(); + $builder + ->addFilter($factory->andX([ + $factory->eq(FieldNameEnum::COLLECTION_ID, $query->getId()), + $factory->eq(FieldNameEnum::COLLECTION_TYPE, CollectionTypeEnum::QUERY), + ])) + ->addFilter($factory->not($factory->eq(FieldNameEnum::DELETE_FROM, $feed->getId()))); + + $response = $this->manager->get($feed->getUser(), $query, $builder); + $advancedFilters = $this->manager->getAdvancedFilters($query, $builder); + } + + return new FeedResponse($response, $advancedFilters, $meta); + } + + /** + * Create search builder for specified feed. + * + * @param AbstractFeed $feed A AbstractFeed entity instance. + * + * @return SearchRequestBuilderInterface|null + */ + public function createRequestBuilder(AbstractFeed $feed) + { + if (! $feed instanceof QueryFeed) { + throw new \InvalidArgumentException( + 'Expect '. QueryFeed::class . ' but got '. get_class($feed) + ); + } + + // + // Get proper stored query for fetched feed. + // + /** @var StoredQueryRepository $repository */ + $repository = $this->em->getRepository('CacheBundle:Query\StoredQuery'); + $query = $repository->getByFeed($feed->getId()); + + if (! $query->isInStatus([ + StoredQueryStatusEnum::INITIALIZE, + StoredQueryStatusEnum::DELETED, + ])) { + return $this->manager->createRequestBuilder( + $feed->getUser(), + $query + ); + } + + return null; + } + + /** + * Return supported feed fqcn. + * + * @return string + */ + public static function support() + { + return QueryFeed::class; + } +} diff --git a/src/CacheBundle/Feed/Formatter/BasicFeedFormatter.php b/src/CacheBundle/Feed/Formatter/BasicFeedFormatter.php new file mode 100644 index 0000000..e7265aa --- /dev/null +++ b/src/CacheBundle/Feed/Formatter/BasicFeedFormatter.php @@ -0,0 +1,107 @@ +feedManager = $feedManager; + $this->container = $container; + } + + /** + * Format feed documents. + * + * @param AbstractFeed $feed A formatted feed entity instance. + * @param FormatterOptions $options Used format options. + * + * @return FormattedData + */ + public function formatFeed(AbstractFeed $feed, FormatterOptions $options) + { + $strategy = $this->createStrategy($options->getFormat()); + + $filterFactory = $this->feedManager->getIndex()->getFilterFactory(); + + $sourceFields = $strategy->requiredFields($options); + $sourceFields[] = FieldNameEnum::SEQUENCE; + $sourceFields[] = FieldNameEnum::COLLECTION_ID; + $sourceFields[] = FieldNameEnum::COLLECTION_TYPE; + + $documents = $this->feedManager->getIndex()->createRequestBuilder() + ->setFilters($filterFactory->andX([ + $filterFactory->eq(FieldNameEnum::COLLECTION_ID, $feed->getCollectionId()), + $filterFactory->eq(FieldNameEnum::COLLECTION_TYPE, $feed->getCollectionType()), + ])) + ->setSources($sourceFields) + ->setLimit($options->getNumberOfDocuments()) + ->setSorts([ FieldNameEnum::PUBLISHED => 'desc' ]) + ->build() + ->execute() + ->getDocuments(); + + return new FormattedData( + $strategy->serialize($feed, $documents, $options), + $strategy->getMime() + ); + } + + /** + * Create strategy for specified format. + * + * @param FormatNameEnum $format Used format name. + * + * @return FeedFormatterStrategyInterface + */ + private function createStrategy(FormatNameEnum $format) + { + $name = 'cache.feed_formatter_strategy.'. strtolower($format->getValue()); + + if (! $this->container->has($name)) { + throw new \InvalidArgumentException('Unknown format '. $format->getValue()); + } + + $strategy = $this->container->get($name); + + if (! $strategy instanceof FeedFormatterStrategyInterface) { + throw new \InvalidArgumentException(sprintf( + 'Feed formatter strategy should implements %s interface.', + FeedFormatterStrategyInterface::class + )); + } + + return $strategy; + } +} diff --git a/src/CacheBundle/Feed/Formatter/FeedFormatterInterface.php b/src/CacheBundle/Feed/Formatter/FeedFormatterInterface.php new file mode 100644 index 0000000..6515d4d --- /dev/null +++ b/src/CacheBundle/Feed/Formatter/FeedFormatterInterface.php @@ -0,0 +1,24 @@ +data = $data; + $this->mime = $mime; + } + + /** + * @return mixed + */ + public function getData() + { + return $this->data; + } + + /** + * @return string + */ + public function getMime() + { + return $this->mime; + } +} diff --git a/src/CacheBundle/Feed/Formatter/FormatterOptions.php b/src/CacheBundle/Feed/Formatter/FormatterOptions.php new file mode 100644 index 0000000..7527575 --- /dev/null +++ b/src/CacheBundle/Feed/Formatter/FormatterOptions.php @@ -0,0 +1,126 @@ +format = $format; + $this->numberOfDocuments = $numberOfDocuments; + $this->extract = $extract ?: ThemeOptionExtractEnum::no(); + $this->showImages = $showImages; + $this->asPlain = $asPlain; + $this->highlight = $highlight; + } + + /** + * @return FormatNameEnum + */ + public function getFormat() + { + return $this->format; + } + + /** + * @return integer + */ + public function getNumberOfDocuments() + { + return $this->numberOfDocuments; + } + + /** + * @return ThemeOptionExtractEnum + */ + public function getExtract() + { + return $this->extract; + } + + /** + * @return boolean + */ + public function isShowImages() + { + return $this->showImages; + } + + /** + * @return boolean + */ + public function isAsPlain() + { + return $this->asPlain; + } + + /** + * @return boolean + */ + public function isHighlight() + { + return $this->highlight; + } +} diff --git a/src/CacheBundle/Feed/Formatter/Strategy/AbstractFeedFormatStrategy.php b/src/CacheBundle/Feed/Formatter/Strategy/AbstractFeedFormatStrategy.php new file mode 100644 index 0000000..d1fe3aa --- /dev/null +++ b/src/CacheBundle/Feed/Formatter/Strategy/AbstractFeedFormatStrategy.php @@ -0,0 +1,79 @@ +extractor = $extractor; + } + + /** + * Return list of required document fields. + * + * @param FormatterOptions $options Formatter options. + * + * @return string[] + */ + public function requiredFields(FormatterOptions $options) + { + $fields = []; + + if (! $options->getExtract()->is(ThemeOptionExtractEnum::NO)) { + $fields[] = FieldNameEnum::MAIN; + } + + return $fields; + } + + /** + * @param string $content Document content. + * @param FormatterOptions $options FormatterOptions. + * @param AbstractFeed $feed A serialized feed entity instance. + * + * @return string + */ + protected function extract($content, FormatterOptions $options, AbstractFeed $feed) + { + $extract = $options->getExtract(); + + // + // We should get normalized search query only if it requested. + // + $query = ''; + if (! $extract->is(ThemeOptionExtractEnum::no())) { + if ($feed instanceof QueryFeed) { + $query = $feed->getQuery()->getNormalized(); + } + } + + $result = $this->extractor->extract($content, $query, $extract); + + return $result->getText() . ($result->getLength() > 0 ? '...' : ''); + } +} diff --git a/src/CacheBundle/Feed/Formatter/Strategy/AtomFeedFormatterStrategy.php b/src/CacheBundle/Feed/Formatter/Strategy/AtomFeedFormatterStrategy.php new file mode 100644 index 0000000..592578e --- /dev/null +++ b/src/CacheBundle/Feed/Formatter/Strategy/AtomFeedFormatterStrategy.php @@ -0,0 +1,139 @@ +generator = $generator; + } + + /** + * Return list of required document fields. + * + * @param FormatterOptions $options Formatter options. + * + * @return string[] + */ + public function requiredFields(FormatterOptions $options) + { + $fields = parent::requiredFields($options); + $fields[] = FieldNameEnum::TITLE; + $fields[] = FieldNameEnum::PERMALINK; + $fields[] = FieldNameEnum::SOURCE_TITLE; + $fields[] = FieldNameEnum::SOURCE_LINK; + $fields[] = FieldNameEnum::PUBLISHED; + $fields[] = FieldNameEnum::SECTION; + + if ($options->isShowImages()) { + $fields[] = FieldNameEnum::IMAGE_SRC; + } + + return $fields; + } + + /** + * Serialize feed. + * + * @param AbstractFeed $feed A serialized feed entity + * instance. + * @param ArticleDocumentInterface[] $documents Array of fetched documents + * which should by serialized. + * @param FormatterOptions $options Formatter options. + * + * @return mixed + */ + public function serialize( + AbstractFeed $feed, + array $documents, + FormatterOptions $options + ) { + $node = new \SimpleXMLElement(''); + + // Attach feed info. + $node->addChild('id', $this->generator->generate('app_index_index', [], UrlGeneratorInterface::ABSOLUTE_URL)); + $node->addChild('title', $feed->getName()); + $node->addChild('updated', date_create()->format('c')); + + $link = $node->addChild('link'); + $link->addAttribute('rel', 'self'); + $link->addAttribute('href', $this->generator->generate('app_index_exportfeed', [ + 'format' => $options->getFormat()->getValue(), + 'id' => $feed->getId(), + ])); + + $textType = $options->isAsPlain() ? 'text' : 'html'; + + foreach ($documents as $document) { + $data = $document->getNormalizedData(); + + $item = $node->addChild('entry'); + $item + ->addChild('title', $data['title']) + ->addAttribute('type', $textType); + + $link = $item->addChild('link'); + $link->addAttribute('rel', 'alternate'); + $link->addAttribute('href', $data['permalink']); + + if ($options->isShowImages()) { + $item->addChild('image', $data['image']); + } + + $author = $item->addChild('author'); + $author->addChild('name', $data['source']['title']); + $author->addChild('uri', $data['source']['link']); + + $item->addChild('pubDate', $data['published']->format('d M Y H:i:s e')); + $item + ->addChild('summary', $this->extract($data['content'], $options, $feed)) + ->addAttribute('type', $textType); + } + + return $node->asXML(); + } + + /** + * Get format mime type. + * + * @return string + */ + public function getMime() + { + return 'text/xml'; + } +} diff --git a/src/CacheBundle/Feed/Formatter/Strategy/FeedFormatterStrategyInterface.php b/src/CacheBundle/Feed/Formatter/Strategy/FeedFormatterStrategyInterface.php new file mode 100644 index 0000000..687a4ef --- /dev/null +++ b/src/CacheBundle/Feed/Formatter/Strategy/FeedFormatterStrategyInterface.php @@ -0,0 +1,49 @@ +templating = $templating; + } + + /** + * Return list of required document fields. + * + * @param FormatterOptions $options Formatter options. + * + * @return string[] + */ + public function requiredFields(FormatterOptions $options) + { + $fields = parent::requiredFields($options); + $fields[] = FieldNameEnum::PERMALINK; + $fields[] = FieldNameEnum::SOURCE_TITLE; + $fields[] = FieldNameEnum::SOURCE_LINK; + $fields[] = FieldNameEnum::PUBLISHED; + $fields[] = FieldNameEnum::AUTHOR_NAME; + $fields[] = FieldNameEnum::TITLE; + + if ($options->isShowImages()) { + $fields[] = FieldNameEnum::IMAGE_SRC; + } + + return $fields; + } + + /** + * Serialize feed. + * + * @param AbstractFeed $feed A serialized feed entity + * instance. + * @param ArticleDocumentInterface[] $documents Array of fetched documents + * which should by serialized. + * @param FormatterOptions $options Formatter options. + * + * @return mixed + */ + public function serialize( + AbstractFeed $feed, + array $documents, + FormatterOptions $options + ) { + $data = \nspl\a\map(function (ArticleDocumentInterface $document) use ($options, $feed) { + $data = $document->getNormalizedData(); + + $data['content'] = $this->extract($data['content'], $options, $feed); + + return $data; + }, $documents); + + return $this->templating->render('CacheBundle::feed.html.twig', [ + 'feed' => $feed, + 'data' => $data, + ]); + } + + /** + * Get format mime type. + * + * @return string + */ + public function getMime() + { + return 'text/html'; + } +} diff --git a/src/CacheBundle/Feed/Formatter/Strategy/RssFeedFormatterStrategy.php b/src/CacheBundle/Feed/Formatter/Strategy/RssFeedFormatterStrategy.php new file mode 100644 index 0000000..42a8caa --- /dev/null +++ b/src/CacheBundle/Feed/Formatter/Strategy/RssFeedFormatterStrategy.php @@ -0,0 +1,120 @@ +generator = $generator; + } + + /** + * Return list of required document fields. + * + * @param FormatterOptions $options Formatter options. + * + * @return string[] + */ + public function requiredFields(FormatterOptions $options) + { + $fields = parent::requiredFields($options); + $fields[] = FieldNameEnum::TITLE; + $fields[] = FieldNameEnum::PERMALINK; + $fields[] = FieldNameEnum::SOURCE_TITLE; + $fields[] = FieldNameEnum::SOURCE_LINK; + $fields[] = FieldNameEnum::PUBLISHED; + + if ($options->isShowImages()) { + $fields[] = FieldNameEnum::IMAGE_SRC; + } + + return $fields; + } + + /** + * Serialize feed. + * + * @param AbstractFeed $feed A serialized feed entity + * instance. + * @param ArticleDocumentInterface[] $documents Array of fetched documents + * which should by serialized. + * @param FormatterOptions $options Formatter options. + * + * @return mixed + */ + public function serialize( + AbstractFeed $feed, + array $documents, + FormatterOptions $options + ) { + $node = new \SimpleXMLElement(''); + + // Attach channel info. + $channel = $node->addChild('channel'); + $channel->addChild('title', $feed->getName()); + $channel->addChild('link', $this->generator->generate('app_index_index', [], UrlGeneratorInterface::ABSOLUTE_URL)); + + foreach ($documents as $document) { + $data = $document->getNormalizedData(); + + $item = $channel->addChild('item'); + $item + ->addChild('title', $data['title']) + ->addAttribute('url', $data['permalink']); + if ($options->isShowImages()) { + $item->addChild('image', $data['image']); + } + $item->addChild('description', $data['content']); + $item + ->addChild('source', $data['source']['title']) + ->addAttribute('url', $data['source']['link']); + $item->addChild('pubDate', $data['published']->format('d M Y H:i:s e')); + } + + return $node->asXML(); + } + + /** + * Get format mime type. + * + * @return string + */ + public function getMime() + { + return 'text/xml'; + } +} diff --git a/src/CacheBundle/Feed/Formatter/Strategy/TsvFeedFormatterStrategy.php b/src/CacheBundle/Feed/Formatter/Strategy/TsvFeedFormatterStrategy.php new file mode 100644 index 0000000..efdbf8d --- /dev/null +++ b/src/CacheBundle/Feed/Formatter/Strategy/TsvFeedFormatterStrategy.php @@ -0,0 +1,113 @@ +getNormalizedData(); + + $date = $data['published']; + if (! $date instanceof \DateTimeInterface) { + $date = date_create(); + } + + return [ + $data['permalink'], + $data['title'], + $data['source']['title'], + $data['source']['link'], + $date->format('Y-m-d H:i:s'), + $data['author']['name'], + $this->extract($data['content'], $options, $feed), + ]; + } + ); + $lines = \nspl\a\map($processor, $documents); + + return $body . implode(PHP_EOL, $lines); + } + + /** + * Get format mime type. + * + * @return string + */ + public function getMime() + { + // + // TSV has own mime type: 'text/tab-separated-values' but response with + // this mime got strange encoding so we use 'text/plain' instead. + // + return 'text/plain'; + } +} diff --git a/src/CacheBundle/Feed/Response/FeedResponse.php b/src/CacheBundle/Feed/Response/FeedResponse.php new file mode 100644 index 0000000..ef76fa1 --- /dev/null +++ b/src/CacheBundle/Feed/Response/FeedResponse.php @@ -0,0 +1,88 @@ +response = $response; + $this->advancedFilters = $advancedFilters; + $this->meta = $meta; + } + + /** + * Get response. + * + * @return SearchResponseInterface + */ + public function getResponse() + { + return $this->response; + } + + /** + * Get available advanced filters values. + * + * @return array + */ + public function getAdvancedFilters() + { + return $this->advancedFilters; + } + + /** + * Get response meta information. + * + * @param Request $request A Request instance. + * + * @return array + */ + public function getMeta(Request $request) + { + $currentAdvancedFilters = $request->request->get('advancedFilters', []); + + // + // Add currently selected advanced filters if they are provided. + // + if (count($currentAdvancedFilters)) { + $this->meta['search']['advancedFilters'] = $currentAdvancedFilters; + } + + return $this->meta; + } +} diff --git a/src/CacheBundle/Feed/Response/FeedResponseInterface.php b/src/CacheBundle/Feed/Response/FeedResponseInterface.php new file mode 100644 index 0000000..23f4549 --- /dev/null +++ b/src/CacheBundle/Feed/Response/FeedResponseInterface.php @@ -0,0 +1,36 @@ +index = $index; + $this->tokenStorage = $tokenStorage; + } + + /** + * Builds the form. + * + * This method is called for each type in the hierarchy starting from the + * top most type. Type extensions can further modify the form. + * + * @see FormTypeExtensionInterface::buildForm() + * + * @param FormBuilderInterface $builder The form builder. + * @param array $options The options. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('feeds', CurrentUserOwnedEntityType::class, [ + 'class' => AbstractFeed::class, + 'multiple' => true, + 'description' => 'Array of current user feeds ids.', + 'constraints' => new NotBlank(), + ]) + ->add('filters', FiltersType::class, [ + 'filter_factory' => $this->index->getFilterFactory(), + 'description' => 'Search filters.', + 'empty_data' => [], + 'filters' => AbstractSearchRequestType::$filters, + 'required' => false, + ]) + ->setDataMapper($this) + ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) { + $data = $event->getData(); + + $this->rawFilters = []; + if (isset($data['filters'])) { + $this->rawFilters = $data['filters']; + } + }); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => AnalyticDTO::class, + 'empty_data' => null, + ]); + } + + /** + * Returns the prefix of the template block name for this type. + * + * The block prefix defaults to the underscored short class name with + * the "Type" suffix removed (e.g. "UserProfileType" => "user_profile"). + * + * @return string The prefix of the template block name. + */ + public function getBlockPrefix() + { + return ''; + } + + /** + * Maps properties of some data to a list of forms. + * + * @param AnalyticDTO|null $data Structured data. + * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of + * {@link FormInterface} + * instances. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function mapDataToForms($data, $forms) + { + // Do nothing because it's not necessary method. + } + + /** + * Maps the data of a list of forms into the properties of some data. + * + * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of + * {@link FormInterface} + * instances. + * @param AnalyticDTO|null $data Structured data. + * + * @return void + */ + public function mapFormsToData($forms, &$data) + { + $forms = iterator_to_array($forms); + + $feeds = $forms['feeds']->getData(); + if ($feeds instanceof Collection) { + $feeds = $feeds->toArray(); + } + + $data = new AnalyticDTO( + $feeds, + \app\op\invokeIf($this->tokenStorage->getToken(), 'getUser'), + $forms['filters']->getData(), + $this->rawFilters + ); + } +} diff --git a/src/CacheBundle/Form/CategoryType.php b/src/CacheBundle/Form/CategoryType.php new file mode 100644 index 0000000..a07c438 --- /dev/null +++ b/src/CacheBundle/Form/CategoryType.php @@ -0,0 +1,53 @@ +add('name') + ->add('parent', CurrentUserOwnedEntityType::class, [ + 'class' => Category::class, + ]); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefault('data_class', Category::class); + } +} diff --git a/src/CacheBundle/Form/CommentType.php b/src/CacheBundle/Form/CommentType.php new file mode 100644 index 0000000..1b27634 --- /dev/null +++ b/src/CacheBundle/Form/CommentType.php @@ -0,0 +1,51 @@ +add('title', null, [ 'empty_data' => '' ]) + ->add('content'); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefault('data_class', Comment::class); + } +} diff --git a/src/CacheBundle/Form/FeedInfoType.php b/src/CacheBundle/Form/FeedInfoType.php new file mode 100644 index 0000000..9b43848 --- /dev/null +++ b/src/CacheBundle/Form/FeedInfoType.php @@ -0,0 +1,141 @@ +add('subType', ChoiceType::class, [ + 'choices' => $availableSubtypes, + 'invalid_message' => sprintf( + 'Unknown feed sub type. Available: %s', + implode(', ', $availableSubtypes) + ), + ]) + ->add('excludedDocuments', EntityType::class, [ + 'class' => Document::class, + 'multiple' => true, + ]) + ->add('name', null, [ + 'constraints' => new NotBlank(), + ]) + ->add('category', CurrentUserOwnedEntityType::class, [ + 'class' => Category::class, + ]) + ->setDataMapper($this); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => AbstractFeed::class, + 'empty_data' => null, + 'validation_groups' => [ 'Feed_Create' ], + ]); + } + + /** + * Maps properties of some data to a list of forms. + * + * @param AbstractFeed|null $data Structured data. + * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of + * {@link FormInterface} + * instances. + * + * @return void + */ + public function mapDataToForms($data, $forms) + { + $forms = iterator_to_array($forms); + + if ($data instanceof AbstractFeed) { + $forms['name']->setData($data->getName()); + $forms['category']->setData($data->getCategory()); + } + } + + /** + * Maps the data of a list of forms into the properties of some data. + * + * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of + * {@link FormInterface} + * instances. + * @param AbstractFeed|null $data Structured data. + * + * @return void + */ + public function mapFormsToData($forms, &$data) + { + $forms = iterator_to_array($forms); + + // + // Create new proper feed instance if it's not provided to us. + // + if (! $data instanceof AbstractFeed) { + try { + $data = AbstractFeed::createBySubType($forms['subType']->getData()); + } catch (\Exception $exception) { + // This should be handled by constraints. + } + } + + $data + ->setName($forms['name']->getData()) + ->setCategory($forms['category']->getData()); + + $excludedDocuments = $forms['excludedDocuments']->getData(); + foreach ($excludedDocuments as $document) { + $data->addExcludedDocument($document); + } + } +} diff --git a/src/CacheBundle/Form/Sources/SourceListSearchType.php b/src/CacheBundle/Form/Sources/SourceListSearchType.php new file mode 100644 index 0000000..fb7ab07 --- /dev/null +++ b/src/CacheBundle/Form/Sources/SourceListSearchType.php @@ -0,0 +1,118 @@ + 'name', + 'sources' => 'sourceNumber', + 'createdBy' => 'user', + 'lastUpdated' => 'updatedAt', + 'lastUpdatedBy' => 'updatedBy', + ]; + + /** + * Builds the form. + * + * This method is called for each type in the hierarchy starting from the + * top most type. Type extensions can further modify the form. + * + * @see FormTypeExtensionInterface::buildForm() + * + * @param FormBuilderInterface $builder The form builder. + * @param array $options The options. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('page', null, [ + 'description' => 'Requested page number, should start from 1. Default value 1.', + 'empty_data' => 1, + ]) + ->add('limit', null, [ + 'description' => 'Max sources per page. Default 20.', + 'empty_data' => 20, + ]) + ->add('sort', SortType::class, [ + 'fields' => self::$fields, + 'default_field' => 'name', + 'default_direction' => 'asc', + ]) + ->add('onlyShared', CheckboxType::class, [ + 'description' => 'Show only shared source lists.', + 'required' => false, + ]) + ->addModelTransformer($this); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefault('key', 'searchSourceList'); + } + + /** + * Transforms a value from the transformed representation to its original + * representation. + * + * This method is called when {@link Form::submit()} is called to transform + * the requests tainted data into an acceptable format for your data + * processing/model layer. + * + * This method must be able to deal with empty values. Usually this will + * be an empty string, but depending on your implementation other empty + * values are possible as well (such as NULL). The reasoning behind + * this is that value transformers must be chainable. If the + * reverseTransform() method of the first value transformer outputs an + * empty string, the second value transformer must be able to process that + * value. + * + * By convention, reverseTransform() should return NULL if an empty string + * is passed. + * + * @param mixed $data The value in the transformed representation. + * + * @return mixed The value in the original representation + * + * @throws TransformationFailedException When the transformation fails. + */ + public function reverseTransform($data) + { + if (count($data['sort']) === 0) { + $data['sort'] = [ 'name' => 'asc' ]; + } + + if (! isset($data['onlyShared'])) { + $data['onlyShared'] = false; + } + + return $data; + } +} diff --git a/src/CacheBundle/Form/Sources/SourceListType.php b/src/CacheBundle/Form/Sources/SourceListType.php new file mode 100644 index 0000000..8c748e9 --- /dev/null +++ b/src/CacheBundle/Form/Sources/SourceListType.php @@ -0,0 +1,47 @@ +add('name'); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefault('data_class', SourceList::class); + } +} diff --git a/src/CacheBundle/Form/Sources/SourceSearchType.php b/src/CacheBundle/Form/Sources/SourceSearchType.php new file mode 100644 index 0000000..2dda612 --- /dev/null +++ b/src/CacheBundle/Form/Sources/SourceSearchType.php @@ -0,0 +1,191 @@ + FieldNameEnum::SOURCE_TITLE, + 'mediaType' => FieldNameEnum::SOURCE_PUBLISHER_TYPE, + 'country' => FieldNameEnum::COUNTRY, + ]; + + private static $filters = [ + 'publisher' => [ + 'type' => QueryFilter\PublisherFilterType::class, + 'description' => 'Filter by publisher type.', + ], + 'language' => [ + 'type' => QueryFilter\LanguageFilterType::class, + 'description' => 'Filter by language, use ISO 639-1 two-letters codes.', + ], + 'country' => [ + 'type' => QueryFilter\CountryFilterType::class, + 'description' => 'Filter by countries, ISO 3166-1 Alpha-2 two-letters codes.', + ], + 'state' => [ + 'type' => QueryFilter\StateFilterType::class, + 'description' => 'Filter by US states, ANSI standard INCITS 38:2009 two-letters codes.', + ], + ]; + + /** + * Builds the form. + * + * This method is called for each type in the hierarchy starting from the + * top most type. Type extensions can further modify the form. + * + * @see FormTypeExtensionInterface::buildForm() + * + * @param FormBuilderInterface $builder The form builder. + * @param array $options The options. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + + /** + * Custom data mapping + * + * @param FormEvent $event A FormEvent instance. + * + * @return void + */ + $postSubmit = function (FormEvent $event) { + /** @var SearchRequestBuilderInterface $builder */ + $builder = $event->getData(); + $builder->setSorts($event->getForm()->get('sort')->getData()); + $event->setData($builder); + }; + + $builder + ->add('query', null, [ + 'description' => 'Search query, maybe empty. Search by source title and url.', + 'empty_data' => '', + 'constraints' => new Length([ + 'max' => 40, + 'maxMessage' => 'Search query is too long. Should be 40 characters long or less.', + ]), + ]) + ->add('page', null, [ + 'description' => 'Requested page number, should start from 1. Default value 1.', + 'empty_data' => 1, + ]) + ->add('limit', null, [ + 'description' => 'Max sources per page. Default 20.', + 'empty_data' => 20, + ]) + ->add('filters', FiltersType::class, [ + 'filter_factory' => $this->index->getFilterFactory(), + 'description' => 'Search filters.', + 'empty_data' => [], + 'filters' => self::$filters, + ]) + ->add('sort', SortType::class, [ + 'fields' => self::$fields, + 'default_field' => 'name', + 'default_direction' => 'asc', + 'mapped' => false, + ]) + ->add('advancedFilters', AdvancedFiltersType::class, [ + 'description' => 'Advanced filters.', + 'config' => AdvancedFiltersConfig::getConfig(AFSourceEnum::SOURCE), + 'empty_data' => [], + 'connection' => $this->index, + 'required' => false, + ]) + ->addEventListener(FormEvents::POST_SUBMIT, $postSubmit) + ->setEmptyData($this->index->createRequestBuilder()) + ->setDataMapper($this); + + $builder + ->get('sort') + ->addModelTransformer(new OnlyReverseTransformer(function (array $data) { + if (count($data) === 0) { + // Default sort order. + $data = [ FieldNameEnum::SOURCE_TITLE => 'asc' ]; + } + + return $data; + })); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + $resolver->setDefault('key', 'searchSource'); + } + + /** + * Maps properties of some data to a list of forms. + * + * @param mixed $data Structured data. + * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of + * {@link FormInterface} + * instances. + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function mapDataToForms($data, $forms) + { + // Do nothing because it's senseless. + } + + /** + * Maps the data of a list of forms into the properties of some data. + * + * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of + * {@link FormInterface} + * instances. + * @param mixed|SearchRequestBuilderInterface $data Structured data. + * + * @return void + */ + public function mapFormsToData($forms, &$data) + { + $forms = iterator_to_array($forms); + + $data + ->setQuery($forms['query']->getData()) + ->setPage($forms['page']->getData()) + ->setLimit($forms['limit']->getData()) + ->setFilters(array_merge( + $forms['filters']->getData(), + $forms['advancedFilters']->getData() + )); + } +} diff --git a/src/CacheBundle/Form/Sources/Type/SortType.php b/src/CacheBundle/Form/Sources/Type/SortType.php new file mode 100644 index 0000000..44631d4 --- /dev/null +++ b/src/CacheBundle/Form/Sources/Type/SortType.php @@ -0,0 +1,78 @@ +add('field', ChoiceType::class, [ + 'description' => 'Field name on which we should sort.', + 'choices' => array_keys($options['fields']), + 'empty_data' => $options['default_field'], + ]) + ->add('direction', ChoiceType::class, [ + 'description' => 'Sorting direction.', + 'choices' => [ 'asc', 'desc' ], + 'empty_data' => $options['default_direction'], + ]); + + $transformer = new OnlyReverseTransformer(function (array $sortObject) use ($fields) { + if (! is_array($sortObject)) { + throw new TransformationFailedException('Expect array got '. gettype($sortObject)); + } + + if (! isset($sortObject['field'], $sortObject['direction'])) { + return []; + } + + return [ $fields[$sortObject['field']] => $sortObject['direction'] ]; + }); + + $builder->addModelTransformer($transformer); + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver + ->setRequired('fields') + ->setDefined('default_field') + ->setDefined('default_direction') + ->setAllowedTypes('fields', 'array'); + } +} diff --git a/src/CacheBundle/Form/Type/CurrentUserOwnedEntityType.php b/src/CacheBundle/Form/Type/CurrentUserOwnedEntityType.php new file mode 100644 index 0000000..2dde636 --- /dev/null +++ b/src/CacheBundle/Form/Type/CurrentUserOwnedEntityType.php @@ -0,0 +1,83 @@ +storage = $storage; + } + + /** + * Configures the options for this type. + * + * @param OptionsResolver $resolver The resolver for the options. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver + ->setRequired('user_property') + ->setAllowedTypes('user_property', 'string') + ->setDefaults([ + 'user_property' => 'user', + 'query_builder' => function (Options $options) { + $userProperty = $options['user_property']; + + return function (EntityRepository $repository) use ($userProperty) { + // Get current user. + $user = \app\op\invokeIf($this->storage->getToken(), 'getUser'); + + if ($user instanceof User) { + $user = $user->getId(); + } + + $qb = $repository->createQueryBuilder('Entity'); + + if ($user instanceof User) { + $qb + ->where("Entity.{$userProperty} = :user") + ->setParameter('user', $user); + } + + return $qb; + }; + }, + ]); + parent::configureOptions($resolver); + } +} diff --git a/src/CacheBundle/Repository/AnalyticRepository.php b/src/CacheBundle/Repository/AnalyticRepository.php new file mode 100644 index 0000000..1bc95b4 --- /dev/null +++ b/src/CacheBundle/Repository/AnalyticRepository.php @@ -0,0 +1,39 @@ +_em->getExpressionBuilder(); + + return $this->createQueryBuilder('Analytic') + ->select( + 'partial Analytic.{id,createdAt,updatedAt}', + 'partial context.{hash,filters,rawFilters}' + ) + ->leftJoin('Analytic.context', 'context') + ->where($expr->eq('Analytic.owner', ':user')) + ->setParameter('user', $user) + ->addOrderBy('Analytic.id', 'desc') + ->getQuery() + ->getResult(); + } + +} diff --git a/src/CacheBundle/Repository/CategoryRepository.php b/src/CacheBundle/Repository/CategoryRepository.php new file mode 100644 index 0000000..1c486b4 --- /dev/null +++ b/src/CacheBundle/Repository/CategoryRepository.php @@ -0,0 +1,221 @@ +_em->getExpressionBuilder(); + $condition = $expr->andX($expr->eq('Category.id', ':id')); + $parameters = [ 'id' => $id ]; + + if ($method !== 'get') { + $condition->add($expr->eq('Category.internal', false)); + } + + return $this->createQueryBuilder('Category') + ->addSelect('partial Query.{id, raw, status}') + ->where($condition) + ->setParameters($parameters) + ->getQuery() + ->getOneOrNullResult(); + } + + /** + * Compute feed count in specified category and all childes categories. + * + * @param integer $id A Category entity id. + * + * @return integer + */ + public function computeFeedCounts($id) + { + return (int) $this->_em->getConnection()->fetchColumn(" + SELECT COUNT(feeds.id) + FROM feeds + WHERE + category_id in ( + SELECT id + FROM ( + SELECT * + FROM categories + ORDER BY parent_id, id + ) categories_sorted, + (SELECT @pv := '{$id}') initialisation + WHERE + ( + FIND_IN_SET(parent_id, @pv) > 0 + OR id = {$id} + ) + AND @pv := CONCAT(@pv, ',', id) + ) + "); + } + + + + /** + * Export all feeds inside this category. + * + * @param integer $category A Category entity id. + * @param boolean $export Export all feeds if true and unexport otherwise. + * + * @return void + */ + public function exportFeedsIn($category, $export = true) + { + $this->_em->getConnection()->transactional(function (Connection $conn) use ($category, $export) { + $conn->exec(sprintf(" + UPDATE feeds + SET exported = %d + WHERE + category_id in ( + SELECT id + FROM ( + SELECT * + FROM categories + ORDER BY parent_id, id + ) categories_sorted, + (SELECT @pv := '%s') initialisation + WHERE + ( + FIND_IN_SET(parent_id, @pv) > 0 + OR id = %s + ) + AND @pv := CONCAT(@pv, ',', id) + ) + ", $export, $category, $category)); + $conn->exec(sprintf(" + UPDATE categories + SET exported = %d + WHERE + id in ( + SELECT id + FROM ( + SELECT * + FROM categories + ORDER BY parent_id, id + ) categories_sorted, + (SELECT @pv := '%s') initialisation + WHERE + ( + FIND_IN_SET(parent_id, @pv) > 0 + OR id = %s + ) + AND @pv := CONCAT(@pv, ',', id) + ) + ", $export, $category, $category)); + }); + } + + /** + * Get active category. + * + * @param integer $id A Category entity id. + * @param integer $user Filter categories by specified owner if set. + * @param string|array $type Filter by category types. + * + * @return Category|null + */ + public function get($id, $user = null, $type = null) + { + $expr = $this->_em->getExpressionBuilder(); + $condition = $expr->andX($expr->eq('Category.id', ':id')); + $parameters = [ 'id' => $id ]; + + if ($type) { + $type = (array) $type; + $condition->add($expr->in('Category.type', (array) $type)); + } + + if ($user !== null) { + $condition->add($expr->eq('Category.user', ':user')); + $parameters['user'] = $user; + } + + return $this->createQueryBuilder('Category') + ->where($condition) + ->setParameters($parameters) + ->getQuery() + ->getOneOrNullResult(); + } + + /** + * Get array of specified user categories. + * + * @param integer $user A User entity id. + * + * @return Category[] + */ + public function getList($user) + { + $expr = $this->_em->getExpressionBuilder(); + + return $this->createQueryBuilder('Category') + ->select( + 'partial Category.{id, name, type}', + 'partial Child.{id, name, type}', + 'Feed' + ) + ->leftJoin('Category.parent', 'Parent') + ->leftJoin('Category.childes', 'Child') + ->leftJoin('Category.feeds', 'Feed') + ->where($expr->andX( + $expr->eq('Category.user', ':user'), + $expr->isNull('Category.parent') + )) + ->setParameter('user', $user) + ->getQuery() + ->getResult(); + } + + /** + * Check that specified 'child' category is child of 'parent'. + * + * @param integer $child A Category entity id which may by child. + * @param integer $parent A Category entity id which must by parent of + * specified child. + * + * @return boolean + */ + public function isChildOf($child, $parent) + { + // + // TODO: May be exists more efficient way to do it. + // + $position = (int) $this->_em + ->getConnection() + ->fetchColumn(" + SELECT + FIND_IN_SET({$child}, lvl) AS result + FROM ( + SELECT + GROUP_CONCAT(lvl SEPARATOR ',') AS lvl + FROM ( + SELECT @parent := (SELECT GROUP_CONCAT(id SEPARATOR ',') + FROM categories + WHERE FIND_IN_SET(parent_id, @parent) + ) AS lvl FROM categories JOIN (SELECT @parent := {$parent}) tmp ) a ) b; + "); + + return $position !== 0; + } +} diff --git a/src/CacheBundle/Repository/ClipFeedRepository.php b/src/CacheBundle/Repository/ClipFeedRepository.php new file mode 100644 index 0000000..229bd1a --- /dev/null +++ b/src/CacheBundle/Repository/ClipFeedRepository.php @@ -0,0 +1,68 @@ +createQueryBuilder('Feed') + ->where('Feed.user = :user AND Feed.name = :name') + ->setParameters([ + 'user' => $user, + 'name' => ClipFeed::READ_LATER, + ]) + ->getQuery() + ->getOneOrNullResult(); + } + + /** + * Create read later feed. + * + * @param integer $user Owner of read later feed. + * + * @return \CacheBundle\Entity\Feed\AbstractFeed + */ + public function createReadLater($user) + { + $mainCategory = $this->_em->createQueryBuilder() + ->select('partial Category.{id}') + ->from(Category::class, 'Category') + ->where('Category.user = :user AND Category.type = :type') + ->setParameters([ + 'user' => $user, + 'type' => Category::TYPE_MY_CONTENT, + ]) + ->getQuery() + ->getOneOrNullResult(); + + $feed = ClipFeed::create() + ->setName(ClipFeed::READ_LATER) + ->setUser($this->_em->getReference(User::class, $user)) + ->setCategory($mainCategory); + + $this->_em->persist($feed); + $this->_em->flush($feed); + + return $feed; + } +} diff --git a/src/CacheBundle/Repository/CommentRepository.php b/src/CacheBundle/Repository/CommentRepository.php new file mode 100644 index 0000000..fcdae56 --- /dev/null +++ b/src/CacheBundle/Repository/CommentRepository.php @@ -0,0 +1,79 @@ +createQueryBuilder('Comment') + ->where('Comment.document = :document') + ->addOrderBy('Comment.createdAt', 'desc') + ->setParameter('document', $document); + + if (count($fields) > 0) { + $authorFields = []; + if (isset($fields['author'])) { + $authorFields = $fields['author']; + unset($fields['author']); + } + + $qb->select('partial Comment.{id, '. implode(',', $fields) .'}'); + if (count($authorFields) > 0) { + $qb + ->join('Comment.author', 'Author') + ->addSelect('partial Author.{id, '. implode(',', $authorFields) .'}'); + } + } else { + $qb + ->join('Comment.author', 'Author') + ->addSelect('Author'); + } + + if ($count !== null) { + $qb->setMaxResults($count); + } + + return $qb; + } + + /** + * @param integer $documentId A Document entity id. + * @param integer $poolSize Max new comments for specified document. + * + * @return void + */ + public function updateCommentMarks($documentId, $poolSize) + { + $this->_em->getConnection()->executeUpdate(sprintf(' + UPDATE comments + SET new = 0 + WHERE id IN ( + SELECT id + FROM ( + SELECT id + FROM comments + WHERE document_id = :document AND new = 1 + ORDER BY created_at DESC LIMIT %d, %d + ) AS + u) + ', $poolSize, 1000), [ 'document' => $documentId ]); + } +} diff --git a/src/CacheBundle/Repository/CommonFeedRepository.php b/src/CacheBundle/Repository/CommonFeedRepository.php new file mode 100644 index 0000000..c2620d5 --- /dev/null +++ b/src/CacheBundle/Repository/CommonFeedRepository.php @@ -0,0 +1,39 @@ +_em->getExpressionBuilder(); + $condition = $expr->andX($expr->eq('Feed.id', ':id')); + $parameters = [ 'id' => $id ]; + + if ($user !== null) { + $condition->add($expr->eq('Feed.user', ':user')); + $parameters['user'] = $user; + } + + return $this->createQueryBuilder('Feed') + ->where($condition) + ->setParameters($parameters) + ->getQuery() + ->getOneOrNullResult(); + } +} diff --git a/src/CacheBundle/Repository/DocumentRepository.php b/src/CacheBundle/Repository/DocumentRepository.php new file mode 100644 index 0000000..40d7db7 --- /dev/null +++ b/src/CacheBundle/Repository/DocumentRepository.php @@ -0,0 +1,253 @@ +_em->getExpressionBuilder(); + + return $this->createQueryBuilder('Document') + ->addSelect('Comment, CommentAuthor') + ->join('Document.pages', 'Page') + ->leftJoin('Document.comments', 'Comment', Join::WITH, $expr->andX( + $expr->eq('Comment.document', 'Document.id'), + $expr->eq('Comment.new', 1) + )) + ->leftJoin('Comment.author', 'CommentAuthor') + ->where($expr->andX( + $expr->eq('Page.number', ':number'), + $expr->eq('Page.query', ':query') + )) + ->setParameters([ + 'number' => $page, + 'query' => $query, + ]) + ->addOrderBy('Comment.createdAt', 'desc') + ->getQuery() + ->getResult(); + } + + /** + * Get documents by given ids for specified query without related pages. + * Result will be ordered depends on specified ids order. + * + * @param integer $collectionId A document collection entity id. + * @param string $collectionType A document collection type. + * @param array $ids Array of document ids. + * @param string[]|array $fields Array of required document fields. Fetch + * all if empty. + * + * @return Document[] + */ + public function getFromCollectionByIds($collectionId, $collectionType, array $ids, array $fields = []) + { + $expr = $this->_em->getExpressionBuilder(); + + $condition = $expr->andX($expr->in('Document.id', $ids)); + switch ($collectionType) { + case CollectionTypeEnum::FEED: + $condition->add($expr->eq('Page.clipFeed', ':collectionId')); + break; + + case CollectionTypeEnum::QUERY: + $condition->add($expr->eq('Page.query', ':collectionId')); + break; + + default: + throw new \InvalidArgumentException('Invalid collection type.'); + } + + $select = 'Document'; + if (count($fields) > 0) { + $select = 'partial Document.{'. implode(',', $fields). '}'; + } + + $results = $this->createQueryBuilder('Document') + ->select($select) + ->addSelect('Comment, CommentAuthor, Page') + ->join('Document.pages', 'Page') + ->leftJoin('Document.comments', 'Comment', Join::WITH, $expr->andX( + $expr->eq('Comment.document', 'Document.id'), + $expr->eq('Comment.new', 1) + )) + ->leftJoin('Comment.author', 'CommentAuthor') + ->where($condition) + ->setParameter('collectionId', $collectionId) + // + // We should clearly say how we want to order because mysql does not + // guarantee what result will be ordered by primary key. + // + ->addOrderBy('Document.id', 'asc') + ->addOrderBy('Comment.createdAt', 'desc') + ->getQuery() + ->getResult(); + + // + // Now we use specified ids as index for fetched documents. Since we got + // ordered result we may use binary search for fetching proper document + // by id. + // + + $orderedResult = []; + foreach ($ids as $id) { + $idx = \app\a\binarySearch($results, $id, \nspl\op\methodCaller('getId')); + if ($idx === false) { + continue; + } + + $orderedResult[] = $results[$idx]; + } + + return $orderedResult; + } + + /** + * Get documents by given ids for specified query. + * + * @param array $ids Array of document ids. + * + * @return Document[] + */ + public function getByIds(array $ids) + { + $expr = $this->_em->getExpressionBuilder(); + + return $this->createQueryBuilder('Document') + ->addSelect('Comment, CommentAuthor') + ->where($expr->in('Document.id', $ids)) + ->leftJoin('Document.comments', 'Comment', Join::WITH, $expr->andX( + $expr->eq('Comment.document', 'Document.id'), + $expr->eq('Comment.new', 1) + )) + ->leftJoin('Comment.author', 'CommentAuthor') + ->addOrderBy('Comment.createdAt', 'desc') + ->getQuery() + ->getResult(); + } + + /** + * @param array $fields Array of required fields names, `id` return + * always. + * @param array $ids Array of document ids. + * + * @return Document[] + */ + public function getWithFieldsByIds(array $fields, array $ids) + { + return $this->createQueryBuilder('Document') + ->select('partial Document.{id, '.implode(',', $fields).'}') + ->where('Document.id IN (:ids)') + ->setParameter('ids', $ids) + ->getQuery() + ->getResult(); + } + + /** + * Check whether the documents exists. + * + * @param string[] $checkedIds Array of document ids. + * + * @return string[] Array of not exists ids from passed. + */ + public function checkIds(array $checkedIds) + { + $expr = $this->_em->getExpressionBuilder(); + + $existsIds = $this->createQueryBuilder('Document') + ->select('Document.id') + ->where($expr->in('Document.id', array_map(\nspl\op\str, $checkedIds))) + ->getQuery() + ->getArrayResult(); + + return array_diff($checkedIds, \nspl\a\flatten($existsIds)); + } + + /** + * Remove from provided ids whose document id which already exists in specified document collection. + * + * @param string $collectionId A DocumentCollectionInterface entity id. + * @param string $collectionType A DocumentCollectionInterface type. + * @param array $ids Array of document ids. + * + * @return string[] Array of documents which is not exists in specified document collection. + */ + public function sanitizeIds($collectionId, $collectionType, array $ids) + { + $expr = $this->_em->getExpressionBuilder(); + + $condition = $expr->andX($expr->in('Document.id', array_map(\nspl\op\str, $ids))); + switch ($collectionType) { + case CollectionTypeEnum::FEED: + $condition->add($expr->eq('Page.clipFeed', ':collectionId')); + break; + + case CollectionTypeEnum::QUERY: + $condition->add($expr->eq('Page.query', ':collectionId')); + break; + + default: + throw new \InvalidArgumentException('Invalid collection type.'); + } + + $existsIds = $this->createQueryBuilder('Document') + ->select('Document.id') + ->join('Document.pages', 'Page') + ->where($condition) + ->setParameter('collectionId', $collectionId) + ->getQuery() + ->getArrayResult(); + + return array_diff($ids, \nspl\a\flatten($existsIds)); + } + + /** + * @param array $queryId + * @return array + */ + public function getByQuery(array $queryId) + { + return $this->createQueryBuilder('Document') + ->select('Document.data','Query.id') + ->join('Document.pages', 'Page') + ->join('Page.query', 'Query') + ->where('Query.id IN (:queryId)') + ->setParameter('queryId', $queryId) + ->getQuery() + ->getResult(); + } + + /** + * @param array $clipFeedId + * @return array + */ + public function getByClip(array $clipFeedId) + { + return $this->createQueryBuilder('Document') + ->select('Document.data','IDENTITY(Page.clipFeed) as clipFeedId ') + ->join('Document.pages', 'Page') + ->where('Page.clipFeed IN (:clipFeedId)') + ->setParameter('clipFeedId', $clipFeedId) + ->getQuery() + ->getResult(); + } +} diff --git a/src/CacheBundle/Repository/QueryFeedRepository.php b/src/CacheBundle/Repository/QueryFeedRepository.php new file mode 100644 index 0000000..8d7c7b0 --- /dev/null +++ b/src/CacheBundle/Repository/QueryFeedRepository.php @@ -0,0 +1,70 @@ +_em->getExpressionBuilder(); + $condition = $expr->andX($expr->eq('Feed.id', ':id')); + $parameters = [ 'id' => $id ]; + + if ($user !== null) { + $condition->add($expr->eq('Feed.user', ':user')); + $parameters['user'] = $user; + } + + return $this->createQueryBuilder('Feed') + ->where($condition) + ->setParameters($parameters) + ->getQuery() + ->getOneOrNullResult(); + } + + /** + * @param integer $user A User entity id. + * + * @return \Doctrine\ORM\QueryBuilder + */ + public function getFeedsForFormQb($user) + { + return $this->createQueryBuilder('ch') + ->where('ch.userId = :userId') + ->setParameter(':userId', $user); + } + + /** + * @param integer $query A stored query instance. + * + * @return StoredQuery[] + */ + public function getWithExcludedDocumentsForQuery($query) + { + return $this->createQueryBuilder('Feed') + ->addSelect('partial Document.{id}') + ->innerJoin('Feed.excludedDocuments', 'Document') + ->where('Feed.query = :query') + ->setParameter('query', $query) + ->getQuery() + ->getResult(); + } +} diff --git a/src/CacheBundle/Repository/QueryRepositoryInterface.php b/src/CacheBundle/Repository/QueryRepositoryInterface.php new file mode 100644 index 0000000..12e9026 --- /dev/null +++ b/src/CacheBundle/Repository/QueryRepositoryInterface.php @@ -0,0 +1,21 @@ +_em->getExpressionBuilder(); + + return $this->createQueryBuilder('Query') + ->select('partial Query.{id, totalCount, expirationDate, raw, rawFilters, rawAdvancedFilters}') + ->where($expr->andX( + $expr->eq('Query.hash', ':hash'), + $expr->gt('Query.expirationDate', ':date') + )) + ->setParameters([ + 'hash' => $hash, + 'date' => date_create(), + ]) + ->getQuery() + ->getOneOrNullResult(); + } + + /** + * Get all old queries. + * + * @return integer[] Old queries ids. + */ + public function getOld() + { + $expr = $this->_em->getExpressionBuilder(); + + // Get all old queries ids. + $ids = $this->createQueryBuilder('Query') + ->select('Query.id') + ->where($expr->lte('Query.expirationDate', ':date')) + ->setParameter('date', date_create()) + ->getQuery() + ->getArrayResult(); + return array_map(function (array $row) { + return $row['id']; + }, $ids); + } +} diff --git a/src/CacheBundle/Repository/SourceListRepository.php b/src/CacheBundle/Repository/SourceListRepository.php new file mode 100644 index 0000000..72d2ebc --- /dev/null +++ b/src/CacheBundle/Repository/SourceListRepository.php @@ -0,0 +1,108 @@ +_em->getExpressionBuilder(); + + $qb = $this->createQueryBuilder('SourceList') + ->addSelect('partial User.{id, firstName, lastName}') + ->join('SourceList.user', 'User') + ->where($expr->eq('SourceList.user', ':user')) + ->setParameter(':user', $user); + + if ($onlyShared) { + $qb->andWhere($expr->eq('SourceList.isGlobal', 1)); + } else { + $qb->orWhere($expr->eq('SourceList.isGlobal', 1)); + } + + foreach ($order as $field => $direction) { + $qb->addOrderBy("SourceList.{$field}", $direction); + } + + return $qb; + } + + /** + * Get concrete SourceLists for the user. + * + * @param integer $id A SourceList entity id. + * @param integer $user A User entity id. + * + * @return SourceList|null + */ + public function getSourcesLists($id, $user) + { + $expr = $this->_em->getExpressionBuilder(); + + return $this->getSourcesListsQB($user) + ->andWhere($expr->eq('SourceList.id', ':id')) + ->setParameter('id', $id) + ->getQuery() + ->getOneOrNullResult(); + } + + /** + * Remove all source list ids which not exists, not global or not owned by + * specified user. + * + * @param array $ids List of requested source list ids. + * + * @param integer $user A User entity id. + * + * @return integer[] + */ + public function sanitizeIds(array $ids, $user) + { + $expr = $this->_em->getExpressionBuilder(); + + return array_map(function (array $result) { + return $result['id']; + }, $this->getSourcesListsQB($user) + ->select('SourceList.id') + ->andWhere($expr->in('SourceList', ':ids')) + ->setParameter('ids', $ids) + ->getQuery() + ->getArrayResult()); + } + + /** + * @param integer $user A User entity id. + * + * @return integer[] + */ + public function getAvailableIdsForUser($user) + { + return \nspl\a\map( + \nspl\op\itemGetter('id'), + $this->getSourcesListsQB($user) + ->select('SourceList.id') + ->getQuery() + ->getArrayResult() + ); + } +} diff --git a/src/CacheBundle/Repository/StoredQueryRepository.php b/src/CacheBundle/Repository/StoredQueryRepository.php new file mode 100644 index 0000000..e71a1ac --- /dev/null +++ b/src/CacheBundle/Repository/StoredQueryRepository.php @@ -0,0 +1,80 @@ +_em->getExpressionBuilder(); + + $condition = $expr->andX($expr->eq('Query.hash', ':hash')); + $parameters = [ 'hash' => $hash ]; + + if ($user !== null) { + $condition->add($expr->eq('Query.user', ':user')); + $parameters['user'] = $user; + } + + return $this->createQueryBuilder('Query') + ->select('partial Query.{id, totalCount}') + ->where($condition) + ->setParameters($parameters) + ->getQuery() + ->getOneOrNullResult(); + } + + /** + * List stored queries ready for updating. + * + * @return \Doctrine\ORM\QueryBuilder + */ + public function getForUpdating() + { + $expr = $this->_em->getExpressionBuilder(); + + return $this->createQueryBuilder('Query') + ->distinct('Query.id') + ->join('Query.feeds', 'feeds') + ->where($expr->andX( + $expr->neq('Query.status', ':status'), + $expr->eq('Query.limitExceed', 0) + )) + ->setParameter('status', StoredQueryStatusEnum::INITIALIZE); + } + + /** + * Get Query by feed + * + * @param integer $feed A Feed entity id. + * + * @return StoredQuery + */ + public function getByFeed($feed) + { + return $this->createQueryBuilder('Query') + ->leftJoin('Query.feeds', 'feeds') + ->where('feeds.id = :feedId') + ->setParameter(':feedId', $feed) + ->getQuery() + ->getOneOrNullResult(); + } +} diff --git a/src/CacheBundle/Repository/UserStoredQueryRepository.php b/src/CacheBundle/Repository/UserStoredQueryRepository.php new file mode 100644 index 0000000..92fa455 --- /dev/null +++ b/src/CacheBundle/Repository/UserStoredQueryRepository.php @@ -0,0 +1,13 @@ + + + + + + {{- feed.name -}} + + + + + + + + +
    + {%- for document in data -%} + + {%- endfor -%} +
    + + + + + \ No newline at end of file diff --git a/src/CacheBundle/Security/Inspector/AnalyticInspector.php b/src/CacheBundle/Security/Inspector/AnalyticInspector.php new file mode 100644 index 0000000..3ef7d49 --- /dev/null +++ b/src/CacheBundle/Security/Inspector/AnalyticInspector.php @@ -0,0 +1,88 @@ +addReasonIf( + "Can't create analytics 'cause you don't have permissions for it.", + ! $user->getBillingSubscription()->getPlan()->isAnalytics() + ); + } + + /** + * Check that user can read specified entity. + * + * @param User $user A user who try to create entity. + * @param Analytic|object $entity A Entity instance. + * + * @return void + */ + protected function canRead(User $user, $entity) + { + // todo implement check + } + + /** + * Check that user can update specified entity. + * + * @param User $user A user who try to create entity. + * @param Analytic|object $entity A Entity instance. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function canUpdate(User $user, $entity) + { + // todo implement check + } + + /** + * Check that user can delete specified entity. + * + * @param User $user A user who try to create entity. + * @param Analytic|object $entity A Entity instance. + * + * @return void + */ + protected function canDelete(User $user, $entity) + { + $this + ->addReasonIf( + "Can't delete analytic owned by other user.", + ! $entity->isOwnedBy($user) + ); + } + + +} diff --git a/src/CacheBundle/Security/Inspector/CategoryInspector.php b/src/CacheBundle/Security/Inspector/CategoryInspector.php new file mode 100644 index 0000000..517be8b --- /dev/null +++ b/src/CacheBundle/Security/Inspector/CategoryInspector.php @@ -0,0 +1,101 @@ +addReasonIf( + "Can't create category for other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can read specified entity. + * + * @param User $user A user who try to create entity. + * @param Category|object $entity A Entity instance. + * + * @return void + */ + protected function canRead(User $user, $entity) + { + $this->addReasonIf( + "Can't read category owned by other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can update specified entity. + * + * @param User $user A user who try to create entity. + * @param Category|object $entity A Entity instance. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function canUpdate(User $user, $entity) + { + $this + ->addReasonIf( + "Can't update category owned by other user.", + ! $entity->isOwnedBy($user) + ) + ->addReasonIf( + "Can't update internal category.", + $entity->isInternal() + ); + } + + /** + * Check that user can delete specified entity. + * + * @param User $user A user who try to create entity. + * @param Category|object $entity A Entity instance. + * + * @return void + */ + protected function canDelete(User $user, $entity) + { + $this + ->addReasonIf( + "Can't delete category owned by other user.", + ! $entity->isOwnedBy($user) + ) + ->addReasonIf( + "Can't delete internal category.", + $entity->isInternal() + ); + } +} diff --git a/src/CacheBundle/Security/Inspector/CommentInspector.php b/src/CacheBundle/Security/Inspector/CommentInspector.php new file mode 100644 index 0000000..37fd901 --- /dev/null +++ b/src/CacheBundle/Security/Inspector/CommentInspector.php @@ -0,0 +1,92 @@ +addReasonIf( + "Can't create comment for other user.", + $entity->getAuthor()->getId() !== $user->getId() + ); + } + + /** + * Check that user can read specified entity. + * + * @param User $user A user who try to create entity. + * @param Comment|object $entity A Entity instance. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function canRead(User $user, $entity) + { + // Do nothing. + } + + /** + * Check that user can update specified entity. + * + * @param User $user A user who try to create entity. + * @param Comment|object $entity A Entity instance. + * + * @return void + */ + protected function canUpdate(User $user, $entity) + { + $this + ->addReasonIf( + "Can't update comment owned by other user.", + $entity->getAuthor()->getId() !== $user->getId() + ); + } + + /** + * Check that user can delete specified entity. + * + * @param User $user A user who try to create entity. + * @param Comment|object $entity A Entity instance. + * + * @return void + */ + protected function canDelete(User $user, $entity) + { + $this + ->addReasonIf( + "Can't delete comment owned by other user.", + $entity->getAuthor()->getId() !== $user->getId() + ); + } +} diff --git a/src/CacheBundle/Security/Inspector/FeedInspector.php b/src/CacheBundle/Security/Inspector/FeedInspector.php new file mode 100644 index 0000000..238cd35 --- /dev/null +++ b/src/CacheBundle/Security/Inspector/FeedInspector.php @@ -0,0 +1,95 @@ +addReasonIf( + "Can't create feed for other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can read specified entity. + * + * @param User $user A user who try to create entity. + * @param AbstractFeed|object $entity A Entity instance. + * + * @return void + */ + protected function canRead(User $user, $entity) + { + $this->addReasonIf( + "Can't read feed owned by other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can update specified entity. + * + * @param User $user A user who try to create entity. + * @param AbstractFeed|object $entity A Entity instance. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function canUpdate(User $user, $entity) + { + $this + ->addReasonIf( + "Can't update feed owned by other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can delete specified entity. + * + * @param User $user A user who try to create entity. + * @param AbstractFeed|object $entity A Entity instance. + * + * @return void + */ + protected function canDelete(User $user, $entity) + { + $this + ->addReasonIf( + "Can't delete feed owned by other user.", + ! $entity->isOwnedBy($user) + ); + } +} diff --git a/src/CacheBundle/Security/Inspector/SourceListInspector.php b/src/CacheBundle/Security/Inspector/SourceListInspector.php new file mode 100644 index 0000000..68ce4c7 --- /dev/null +++ b/src/CacheBundle/Security/Inspector/SourceListInspector.php @@ -0,0 +1,124 @@ +addReasonIf( + "Can't share source list owned by other user.", + ! $entity->isOwnedBy($user) + ); + } elseif ($action === self::UNSHARE) { + $this->addReasonIf( + "Can't unshare source list owned by other user.", + ! $entity->isOwnedBy($user) + ); + } + + return $this->reasons; + } + + /** + * Check that user can create specified entity. + * + * @param User $user A user who try to create entity. + * @param SourceList|object $entity A Entity instance. + * + * @return void + */ + protected function canCreate(User $user, $entity) + { + $this->addReasonIf( + "Can't create feed for other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can read specified entity. + * + * @param User $user A user who try to create entity. + * @param SourceList|object $entity A Entity instance. + * + * @return void + */ + protected function canRead(User $user, $entity) + { + $this->addReasonIf( + "Can't read feed owned by other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can update specified entity. + * + * @param User $user A user who try to create entity. + * @param SourceList|object $entity A Entity instance. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function canUpdate(User $user, $entity) + { + $this + ->addReasonIf( + "Can't update feed owned by other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can delete specified entity. + * + * @param User $user A user who try to create entity. + * @param SourceList|object $entity A Entity instance. + * + * @return void + */ + protected function canDelete(User $user, $entity) + { + $this + ->addReasonIf( + "Can't delete feed owned by other user.", + ! $entity->isOwnedBy($user) + ); + } +} diff --git a/src/CacheBundle/Service/Factory/Analytic/AnalyticFactory.php b/src/CacheBundle/Service/Factory/Analytic/AnalyticFactory.php new file mode 100644 index 0000000..3a5a586 --- /dev/null +++ b/src/CacheBundle/Service/Factory/Analytic/AnalyticFactory.php @@ -0,0 +1,181 @@ +analyticRepository = $analyticRepository; + } + + /** + * @param AnalyticDTO $dto A analytic dto instance. + * @param User $user User for which we create analytic. + * + * @return Analytic + * + * @throws \InvalidArgumentException If got invalid dto. + * @throws NotAllowedException If specified user can't create save. + */ + public function createAnalytic(AnalyticDTO $dto, User $user) + { + if (!$user->isAllowedTo(AppPermissionEnum::analytics())) { + throw new NotAllowedException($user, AppPermissionEnum::analytics()); + } + + $context = $this->createContext($dto); + + return new Analytic($user, $context); + } + + /** + * @param AnalyticDTO $dto A data required for creating analytic. + * + * @return AnalyticContext + */ + private function createContext(AnalyticDTO $dto) + { + if (!\app\a\allInstanceOf($dto->feeds, AbstractFeed::class)) { + throw new \InvalidArgumentException(sprintf( + '\'$dto->feeds\' should be an array of \'%s\' instances', + AbstractFeed::class + )); + } + + if (!\app\a\allInstanceOf($dto->filters, FilterInterface::class)) { + throw new \InvalidArgumentException(sprintf( + '\'$dto->filters\' should be an array of \'%s\' instances', + FilterInterface::class + )); + } + + if (!is_array($dto->rawFilters)) { + throw new \InvalidArgumentException('\'$dto->rawFilters\' should be an array'); + } + + $hash = $this->computeHash($dto->feeds, $dto->filters); + + $analytic = $this->analyticRepository->find($hash); + if ($analytic === null) { + $analytic = new AnalyticContext( + $hash, + $dto->feeds, + $dto->filters, + $dto->rawFilters + ); + } + + return $analytic; + } + + /** + * Compute hash for analytic. + * + * @param AbstractFeed[] $feeds Array of used feeds. + * @param FilterInterface[] $filters Array of used filters. + * + * @return string + */ + private function computeHash(array $feeds, array $filters) + { + $collectionIds = $this->getUniqueCollectionIds($feeds); + sort($collectionIds); + + foreach ($filters as $filter) { + $filter->sort(); + } + + return md5(serialize($collectionIds) . serialize($filters)); + } + + /** + * @param AbstractFeed[] $feeds Collection of used feeds. + * + * @return string[] + */ + private function getUniqueCollectionIds(array $feeds) + { + $collectionIds = []; + + foreach ($feeds as $feed) { + $collectionIds[$feed->getCollectionId()] = true; + } + + return array_keys($collectionIds); + } + + + /** + * @param AnalyticDTO $dto + * @param User $user + * @param Analytic $oldAnalytic + * @return Analytic|AnalyticContext|object|null + */ + public function updateAnalytic(AnalyticDTO $dto, User $user, Analytic $oldAnalytic) + { + if (!$user->isAllowedTo(AppPermissionEnum::analytics())) { + throw new NotAllowedException($user, AppPermissionEnum::analytics()); + } + + if (!\app\a\allInstanceOf($dto->feeds, AbstractFeed::class)) { + throw new \InvalidArgumentException(sprintf( + '\'$dto->feeds\' should be an array of \'%s\' instances', + AbstractFeed::class + )); + } + + if (!\app\a\allInstanceOf($dto->filters, FilterInterface::class)) { + throw new \InvalidArgumentException(sprintf( + '\'$dto->filters\' should be an array of \'%s\' instances', + FilterInterface::class + )); + } + + if (!is_array($dto->rawFilters)) { + throw new \InvalidArgumentException('\'$dto->rawFilters\' should be an array'); + } + + $hash = $this->computeHash($dto->feeds, $dto->filters); + $analytic = $this->analyticRepository->find($hash); + if ($analytic === null) { + $analytic = new AnalyticContext( + $hash, + $dto->feeds, + $dto->filters, + $dto->rawFilters + ); + + /** @var $oldAnalytic $analytic */ + $oldAnalytic->setContext($analytic); + } + + return $oldAnalytic; + } +} diff --git a/src/CacheBundle/Service/Factory/Analytic/AnalyticFactoryInterface.php b/src/CacheBundle/Service/Factory/Analytic/AnalyticFactoryInterface.php new file mode 100644 index 0000000..fdc9afc --- /dev/null +++ b/src/CacheBundle/Service/Factory/Analytic/AnalyticFactoryInterface.php @@ -0,0 +1,39 @@ +repository = $repository; + } + + /** + * Checks if the passed value is valid. + * + * @param mixed $value The value that should be validated. + * @param Constraint $constraint The constraint for the validation. + * + * @return void + */ + public function validate($value, Constraint $constraint) + { + if (! $constraint instanceof CategoryParent) { + throw new UnexpectedTypeException($constraint, CategoryParent::CLASS_CONSTRAINT); + } + + // Check current object. + $current = $this->context->getObject(); + if (! $current instanceof Category) { + throw new RuntimeException('This validator works only with categories.'); + } + + // We don't make any checks if current category is new. + if ($current->getId() === null) { + return; + } + + // Check that we try to put category inside it self. + if ($value instanceof Category) { + $currentId = $current->getId(); + $valueId = $value->getId(); + + if ($valueId === $currentId) { + $this->context->addViolation('Try to place category inside itself.'); + } + + // Check that we try to put category inside one of it childes. + if ($this->repository->isChildOf($valueId, $currentId)) { + $this->context->addViolation('Try to place category inside it child.'); + } + } + } +} diff --git a/src/Common/Annotation/AbstractAppAnnotation.php b/src/Common/Annotation/AbstractAppAnnotation.php new file mode 100644 index 0000000..a16ec99 --- /dev/null +++ b/src/Common/Annotation/AbstractAppAnnotation.php @@ -0,0 +1,53 @@ +getDefault(); + if (isset($arguments['value']) && $defaultName) { + // Set default value if we have it. + $this->{$defaultName} = $arguments['value']; + unset($arguments['value']); + } + + foreach ($arguments as $name => $value) { + $this->{$name} = $value; + } + + $this->normalize(); + } + + /** + * Return name of default property. + * + * @return string + */ + public function getDefault() + { + return null; + } + + /** + * Normalize annotation parameters. + * Called after all parameters set in constrictor. + * + * @return void + */ + protected function normalize() + { + // Implements in derived class if it necessary. + } +} diff --git a/src/Common/Annotation/AppAnnotationInterface.php b/src/Common/Annotation/AppAnnotationInterface.php new file mode 100644 index 0000000..c40e9a2 --- /dev/null +++ b/src/Common/Annotation/AppAnnotationInterface.php @@ -0,0 +1,18 @@ + [ + * ['from' => 1, 'to' => 100], + * ['from' => 100, 'to' => 200], + * ... + * ] + * $settings['interval'] - See elastic search docs(Date Histogram Aggregation) + * + * @param array $settings Aggregation settings. + */ + public function __construct(array $settings) + { + $this->fieldName = array_key_exists('field_name', $settings) + ? $settings['field_name'] : ''; + $this->size = array_key_exists('size', $settings) && is_numeric($settings['size']) + ? $settings['size'] : null; + $this->ranges = array_key_exists('ranges', $settings) && is_array($settings['ranges']) + ? $settings['ranges'] : []; + } +} diff --git a/src/IndexBundle/Aggregation/AggregationFacadeInterface.php b/src/IndexBundle/Aggregation/AggregationFacadeInterface.php new file mode 100644 index 0000000..83d039e --- /dev/null +++ b/src/IndexBundle/Aggregation/AggregationFacadeInterface.php @@ -0,0 +1,21 @@ + [ + * ['from' => 1, 'to' => 100], + * ['from' => 100, 'to' => 200], + * ... + * ] + * $settings['interval'] - See elastic search docs(Date Histogram Aggregation) + * + * @param array $settings Aggregation settings. + */ + public function __construct(array $settings) + { + $this->interval = array_key_exists('interval', $settings) && is_string($settings['interval']) + ? $settings['interval'] : ''; + + parent::__construct($settings); + } + + /** + * Resolve given aggregation into proper index format. + * + * @param AggregationTypeResolverInterface $resolver A AggregationTypeResolverInterface + * instance. + * + * @return mixed + */ + public function resolve(AggregationTypeResolverInterface $resolver) + { + return $resolver->dateHistogram($this->fieldName, $this->interval); + } +} diff --git a/src/IndexBundle/Aggregation/Aggregations/RangeAggregation.php b/src/IndexBundle/Aggregation/Aggregations/RangeAggregation.php new file mode 100644 index 0000000..e647132 --- /dev/null +++ b/src/IndexBundle/Aggregation/Aggregations/RangeAggregation.php @@ -0,0 +1,27 @@ +range($this->fieldName, $this->ranges); + } +} diff --git a/src/IndexBundle/Aggregation/Aggregations/TermsAggregation.php b/src/IndexBundle/Aggregation/Aggregations/TermsAggregation.php new file mode 100644 index 0000000..6cb6af5 --- /dev/null +++ b/src/IndexBundle/Aggregation/Aggregations/TermsAggregation.php @@ -0,0 +1,39 @@ +size = $size; + + return $this; + } + + /** + * Resolve given aggregation into proper index format. + * + * @param AggregationTypeResolverInterface $resolver A AggregationTypeResolverInterface + * instance. + * + * @return mixed + */ + public function resolve(AggregationTypeResolverInterface $resolver) + { + return $resolver->terms($this->fieldName, $this->size); + } +} diff --git a/src/IndexBundle/Aggregation/Aggregations/TopHitsAggregation.php b/src/IndexBundle/Aggregation/Aggregations/TopHitsAggregation.php new file mode 100644 index 0000000..664f084 --- /dev/null +++ b/src/IndexBundle/Aggregation/Aggregations/TopHitsAggregation.php @@ -0,0 +1,55 @@ + [ + * ['from' => 1, 'to' => 100], + * ['from' => 100, 'to' => 200], + * ... + * ] + * $settings['interval'] - See elastic search docs(Date Histogram Aggregation) + * + * @param array $settings Aggregation settings. + */ + public function __construct(array $settings) + { + $this->sources = array_key_exists('sources', $settings) && is_array($settings['sources']) + ? $settings['sources'] : []; + + parent::__construct($settings); + } + + /** + * Resolve given aggregation into proper index format. + * + * @param AggregationTypeResolverInterface $resolver A AggregationTypeResolverInterface + * instance. + * + * @return mixed + */ + public function resolve(AggregationTypeResolverInterface $resolver) + { + return $resolver->topHits($this->size, $this->sources); + } +} diff --git a/src/IndexBundle/Aggregation/ElasticsearchAggregation.php b/src/IndexBundle/Aggregation/ElasticsearchAggregation.php new file mode 100644 index 0000000..0534e75 --- /dev/null +++ b/src/IndexBundle/Aggregation/ElasticsearchAggregation.php @@ -0,0 +1,114 @@ +name = $name; + $this->type = $type; + } + + /** + * Overwrite existence aggregations + * + * @param AggregationInterface|AggregationInterface[] $aggregations An AggregationInterface instance + * or array of + * AggregationInterface instances. + * + * @return AggregationInterface + */ + public function setAggregations($aggregations) + { + if ($aggregations instanceof AggregationInterface) { + $aggregations = [ $aggregations ]; + } + $this->aggregations = $aggregations; + + return $this; + } + + /** + * Add new aggregation + * + * @param AggregationInterface $aggregation A AggregationInterface instance. + * + * @return AggregationInterface + */ + public function addAggregation(AggregationInterface $aggregation) + { + $this->aggregations[] = $aggregation; + + return $this; + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @return AggregationInterface[] + */ + public function getAggregations() + { + return $this->aggregations; + } + + /** + * Resolve aggregation to need format to query. + * + * @param AggregationTypeResolverInterface $resolver A AggregationTypeResolverInterface + * instance. + * + * @return mixed + */ + public function build(AggregationTypeResolverInterface $resolver) + { + $aggregation = []; + $aggregation[$this->name] = $this->type->resolve($resolver); + + $subAggr = []; + foreach ($this->aggregations as $value) { + if ($value instanceof AggregationInterface) { + $subAggr = array_merge($subAggr, $value->build($resolver)); + } + } + + if (count($subAggr) > 0) { + $aggregation[$this->name]['aggs'] = $subAggr; + } + + return $aggregation; + } +} diff --git a/src/IndexBundle/Aggregation/ElasticsearchFacade.php b/src/IndexBundle/Aggregation/ElasticsearchFacade.php new file mode 100644 index 0000000..c2bc7e0 --- /dev/null +++ b/src/IndexBundle/Aggregation/ElasticsearchFacade.php @@ -0,0 +1,24 @@ +strategy = $strategy; + } + + /** + * Generate structure for need type of the aggregation + * + * @param string $fieldName Aggregated field name. + * @param integer $size Aggregation size. + * + * @return array + */ + public function terms($fieldName, $size) + { + $params = [ 'field' => $this->strategy->denormalizeFieldName($fieldName, true) ]; + + if ($size !== null) { + $params['size'] = $size; + } + return [ 'terms' => $params ]; + } + + /** + * Generate structure for need type of the aggregation + * + * @param integer $size Aggregation size. + * @param string[] $sources Requested sources. + * + * @return array + */ + public function topHits($size, array $sources) + { + $config = [ + 'size' => $size, + ]; + + if (count($sources) > 0) { + $config['_source'] = \nspl\a\map(function ($source) { + return $this->strategy->denormalizeFieldName($source); + }, $sources); + } + + return [ 'top_hits' => $config ]; + } + + /** + * Generate structure for range type of the aggregation + * + * @param string $fieldName Aggregated field name. + * @param array[] $ranges Aggregation ranges. + * + * @return array + */ + public function range($fieldName, array $ranges) + { + return [ + 'range' => [ + 'field' => $this->strategy->denormalizeFieldName($fieldName, true), + 'ranges' => $ranges, + ], + ]; + } + + /** + * Generate structure for date_histogram type of the aggregation + * + * @param string $fieldName Aggregated field name. + * @param string $interval Histogram interval. + * + * @return array + */ + public function dateHistogram($fieldName, $interval) + { + return [ + 'date_histogram' => [ + 'field' => $this->strategy->denormalizeFieldName($fieldName, true), + 'interval' => $interval, + ], + ]; + } +} diff --git a/src/IndexBundle/DependencyInjection/IndexExtension.php b/src/IndexBundle/DependencyInjection/IndexExtension.php new file mode 100644 index 0000000..ab47dc5 --- /dev/null +++ b/src/IndexBundle/DependencyInjection/IndexExtension.php @@ -0,0 +1,43 @@ +getParameter('kernel.environment'); + + $loader->load('indices.yml'); + $loader->load('indices_'. $environment . '.yml'); + } +} diff --git a/src/IndexBundle/Filter/AbstractFilter.php b/src/IndexBundle/Filter/AbstractFilter.php new file mode 100644 index 0000000..097fdba --- /dev/null +++ b/src/IndexBundle/Filter/AbstractFilter.php @@ -0,0 +1,27 @@ + 0, + Filters\GteFilter::class => 0, + Filters\GtFilter::class => 0, + Filters\LteFilter::class => 0, + Filters\LtFilter::class => 0, + Filters\InFilter::class => 1, + Filters\NotFilter::class => 2, + Filters\AndFilter::class => 3, + Filters\OrFilter::class => 4, + ]; + + /** + * @var FilterInterface[] + */ + protected $filters = []; + + /** + * @param FilterInterface|FilterInterface[] $filters FilterInterface + * instance or array of + * instances. + */ + public function __construct($filters = []) + { + if (! is_array($filters)) { + $filters = [ $filters ]; + } + + $filters = array_filter($filters); + + if (! \nspl\a\all($filters, \nspl\f\rpartial(\app\op\isInstanceOf, FilterInterface::class))) { + throw new \InvalidArgumentException('\'$filters\' should be array of FilterInterface instances or single instance'); + } + + $this->filters = $filters; + } + + /** + * Add new filter to group. + * + * @param FilterInterface $filter A FilterInterface instance. + * + * @return GroupFilterInterface + */ + public function add(FilterInterface $filter) + { + $this->filters[] = $filter; + + return $this; + } + + /** + * Get all internal filters. + * + * @return FilterInterface[] + */ + public function getFilters() + { + return $this->filters; + } + + /** + * Set internal filters. + * + * @param FilterInterface[]|array $filters Array of FilterInterface instances. + * + * @return static + */ + public function setFilters(array $filters) + { + $this->filters = $filters; + + return $this; + } + + /** + * Count elements of an object + * + * @return integer + */ + public function count() + { + return count($this->filters); + } + + /** + * @return string + */ + public function serialize() + { + return serialize($this->filters); + } + + /** + * @param string $serialized The string representation of the object. + * + * @return void + */ + public function unserialize($serialized) + { + $filters = unserialize($serialized); + + if (! \nspl\a\all($filters, \nspl\f\rpartial(\app\op\isInstanceOf, FilterInterface::class))) { + throw new \UnexpectedValueException(sprintf( + '%s expects that unserialized data will contains array of %s instances', + static::class, + FilterInterface::class + )); + } + + $this->filters = $filters; + } + + /** + * Sort filter values or internal filters. + * + * @return void + */ + public function sort() + { + \nspl\a\map(\nspl\op\methodCaller('sort'), $this->filters); + + usort($this->filters, [ $this, 'compareFilters' ]); + } + + /** + * @param FilterInterface $lfilter Left compared filter. + * @param FilterInterface $rfilter Right compared filter. + * + * @return integer + */ + private function compareFilters(FilterInterface $lfilter, FilterInterface $rfilter) + { + $lpriority = self::$priorityTable[get_class($lfilter)]; + $rpriority = self::$priorityTable[get_class($rfilter)]; + + if ($lpriority === $rpriority) { + return $this->compareEqualFilters($lfilter, $rfilter); + } + + return $lpriority < $rpriority ? -1 : 1; + } + + /** + * @param FilterInterface $lfilter Left compared filter. + * @param FilterInterface $rfilter Right compared filter. + * + * @return integer + */ + private function compareEqualFilters(FilterInterface $lfilter, FilterInterface $rfilter) + { + switch (true) { + case ($lfilter instanceof AbstractValueFilter) && ($rfilter instanceof AbstractValueFilter): + return $this->compareValueFilters($lfilter, $rfilter); + + case ($lfilter instanceof InFilter) && ($rfilter instanceof InFilter): + return $this->compareInFilters($lfilter, $rfilter); + + case ($lfilter instanceof NotFilter) && ($rfilter instanceof NotFilter): + return $this->compareFilters($lfilter->getFilter(), $rfilter->getFilter()); + + case (($lfilter instanceof AndFilter) && ($rfilter instanceof AndFilter)): + case ($lfilter instanceof OrFilter) && ($rfilter instanceof OrFilter): + return $this->compareGroupFilters($lfilter, $rfilter); + } + + throw new \LogicException('Unhandled filters comparing situations.'); + } + + /** + * @param AbstractValueFilter $lfilter Left compared filter. + * @param AbstractValueFilter $rfilter Right compared filter. + * + * @return integer + */ + private function compareValueFilters( + AbstractValueFilter $lfilter, + AbstractValueFilter $rfilter + ) { + $cmpRes = strcmp( + strtolower($lfilter->getFieldName()), + strtolower($rfilter->getFieldName()) + ); + + if ($cmpRes === 0) { + $lvalue = $lfilter->getValue(); + $rvalue = $rfilter->getValue(); + + if ($lvalue !== $rvalue) { + $cmpRes = $lvalue < $rvalue ? -1 : 1; + } + } + + return $cmpRes; + } + + /** + * @param InFilter $lfilter Left compared filter. + * @param InFilter $rfilter Right compared filter. + * + * @return integer + */ + private function compareInFilters(InFilter $lfilter, InFilter $rfilter) + { + $cmpRes = strcmp( + strtolower($lfilter->getFieldName()), + strtolower($rfilter->getFieldName()) + ); + + if ($cmpRes === 0) { + $lvalue = $lfilter->getValue(); + $rvalue = $rfilter->getValue(); + + if ($lvalue !== $rvalue) { + $cmpRes = $lvalue < $rvalue ? -1 : 1; + } + } + + return $cmpRes; + } + + /** + * @param AbstractGroupFilter $lfilter Left compared filter. + * @param AbstractGroupFilter $rfilter Right compared filter. + * + * @return integer + */ + private function compareGroupFilters( + AbstractGroupFilter $lfilter, + AbstractGroupFilter $rfilter + ) { + $lfilters = $lfilter->getFilters(); + $rfilters = $rfilter->getFilters(); + $lfiltersCount = count($lfilters); + $rfiltersCount = count($rfilters); + + if ($lfiltersCount === $rfiltersCount) { + for ($i = 0; $i < $lfiltersCount; ++$i) { + $res = $this->compareFilters($lfilters[$i], $rfilters[$i]); + + if ($res !== 0) { + return $res; + } + } + + return 0; + } + + return $lfiltersCount < $rfiltersCount ? -1 : 1; + } +} diff --git a/src/IndexBundle/Filter/AbstractValueFilter.php b/src/IndexBundle/Filter/AbstractValueFilter.php new file mode 100644 index 0000000..17a53a6 --- /dev/null +++ b/src/IndexBundle/Filter/AbstractValueFilter.php @@ -0,0 +1,101 @@ +field = trim($field); + $this->value = $value instanceof AbstractEnum ? $value->getValue() : $value; + } + + /** + * Get filtered field name. + * + * @return string + */ + public function getFieldName() + { + return $this->field; + } + + /** + * Get filter value. + * + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + /** + * @return string + */ + public function serialize() + { + return serialize([ $this->field, $this->value ]); + } + + /** + * @param string $serialized The string representation of the object. + * + * @return void + */ + public function unserialize($serialized) + { + $data = unserialize($serialized); + if (! is_array($data) || (count($data) !== 2)) { + throw new \UnexpectedValueException(sprintf( + '%s got invalid unserialized data. Should be an array with two values', + static::class + )); + } + + $this->field = $data[0]; + $this->value = $data[1]; + } + + /** + * Sort filter values or internal filters. + * + * @return void + */ + public function sort() + { + // do nothing. + } +} diff --git a/src/IndexBundle/Filter/Factory/FilterFactory.php b/src/IndexBundle/Filter/Factory/FilterFactory.php new file mode 100644 index 0000000..687eb5c --- /dev/null +++ b/src/IndexBundle/Filter/Factory/FilterFactory.php @@ -0,0 +1,150 @@ +andX($this->filters); + } +} diff --git a/src/IndexBundle/Filter/Filters/EqFilter.php b/src/IndexBundle/Filter/Filters/EqFilter.php new file mode 100644 index 0000000..00afc36 --- /dev/null +++ b/src/IndexBundle/Filter/Filters/EqFilter.php @@ -0,0 +1,27 @@ +eq($this->field, $this->value); + } +} diff --git a/src/IndexBundle/Filter/Filters/GtFilter.php b/src/IndexBundle/Filter/Filters/GtFilter.php new file mode 100644 index 0000000..97ac8a5 --- /dev/null +++ b/src/IndexBundle/Filter/Filters/GtFilter.php @@ -0,0 +1,27 @@ +gt($this->field, $this->value); + } +} diff --git a/src/IndexBundle/Filter/Filters/GteFilter.php b/src/IndexBundle/Filter/Filters/GteFilter.php new file mode 100644 index 0000000..1926f87 --- /dev/null +++ b/src/IndexBundle/Filter/Filters/GteFilter.php @@ -0,0 +1,27 @@ +gte($this->field, $this->value); + } +} diff --git a/src/IndexBundle/Filter/Filters/InFilter.php b/src/IndexBundle/Filter/Filters/InFilter.php new file mode 100644 index 0000000..f5797ad --- /dev/null +++ b/src/IndexBundle/Filter/Filters/InFilter.php @@ -0,0 +1,125 @@ +field = trim($field); + + if (! \nspl\a\all($values, function ($value) { + return is_scalar($value) || ($value instanceof AbstractEnum) || ($value instanceof \DateTimeInterface); + })) { + throw new \InvalidArgumentException('\'$values\' should be an array of scalar values, AbstractEnum or \DateTimeInterface instances'); + } + + $this->values = \nspl\a\map(function ($value) { + if ($value instanceof AbstractEnum) { + $value = $value->getValue(); + } + + return $value; + }, $values); + } + + /** + * Resolve given filter into proper index format. + * + * @param FilterResolverInterface $resolver A resolver instance used for resolving + * this filter. + * + * @return mixed + */ + public function resolve(FilterResolverInterface $resolver) + { + return $resolver->in($this->field, $this->values); + } + + /** + * Get filtered field name. + * + * @return string + */ + public function getFieldName() + { + return $this->field; + } + + /** + * Get filter value. + * + * @return mixed + */ + public function getValue() + { + return $this->values; + } + + /** + * @return string + */ + public function serialize() + { + return serialize([ + $this->field, + $this->values, + ]); + } + + /** + * @param string $serialized The string representation of the object. + * + * @return void + */ + public function unserialize($serialized) + { + $data = unserialize($serialized); + + if (! is_array($data) || (count($data) !== 2)) { + throw new \UnexpectedValueException(sprintf( + '%s expects that unserialzied data will be an array with two items', + static::class + )); + } + + $this->field = $data[0]; + $this->values = $data[1]; + } + + /** + * @return void + */ + public function sort() + { + sort($this->values); + } +} diff --git a/src/IndexBundle/Filter/Filters/LtFilter.php b/src/IndexBundle/Filter/Filters/LtFilter.php new file mode 100644 index 0000000..db77805 --- /dev/null +++ b/src/IndexBundle/Filter/Filters/LtFilter.php @@ -0,0 +1,27 @@ +lt($this->field, $this->value); + } +} diff --git a/src/IndexBundle/Filter/Filters/LteFilter.php b/src/IndexBundle/Filter/Filters/LteFilter.php new file mode 100644 index 0000000..f57bd41 --- /dev/null +++ b/src/IndexBundle/Filter/Filters/LteFilter.php @@ -0,0 +1,27 @@ +lte($this->field, $this->value); + } +} diff --git a/src/IndexBundle/Filter/Filters/NotFilter.php b/src/IndexBundle/Filter/Filters/NotFilter.php new file mode 100644 index 0000000..23e0c6f --- /dev/null +++ b/src/IndexBundle/Filter/Filters/NotFilter.php @@ -0,0 +1,108 @@ +filter = $filter; + } + + /** + * Resolve given filter into proper index format. + * + * @param FilterResolverInterface $resolver A resolver instance used for resolving + * this filter. + * + * @return mixed + */ + public function resolve(FilterResolverInterface $resolver) + { + return $resolver->not($this->filter); + } + + /** + * Get internal filter. + * + * @return FilterInterface + */ + public function getFilter() + { + return $this->filter; + } + + /** + * Set internal filter. + * + * @param FilterInterface $filter A FilterInterface instance. + * + * @return $this + */ + public function setFilter(FilterInterface $filter) + { + $this->filter = $filter; + + return $this; + } + + /** + * String representation of object. + * + * @return string the string representation of the object or null. + */ + public function serialize() + { + return serialize($this->filter); + } + + /** + * Constructs the object. + * + * @param string $serialized The string representation of the object. + * + * @return void + * @since 5.1.0 + */ + public function unserialize($serialized) + { + $filter = unserialize($serialized); + + if (! $filter instanceof FilterInterface) { + throw new \UnexpectedValueException(sprintf( + '%s expects that unserialized data will be instance of %s', + static::class, + FilterInterface::class + )); + } + + $this->filter = $filter; + } + + /** + * Sort filter values or internal filters. + * + * @return void + */ + public function sort() + { + $this->filter->sort(); + } +} diff --git a/src/IndexBundle/Filter/Filters/OrFilter.php b/src/IndexBundle/Filter/Filters/OrFilter.php new file mode 100644 index 0000000..0c0c060 --- /dev/null +++ b/src/IndexBundle/Filter/Filters/OrFilter.php @@ -0,0 +1,27 @@ +orX($this->filters); + } +} diff --git a/src/IndexBundle/Filter/Filters/RegexpFilter.php b/src/IndexBundle/Filter/Filters/RegexpFilter.php new file mode 100644 index 0000000..a991187 --- /dev/null +++ b/src/IndexBundle/Filter/Filters/RegexpFilter.php @@ -0,0 +1,27 @@ +regex($this->field, $this->value); + } +} diff --git a/src/IndexBundle/Filter/GroupFilterInterface.php b/src/IndexBundle/Filter/GroupFilterInterface.php new file mode 100644 index 0000000..57a0778 --- /dev/null +++ b/src/IndexBundle/Filter/GroupFilterInterface.php @@ -0,0 +1,36 @@ +indexStrategy = $indexStrategy; + } + + /** + * Generate 'greater or equal to' filter for search request. + * + * @param string $field Filtered filed name. + * @param string $value Filter value. + * + * @return mixed + */ + public function gte($field, $value) + { + $field = $this->indexStrategy->denormalizeFieldName($field); + $value = $this->denormalizeValue($value); + + return $field .':['. $value .' TO *]'; + } + + /** + * Generate 'greater then' filter for search request. + * + * @param string $field Filtered filed name. + * @param string $value Filter value. + * + * @return mixed + */ + public function gt($field, $value) + { + $field = $this->indexStrategy->denormalizeFieldName($field); + $value = $this->denormalizeValue($value); + + return $field .':{'. $value .' TO *]'; + } + + /** + * Generate 'equal to' filter for search request. + * + * @param string $field Filtered filed name. + * @param string $value Filter value. + * + * @return mixed + */ + public function eq($field, $value) + { + $field = $this->indexStrategy->denormalizeFieldName($field); + $value = $this->denormalizeValue($value); + + return $field .':'. $value; + } + + /** + * Generate 'less then' filter for search request. + * + * @param string $field Filtered filed name. + * @param string $value Filter value. + * + * @return mixed + */ + public function lt($field, $value) + { + $field = $this->indexStrategy->denormalizeFieldName($field); + $value = $this->denormalizeValue($value); + + return $field .':[* TO '. $value .'}'; + } + + /** + * Generate 'less or equal to' filter for search request. + * + * @param string $field Filtered filed name. + * @param string $value Filter value. + * + * @return mixed + */ + public function lte($field, $value) + { + $field = $this->indexStrategy->denormalizeFieldName($field); + $value = $this->denormalizeValue($value); + + return $field .':[* TO '. $value .']'; + } + + /** + * Generate 'in' filter for search request. + * + * @param string $field Filtered filed name. + * @param array $values Filter values. + * + * @return mixed + */ + public function in($field, array $values) + { + $field = $this->indexStrategy->denormalizeFieldName($field); + return $field .':('. implode(' OR ', $this->denormalizeValue($values)) .')'; + } + + /** + * Generate regexp filter for search request. + * + * @param string $field Filtered field name. + * @param string $pattern Regexp pattern. + * + * @return mixed + */ + public function regex($field, $pattern) + { + $field = $this->indexStrategy->denormalizeFieldName($field); + + return $field .':/'. $pattern .'/'; + } + + /** + * Generate 'not' filter for search request. + * + * @param FilterInterface $filter A FilterInterface instance. + * + * @return mixed + */ + public function not(FilterInterface $filter) + { + return 'NOT ('. $filter->resolve($this) .')'; + } + + /** + * Generate 'and' filter for search request. + * + * @param FilterInterface|FilterInterface[] $filters A FilterInterface + * instance or array of + * instances. + * + * @return mixed + */ + public function andX($filters) + { + return implode(' AND ', array_map(function (FilterInterface $filter) { + return '('. $filter->resolve($this) .')'; + }, $filters)); + } + + /** + * Generate 'or' filter for search request. + * + * @param FilterInterface|FilterInterface[] $filters A FilterInterface + * instance or array of + * instances. + * + * @return mixed + */ + public function orX($filters) + { + return implode(' OR ', array_map(function (FilterInterface $filter) { + return '('. $filter->resolve($this) .')'; + }, $filters)); + } + + /** + * Denormalize filter value. + * + * @param mixed $value Filter value. + * + * @return mixed + */ + private function denormalizeValue($value) + { + switch (true) { + case PublisherTypeEnum::isValid($value): + // + // Denormalize publisher types. + // + $value = implode(' OR ', $this->indexStrategy->denormalizePublisherType($value)); + break; + + // todo add proper code for denormalization of this values + // case LanguageEnum::isValid($value): + // case CountryEnum::isValid($value): + // case StateEnum::isValid($value): + + // + // We should quote all text only if it's not regexp and not already + // wrapped by quote's or bracket's. + // + case is_string($value) + && ! in_array($value[0], [ '/', '"', '(' ], true) + && ! in_array($value[strlen($value) - 1], [ '/', '"', ')' ], true): + $value = '"'. $value .'"'; + break; + + // + // Denormalize date. + // + case $value instanceof \DateTimeInterface: + $value = $value->format('c'); + break; + + // + // Denormalize all values in array. + // + case is_array($value): + $value = array_map([ $this, 'denormalizeValue' ], $value); + break; + } + + return $value; + } +} diff --git a/src/IndexBundle/Filter/Resolver/FilterResolverInterface.php b/src/IndexBundle/Filter/Resolver/FilterResolverInterface.php new file mode 100644 index 0000000..dd9188f --- /dev/null +++ b/src/IndexBundle/Filter/Resolver/FilterResolverInterface.php @@ -0,0 +1,118 @@ +logger = $logger; + + return $this; + } + + /** + * Log specified message by using logger callback. + * + * @param string $message Some message. + * + * @return void + */ + protected function log($message) + { + $logger = $this->logger; + if (($logger instanceof \Closure) || is_callable($logger)) { + $logger($message); + } + } +} diff --git a/src/IndexBundle/Fixture/Executor/Factory/IndexFixtureExecutorFactory.php b/src/IndexBundle/Fixture/Executor/Factory/IndexFixtureExecutorFactory.php new file mode 100644 index 0000000..3a677e9 --- /dev/null +++ b/src/IndexBundle/Fixture/Executor/Factory/IndexFixtureExecutorFactory.php @@ -0,0 +1,63 @@ +getIndex() === IndexFixtureInterface::INDEX_INTERNAL; + } + ); + } + + /** + * @param InternalIndexInterface $index A InternalIndexInterface instance. + * + * @return IndexFixtureExecutorInterface + */ + public function external(InternalIndexInterface $index) + { + return new FilteredIndexFixtureExecutor( + new IndexFixtureExecutor($index), + function (IndexFixtureInterface $fixture) { + return $fixture->getIndex() === IndexFixtureInterface::INDEX_EXTERNAL; + } + ); + } + + /** + * @param SourceIndexInterface $index A SourceIndexInterface instance. + * + * @return IndexFixtureExecutorInterface + */ + public function source(SourceIndexInterface $index) + { + return new FilteredIndexFixtureExecutor( + new IndexFixtureExecutor($index), + function (IndexFixtureInterface $fixture) { + return $fixture->getIndex() === IndexFixtureInterface::INDEX_SOURCE; + } + ); + } +} diff --git a/src/IndexBundle/Fixture/Executor/Factory/IndexFixtureExecutorFactoryInterface.php b/src/IndexBundle/Fixture/Executor/Factory/IndexFixtureExecutorFactoryInterface.php new file mode 100644 index 0000000..5536fdd --- /dev/null +++ b/src/IndexBundle/Fixture/Executor/Factory/IndexFixtureExecutorFactoryInterface.php @@ -0,0 +1,36 @@ +executor = $executor; + $this->filter = $filter; + } + + /** + * Execute specified fixtures. + * + * @param array $fixtures Array of IndexFixtureInterface instances. + * + * @return void + */ + public function execute(array $fixtures) + { + $fixtures = array_filter($fixtures, $this->filter); + $this->executor->setLogger($this->logger)->execute($fixtures); + } +} diff --git a/src/IndexBundle/Fixture/Executor/IndexFixtureExecutor.php b/src/IndexBundle/Fixture/Executor/IndexFixtureExecutor.php new file mode 100644 index 0000000..4100f7d --- /dev/null +++ b/src/IndexBundle/Fixture/Executor/IndexFixtureExecutor.php @@ -0,0 +1,47 @@ +index = $index; + } + + /** + * Execute specified fixtures. + * + * @param array $fixtures Array of IndexFixtureInterface instances. + * + * @return void + */ + public function execute(array $fixtures) + { + /** @var IndexFixtureInterface $fixture */ + foreach ($fixtures as $fixture) { + $this->log('loading ' . get_class($fixture) . " [{$fixture->getIndex()}]"); + $fixture->load($this->index); + } + } +} diff --git a/src/IndexBundle/Fixture/Executor/IndexFixtureExecutorInterface.php b/src/IndexBundle/Fixture/Executor/IndexFixtureExecutorInterface.php new file mode 100644 index 0000000..f8e9a4b --- /dev/null +++ b/src/IndexBundle/Fixture/Executor/IndexFixtureExecutorInterface.php @@ -0,0 +1,30 @@ +container = $container; + } + + /** + * Get all loaded fixtures. + * + * @return \IndexBundle\Fixture\IndexFixtureInterface[] + */ + public function getFixtures() + { + return $this->fixtures; + } + + /** + * Load single fixture. + * + * @param string $path Path to fixture file. + * + * @return void + */ + public function load($path) + { + if (! is_readable($path)) { + throw new \InvalidArgumentException("Can't read file {$path}."); + } + + $this->loadFromIterator(new \ArrayIterator([ new \SplFileInfo($path) ])); + } + + /** + * Load fixtures from directory. + * + * @param string $path Path to directory. + * + * @return void + */ + public function loadFromDirectory($path) + { + if (! is_dir($path)) { + throw new \InvalidArgumentException(sprintf("$path does not exist")); + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + $this->loadFromIterator($iterator); + } + + /** + * Load fixtures from iterator. + * + * @param \Iterator $iterator A Iterator instance. Must iterate through + * SplFileInfo. + * + * @return void + */ + private function loadFromIterator(\Iterator $iterator) + { + $files = []; + + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if ((! $file->isDir()) && ($file->getExtension() === 'php')) { + $filePath = realpath($file->getPathname()); + require_once $filePath; + $files[] = $filePath; + } + } + + $declared = get_declared_classes(); + + foreach ($declared as $className) { + $reflection = new \ReflectionClass($className); + $sourceFile = $reflection->getFileName(); + + if (in_array($sourceFile, $files, true) && $this->checkFixture($reflection)) { + /** @var IndexFixtureInterface $fixture */ + $fixture = new $className(); + if ($fixture instanceof ContainerAwareInterface) { + $fixture->setContainer($this->container); + } + + $this->fixtures[] = $fixture; + } + } + } + + /** + * @param \ReflectionClass $reflection A ReflectionClass instance. + * + * @return boolean True if class represented by specified reflection is + * valid fixture. + */ + private function checkFixture(\ReflectionClass $reflection) + { + if ($reflection->isAbstract()) { + return false; + } + + return in_array( + IndexFixtureInterface::class, + $reflection->getInterfaceNames(), + true + ); + } +} diff --git a/src/IndexBundle/Fixture/Loader/IndexFixtureLoaderInterface.php b/src/IndexBundle/Fixture/Loader/IndexFixtureLoaderInterface.php new file mode 100644 index 0000000..80aea4f --- /dev/null +++ b/src/IndexBundle/Fixture/Loader/IndexFixtureLoaderInterface.php @@ -0,0 +1,38 @@ +client = ClientBuilder::create() + ->setHosts([ + [ + 'host' => $host, + 'port' => $port, + ], + ]) + ->build(); + $this->index = $index; + $this->type = $type; + } + + /** + * Search information in index. + * + * @param SearchRequestInterface $request Internal representation of search + * request. + * + * @return SearchResponseInterface + */ + public function search(SearchRequestInterface $request) + { + $limit = $this->normalizeLimit($request->getLimit()); + $page = $this->normalizePage($request->getPage(), $limit); + + $parameters = $this->buildSearchParameters($request); + + // + // Limit query and set offset if it necessary. + // + if ($limit !== null) { + $parameters['size'] = $limit; + if ($page) { + $parameters['from'] = ($page - 1) * $limit; + } + } + + try { + return $this->normalize($this->client->search($this->beforeSearch($parameters))); + } catch (\Exception $exception) { + throw new \RuntimeException(sprintf( + 'Can\'t exec search request \'%s\'. %s', + json_encode($parameters), + $exception->getMessage() + )); + } + } + + /** + * Fetch all relevant documents. + * + * @param SearchRequestInterface $request Internal representation of search + * request. + * + * @return \Traversable + */ + public function fetchAll(SearchRequestInterface $request) + { + // + // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-scroll.html + // + + $parameters = $this->buildSearchParameters($request); + $parameters['scroll'] = '10m'; + $parameters['size'] = $this->normalizeLimit($request->getLimit()); + + $response = $this->client->search($this->beforeSearch($parameters)); + $scrollId = $response['_scroll_id']; + + while (count($response['hits']['hits']) > 0) { + $response = $this->normalize($response); + gc_collect_cycles(); + yield $response; + + $response = $this->client->scroll([ + 'scroll_id' => $scrollId, + 'scroll' => '10m', + ]); + $scrollId = $response['_scroll_id']; + } + } + + /** + * Fetch all relevant documents. + * + * @param SearchRequestInterface $request Internal representation of search + * request. + * + * @return integer + */ + public function getTotal(SearchRequestInterface $request) + { + $parameters = $this->buildSearchParameters($request); + $response = $this->client->count($this->beforeSearch($parameters)); + return $response['count']; + } + + /** + * Get documents by it ids. + * + * @param integer|integer[] $ids Array of document ids or single id. + * @param string|string[] $fields Array of requested fields of single + * field. + * + * @return DocumentInterface[] + */ + public function get($ids, $fields = []) + { + $ids = (array) $ids; + $fields = (array) $fields; + + $parameters = [ + 'body' => [ 'query' => [ 'ids' => [ 'values' => $ids ] ] ], + 'index' => $this->index, + 'type' => $this->type, + 'size' => count($ids), + ]; + + if (count($fields) > 0) { + $parameters['_source'] = $fields; + } + + return $this + ->normalize($this->client->search($this->beforeSearch($parameters))) + ->getDocuments(); + } + + /** + * Check that specified documents is exists. + * + * @param integer|array $ids Array of document ids or single id. + * + * @return array Contains all ids which not found in index. + */ + public function has($ids) + { + // + // We should insure that all ids has string type for comparision. + // + $ids = \nspl\a\map(\nspl\op\str, (array) $ids); + + $response = $this->client->search([ + 'body' => [ 'query' => [ 'ids' => [ 'values' => $ids ] ] ], + 'index' => $this->index, + 'type' => $this->type, + '_source' => false, + ]); + + // Get founded ids. + $founded = \nspl\a\map(\nspl\op\itemGetter('_id'), $response['hits']['hits']); + + return array_diff($ids, $founded); + } + + /** + * Get aggregation factory instance + * + * @return AggregationFactory + * + * todo make something with that + */ + public function getAggregationFactory() + { + return new AggregationFactory(); + } + + /** + * Get aggregation instance + * + * @return AggregationFacadeInterface + * + * todo make something with that + */ + public function getAggregation() + { + return new ElasticsearchFacade(); + } + + /** + * Create concrete filter resolver instance. + * + * @return FilterResolverInterface + */ + protected function createFilterResolver() + { + return new ElasticSearchFilterResolver($this->getStrategy()); + } + + /** + * Build elastic search query. + * + * @param SearchRequestInterface $request A internal representation of + * search query. + * + * @return array + * + * @link https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html + */ + protected function buildQuery(SearchRequestInterface $request) + { + $query = trim($request->getQuery()); + $buildedQuery = ''; + + if ($query !== '') { + // + // Denormalize field names. + // + $fields = \nspl\a\map(function ($fieldName) { + return $this->getStrategy()->denormalizeFieldName($fieldName); + }, $request->getFields()); + + if (is_array($fields) && (count($fields) > 0)) { + // + // Build search query from provided keywords. + // + $buildedQuery = implode(' OR ', array_map(function ($fieldName) use ($query) { + return "{$fieldName}:({$query})"; + }, $fields)); + } + } + + // + // Build filters + // + $filters = $request->getFilters(); + + if (count($filters) > 0) { + $resolver = function (FilterInterface $filter) { + $resolved = $filter->resolve($this->getFilterResolver()); + + if ($resolved !== '') { + return "({$resolved})"; + } + + return ''; + }; + + $filters = implode(' AND ', array_map($resolver, $filters)); + if ($buildedQuery) { + $buildedQuery = "({$buildedQuery})"; + } + if ($filters) { + $buildedQuery .= $buildedQuery !== '' ? " AND ({$filters})" : "{$filters}"; + } + } + + return $buildedQuery; + } + + /** + * @param array $normalized Normalized document data. + * @param array $raw Raw document data. + * + * @return array + */ + protected function additionalNormalization(array $normalized, array $raw) + { + if (! isset($normalized['sequence'])) { + // + // Use id provided by Elastic if hose not return sequence field. + // Just in case. + // + $normalized['sequence'] = $raw['_id']; + } + + return $normalized; + } + + /** + * @return AggregationTypeResolverInterface + */ + protected function getAggregationResolver() + { + return new ElasticserachAggregationTypeResolver($this->getStrategy()); + } + + /** + * @param array $parameters A builded parameters. + * + * @return array + */ + protected function beforeSearch(array $parameters) + { + return $parameters; + } + + /** + * Build proper parameters for ElasticSearch '_search' method. + * + * @param SearchRequestInterface $request A search request instance. + * + * @return array + */ + private function buildSearchParameters(SearchRequestInterface $request) + { + $parameters = [ + 'body' => [], + 'index' => $this->index, + 'type' => $this->type, + ]; + + // + // Set fields which should be fetched or get all available fields. + // + $sources = $request->getSources(); + if (count($sources) > 0) { + $parameters['_source'] = array_map([ $this->getStrategy(), 'denormalizeFieldName'], $sources); + } + + // + // Build 'query_string' conditions. + // + $buildedQuery = $this->buildQuery($request); + if ($buildedQuery !== '') { + $parameters['body'] = [ + 'query' => [ + 'query_string' => [ + 'query' => $buildedQuery, + ], + ], + ]; + } else { + // + // Fetch all documents. + // + // https://github.com/elastic/elasticsearch-php/issues/495 + // Convert array to object. + // + $parameters['body'] = [ 'query' => [ 'match_all' => (object) [] ] ]; + } + + // + // Build aggregations. + // + $aggregation = $this->buildAggregation($request); + if (is_array($aggregation) && count($aggregation)) { + $parameters['body']['aggs'] = $aggregation; + } + + // + // Build sorting conditions. + // + $sort = $request->getSorts(); + if (count($sort) > 0) { + // + // Normalize sorted fields names. + // + $normalized = []; + foreach ($sort as $field => $direction) { + // + // Denormalize field name used for sorting. + // + $normalized[$this->getStrategy()->denormalizeFieldName($field, true)] = [ 'order' => $direction ]; + } + $parameters['body']['sort'] = $normalized; + } + + return $parameters; + } + + /** + * @param integer|null $limit Current limit. + * + * @return integer + */ + private function normalizeLimit($limit) + { + if (($limit === null) || ($limit > IndexInterface::MAX_RESULT_COUNT)) { + $limit = self::MAX_RESULT_COUNT; + } + + if ($limit > self::MAX_RESULT_COUNT) { + $limit = self::MAX_RESULT_COUNT; + } + + return $limit; + } + + /** + * @param integer $page Current page. + * @param integer $limit Normalized limit. + * + * @return integer + */ + private function normalizePage($page, $limit) + { + if (($page - 1) * $limit > self::MAX_RESULT_COUNT) { + $page = (int) floor(self::MAX_RESULT_COUNT / $limit); + } + + return $page; + } + + /** + * Build elastic search aggregation + * + * @param SearchRequestInterface $request A SearchRequestInterface instance. + * + * @return array + */ + private function buildAggregation(SearchRequestInterface $request) + { + $aggregations = $request->getAggregation(); + + if ($aggregations === null) { + return []; + } + $result = []; + + foreach ($aggregations as $aggregation) { + $aggregation = $aggregation->build($this->getAggregationResolver()); + $result[key($aggregation)] = current($aggregation); + } + + return $result; + } + + /** + * @param array $response Response from ElasticSearch server. + * + * @return SearchResponse + */ + private function normalize(array $response) + { + $totalCount = $response['hits']['total']; + $data = $response['hits']['hits']; + + $data = array_map(function (array $document) { + $result = $document['_source']; + + // + // Copy ElasticSearch id as id for document. Concrete normalizer may + // use or not use this field. + // + $document['_source']['id'] = $document['_id']; + + // + // Additionally fetch some data. + // + $result = $this->additionalNormalization($result, $document); + + return $this->getStrategy()->createDocument($result); + }, $data); + + return new SearchResponse( + $data, + $this->normalizeAggr($response), + ($totalCount > self::MAX_RESULT_COUNT) ? self::MAX_RESULT_COUNT : $totalCount + ); + } + + /** + * Normalize aggregation results. + * + * @param array $data Raw data from ElasticSearch. + * + * @return array + */ + private function normalizeAggr(array $data) + { + if (! isset($data['aggregations'])) { + // + // We don't have any aggregation results. + // + return []; + } + + return $this->doAggrNormalization($data['aggregations']); + } + + /** + * @param array $rawResult Raw aggregation results. + * + * @return array + */ + private function doAggrNormalization(array $rawResult) + { + $normalized = []; + + foreach ($rawResult as $name => $result) { + if (isset($result['buckets'])) { + $result = $result['buckets']; + + foreach ($result as $bucket) { + $data = [ + 'value' => $bucket['key'], + 'count' => $bucket['doc_count'], + ]; + + unset($bucket['key'], $bucket['doc_count']); + // + // Normalize sub aggregation. + // + $data['sub'] = $this->doAggrNormalization($bucket); + + $normalized[$name][] = $data; + } + } elseif (isset($result['hits'])) { + $result = $result['hits']['hits']; + + foreach ($result as $bucket) { + $bucket['_source']['_id'] = $bucket['_id']; + $normalized[$name][] = $bucket['_source']; + } + } + } + + return $normalized; + } +} \ No newline at end of file diff --git a/src/IndexBundle/Index/AbstractIndex.php b/src/IndexBundle/Index/AbstractIndex.php new file mode 100644 index 0000000..6244876 --- /dev/null +++ b/src/IndexBundle/Index/AbstractIndex.php @@ -0,0 +1,149 @@ +getQueryNormalizer()); + } + + /** + * Get document normalizer for this index. + * + * @return IndexStrategyInterface + */ + public function getStrategy() + { + if ($this->strategy === null) { + $this->strategy = $this->createStrategy(); + } + + return $this->strategy; + } + + /** + * Get filter resolver. + * + * @return FilterFactoryInterface + */ + public function getFilterFactory() + { + if ($this->filterFactory === null) { + $this->filterFactory = new FilterFactory(); + } + + return $this->filterFactory; + } + + /** + * Get aggregation filter resolver. + * + * @return AFResolverInterface + */ + public function getAFResolver() + { + if ($this->afResolver === null) { + $this->afResolver = new AFResolver( + $this->createAggregator(), + $this->getFilterFactory() + ); + } + + return $this->afResolver; + } + + /** + * Get query normalizer which will be used for this index. + * + * @return QueryNormalizerInterface + */ + protected function getQueryNormalizer() + { + if ($this->queryNormalizer === null) { + $this->queryNormalizer = new QueryNormalizer(); + } + + return $this->queryNormalizer; + } + + /** + * @return FilterResolverInterface + */ + protected function getFilterResolver() + { + if ($this->filterResolver === null) { + $this->filterResolver = $this->createFilterResolver(); + } + + return $this->filterResolver; + } + + /** + * Create concrete filter resolver instance. + * + * @return FilterResolverInterface + */ + abstract protected function createFilterResolver(); + + /** + * @return AFAggregatorInterface + */ + abstract protected function createAggregator(); + + /** + * Create concrete strategy instance. + * + * @return IndexStrategyInterface + */ + abstract protected function createStrategy(); +} diff --git a/src/IndexBundle/Index/External/ExternalIndexInterface.php b/src/IndexBundle/Index/External/ExternalIndexInterface.php new file mode 100644 index 0000000..dc09dd7 --- /dev/null +++ b/src/IndexBundle/Index/External/ExternalIndexInterface.php @@ -0,0 +1,16 @@ +logger = $logger; + $this->cache = $cache; + $connectionParams = [ + 'client' => [ + 'headers' => [ + // + // We should set 'host' header explicitly 'cause otherwise + // library add port and we got 503 error from hose server. + // + // Most likely this is a bug of hose but we should handle + // it. + // + 'host' => [ 'allcontent.elasticsearch.socialhose.io' ], + 'X-vendor' => [ $vendor ], + 'X-vendor-auth' => [ $auth ], + ], + ], + ]; + + if ($proxy !== null) { + // + // Set proxy 'cause from some servers we always got 503 error from + // hose. + // + // Maybe this issues relative to previous bug with host header ... + // + $connectionParams['client']['curl'] = [ + CURLOPT_PROXY => $proxy, + ]; + } + + $this->client = ClientBuilder::create() + ->setHosts([ + [ + 'host' => $host, + 'port' => 80, + ], + ]) + ->setConnectionParams($connectionParams) + ->build(); + $this->type = null; + $this->index = self::HOT; + } + + /** + * Create concrete strategy instance. + * + * @return IndexStrategyInterface + */ + protected function createStrategy() + { + return new HoseIndexStrategy(); + } + + /** + * @return AFAggregatorInterface + */ + protected function createAggregator() + { + return new CachedAFAggregator($this->cache, new ArticleAFAggregator($this)); + } + + /** + * @param array $parameters A builded parameters. + * + * @return array + */ + protected function beforeSearch(array $parameters) + { + $this->logger->info(sprintf( + 'Make search request to hose with parameters \'%s\'', + json_encode($parameters) + )); + + $query = $parameters['body']['query']['query_string']['query']; + $parameters['index'] = $this->determineIndex($this->getQueryRangeEnd($query)); + + return $parameters; + } + + /** + * @param string $query ElasticSearch 'query_string' query parameter. + * + * @return \DateTime|null + */ + private function getQueryRangeEnd($query) + { + $matched = []; + preg_match_all('/published:\[(.*?) TO .*?\]/i', $query, $matched); + + $endDates = array_filter(array_map('trim', $matched[1]), function ($value) { + return $value !== '*'; + }); + + if (count($endDates) > 0) { + rsort($endDates); + return new \DateTime(\nspl\a\first($endDates)); + } + + return null; + } + + /** + * Determine used Spinn3 index based on provided filters. + * + * @param \DateTime $endDate End date of fetch range. + * + * @return string + * + * @see http://allcontent.console.datastreamer.io/docs/search-overview.html#hot-warm-cold-architecture-and-archive-content + */ + private function determineIndex(\DateTime $endDate = null) + { + $usedIndices = [ self::HOT, self::WARM, self::COLD ]; + if ($endDate !== null) { + switch (true) { + case $endDate <= date_create('+ 30 days 00:00:00'): + $usedIndices = [ self::HOT ]; + break; + + case $endDate <= date_create('+ 60 days 00:00:00'): + $usedIndices = [ self::HOT, self::WARM ]; + break; + } + } + + return implode(',', $usedIndices); + } + + /** + * @return LoggerInterface + */ + public function getLogger() + { + return $this->logger; + } +} diff --git a/src/IndexBundle/Index/External/InternalHoseIndex.php b/src/IndexBundle/Index/External/InternalHoseIndex.php new file mode 100644 index 0000000..8f004bc --- /dev/null +++ b/src/IndexBundle/Index/External/InternalHoseIndex.php @@ -0,0 +1,65 @@ +cache = $cache; + } + + /** + * Create concrete strategy instance. + * + * @return IndexStrategyInterface + */ + protected function createStrategy() + { + return new HoseIndexStrategy(); + } + + /** + * @return AFAggregatorInterface + */ + protected function createAggregator() + { + return new CachedAFAggregator($this->cache, parent::createAggregator()); + } +} diff --git a/src/IndexBundle/Index/IndexInterface.php b/src/IndexBundle/Index/IndexInterface.php new file mode 100644 index 0000000..5e40afc --- /dev/null +++ b/src/IndexBundle/Index/IndexInterface.php @@ -0,0 +1,123 @@ +index .'_current'; + + // Remove index if it already created. + $exists = $this->client->indices()->exists([ 'index' => $alias ]); + if ($exists) { + $this->client->indices()->delete([ 'index' => $alias ]); + } + + // Create new actual index. + $this->client->indices()->create([ + 'index' => $alias, + 'body' => [ + 'settings' => $settings, + 'mappings' => [ + $this->type => [ + '_all' => [ 'enabled' => false ], + 'properties' => $mapping, + ], + ], + ], + ]); + + // + // Create alias with specified name for new index. + // + $this->client->indices()->putAlias([ + 'index' => $alias, + 'name' => $this->index, + ]); + } + + /** + * Index given document or array of documents. + * + * @param DocumentInterface|DocumentInterface[] $data DocumentInterface instance + * or array of instances. + * + * @return void + */ + public function index($data) + { + if ($data instanceof DocumentInterface) { + $data = [ $data ]; + } + + if (! is_array($data) || ! \app\a\allInstanceOf($data, DocumentInterface::class)) { + throw new \InvalidArgumentException(sprintf( + 'Data should be single %s instance or array of instance', + DocumentInterface::class + )); + } + + // + // Convert data into proper ES 'bulk' method properties. + // + $data = \nspl\a\map(function (DocumentInterface $document) { + $data = $document->getIndexableData(); + $config = [ + 'index' => [ + '_index' => $this->index, + '_type' => $this->type, + ], + ]; + + if (isset($data['id'])) { + $config['index']['_id'] = $data['id']; + } + + return [ + 'config' => $config, + 'document' => $data, + ]; + }, $data); + + // + // ElasticSearch has limit for request payload size, for example x4.large + // on AWS limits up to 10 Mb. So we should split 'index' request to small + // buckets. + // + // See: http://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/aes-limits.html#network-limits + // + $buckets = []; + $bucketsCount = count($data) / self::BUCKET_SIZE; + + for ($i = 0; $i < $bucketsCount; ++$i) { + $bucket = \nspl\a\drop($data, $i * self::BUCKET_SIZE); + $bucket = \nspl\a\take($bucket, self::BUCKET_SIZE); + + $buckets[] = $bucket; + } + + foreach ($buckets as $bucket) { + $params = [ 'body' => [] ]; + foreach ($bucket as $part) { + $params['body'][] = $part['config']; + $params['body'][] = $part['document']; + } + + $response = $this->client->bulk($params); + if (!isset($response['errors']) || $response['errors']) { + throw new \RuntimeException("Can't make bulk index due to " . json_encode($response)); + } + } + } + + /** + * Update specified document. + * + * Make partial update so in data must be placed only changed properties. + * + * @param string|integer $id Updated document id. + * @param array $data Array of changed data where key is property + * name and value is new property value. + * + * @return void + */ + public function update($id, array $data) + { + $this->client->update([ + 'id' => $id, + 'index' => $this->index, + 'type' => $this->type, + 'body' => [ 'doc' => $data ], + ]); + } + + /** + * Update array of documents. + * + * Make partial update so for each document id we should place only changed + * property. + * + * @param array $config Array of arrays where key is updated document id and + * value is array of updated fields same as $data in + * `update` method. + * + * @return void + */ + public function updateBulk(array $config) + { + $params = [ 'body' => [] ]; + foreach ($config as $id => $data) { + if (! is_array($data)) { + throw new \InvalidArgumentException("Invalid data for updating {$id}, must be an array of property name and new value."); + } + + $params['body'][] = [ + 'update' => [ + '_index' => $this->index, + '_type' => $this->type, + '_id' => $id, + ], + ]; + $params['body'][] = $data; + } + + $response = $this->client->bulk($params); + + if (! isset($response['errors']) || $response['errors']) { + throw new \RuntimeException("Can't make bulk update due to {$response['errors']}"); + } + } + + /** + * Update array of documents with filtering. + * + * Make partial update so for each document id we should place only changed + * property. + * + * @param SearchRequestInterface $request A SearchRequestInterface instance. + * @param string $script Updating script. + * @param array $params Script parameters. + * + * @return void + */ + public function updateByQuery(SearchRequestInterface $request, $script, array $params = []) + { + $parameters = [ + 'body' => [ + 'script' => [ + 'inline' => $script, + ], + ], + 'index' => $this->index, + 'type' => $this->type, + ]; + + if (count($params) > 0) { + $parameters['body']['script']['params'] = $params; + } + + $buildedQuery = $this->buildQuery($request); + if ($buildedQuery) { + $parameters['body']['query'] = [ + 'query_string' => [ + 'query' => $buildedQuery, + ], + ]; + } + + $this->client->updateByQuery($parameters); + } + + /** + * Purge index. + * + * @return void + */ + public function purge() + { + $this->client->deleteByQuery([ + 'index' => $this->index, + 'type' => $this->type, + + // + // https://github.com/elastic/elasticsearch-php/issues/495 + // Convert array to object. + // + 'body' => [ 'query' => [ 'match_all' => (object) [] ] ], + ]); + } + + /** + * Remove document by specified id or array of ids. + * + * @param string|string[] $id Document id or array of document ids. + * + * @return void + */ + public function remove($id) + { + $this->client->deleteByQuery([ + 'index' => $this->index, + 'type' => $this->type, + 'body' => [ 'query' => [ 'ids' => [ 'values' => array_filter((array) $id) ] ] ], + ]); + } + + /** + * Create concrete strategy instance. + * + * @return IndexStrategyInterface + */ + protected function createStrategy() + { + return new InternalIndexStrategy(); + } + + /** + * @return AFAggregatorInterface + */ + protected function createAggregator() + { + return new ArticleAFAggregator($this); + } +} diff --git a/src/IndexBundle/Index/Internal/InternalIndexInterface.php b/src/IndexBundle/Index/Internal/InternalIndexInterface.php new file mode 100644 index 0000000..dcf0a1b --- /dev/null +++ b/src/IndexBundle/Index/Internal/InternalIndexInterface.php @@ -0,0 +1,95 @@ + 'https?://[^/]*twitter', + 'facebook' => 'https?://[^/]*facebook', + 'instagram' => 'https?://[^/]*instagram', + 'tumblr' => 'https?://[^/]*tumblr', + 'pinterest' => 'https?://[^/]*pinterest', + 'youtube' => 'https?://[^/]*youtube', + 'reddit' => 'https?://[^/]*reddit', + ]; + + /** + * Required fields types. + * + * @var array + */ + private static $fieldsConfig = [ + 'sequence' => 'string', + 'date_found' => 'date', + 'source_hashcode' => 'string', + 'source_link' => 'string', + 'source_publisher_type' => 'string', + 'source_publisher_subtype' => 'string', + 'source_date_found' => 'string', + 'source_spam_probability' => 'float', + 'source_assigned_tags' => 'array', + 'source_title' => 'string', + 'source_favorites' => 'integer', + 'source_followers' => 'integer', + 'source_following' => 'integer', + 'source_verified' => 'boolean', + 'source_profiles' => 'array', + 'source_tags' => 'array', + 'source_likes' => 'integer', + 'source_location' => 'string', + 'permalink' => 'string', + 'main' => 'string', + 'main_length' => 'integer', + 'title' => 'string', + 'publisher' => 'string', + 'mentions' => 'array', + 'section' => 'string', + 'tags' => 'array', + 'published' => 'date', + 'author_name' => 'string', + 'author_link' => 'string', + 'author_gender' => 'string', + 'geo_country' => 'string', + 'geo_state' => 'string', + 'geo_city' => 'string', + 'geo_point' => 'string', + 'image_src' => 'string', + 'sentiment' => 'string', + 'extract' => 'string', + 'lang' => 'string', + 'categories' => 'array', + 'duplicates_count' => 'integer', + 'likes' => 'integer', + 'dislikes' => 'integer', + 'comments' => 'integer', + 'views' => 'integer', + 'shares' => 'integer', + 'video_player' => 'string', + 'video_player_width' => 'integer', + 'video_player_height' => 'integer', + 'domain' => 'string', + ]; + + private static $indexableFields = [ + 'sequence', + 'date_found', + 'source_hashcode', + 'source_link', + 'source_publisher_type', + 'source_publisher_subtype', + 'source_spam_probability', + 'source_title', + 'source_favorites', + 'source_followers', + 'source_following', + 'source_verified', + 'source_profiles', + 'source_tags', + 'source_likes', + 'source_location', + 'permalink', + 'main', + 'title', + 'publisher', + 'mentions', + 'section', + 'tags', + 'published', + 'author_name', + 'author_link', + 'author_gender', + 'geo_country', + 'geo_state', + 'geo_city', + 'image_src', + 'sentiment', + 'lang', + 'categories', + 'duplicates_count', + 'likes', + 'dislikes', + 'comments', + 'views', + 'shares', + 'domain' + ]; + + /** + * Map between application publisher type and hose publisher type. + * + * @var array + */ + private static $publisherMap = [ + PublisherTypeEnum::UNKNOWN => [ 'UNKNOWN' ], + PublisherTypeEnum::BLOGS => [ 'WEBLOG'], + PublisherTypeEnum::FORUMS => [ 'FORUM', 'MEMETRACKER', 'UNKNOWN' ], + PublisherTypeEnum::NEWS => [ 'MAINSTREAM_NEWS'], + PublisherTypeEnum::SOCIAL => [ 'SOCIAL_MEDIA', 'UNKNOWN' ], + PublisherTypeEnum::VIDEO => [ 'VIDEO', 'UNKNOWN' ], + PublisherTypeEnum::PHOTO => [ 'PHOTO', 'UNKNOWN' ], + ]; + + /** + * Map between hose publisher type and application publisher type. + * + * @var array + */ + private static $reversePublisherMap = [ + 'UNKNOWN' => PublisherTypeEnum::UNKNOWN, + 'CLASSIFIED' => PublisherTypeEnum::NEWS, + 'WEBLOG' => PublisherTypeEnum::BLOGS, + 'MICROBLOG' => PublisherTypeEnum::BLOGS, + 'FORUM' => PublisherTypeEnum::FORUMS, + 'MEMETRACKER' => PublisherTypeEnum::FORUMS, + 'MAINSTREAM_NEWS' => PublisherTypeEnum::NEWS, + 'REVIEW' => PublisherTypeEnum::NEWS, + 'SOCIAL_MEDIA' => PublisherTypeEnum::SOCIAL, + 'VIDEO' => PublisherTypeEnum::VIDEO, + 'PHOTO' => PublisherTypeEnum::PHOTO, + ]; + + /** + * Name of the fields which have 'raw' fields. + * + * @var array + */ + public static $rawFieldNameMap = [ + FieldNameEnum::SOURCE_TITLE, + FieldNameEnum::SECTION, + FieldNameEnum::AUTHOR_NAME, + FieldNameEnum::PUBLISHER, + ]; + + /** + * Create proper document instance. + * + * @param array $data Document data fetched from index. + * + * @return DocumentInterface + */ + public function createDocument(array $data) + { + return new ArticleDocument($this, $data); + } + + /** + * Normalized document data. + * + * @param array $rawData Raw document data. + * + * @return array + * @internal + */ + public function normalizeDocumentData(array $rawData) + { + $normalized = []; + + // + // Insure that all required field are exists and has values 'cause + // hose don't worry about data that contains in they index so we should + // do that .... + // + foreach (self::$fieldsConfig as $field => $type) { + $value = isset($rawData[$field]) ? $rawData[$field] : null; + + switch ($type) { + case 'array': + $value = array_filter((array) $value); + break; + + case 'date': + $value = DateConverter::convert($value); + if ($value === false) { + $value = null; + } + + // + // hose may contains data from future so we should use + // current date instead of that dates. + // + $now = date_create(); + if (($value instanceof \DateTimeInterface) && ($value > $now)) { + $value = $now; + } + + break; + + default: + if ($value !== null) { + settype($value, $type); + if ($type === 'string') { + $value = trim($value); + } + } + } + + $normalized[$field] = $value; + } + + // + // Insure that we have 'comments' fields. + // + $comments = []; + $commentsCount = 0; + if (isset($rawData['__comments'])) { + $comments = $rawData['__comments']; + $commentsCount = $rawData['__commentsCount']; + } + + // + // Some documents in hose index don't contains 'source_publisher_type' + // so we assume that this document has UNKNOWN type. + // + $sourcePublisherType = trim($normalized['source_publisher_type']) === '' + ? PublisherTypeEnum::UNKNOWN + : $normalized['source_publisher_type']; + + return [ + 'id' => $normalized['sequence'], + 'type' => 'document', + 'title' => $normalized['title'], + 'permalink' => $normalized['permalink'], + 'dateFound' => $normalized['date_found'], + 'published' => $normalized['published'], + 'content' => $this->normalizeContent($normalized), + // + // Some documents don't have language field so we assume that it english. + // + 'language' => trim($normalized['lang']) === '' ? LanguageEnum::ENGLISH : $normalized['lang'], + 'publisher' => $normalized['publisher'], + 'source' => [ + 'id' => $normalized['source_hashcode'], + 'title' => $this->normalizeSourceTitle($normalized), + 'type' => $this->normalizePublisherType($sourcePublisherType), + 'link' => $normalized['source_link'], + 'section' => $normalized['section'], + 'country' => $normalized['geo_country'], + 'state' => $normalized['geo_state'], + 'city' => $normalized['geo_city'], + 'siteType' => $this->determineSiteType($normalized['source_link']), + ], + 'author' => [ + 'name' => $normalized['author_name'], + 'link' => $normalized['author_link'], + ], + 'duplicates' => $normalized['duplicates_count'], + 'image' => $normalized['image_src'], + 'views' => $normalized['views'], + 'sentiment' => $normalized['sentiment'], + 'comments' => $comments, + 'commentsCount' => $commentsCount, + 'domain' =>$normalized['domain'] + ]; + } + + /** + * Get data which should be used for indexing. + * + * @param array $rawData Raw document data. + * + * @return array + * @internal + */ + public function getIndexableData(array $rawData) + { + $indexableData = [ + FieldNameEnum::PLATFORM => 'hose', + 'main' => $this->normalizeContent($rawData), + 'source_publisher_type' => $this->denormalizePublisherType($rawData['source_publisher_type'])[0], + 'source_title' => $this->normalizeSourceTitle($rawData), + ]; + + if (isset($rawData[FieldNameEnum::COLLECTION_ID])) { + $indexableData[FieldNameEnum::COLLECTION_ID] = $rawData[FieldNameEnum::COLLECTION_ID]; + $indexableData[FieldNameEnum::COLLECTION_TYPE] = $rawData[FieldNameEnum::COLLECTION_TYPE]; + } + + if (isset($rawData[FieldNameEnum::DELETE_FROM])) { + $indexableData[FieldNameEnum::DELETE_FROM] = $rawData[FieldNameEnum::DELETE_FROM]; + } else { + $indexableData[FieldNameEnum::DELETE_FROM] = []; + } + + foreach (self::$indexableFields as $field) { + if (isset($rawData[$field])) { + $indexableData[$field] = $rawData[$field]; + } + } + + return $indexableData; + } + + /** + * Convert concrete index field name into proper application field name. + * + * @param string $indexFieldName Field name from index. + * @param boolean $fromAggregation We got field from aggregation response and + * should normalize by another rules if true. + * We need this flag because of some index + * services like ElasticSearch where some + * field maybe exists in data without indexing + * but this field has field which is indexed. + * + * @return string + */ + public function normalizeFieldName($indexFieldName, $fromAggregation = false) + { + $applicationFieldName = $indexFieldName; + if ($fromAggregation && (strpos($indexFieldName, '.raw') !== false)) { + $applicationFieldName = substr($indexFieldName, 0, -4); + if ($applicationFieldName === false) { + throw new \RuntimeException(sprintf( + 'Can\'t normalize field name from aggregation \'%s\'', + $indexFieldName + )); + } + } + + return $applicationFieldName; + } + + /** + * Convert application level field name into field name for concrete index. + * + * @param string $applicationFieldName Application field name. + * @param boolean $forAggregation This field will be used in aggregation + * and we should denormalize by another + * rules if true. We need this flag + * because of some index services like + * ElasticSearch where some field maybe + * exists in data without indexing but + * this field has field which is indexed. + * + * @return string + */ + public function denormalizeFieldName($applicationFieldName, $forAggregation = false) + { + $indexFieldName = $applicationFieldName; + if ($forAggregation && in_array($applicationFieldName, self::$rawFieldNameMap, true)) { + $indexFieldName .= '.raw'; + } + + return $indexFieldName; + } + + /** + * Convert concrete publisher type from index into application level type. + * + * @param string $indexPublisherType Publisher type from index. + * + * @return string + */ + public function normalizePublisherType($indexPublisherType) + { + if (array_key_exists($indexPublisherType, self::$publisherMap)) { + return $indexPublisherType; + } + + if (! array_key_exists($indexPublisherType, self::$reversePublisherMap)) { + throw new \UnexpectedValueException(sprintf( + 'Unhandled index publisher type \'%s\'', + $indexPublisherType + )); + } + + return self::$reversePublisherMap[$indexPublisherType]; + } + + /** + * Convert application level publisher type into type for concrete index. + * + * @param string $applicationPublisherType Application publisher type. + * + * @return string[] + */ + public function denormalizePublisherType($applicationPublisherType) + { + if (array_key_exists($applicationPublisherType, self::$reversePublisherMap)) { + return [ $applicationPublisherType, 'UNKNOWN' ]; + } + + if (! array_key_exists($applicationPublisherType, self::$publisherMap)) { + throw new \UnexpectedValueException(sprintf( + 'Unhandled application publisher type \'%s\'', + $applicationPublisherType + )); + } + + return self::$publisherMap[$applicationPublisherType]; + } + + /** + * @param array $rawData Raw data from hose. + * + * @return string + */ + private function normalizeContent(array $rawData) + { + if (isset($rawData['main'])) { + $main = $rawData['main']; + } elseif (isset($rawData['extract'])) { + $main = $rawData['extract']; + } else { + return ''; + } + + // + // Replace html entities before any normalization steps. + // + $main = html_entity_decode($main); + + // + // Replace some html tags with new line symbols. + // + $main = preg_replace([ + '#
    #i', + '##i', + ], "\n", $main); + + // + // Remove links, scripts, styles, images and etc. + // + $main = preg_replace([ + '#<(script|style|a)>#i', + '#]*?>#', + ], '', $main); + + $main = strip_tags($main); + + return trim(preg_replace([ + '# {2,}#', // Replace multiple consecutive spaces by one. + '#\n{2,}#', // Replace multiple consecutive 'new line' symbols by one. + '#\s{2,}#', // At last replace all multiple consecutive of 'empty' + // symbols by 'new line'. + ], [ + '', + "\n", + "\n", + ], $main)); + } + + /** + * @param array $data Normalized data. + * + * @return string|null + */ + private function normalizeSourceTitle(array $data) + { + $title = null; + + switch (true) { + case isset($data['source_feed_title']) && trim($data['source_feed_title']) !== '': + $title = $data['source_feed_title']; + break; + + case isset($data['source_title']) && trim($data['source_title']) !== '': + $title = $data['source_title']; + break; + + case isset($data['source_resource']) && trim($data['source_resource']) !== '': + $title = $data['source_resource']; + break; + + case isset($data['source_link']) && trim($data['source_link']) !== '': + $title = $data['source_link']; + break; + } + + return $title; + } + + /** + * @param string $link Source link. + * + * @return string + */ + private function determineSiteType($link) + { + foreach (self::$siteTypesMap as $type => $regexp) { + if (preg_match("#{$regexp}#i", $link) === 1) { + return $type; + } + } + + return null; + } +} diff --git a/src/IndexBundle/Index/Strategy/IndexStrategyInterface.php b/src/IndexBundle/Index/Strategy/IndexStrategyInterface.php new file mode 100644 index 0000000..114a749 --- /dev/null +++ b/src/IndexBundle/Index/Strategy/IndexStrategyInterface.php @@ -0,0 +1,95 @@ + 'https?://[^/]*twitter', + 'facebook' => 'https?://[^/]*facebook', + 'instagram' => 'https?://[^/]*instagram', + 'tumblr' => 'https?://[^/]*tumblr', + 'pinterest' => 'https?://[^/]*pinterest', + 'youtube' => 'https?://[^/]*youtube', + ]; + + /** + * Map between internal fields name and cache field name. + * + * @var array + */ + private static $fieldNameMap = [ + 'id' => FieldNameEnum::SOURCE_HASHCODE, + 'title' => FieldNameEnum::SOURCE_TITLE, + 'type' => FieldNameEnum::SOURCE_PUBLISHER_TYPE, + 'url' => FieldNameEnum::SOURCE_LINK, + 'country' => FieldNameEnum::COUNTRY, + 'city' => FieldNameEnum::CITY, + 'state' => FieldNameEnum::STATE, + ]; + + /** + * Reverse filed map. + * + * @var array + */ + private static $reversedFieldNameMap = [ + FieldNameEnum::SOURCE_HASHCODE => 'id', + FieldNameEnum::SOURCE_TITLE => 'title', + FieldNameEnum::SOURCE_PUBLISHER_TYPE => 'type', + FieldNameEnum::SOURCE_LINK => 'url', + FieldNameEnum::COUNTRY => 'country', + FieldNameEnum::CITY => 'city', + FieldNameEnum::STATE => 'state', + ]; + + /** + * Name of the fields which have 'raw' fields. + * + * @var array + */ + public static $rawFieldNameMap = [ + FieldNameEnum::SOURCE_TITLE, + ]; + + /** + * Create proper document instance. + * + * @param array $data Document data fetched from index. + * + * @return DocumentInterface + */ + public function createDocument(array $data) + { + return new SourceDocument($this, $data); + } + + /** + * Normalized document data. + * + * @param array $rawData Raw document data. + * + * @return array + * @internal + */ + public function normalizeDocumentData(array $rawData) + { + $rawData['type'] = $this->normalizePublisherType($rawData['type']); + $rawData['siteType'] = $this->determineSiteType($rawData['url']); + + return $rawData; + } + + /** + * Get data which should be used for indexing. + * + * @param array $rawData Raw document data. + * + * @return array + * @internal + */ + public function getIndexableData(array $rawData) + { + return $rawData; + } + + /** + * Convert concrete index field name into proper application field name. + * + * @param string $indexFieldName Field name from index. + * @param boolean $fromAggregation We got field from aggregation response and + * should normalize by another rules if true. + * We need this flag because of some index + * services like ElasticSearch where some + * field maybe exists in data without indexing + * but this field has field which is indexed. + * + * @return string + */ + public function normalizeFieldName($indexFieldName, $fromAggregation = false) + { + $applicationFieldName = $indexFieldName; + if ($fromAggregation && (strpos($indexFieldName, '.raw') !== false)) { + $applicationFieldName = substr($indexFieldName, 0, -4); + if ($applicationFieldName === false) { + throw new \RuntimeException(sprintf( + 'Can\'t normalize field name from aggregation \'%s\'', + $indexFieldName + )); + } + } + + return isset(self::$fieldNameMap[$applicationFieldName]) + ? self::$fieldNameMap[$applicationFieldName] + : $applicationFieldName; + } + + /** + * Convert application level field name into field name for concrete index. + * + * @param string $applicationFieldName Application field name. + * @param boolean $forAggregation This field will be used in aggregation + * and we should denormalize by another + * rules if true. We need this flag + * because of some index services like + * ElasticSearch where some field maybe + * exists in data without indexing but + * this field has field which is indexed. + * + * @return string + */ + public function denormalizeFieldName($applicationFieldName, $forAggregation = false) + { + $indexFieldName = $applicationFieldName; + if (isset(self::$reversedFieldNameMap[$applicationFieldName])) { + $indexFieldName = self::$reversedFieldNameMap[$applicationFieldName]; + } + + if ($forAggregation && in_array($applicationFieldName, self::$rawFieldNameMap, true)) { + $indexFieldName .= '.raw'; + } + + return $indexFieldName; + } + + /** + * Convert concrete publisher type from index into application level type. + * + * @param string $indexPublisherType Publisher type from index. + * + * @return string + */ + public function normalizePublisherType($indexPublisherType) + { + return $indexPublisherType; + } + + /** + * Convert application level publisher type into type for concrete index. + * + * @param string $applicationPublisherType Application publisher type. + * + * @return string[] + */ + public function denormalizePublisherType($applicationPublisherType) + { + return [ $applicationPublisherType ]; + } + + /** + * @param string $link Source link. + * + * @return string + */ + private function determineSiteType($link) + { + foreach (self::$siteTypesMap as $type => $regexp) { + if (preg_match("#{$regexp}#i", $link) === 1) { + return $type; + } + } + + return null; + } +} diff --git a/src/IndexBundle/IndexBundle.php b/src/IndexBundle/IndexBundle.php new file mode 100644 index 0000000..9d2c5b4 --- /dev/null +++ b/src/IndexBundle/IndexBundle.php @@ -0,0 +1,13 @@ +strategy = $strategy; + $this->data = $data; + } + + /** + * @param callable|\Closure $listener Listener callback. + * + * @return $this + */ + public function addNormalizerListener($listener) + { + $this->normalizerListeners[] = $listener; + + return $this; + } + + /** + * Get normalized data from document as array. + * + * @return array + */ + public function getNormalizedData() + { + if ($this->normalizedData === null) { + $this->normalizedData = $this->strategy->normalizeDocumentData($this->data); + + foreach ($this->normalizerListeners as $listener) { + $this->normalizedData = $listener($this->normalizedData); + } + } + + return $this->normalizedData; + } + + /** + * Normalize inner data. + * + * @return $this + */ + public function normalize() + { + $this->data = $this->getNormalizedData(); + + return $this; + } + + /** + * Get data used for indexing. + * + * @return array + */ + public function getIndexableData() + { + if ($this->indexableData === null) { + $this->indexableData = $this->strategy->getIndexableData($this->data); + } + + return $this->indexableData; + } + + /** + * Map data inside document. + * + * Callback signature: + * ```php + * function (array $data): array { ... } + * ``` + * + * @param callable|\Closure $callback Data mapper callback. + * + * @return static + */ + public function mapRawData($callback) + { + $this->data = $callback($this->data); + $this->normalizedData = null; + $this->indexableData = null; + + return $this; + } + + /** + * Add listener which is called after normalization process. + * + * Callback signature: + * ```php + * function (array $data): array { ... } + * ``` + * + * @param callable|\Closure $callback Listener. + * + * @return static + */ + public function mapNormalizedData($callback) + { + $this->normalizerListeners[] = $callback; + $this->normalizedData = null; + $this->indexableData = null; + + return $this; + } + + /** + * Whether a offset exists + * + * @param mixed $offset An offset to check for. + * + * @return boolean true on success or false on failure + */ + public function offsetExists($offset) + { + return isset($this->data[$offset]); + } + + /** + * Offset to retrieve + * + * @param mixed $offset The offset to retrieve. + * + * @return mixed + */ + public function offsetGet($offset) + { + if (! isset($this->data[$offset])) { + throw new \InvalidArgumentException('Unknown property \''. $offset .'\''); + } + + return $this->data[$offset]; + } + + /** + * Offset to set + * + * @param mixed $offset The offset to assign the value to. + * @param mixed $value The value to set. + * + * @return void + */ + public function offsetSet($offset, $value) + { + $this->data[$offset] = $value; + $this->normalizedData = null; + } + + /** + * Offset to unset + * + * @param mixed $offset The offset to unset. + * + * @return void + */ + public function offsetUnset($offset) + { + throw new \LogicException('Can\'t unset \''. $offset .'\''); + } + + /** + * Retrieve an external iterator + * + * @return \Traversable + */ + public function getIterator() + { + return new \ArrayIterator($this->data); + } + + /** + * Is utilized for reading data from inaccessible members. + * + * @param string $name Property name. + * + * @return mixed + */ + public function __get($name) + { + return $this->data[$name]; + } + + /** + * Run when writing data to inaccessible members. + * + * @param string $name Property name. + * @param mixed $value New value. + * + * @return void + * + * @deprecated Use mapData instead. + * todo remove it + */ + public function __set($name, $value) + { + $this->data[$name] = $value; + $this->normalizedData = null; + } + + /** + * Is triggered by calling isset() or empty() on inaccessible members. + * + * @param string $name Property name. + * + * @return boolean + */ + public function __isset($name) + { + return isset($this->data[$name]); + } +} diff --git a/src/IndexBundle/Model/ArticleDocument.php b/src/IndexBundle/Model/ArticleDocument.php new file mode 100644 index 0000000..9efdedf --- /dev/null +++ b/src/IndexBundle/Model/ArticleDocument.php @@ -0,0 +1,100 @@ + self::PLATFORM_hose, + InternalIndexInterface::class => self::PLATFORM_hose, + ]; + + /** + * Get document id. + * + * @return string + */ + public function getId() + { + return $this->data['sequence']; + } + + /** + * Get platform from which we get this document. + * + * @return string + */ + public function getPlatform() + { + return self::$strategyToTypeMap[get_class($this->strategy)]; + } + + /** + * Create proper source document instance from this article document. + * + * @return array + */ + public function toSourceDocumentData() + { + $data = $this->getNormalizedData(); + + return [ + 'id' => $data['source']['id'], + 'title' => $data['source']['title'], + 'url' => $data['source']['link'], + 'country' => $data['source']['country'], + 'state' => $data['source']['state'], + 'city' => $data['source']['city'], + 'section' => $data['source']['section'], + 'lang' => $data['language'], + 'deleted' => 0, + 'type' => $data['source']['type'], + 'listIds' => [], + ]; + } + + /** + * @return Document + */ + public function toDocumentEntity() + { + return Document::create() + ->setData($this->data) + ->setPlatform(ArticleDocumentInterface::PLATFORM_hose) + ->setId($this->getId()); + } + + /** + * Is triggered when invoking inaccessible methods in an object context. + * + * @param string $name Method name. + * @param array $arguments Methid arguments. + * + * @return mixed + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __call($name, array $arguments) + { + if (isset($this->data[$name])) { + return $this->data[$name]; + } + + return null; + } +} diff --git a/src/IndexBundle/Model/ArticleDocumentInterface.php b/src/IndexBundle/Model/ArticleDocumentInterface.php new file mode 100644 index 0000000..c7e9086 --- /dev/null +++ b/src/IndexBundle/Model/ArticleDocumentInterface.php @@ -0,0 +1,42 @@ +faker = Factory::create(); + } +} diff --git a/src/IndexBundle/Model/Generator/ExternalDocumentGenerator.php b/src/IndexBundle/Model/Generator/ExternalDocumentGenerator.php new file mode 100644 index 0000000..88f21f2 --- /dev/null +++ b/src/IndexBundle/Model/Generator/ExternalDocumentGenerator.php @@ -0,0 +1,147 @@ +strategy = new HoseIndexStrategy(); + } + + /** + * Generate external document. + * + * @param integer $id Document id. + * + * @return ArticleDocument + */ + public function generate($id = null) + { + $content = $this->faker->realText(400); + $summary = substr($content, 0, 50) .'...'; + $dateFound = date_create()->format('c'); + + $title = $this->faker->realText(20); + $publisher = $this->faker->randomElement(self::$publisherNames); + + $sourceUrl = "http://{$this->faker->domainName}/"; + $documentUrl = "{$sourceUrl}{$this->faker->slug}.html"; + + $publisherType = $this->faker->randomElement(PublisherTypeEnum::getAvailables()); + $country = $this->faker->randomElement(CountryEnum::getAvailables()); + $state = $this->faker->randomElement(StateEnum::getAvailables()); + $city = $this->faker->randomElement(self::$cityNames); + + $data = [ + // To insure that sequence will be unique number. + 'sequence' => $id === null ?: date_create()->getTimestamp() + $this->faker->randomNumber(8), + 'date_found' => $dateFound, + 'source_hashcode' => md5($sourceUrl . date_create()->getTimestamp()), + 'source_link' => $sourceUrl, + 'source_publisher_type' => $publisherType, + 'source_publisher_subtype' => $this->faker->domainName, + 'source_date_found' => $dateFound, + 'source_title' => $publisher, + 'source_description' => $this->faker->text(50), + 'source_location' => "{$country}, {$state}, {$city}", + 'permalink' => $documentUrl, + 'main' => $content, + 'main_length' => strlen($content), + 'summary_text' => $summary, + 'title' => $title, + 'publisher' => $publisher, + 'section' => $this->faker->randomElement(self::$sectionNames), + 'tags' => $this->faker->randomElements(self::$tags, random_int(2, 4)), + 'links' => $this->randomLinks(), + 'published' => $this->faker->dateTimeBetween('- 1 months')->format('c'), + 'author_name' => $this->faker->randomElement(self::$authorNames), + 'author_link' => $this->faker->url, + 'author_gender' => $this->faker->randomElement(['M', 'W']), + 'geo_country' => $country, + 'geo_state' => $state, + 'geo_city' => $city, + 'geo_point' => $this->faker->latitude .', '. $this->faker->longitude, + 'image_src' => $this->realImageUrl(), + 'sentiment' => $this->faker->randomElement(self::$sentimentTypes), + 'lang' => $this->faker->randomElement(LanguageEnum::getAvailables()), + 'duplicates_count' => $this->faker->numberBetween(0, 50), + 'views' => $this->faker->numberBetween(0, 11000000), + 'shares' => $this->faker->randomNumber(8), + ]; + + return new ArticleDocument($this->strategy, $data); + } + + /** + * @return array + */ + private function randomLinks() + { + $count = random_int(1, 4); + + $links = []; + for ($i = 0; $i < $count; ++$i) { + $links[] = $this->faker->url; + } + + return $links; + } + + /** + * @return string + */ + private function realImageUrl() + { + if ($this->faker->boolean()) { + return sprintf( + self::REAL_IMG_URL, + $this->faker->randomElement(self::$heights), + $this->faker->randomElement(self::$widths) + ); + } + + return ''; + } +} diff --git a/src/IndexBundle/Model/Generator/InternalDocumentGenerator.php b/src/IndexBundle/Model/Generator/InternalDocumentGenerator.php new file mode 100644 index 0000000..c9b4a45 --- /dev/null +++ b/src/IndexBundle/Model/Generator/InternalDocumentGenerator.php @@ -0,0 +1,12 @@ +strategy = new SourceIndexStrategy(); + } + + /** + * Generate external document. + * + * @return SourceDocument + */ + public function generate() + { + return new SourceDocument($this->strategy, [ + 'title' => $this->faker->randomElement(self::$publisherNames), + 'url' => $this->faker->url, + 'country' => $this->faker->randomElement(CountryEnum::getAvailables()), + 'state' => $this->faker->randomElement(StateEnum::getAvailables()), + 'city' => $this->faker->randomElement(self::$cityNames), + 'section' => $this->faker->randomElement(self::$sectionNames), + 'lang' => $this->faker->randomElement(LanguageEnum::getAvailables()), + 'deleted' => $this->faker->boolean(90), + 'type' => $this->faker->randomElement(PublisherTypeEnum::getAvailables()), + 'listIds' => [], + ]); + } +} diff --git a/src/IndexBundle/Model/SourceDocument.php b/src/IndexBundle/Model/SourceDocument.php new file mode 100644 index 0000000..16c83ff --- /dev/null +++ b/src/IndexBundle/Model/SourceDocument.php @@ -0,0 +1,27 @@ +setInput($queryString); + + $tokens = self::buildTokenArrayFromLexer($lexer); + + // + // We may get operators at the beginning and at the end of tokens list + // and this is because of error in query typed by user, so we should drop + // this operators. + // + while ((count($tokens) > 0) && $tokens[0]->isBinaryOperator()) { + array_shift($tokens); + } + + while ((count($tokens) > 0) && $tokens[count($tokens) - 1]->isOperator()) { + array_pop($tokens); + } + + return $tokens; + } + + /** + * @param QueryLexer $lexer A QueryLexer instance. + * + * @return Token[] + */ + protected static function buildTokenArrayFromLexer(QueryLexer $lexer) + { + /** @var Token[] $tokens */ + $tokens = []; + while ($lexer->moveNext() && $lexer->lookahead !== null) { + $current = Token::fromLexerToken($lexer->token); + $next = Token::fromLexerToken($lexer->lookahead); + + if ($current->isNot() && $next->isNull()) { + // + // We got 'NOT' at the end of query, this is invalid query so we + // don't add this 'NOT' into result set. + // + continue; + } + + if (($current->isWord() || $current->isCloseBracket()) + && ($next->isWord() || $next->isOpenBracket())) { + // + // We got two words which go one after another or words after + // open bracket. In that case we need to add 'OR' token between + // them because spaces between words are the same as 'OR' tokens. + // + $tokens[] = new Token(Token::TYPE_OR, 'OR'); + } + + $tokens[] = $next; + } + + return $tokens; + } + + /** + * Lexical catchable patterns. + * + * @return array + */ + protected function getCatchablePatterns() + { + // + // We should catch all parentheses, words and quotes. + // + return [ + '\(|\)', + '"[^"]+"[+~\d.^]*', + '[\w^~+*?.]+', + ]; + } + + /** + * Lexical non-catchable patterns. + * + * @return array + */ + protected function getNonCatchablePatterns() + { + // + // We don't want to catch spaces. + // + return [ '\s+' ]; + } + + /** + * Retrieve token type. Also processes the token value if necessary. + * + * @param string $value Token value. + * + * @return integer + */ + protected function getType(&$value) + { + switch ($value) { + case 'OR': + return Token::TYPE_OR; + + case 'AND': + return Token::TYPE_AND; + + case 'NOT': + return Token::TYPE_NOT; + + case '(': + return Token::TYPE_OPEN_BRACKET; + + case ')': + return Token::TYPE_CLOSE_BRACKET; + } + + return Token::TYPE_WORD; + } +} diff --git a/src/IndexBundle/Normalizer/Query/QueryNormalizer.php b/src/IndexBundle/Normalizer/Query/QueryNormalizer.php new file mode 100644 index 0000000..01c2989 --- /dev/null +++ b/src/IndexBundle/Normalizer/Query/QueryNormalizer.php @@ -0,0 +1,303 @@ +reorder($tokens); + + while (count($tokens) > 1) { + $tokens = $this->normalizationStep($tokens); + } + + if (count($tokens) === 0) { + return ''; + } + + return (string) $tokens[0]; + } + + /** + * @param Token[] $tokens Array of normalized tokens. + * + * @return array + */ + private function normalizationStep(array $tokens) + { + $length = count($tokens); + $buf = []; + + $normalizeToken = function ($idx) use (&$tokens) { + return isset($tokens[$idx]) ? $tokens[$idx] : Token::nullToken(); + }; + + for ($i = 0; $i < $length;) { + $first = $tokens[$i]; + // + // In some case we can try to get element beyond index, so we + // need to handle this situation. + // + $second = $normalizeToken($i + 1); + $third = $normalizeToken($i + 2); + + if ($third->isBinaryOperator() + && ! $first->isOperator() + && ! $second->isOperator() + ) { + // + // Group of tokens. + // After reordering we can have next elements in stack: + // + // x y OR z AND a b OR AND + // + // In this section we process `x y OR` and `a b OR` parts of + // stack and create new token, it may be a single word or a + // group of words. + // + $group = Token::groupToken([$first, $second], $third); + $buf[] = $this->normalizeToken($group); + + // + // We jump through next two elements because we already + // process it. + // + $i += 3; + } elseif ($second->isNot() && ($first->isGroup() || ! $first->isOperator())) { + $buf[] = Token::negativeGroupToken($first); + $i += 2; + } else { + // + // Single token, like operator or single word. + // + $buf[] = $first; + ++$i; + } + } + + return $buf; + } + + /** + * Reorder tokens in Reverse Polish Notation. + * {@link https://en.wikipedia.org/wiki/Reverse_Polish_notation} + * + * @param array|Token[] $tokens Query string tokens. + * + * @return Token[] + */ + private function reorder($tokens) + { + $operators = []; + $result = []; + + foreach ($tokens as $token) { + switch ($token->getType()) { + // + // If current token is word we add it to result stack. + // + case Token::TYPE_WORD: + $result[] = $this->normalizeToken($token); + break; + + // + // Open bracket we add to operators stack without any over + // actions. + // + case Token::TYPE_OPEN_BRACKET: + $operators[] = $token; + break; + + // + // If we got close bracket we should pop all operators that go + // to the first opening bracket into result stack and remove + // opening bracket from operators stack. + // + case Token::TYPE_CLOSE_BRACKET: + /** @var Token $element */ + while (($element = array_pop($operators)) + && ! $element->isOpenBracket()) { + $result[] = $element; + } + break; + + // + // We got one of operators like AND or OR. + // + default: + $this->addOperation($operators, $result, $token); + } + } + + // + // Push all remain operators into result stack. + // + while (($element = array_pop($operators)) !== null) { + $result[] = $element; + } + + return $result; + } + + /** + * Add given operators token. + * + * @param array|Token[] $operators Operation stack. + * @param array|Token[] $result Result stack. + * @param Token $token Current token. + * + * @return void + */ + private function addOperation( + array &$operators, + array &$result, + Token $token + ) { + while (true) { + // + // Get last operator from stack. + // + /** @var Token $lastOperatorToken */ + $lastOperatorToken = array_pop($operators); + if (! $lastOperatorToken) { + // + // If we don't have any operators we just push current operator + // into stack and break loop. + // + $operators[] = $token; + break; + } + + // + // If we have operators in stack we should check priorities. + // + if ($token->getPriority() > $lastOperatorToken->getPriority()) { + // + // If current priority greater than previous we add both of + // them into operators stack. + // + $operators[] = $lastOperatorToken; + $operators[] = $token; + break; + } + + // + // Otherwise put last operator token into result stack. + // + $result[] = $lastOperatorToken; + } + } + + /** + * Normalize given token. + * + * @param Token $token A Token instance. + * + * @return Token + */ + private function normalizeToken(Token $token) + { + if ($token->isNormalized()) { + return $token; + } + + if ($token->isGroup()) { + $first = $this->normalizeToken($token->getValue()[0]); + $second = $this->normalizeToken($token->getValue()[1]); + + if ($first->isGroup() || $second->isGroup()) { + $token = $this + ->normalizeGroup($first, $second, $token->getGroupOperator()); + } else { + $words = $this->sortWords([$first, $second]); + + if (count($words) > 1) { + $token = Token::groupToken( + $this->sortWords([$first, $second]), + $token->getGroupOperator() + ); + } else { + $token = new Token($first->getType(), $first->getValue()); + } + } + } else { + $token = new Token($token->getType(), strtolower($token->getValue())); + } + + $token->setNormalized(true); + return $token; + } + + /** + * @param Token $first A first Token instance. + * @param Token $second A second Token instance. + * @param Token $operator A operator Token instance. + * + * @return Token + */ + private function normalizeGroup(Token $first, Token $second, Token $operator) + { + $tokens = [$first, $second]; + + if ($operator->isSameType($first->getGroupOperator()) + || $operator->isSameType($second->getGroupOperator())) { + $tokens = array_merge( + $first->isGroup() ? $first->getValue() : [ $first ], + $second->isGroup() ? $second->getValue() : [ $second ] + ); + } + + return Token::groupToken($this->sortWords($tokens), $operator); + } + + /** + * Sort words in ascending order. + * + * @param array|Token[] $words Array of tokens. + * + * @return array + */ + private function sortWords($words) + { + $buf = []; + + if (count($words) === 1) { + return $words[0]; + } + + // + // Create map between token value and token. + // + foreach ($words as $word) { + $buf[(string) $word] = $word; + } + + // + // Sort words and create query string for given query part. + // + ksort($buf); + return array_values($buf); + } +} diff --git a/src/IndexBundle/Normalizer/Query/QueryNormalizerInterface.php b/src/IndexBundle/Normalizer/Query/QueryNormalizerInterface.php new file mode 100644 index 0000000..100712a --- /dev/null +++ b/src/IndexBundle/Normalizer/Query/QueryNormalizerInterface.php @@ -0,0 +1,23 @@ + 5, + self::TYPE_AND => 4, + self::TYPE_OR => 3, + self::TYPE_OPEN_BRACKET => 2, + self::TYPE_CLOSE_BRACKET => 2, + self::TYPE_WORD => 0, + self::TYPE_GROUP => 0, + self::TYPE_NEGATIVE_GROUP => 0, + ]; + + /** + * @var string|Token[] + */ + private $value; + + /** + * @var integer + */ + private $type; + + /** + * @var Token + */ + private $groupOperator; + + /** + * @var boolean + */ + private $normalized = false; + + /** + * @param integer $type Token type. + * @param string|Token[] $value Token value, if null generate from type. + */ + public function __construct($type, $value = null) + { + $this->value = $value; + $this->type = $type; + if ($type !== self::TYPE_NULL) { + $this->groupOperator = self::nullToken(); + } + } + + /** + * Convert token to query string. + * + * @return string + */ + public function __toString() + { + if ($this->isGroup()) { + $length = count($this->value); + $result = []; + + for ($i = 0; $i < $length; ++$i) { + $result[] = (string) $this->value[$i]; + if ($i < $length - 1) { + $result[] = $this->groupOperator->getValue(); + } + } + + $result = '('. implode(' ', $result) .')'; + } else { + // + // Make proper handling for negative group too. + // + $result = ''; + if ($this->isNegativeGroup()) { + $result = 'NOT '; + } + $result .= (string) $this->value; + } + + return $result; + } + + /** + * Create token instance from doctrine lexer token. + * + * @param array|null $token Token from abstract doctrine lexer. + * + * @return Token + */ + public static function fromLexerToken($token) + { + if ($token === null) { + return self::nullToken(); + } + + return new Token($token['type'], $token['value']); + } + + /** + * Named constructor for token with 'null' type. + * + * @return Token + */ + public static function nullToken() + { + return new Token(self::TYPE_NULL); + } + + /** + * Named constructor for token with 'group' type. + * + * @param array $tokens Array of tokens instances. + * @param Token $operator Group operator. + * + * @return Token + */ + public static function groupToken(array $tokens, Token $operator) + { + $token = new Token(self::TYPE_GROUP, $tokens); + $token->setGroupOperator($operator); + + return $token; + } + + /** + * Named constructor for token with 'negative group' type. + * + * @param Token $token Some token. + * + * @return Token + */ + public static function negativeGroupToken(Token $token) + { + if ($token->isNegativeGroup()) { + /** @var Token $innerToken */ + $innerToken = $token->getValue(); + return new Token( + $innerToken->getType(), + $innerToken->getValue() + ); + } + + return new Token(self::TYPE_NEGATIVE_GROUP, $token); + } + + /** + * @return boolean True if current token has 'word' type. + */ + public function isWord() + { + return $this->type === self::TYPE_WORD; + } + + /** + * @return boolean True if current token has 'and' type. + */ + public function isAnd() + { + return $this->type === self::TYPE_AND; + } + + /** + * @return boolean True if current token has 'or' type. + */ + public function isOr() + { + return $this->type === self::TYPE_OR; + } + + /** + * @return boolean True if current token has 'not' type. + */ + public function isNot() + { + return $this->type === self::TYPE_NOT; + } + + /** + * @return boolean True if current token has 'and', 'or' ot 'not' type. + */ + public function isOperator() + { + return $this->isAnd() || $this->isOr() || $this->isNot(); + } + + /** + * @return boolean True if current token has 'and' or 'or' type. + */ + public function isBinaryOperator() + { + return $this->isAnd() || $this->isOr(); + } + + /** + * @return boolean True if current token has 'open bracket' type. + */ + public function isOpenBracket() + { + return $this->type === self::TYPE_OPEN_BRACKET; + } + + /** + * @return boolean True if current token has 'close bracket' type. + */ + public function isCloseBracket() + { + return $this->type === self::TYPE_CLOSE_BRACKET; + } + + /** + * @return boolean True if current token has 'group' type. + */ + public function isGroup() + { + return $this->type === self::TYPE_GROUP; + } + + /** + * @return boolean True if current token has 'group' type. + */ + public function isNegativeGroup() + { + return $this->type === self::TYPE_NEGATIVE_GROUP; + } + + /** + * @return boolean True if current token has 'null' type. + */ + public function isNull() + { + return $this->type === self::TYPE_NULL; + } + + /** + * @return Token[]|null|string + */ + public function getValue() + { + return $this->value; + } + + /** + * @return integer + */ + public function getType() + { + return $this->type; + } + + /** + * Get priority of current token. + * + * @return integer + */ + public function getPriority() + { + return self::$operationPriorities[$this->type]; + } + + /** + * Set operator used in group. + * + * @param Token $operator A operator token. + * + * @return Token + */ + public function setGroupOperator(Token $operator) + { + $this->groupOperator = $operator; + + return $this; + } + + /** + * Get operator used in group. + * + * @return Token + */ + public function getGroupOperator() + { + return $this->groupOperator; + } + + /** + * Checks that current token has same type as specified token. + * + * @param Token $token A Token instance. + * + * @return boolean + */ + public function isSameType(Token $token) + { + return $this->type === $token->type; + } + + /** + * @param boolean $normalized Flag, if set this token already normalized. + * + * @return Token + */ + public function setNormalized($normalized) + { + $this->normalized = $normalized; + + return $this; + } + + /** + * @return boolean + */ + public function isNormalized() + { + return $this->normalized; + } +} diff --git a/src/IndexBundle/Resources/config/indices.yml b/src/IndexBundle/Resources/config/indices.yml new file mode 100644 index 0000000..7b51df6 --- /dev/null +++ b/src/IndexBundle/Resources/config/indices.yml @@ -0,0 +1,52 @@ +# +# Define all possible indices. +# +# Used indices determines based on current application environment. +# see indices_*.yml files in the same directory. +# + +services: + # + # All possible external indices. + # + index.external.hose: + class: 'IndexBundle\Index\External\InternalHoseIndex' + arguments: + - '@app.cache' + - '%internal_hose.host%' + - '%internal_hose.port%' + - '%internal_hose.index%' + - '%internal_hose.type%' + + index.external.internal_hose: + class: 'IndexBundle\Index\External\InternalHoseIndex' + arguments: + - '@app.cache' + - '%internal_hose.host%' + - '%internal_hose.port%' + - '%internal_hose.index%' + - '%internal_hose.type%' + + # + # All possible internal articles indices + # + + index.articles.elasticsearch: + class: 'IndexBundle\Index\Internal\InternalIndex' + arguments: + - '%cache_index.host%' + - '%cache_index.port%' + - '%cache_index.index%' + - '%cache_index.type%' + + # + # All possible internal sources indices + # + + index.sources.elasticsearch: + class: 'IndexBundle\Index\Source\SourceIndex' + arguments: + - '%cache_index.host%' + - '%cache_index.port%' + - '%source_index.index%' + - '%source_index.type%' diff --git a/src/IndexBundle/Resources/config/indices_dev.yml b/src/IndexBundle/Resources/config/indices_dev.yml new file mode 100644 index 0000000..035f66f --- /dev/null +++ b/src/IndexBundle/Resources/config/indices_dev.yml @@ -0,0 +1,7 @@ +# +# Define used indices for development environment +# +services: + index.external: '@index.external.internal_hose' + index.articles: '@index.articles.elasticsearch' + index.sources: '@index.sources.elasticsearch' diff --git a/src/IndexBundle/Resources/config/indices_prod.yml b/src/IndexBundle/Resources/config/indices_prod.yml new file mode 100644 index 0000000..b753d9a --- /dev/null +++ b/src/IndexBundle/Resources/config/indices_prod.yml @@ -0,0 +1,7 @@ +# +# Define used indices for production environment +# +services: + index.external: '@index.external.hose' + index.articles: '@index.articles.elasticsearch' + index.sources: '@index.sources.elasticsearch' diff --git a/src/IndexBundle/Resources/config/indices_stage.yml b/src/IndexBundle/Resources/config/indices_stage.yml new file mode 100644 index 0000000..b543323 --- /dev/null +++ b/src/IndexBundle/Resources/config/indices_stage.yml @@ -0,0 +1,5 @@ +# +# Define used indices for staging environment +# +imports: + - { resource: 'indices_prod.yml' } \ No newline at end of file diff --git a/src/IndexBundle/Resources/config/indices_test.yml b/src/IndexBundle/Resources/config/indices_test.yml new file mode 100644 index 0000000..f94ab06 --- /dev/null +++ b/src/IndexBundle/Resources/config/indices_test.yml @@ -0,0 +1,7 @@ +# +# Define used indices for testing environment +# +services: + index.external: '@index.external.internal_hose' + index.articles: '@index.articles.elasticsearch' + index.sources: '@index.sources.elasticsearch' \ No newline at end of file diff --git a/src/IndexBundle/SearchRequest/ImmutableSearchRequestInterface.php b/src/IndexBundle/SearchRequest/ImmutableSearchRequestInterface.php new file mode 100644 index 0000000..1cffe2c --- /dev/null +++ b/src/IndexBundle/SearchRequest/ImmutableSearchRequestInterface.php @@ -0,0 +1,98 @@ +normalizer = $normalizer; + $this->builder = $builder; + } + + /** + * @return IndexInterface + */ + public function getIndex() + { + return $this->builder->getIndex(); + } + + /** + * Return filters. + * + * @return \IndexBundle\Filter\FilterInterface[] + */ + public function getFilters() + { + return $this->builder->getFilters(); + } + + /** + * Get fetched source fields names. + * + * @return string[] + */ + public function getSources() + { + return $this->builder->getSources(); + } + + /** + * Return aggregation + * + * @return AggregationInterface[] + */ + public function getAggregation() + { + return $this->builder->getAggregation(); + } + + /** + * Get user who made this search request. + * + * @return User + */ + public function getUser() + { + return $this->builder->getUser(); + } + + /** + * Get fields names. + * + * @return string[] + */ + public function getFields() + { + return $this->builder->getFields(); + } + + /** + * Get sorting fields. + * + * @return array Where key is field name and value is sorting direction. + */ + public function getSorts() + { + return $this->builder->getSorts(); + } + + /** + * Get raw search query. + * + * @return string + */ + public function getQuery() + { + return $this->builder->getQuery(); + } + + /** + * Get normalized query. + * + * @return string + */ + public function getNormalizedQuery() + { + if ($this->normalizedQuery === null) { + $this->normalizedQuery = $this->normalizer + ->normalize($this->getQuery()); + } + + return $this->normalizedQuery; + } + + /** + * Set requested page number. + * + * @param integer $page Page number, start from 1. + * + * @return SearchRequest + */ + public function setPage($page) + { + // + // Because of page changing affects on set of documents that we get from + // index we should remove cached response. But it not affects on total + // count and advanced filters. + // + $this->response = null; + $this->builder->setPage($page); + + return $this; + } + + /** + * Get requested page number. + * + * @return integer + */ + public function getPage() + { + return $this->builder->getPage(); + } + + /** + * Set limit of documents per page. + * + * @param integer $limit Max document per page. + * + * @return SearchRequest + */ + public function setLimit($limit) + { + // + // Because of page changing affects on set of documents that we get from + // index we should remove cached response. But it not affects on total + // count and advanced filters. + // + $this->response = null; + $this->builder->setLimit($limit); + + return $this; + } + + /** + * Get limit of documents per page. + * + * @return integer + */ + public function getLimit() + { + return $this->builder->getLimit(); + } + + /** + * Compute this response hash. + * + * @return string + */ + public function getHash() + { + if (! $this->hash) { + $this->hash = md5( + $this->getNormalizedQuery() + . serialize($this->getFields()) + . serialize($this->getFilters()) + ); + } + + return $this->hash; + } + + /** + * Execute this search request and get response from server. + * + * @return SearchResponseInterface + */ + public function execute() + { + if ($this->response === null) { + $this->response = $this->getIndex()->search($this); + // + // Also in response we get total count, so we may use this value and + // reduce request count. + // + $this->count = $this->response->getTotalCount(); + } + + return $this->response; + } + + /** + * Get available advanced filters for this request. + * + * @return array + */ + public function getAvailableAdvancedFilters() + { + if ($this->aggregationRatings === null) { + $this->aggregationRatings = $this->getIndex() + ->getAFResolver() + ->getAvailables($this); + } + + return $this->aggregationRatings; + } +} diff --git a/src/IndexBundle/SearchRequest/SearchRequestBuilder.php b/src/IndexBundle/SearchRequest/SearchRequestBuilder.php new file mode 100644 index 0000000..6bfb9e0 --- /dev/null +++ b/src/IndexBundle/SearchRequest/SearchRequestBuilder.php @@ -0,0 +1,527 @@ +index = $index; + $this->normalizer = $normalizer; + } + + /** + * @param IndexInterface $index A IndexInterface + * or InternalIndexInterface + * Instance. + * @param QueryNormalizerInterface $normalizer A QueryNormalizerInterface + * instance. + * + * @return SearchRequestBuilder + */ + public static function create( + IndexInterface $index, + QueryNormalizerInterface $normalizer + ) { + // @codingStandardsIgnoreStart + return new self($index, $normalizer); + // @codingStandardsIgnoreEnd + } + + /** + * Get filter factory for this search request builder. + * + * @return FilterFactoryInterface + */ + public function getFilterFactory() + { + return $this->getIndex()->getFilterFactory(); + } + + /** + * Set filters, override already exists. + * + * @param FilterInterface|FilterInterface[] $filters A FilterInterface + * instance or array of + * FilterInterface's + * instances. + * + * @return SearchRequestBuilderInterface + */ + public function setFilters($filters) + { + if ($filters instanceof FilterInterface) { + $filters = [ $filters ]; + } + $this->filters = array_filter($filters); + + return $this; + } + + /** + * Add new filter. + * + * @param FilterInterface $filter A FilterInterface instance. + * + * @return SearchRequestBuilderInterface + */ + public function addFilter(FilterInterface $filter) + { + $this->filters[] = $filter; + + return $this; + } + + /** + * Get all filters. + * + * @return FilterInterface[] + */ + public function getFilters() + { + return $this->filters; + } + + /** + * Set aggregation + * + * @param AggregationInterface|AggregationInterface[] $aggregation A + * AggregationInterface + * instance + * or array + * of instances. + * + * @return $this + */ + public function setAggregation($aggregation) + { + if (! is_array($aggregation)) { + $aggregation = [ $aggregation ]; + } + $this->aggregation = $aggregation; + + return $this; + } + + /** + * Get aggregations. + * + * @return AggregationInterface[] + */ + public function getAggregation() + { + return $this->aggregation; + } + + /** + * Set user. + * + * @param User $user A user who made search request. + * + * @return SearchRequestBuilderInterface + */ + public function setUser(User $user = null) + { + $this->user = $user; + + return $this; + } + + /** + * Get user. + * + * @return User + */ + public function getUser() + { + return $this->user; + } + + /** + * Set raw search query. + * + * @param string $query Raw search query. + * + * @return SearchRequestBuilderInterface + */ + public function setQuery($query) + { + $this->query = $query; + + return $this; + } + + /** + * Get raw search query. + * + * @return string + */ + public function getQuery() + { + return $this->query; + } + + /** + * Set fields, override already exists. + * + * @param array $fields Fields names. + * + * @return SearchRequestBuilderInterface + */ + public function setFields(array $fields) + { + $this->fields = $fields; + + return $this; + } + + /** + * Add new field name. + * + * @param string $field Field name. + * + * @return SearchRequestBuilderInterface + */ + public function addField($field) + { + $this->fields[] = $field; + + return $this; + } + + /** + * Set fetched source fields names. + * + * @param string[]|array $sources Array of fetched source fields. Fetch all if + * empty. + * + * @return SearchRequestBuilderInterface + */ + public function setSources(array $sources) + { + $this->sources = $sources; + + return $this; + } + + /** + * Get fetched source fields names. + * + * @return string[] + */ + public function getSources() + { + return $this->sources; + } + + /** + * Set sort field name. + * + * @param string $fieldName Field name. + * @param string $direction Sorting direction, must be 'asc' or 'desc'. + * + * @return SearchRequestBuilderInterface + */ + public function addSort($fieldName, $direction = 'asc') + { + $this->sortFields[$fieldName] = $direction; + + return $this; + } + + /** + * Set new sorting fields. + * + * @param array $sortFields Assoc array where key is field name and value is + * sorting direction. + * + * @return SearchRequestBuilderInterface + */ + public function setSorts(array $sortFields) + { + $this->sortFields = $sortFields; + + return $this; + } + + /** + * Get sorting fields. + * + * @return array Where key is field name and value is sorting direction. + */ + public function getSorts() + { + return $this->sortFields; + } + + /** + * Get fields names. + * + * @return array + */ + public function getFields() + { + return $this->fields; + } + + /** + * Set request page number. + * + * @param integer $page Page number, start from 1. + * + * @return SearchRequestBuilderInterface + */ + public function setPage($page) + { + $this->page = (int) $page; + + if ($this->page < 1) { + $message = 'Invalid page parameter, must be greater or equal to 1'; + throw new \InvalidArgumentException($message); + } + + return $this; + } + + /** + * Get requested page number. + * + * @return integer + */ + public function getPage() + { + return $this->page; + } + + /** + * Set limit. + * + * @param integer $limit Max documents per page. + * + * @return SearchRequestBuilderInterface + */ + public function setLimit($limit) + { + if ($limit !== null) { + // + // We should make type cast and check only if we got some value. + // This condition is necessary for avoiding problems when we create + // one request builder from another and then we got $limit === 0 instead + // of null which leads to the fact that we get 0 results when we want + // to get all. + // + $limit = (int) $limit; + + if ($limit < 0) { + $message = 'Invalid limit parameter, must be greater or equal to 0'; + throw new \InvalidArgumentException($message); + } + } + + $this->limit = $limit; + + return $this; + } + + /** + * Get max documents per page. + * + * @return integer + */ + public function getLimit() + { + return $this->limit; + } + + /** + * Initialize builder parameters from specified search request. + * + * @param SearchRequestInterface $request A SearchRequestInterface instance. + * + * @return SearchRequestBuilderInterface + */ + public function fromSearchRequest(SearchRequestInterface $request) + { + return $this + ->setQuery($request->getQuery()) + ->setFields($request->getFields()) + ->setFilters($request->getFilters()) + ->setAggregation($request->getAggregation()) + ->setUser($request->getUser()) + ->setSorts($request->getSorts()) + ->setLimit($request->getLimit()) + ->setPage($request->getPage()); + } + + /** + * Initialize builder parameters from specified search request. + * + * @param SearchRequestBuilderInterface $builder A SearchRequestBuilderInterface instance. + * + * @return SearchRequestBuilderInterface + */ + public function fromSearchRequestBuilder(SearchRequestBuilderInterface $builder) + { + return $this->fromSearchRequest($builder->build()); + } + + /** + * Initialize builder parameters from specified query entity. + * + * @param AbstractQuery $query A AbstractQuery entity instance. + * + * @return SearchRequestBuilderInterface + */ + public function fromQueryEntity(AbstractQuery $query) + { + return $this + ->setQuery($query->getRaw()) + ->setFields($query->getFields()) + ->setFilters($query->getFilters()); + } + + /** + * @return IndexInterface + */ + public function getIndex() + { + return $this->index; + } + + /** + * Build search request. + * + * @return SearchRequest + */ + public function build() + { + // + // Remove duplicate fields and sort they. + // + $fields = array_unique($this->getFields()); + sort($fields); + $this + ->setFilters($this->normalizeFilters($this->getFilters())) + ->setFields($fields); + + return new SearchRequest($this->normalizer, clone $this); + } + + /** + * Remove empty filters group from filters. + * + * @param array $filters Array of filters. + * + * @return array + */ + private function normalizeFilters(array $filters) + { + $normalizedFilters = []; + + foreach ($filters as $filter) { + if ($filter instanceof GroupFilterInterface) { + $filter->setFilters($this->normalizeFilters($filter->getFilters())); + if (count($filter) === 0) { + continue; + } + } + + $normalizedFilters[] = $filter; + } + + return $normalizedFilters; + } +} diff --git a/src/IndexBundle/SearchRequest/SearchRequestBuilderInterface.php b/src/IndexBundle/SearchRequest/SearchRequestBuilderInterface.php new file mode 100644 index 0000000..03def8f --- /dev/null +++ b/src/IndexBundle/SearchRequest/SearchRequestBuilderInterface.php @@ -0,0 +1,164 @@ + $fields Fields names. + * + * @return SearchRequestBuilderInterface + */ + public function setFields(array $fields); + + /** + * Set fetched source fields names. + * + * @param string[]|array $sources Array of fetched source fields. Fetch all if + * empty. + * + * @return SearchRequestBuilderInterface + */ + public function setSources(array $sources); + + /** + * Add new field name. + * + * @param string $field Field name. + * + * @return SearchRequestBuilderInterface + */ + public function addField($field); + + /** + * Add sorting by specified field. + * + * @param string $fieldName Field name. + * @param string $direction Sorting direction, must be 'asc' or 'desc'. + * + * @return SearchRequestBuilderInterface + */ + public function addSort($fieldName, $direction = 'asc'); + + /** + * Set new sorting fields. + * + * @param array $sortFields Assoc array where key is field name and value is + * sorting direction. + * + * @return SearchRequestBuilderInterface + */ + public function setSorts(array $sortFields); + + /** + * Build search request. + * + * @return SearchRequestInterface + */ + public function build(); + + /** + * Initialize builder parameters from specified search request. + * + * @param SearchRequestInterface $request A SearchRequestInterface instance. + * + * @return SearchRequestBuilderInterface + */ + public function fromSearchRequest(SearchRequestInterface $request); + + /** + * Initialize builder parameters from specified search request. + * + * @param SearchRequestBuilderInterface $builder A SearchRequestBuilderInterface instance. + * + * @return SearchRequestBuilderInterface + */ + public function fromSearchRequestBuilder(SearchRequestBuilderInterface $builder); + + /** + * Initialize builder parameters from specified query entity. + * + * @param AbstractQuery $query A AbstractQuery entity instance. + * + * @return SearchRequestBuilderInterface + */ + public function fromQueryEntity(AbstractQuery $query); +} diff --git a/src/IndexBundle/SearchRequest/SearchRequestInterface.php b/src/IndexBundle/SearchRequest/SearchRequestInterface.php new file mode 100644 index 0000000..0e2ffee --- /dev/null +++ b/src/IndexBundle/SearchRequest/SearchRequestInterface.php @@ -0,0 +1,49 @@ +index = $index; + } + + /** + * @param IndexInterface $index A IndexInterface instance. + * + * @return void + */ + public static function initialize(IndexInterface $index) + { + // @codingStandardsIgnoreStart + // phpcs says to us that we don't use parentheses when instantiating classes + // But we do it. + $instance = new static($index); + // @codingStandardsIgnoreEnd + $instance->initializeIndex(); + } +} diff --git a/src/IndexBundle/Util/Initializer/ExternalIndexInitializer.php b/src/IndexBundle/Util/Initializer/ExternalIndexInitializer.php new file mode 100644 index 0000000..f6c1f3c --- /dev/null +++ b/src/IndexBundle/Util/Initializer/ExternalIndexInitializer.php @@ -0,0 +1,42 @@ +index instanceof HoseIndex) && ($this->index instanceof InternalIndexInterface)) { + $path = __DIR__ . '/../../../../hose_external_schema.json'; + $config = json_decode(file_get_contents($path), true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException( + 'External index setup: ' . json_last_error_msg() + ); + } + + $this->index->createIndex( + $config['mappings']['simple_document']['properties'], + $config['settings'] + ); + } else { + throw new \LogicException(sprintf( + 'Can\'t initialize external index for \'%s\'', + get_class($this->index) + )); + } + } +} diff --git a/src/IndexBundle/Util/Initializer/IndexInitializerInterface.php b/src/IndexBundle/Util/Initializer/IndexInitializerInterface.php new file mode 100644 index 0000000..0df1898 --- /dev/null +++ b/src/IndexBundle/Util/Initializer/IndexInitializerInterface.php @@ -0,0 +1,21 @@ +index instanceof InternalIndexInterface) { + $this->index->createIndex([ + // + // Application specific. + // + FieldNameEnum::PLATFORM => ['type' => 'keyword'], + FieldNameEnum::COLLECTION_ID => ['type' => 'long'], + FieldNameEnum::COLLECTION_TYPE => ['type' => 'keyword'], + FieldNameEnum::DELETE_FROM => ['type' => 'long'], + // + // hose specific. + // + 'sequence' => ['type' => 'long'], + 'date_found' => ['type' => 'date'], + // Start hose source_* + 'source_hashcode' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + ], + 'source_link' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + ], + 'source_publisher_type' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + ], + 'source_publisher_subtype' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + ], + 'source_spam_probability' => ['type' => 'float'], + 'source_title' => [ + 'type' => 'string', + 'fields' => [ + 'raw' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + ], + ], + ], + 'source_favorites' => ['type' => 'integer'], + 'source_followers' => ['type' => 'integer'], + 'source_following' => ['type' => 'integer'], + 'source_verified' => ['type' => 'boolean'], + 'source_profiles' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + ], + 'source_tags' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + ], + 'source_likes' => ['type' => 'integer'], + 'source_location' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + ], + // End hose source_* + 'permalink' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + ], + 'main' => ['type' => 'string'], + 'title' => ['type' => 'string'], + 'publisher' => [ + 'type' => 'string', + 'norms' => false, + 'fields' => [ + 'raw' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + ], + ], + ], + 'mentions' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + ], + 'section' => [ + 'type' => 'string', + 'norms' => false, + 'fields' => [ + 'raw' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + ], + ], + ], + 'tags' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + ], + 'published' => ['type' => 'date'], + 'author_name' => [ + 'type' => 'string', + 'fields' => [ + 'raw' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + ], + ], + ], + 'author_link' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + ], + 'author_gender' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + ], + 'geo_country' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + 'fielddata' => true, + ], + 'geo_state' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + ], + 'geo_city' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + ], + 'image_src' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + ], + 'sentiment' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + ], + 'lang' => [ + 'type' => 'string', + 'index' => 'not_analyzed', + 'norms' => false, + ], + 'categories' => ['type' => 'object'], + 'duplicates_count' => ['type' => 'integer'], + 'likes' => ['type' => 'integer'], + 'dislikes' => ['type' => 'integer'], + 'comments' => ['type' => 'integer'], + 'shares' => ['type' => 'integer'], + 'views' => ['type' => 'integer'], + ], [ + 'number_of_shards' => 4, + ]); + } else { + throw new \LogicException('Can\'t initialize internal connection for '. get_class($this->index)); + } + } +} diff --git a/src/IndexBundle/Util/Initializer/SourceIndexInitializer.php b/src/IndexBundle/Util/Initializer/SourceIndexInitializer.php new file mode 100644 index 0000000..3e8e3c9 --- /dev/null +++ b/src/IndexBundle/Util/Initializer/SourceIndexInitializer.php @@ -0,0 +1,69 @@ +index instanceof SourceIndexInterface) { + $this->index->createIndex([ + 'title' => [ + 'type' => 'text', + 'fields' => ['raw' => ['type' => 'keyword']], + ], + 'url' => [ + 'type' => 'text', + 'fields' => ['raw' => ['type' => 'keyword']], + ], + 'country' => [ + 'type' => 'keyword', + 'norms' => false, + ], + 'city' => [ + 'type' => 'keyword', + 'norms' => false, + ], + 'state' => [ + 'type' => 'keyword', + 'norms' => false, + ], + 'section' => [ + 'type' => 'keyword', + 'norms' => false, + ], + 'lang' => [ + 'type' => 'keyword', + 'norms' => false, + ], + 'deleted' => ['type' => 'boolean'], + 'type' => [ + 'type' => 'keyword', + 'norms' => false, + ], + 'source_publisher_type' => [ + 'type' => 'keyword', + 'norms' => false, + ], + 'listIds' => ['type' => 'integer'], + ], [ + 'number_of_shards' => 4, + 'index.store.type' => 'mmapfs', + ]); + } else { + throw new \LogicException('Can\'t initialize source index for '. get_class($this->index)); + } + } +} diff --git a/src/PayPal/ApiContextFactory.php b/src/PayPal/ApiContextFactory.php new file mode 100644 index 0000000..71e4ffc --- /dev/null +++ b/src/PayPal/ApiContextFactory.php @@ -0,0 +1,58 @@ +logger = $logger; + } + + /** + * @param string $clientId PayPal application client id. + * @param string $secret PayPal application secret. + * @param string $mode PayPal mode. + * + * @return ApiContext + */ + public function generate($clientId, $secret, $mode) + { + $context = new ApiContext(new OAuthTokenCredential($clientId, $secret)); + + $config = [ 'mode' => $mode ]; + if ($mode === 'sandbox') { + MonologPayPalLogFactory::$logger = $this->logger; + + $config['log.LogEnabled'] = true; + $config['log.FileName'] = 'PayPal.log'; + $config['log.LogLevel'] = 'DEBUG'; + $config['log.AdapterFactory'] = MonologPayPalLogFactory::class; + } + + $context->setConfig($config); + + return $context; + } +} diff --git a/src/PayPal/MonologPayPalLogFactory.php b/src/PayPal/MonologPayPalLogFactory.php new file mode 100644 index 0000000..8fa290c --- /dev/null +++ b/src/PayPal/MonologPayPalLogFactory.php @@ -0,0 +1,39 @@ +em = $em; + } + + /** + * @param AbstractSubscription $subscription A AbstractSubscription entity + * instance. + * + * @return void + */ + public function removeAgreement(AbstractSubscription $subscription) + { + $this->em->getRepository(Agreement::class) + ->createQueryBuilder('Agreement') + ->delete() + ->where('Agreement.subscription = :subscription') + ->setParameter('subscription', $subscription->getId()) + ->getQuery() + ->execute(); + } + + /** + * @param AbstractSubscription $subscription A AbstractSubscription instance. + * + * @return string + */ + public function getAgreementId(AbstractSubscription $subscription) + { + $id = $this->em->getRepository(Agreement::class) + ->createQueryBuilder('Agreement') + ->select('Agreement.agreementId') + ->where('Agreement.subscription = :subscription AND Agreement.gateway = :gateway') + ->setParameter('subscription', $subscription->getId()) + ->setParameter('gateway', $subscription->getGateway()->getValue()) + ->getQuery() + ->getOneOrNullResult(); + + if ($id === null) { + return ''; + } + + return current($id); + } + + /** + * @param PaymentGatewayEnum $gateway A used payment gateway. + * @param string $agreementId Gateway specific agreement id. + * + * @return AbstractSubscription|null + */ + public function getSubscription(PaymentGatewayEnum $gateway, $agreementId) + { + /** @var AgreementRepository $repository */ + $repository = $this->em->getRepository(Agreement::class); + + $agreement = $repository->findByPlatformId($gateway, $agreementId); + return $agreement === null ? null : $agreement->getSubscription(); + } + + /** + * @param AbstractSubscription $subscription User for whom we should store + * agreement. + * @param string $agreementId Gateway specific agreement id. + * + * @return void + */ + public function storeAgreement(AbstractSubscription $subscription, $agreementId) + { + $agreement = Agreement::create() + ->setGateway($subscription->getGateway()) + ->setSubscription($subscription) + ->setAgreementId($agreementId); + + $this->em->persist($agreement); + $this->em->flush(); + } +} diff --git a/src/PaymentBundle/Command/BillingPlanCreateCommand.php b/src/PaymentBundle/Command/BillingPlanCreateCommand.php new file mode 100644 index 0000000..df0de58 --- /dev/null +++ b/src/PaymentBundle/Command/BillingPlanCreateCommand.php @@ -0,0 +1,141 @@ +apiContext = $apiContext; + } + + /** + * Executes the current command. + * + * This method is not abstract because you can use this class + * as a concrete class. In this case, instead of defining the + * execute() method, you set the code to execute by passing + * a Closure to the setCode() method. + * + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer null or 0 if everything went fine, or an error code. + * + * @see setCode() + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @throws \Exception Got any exception while update plans. + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $options = [ + [ + 'name' => 'starter', + 'description' => 'Starter Plan', + 'amount' => 59 + ], + [ + 'name' => 'premium', + 'description' => 'Premium Plan', + 'amount' => 149 + ], + ]; + foreach ($options as $option) { + $this->createPlan($option); + } + return 0; + } + + private function createPlan(array $options) + { + // Create a new billing plan + $plan = new Plan(); + $plan->setName($options['name']) + ->setDescription($options['description']) + ->setType('INFINITE'); + + // Set billing plan definitions + $paymentDefinition = new PaymentDefinition(); + $paymentDefinition->setName('Regular Payments') + ->setType('REGULAR') + ->setFrequency('Month') + ->setFrequencyInterval('1') + ->setAmount(new Currency(array('value' => $options['amount'], 'currency' => 'USD'))); + + // Set charge models + $paymentDefinition->setChargeModels([]); + + // Set merchant preferences + $merchantPreferences = new MerchantPreferences(); + $merchantPreferences->setReturnUrl('http://socialhose.local/auth/register-finish') + ->setCancelUrl('http://socialhose.local/auth/register-finish') + ->setAutoBillAmount('yes') + ->setInitialFailAmountAction('CONTINUE') + ->setMaxFailAttempts('0'); + $plan->setPaymentDefinitions(array($paymentDefinition)); + $plan->setMerchantPreferences($merchantPreferences); + + //create plan + try { + $createdPlan = $plan->create($this->apiContext); + + try { + $patch = new Patch(); + $value = new PayPalModel('{"state":"ACTIVE"}'); + $patch->setOp('replace') + ->setPath('/') + ->setValue($value); + $patchRequest = new PatchRequest(); + $patchRequest->addPatch($patch); + $createdPlan->update($patchRequest, $this->apiContext); + $plan = Plan::get($createdPlan->getId(), $this->apiContext); + + // Output plan id + echo 'New: ' . $plan->getId() . PHP_EOL; + } catch (PayPalConnectionException $ex) { + echo $ex->getCode(); + echo $ex->getData(); + die($ex); + } catch (\Exception $ex) { + die($ex); + } + } catch (PayPalConnectionException $ex) { + echo $ex->getCode(); + echo $ex->getData(); + die($ex); + } catch (\Exception $ex) { + die($ex); + } + + } +} \ No newline at end of file diff --git a/src/PaymentBundle/Command/BillingPlanDeleteCommand.php b/src/PaymentBundle/Command/BillingPlanDeleteCommand.php new file mode 100644 index 0000000..c68d5f8 --- /dev/null +++ b/src/PaymentBundle/Command/BillingPlanDeleteCommand.php @@ -0,0 +1,85 @@ +apiContext = $apiContext; + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure() + { + $this + ->setName(self::NAME) + ->setDescription('Delete all plans.') + ->addOption('force', null, InputOption::VALUE_NONE); + } + + /** + * Executes the current command. + * + * This method is not abstract because you can use this class + * as a concrete class. In this case, instead of defining the + * execute() method, you set the code to execute by passing + * a Closure to the setCode() method. + * + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer null or 0 if everything went fine, or an error code. + * + * @see setCode() + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @throws \Exception Got any exception while update plans. + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + if (! $input->getOption('force')) { + $message = 'Provide --force option if you really want to remove plans.'; + $output->writeln($message); + return 0; + } + + $availablePayPalPlans = Plan::all([ + 'page_size' => 10, + 'status' => 'ACTIVE', + ], $this->apiContext)->getPlans(); + + foreach ($availablePayPalPlans as $plan) { + $plan->delete($this->apiContext); + } + + echo 'Mission completed'; + return 0; + } +} diff --git a/src/PaymentBundle/Command/BillingPlanListCommand.php b/src/PaymentBundle/Command/BillingPlanListCommand.php new file mode 100644 index 0000000..84a24fa --- /dev/null +++ b/src/PaymentBundle/Command/BillingPlanListCommand.php @@ -0,0 +1,69 @@ +apiContext = $apiContext; + } + + /** + * Executes the current command. + * + * This method is not abstract because you can use this class + * as a concrete class. In this case, instead of defining the + * execute() method, you set the code to execute by passing + * a Closure to the setCode() method. + * + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer null or 0 if everything went fine, or an error code. + * + * @see setCode() + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @throws \Exception Got any exception while update plans. + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $availablePayPalPlans = Plan::all( + ['page_size' => 10, 'status' => 'ACTIVE'], + $this->apiContext + )->getPlans(); + + if ($availablePayPalPlans === null) { + echo 'Empty' . PHP_EOL;; + return 0; + } + + foreach ($availablePayPalPlans as $plan) { + echo 'All: ' . $plan->getId() . PHP_EOL; + } + return 0; + } +} diff --git a/src/PaymentBundle/Command/BillingPlanSyncCommand.php b/src/PaymentBundle/Command/BillingPlanSyncCommand.php new file mode 100644 index 0000000..b4b5027 --- /dev/null +++ b/src/PaymentBundle/Command/BillingPlanSyncCommand.php @@ -0,0 +1,93 @@ +planRepository = $planRepository; + $this->gatewayFactory = $gatewayFactory; + } + + /** + * Executes the current command. + * + * This method is not abstract because you can use this class + * as a concrete class. In this case, instead of defining the + * execute() method, you set the code to execute by passing + * a Closure to the setCode() method. + * + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer null or 0 if everything went fine, or an error code. + * + * @see setCode() + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @throws \Exception Got any exception while update plans. + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + /** @var Plan[] $plans */ + $plans = $this->planRepository->findAll(); + + foreach (PaymentGatewayEnum::getAvailables() as $name) { + $gateway = $this->gatewayFactory->getGateway(new PaymentGatewayEnum($name)); + + $output->writeln("Sync plan with {$name} payment gateway"); + foreach ($plans as $plan) { + $output->write("\tPlan {$plan->getInnerName()} ... "); + try { + $gateway->updatePlan($plan); + $output->writeln('[ OK ]'); + } catch (\Exception $e) { + $output->writeln('[ ERROR ]'); + throw $e; + } + } + } + + return 0; + } +} diff --git a/src/PaymentBundle/Controller/IpnController.php b/src/PaymentBundle/Controller/IpnController.php new file mode 100644 index 0000000..3eceb44 --- /dev/null +++ b/src/PaymentBundle/Controller/IpnController.php @@ -0,0 +1,82 @@ +get(PaymentBundleServices::PAYMENT_GATEWAY_FACTORY); + /** @var AgreementManagerInterface $agreementManager */ + $agreementManager = $this->get(PaymentBundleServices::AGREEMENT_MANAGER); + $em = $this->getDoctrine()->getManager(); + /** @var LoggerInterface $logger */ + $logger = $this->get('monolog.logger.payment_api'); + + $logger->info('Got payment notification from '. $gateway .'. Content: '. $request->getContent()); + if (! PaymentGatewayEnum::isValid($gateway)) { + $logger->error('Unknown gateway: '. $gateway); + throw Response::create(null, 404); + } + + $gatewayEnum = new PaymentGatewayEnum($gateway); + $paymentGateway = $factory->getGateway($gatewayEnum); + $notification = $paymentGateway->processNotification($request); + + $logger->info('Notification processed successfully'); + $subscription = $agreementManager->getSubscription($gatewayEnum, $notification->getAgreementId()); + if ($subscription === null) { + // + // Because we don't want to get invalid notification again. + // + $logger->error('Can\'t find proper billing subscription.'); + return new Response(); + } + + $logger->info('Store payment information'); + $payment = Payment::create() + ->setAmount($notification->getAmount()) + ->setStatus($notification->getStatus()) + ->setSubscription($subscription) + ->setTransactionId($notification->getTransactionId()) + ->setGateway($gatewayEnum); + + $subscription + ->setPayed($notification->getStatus()->is(PaymentStatusEnum::success())); + + $em->persist($subscription); + $em->persist($payment); + $em->flush(); + + return new Response(); + } +} diff --git a/src/PaymentBundle/Doctrine/DBAL/Types/PaymentGatewayEnumType.php b/src/PaymentBundle/Doctrine/DBAL/Types/PaymentGatewayEnumType.php new file mode 100644 index 0000000..4d62ae9 --- /dev/null +++ b/src/PaymentBundle/Doctrine/DBAL/Types/PaymentGatewayEnumType.php @@ -0,0 +1,35 @@ +gateway; + } + + /** + * @param PaymentGatewayEnum $gateway A Used payment gateway. + * + * @return Agreement + */ + public function setGateway(PaymentGatewayEnum $gateway) + { + $this->gateway = $gateway; + + return $this; + } + + /** + * @return AbstractSubscription + */ + public function getSubscription() + { + return $this->subscription; + } + + /** + * @param AbstractSubscription $subscription A AbstractSubscription entity instance. + * + * @return Agreement + */ + public function setSubscription(AbstractSubscription $subscription) + { + $this->subscription = $subscription; + + return $this; + } + + /** + * @return string + */ + public function getAgreementId() + { + return $this->agreementId; + } + + /** + * @param string $agreementId Gateway specific agreement id. + * + * @return Agreement + */ + public function setAgreementId($agreementId) + { + $this->agreementId = $agreementId; + + return $this; + } +} diff --git a/src/PaymentBundle/Entity/Model/Money.php b/src/PaymentBundle/Entity/Model/Money.php new file mode 100644 index 0000000..5f7f8cb --- /dev/null +++ b/src/PaymentBundle/Entity/Model/Money.php @@ -0,0 +1,73 @@ +getCurrencyNames()); + if (! in_array($currency, $currencies, true)) { + throw new \InvalidArgumentException('Unknown currency: \''. $currency .'\''); + } + + $this->amount = round($amount, 2); + $this->currency = $currency; + } + + /** + * Get money amount. + * + * @return float + */ + public function getAmount() + { + return $this->amount; + } + + /** + * Get money currency. + * + * @return string + */ + public function getCurrency() + { + return $this->currency; + } +} diff --git a/src/PaymentBundle/Entity/Payment.php b/src/PaymentBundle/Entity/Payment.php new file mode 100644 index 0000000..076b907 --- /dev/null +++ b/src/PaymentBundle/Entity/Payment.php @@ -0,0 +1,202 @@ +createdAt = new \DateTime(); + } + + /** + * @return AbstractSubscription + */ + public function getSubscription() + { + return $this->subscription; + } + + /** + * @param AbstractSubscription $subscription A AbstractSubscription instance. + * + * @return Payment + */ + public function setSubscription(AbstractSubscription $subscription = null) + { + $this->subscription = $subscription; + + return $this; + } + + /** + * @return PaymentGatewayEnum + */ + public function getGateway() + { + return $this->gateway; + } + + /** + * @param PaymentGatewayEnum $gateway A PaymentGatewayEnum instance. + * + * @return Payment + */ + public function setGateway(PaymentGatewayEnum $gateway) + { + $this->gateway = $gateway; + + return $this; + } + + /** + * @return string + */ + public function getTransactionId() + { + return $this->transactionId; + } + + /** + * @param string $transactionId Payment gateway specific transaction id. + * + * @return Payment + */ + public function setTransactionId($transactionId) + { + $this->transactionId = $transactionId; + + return $this; + } + + /** + * @return Money + */ + public function getAmount() + { + return $this->amount; + } + + /** + * @param Money $amount A Money instance. + * + * @return Payment + */ + public function setAmount(Money $amount) + { + $this->amount = $amount; + + return $this; + } + + /** + * @return \DateTime + */ + public function getCreatedAt() + { + return $this->createdAt; + } + + /** + * @param \DateTime $createdAt When payment was created. + * + * @return Payment + */ + public function setCreatedAt(\DateTime $createdAt) + { + $this->createdAt = $createdAt; + + return $this; + } + + /** + * @return PaymentStatusEnum + */ + public function getStatus() + { + return $this->status; + } + + /** + * @param PaymentStatusEnum $status A PaymentStatusEnum instance. + * + * @return Payment + */ + public function setStatus(PaymentStatusEnum $status) + { + $this->status = $status; + + return $this; + } +} diff --git a/src/PaymentBundle/Enum/PaymentGatewayEnum.php b/src/PaymentBundle/Enum/PaymentGatewayEnum.php new file mode 100644 index 0000000..6bde147 --- /dev/null +++ b/src/PaymentBundle/Enum/PaymentGatewayEnum.php @@ -0,0 +1,30 @@ + 'PayPal', + self::FREE => 'Free', + ]; + } +} diff --git a/src/PaymentBundle/Enum/PaymentStatusEnum.php b/src/PaymentBundle/Enum/PaymentStatusEnum.php new file mode 100644 index 0000000..5b196f9 --- /dev/null +++ b/src/PaymentBundle/Enum/PaymentStatusEnum.php @@ -0,0 +1,26 @@ +gateway = $gateway; + } + + /** + * Get proper gateway. + * + * @param PaymentGatewayEnum $gateway A required payment gateway name. + * + * @return PaymentGatewayInterface + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function getGateway(PaymentGatewayEnum $gateway) + { + return $this->gateway; + } +} diff --git a/src/PaymentBundle/Gateway/PayPalPaymentGateway.php b/src/PaymentBundle/Gateway/PayPalPaymentGateway.php new file mode 100644 index 0000000..43309c2 --- /dev/null +++ b/src/PaymentBundle/Gateway/PayPalPaymentGateway.php @@ -0,0 +1,521 @@ + PaymentStatusEnum::CANCELED, + 'Completed' => PaymentStatusEnum::SUCCESS, + 'Declined' => PaymentStatusEnum::CANCELED, + 'Expired' => PaymentStatusEnum::FAILED, + 'Failed' => PaymentStatusEnum::FAILED, + 'In-Progress' => PaymentStatusEnum::PENDING, + 'Partially_Refunded' => PaymentStatusEnum::PENDING, + 'Pending' => PaymentStatusEnum::PENDING, + 'Processed' => PaymentStatusEnum::PENDING, + 'Refunded' => PaymentStatusEnum::REFUND, + 'Reversed' => PaymentStatusEnum::PENDING, + 'Voided' => PaymentStatusEnum::FAILED, + 'Max_Failed' => PaymentStatusEnum::FAILED, + ]; + + /** + * Path to live IPN verification endpoint. + */ + const LIFE_URL = 'https://ipnpb.paypal.com/cgi-bin/webscr'; + + /** + * Path to sandbox IPN verification endpoint. + */ + const SANDBOX_URL = 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr'; + + /** + * Response from PayPal indicating validation was successful. + */ + const VALID = 'VERIFIED'; + + /** + * @var AgreementManagerInterface + */ + private $agreementManager; + + /** + * @var ApiContext + */ + private $apiContext; + + /** + * @var string + */ + private $returnUrl; + + /** + * @var string + */ + private $mode; + + /** + * @var PayPalApi\Plan[] + */ + private $availablePayPalPlans; + + /** + * PayPalPaymentGateway constructor. + * + * @param AgreementManagerInterface $agreementManager A AgreementManagerInterface + * instance. + * @param ApiContext $apiContext A PayPal api context + * instance. + * @param string $returnUrl Url on which user will + * be redirected after + * payment processing. + * @param string $mode PayPal mode. + */ + public function __construct( + AgreementManagerInterface $agreementManager, + ApiContext $apiContext, + $returnUrl, + $mode + ) { + $this->agreementManager = $agreementManager; + $this->apiContext = $apiContext; + $this->returnUrl = $returnUrl; + $this->mode = $mode; + } + + /** + * Update or create specific billing plan for specified application billing + * plan. + * + * @param Plan $plan A application Plan entity instance. + * + * @return void + */ + public function updatePlan(Plan $plan) + { + // + // Get list of available plan. + // + $payPalPlans = $this->getAvailablePlans(); + ($payPalPlans === null) && $payPalPlans = []; + + // + // Find PayPal plan with same name. + // + $payPalPlan = $this->findProperPlan($plan); + + if ($payPalPlan instanceof PayPalApi\Plan) { + // + // We got same plan already so we should remove it because PayPal + // don't give to us ability to update activated plan. + // + $this->removePlan($plan); + } + + if ($plan->isFree()) { + // + // Don't create free plan on paypal. + // + return; + } + + // + // Create new PayPal billing plan. + // + $amount = new PayPalApi\Currency([ + 'value' => (string) $plan->getPrice(), + 'currency' => 'USD', + ]); + + $payPalPlan = new PayPalApi\Plan(); + $payPalPlan + ->setName($plan->getInnerName()) + ->setDescription(ucfirst($plan->getInnerName())) + ->setType('INFINITE') + ->setMerchantPreferences(new PayPalApi\MerchantPreferences()) + ->setPaymentDefinitions([ new PayPalApi\PaymentDefinition() ]); + + $payPalPlan->getMerchantPreferences() + ->setSetupFee($amount) + ->setReturnUrl($this->returnUrl .'?accept=') + ->setCancelUrl($this->returnUrl .'?cancel=') + ->setAutoBillAmount('YES') + ->setInitialFailAmountAction('CONTINUE'); + + // Create payment definition. + $payPalPlan->getPaymentDefinitions()[0] + ->setName('Regular') + ->setType('REGULAR') + ->setFrequency('MONTH') + ->setFrequencyInterval('1') + ->setAmount($amount); + + $payPalPlan = $payPalPlan->create($this->apiContext); + + // + // By default plan is not active so we should make another request. + // https://developer.paypal.com/docs/integration/direct/billing-plans-and-agreements/#activate-a-plan + // + $patch = new PayPalApi\Patch(); + + $patch->setOp('replace') + ->setPath('/') + ->setValue([ 'state' => 'ACTIVE' ]); + $patchRequest = new PayPalApi\PatchRequest(); + $patchRequest->addPatch($patch); + + $payPalPlan->update($patchRequest, $this->apiContext); + } + + /** + * Remove specified billing plan. + * + * @param Plan $plan A removed application billing Plan entity instance. + * + * @return void + */ + public function removePlan(Plan $plan) + { + // + // Get list of available plan. + // + $payPalPlans = $this->getAvailablePlans(); + ($payPalPlans === null) && $payPalPlans = []; + + // + // Find PayPal plan with same name. + // + $payPalPlan = $this->findProperPlan($plan); + + if ($payPalPlan instanceof PayPalApi\Plan) { + // + // We got same plan already so we should remove it because PayPal + // don't give to us ability to update activated plan. + // + $payPalPlan->delete($this->apiContext); + } + } + + /** + * Execute specified subscription. + * + * @param BillingSubscription $subscription A Subscription instance. + * + * @return void + */ + public function executeSubscription(BillingSubscription $subscription) + { + $plan = $subscription->getPlan(); + $subscriptionEntity = $subscription->getSubscription(); + $creditCard = $subscription->getCreditCard(); + + if ($plan->isFree()) { + $subscriptionEntity->setPayed(true); + + return; + } + + if ($creditCard === null) { + throw new \LogicException('Subscription credit card is null'); + } + + $address = $creditCard->getAddress(); + + $fundingInstrument = new PayPalApi\FundingInstrument(); + $fundingInstrument->setCreditCard(new PayPalApi\CreditCard()); + $fundingInstrument->getCreditCard() + ->setFirstName($creditCard->getFirstName()) + ->setLastName($creditCard->getLastName()) + ->setType(strtolower($creditCard->getSchema())) + ->setNumber($creditCard->getNumber()) + ->setExpireMonth((string) $creditCard->getExpiresAt()->format('m')) + ->setExpireYear((string) $creditCard->getExpiresAt()->format('Y')) + ->setCvv2((string) $creditCard->getCvv()) + ->setBillingAddress(new PayPalApi\Address()); + + $fundingInstrument->getCreditCard()->getBillingAddress() + ->setCountryCode($address->getCountry()) + ->setCity($address->getCity()) + ->setLine1($address->getStreet()) + ->setPostalCode($address->getPostalCode()); + + $payer = new PayPalApi\Payer(); + $payer + ->setPaymentMethod('credit_card') + ->setFundingInstruments([ $fundingInstrument ]); + + // + // Create subscription agreement. + // + $agreement = new PayPalApi\Agreement(); + $agreement + ->setName($plan->getTitle() .' subscription agreement') + ->setDescription($plan->getTitle() .' subscription agreement') + ->setStartDate(date_create()->modify('+ 1 month')->format('c')) + ->setPayer($payer); + + $payPalPlan = $this->findProperPlan($plan); + + if ($payPalPlan === null) { + throw new \RuntimeException('Can\'t find proper plan, maybe plans not synced?'); + } + + $agreement->setPlan(new PayPalApi\Plan()); + $agreement->getPlan()->setId($payPalPlan->getId()); + $agreement->create($this->apiContext); + + // + // PayPal don't give to us any options to pass current user information + // with subscription agreement like 'custom' field in payment, so we store + // it in agreement manager instead. + // + // We should create subscription agreement here, because credit card + // payment not redirect back to our application like paypal payment's. + // + $this->agreementManager->storeAgreement($subscriptionEntity, $agreement->getId()); + } + + /** + * Process payment notification. + * + * @param Request $request A HTTP Request instance. + * + * @return PaymentNotification + */ + public function processNotification(Request $request) + { + if (!$request->isMethod(Request::METHOD_POST)) { + return PaymentNotification::createFailed(new \Exception('Wrong HTTP method.')); + } + + $parameters = $request->request->all(); + if (($response = $this->verify($parameters)) !== null) { + return $response; + } + + if (isset($parameters['initial_payment_status'])) { + // + // This notification about executed billing plan subscription. + // + switch ($parameters['txn_type']) { + case 'recurring_payment_profile_created': + case 'recurring_payment': + case 'cart': + $status = array_key_exists('initial_payment_status', $parameters) + ? $parameters['initial_payment_status'] + : $parameters['payment_status']; + $amount = array_key_exists('amount', $parameters) + ? $parameters['amount'] + : $parameters['mc_gross']; + $currency = array_key_exists('currency_code', $parameters) + ? $parameters['currency_code'] + : $parameters['mc_currency']; + $transactionId = array_key_exists('initial_payment_txn_id', $parameters) + ? $parameters['initial_payment_txn_id'] + : $parameters['txn_id']; + + return new PaymentNotification( + new Money($amount, $currency), + new PaymentStatusEnum(self::$paymentStatusMap[$status]), + $parameters['recurring_payment_id'], + $transactionId + ); + } + } + + // + // Single payment. + // + return PaymentNotification::createFailed(new \Exception('Unknown notification.')); + } + + /** + * Refund specified payment. + * + * @param AbstractSubscription $subscription A application subscription. + * @param string $note A cancel note. + * + * @return void + */ + public function cancelSubscription(AbstractSubscription $subscription, $note) + { + $agreementId = $this->agreementManager->getAgreementId($subscription); + if ($agreementId === '') { + // + // We don't have agreement so we should do nothing here. + // + return; + } + + $agreement = PayPalApi\Agreement::get($agreementId, $this->apiContext); + + // + // We should get all transaction which is maid for this subscription + // agreement, found last completed and refund it. + // + $transactions = PayPalApi\Agreement::searchTransactions($agreementId, [ + 'start_date' => date_create() + ->modify('first day of previous month') + ->format('Y-m-d'), + 'end_date' => date_create()->format('Y-m-d'), + ], $this->apiContext)->getAgreementTransactionList(); + + // + // Get last completed payment. + // + $index = count($transactions) - 1; + while (($index >= 0) && (strtolower(trim($transactions[$index]->getStatus())) !== 'completed')) { + --$index; + } + $transaction = $transactions[$index]; + + if ($transaction === null) { + // + // We don't have transaction. + // This situation may occurs if previously we change PayPal billing + // plan on which current subscription is subscribed. So we shouldn't + // do anything here. + // + return; + } + + if (strtolower(trim($transaction->getStatus())) === 'completed') { + // + // We found last completed transaction, so we should refund it. + // + // See https://github.com/paypal/PayPal-Python-SDK/issues/115 for + // more details. + // + $sale = PayPalApi\Sale::get($transaction->getTransactionId(), $this->apiContext); + $refundRequest = new PayPalApi\RefundRequest(); + + $amount = new PayPalApi\Amount(); + $amount->setCurrency($transaction->getAmount()->getCurrency()); + $amount->setTotal($transaction->getAmount()->getValue()); + + $refundRequest + ->setAmount($amount) + ->setDescription('You registration was rejected'); + $sale->refundSale($refundRequest, $this->apiContext); + } + + $currency = new PayPalApi\Currency(); + $currency->setCurrency('USD'); + $currency->setValue($subscription->getPlan()->getPrice()); + + $descriptor = new PayPalApi\AgreementStateDescriptor(); + $descriptor->setAmount($currency); + $descriptor->setNote($note); + + $agreement->cancel($descriptor, $this->apiContext); + } + + /** + * @param Plan $plan A application billing Plan entity instance. + * + * @return null|PayPalApi\Plan + */ + private function findProperPlan(Plan $plan) + { + $availablePlans = $this->getAvailablePlans(); + + $payPalPlan = null; + foreach ($availablePlans as $item) { + if ($item->getName() === $plan->getInnerName()) { + $payPalPlan = $item; + break; + } + } + + return $payPalPlan; + } + + /** + * @return PayPalApi\Plan[] + */ + private function getAvailablePlans() + { + if ($this->availablePayPalPlans === null) { + $this->availablePayPalPlans = PayPalApi\Plan::all([ + 'page_size' => 10, // We assume that we don't get more than 10 billing + // plans + 'status' => 'ACTIVE', + ], $this->apiContext)->getPlans(); + + if ($this->availablePayPalPlans === null) { + $this->availablePayPalPlans = []; + } + } + + return $this->availablePayPalPlans; + } + + /** + * @param array $parameters Array of PayPal notification parameters. + * + * @return PaymentNotification|null + */ + private function verify(array $parameters) + { + $parameters['cmd'] = '_notify-validate'; + + $curlHandler = curl_init($this->mode === 'sandbox' ? self::SANDBOX_URL : self::LIFE_URL); + curl_setopt_array($curlHandler, [ + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POSTFIELDS => $parameters, + CURLOPT_SSLVERSION => 6, + CURLOPT_SSL_VERIFYPEER => 1, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_FORBID_REUSE => 1, + CURLOPT_CONNECTTIMEOUT => 30, + CURLOPT_HTTPHEADER => ['Connection: Close'], + ]); + + $verificationResult = curl_exec($curlHandler); + $errorCode = curl_errno($curlHandler); + + if ($errorCode !== 0) { + $errorMsg = curl_error($curlHandler); + curl_close($curlHandler); + + return PaymentNotification::createFailed(new \Exception("cURL error: [{$errorCode}] {$errorMsg}")); + } + $httpCode = curl_getinfo($curlHandler)['http_code']; + curl_close($curlHandler); + + if ($httpCode !== 200) { + return PaymentNotification::createFailed(new \Exception("PayPal responded with http code $httpCode")); + } + + if ($verificationResult !== self::VALID) { + return PaymentNotification::createFailed(new \Exception('Notification not valid')); + } + + return null; + } +} diff --git a/src/PaymentBundle/Gateway/PayPalPaymentGatewayFactory.php b/src/PaymentBundle/Gateway/PayPalPaymentGatewayFactory.php new file mode 100644 index 0000000..b16b727 --- /dev/null +++ b/src/PaymentBundle/Gateway/PayPalPaymentGatewayFactory.php @@ -0,0 +1,79 @@ +agreementManager = $agreementManager; + $this->urlGenerator = $urlGenerator; + $this->returnRoute = $returnRoute; + $this->mode = $mode; + } + + /** + * @param ApiContext $apiContext A ApiContext instance. + * + * @return PayPalPaymentGateway + */ + public function createPayPalGateway(ApiContext $apiContext) + { + return new PayPalPaymentGateway( + $this->agreementManager, + $apiContext, + $this->urlGenerator->generate( + $this->returnRoute, + [], + UrlGeneratorInterface::ABSOLUTE_URL + ), + $this->mode + ); + } +} diff --git a/src/PaymentBundle/Gateway/PaymentGatewayInterface.php b/src/PaymentBundle/Gateway/PaymentGatewayInterface.php new file mode 100644 index 0000000..00211b1 --- /dev/null +++ b/src/PaymentBundle/Gateway/PaymentGatewayInterface.php @@ -0,0 +1,65 @@ +subscription = $subscription; + $this->plan = $plan; + $this->creditCard = $creditCard; + } + + /** + * @return AbstractSubscription + */ + public function getSubscription() + { + return $this->subscription; + } + + /** + * @return Plan + */ + public function getPlan() + { + return $this->plan; + } + + /** + * @return CreditCard|null + */ + public function getCreditCard() + { + return $this->creditCard; + } +} diff --git a/src/PaymentBundle/Model/CreditCard.php b/src/PaymentBundle/Model/CreditCard.php new file mode 100644 index 0000000..8d801ba --- /dev/null +++ b/src/PaymentBundle/Model/CreditCard.php @@ -0,0 +1,175 @@ + [ + '/^3[47][0-9]{13}$/', + ], + // Discover card numbers begin with 6011, 622126 through 622925, 644 through 649 or 65. + // All have 16 digits. + 'DISCOVER' => [ + '/^6011[0-9]{12}$/', + '/^64[4-9][0-9]{13}$/', + '/^65[0-9]{14}$/', + '/^622(12[6-9]|1[3-9][0-9]|[2-8][0-9][0-9]|91[0-9]|92[0-5])[0-9]{10}$/', + ], + // Maestro international cards begin with 675900..675999 and have between 12 and 19 digits. + // Maestro UK cards begin with either 500000..509999 or 560000..699999 and have between 12 and 19 digits. + 'MAESTRO' => [ + '/^(6759[0-9]{2})[0-9]{6,13}$/', + '/^(50[0-9]{4})[0-9]{6,13}$/', + '/^5[6-9][0-9]{10,17}$/', + '/^6[0-9]{11,18}$/', + ], + // All MasterCard numbers start with the numbers 51 through 55. All have 16 digits. + // October 2016 MasterCard numbers can also start with 222100 through 272099. + 'MASTERCARD' => [ + '/^5[1-5][0-9]{14}$/', + '/^2(22[1-9][0-9]{12}|2[3-9][0-9]{13}|[3-6][0-9]{14}|7[0-1][0-9]{13}|720[0-9]{12})$/', + ], + // All Visa card numbers start with a 4. New cards have 16 digits. Old cards have 13. + 'VISA' => [ + '/^4([0-9]{12}|[0-9]{15})$/', + ], + ]; + + /** + * @var string + */ + private $firstName; + + /** + * @var string + */ + private $lastName; + + /** + * @var string + */ + private $number; + + /** + * @var integer + */ + private $cvv; + + /** + * @var \DateTime + */ + private $expiresAt; + + /** + * @var CreditCardAddress + */ + private $address; + + /** + * @var string + */ + private $schema; + + /** + * CreditCard constructor. + * + * @param string $firstName Cardholder first name. + * @param string $lastName Cardholder last name. + * @param string $number Credit card number. + * @param integer $cvv CVV code. + * @param \DateTime $expiresAt When card should expires. + * @param CreditCardAddress $address A address instance. + */ + public function __construct( + $firstName, + $lastName, + $number, + $cvv, + \DateTime $expiresAt, + CreditCardAddress $address + ) { + $this->firstName = $firstName; + $this->lastName = $lastName; + $this->number = $number; + $this->cvv = $cvv; + $this->expiresAt = $expiresAt; + $this->address = $address; + } + + /** + * @return string + */ + public function getFirstName() + { + return $this->firstName; + } + + /** + * @return string + */ + public function getLastName() + { + return $this->lastName; + } + + /** + * @return string + */ + public function getNumber() + { + return $this->number; + } + + /** + * @return integer + */ + public function getCvv() + { + return $this->cvv; + } + + /** + * @return \DateTime + */ + public function getExpiresAt() + { + return $this->expiresAt; + } + + /** + * @return string + */ + public function getSchema() + { + if ($this->schema === null) { + $this->schema = ''; + + foreach (self::$schemes as $schema => $regexes) { + foreach ($regexes as $regex) { + if (preg_match($regex, $this->number)) { + $this->schema = $schema; + break 2; + } + } + } + } + + return $this->schema; + } + + /** + * @return CreditCardAddress + */ + public function getAddress() + { + return $this->address; + } +} diff --git a/src/PaymentBundle/Model/CreditCardAddress.php b/src/PaymentBundle/Model/CreditCardAddress.php new file mode 100644 index 0000000..5bfe4e9 --- /dev/null +++ b/src/PaymentBundle/Model/CreditCardAddress.php @@ -0,0 +1,80 @@ +country = strtoupper($country); + $this->city = $city; + $this->street = $street; + $this->postalCode = $postalCode; + } + + /** + * @return string + */ + public function getCountry() + { + return $this->country; + } + + /** + * @return string + */ + public function getCity() + { + return $this->city; + } + + /** + * @return string + */ + public function getStreet() + { + return $this->street; + } + + /** + * @return string + */ + public function getPostalCode() + { + return $this->postalCode; + } +} diff --git a/src/PaymentBundle/Model/PaymentData.php b/src/PaymentBundle/Model/PaymentData.php new file mode 100644 index 0000000..7eed7fe --- /dev/null +++ b/src/PaymentBundle/Model/PaymentData.php @@ -0,0 +1,71 @@ +user = $user; + $this->creditCard = $creditCard; + $this->gateway = $gateway; + } + + /** + * @return PaymentGatewayEnum + */ + public function getGateway() + { + return $this->gateway; + } + + /** + * @return User|null + */ + public function getUser() + { + return $this->user; + } + + /** + * @return CreditCard|null + */ + public function getCreditCard() + { + return $this->creditCard; + } +} diff --git a/src/PaymentBundle/Model/PaymentNotification.php b/src/PaymentBundle/Model/PaymentNotification.php new file mode 100644 index 0000000..a01ba71 --- /dev/null +++ b/src/PaymentBundle/Model/PaymentNotification.php @@ -0,0 +1,117 @@ +amount = $amount; + $this->status = $status; + $this->agreementId = $agreementId; + $this->transactionId = $transactionId; + $this->exception = $exception; + } + + /** + * @param \Exception $exception Occurred exception. + * + * @return static + */ + public static function createFailed(\Exception $exception) + { + return new static(new Money(0.0, 'USD'), PaymentStatusEnum::failed(), '', '', $exception); + } + + /** + * @return Money + */ + public function getAmount() + { + return $this->amount; + } + + /** + * @return PaymentStatusEnum + */ + public function getStatus() + { + return $this->status; + } + + /** + * @return string + */ + public function getAgreementId() + { + return $this->agreementId; + } + + /** + * @return string + */ + public function getTransactionId() + { + return $this->transactionId; + } + + /** + * @return \Exception|null + */ + public function getException() + { + return $this->exception; + } +} diff --git a/src/PaymentBundle/PaymentBundle.php b/src/PaymentBundle/PaymentBundle.php new file mode 100644 index 0000000..e161f68 --- /dev/null +++ b/src/PaymentBundle/PaymentBundle.php @@ -0,0 +1,13 @@ +createQueryBuilder('Agreement') + ->addSelect('Subscription') + ->join('Agreement.subscription', 'Subscription') + ->where('Agreement.agreementId = :id AND Agreement.gateway = :gateway') + ->setParameter('id', $id) + ->setParameter('gateway', $gateway->getValue()) + ->getQuery() + ->getOneOrNullResult(); + } +} diff --git a/src/PaymentBundle/Repository/PaymentRepository.php b/src/PaymentBundle/Repository/PaymentRepository.php new file mode 100644 index 0000000..1ead6a6 --- /dev/null +++ b/src/PaymentBundle/Repository/PaymentRepository.php @@ -0,0 +1,27 @@ +createQueryBuilder('Payment') + ->addSelect('Subscription, Plan, Owner') + ->join('Payment.subscription', 'Subscription') + ->join('Subscription.plan', 'Plan') + ->join('Subscription.owner', 'Owner'); + } +} diff --git a/src/PaymentBundle/Resources/config/agreement_manager.yml b/src/PaymentBundle/Resources/config/agreement_manager.yml new file mode 100644 index 0000000..d92ea41 --- /dev/null +++ b/src/PaymentBundle/Resources/config/agreement_manager.yml @@ -0,0 +1,10 @@ +# +# Agreement managers. +# +services: + payment.agreement_manager.orm: + class: 'PaymentBundle\Agreement\ORMAgreementManager' + arguments: + - '@doctrine.orm.default_entity_manager' + + payment.agreement_manager: '@payment.agreement_manager.orm' \ No newline at end of file diff --git a/src/PaymentBundle/Resources/config/gateway_factories.yml b/src/PaymentBundle/Resources/config/gateway_factories.yml new file mode 100644 index 0000000..d1beec5 --- /dev/null +++ b/src/PaymentBundle/Resources/config/gateway_factories.yml @@ -0,0 +1,10 @@ +# +# Payment gateway factory +# +services: + payment.gateway_factory.static: + class: 'PaymentBundle\Gateway\Factory\StaticPaymentGatewayFactory' + arguments: + - '@payment.gateway.paypal' + + payment.gateway_factory: '@payment.gateway_factory.static' \ No newline at end of file diff --git a/src/PaymentBundle/Resources/config/gateways.yml b/src/PaymentBundle/Resources/config/gateways.yml new file mode 100644 index 0000000..2b1c379 --- /dev/null +++ b/src/PaymentBundle/Resources/config/gateways.yml @@ -0,0 +1,40 @@ +# +# Payment gateways. +# +services: + # PayPal + payment.gateway.paypal_factory: + class: 'PaymentBundle\Gateway\PayPalPaymentGatewayFactory' + arguments: + - '@payment.agreement_manager' + - '@router' + - 'app_index_index' + - '%paypal.mode%' + public: false + + payment.gateway.paypal: + class: 'PaymentBundle\Gateway\PayPalPaymentGateway' + factory: [ '@payment.gateway.paypal_factory', 'createPayPalGateway' ] + arguments: + - '@payment.paypal.api_context' + public: false + + # + # Payment gateway specific services and configurators. + # + + # PayPal + payment.paypal.api_context_factory: + class: 'PayPal\ApiContextFactory' + arguments: + - '@monolog.logger.payment_api' + public: false + + payment.paypal.api_context: + class: 'PayPal\Rest\ApiContext' + factory: [ '@payment.paypal.api_context_factory', 'generate' ] + arguments: + - '%paypal.client_id%' + - '%paypal.secret%' + - '%paypal.mode%' + public: false \ No newline at end of file diff --git a/src/PaymentBundle/Resources/config/services.yml b/src/PaymentBundle/Resources/config/services.yml new file mode 100644 index 0000000..4d08735 --- /dev/null +++ b/src/PaymentBundle/Resources/config/services.yml @@ -0,0 +1,49 @@ +imports: + - { resource: 'gateway_factories.yml' } + - { resource: 'gateways.yml' } + +services: + # + # Commands. + # + payment.command.billing_plan_sync: + class: 'PaymentBundle\Command\BillingPlanSyncCommand' + arguments: + - '@user.repository.plan' + - '@payment.gateway_factory' + tags: + - { name: console.command } + + payment.command.billing_plan_list: + class: 'PaymentBundle\Command\BillingPlanListCommand' + arguments: + - '@payment.paypal.api_context' + tags: + - { name: console.command } + + payment.command.billing_plan_create: + class: 'PaymentBundle\Command\BillingPlanCreateCommand' + arguments: + - '@payment.paypal.api_context' + tags: + - { name: console.command } + + payment.command.billing_plan_delete: + class: 'PaymentBundle\Command\BillingPlanDeleteCommand' + arguments: + - '@payment.paypal.api_context' + tags: + - { name: console.command } + + + + # + # Agreement manager. + # + payment.agreement_manager.orm: + class: 'PaymentBundle\Agreement\ORMAgreementManager' + arguments: + - '@doctrine.orm.default_entity_manager' + + payment.agreement_manager: '@payment.agreement_manager.orm' + diff --git a/src/QueueBundle/Command/StartNotificationSendingCommand.php b/src/QueueBundle/Command/StartNotificationSendingCommand.php new file mode 100644 index 0000000..a908256 --- /dev/null +++ b/src/QueueBundle/Command/StartNotificationSendingCommand.php @@ -0,0 +1,92 @@ +logger = $logger; + $this->producer = $producer; + $this->container = $container; + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure() + { + $this->setDescription('Start notification sending process.'); + } + + /** + * Executes the current command. + * + * This method is not abstract because you can use this class + * as a concrete class. In this case, instead of defining the + * execute() method, you set the code to execute by passing + * a Closure to the setCode() method. + * + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer null or 0 if everything went fine, or an error code. + * + * @see setCode() + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $date = date_create(); + $date = $date + ->setTime($date->format('H'), $date->format('i')) + ->format('c'); + $this->logger->info('Initialize notification sending for date '. $date); + $this->producer->publish($date); + + return 0; + } +} diff --git a/src/QueueBundle/Consumer/AbstractConsumer.php b/src/QueueBundle/Consumer/AbstractConsumer.php new file mode 100644 index 0000000..eecb54e --- /dev/null +++ b/src/QueueBundle/Consumer/AbstractConsumer.php @@ -0,0 +1,138 @@ +logger = $logger; + $this->connection = $connection; + } + + /** + * @param AMQPMessage $msg The message. + * + * @return mixed false to reject and requeue, any other value to acknowledge. + */ + public function execute(AMQPMessage $msg) + { + // + // The database may close a connection if we don't use it in long-running + // code. And we should handle this situation 'cause doctrine will not + // reconnect so we should check connection and manually reconnect if it's + // closed. + // + + $this->logger->info('This connection return ' . $this->connection->ping()); + if (! $this->connection->ping()) { + try { + $this->connection->close(); + $this->connection->connect(); + } catch (\Throwable $exception) { + $this->logger->critical(sprintf( + '%s: Can\'t reconnect to database due to %s', + static::class, + $exception->getMessage() + )); + } + } + + try { + return $this->doExecute(trim($msg->getBody())); + } catch (\Exception $exception) { + $this->logError($exception); + + // + // Because we don't want to reprocess failed job. + // + return true; + } + } + + /** + * Add error message to log. + * + * @param string $message Some error message. + * @param array $context Logged message context. + * + * @return void + */ + protected function error($message, array $context = []) + { + $this->logger->error(sprintf( + '%s: %s', + static::class, + $message + ), $context); + } + + /** + * Add info message to log. + * + * @param string $message Some info message. + * @param array $context Logged message context. + * + * @return void + */ + protected function info($message, array $context = []) + { + $this->logger->info(sprintf( + '%s: %s', + static::class, + $message + ), $context); + } + + /** + * Execute consumer specific code. + * + * @param string $messageBody Sanitized message body. + * + * @return mixed + */ + abstract protected function doExecute($messageBody); + + /** + * @param \Exception $exception A occurred exception. + * + * @return void + */ + private function logError(\Exception $exception) + { + $this->error(sprintf( + 'Exception \'%s\' with message \'%s\'', + get_class($exception), + $exception->getMessage() + ), [ 'trace' => $exception->getTrace() ]); + } +} diff --git a/src/QueueBundle/Consumer/DocumentsEmailConsumer.php b/src/QueueBundle/Consumer/DocumentsEmailConsumer.php new file mode 100644 index 0000000..569c99a --- /dev/null +++ b/src/QueueBundle/Consumer/DocumentsEmailConsumer.php @@ -0,0 +1,77 @@ +getConnection()); + + $this->em = $em; + $this->mailer = $mailer; + } + + /** + * Execute consumer specific code. + * + * @param string $id Emailed document id. + * + * @return mixed + */ + protected function doExecute($id) + { + if (($id === '') || ! is_numeric($id)) { + $this->error('Got empty or not numeric value', [ 'id' => $id ]); + + return true; // We return true in order to not requeue this invalid + // message again. + } + + $this->info('Send emailed documents with', [ 'id' => $id ]); + + $repository = $this->em->getRepository(EmailedDocument::class); + $emailedDocument = $repository->find($id); + + if ($emailedDocument instanceof EmailedDocument) { + $this->mailer->sendEmailedDocument($emailedDocument); + $this->mailer->flushQueue(); + + $this->em->remove($this->em->getReference(EmailedDocument::class, $id)); + $this->em->flush(); + } + + return true; + } +} diff --git a/src/QueueBundle/Consumer/DocumentsFetchConsumer.php b/src/QueueBundle/Consumer/DocumentsFetchConsumer.php new file mode 100644 index 0000000..a2cfe95 --- /dev/null +++ b/src/QueueBundle/Consumer/DocumentsFetchConsumer.php @@ -0,0 +1,128 @@ +getConnection()); + + $this->em = $em; + $this->queryManager = $queryManager; + } + + /** + * Execute consumer specific code. + * + * @param string $storedQueryId Fetched stored query id. + * + * @return mixed + */ + protected function doExecute($storedQueryId) + { + $this->info('Got document fetch request for stored query', [ 'id' => $storedQueryId ]); + + if (! is_string($storedQueryId) || ($storedQueryId === '')) { + $this->error('Stored query id should be not empty string'); + + return true; // We return true in order to not requeue this invalid + // message again. + } + + /** @var StoredQueryRepository $repository */ + $repository = $this->em->getRepository(StoredQuery::class); + $query = $repository->find($storedQueryId); + + if (! $query instanceof StoredQuery) { + throw new \LogicException(sprintf( + 'Can\'t find %s with id \'%s\'', + StoredQuery::class, + $storedQueryId + )); + } + + $previousStatus = $query->getStatus(); + $query = $this->queryManager->fetchDocuments($query); + + if ($previousStatus !== StoredQueryStatusEnum::SYNCED) { + $this->info('Fetch documents for new stored query', [ 'id' => $storedQueryId ]); + + // + // Remove excluded documents from feeds which are created for + // this stored query. + // + /** @var QueryFeedRepository $repository */ + $repository = $this->em->getRepository(QueryFeed::class); + $feeds = $repository->getWithExcludedDocumentsForQuery($query->getId()); + + /** @var AbstractFeed $feed */ + foreach ($feeds as $feed) { + $this->feedManager->deleteDocuments( + $feed, + \nspl\a\map( + \nspl\op\methodCaller('getId'), + $feed->getExcludedDocuments() + ) + ); + } + } else { + $this->info('Update already exists stored query', [ 'id' => $storedQueryId ]); + } + + $query->setLastUpdateAt(new \DateTime()); + $this->em->persist($query); + $this->em->flush(); + $this->info('Stored query successfully processed', [ 'id' => $storedQueryId ]); + + $this->em->clear(); + gc_collect_cycles(); + + return true; + } +} diff --git a/src/QueueBundle/Consumer/NotificationsFetcherConsumer.php b/src/QueueBundle/Consumer/NotificationsFetcherConsumer.php new file mode 100644 index 0000000..232e2e9 --- /dev/null +++ b/src/QueueBundle/Consumer/NotificationsFetcherConsumer.php @@ -0,0 +1,90 @@ +connection = $connection; + $this->producer = $producer; + $this->container = $container; + } + + /** + * Execute consumer specific code. + * + * @param string $messageBody Sanitized message body. + * + * @return mixed + */ + protected function doExecute($messageBody) + { + // + // We don't need seconds, so for each date we set it to 0. + // + $date = new \DateTime($messageBody); + if (! $date instanceof \DateTime) { + $this->error('Invalid date', [ 'date' => $messageBody ]); + + return true; // We return true in order to not requeue this invalid + // message again. + } + $this->info('Fetch notification\'s', [ 'date' => $messageBody ]); + + // + // Find all notification which should be send for specified date. + // Also we should remove all fetched notification from scheduling. + // + $rows = $this->connection->fetchAll(' + SELECT notification_id, schedules FROM internal_notification_scheduling + WHERE date = :date + GROUP BY notification_id + ', [ 'date' => $date->format('Y-m-d H:i:s') ]); + $this->connection->executeQuery(' + DELETE FROM internal_notification_scheduling + WHERE date = :date + ', [ 'date' => $date->format('Y-m-d H:i:s') ]); + + // + // All founded notification we should publish into queue. + // + foreach ($rows as $row) { + $this->producer->publish(serialize($row)); + } + + return true; + } +} diff --git a/src/QueueBundle/Consumer/NotificationsSenderConsumer.php b/src/QueueBundle/Consumer/NotificationsSenderConsumer.php new file mode 100644 index 0000000..a6e6642 --- /dev/null +++ b/src/QueueBundle/Consumer/NotificationsSenderConsumer.php @@ -0,0 +1,117 @@ +getConnection()); + + $this->em = $em; + $this->manager = $manager; + $this->mailer = $mailer; + $this->templating = $templating; + $this->container = $container; + } + + /** + * Execute consumer specific code. + * + * @param string $messageBody Sanitized message body. + * + * @return mixed + */ + protected function doExecute($messageBody) + { + $row = unserialize($messageBody); + + if (! is_array($row) || ! isset($row['notification_id'], $row['schedules'])) { + $this->error('Got invalid message, drop it', [ 'message' => $messageBody ]); + + return true; // We return true in order to not requeue this invalid + // message again. + } + $id = $row['notification_id']; + $schedules = $row['schedules']; + $schedules = explode(',', $schedules); + + if (($schedules === false) || (count($schedules) === 0)) { + $this->error('Got invalid message, drop it', [ 'message' => $messageBody ]); + + return true; // We return true in order to not requeue this invalid + // message again. + } + + $this->info('Send notification', [ 'message' => $messageBody, 'id' => $id ]); + + /** @var NotificationRepository $repository */ + $repository = $this->em->getRepository(Notification::class); + $notification = $repository->getForSending($id); + + if ($notification instanceof Notification) { + $sendableNotification = $this->manager->prepareToSend($notification); + $sendableNotification->send( + $this->mailer, + $this->templating, + $this->em, + $schedules + ); + } + + return true; + } +} diff --git a/src/QueueBundle/QueueBundle.php b/src/QueueBundle/QueueBundle.php new file mode 100644 index 0000000..80a28f4 --- /dev/null +++ b/src/QueueBundle/QueueBundle.php @@ -0,0 +1,13 @@ +em = $em; + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure() + { + $this->setDescription('Cancel subscription'); + } + + /** + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function doExecute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + $repository = $this->em->getRepository(User::class); + $currentDate = date('Y-m-d'); + $users = $repository->getAllUserBillingSubscription($currentDate); + foreach ($users as $user) { + $repository = $this->em->getRepository(Plan::class); + $planObj = $repository->findOneBy([ 'title' => 'Free' ]); + + $plan = $user->getBillingSubscription()->getPlan(); + $plan->setTitle($user->getCompanyName()); + $plan->setInnerName('Starter'); + $plan->setPrice(0); + $plan->setNews($planObj->isNews()); + $plan->setBlog($planObj->isBlog()); + $plan->setReddit($planObj->isReddit()); + $plan->setInstagram($planObj->isInstagram()); + $plan->setTwitter($planObj->isTwitter()); + $plan->setAnalytics($planObj->isAnalytics()); + $plan->setSearchesPerDay($planObj->getSearchesPerDay()); + $plan->setSavedFeeds($planObj->getSavedFeeds()); + $plan->setMasterAccounts($planObj->getMasterAccounts()); + $plan->setSubscriberAccounts($planObj->getSubscriberAccounts()); + $plan->setAlerts($planObj->getAlerts()); + $plan->setNewsLetters($planObj->getNewsLetters()); + $plan->setWebFeeds($planObj->getWebFeeds()); + $plan->setAlerts($planObj->getAlerts()); + $plan->setIsPlanDowngrade(false); + + $this->em->persist($plan); + $this->em->flush(); + + $subscription = $user->getBillingSubscription(); + $subscription->setIsSubscriptionCancelled(false); + $this->em->persist($subscription); + $this->em->flush(); + + } + $io->success('Cancel Subscription Successfully.'); + return 0; + } +} diff --git a/src/UserBundle/Command/DowngradeSubscriptionPlanCommand.php b/src/UserBundle/Command/DowngradeSubscriptionPlanCommand.php new file mode 100644 index 0000000..0ddaa95 --- /dev/null +++ b/src/UserBundle/Command/DowngradeSubscriptionPlanCommand.php @@ -0,0 +1,175 @@ +em = $em; + $this->container = $container; + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure() + { + $this->setDescription('Downgrade subscription plan'); + } + + /** + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function doExecute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + $repository = $this->em->getRepository(User::class); + $currentDate = date('Y-m-d'); + $users = $repository->getAllUserBillingSubscriptionPlanDowngrade($currentDate); + foreach ($users as $user) { + $repository = $this->em->getRepository(Plan::class); + $planObj = $repository->findOneBy(['isPlanDowngrade' => true, 'user' => $user->getId()], ['id'=>'desc']); + + $subscription = $user->getBillingSubscription(); + $subscription->setIsPlanDowngrade(false); + $subscription->setPlan($planObj); + $this->em->persist($subscription); + $this->em->flush(); + $this->downgradePlanInStripe($user,$planObj); + } + $io->success('Downgrade Subscription Plan Successfully.'); + return 0; + } + + protected function downgradePlanInStripe($user, $planObj) + { + $stripe = $this->container->get('stripe.service'); + $stripe->setApiKey(); + $customer = $stripe->getCustomer( + $user->getStripeUserId() + ); + $customerArray = []; + if ($customer instanceof ApiErrorException) { + $customerArray = ['paymentError' => 1,'data'=>$customer,'message'=>'Customer not found']; + $this->logger->info(sprintf( + 'Error cron job downgrade \'%s\'', + json_encode($customerArray) + )); + } + if (isset($customer['id'])) { + $price = $stripe->addPrice( + [ + 'unit_amount' => !empty($planObj->getPrice()) ? $planObj->getPrice() * 100 : 0, + 'currency' => 'usd', + 'recurring' => ['interval' => 'month'], + 'product' => $customer['metadata']['productId'] + ] + ); + if ($price instanceof ApiErrorException) { + $priceArray = ['paymentError' => 1,'data'=>$price,'message'=>'Price not found']; + $this->logger->info(sprintf( + 'Error cron job downgrade \'%s\'', + json_encode($priceArray) + )); + return $this->generateResponse($priceArray, 400); + } + if (isset($price['id'])) { + //Add subscription + $subscription = $stripe->getSubscription( + $customer['metadata']['subscriptionId'] + ); + + if ($subscription instanceof ApiErrorException) { + $subscriptionArray = ['paymentError' => 1,'data'=>$subscription,'message'=>'Subscribtion get failed']; + $this->logger->info(sprintf( + 'Error cron job downgrade \'%s\'', + json_encode($subscriptionArray) + )); + return $this->generateResponse($subscriptionArray, 400); + } + + if (isset($subscription['id'])) { + //Add subscription item + $subscriptionItem = $stripe->updateSubscriptionItem($subscription['items']['data'][0]['id'], + [ + 'price' => $price['id'], + ] + ); + if ($subscriptionItem instanceof ApiErrorException) { + $subscriptionItemArray = ['paymentError' => 1,'data'=>$subscriptionItem,'message'=>'Subscribtion Item failed']; + $this->logger->info(sprintf( + 'Error cron job downgrade \'%s\'', + json_encode($subscriptionItemArray) + )); + return $this->generateResponse($subscriptionItemArray, 400); + } + //update customer metadata + $customer = $stripe->updateCustomer($customer['id'], + [ + 'metadata' => [ + 'priceId' => $price['id'], + 'subscriptionId' => $subscription['id'], + 'subStartDate' => $subscription['current_period_start'], + 'subEndDate' => $subscription['current_period_end'], + ] + ] + ); + if ($customer instanceof ApiErrorException) { + $customerArray = ['paymentError' => 1,'data'=>$customer,'message'=>'Customer update meta data failed']; + $this->logger->info(sprintf( + 'Error cron job downgrade \'%s\'', + json_encode($customerArray) + )); + } + } + } + } + } +} diff --git a/src/UserBundle/Command/RenewSearchLimitsCommand.php b/src/UserBundle/Command/RenewSearchLimitsCommand.php new file mode 100644 index 0000000..d661bbe --- /dev/null +++ b/src/UserBundle/Command/RenewSearchLimitsCommand.php @@ -0,0 +1,67 @@ +em = $em; + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure() + { + $this->setDescription('Set all searchPerDay limits to zero'); + } + + /** + * @param InputInterface $input An InputInterface instance. + * @param OutputInterface $output An OutputInterface instance. + * + * @return null|integer + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function doExecute(InputInterface $input, OutputInterface $output) + { + /** @var SubscriptionRepository $repository */ + $repository = $this->em->getRepository(AbstractSubscription::class); + $repository->renewSearchLimits(); + + return 0; + } +} diff --git a/src/UserBundle/Controller/Developing/EmailController.php b/src/UserBundle/Controller/Developing/EmailController.php new file mode 100644 index 0000000..6a4dcc0 --- /dev/null +++ b/src/UserBundle/Controller/Developing/EmailController.php @@ -0,0 +1,353 @@ +strategy = new HoseIndexStrategy(); + } + + /** + * @Route("/plain", methods={ "GET" }) + * + * @return Response + */ + public function plainAction() + { + $notification = $this->generateNotification(Notification::create()->setThemeType(ThemeTypeEnum::plain())); + + $notification->setPlainThemeOptionsDiff([ +// 'summary' => '

    Summary

    ', +// 'conclusion' => '

    Conclusion

    ', +// +// 'header.imageUrl' => ThemeOptionHeader::DEFAULT_IMAGE, +// 'header.logoLink' => 'http://ya.ru', +// +// 'content.showInfo.images' => true, +// 'content.showInfo.sectionDivider' => true, +// 'content.showInfo.sourceCountry' => true, +// 'content.showInfo.articleCount' => true, +// 'content.showInfo.tableOfContents' => ThemeOptionsTableOfContentsEnum::SOURCE_HEADLINE_DATE, + 'content.showInfo.userComments' => ThemeOptionsUserCommentsEnum::WITHOUT_AUTHOR_DATE, +// +// 'colors.text.articleHeadline' => 'yellow', +// 'colors.text.source' => 'green', +// 'colors.text.articleContent' => 'red', +// 'colors.text.author' => '#fdfdfd', +// 'colors.text.publishDate' => '#23fa1f', +// +// 'fonts.tableOfContents.size' => 18, +// 'fonts.tableOfContents.family' => 'Courier New', +// 'fonts.tableOfContents.style.bold' => true, +// 'fonts.tableOfContents.style.italic' => true, +// 'fonts.tableOfContents.style.underline' => true, +// +// 'fonts.feedTitle.size' => 18, +// 'fonts.feedTitle.family' => 'Courier New', +// 'fonts.feedTitle.style.bold' => true, +// 'fonts.feedTitle.style.italic' => true, +// 'fonts.feedTitle.style.underline' => true, +// +// 'fonts.articleHeadline.size' => 10, +// 'fonts.articleHeadline.family' => 'Courier New', +// 'fonts.articleHeadline.style.bold' => true, +// 'fonts.articleHeadline.style.italic' => true, +// 'fonts.articleHeadline.style.underline' => true, +// +// 'fonts.source.size' => 10, +// 'fonts.source.family' => 'Courier New', +// 'fonts.source.style.bold' => true, +// 'fonts.source.style.italic' => true, +// 'fonts.source.style.underline' => true, +// +// 'fonts.author.size' => 6, +// 'fonts.author.family' => 'Courier New', +// 'fonts.author.style.bold' => true, +// 'fonts.author.style.italic' => true, +// 'fonts.author.style.underline' => true, +// +// 'fonts.date.size' => 14, +// 'fonts.date.family' => 'Courier New', +// 'fonts.date.style.bold' => true, +// 'fonts.date.style.italic' => true, +// 'fonts.date.style.underline' => true, +// +// 'fonts.articleContent.size' => 6, +// 'fonts.articleContent.family' => 'Courier New', +// 'fonts.articleContent.style.bold' => true, +// 'fonts.articleContent.style.italic' => true, +// 'fonts.articleContent.style.underline' => true, + ]); + + /** @var ConfigurationInterface $configuration */ + $configuration = $this->get(AppBundleServices::CONFIGURATION); + + $sendableNotification = new SendableNotification( + SendableNotificationConfig::fromConfiguration($configuration), + $notification, + $this->generateFeeds(3) + ); + + return new Response($sendableNotification->render($this->get('templating'))); + } + + /** + * @Route("/enhanced", methods={ "GET" }) + * + * @return Response + */ + public function enhancedAction() + { + $notification = $this->generateNotification(Notification::create()->setThemeType(ThemeTypeEnum::enhanced())); + + $notification->setEnhancedThemeOptionsDiff([ +// 'summary' => '

    Summary

    ', +// 'conclusion' => '

    Conclusion

    ', +// +// 'header.imageUrl' => ThemeOptionHeader::DEFAULT_IMAGE, +// 'header.logoLink' => 'http://ya.ru', +// +// 'content.showInfo.sectionDivider' => true, +// 'content.showInfo.sourceCountry' => true, +// 'content.showInfo.articleCount' => true, +// 'content.showInfo.tableOfContents' => ThemeOptionsTableOfContentsEnum::HEADLINE_SOURCE_DATE, + 'content.showInfo.userComments' => ThemeOptionsUserCommentsEnum::WITH_AUTHOR_DATE, +// +// 'colors.background.header' => '#541dab', +// 'colors.background.accent' => '#df10bc', +// 'colors.background.emailBody' => '#eeeeee', +// +// 'colors.text.header' => '#12fdfd', +// 'colors.text.articleHeadline' => 'yellow', +// 'colors.text.source' => 'green', +// 'colors.text.articleContent' => 'red', +// 'colors.text.author' => '#fdfdfd', +// 'colors.text.publishDate' => '#23fa1f', +// +// 'fonts.header.size' => 18, +// 'fonts.header.family' => 'Courier New', +// 'fonts.header.style.bold' => true, +// 'fonts.header.style.italic' => true, +// 'fonts.header.style.underline' => true, +// +// 'fonts.tableOfContents.size' => 32, +// 'fonts.tableOfContents.family' => 'Courier New', +// 'fonts.tableOfContents.style.bold' => true, +// 'fonts.tableOfContents.style.italic' => true, +// 'fonts.tableOfContents.style.underline' => true, +// +// 'fonts.feedTitle.size' => 18, +// 'fonts.feedTitle.family' => 'Courier New', +// 'fonts.feedTitle.style.bold' => true, +// 'fonts.feedTitle.style.italic' => true, +// 'fonts.feedTitle.style.underline' => true, +// +// 'fonts.articleHeadline.size' => 10, +// 'fonts.articleHeadline.family' => 'Courier New', +// 'fonts.articleHeadline.style.bold' => true, +// 'fonts.articleHeadline.style.italic' => true, +// 'fonts.articleHeadline.style.underline' => true, +// +// 'fonts.source.size' => 10, +// 'fonts.source.family' => 'Courier New', +// 'fonts.source.style.bold' => true, +// 'fonts.source.style.italic' => true, +// 'fonts.source.style.underline' => true, +// +// 'fonts.author.size' => 6, +// 'fonts.author.family' => 'Courier New', +// 'fonts.author.style.bold' => true, +// 'fonts.author.style.italic' => true, +// 'fonts.author.style.underline' => true, +// +// 'fonts.date.size' => 14, +// 'fonts.date.family' => 'Courier New', +// 'fonts.date.style.bold' => true, +// 'fonts.date.style.italic' => true, +// 'fonts.date.style.underline' => true, +// +// 'fonts.articleContent.size' => 12, +// 'fonts.articleContent.family' => 'Courier New', +// 'fonts.articleContent.style.bold' => true, +// 'fonts.articleContent.style.italic' => true, +// 'fonts.articleContent.style.underline' => true, + ]); + + /** @var ConfigurationInterface $configuration */ + $configuration = $this->get(AppBundleServices::CONFIGURATION); + + $feeds = $this->generateFeeds(3); + + $sendableNotification = new SendableNotification( + SendableNotificationConfig::fromConfiguration($configuration), + $notification, + $feeds + ); + + return new Response($sendableNotification->render($this->get('templating'))); + } + + /** + * Generate notification. + * + * @param Notification $notification A Notification instance. + * + * @return Notification + */ + private function generateNotification(Notification $notification) + { + $faker = $this->getFaker(); + + $user = User::create('some@email.com') + ->setFirstName('John') + ->setLastName('Due'); + + $defaultOptions = NotificationThemeOptions::createDefault(); + + return $notification + ->setSubject(ucfirst($faker->word)) + ->setName($faker->word) + ->setOwner($user) + ->setTheme(NotificationTheme::create() + ->setName('Some') + ->setEnhanced($defaultOptions) + ->setPlain($defaultOptions) + ->setDefault(true)); + } + + /** + * @param integer $count Number of generated feeds. + * + * @return array[] + */ + private function generateFeeds($count = 2) + { + $feeds = []; + $faker = $this->getFaker(); + + for ($i = 0; $i < $count; ++$i) { + $documentCount = random_int(1, 3); + + $feeds[] = new FeedData( + $faker->word, + $this->generateDocuments($documentCount) + ); + } + + return $feeds; + } + + /** + * Generate documents for notification rendering. + * + * @param integer $count Number of documents. + * + * @return ArticleDocumentInterface[] + */ + private function generateDocuments($count) + { + $documents = []; + $generator = new ExternalDocumentGenerator(); + + for ($i = 0; $i < $count; ++$i) { + $documentEntity = $this->generateComments($generator->generate()->toDocumentEntity()); + $data = $documentEntity->getData(); + $data['comments'] = $documentEntity->getComments()->toArray(); + $data['commentsCount'] = count($data['comments']); + + $documents[] = new ArticleDocument( + $this->strategy, + $this->strategy->createDocument($data)->getNormalizedData() + ); + } + + return $documents; + } + + /** + * Generate comments for documents. + * + * @param Document $document A Document entity instance. + * + * @return Document + */ + private function generateComments(Document $document) + { + $faker = $this->getFaker(); + + $commentsCount = random_int(0, 3); + for ($i = 0; $i < $commentsCount; ++$i) { + $user = User::create($faker->email, '') + ->setFirstName($faker->firstName) + ->setLastName($faker->lastName); + + $document->addComment(new Comment( + $user, + $faker->text, + $faker->optional(0.4, '')->word + )); + } + + return $document; + } + + /** + * @return \Faker\Generator + */ + private function getFaker() + { + if ($this->faker === null) { + $this->faker = Factory::create(); + } + + return $this->faker; + } +} diff --git a/src/UserBundle/Controller/Security/CostCalculationController.php b/src/UserBundle/Controller/Security/CostCalculationController.php new file mode 100644 index 0000000..03402eb --- /dev/null +++ b/src/UserBundle/Controller/Security/CostCalculationController.php @@ -0,0 +1,84 @@ +request->all(); + $mtPrice = 0; + $totalPrice = 0; + $responseData = []; + //echo '
    '; print_r($data); die;
    +        if(!empty($data)){
    +            if(isset(
    +                    $data['news'],
    +                    $data['blog'],
    +                    $data['reddit'],
    +                    $data['instagram'],
    +                    $data['twitter'],
    +                    $data['searchesPerDay'],
    +                    $data['savedFeeds'],
    +                    $data['subscriberAccounts'],
    +                    $data['webFeeds'],
    +                    $data['alerts'],
    +                    $data['analytics']
    +                )
    +            ){
    +                //die('hello');
    +                $mtPrice += ($data['news'] == true) ? 20 : 0;
    +                $mtPrice += ($data['blog'] == true) ? 15 : 0;
    +                $mtPrice += ($data['reddit'] == true) ? 1 : 0;
    +                $mtPrice += ($data['instagram'] == true) ? 3 : 0;
    +                $mtPrice += ($data['twitter'] == true) ? 3 : 0;
    +                $responseData['selectedMediaTypeCost'] = $mtPrice;
    +                $searchPerDayPrice = ($data['searchesPerDay'] > 10) ? ($data['searchesPerDay'] - 10)/10 : 0;
    +                $searchPerDayPrice = $searchPerDayPrice ? ($searchPerDayPrice * $mtPrice) : 0;
    +                $responseData['searchPerDayPrice'] = $searchPerDayPrice;
    +
    +                $savedFeedsPrice = $data['savedFeeds'] ? ($data['savedFeeds'] * $mtPrice) : 0;
    +                $responseData['savedFeedsPrice'] = $savedFeedsPrice;
    +
    +                $subscriberAccountsPrice = $data['subscriberAccounts'] > 1 ? (($data['subscriberAccounts'] - 1) * 15) : 0; // Fixed price $15 per account
    +                $responseData['subscriberAccountsPrice'] = $subscriberAccountsPrice;
    +
    +                $webFeedsPrice = $data['webFeeds'] > 0 ? ($data['webFeeds'] * 5) : 0; // Fixed price $5 per export/webFeeds
    +                $responseData['webFeedsPrice'] = $webFeedsPrice;
    +
    +                $alertsPrice = $data['alerts'] ? ($data['alerts'] * 5) : 0; // Fixed price $5 per alerts
    +                $responseData['alertsPrice'] = $alertsPrice;
    +
    +                $analyticsPrice = $data['analytics'] ? ($data['savedFeeds'] * 15) : 0; // Fixed price $15 if analytics field comes true in request
    +                $responseData['analyticsPrice'] = $analyticsPrice;
    +
    +                $totalPrice = $searchPerDayPrice + $savedFeedsPrice + $subscriberAccountsPrice + $webFeedsPrice + $alertsPrice + $analyticsPrice;
    +                $responseData['totalPrice'] = $totalPrice;
    +                if ($isCallContoller) {
    +                    return ['price' => $totalPrice];
    +                }
    +                return $this->generateResponse($responseData, 200);
    +            } else {
    +                return $this->generateResponse("Invalid request", 400);
    +            }
    +        } else {
    +            return $this->generateResponse("Something went wrong in the request.", 400);
    +        }
    +    }
    +}
    diff --git a/src/UserBundle/Controller/Security/HubSpotRegistrationController.php b/src/UserBundle/Controller/Security/HubSpotRegistrationController.php
    new file mode 100644
    index 0000000..478b5dd
    --- /dev/null
    +++ b/src/UserBundle/Controller/Security/HubSpotRegistrationController.php
    @@ -0,0 +1,60 @@
    +get('fos_user.user_manager');
    +        /** @var \UserBundle\Entity\User $user */
    +        $user = $userManager->createUser();
    +        $user->setEnabled(true);
    +        $form = $this->createForm(HubSpotRegistrationType::class, $user);
    +
    +        $form->submit($request->request->all());
    +
    +        if ($form->isSubmitted() && $form->isValid()) {
    +            $user->setPassword('');
    +            /** @var TokenGeneratorInterface $tokenGenerator */
    +            $tokenGenerator = $this->container->get('fos_user.util.token_generator');
    +            $user->setConfirmationToken($tokenGenerator->generateToken());
    +
    +            $userManager->updateUser($user);
    +
    +            return $this->generateResponse([
    +                'code' => $user->getConfirmationToken(),
    +            ]);
    +        }
    +
    +        return $this->generateResponse($form, 400);
    +
    +    }
    +
    +}
    diff --git a/src/UserBundle/Controller/Security/PlanController.php b/src/UserBundle/Controller/Security/PlanController.php
    new file mode 100644
    index 0000000..4e626da
    --- /dev/null
    +++ b/src/UserBundle/Controller/Security/PlanController.php
    @@ -0,0 +1,57 @@
    +getManager()->getRepository(Plan::class);
    +
    +        $qb = $repository->createQueryBuilder('p');
    +        $query = $qb
    +            ->where('p.is_default = true')
    +            ->andwhere('p.title != :title')
    +            ->setParameters(array(
    +                'title' => 'Free'
    +            ));
    +        $query = $query->getQuery();
    +        $plans = $query->getResult();
    +
    +        if (count($plans) === 0) {
    +            return $this->generateResponse("Can't find plans.", 404);
    +        }
    +
    +        return $this->generateResponse($plans, 200, [
    +            'id',
    +            'plan'
    +        ]);
    +    }
    +
    +}
    diff --git a/src/UserBundle/Controller/Security/RegistrationController.php b/src/UserBundle/Controller/Security/RegistrationController.php
    new file mode 100644
    index 0000000..ac2da0b
    --- /dev/null
    +++ b/src/UserBundle/Controller/Security/RegistrationController.php
    @@ -0,0 +1,441 @@
    +get('fos_user.user_manager');
    +        $dispatcher = $this->get('event_dispatcher');
    +        /** @var \UserBundle\Entity\User $user */
    +        $user = $userManager->createUser();
    +        $user->setEnabled(true);
    +        $form = $this->createForm(RegistrationType::class, $user,  array(
    +            'paymentID' => $request->request->get('paymentID'),
    +        ));
    +        $form->submit($request->request->all());
    +        if ($form->isSubmitted() && $form->isValid()) {
    +            $passwordEncoder = $this->get('security.password_encoder');
    +            $encoded = $passwordEncoder->encodePassword($user, $form['password']->getData());
    +            $user->setPassword($encoded);
    +            
    +            // if (!empty($user->getBillingSubscription()->getPlan()->isFree())) {
    +            //     $user->getBillingSubscription()->setPayed(true);
    +            //     $userManager->updateUser($user);
    +            
    +            //     /** @var ConfigurationInterface $configuration */
    +            //     $configuration = $this->get(AppBundleServices::CONFIGURATION);
    +            
    +            //     return $this->generateResponse([
    +            //         'message' => $configuration->getParameter(ParametersName::REGISTRATION_PAYMENT_AWAITING),
    +            //         ]);
    +            // }
    +            
    +            /** @var TokenGeneratorInterface $tokenGenerator */
    +            $tokenGenerator = $this->container->get('fos_user.util.token_generator');
    +            $user->setConfirmationToken($tokenGenerator->generateToken());
    +            
    +            //stripe register user
    +            if (isset($form['paymentID'])) {
    +                $stripe = $this->get('stripe.service');
    +                $stripe->setApiKey();
    +               
    +                $customer = $stripe->createCustomer(
    +                    [
    +                    'email' => $form['email']->getData(),
    +                    'name' => $form['firstName']->getData().' '.$form['lastName']->getData(),
    +                    ]
    +                );
    +
    +                $customerArray = [];
    +                if ($customer instanceof ApiErrorException) {
    +                    $customerArray = ['paymentError' => 1,'data'=>$customer,'message'=>'Customer failed'];
    +                    return $this->generateResponse($customerArray, 400);
    +                } 
    +                if (isset($customer['id'])) {
    +                    $user->setStripeUserId($customer['id']);
    +
    +                    //Add card atatch to customer
    +                    $cardAttach = $stripe->paymentMethodAttachToCustomer($form['paymentID']->getData(),
    +                        ['customer' => $customer['id']]
    +                    );
    +
    +                    if ($cardAttach instanceof ApiErrorException) {
    +                        $cardAttachArray = ['paymentError' => 1,'data'=>$cardAttach,'message'=>'Card attached to customer failed'];
    +                        return $this->generateResponse($cardAttachArray, 500);
    +                    }
    +
    +                    //Add product
    +                    $product = $stripe->addProduct(
    +                        [
    +                        'name' => 'SOCIALHOSE.IO Media Monitoring Subscription',
    +                        'metadata' => (array)$customer['id']
    +                        ]
    +                    );
    +                    if ($product instanceof ApiErrorException) {
    +                        $productArray = ['paymentError' => 1,'data'=>$product,'message'=>'Product failed'];
    +                        return $this->generateResponse($productArray, 400);
    +                    }
    +
    +                    if (isset($product['id'])) {
    +
    +                        //Call cost calculation plan 
    +                        $costCalculation = $this->get('cost.calculation');
    +                        $response =  $costCalculation->costCalculationAction($request, true); 
    +                        //Add plan
    +                        // $plan = $stripe->addPlan(
    +                        //     [
    +                        //     'amount' => isset($response['price']) ? $response['price'] * 100 : 0,
    +                        //     'currency' => 'usd',
    +                        //     'interval' => 'month',
    +                        //     'product' => $product['id']
    +                        //     ]
    +                        // );
    +                        // if ($plan instanceof ApiErrorException) {
    +                        //     $planArray = ['paymentError' => 1,'data'=>[],'message'=>'Plan failed'];
    +                        //     return $this->generateResponse($planArray, 500);
    +                        // }
    +
    +                        $price = $stripe->addPrice(
    +                            [
    +                            'unit_amount' => isset($response['price']) ? $response['price'] * 100 : 0,
    +                            'currency' => 'usd',
    +                            'recurring' => ['interval' => 'month'],
    +                            'product' => $product['id']
    +                            ]
    +                        );
    +                        if ($price instanceof ApiErrorException) {
    +                            $priceArray = ['paymentError' => 1,'data'=>$price,'message'=>'Price add failed'];
    +                            return $this->generateResponse($priceArray, 400);
    +                        }
    +
    +                        //Plan subscription code
    +                        if (isset($price['id'])) {
    +                            //Add plan
    +                            $subscription = $stripe->createSubscription(
    +                                [
    +                                    'customer' => $customer['id'],
    +                                    'items' => [['price' => $price['id']]],
    +                                    'default_payment_method' => $form['paymentID']->getData()
    +                                ]
    +                            );
    +                            if ($subscription instanceof ApiErrorException) {
    +                                $subscriptionArray = ['paymentError' => 1,'data'=>$subscription,'message'=>'Subscribtion failed'];
    +                                return $this->generateResponse($subscriptionArray, 400);
    +                            }
    +                        }
    +                    }    
    +
    +                }
    +            }
    +            $mailer = $this->get('user.mailer.default');
    +            $baseurl = $request->getScheme() . '://' . $request->getHttpHost() . $request->getBasePath();
    +            $mailer->sendEmailMessage($user, $baseurl);
    +            $userManager->updateUser($user);
    +            
    +            return $this->generateResponse([
    +                'success' => true,
    +                'isFreeUser'=> isset($form['paymentID']) ? false : true
    +            ]);
    +        }
    +            
    +        return $this->generateResponse($form, 400);
    +    }
    +             
    +    /**
    +     * Receive the confirmation token from user email provider, login the user.
    +     *
    +     * @param Request $request
    +     * @param string  $token
    +     *
    +     * @return Response
    +     */
    +    public function confirmAction(Request $request, $token)
    +    {
    +        /** @var $userManager \FOS\UserBundle\Model\UserManagerInterface */
    +        $userManager = $this->get('fos_user.user_manager');
    +
    +        $user = $userManager->findUserByConfirmationToken($token);
    +
    +        if (null === $user) {
    +            throw new NotFoundHttpException(sprintf('The user with confirmation token "%s" does not exist', $token));
    +        }
    +
    +        /** @var $dispatcher EventDispatcherInterface */
    +        $dispatcher = $this->get('event_dispatcher');
    +
    +        $user->setConfirmationToken(null);
    +        $user->setEnabled(true);
    +        $user->setVerified(true);
    +
    +        $event = new GetResponseUserEvent($user, $request);
    +        $dispatcher->dispatch(FOSUserEvents::REGISTRATION_CONFIRM, $event);
    +
    +        $userManager->updateUser($user);
    +
    +        return $this->generateResponse([
    +            'success' => true,
    +        ]);
    +    }
    +
    +    /**
    +     * Get billing plans
    +     *
    +     * @Route("/plans", methods={ "GET" })
    +     *
    +     * @ApiDoc(
    +     *     resource="Registration",
    +     *     section="Security",
    +     *     output={
    +     *      "class"="Array",
    +     *      "groups"={ "id", "plan" }
    +     *     },
    +     *     statusCodes={
    +     *      200="All available plans."
    +     *     }
    +     * )
    +     *
    +     * @return array|\ApiBundle\Response\ViewInterface
    +     */
    +    public function billingPlansAction()
    +    {
    +        $repository = $this->getManager()->getRepository(Plan::class);
    +        $plans = $repository->findAll();
    +        if (count($plans) === 0) {
    +            return $this->generateResponse("Can't find plans.", 404);
    +        }
    +
    +        return $this->generateResponse($plans, 200, [
    +            'id',
    +            'plan',
    +        ]);
    +    }
    +
    +    /**
    +     * Organization autocomplete
    +     *
    +     * @Route("/organizationAutocomplete", methods={ "GET" })
    +     * @ApiDoc(
    +     *     resource="Registration",
    +     *     section="Security",
    +     *     filters={
    +     *      {
    +     *          "name"="organizationName",
    +     *          "dataType"="string",
    +     *          "description"="Part of organization name",
    +     *          "requirements"="[\w\s]+"
    +     *      }
    +     *     },
    +     *     output={
    +     *      "class"="",
    +     *      "data"={
    +     *          ""={
    +     *              "dataType"="Collection of string",
    +     *              "description"="Matched organization names.",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          }
    +     *      }
    +     *     },
    +     *     statusCodes={
    +     *      200="All available organization names."
    +     *     }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return string
    +     */
    +    public function organizationAutocompleteAction(Request $request)
    +    {
    +        $repository = $this->getManager()->getRepository(Organization::class);
    +        $organizationName = trim($request->query->get('organizationName'));
    +        $organizationName = implode(' ', \nspl\a\map(function ($name) {
    +            return '%'. trim($name) .'%';
    +        }, explode(' ', $organizationName)));
    +
    +        $organizations = $repository->createQueryBuilder('Organization')
    +            ->select('Organization.name')
    +            ->where('Organization.name LIKE :name')
    +            ->setParameter('name', $organizationName)
    +            ->getQuery()
    +            ->setMaxResults(self::DEFAULT_LIMIT)
    +            ->getResult();
    +
    +        return $this->generateResponse(\nspl\a\map(\nspl\op\itemGetter('name'), $organizations));
    +    }
    +
    +    /**
    +     * Get list of available gateways.
    +     *
    +     * @Route("/paymentGateways", methods={ "GET" })
    +     * @ApiDoc(
    +     *     resource="Registration",
    +     *     section="Security",
    +     *     output={
    +     *      "class"="",
    +     *      "data"={
    +     *          ""={
    +     *              "dataType"="collection of string",
    +     *              "description"="Available payment gateways"
    +     *          }
    +     *      }
    +     *     },
    +     * )
    +     *
    +     * @return array
    +     */
    +    public function gatewaysAction()
    +    {
    +        return PaymentGatewayEnum::getChoices();
    +    }
    +
    +    /**
    +     * Finish registration.
    +     *
    +     * @Route("/finish", methods={ "POST" })
    +     * @ApiDoc(
    +     *     resource="Registration",
    +     *     section="Security",
    +     *     input={
    +     *      "class"="UserBundle\Form\PaymentDataType",
    +     *      "name"=false
    +     *     },
    +     *     output={
    +     *      "class"="",
    +     *      "data"={
    +     *          "message"={
    +     *              "dataType"="string",
    +     *              "description"="Payment success message"
    +     *          }
    +     *      }
    +     *     },
    +     * )
    +     *
    +     * @param Request $request A HTTP Request instance.
    +     *
    +     * @return string
    +     */
    +    public function finishAction(Request $request)
    +    {
    +        $form = $this->createForm(PaymentDataType::class);
    +        $form->submit($request->request->all());
    +
    +        if ($form->isSubmitted() && $form->isValid()) {
    +            /** @var PaymentData $data */
    +            $data = $form->getData();
    +
    +            $gatewayName = $data->getGateway();
    +            $subscription = $data->getUser()->getBillingSubscription();
    +            $creditCard = $data->getCreditCard();
    +
    +            if ($subscription === null) {
    +                return $this->generateResponse([ 'Unknown confirmation token' ], 400);
    +            }
    +
    +            /** @var PaymentGatewayFactoryInterface $gatewayFactory */
    +            $gatewayFactory = $this->get(PaymentBundleServices::PAYMENT_GATEWAY_FACTORY);
    +            $gateway = $gatewayFactory->getGateway($gatewayName);
    +
    +            $billingSubscription = new BillingSubscription($subscription, $subscription->getPlan(), $creditCard);
    +            $gateway->executeSubscription($billingSubscription);
    +
    +            $user = $data->getUser();
    +            $user->setConfirmationToken(null);
    +
    +            /** @var EntityManagerInterface $em */
    +            $em = $this->get('doctrine.orm.default_entity_manager');
    +
    +            $em->persist($user);
    +            $em->flush();
    +
    +            /** @var ConfigurationInterface $configuration */
    +            $configuration = $this->get(AppBundleServices::CONFIGURATION);
    +
    +            return $this->generateResponse([
    +                'message' => $configuration->getParameter(ParametersName::REGISTRATION_PAYMENT_AWAITING),
    +            ]);
    +        }
    +
    +        return $this->generateResponse($form, 400);
    +    }
    +}
    diff --git a/src/UserBundle/Controller/Security/ResettingController.php b/src/UserBundle/Controller/Security/ResettingController.php
    new file mode 100644
    index 0000000..265db6a
    --- /dev/null
    +++ b/src/UserBundle/Controller/Security/ResettingController.php
    @@ -0,0 +1,171 @@
    +createForm(ResettingRequestType::class);
    +
    +        $form->submit($request->request->all());
    +        if ($form->isValid()) {
    +            $data = $form->getData();
    +
    +            /** @var UserManagerInterface $manager */
    +            $manager = $this->get('fos_user.user_manager');
    +
    +            $user = $manager->findUserByEmail($data['email']);
    +            if (! $user instanceof User) {
    +                $message = "A recovery link has been sent to {$data['email']}, if found in our system.";
    +                return $this->generateResponse($message, 400);
    +            }
    +
    +            if (! $user->isEnabled()) {
    +                // This user id locked, so we don't send reset email to him.
    +                return $this->generateResponse('User is locked.', 400);
    +            }
    +
    +            $ttl = $this->container->getParameter('fos_user.resetting.token_ttl');
    +            if ($user->isPasswordRequestNonExpired($ttl)) {
    +                // This user already request password changing and reset token is not
    +                // expired yet.
    +                return $this->generateResponse('Already requested.', 400);
    +            }
    +
    +            // Generate new confirmation token.
    +            /** @var TokenGeneratorInterface $tokenGenerator */
    +            $tokenGenerator = $this->get('fos_user.util.token_generator');
    +            $user->setConfirmationToken($tokenGenerator->generateToken());
    +            $user->setPasswordRequestedAt(new \DateTime());
    +            $manager->updateUser($user);
    +
    +            // Send confirmation email to user.
    +            /** @var MailerInterface $mailer */
    +            $mailer = $this->get(UserBundleServices::MAILER);
    +            $mailer->sendPasswordResettingConfirmation($user);
    +
    +            return $this->generateResponse();
    +        }
    +
    +        return $this->generateResponse($form, 400);
    +    }
    +
    +    /**
    +     * Confirm password resetting.
    +     *
    +     * Example
    +     *
    +     * Request:
    +     * ```json
    +     * {
    +     *  "confirmationToken": "12dasv ...",
    +     *  "password": "newPassword"
    +     * }
    +     * ```
    +     *
    +     * @Route("/confirm", methods={ "POST" })
    +     * @ApiDoc(
    +     *  resource="Resetting",
    +     *  section="Security",
    +     *  input={
    +     *     "class"="UserBundle\Form\ResettingConfirmType",
    +     *     "name"=false
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function confirmAction(Request $request)
    +    {
    +        $form = $this->createForm(ResettingConfirmType::class);
    +
    +        $form->submit($request->request->all());
    +        if ($form->isValid()) {
    +            /** @var UserManagerInterface $userManager */
    +            $userManager = $this->get('fos_user.user_manager');
    +
    +            $data = $form->getData();
    +
    +            $user = $userManager
    +                ->findUserByConfirmationToken($data['confirmationToken']);
    +
    +            if ($user === null) {
    +                // Can't find user by provided confirmation token
    +                return $this->generateResponse('Invalid token.', 400);
    +            }
    +
    +            if (! $user->isEnabled()) {
    +                // This user id locked, so we don't send reset email to him.
    +                return $this->generateResponse('User is locked.', 400);
    +            }
    +
    +            $ttl = $this->container->getParameter('fos_user.resetting.token_ttl');
    +            if (! $user->isPasswordRequestNonExpired($ttl)) {
    +                // This token is expired.
    +                return $this->generateResponse('Confirmation token expired.', 400);
    +            }
    +
    +            // All ok.
    +            $user
    +                ->setPlainPassword($data['password'])
    +                ->setConfirmationToken(null)
    +                ->setPasswordRequestedAt(null);
    +            $userManager->updateUser($user);
    +
    +            return $this->generateResponse();
    +        }
    +
    +        return $this->generateResponse($form, 400);
    +    }
    +}
    diff --git a/src/UserBundle/Controller/V1/AbstractRecipientController.php b/src/UserBundle/Controller/V1/AbstractRecipientController.php
    new file mode 100644
    index 0000000..7887711
    --- /dev/null
    +++ b/src/UserBundle/Controller/V1/AbstractRecipientController.php
    @@ -0,0 +1,124 @@
    +getManager();
    +
    +            foreach ($recipients as $recipient) {
    +                $em->remove($recipient);
    +            }
    +
    +            $em->flush();
    +        };
    +
    +        return $this->batchProcessing(
    +            $request,
    +            InspectorInterface::DELETE,
    +            EntitiesBatchType::class,
    +            $processor
    +        );
    +    }
    +
    +    /**
    +     * Batch activate/deactivate recipients.
    +     *
    +     * @param Request $request A HTTP Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    protected function batchActiveToggle(Request $request)
    +    {
    +        $processor = function (Collection $recipients, $active) {
    +            $em = $this->getManager();
    +
    +            foreach ($recipients as $recipient) {
    +                $recipient->setActive($active);
    +                $em->persist($recipient);
    +            }
    +            $em->flush();
    +        };
    +
    +        return $this->batchProcessing(
    +            $request,
    +            InspectorInterface::UPDATE,
    +            ActivatedEntitiesBatchType::class,
    +            $processor
    +        );
    +    }
    +
    +    /**
    +     * Batch subscribe/unsubscribe specified recipient from notifications.
    +     *
    +     * @param Request $request A HTTP Request instance.
    +     * @param integer $id      A recipient entity instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    protected function batchSubscriptionToggle(Request $request, $id)
    +    {
    +        $recipient = $this->getManager()->getRepository($this->entity)->find($id);
    +
    +        if ($recipient === null) {
    +            $name = \app\c\getShortName($this->entity);
    +            // Remove 'Abstract' prefix if it exists.
    +            if (strpos($name, 'Abstract') !== false) {
    +                $name = substr($name, 8);
    +            }
    +
    +            return $this->generateResponse("Can't find {$name} with id {$id}.", 404);
    +        }
    +
    +        $reasons = $this->checkAccess(NotificationInspector::UPDATE, $recipient);
    +        if (count($reasons) > 0) {
    +            return $this->generateResponse($reasons, 403);
    +        }
    +
    +        $processor = function (Collection $notifications, $subscribed) use ($recipient) {
    +            /** @var NotificationManagerInterface $manager */
    +            $manager = $this->get(UserBundleServices::NOTIFICATION_MANAGER);
    +            $manager->subscriptionToggle(
    +                $recipient,
    +                $notifications->toArray(),
    +                (bool) $subscribed
    +            );
    +        };
    +
    +        return $this->batchProcessing(
    +            $request,
    +            function (Collection $notifications, $subscribed) {
    +                return $subscribed ? NotificationInspector::SUBSCRIBE : NotificationInspector::UNSUBSCRIBE;
    +            },
    +            SubscribeToNotificationsBatchType::class,
    +            $processor
    +        );
    +    }
    +}
    diff --git a/src/UserBundle/Controller/V1/CurrentSubscriberController.php b/src/UserBundle/Controller/V1/CurrentSubscriberController.php
    new file mode 100644
    index 0000000..ecf8450
    --- /dev/null
    +++ b/src/UserBundle/Controller/V1/CurrentSubscriberController.php
    @@ -0,0 +1,272 @@
    +",
    +     *     "groups"={ "subscriber", "id" }
    +     *  },
    +     *  statusCodes={
    +     *     200="Subscribers successfully returned."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function listAction(Request $request)
    +    {
    +        /** @var UserRepository $repository */
    +        $repository = $this->getManager()->getRepository('UserBundle:User');
    +        $user = $this->getCurrentUser();
    +
    +        $pagination = $this->paginate(
    +            $request,
    +            $repository->getSubscribersQueryBuilder($user->getId())
    +        );
    +
    +        return $this->generateResponse($pagination, 200, [ 'subscriber', 'id' ]);
    +    }
    +
    +    /**
    +     * Get information about subscriber.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("/{id}", requirements={ "id"="\d+" }, methods={ "GET" })
    +     * @ApiDoc(
    +     *  resource="Current user subscribers",
    +     *  section="User",
    +     *  output={
    +     *     "class"="Pagination",
    +     *     "groups"={ "subscriber", "id" }
    +     *  },
    +     *  statusCodes={
    +     *     200="Subscriber successfully returned."
    +     *  }
    +     * )
    +     *
    +     * @param integer $id A subscriber User entity id.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function getAction($id)
    +    {
    +        /** @var UserManagerInterface $manager */
    +        $manager = $this->get('fos_user.user_manager');
    +
    +        $current = $this->getCurrentUser();
    +        $user = $manager->findUserBy([
    +            'id' => $id,
    +            'masterUser' => $current->getId(),
    +        ]);
    +
    +        if ($user === null) {
    +            return $this->generateResponse("Can't find subscriber with id {$id}.", 404);
    +        }
    +
    +        return $this->generateResponse($user, 200, [ 'subscriber', 'id' ]);
    +    }
    +
    +    /**
    +     * Create new subscriber for current user.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("", methods={ "POST" })
    +     * @ApiDoc(
    +     *  resource="Current user subscribers",
    +     *  section="User",
    +     *  input={
    +     *     "class"="UserBundle\Form\SubscriberType",
    +     *     "name"=false
    +     *  },
    +     *  output={
    +     *     "class"="Pagination",
    +     *     "groups"={ "subscriber", "id" }
    +     *  },
    +     *  statusCodes={
    +     *     200="Subscriber successfully created."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return User|\ApiBundle\Response\ViewInterface
    +     */
    +    public function createAction(Request $request)
    +    {
    +        $current = $this->getCurrentUser();
    +        $user = new User();
    +        $user
    +            ->generatePassword()
    +            ->setMasterUser($current)
    +            ->setRoles([ UserRoleEnum::SUBSCRIBER ]);
    +
    +        $form = $this->createForm(SubscriberType::class, $user);
    +
    +        $form->submit($request->request->all());
    +        if ($form->isValid()) {
    +            /** @var UserManagerInterface $manager */
    +            $manager = $this->get('fos_user.user_manager');
    +
    +            $password = $user->getPlainPassword();
    +            $manager->updateUser($user);
    +
    +            /** @var MailerInterface $mailer */
    +            $mailer = $this->get(UserBundleServices::MAILER);
    +            $mailer->sendPassword($user, $password);
    +
    +            return $this->generateResponse($user, 200, [ 'subscriber', 'id' ]);
    +        }
    +
    +        return $this->generateResponse($form, 400);
    +    }
    +
    +    /**
    +     * Update current user subscriber.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("/{id}", requirements={ "id"="\d+" }, methods={ "PUT" })
    +     * @ApiDoc(
    +     *  resource="Current user subscribers",
    +     *  section="User",
    +     *  input={
    +     *     "class"="UserBundle\Form\SubscriberType",
    +     *     "name"=false
    +     *  },
    +     *  output={
    +     *     "class"="Pagination",
    +     *     "groups"={ "subscriber", "id" }
    +     *  },
    +     *  statusCodes={
    +     *     200="Subscriber successfully updated.",
    +     *     404="Can't find category by specified id."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     * @param integer $id      A subscriber User entity id.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function putAction(Request $request, $id)
    +    {
    +        /** @var UserManagerInterface $manager */
    +        $manager = $this->get('fos_user.user_manager');
    +
    +        $current = $this->getCurrentUser();
    +        $user = $manager->findUserBy([
    +            'id' => $id,
    +            'masterUser' => $current->getId(),
    +        ]);
    +
    +        if ($user === null) {
    +            return $this->generateResponse("Can't find subscriber with id {$id}.", 404);
    +        }
    +
    +        $form = $this->createForm(SubscriberType::class, $user, [
    +            'method' => 'PUT',
    +        ]);
    +
    +        $form->submit($request->request->all());
    +        if ($form->isValid()) {
    +            $manager->updateUser($user);
    +
    +            return $this->generateResponse($user, 200, [ 'subscriber', 'id' ]);
    +        }
    +
    +        return $this->generateResponse($form, 400);
    +    }
    +
    +    /**
    +     * Update current user subscriber.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("/{id}", requirements={ "id"="\d+" }, methods={ "DELETE" })
    +     * @ApiDoc(
    +     *  resource="Current user subscribers",
    +     *  section="User",
    +     *  statusCodes={
    +     *     200="Subscriber successfully deleted.",
    +     *     404="Can't find category by specified id."
    +     *  }
    +     * )
    +     *
    +     * @param integer $id A subscriber User entity id.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function deleteAction($id)
    +    {
    +        /** @var UserManagerInterface $manager */
    +        $manager = $this->get('fos_user.user_manager');
    +
    +        $current = $this->getCurrentUser();
    +        $user = $manager->findUserBy([
    +            'id' => $id,
    +            'masterUser' => $current->getId(),
    +        ]);
    +
    +        if ($user === null) {
    +            return $this->generateResponse("Can't find subscriber with id {$id}.", 404);
    +        }
    +
    +        $manager->deleteUser($user);
    +
    +        return $this->generateResponse();
    +    }
    +}
    diff --git a/src/UserBundle/Controller/V1/GroupRecipientController.php b/src/UserBundle/Controller/V1/GroupRecipientController.php
    new file mode 100644
    index 0000000..3f8587f
    --- /dev/null
    +++ b/src/UserBundle/Controller/V1/GroupRecipientController.php
    @@ -0,0 +1,440 @@
    +get('doctrine.orm.default_entity_manager');
    +        $recipientId = trim($request->query->get('recipientId'));
    +        $currentUser = $this->getCurrentUser();
    +
    +        //
    +        // Get sort parameters and filter.
    +        //
    +        $sortingOptions = SortingOptions::fromRequest($request, 'name');
    +        $filter = trim($request->query->get('filter'));
    +
    +        /** @var GroupRecipientRepository $groupRepository */
    +        $groupRepository = $em->getRepository(GroupRecipient::class);
    +
    +        //
    +        // If we got group id we should try to fetch proper recipient group and
    +        // check that user can get this group recipient.
    +        //
    +        if ($recipientId !== '') {
    +            /** @var PersonRecipientRepository $personRepository */
    +            $personRepository = $em->getRepository(PersonRecipient::class);
    +            $person = $personRepository->getForUser($recipientId, $currentUser->getId());
    +
    +            if (! $person instanceof PersonRecipient) {
    +                return $this->generateResponse("Can't find recipient with id {$recipientId}.", 404);
    +            }
    +
    +            //
    +            // Check access.
    +            //
    +            $reasons = $this->checkAccess(InspectorInterface::READ, $person);
    +            if (count($reasons) > 0) {
    +                return $this->generateResponse($reasons, 403);
    +            }
    +
    +            $statusFilter = trim($request->query->get('statusFilter', StatusFilterEnum::ALL));
    +
    +            if ($statusFilter !== '') {
    +                if (! StatusFilterEnum::isValid($statusFilter)) {
    +                    return $this->generateResponse("'statusFilter' should be one of ". implode(', ', StatusFilterEnum::getAvailables()));
    +                }
    +
    +                $statusFilter = new StatusFilterEnum($statusFilter);
    +            }
    +
    +            $additionalCond = AdditionalConditions::fromRequest($request);
    +
    +            $qb = $groupRepository->getQueryBuilderForPerson(
    +                $currentUser->getId(),
    +                $person->getId(),
    +                $statusFilter,
    +                $sortingOptions,
    +                $filter,
    +                $additionalCond
    +            );
    +        } else {
    +            $qb = $groupRepository->getQueryBuilderForUser(
    +                $currentUser->getId(),
    +                $sortingOptions,
    +                $filter
    +            );
    +        }
    +
    +        //
    +        // We should get all paginated data and put 'subscribed' field value into
    +        // Notification entity.
    +        //
    +        /** @var SlidingPagination $pagination */
    +        $pagination = $this->paginate($request, $qb);
    +
    +        $serializationGroups = [ 'recipient', 'id' ];
    +        if ($recipientId !== '') {
    +            $data = array_map(function (array $element) {
    +                /** @var AbstractRecipient $recipient */
    +                $recipient = $element[0];
    +
    +                $recipient->enrolled = (bool) $element['enrolled'];
    +
    +                return $recipient;
    +            }, iterator_to_array($pagination));
    +
    +            $serializationGroups[] = 'sublist';
    +            $totalCount = $pagination->getTotalItemCount();
    +
    +            $pagination = $this->paginate($request, $data);
    +            $pagination->setTotalItemCount($totalCount);
    +        }
    +
    +        return $this->generateResponse(
    +            [
    +                'groups' => $pagination,
    +                'meta' => [ 'sort' => $sortingOptions ],
    +            ],
    +            200,
    +            $serializationGroups
    +        );
    +    }
    +
    +    /**
    +     * Create new recipient group.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("", methods={ "POST" })
    +     * @ApiDoc(
    +     *  resource="Group",
    +     *  section="Receivers",
    +     *  input={
    +     *     "class"="UserBundle\Form\GroupRecipientType",
    +     *     "name"=false
    +     *  },
    +     *  output={
    +     *      "class"="UserBundle\Entity\Recipient\GroupRecipient",
    +     *      "groups"={ "id", "recipient" }
    +     *  },
    +     *  statusCodes={
    +     *     204="New recipient group successfully created.",
    +     *     400="Invalid parameters."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Entity\ManageableEntityInterface|\ApiBundle\Response\ViewInterface
    +     */
    +    public function createAction(Request $request)
    +    {
    +        return parent::createEntity($request, GroupRecipient::create()->setOwner($this->getCurrentUser()));
    +    }
    +
    +    /**
    +     * Update specified recipient group.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("/{id}", methods={ "PUT" }, requirements={ "id": "\d*" })
    +     * @ApiDoc(
    +     *  resource="Group",
    +     *  section="Receivers",
    +     *  input={
    +     *     "class"="UserBundle\Form\GroupRecipientType",
    +     *     "name"=false
    +     *  },
    +     *  output={
    +     *      "class"="UserBundle\Entity\Recipient\GroupRecipient",
    +     *      "groups"={ "id", "recipient" }
    +     *  },
    +     *  statusCodes={
    +     *     204="Recipient group successfully updated.",
    +     *     400="Invalid parameters."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     * @param integer $id      A GroupRecipient entity id.
    +     *
    +     * @return \ApiBundle\Entity\ManageableEntityInterface|\ApiBundle\Response\ViewInterface
    +     */
    +    public function putAction(Request $request, $id)
    +    {
    +        return parent::putEntity($request, $id);
    +    }
    +
    +    /**
    +     * Delete recipient groups.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("/delete", methods={ "POST" })
    +     * @ApiDoc(
    +     *  resource="Group",
    +     *  section="Receivers",
    +     *  input={
    +     *     "class"="",
    +     *      "data"={
    +     *          "ids"={
    +     *              "dataType"="Array of recipient group ids",
    +     *              "actualType"="collection",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *      }
    +     *      }
    +     *  },
    +     *  output={
    +     *      "class"="UserBundle\Entity\Recipient\GroupRecipient",
    +     *      "groups"={ "id", "recipient" }
    +     *  },
    +     *  statusCodes={
    +     *     204="Recipient groups successfully deleted.",
    +     *     400="Invalid parameters."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface|null
    +     */
    +    public function deleteAction(Request $request)
    +    {
    +        return $this->batchDelete($request);
    +    }
    +
    +    /**
    +     * Activate/deactivate recipient groups.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("/active", methods={ "PUT" })
    +     * @ApiDoc(
    +     *  resource="Group",
    +     *  section="Receivers",
    +     *  input={
    +     *     "class"="",
    +     *      "data"={
    +     *          "ids"={
    +     *              "dataType"="Array of recipient groups ids",
    +     *              "actualType"="collection",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          },
    +     *          "active"={
    +     *              "dataType"="Boolean flag",
    +     *              "actualType"="boolean",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          }
    +     *      }
    +     *
    +     *  },
    +     *  statusCodes={
    +     *     204="Recipient groups successfully activated/deactivated."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function activateAction(Request $request)
    +    {
    +        return $this->batchActiveToggle($request);
    +    }
    +
    +    /**
    +     * Subscribe/unsubscribe recipient from specified notifications.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("/{id}/subscribe", methods={ "PUT" })
    +     * @ApiDoc(
    +     *  resource="Group",
    +     *  section="Receivers",
    +     *  input={
    +     *     "class"="",
    +     *      "data"={
    +     *          "ids"={
    +     *              "dataType"="Array of recipients ids",
    +     *              "actualType"="collection",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          },
    +     *          "subscribe"={
    +     *              "dataType"="Boolean flag",
    +     *              "actualType"="boolean",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          }
    +     *      }
    +     *
    +     *  },
    +     *  statusCodes={
    +     *     204="Recipient group successfully subscribed/unsubscribed."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     * @param integer $id      A PersonRecipient entity instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function subscribeAction(Request $request, $id)
    +    {
    +        return $this->batchSubscriptionToggle($request, $id);
    +    }
    +}
    diff --git a/src/UserBundle/Controller/V1/NotificationController.php b/src/UserBundle/Controller/V1/NotificationController.php
    new file mode 100644
    index 0000000..4a15f7e
    --- /dev/null
    +++ b/src/UserBundle/Controller/V1/NotificationController.php
    @@ -0,0 +1,1042 @@
    +",
    +     *          "groups"={ "notification_list", "schedule", "id" }
    +     *      },
    +     *      "meta"={
    +     *          "dataType"="model",
    +     *          "description"="Response meta information",
    +     *          "required"=true,
    +     *          "readonly"=true,
    +     *          "children"={
    +     *           "sort"={
    +     *               "dataType"="model",
    +     *               "requited"=true,
    +     *               "readonly"=true,
    +     *               "children"={
    +     *                   "field"={
    +     *                       "dataType"="string",
    +     *                       "description"="Field name for sorting. Available:
    +     *                       name, type, published, sourcesCount, status",
    +     *                       "required"=true,
    +     *                       "readonly"=true
    +     *                   },
    +     *                   "direction"={
    +     *                       "dataType"="string",
    +     *                       "description"="Sort direction. Available: asc,
    +     *                       desc",
    +     *                       "required"=true,
    +     *                       "readonly"=true
    +     *                   }
    +     *               }
    +     *           }
    +     *          }
    +     *      }
    +     *     }
    +     *  },
    +     *  statusCodes={
    +     *     200="Subscribers successfully returned."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function listAction(Request $request)
    +    {
    +        /** @var NotificationRepository $repository */
    +        $repository = $this->getManager()->getRepository(Notification::class);
    +
    +        $currentUser = $this->getCurrentUser();
    +        $sortingOptions = SortingOptions::fromRequest($request, 'name');
    +
    +        $entityId = trim($request->query->get('entityId'));
    +        $nameFilter = trim($request->query->get('filter'));
    +        if ($entityId !== '') {
    +            /** @var RecipientRepository $recipientRepository */
    +            $recipientRepository = $this->getManager()->getRepository(AbstractRecipient::class);
    +            $recipient = $recipientRepository->find($entityId);
    +            if (! $recipient instanceof AbstractRecipient) {
    +                return $this->generateResponse("Can't find  recipient or group recipient with id {$entityId}.", 404);
    +            }
    +            //
    +            // Check access.
    +            //
    +            $reasons = $this->checkAccess(InspectorInterface::READ, $recipient);
    +            if (count($reasons) > 0) {
    +                return $this->generateResponse($reasons, 403);
    +            }
    +
    +            $statusFilter = trim($request->query->get('statusFilter', StatusFilterEnum::ALL));
    +
    +            if ($statusFilter !== '') {
    +                if (! StatusFilterEnum::isValid($statusFilter)) {
    +                    return $this->generateResponse("'statusFilter' should be one of ". implode(', ', StatusFilterEnum::getAvailables()));
    +                }
    +
    +                $statusFilter = new StatusFilterEnum($statusFilter);
    +            }
    +            $qb = $repository->getQueryBuilderForRecipient(
    +                $recipient,
    +                $currentUser,
    +                $sortingOptions,
    +                $statusFilter,
    +                $nameFilter
    +            );
    +        } else {
    +            $onlyPublished = $request->query->get('onlyPublished', 'false') !== 'false';
    +            $qb = $repository->getQueryBuilder($sortingOptions, $currentUser, $onlyPublished, $nameFilter);
    +        }
    +
    +        //
    +        // We should get all paginated data and put 'subscribed' field value into
    +        // Notification entity.
    +        //
    +        /** @var SlidingPagination $pagination */
    +        $pagination = $this->paginate($request, $qb);
    +        $elements = iterator_to_array($pagination);
    +        $elements = array_map(function (array $element) {
    +            /** @var Notification $notification */
    +            $notification = $element[0];
    +
    +            $notification->subscribed = (bool) $element['subscribed'];
    +
    +            return $notification;
    +        }, $elements);
    +
    +        return $this->generateResponse(
    +            [
    +                'notifications' => [
    +                    'data' => $elements,
    +                    'count' => count($elements),
    +                    'totalCount' => $pagination->getTotalItemCount(),
    +                    'page' => $pagination->getCurrentPageNumber(),
    +                    'limit' => $pagination->getItemNumberPerPage(),
    +                ],
    +                'meta' => [ 'sort' => $sortingOptions ],
    +            ],
    +            200,
    +            [ 'notification_list', 'schedule', 'id' ]
    +        );
    +    }
    +
    +    /**
    +     * Create new no.
    +     *
    +     * @Roles("ROLE_SUBSCRIBER")
    +     *
    +     * @Route("", methods={ "POST" })
    +     * @ApiDoc(
    +     *  resource=true,
    +     *  section="Notification",
    +     *  input={
    +     *     "class"="UserBundle\Form\NotificationType",
    +     *     "name"=false
    +     *  },
    +     *  output={
    +     *     "class"="UserBundle\Entity\Notification\Notification",
    +     *     "groups"={ "notification", "schedule", "id" }
    +     *  },
    +     *  statusCodes={
    +     *     200="Subscribers successfully returned."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function createAction(Request $request)
    +    {
    +        $user = $this->getCurrentUser();
    +        $notification = Notification::create()
    +            ->setOwner($user)
    +            ->setBillingSubscription($user->getBillingSubscription());
    +
    +        $form = $this->createForm(NotificationType::class, $notification);
    +
    +        $form->submit($request->request->all());
    +        if ($form->isSubmitted() && $form->isValid()) {
    +            $reasons = $this->checkAccess(NotificationInspector::CREATE, $notification);
    +            if (count($reasons) > 0) {
    +                return $this->generateResponse($reasons, 403);
    +            }
    +
    +            $appLimit = $notification->getNotificationType()->toAppLimit();
    +            try {
    +                $user->useLimit($appLimit);
    +            } catch (LimitExceedException $exception) {
    +                return $this->generateResponse([
    +                    'failedRestriction' => (string) $appLimit,
    +                    'restrictions' => $user->getRestrictions(),
    +                ], 402);
    +            }
    +
    +            /** @var NotificationManagerInterface $manager */
    +            $manager = $this->get(UserBundleServices::NOTIFICATION_MANAGER);
    +            $manager->persists($notification);
    +
    +            $this->getManager()->persist($user);
    +            $this->getManager()->flush();
    +
    +            return $this->generateResponse($notification, 200, [
    +                'notification',
    +                'schedule',
    +                'id',
    +            ]);
    +        }
    +
    +        return $this->generateResponse($form, 400);
    +    }
    +
    +    /**
    +     * Update alert.
    +     *
    +     * @Roles("ROLE_SUBSCRIBER")
    +     *
    +     * @Route("/{id}", methods={ "PUT" }, requirements={ "id"="\d+" })
    +     * @ApiDoc(
    +     *  resource=true,
    +     *  section="Notification",
    +     *  input={
    +     *     "class"="UserBundle\Form\NotificationType",
    +     *     "name"=false
    +     *  },
    +     *  output={
    +     *     "class"="UserBundle\Entity\Notification\Notification",
    +     *     "groups"={ "notification", "schedule", "id" }
    +     *  },
    +     *  statusCodes={
    +     *     200="Subscribers successfully returned."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     * @param integer $id      A Notification entity id.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function updateAction(Request $request, $id)
    +    {
    +        /** @var NotificationRepository $repository */
    +        $repository = $this->getManager()->getRepository(Notification::class);
    +
    +        $notification = $repository->get($id);
    +        if (! $notification instanceof Notification) {
    +            return $this->generateResponse("Can't find notification with id {$id}.", 404);
    +        }
    +
    +        $form = $this->createForm(NotificationType::class, $notification);
    +
    +        $form->submit($request->request->all());
    +        if ($form->isSubmitted() && $form->isValid()) {
    +            $reasons = $this->checkAccess(NotificationInspector::UPDATE, $notification);
    +            if (count($reasons) > 0) {
    +                return $this->generateResponse($reasons, 403);
    +            }
    +
    +            /** @var NotificationManagerInterface $manager */
    +            $manager = $this->get(UserBundleServices::NOTIFICATION_MANAGER);
    +            $manager->persists($notification);
    +
    +            return $this->generateResponse($notification, 200, [
    +                'notification',
    +                'schedule',
    +                'id',
    +            ]);
    +        }
    +
    +        return $this->generateResponse($form, 400);
    +    }
    +
    +    /**
    +     * Get notification.
    +     *
    +     * @Roles("ROLE_SUBSCRIBER")
    +     *
    +     * @Route("/{id}", methods={ "GET" }, requirements={ "id"="\d+" })
    +     * @ApiDoc(
    +     *  resource=true,
    +     *  section="Notification",
    +     *  output={
    +     *     "class"="UserBundle\Entity\Notification\Notification",
    +     *     "groups"={ "notification", "schedule", "id" }
    +     *  },
    +     *  statusCodes={
    +     *     200="Subscribers successfully returned."
    +     *  }
    +     * )
    +     *
    +     * @param integer $id A Notification entity id.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function getAction($id)
    +    {
    +        /** @var NotificationRepository $repository */
    +        $repository = $this->getManager()->getRepository(Notification::class);
    +
    +        $notification = $repository->get($id);
    +        if (! $notification instanceof Notification) {
    +            return $this->generateResponse("Can't find notification with id {$id}.", 404);
    +        }
    +        $reasons = $this->checkAccess(NotificationInspector::READ, $notification);
    +        if (count($reasons) > 0) {
    +            return $this->generateResponse($reasons, 403);
    +        }
    +
    +        return $this->generateResponse($notification, 200, [
    +            'notification',
    +            'schedule',
    +            'id',
    +        ]);
    +    }
    +
    +    /**
    +     * Activate notifications.
    +     *
    +     * @Roles("ROLE_SUBSCRIBER")
    +     *
    +     * @Route("/active", methods={ "PUT" })
    +     * @ApiDoc(
    +     *  resource=true,
    +     *  section="Notification",
    +     *  input={
    +     *     "class"="",
    +     *      "data"={
    +     *          "ids"={
    +     *              "dataType"="Array of notifications ids",
    +     *              "actualType"="collection",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          },
    +     *          "active"={
    +     *              "dataType"="Boolean flag",
    +     *              "actualType"="boolean",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          }
    +     *      }
    +     *
    +     *  },
    +     *  statusCodes={
    +     *     204="Notification successfully activated."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function activeAction(Request $request)
    +    {
    +        $processor = function (Collection $notifications, $active) {
    +            /** @var NotificationManagerInterface $manager */
    +            /** @var NotificationManagerInterface $manager */
    +            $manager = $this->get(UserBundleServices::NOTIFICATION_MANAGER);
    +            $manager->activatedToggle(
    +                $notifications->toArray(),
    +                (bool) $active
    +            );
    +        };
    +
    +        return $this->batchProcessing(
    +            $request,
    +            NotificationInspector::UPDATE,
    +            ActivatedEntitiesBatchType::class,
    +            $processor
    +        );
    +    }
    +
    +    /**
    +     * Publish notifications.
    +     *
    +     * @Roles("ROLE_SUBSCRIBER")
    +     *
    +     * @Route("/published", methods={ "PUT" })
    +     * @ApiDoc(
    +     *  resource=true,
    +     *  section="Notification",
    +     *  input={
    +     *     "class"="",
    +     *      "data"={
    +     *          "ids"={
    +     *              "dataType"="Array of notifications ids",
    +     *              "actualType"="collection",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          },
    +     *          "published"={
    +     *              "dataType"="Boolean flag",
    +     *              "actualType"="boolean",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          }
    +     *      }
    +     *  },
    +     *  statusCodes={
    +     *     200="Subscribers successfully returned."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function publishedAction(Request $request)
    +    {
    +        $processor = function (Collection $notifications, $subscribed) {
    +            /** @var NotificationManagerInterface $manager */
    +            /** @var NotificationManagerInterface $manager */
    +            $manager = $this->get(UserBundleServices::NOTIFICATION_MANAGER);
    +            $manager->publishedToggle(
    +                $notifications->toArray(),
    +                (bool) $subscribed
    +            );
    +        };
    +
    +        return $this->batchProcessing(
    +            $request,
    +            NotificationInspector::UPDATE,
    +            PublishedEntitiesBatchType::class,
    +            $processor
    +        );
    +    }
    +
    +    /**
    +     * Subscribe alert.
    +     *
    +     * @Roles("ROLE_SUBSCRIBER")
    +     *
    +     * @Route("/subscribe", methods={ "POST" })
    +     * @ApiDoc(
    +     *  resource=true,
    +     *  section="Notification",
    +     *  input={
    +     *     "class"="",
    +     *      "data"={
    +     *          "ids"={
    +     *              "dataType"="Array of notifications ids",
    +     *              "actualType"="collection",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          },
    +     *          "subscribed"={
    +     *              "dataType"="Boolean flag",
    +     *              "actualType"="boolean",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          }
    +     *      }
    +     *  },
    +     *  statusCodes={
    +     *     200="Subscribers successfully returned."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function subscribeAction(Request $request)
    +    {
    +        $processor = function (Collection $notifications, $subscribed) {
    +            $user = $this->getCurrentUser();
    +
    +            /** @var NotificationManagerInterface $manager */
    +            $manager = $this->get(UserBundleServices::NOTIFICATION_MANAGER);
    +            $manager->subscriptionToggle(
    +                $user->getRecipient(),
    +                $notifications->toArray(),
    +                (bool) $subscribed
    +            );
    +
    +            if ($subscribed === false) {
    +                /** @var Notification $notification */
    +                foreach ($notifications as $notification) {
    +                    if ($notification->isUnsubscribeNotification()) {
    +                        /** @var MailerInterface $mailer */
    +                        $mailer = $this->get(UserBundleServices::MAILER);
    +                        $mailer->sendUnsubscribe($notification, $user);
    +                    }
    +                }
    +            }
    +        };
    +
    +        return $this->batchProcessing(
    +            $request,
    +            function (Collection $notifications, $subscribed) {
    +                return $subscribed ? NotificationInspector::SUBSCRIBE : NotificationInspector::UNSUBSCRIBE;
    +            },
    +            NotificationSubscribeBatchType::class,
    +            $processor
    +        );
    +    }
    +
    +    /**
    +     * Delete notification.
    +     *
    +     * @Roles("ROLE_SUBSCRIBER")
    +     *
    +     * @Route("/delete", methods={ "POST" })
    +     * @ApiDoc(
    +     *  resource=true,
    +     *  section="Notification",
    +     *  input={
    +     *     "class"="",
    +     *      "data"={
    +     *          "ids"={
    +     *              "dataType"="Array of notifications ids",
    +     *              "actualType"="collection",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *      }
    +     *      }
    +     *  },
    +     *  statusCodes={
    +     *     204="Notifications successfully removed."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function deleteAction(Request $request)
    +    {
    +        $processor = function (Collection $notifications) {
    +            /** @var NotificationManagerInterface $manager */
    +            $manager = $this->get(UserBundleServices::NOTIFICATION_MANAGER);
    +            $manager->remove($notifications->toArray());
    +
    +            /** @var Notification $notification */
    +            $user = $this->getCurrentUser();
    +            foreach ($notifications as $notification) {
    +                $appLimit = $notification->getNotificationType()->toAppLimit();
    +
    +                $user->releaseLimit($appLimit);
    +            }
    +
    +            $this->getManager()->persist($user);
    +            $this->getManager()->flush();
    +        };
    +
    +        return $this->batchProcessing(
    +            $request,
    +            NotificationInspector::DELETE,
    +            EntitiesBatchType::class,
    +            $processor
    +        );
    +    }
    +
    +    /**
    +     * sendHistory alert.
    +     *
    +     * @Roles("ROLE_SUBSCRIBER")
    +     *
    +     * @Route("/{id}/history", methods={ "GET" }, requirements={ "id"="\d+" })
    +     * @ApiDoc(
    +     *  resource=true,
    +     *  section="Notification",
    +     *  filters={
    +     *     {
    +     *          "name"="offset",
    +     *          "dataType"="integer",
    +     *          "description"="Offset from beginning of collection, start from
    +     *          1",
    +     *          "requirements"="\d+",
    +     *          "default"="1"
    +     *     },
    +     *     {
    +     *          "name"="limit",
    +     *          "dataType"="integer",
    +     *          "description"="Max entities per page, default 10",
    +     *          "requirements"="\d+",
    +     *          "default"="10"
    +     *     },
    +     *  },
    +     *  input={
    +     *      "class"="",
    +     *      "data"={
    +     *          "page"={
    +     *              "dataType"="Number page",
    +     *              "actualType"="integer",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          },
    +     *          "limit"={
    +     *              "dataType"="Limit",
    +     *              "actualType"="integer",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          }
    +     *      }
    +     *  },
    +     *  output={
    +     *     "class"="",
    +     *     "data"={
    +     *          "data"={
    +     *              "description"="Requested entities.",
    +     *              "dataType"="Collection of notification send dates",
    +     *              "actualType"="collection",
    +     *              "subType"="History",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *              "children"={
    +     *               "date"={
    +     *                   "dataType"="string",
    +     *                   "required"=true,
    +     *                   "readonly"=true
    +     *               }
    +     *           }
    +     *          },
    +     *          "count"={
    +     *              "description"="Count of requested entities on current
    +     *              page.",
    +     *              "dataType"="integer",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *          },
    +     *          "totalCount"={
    +     *              "description"="Total count of founded entities.",
    +     *              "dataType"="integer",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *          },
    +     *          "page"={
    +     *              "description"="Current page.",
    +     *              "dataType"="integer",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *          },
    +     *          "limit"={
    +     *              "description"="Max entities per page.",
    +     *              "dataType"="integer",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *          }
    +     *     }
    +     *  },
    +     *  statusCodes={
    +     *     200="Subscribers successfully returned."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     * @param integer $id      A Notification entity instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function historyAction(Request $request, $id)
    +    {
    +        $notification = $this->getManager()->getRepository(Notification::class)->find($id);
    +        if (! $notification instanceof Notification) {
    +            return $this->generateResponse("Not found notification with id {$id}.", 404);
    +        }
    +
    +        //
    +        // Check access to notification.
    +        //
    +        $reasons = $this->checkAccess(NotificationInspector::READ, $notification);
    +        if (count($reasons) > 0) {
    +            return $this->generateResponse($reasons, 403);
    +        }
    +
    +        /** @var NotificationSendHistoryRepository $repository */
    +        $repository = $this->getManager()->getRepository(NotificationSendHistory::class);
    +        $qb = $repository->getListForNotification($id);
    +        $pagination = $this->paginate($request, $qb);
    +
    +        return $this->generateResponse($pagination);
    +    }
    +
    +    /**
    +     * Filters notifications.
    +     *
    +     * @Roles("ROLE_SUBSCRIBER")
    +     *
    +     * @Route("/filters", methods={ "GET" })
    +     * @ApiDoc(
    +     *  resource=true,
    +     *  section="Notification",
    +     *     filters={
    +     *     {
    +     *          "name"="page",
    +     *          "dataType"="integer",
    +     *          "description"="Requested page number, start from 1",
    +     *          "requirements"="\d+",
    +     *          "default"="1"
    +     *     },
    +     *     {
    +     *          "name"="limit",
    +     *          "dataType"="integer",
    +     *          "description"="Max entities per page, default 100",
    +     *          "requirements"="\d+",
    +     *          "default"="100"
    +     *     },
    +     *     {
    +     *          "name"="sortField",
    +     *          "dataType"="string",
    +     *          "description"="Field name for sorting. Available: name, type,
    +     *          published, sourcesCount, status",
    +     *          "requirements"="\w+",
    +     *          "default"="name",
    +     *          "required"=false
    +     *     }
    +     *  },
    +     *  output={
    +     *     "class"="",
    +     *     "data"={
    +     *          "data"={
    +     *              "description"="Requested entities.",
    +     *              "dataType"="Collection of notification send dates",
    +     *              "actualType"="collection",
    +     *              "subType"="History",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *              "children"={
    +     *               "date"={
    +     *                   "dataType"="string",
    +     *                   "required"=true,
    +     *                   "readonly"=true
    +     *               }
    +     *           }
    +     *          },
    +     *          "count"={
    +     *              "description"="Count of requested entities on current
    +     *              page.",
    +     *              "dataType"="integer",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *          },
    +     *          "totalCount"={
    +     *              "description"="Total count of founded entities.",
    +     *              "dataType"="integer",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *          },
    +     *          "page"={
    +     *              "description"="Current page.",
    +     *              "dataType"="integer",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *          },
    +     *          "limit"={
    +     *              "description"="Max entities per page.",
    +     *              "dataType"="integer",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *          }
    +     *     }
    +     *  },
    +     *  statusCodes={
    +     *     200="Subscribers successfully returned."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function filtersAction(Request $request)
    +    {
    +        /** @var NotificationRepository $repository */
    +        $repository = $this->getManager()->getRepository(Notification::class);
    +        $sortingOptions = SortingOptions::fromRequest($request, 'name');
    +        $typeFilter = trim($request->query->get('type'));
    +        switch ($typeFilter) {
    +            case 'owner':
    +                $filter = $repository->computeUserNotificationsCount($sortingOptions);
    +                break;
    +            case 'recipient':
    +                $filter = $repository->computeRecipientNotificationsCount($sortingOptions);
    +                break;
    +            case 'feed':
    +                $filter = $repository->getCountFeedNotifications($sortingOptions);
    +                break;
    +            default:
    +                return $this->generateResponse("Bad type value.", 400);
    +        }
    +        // We should get all paginated data
    +        // Notification entity.
    +        //
    +        /** @var SlidingPagination $pagination */
    +        $pagination = $this->paginate($request, $filter);
    +        $elements = iterator_to_array($pagination);
    +
    +        return $this->generateResponse(
    +            [
    +                'filters' => [
    +                    'data' => $elements,
    +                    'count' => count($elements),
    +                    'totalCount' => $pagination->getTotalItemCount(),
    +                    'page' => $pagination->getCurrentPageNumber(),
    +                    'limit' => $pagination->getItemNumberPerPage(),
    +                ],
    +                'meta' => [
    +                    'sort' => $sortingOptions,
    +                ],
    +            ],
    +            200
    +        );
    +    }
    +
    +
    +    /**
    +     * All notifications.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("/all", methods={ "GET" })
    +     * @ApiDoc(
    +     *  resource=true,
    +     *  section="Notification",
    +     *  filters={
    +     *     {
    +     *          "name"="page",
    +     *          "dataType"="integer",
    +     *          "description"="Requested page number, start from 1",
    +     *          "requirements"="\d+",
    +     *          "default"="1"
    +     *     },
    +     *     {
    +     *          "name"="limit",
    +     *          "dataType"="integer",
    +     *          "description"="Max entities per page, default 100",
    +     *          "requirements"="\d+",
    +     *          "default"="100"
    +     *     },
    +     *     {
    +     *          "name"="sortField",
    +     *          "dataType"="string",
    +     *          "description"="Field name for sorting. Available: name, type,
    +     *          published, sourcesCount, status",
    +     *          "requirements"="\w+",
    +     *          "default"="name",
    +     *          "required"=false
    +     *     }
    +     *  },
    +     *  output={
    +     *     "class"="",
    +     *     "data"={
    +     *          "data"={
    +     *              "description"="Requested entities.",
    +     *              "dataType"="Collection of notification send dates",
    +     *              "actualType"="collection",
    +     *              "subType"="History",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *              "children"={
    +     *               "date"={
    +     *                   "dataType"="string",
    +     *                   "required"=true,
    +     *                   "readonly"=true
    +     *               }
    +     *           }
    +     *          },
    +     *          "count"={
    +     *              "description"="Count of requested entities on current
    +     *              page.",
    +     *              "dataType"="integer",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *          },
    +     *          "totalCount"={
    +     *              "description"="Total count of founded entities.",
    +     *              "dataType"="integer",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *          },
    +     *          "page"={
    +     *              "description"="Current page.",
    +     *              "dataType"="integer",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *          },
    +     *          "limit"={
    +     *              "description"="Max entities per page.",
    +     *              "dataType"="integer",
    +     *              "required"=true,
    +     *              "readonly"=true,
    +     *          }
    +     *     }
    +     *  },
    +     *  statusCodes={
    +     *     200="Subscribers successfully returned."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function notificationsAllAction(Request $request)
    +    {
    +        /** @var NotificationRepository $repository */
    +        $repository = $this->getManager()->getRepository(Notification::class);
    +        $sortingOptions = SortingOptions::fromRequest($request, 'name');
    +        $typeFilter = trim($request->query->get('filterType'));
    +        $filterId = trim($request->query->get('filterId'));
    +
    +        $user = $this->getCurrentUser();
    +        if ($filterId !== '') {
    +            $qb = $repository->getQueryBuilderForFilter(
    +                $sortingOptions,
    +                $typeFilter,
    +                $filterId,
    +                $user
    +            );
    +        } else {
    +            $qb = $repository->getNotificationsAllQueryBuilder(
    +                $sortingOptions,
    +                $user->getBillingSubscription()
    +            );
    +        }
    +        //
    +        // We should get all paginated data and put 'subscribed' field value into
    +        // Notification entity.
    +        //
    +        /** @var SlidingPagination $pagination */
    +        $pagination = $this->paginate($request, $qb);
    +        $elements = iterator_to_array($pagination);
    +
    +        return $this->generateResponse(
    +            [
    +                'notifications' => [
    +                    'data' => $elements,
    +                    'count' => count($elements),
    +                    'totalCount' => $pagination->getTotalItemCount(),
    +                    'page' => $pagination->getCurrentPageNumber(),
    +                    'limit' => $pagination->getItemNumberPerPage(),
    +                ],
    +                'meta' => [ 'sort' => $sortingOptions ],
    +            ],
    +            200,
    +            [ 'notification_list', 'schedule', 'id' ]
    +        );
    +    }
    +}
    diff --git a/src/UserBundle/Controller/V1/NotificationThemeController.php b/src/UserBundle/Controller/V1/NotificationThemeController.php
    new file mode 100644
    index 0000000..f09b2ab
    --- /dev/null
    +++ b/src/UserBundle/Controller/V1/NotificationThemeController.php
    @@ -0,0 +1,60 @@
    +getManager()->getRepository(NotificationTheme::class);
    +        $notification = $repository->getDefault();
    +
    +        if (! $notification instanceof NotificationTheme) {
    +            return $this->generateResponse('Can\'t find default notification theme', 404);
    +        }
    +
    +        return $this->generateResponse($notification, 200, [
    +            'id',
    +            'notification_theme',
    +        ]);
    +    }
    +}
    diff --git a/src/UserBundle/Controller/V1/PersonRecipientController.php b/src/UserBundle/Controller/V1/PersonRecipientController.php
    new file mode 100644
    index 0000000..a06f083
    --- /dev/null
    +++ b/src/UserBundle/Controller/V1/PersonRecipientController.php
    @@ -0,0 +1,441 @@
    +get('doctrine.orm.default_entity_manager');
    +        $groupId = trim($request->query->get('groupId'));
    +
    +        $currentUser = $this->getCurrentUser();
    +
    +        //
    +        // Get sort parameters and filter.
    +        //
    +        $sortingOptions = SortingOptions::fromRequest($request, 'name');
    +        $filter = $request->query->get('filter', '');
    +
    +        /** @var PersonRecipientRepository $personRepository */
    +        $personRepository = $em->getRepository(PersonRecipient::class);
    +
    +        //
    +        // If we got group id we should try to fetch proper recipient group and
    +        // check that user can get this group recipient.
    +        //
    +        if ($groupId !== '') {
    +            /** @var GroupRecipientRepository $groupRepository */
    +            $groupRepository = $em->getRepository(GroupRecipient::class);
    +            $group = $groupRepository->get($groupId);
    +
    +            if (! $group instanceof GroupRecipient) {
    +                return $this->generateResponse("Can't find group recipient with id {$groupId}.", 404);
    +            }
    +
    +            //
    +            // Check access.
    +            //
    +            $reasons = $this->checkAccess(InspectorInterface::READ, $group);
    +            if (count($reasons) > 0) {
    +                return $this->generateResponse($reasons, 403);
    +            }
    +
    +            $statusFilter = trim($request->query->get('statusFilter', StatusFilterEnum::ALL));
    +
    +            if ($statusFilter !== '') {
    +                if (! StatusFilterEnum::isValid($statusFilter)) {
    +                    return $this->generateResponse("'statusFilter' should be one of ". implode(', ', StatusFilterEnum::getAvailables()));
    +                }
    +
    +                $statusFilter = new StatusFilterEnum($statusFilter);
    +            }
    +
    +            $additionalCond = AdditionalConditions::fromRequest($request);
    +
    +            $qb = $personRepository->getQueryBuilderForGroup(
    +                $currentUser->getId(),
    +                $group->getId(),
    +                $statusFilter,
    +                $sortingOptions,
    +                $filter,
    +                $additionalCond
    +            );
    +        } else {
    +            $qb = $personRepository->getQueryBuilderForUser(
    +                $currentUser->getId(),
    +                $sortingOptions,
    +                $filter
    +            );
    +        }
    +
    +        //
    +        // We should get all paginated data and put 'subscribed' field value into
    +        // Notification entity.
    +        //
    +        /** @var SlidingPagination $pagination */
    +        $pagination = $this->paginate($request, $qb);
    +
    +        $serializationGroups = [ 'recipient', 'id' ];
    +        if ($groupId !== '') {
    +            $data = array_map(function (array $element) {
    +                /** @var AbstractRecipient $recipient */
    +                $recipient = $element[0];
    +
    +                $recipient->enrolled = (bool) $element['enrolled'];
    +
    +                return $recipient;
    +            }, iterator_to_array($pagination));
    +
    +            $serializationGroups[] = 'sublist';
    +            $totalCount = $pagination->getTotalItemCount();
    +
    +            $pagination = $this->paginate($request, $data);
    +            $pagination->setTotalItemCount($totalCount);
    +        }
    +
    +        return $this->generateResponse(
    +            [
    +                'recipients' => $pagination,
    +                'meta' => [ 'sort' => $sortingOptions ],
    +            ],
    +            200,
    +            $serializationGroups
    +        );
    +    }
    +
    +    /**
    +     * Create new recipient.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("", methods={ "POST" })
    +     * @ApiDoc(
    +     *  resource="Recipient",
    +     *  section="Receivers",
    +     *  input={
    +     *     "class"="UserBundle\Form\PersonRecipientType",
    +     *     "name"=false
    +     *  },
    +     *  output={
    +     *      "class"="UserBundle\Entity\Recipient\PersonRecipient",
    +     *      "groups"={ "id", "recipient" }
    +     *  },
    +     *  statusCodes={
    +     *     204="New recipient successfully created.",
    +     *     400="Invalid parameters."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function createAction(Request $request)
    +    {
    +        return parent::createEntity($request, PersonRecipient::create()->setOwner($this->getCurrentUser()));
    +    }
    +
    +    /**
    +     * Update recipient.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("/{id}", methods={ "PUT" }, requirements={ "id": "\d+" })
    +     * @ApiDoc(
    +     *  resource="Recipient",
    +     *  section="Receivers",
    +     *  input={
    +     *     "class"="UserBundle\Form\PersonRecipientType",
    +     *     "name"=false
    +     *  },
    +     *  output={
    +     *      "class"="UserBundle\Entity\Recipient\PersonRecipient",
    +     *      "groups"={ "id", "recipient" }
    +     *  },
    +     *  statusCodes={
    +     *     200="Recipient successfully updated.",
    +     *     400="Invalid parameters."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     * @param integer $id      A PersonRecipient entity id.
    +     *
    +     * @return \ApiBundle\Entity\ManageableEntityInterface|\ApiBundle\Response\ViewInterface
    +     */
    +    public function putAction(Request $request, $id)
    +    {
    +        return parent::putEntity($request, $id);
    +    }
    +
    +    /**
    +     * Delete recipient.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("/delete", methods={ "POST" })
    +     * @ApiDoc(
    +     *  resource="Recipient",
    +     *  section="Receivers",
    +     *  input={
    +     *     "class"="",
    +     *      "data"={
    +     *          "ids"={
    +     *              "dataType"="Array of recipients ids",
    +     *              "actualType"="collection",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *      }
    +     *      }
    +     *  },
    +     *  statusCodes={
    +     *     204="Recipients successfully deleted.",
    +     *     400="Invalid parameters."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function deleteAction(Request $request)
    +    {
    +        return $this->batchDelete($request);
    +    }
    +
    +    /**
    +     * Activate/deactivate recipients.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("/active", methods={ "PUT" })
    +     * @ApiDoc(
    +     *  resource="Recipient",
    +     *  section="Receivers",
    +     *  input={
    +     *     "class"="",
    +     *      "data"={
    +     *          "ids"={
    +     *              "dataType"="Array of recipients ids",
    +     *              "actualType"="collection",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          },
    +     *          "active"={
    +     *              "dataType"="Boolean flag",
    +     *              "actualType"="boolean",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          }
    +     *      }
    +     *
    +     *  },
    +     *  statusCodes={
    +     *     204="Recipient successfully activated/deactivated."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function activateAction(Request $request)
    +    {
    +        return $this->batchActiveToggle($request);
    +    }
    +
    +    /**
    +     * Subscribe/unsubscribe recipient from specified notifications.
    +     *
    +     * @Roles("ROLE_MASTER_USER")
    +     *
    +     * @Route("/{id}/subscribe", methods={ "PUT" })
    +     * @ApiDoc(
    +     *  resource="Recipient",
    +     *  section="Receivers",
    +     *  input={
    +     *     "class"="",
    +     *      "data"={
    +     *          "ids"={
    +     *              "dataType"="Array of recipients ids",
    +     *              "actualType"="collection",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          },
    +     *          "subscribe"={
    +     *              "dataType"="Boolean flag",
    +     *              "actualType"="boolean",
    +     *              "subtype"="string",
    +     *              "required"=true,
    +     *              "readonly"=true
    +     *          }
    +     *      }
    +     *
    +     *  },
    +     *  statusCodes={
    +     *     204="Recipient successfully subscribed/unsubscribed."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     * @param integer $id      A PersonRecipient entity instance.
    +     *
    +     * @return ViewInterface
    +     */
    +    public function subscribeAction(Request $request, $id)
    +    {
    +        return $this->batchSubscriptionToggle($request, $id);
    +    }
    +}
    diff --git a/src/UserBundle/Controller/V1/ReceiverController.php b/src/UserBundle/Controller/V1/ReceiverController.php
    new file mode 100644
    index 0000000..d58b053
    --- /dev/null
    +++ b/src/UserBundle/Controller/V1/ReceiverController.php
    @@ -0,0 +1,198 @@
    +tokenStorage = $tokenStorage;
    +        $this->em = $em;
    +    }
    +
    +    /**
    +     * Get list of available receivers.
    +     *
    +     * @Roles("ROLE_SUBSCRIBER")
    +     *
    +     * @Route("", methods={ "GET" })
    +     * @ApiDoc(
    +     *  resource=true,
    +     *  section="Receivers",
    +     *  filters={
    +     *     {
    +     *          "name"="filter",
    +     *          "dataType"="string",
    +     *          "description"="Receivers name filter",
    +     *          "requirements"="[\w\s]+"
    +     *     },
    +     *     {
    +     *          "name"="exclude",
    +     *          "dataType"="string",
    +     *          "description"="Comma separated list of ids.",
    +     *          "requirements"="[\w,]+"
    +     *     }
    +     *  },
    +     *  output={
    +     *     "class"="Pagination",
    +     *     "groups"={ "id", "recipient_autocompletion" }
    +     *  },
    +     *  statusCodes={
    +     *     200="Receivers successfully funded."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function listAction(Request $request)
    +    {
    +        $keyword = trim($request->query->get('filter'));
    +        $exclude = array_filter(array_map('trim', explode(',', $request->query->get('exclude', ''))));
    +
    +        /** @var RecipientRepository $repository */
    +        $repository = $this->em->getRepository(AbstractRecipient::class);
    +        $recipients = $repository->search(
    +            $this->getCurrentUser()->getId(),
    +            self::DEFAULT_LIMIT,
    +            $keyword,
    +            $exclude
    +        );
    +
    +        return $this->generateResponse($recipients, 200, [ 'recipient_autocompletion', 'id' ]);
    +    }
    +
    +    /**
    +     * Get email history for specified receiver.
    +     *
    +     * @Roles("ROLE_SUBSCRIBER")
    +     *
    +     * @Route("/{id}/emailHistory", methods={ "GET" }, requirements={ "id": "\d+" })
    +     * @ApiDoc(
    +     *  resource=true,
    +     *  section="Receivers",
    +     *  filters={
    +     *     {
    +     *          "name"="page",
    +     *          "dataType"="integer",
    +     *          "description"="Requested page number, start from 1",
    +     *          "requirements"="\d+",
    +     *          "default"="1"
    +     *     },
    +     *     {
    +     *          "name"="limit",
    +     *          "dataType"="integer",
    +     *          "description"="Max entities per page, default 100",
    +     *          "requirements"="\d+",
    +     *          "default"="100"
    +     *     },
    +     *     {
    +     *          "name"="sortField",
    +     *          "dataType"="string",
    +     *          "description"="Field name for sorting. Available: name, type, scheduleTime, sentTime",
    +     *          "requirements"="\w+",
    +     *          "default"="name",
    +     *          "required"=false
    +     *     },
    +     *     {
    +     *          "name"="sortDirection",
    +     *          "dataType"="string",
    +     *          "description"="Sort direction. Available: asc, desc",
    +     *          "requirements"="(asc|desc)",
    +     *          "default"="asc",
    +     *          "required"=false
    +     *     },
    +     *     {
    +     *          "name"="typeFilter",
    +     *          "dataType"="string",
    +     *          "description"="Filter receivers by notification type of specified entity id.",
    +     *          "requirements"="(alerts|newsletter|all)",
    +     *          "default"="all",
    +     *          "required"=false
    +     *     }
    +     *  },
    +     *  output={
    +     *     "class"="Pagination",
    +     *     "groups"={ "id", "history", "schedule" }
    +     *  },
    +     *  statusCodes={
    +     *     200="Receivers successfully funded."
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Request instance.
    +     * @param integer $id      A AbstractRecipient entity id.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function historyAction(Request $request, $id)
    +    {
    +        $recipient = $this->em->find(AbstractRecipient::class, $id);
    +        if (! $recipient instanceof AbstractRecipient) {
    +            return $this->generateResponse("Can't find receiver with id {$id}.", 404);
    +        }
    +
    +        $sortingOptions = SortingOptions::fromRequest($request, 'sentTime');
    +        $typeFilter = $request->query->get('typeFilter', 'all');
    +        if (($typeFilter !== 'all') && ! NotificationTypeEnum::isValid($typeFilter)) {
    +            return $this->generateResponse("'typeFilter' should be one of all, ". implode(', ', NotificationTypeEnum::getAvailables()));
    +        }
    +
    +        /** @var NotificationSendHistoryRepository $repository */
    +        $repository = $this->em->getRepository(NotificationSendHistory::class);
    +        $qb = $repository->getListForRecipient($recipient, $sortingOptions, $typeFilter);
    +
    +        $pagination = $this->paginate(
    +            $qb,
    +            $request->query->getInt('page', 1),
    +            $request->query->getInt('limit', 10)
    +        );
    +
    +        return $this->generateResponse($pagination, 200, [ 'id', 'history', 'schedule' ]);
    +    }
    +}
    diff --git a/src/UserBundle/Controller/V1/UserController.php b/src/UserBundle/Controller/V1/UserController.php
    new file mode 100644
    index 0000000..5eff0c1
    --- /dev/null
    +++ b/src/UserBundle/Controller/V1/UserController.php
    @@ -0,0 +1,741 @@
    +tokenStorage = $tokenStorage;
    +        $this->formFactory = $formFactory;
    +        $this->userManager = $userManager;
    +        $this->container = $container;
    +    }
    +
    +    /**
    +     * Change password for current user.
    +     *
    +     * @Roles("ROLE_SUBSCRIBER")
    +     *
    +     * @Route("/change-password", methods={ "POST" })
    +     * @ApiDoc(
    +     *  resource="Security",
    +     *  section="User",
    +     *  input={
    +     *     "class"="UserBundle\Form\ChangePasswordType",
    +     *     "name"=false
    +     *  }
    +     * )
    +     *
    +     * @param Request $request A Http Request instance.
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function changePasswordAction(Request $request)
    +    {
    +        $user = $this->getCurrentUser();
    +        $form = $this
    +            ->createForm(ChangePasswordType::class, $user)
    +            ->submit($request->request->all());
    +
    +        if ($form->isSubmitted() && $form->isValid()) {
    +            $this->userManager->updateUser($user);
    +
    +            return $this->generateResponse();
    +        }
    +
    +        return $this->generateResponse($form, 400);
    +    }
    +
    +    /**
    +     * Get list of subscriber for current master.
    +     *
    +     * @Roles("ROLE_SUBSCRIBER")
    +     *
    +     * @Route("/current/restrictions", methods={ "GET" })
    +     * @ApiDoc(
    +     *  resource="Current",
    +     *  section="User",
    +     *  output={
    +     *     "class"="",
    +     *     "data"={
    +     *      "limits"={
    +     *       "dataType"="object",
    +     *       "required"=true,
    +     *       "readonly"=true,
    +     *       "children"={
    +     *         "searchesPerDay"={
    +     *          "dataType"="object",
    +     *          "description"="Searches per day limits",
    +     *          "required"=true,
    +     *          "readonly"=true,
    +     *          "children"={
    +     *              "limit"={
    +     *                  "dataType"="integer",
    +     *                  "description"="Allowed searches count",
    +     *                  "required"=true,
    +     *                  "readonly"=true
    +     *              },
    +     *              "current"={
    +     *                  "dataType"="integer",
    +     *                  "description"="Used searches count",
    +     *                  "required"=true,
    +     *                  "readonly"=true
    +     *              }
    +     *          }
    +     *         },
    +     *         "savedFeeds"={
    +     *          "dataType"="object",
    +     *          "description"="Feeds limits",
    +     *          "required"=true,
    +     *          "readonly"=true,
    +     *          "children"={
    +     *              "limit"={
    +     *                  "dataType"="integer",
    +     *                  "description"="Allowed feeds count",
    +     *                  "required"=true,
    +     *                  "readonly"=true
    +     *              },
    +     *              "current"={
    +     *                  "dataType"="integer",
    +     *                  "description"="Used feeds count",
    +     *                  "required"=true,
    +     *                  "readonly"=true
    +     *              }
    +     *          }
    +     *         },
    +     *         "masterAccounts"={
    +     *          "dataType"="object",
    +     *          "description"="Master accounts limits",
    +     *          "required"=true,
    +     *          "readonly"=true,
    +     *          "children"={
    +     *              "limit"={
    +     *                  "dataType"="integer",
    +     *                  "description"="Allowed master accounts count",
    +     *                  "required"=true,
    +     *                  "readonly"=true
    +     *              },
    +     *              "current"={
    +     *                  "dataType"="integer",
    +     *                  "description"="Used master accounts count",
    +     *                  "required"=true,
    +     *                  "readonly"=true
    +     *              }
    +     *          }
    +     *         },
    +     *         "subscriberAccounts"={
    +     *          "dataType"="object",
    +     *          "description"="Subscriber accounts limits",
    +     *          "required"=true,
    +     *          "readonly"=true,
    +     *          "children"={
    +     *              "limit"={
    +     *                  "dataType"="integer",
    +     *                  "description"="Allowed subscriber accounts count",
    +     *                  "required"=true,
    +     *                  "readonly"=true
    +     *              },
    +     *              "current"={
    +     *                  "dataType"="integer",
    +     *                  "description"="Used subscriber accounts count",
    +     *                  "required"=true,
    +     *                  "readonly"=true
    +     *              }
    +     *          }
    +     *         },
    +     *         "alerts"={
    +     *          "dataType"="object",
    +     *          "description"="Alerts limits",
    +     *          "required"=true,
    +     *          "readonly"=true,
    +     *          "children"={
    +     *              "limit"={
    +     *                  "dataType"="integer",
    +     *                  "description"="Allowed alerts count",
    +     *                  "required"=true,
    +     *                  "readonly"=true
    +     *              },
    +     *              "current"={
    +     *                  "dataType"="integer",
    +     *                  "description"="Used alerts count",
    +     *                  "required"=true,
    +     *                  "readonly"=true
    +     *              }
    +     *          }
    +     *         },
    +     *         "newsletters"={
    +     *          "dataType"="object",
    +     *          "description"="Newsletters limits",
    +     *          "required"=true,
    +     *          "readonly"=true,
    +     *          "children"={
    +     *              "limit"={
    +     *                  "dataType"="integer",
    +     *                  "description"="Allowed newsletters count",
    +     *                  "required"=true,
    +     *                  "readonly"=true
    +     *              },
    +     *              "current"={
    +     *                  "dataType"="integer",
    +     *                  "description"="Used newsletters count",
    +     *                  "required"=true,
    +     *                  "readonly"=true
    +     *              }
    +     *          }
    +     *         }
    +     *
    +     *       }
    +     *     },
    +     *     "permissions"={
    +     *       "dataType"="object",
    +     *       "required"=true,
    +     *       "readonly"=true,
    +     *       "children"={
    +     *         "analytics"={
    +     *          "dataType"="boolean",
    +     *          "description"="Can user use analytics or not",
    +     *          "required"=true,
    +     *          "readonly"=true
    +     *         }
    +     *       }
    +     *      }
    +     *     }
    +     *  },
    +     *  statusCodes={
    +     *     200="List of restrictions successfully returned."
    +     *  }
    +     * )
    +     *
    +     * @return \ApiBundle\Response\ViewInterface
    +     */
    +    public function restrictionsAction()
    +    {
    +        $user = $this->getCurrentUser();
    +        if ($user === null) {
    +            return $this->generateResponse([], 403);
    +        }
    +
    +        return $this->generateResponse($user->getRestrictions());
    +    }
    +
    +    /**
    +     *
    +     * @Route("/update/plan", methods={ "POST" })
    +     *
    +     * @param Request $request A Http Request instance.
    +     */
    +    public function updatePlanAction(Request $request)
    +    {
    +        $user = $this->getCurrentUser();
    +        $data = $request->request->all();
    +        $gateway = PaymentGatewayEnum::paypal();
    +        $em = $this->container->get('doctrine.orm.default_entity_manager');
    +        if (isset(
    +            $data['news'],
    +            $data['blog'],
    +            $data['reddit'],
    +            $data['instagram'],
    +            $data['twitter'],
    +            $data['analytics'],
    +            $data['searchesPerDay'],
    +            $data['savedFeeds'],
    +            $data['masterAccounts'],
    +            $data['subscriberAccounts'],
    +            $data['webFeeds'],
    +            $data['alerts']
    +            ) && 
    +            ($data['searchesPerDay'] >= 0) &&
    +            ($data['savedFeeds'] >= 0) &&
    +            ($data['masterAccounts'] >= 0) &&
    +            ($data['subscriberAccounts'] >= 0) &&
    +            ($data['webFeeds'] >= 0) &&
    +            ($data['alerts'] >= 0)
    +            ) {
    +                //Call cost calculation plan 
    +                $costCalculation = $this->container->get('cost.calculation');
    +                $response =  $costCalculation->costCalculationAction($request, true); 
    +                $oldPrice = $user->getBillingSubscription()->getPlan()->getPrice();
    +                
    +                //Stripe process
    +                $stripe = $this->container->get('stripe.service');
    +                $stripe->setApiKey();
    +                if (empty($user->getStripeUserId())) {
    +                    $customer = $stripe->createCustomer(
    +                        [
    +                        'email' => $user->getEmail(),
    +                        'name' => $user->getFirstName().' '.$user->getLastName(),
    +                        'metadata' => ['paymentMethod' => $data['paymentID']]
    +                        ]
    +                    );
    +                    $customerArray = [];
    +                    if ($customer instanceof ApiErrorException) {
    +                        $customerArray = ['paymentError' => 1,'data'=>$customer,'message'=>'Customer failed'];
    +                        return $this->generateResponse($customerArray, 400);
    +                    }
    +
    +                    if (isset($customer['id'])) {
    +                        $user->setStripeUserId($customer['id']);
    +                        $em->persist($user);
    +                        $em->flush();
    +                        //Add card atatch to customer
    +                        if (isset($data['paymentID']) && !empty($data['paymentID'])) {
    +                            $cardAttach = $stripe->paymentMethodAttachToCustomer($data['paymentID'],
    +                                ['customer' => $customer['id']]
    +                            );
    +            
    +                            if ($cardAttach instanceof ApiErrorException) {
    +                                $cardAttachArray = ['paymentError' => 1,'data'=>$cardAttach,'message'=>'Card attached to customer failed'];
    +                                return $this->generateResponse($cardAttachArray, 500);
    +                            }
    +                        }
    +                        //Add product
    +                        $product = $stripe->addProduct(
    +                            [
    +                            'name' => $user->getCompanyName(),
    +                            'metadata' => (array)$customer['id']
    +                            ]
    +                        );
    +                        if ($product instanceof ApiErrorException) {
    +                            $productArray = ['paymentError' => 1,'data'=>$product,'message'=>'Product failed'];
    +                            return $this->generateResponse($productArray, 400);
    +                        }
    +        
    +                        if (isset($product['id'])) {
    +                            //Call cost calculation plan 
    +                            $costCalculation = $this->container->get('cost.calculation');
    +                            $response =  $costCalculation->costCalculationAction($request, true); 
    +                        
    +                            $price = $stripe->addPrice(
    +                                [
    +                                'unit_amount' => isset($response['price']) ? $response['price'] * 100 : 0,
    +                                'currency' => 'usd',
    +                                'recurring' => ['interval' => 'month'],
    +                                'product' => $product['id']
    +                                ]
    +                            );
    +                            if ($price instanceof ApiErrorException) {
    +                                $priceArray = ['paymentError' => 1,'data'=>$price,'message'=>'Price add failed'];
    +                                return $this->generateResponse($priceArray, 400);
    +                            }
    +        
    +                            //Plan subscription code
    +                            if (isset($price['id'])) {
    +                                //Add plan
    +                                $subscription = $stripe->createSubscription(
    +                                    [
    +                                        'customer' => $customer['id'],
    +                                        'items' => [['price' => $price['id']]],
    +                                        'default_payment_method' => $data['paymentID']
    +                                    ]
    +                                );
    +                                if ($subscription instanceof ApiErrorException) {
    +                                    $subscriptionArray = ['paymentError' => 1,'data'=>$subscription,'message'=>'Subscribtion failed'];
    +                                    return $this->generateResponse($subscriptionArray, 400);
    +                                }
    +                                //update customer metadata
    +                                $customer = $stripe->updateCustomer($customer['id'],
    +                                    [
    +                                    'metadata' => [
    +                                                    'paymentMethod' => $data['paymentID'],
    +                                                    'productId' => $product['id'],
    +                                                    'priceId' => $price['id'],
    +                                                    'subscriptionId' => $subscription['id'],
    +                                                    'subStartDate' => $subscription['current_period_start'],
    +                                                    'subEndDate' => $subscription['current_period_end'],
    +                                                ]
    +                                    ]
    +                                );
    +                                $customerArray = [];
    +                                if ($customer instanceof ApiErrorException) {
    +                                    $customerArray = ['paymentError' => 1,'data'=>$customer,'message'=>'Customer add meta data failed'];
    +                                    return $this->generateResponse($customerArray, 400);
    +                                }
    +
    +                                $plan = $user->getBillingSubscription()->getPlan();
    +                                $plan->setTitle($user->getCompanyName())
    +                                        ->setInnerName('Starter')
    +                                        ->setPrice(isset($response['price']) ? $response['price'] : 0)
    +                                        ->setNews($data['news'])
    +                                        ->setBlog($data['blog'])
    +                                        ->setReddit($data['reddit'])
    +                                        ->setInstagram($data['instagram'])
    +                                        ->setTwitter($data['twitter'])
    +                                        ->setAnalytics($data['analytics'])
    +                                        ->setSearchesPerDay($data['searchesPerDay'])
    +                                        ->setSavedFeeds($data['savedFeeds'])
    +                                        ->setMasterAccounts($data['masterAccounts'])
    +                                        ->setSubscriberAccounts($data['subscriberAccounts'])
    +                                        ->setWebFeeds($data['webFeeds'])
    +                                        ->setUser($user)
    +                                        ->setAlerts($data['alerts']);                    
    +                                $em->persist($plan);  
    +                                $em->flush();
    +                                
    +                
    +                                $subscriptionObj = $user->getBillingSubscription();    
    +                                $subscriptionObj->setGateway($gateway);
    +                                $subscriptionObj->setStartDate(new \DateTime('@' . $subscription['current_period_start']));
    +                                $subscriptionObj->setEndDate(new \DateTime('@' . $subscription['current_period_end']));
    +                                $em->persist($subscriptionObj);  
    +                                $em->flush();
    +                            }
    +                        }    
    +        
    +                    }
    +                } else {
    +                    if ($response['price'] < $oldPrice) {
    +                        $planNew = new  Plan();
    +                        $planNew->setTitle($user->getCompanyName());
    +                        $planNew->setInnerName('Starter');
    +                        $planNew->setPrice(isset($response['price']) ? $response['price'] : 0);
    +                        $planNew->setNews($data['news']);
    +                        $planNew->setBlog($data['blog']);
    +                        $planNew->setReddit($data['reddit']);
    +                        $planNew->setInstagram($data['instagram']);
    +                        $planNew->setTwitter($data['twitter']);
    +                        $planNew->setAnalytics($data['analytics']);
    +                        $planNew->setSearchesPerDay($data['searchesPerDay']);
    +                        $planNew->setSavedFeeds($data['savedFeeds']);
    +                        $planNew->setMasterAccounts($data['masterAccounts']);
    +                        $planNew->setSubscriberAccounts($data['subscriberAccounts']);
    +                        $planNew->setWebFeeds($data['webFeeds']);
    +                        $planNew->setAlerts($data['alerts']);
    +                        $planNew->setUser($user);
    +                        $planNew->setIsPlanDowngrade(true);
    +                        $em->persist($planNew);  
    +                        $em->flush();
    +
    +                        $subscription = $user->getBillingSubscription();
    +                        $subscription->setIsPlanDowngrade(true);
    +                        $em->persist($subscription);
    +                        $em->flush();
    +
    +                        $customer = $stripe->getCustomer(
    +                            $user->getStripeUserId()
    +                        );
    +                        $customerArray = [];
    +                        if ($customer instanceof ApiErrorException) {
    +                            $customerArray = ['paymentError' => 1,'data'=>$customer,'message'=>'Customer not found'];
    +                            return $this->generateResponse($customerArray, 400);
    +                        }
    +                        if (isset($customer['id']) && isset($data['paymentID']) && !empty($data['paymentID'])) {
    +                            //Add card atatch to customer
    +                            $cardAttach = $stripe->paymentMethodAttachToCustomer($data['paymentID'],
    +                                ['customer' => $customer['id']]
    +                            );
    +            
    +                            if ($cardAttach instanceof ApiErrorException) {
    +                                $cardAttachArray = ['paymentError' => 1,'data'=>$cardAttach,'message'=>'Card update attached to customer failed'];
    +                                return $this->generateResponse($cardAttachArray, 500);
    +                            }
    +                            //Card detach to customer
    +                            $cardDetachPaymentMethod = $stripe->paymentMethodDetachToCustomer($customer['metadata']['paymentMethod']
    +                            );
    +                            if ($cardDetachPaymentMethod instanceof ApiErrorException) {
    +                                $cardDetachArray = ['paymentError' => 1,'data'=>$cardDetachPaymentMethod,'message'=>'Card detach to customer failed'];
    +                                return $this->generateResponse($cardDetachArray, 500);
    +                            }
    +                             //update customer metadata
    +                             $customer = $stripe->updateCustomer($customer['id'],
    +                             [
    +                             'metadata' => [
    +                                             'paymentMethod' => isset($data['paymentID']) ? $data['paymentID'] : $customer['metadata']['paymentMethod'],
    +                                             ]
    +                             ]
    +                            );
    +                            $customerArray = [];
    +                            if ($customer instanceof ApiErrorException) {
    +                                $customerArray = ['paymentError' => 1,'data'=>$customer,'message'=>'Customer update meta data failed'];
    +                                return $this->generateResponse($customerArray, 400);
    +                            }
    +                        }    
    +                                
    +                    } else {
    +                        $customer = $stripe->getCustomer(
    +                            $user->getStripeUserId()
    +                        );
    +                        $customerArray = [];
    +                        if ($customer instanceof ApiErrorException) {
    +                            $customerArray = ['paymentError' => 1,'data'=>$customer,'message'=>'Customer not found'];
    +                            return $this->generateResponse($customerArray, 400);
    +                        }
    +                        if (isset($customer['id'])) {
    +                            //Add card atatch to customer
    +                            if (isset($data['paymentID']) && !empty($data['paymentID'])) {
    +                                $cardAttach = $stripe->paymentMethodAttachToCustomer($data['paymentID'],
    +                                    ['customer' => $customer['id']]
    +                                );
    +                
    +                                if ($cardAttach instanceof ApiErrorException) {
    +                                    $cardAttachArray = ['paymentError' => 1,'data'=>$cardAttach,'message'=>'Card update attached to customer failed'];
    +                                    return $this->generateResponse($cardAttachArray, 500);
    +                                }
    +                                //Card detach to customer
    +                                $cardDetachPaymentMethod = $stripe->paymentMethodDetachToCustomer($customer['metadata']['paymentMethod']
    +                                );
    +                                if ($cardDetachPaymentMethod instanceof ApiErrorException) {
    +                                    $cardDetachArray = ['paymentError' => 1,'data'=>$cardDetachPaymentMethod,'message'=>'Card detach to customer failed'];
    +                                    return $this->generateResponse($cardDetachArray, 500);
    +                                }
    +                            }
    +                            if (isset($customer['metadata']['productId'])) {
    +                                   
    +                                $price = $stripe->addPrice(
    +                                    [
    +                                    'unit_amount' => isset($response['price']) ? $response['price'] * 100 : 0,
    +                                    'currency' => 'usd',
    +                                    'recurring' => ['interval' => 'month'],
    +                                    'product' => $customer['metadata']['productId']
    +                                    ]
    +                                );
    +                                if ($price instanceof ApiErrorException) {
    +                                    $priceArray = ['paymentError' => 1,'data'=>$price,'message'=>'Price not found'];
    +                                    return $this->generateResponse($priceArray, 400);
    +                                }
    +            
    +                                //Plan subscription code
    +                                if (isset($price['id'])) {
    +                                    //Add subscription
    +                                    $subscription = $stripe->getSubscription(
    +                                        $customer['metadata']['subscriptionId']
    +                                    );
    +                                 
    +                                    if ($subscription instanceof ApiErrorException) {
    +                                        $subscriptionArray = ['paymentError' => 1,'data'=>$subscription,'message'=>'Subscribtion get failed'];
    +                                        return $this->generateResponse($subscriptionArray, 400);
    +                                    }
    +    
    +                                    if (isset($subscription['id'])) {
    +                                         //Add subscription item
    +                                        $subscriptionItem = $stripe->updateSubscriptionItem($subscription['items']['data'][0]['id'],
    +                                        [
    +                                            'price' => $price['id'],
    +                                        ]
    +                                        );
    +                                        if ($subscriptionItem instanceof ApiErrorException) {
    +                                            $subscriptionItemArray = ['paymentError' => 1,'data'=>$subscriptionItem,'message'=>'Subscribtion Item failed'];
    +                                            return $this->generateResponse($subscriptionItemArray, 400);
    +                                        }
    +                                    }
    +    
    +                                     //update customer metadata
    +                                    $customer = $stripe->updateCustomer($customer['id'],
    +                                        [
    +                                        'metadata' => [
    +                                                        'paymentMethod' => isset($data['paymentID']) ? $data['paymentID'] : $customer['metadata']['paymentMethod'],
    +                                                        'priceId' => $price['id'],
    +                                                        'subscriptionId' => $subscription['id'],
    +                                                        'subStartDate' => $subscription['current_period_start'],
    +                                                        'subEndDate' => $subscription['current_period_end'],
    +                                                        ]
    +                                        ]
    +                                    );
    +                                    $customerArray = [];
    +                                    if ($customer instanceof ApiErrorException) {
    +                                        $customerArray = ['paymentError' => 1,'data'=>$customer,'message'=>'Customer update meta data failed'];
    +                                        return $this->generateResponse($customerArray, 400);
    +                                    }
    +                                    $plan = $user->getBillingSubscription()->getPlan();
    +                                    $plan->setTitle($user->getCompanyName())
    +                                            ->setInnerName('Starter')
    +                                            ->setPrice(isset($response['price']) ? $response['price'] : 0)
    +                                            ->setNews($data['news'])
    +                                            ->setBlog($data['blog'])
    +                                            ->setReddit($data['reddit'])
    +                                            ->setInstagram($data['instagram'])
    +                                            ->setTwitter($data['twitter'])
    +                                            ->setAnalytics($data['analytics'])
    +                                            ->setSearchesPerDay($data['searchesPerDay'])
    +                                            ->setSavedFeeds($data['savedFeeds'])
    +                                            ->setMasterAccounts($data['masterAccounts'])
    +                                            ->setSubscriberAccounts($data['subscriberAccounts'])
    +                                            ->setWebFeeds($data['webFeeds'])
    +                                            ->setUser($user)
    +                                            ->setAlerts($data['alerts']);                    
    +                                    $em->persist($plan);  
    +                                    $em->flush();
    +
    +                                    $user->getBillingSubscription()->setStartDate(new \DateTime('@' . $subscription['current_period_start']));
    +                                    $user->getBillingSubscription()->setEndDate(new \DateTime('@' . $subscription['current_period_end']));
    +                                    $em->persist($user);
    +                                    $em->flush();                                    
    +                                }
    +                            }    
    +                        }
    +                    }
    +                   
    +                }
    +        }
    +       
    +        return $this->generateResponse([
    +            'success' => true,
    +        ]);
    +    }
    +
    +    /**
    +     *
    +     * @Route("/cancel/plan", methods={ "POST" })
    +     *
    +     * @param Request $request A Http Request instance.
    +     */
    +    public function cancelSubscriptionAction(Request $request)
    +    {
    +        $user = $this->getCurrentUser();
    +        //Stripe process
    +        $stripe = $this->container->get('stripe.service');
    +        $stripe->setApiKey();
    +        $em = $this->container->get('doctrine.orm.default_entity_manager');
    +
    +        $customer = $stripe->getCustomer(
    +            $user->getStripeUserId()
    +        );
    +        $customerArray = [];
    +        if ($customer instanceof ApiErrorException) {
    +            $customerArray = ['paymentError' => 1,'data'=>$customer,'message'=>'Customer not found'];
    +            return $this->generateResponse($customerArray, 400);
    +        }
    +        if (isset($customer['id'])) {
    +           $updateSubscription = $stripe->updateSubscription($customer['metadata']['subscriptionId'],
    +                [
    +                    'cancel_at_period_end' => true,
    +                ]
    +            );
    +            if ($updateSubscription instanceof ApiErrorException) {
    +                $updateSubscriptionArray = ['paymentError' => 1,'data'=>$updateSubscription,'message'=>'Cancel subscription'];
    +                return $this->generateResponse($updateSubscriptionArray, 500);
    +            }
    +            $user->getBillingSubscription()->setIsSubscriptionCancelled(true);
    +            $em->persist($user);  
    +            $em->flush();
    +        }
    +       
    +        return $this->generateResponse([
    +            'success' => true,
    +        ]);
    +    }    
    +
    +    /**
    +     *
    +     * @Route("/card/change", methods={ "POST" })
    +     *
    +     * @param Request $request A Http Request instance.
    +     */
    +    public function changeCardAction(Request $request)
    +    {
    +        $user = $this->getCurrentUser();
    +        if (!empty($user->getStripeUserId())) {
    +            $customerStripeId = $user->getStripeUserId();
    +            $stripe = $this->container->get('stripe.service');
    +            $stripe->setApiKey();
    +            $customer = $stripe->getCustomer(
    +                $user->getStripeUserId()
    +            );
    +            //Card detach to customer
    +            $cardDetachPaymentMethod = $stripe->paymentMethodDetachToCustomer($customer['metadata']['paymentMethod']
    +            );
    +            if ($cardDetachPaymentMethod instanceof ApiErrorException) {
    +                $cardDetachArray = ['paymentError' => 1,'data'=>$cardDetachPaymentMethod,'message'=>'Card detach to customer failed'];
    +                return $this->generateResponse($cardDetachArray, 500);
    +            }
    +            //Card attach to customer
    +            $cardAttachPaymentMethod = $stripe->paymentMethodAttachToCustomer($request->request->get('paymentID'),
    +                        ['customer' => $customerStripeId]
    +            );
    +            if ($cardAttachPaymentMethod instanceof ApiErrorException) {
    +                $cardAttachArray = ['paymentError' => 1,'data'=>$cardAttachPaymentMethod,'message'=>'Updated card attached to customer failed'];
    +                return $this->generateResponse($cardAttachArray, 500);
    +            }
    +            //update customer metadata
    +            $customer = $stripe->updateCustomer($customerStripeId,
    +                [
    +                'metadata' => ['paymentMethod' => $request->request->get('paymentID')]
    +                ]
    +            );
    +            if ($customer instanceof ApiErrorException) {
    +                $customerArray = ['paymentError' => 1,'data'=>$customer,'message'=>'Customer update meta data failed'];
    +                return $this->generateResponse($customerArray, 400);
    +            }
    +            $data = ['success' => 1,'message'=>'Card updated successfully to this customer..'];
    +            return $this->generateResponse($data,200);
    +        }
    +        $data = ['error' => 1,'message'=>'Customer not registered in Stripe'];
    +        return $this->generateResponse($data, 400);
    +    }
    +
    +    /**
    +     *
    +     * @Route("/invoices", methods={ "GET" })
    +     *
    +     * @param Request $request A Http Request instance.
    +     */
    +    public function getInvoiceAction(Request $request)
    +    {
    +        $user = $this->getCurrentUser();
    +        $invoices = [];
    +        if (!empty($user->getStripeUserId())) {
    +            $stripe = $this->container->get('stripe.service');
    +            $stripe->setApiKey();
    +            $invoices = $stripe->getAllInvoice(['customer' => $user->getStripeUserId()]);
    +            if ($invoices instanceof ApiErrorException) {
    +                $invoicesArray = ['paymentError' => 1,'data'=>$invoices,'message'=>'List all invoice of customer faild'];
    +                return $this->generateResponse($invoicesArray, 400);
    +            }
    +        }    
    +        $data = ['success' => 1,'data' => $invoices];
    +        return $this->generateResponse($data, 200);
    +    }    
    +}
    diff --git a/src/UserBundle/DependencyInjection/Compiler/RemoveLastLoginListenerPass.php b/src/UserBundle/DependencyInjection/Compiler/RemoveLastLoginListenerPass.php
    new file mode 100644
    index 0000000..52746ef
    --- /dev/null
    +++ b/src/UserBundle/DependencyInjection/Compiler/RemoveLastLoginListenerPass.php
    @@ -0,0 +1,35 @@
    +hasDefinition(self::ID)) {
    +            $container->removeDefinition(self::ID);
    +        }
    +    }
    +}
    diff --git a/src/UserBundle/Doctrine/DBAL/Types/NotificationTypeEnumType.php b/src/UserBundle/Doctrine/DBAL/Types/NotificationTypeEnumType.php
    new file mode 100644
    index 0000000..6b2d332
    --- /dev/null
    +++ b/src/UserBundle/Doctrine/DBAL/Types/NotificationTypeEnumType.php
    @@ -0,0 +1,34 @@
    +recipients = new ArrayCollection();
    +        $this->feeds = new ArrayCollection();
    +        $this->schedules = new ArrayCollection();
    +        $this->history = new ArrayCollection();
    +
    +        $this->createdAt = new \DateTime();
    +        $this->lastSentAt = clone $this->createdAt;
    +    }
    +
    +    /**
    +     * Set name
    +     *
    +     * @param string $name Notification name.
    +     *
    +     * @return Notification
    +     */
    +    public function setName($name)
    +    {
    +        $this->name = $name;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get name
    +     *
    +     * @return string
    +     */
    +    public function getName()
    +    {
    +        return $this->name;
    +    }
    +
    +    /**
    +     * Set subject
    +     *
    +     * @param string $subject Notification email subject.
    +     *
    +     * @return Notification
    +     */
    +    public function setSubject($subject)
    +    {
    +        $this->subject = trim($subject);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get subject
    +     *
    +     * @return string
    +     */
    +    public function getSubject()
    +    {
    +        return $this->subject;
    +    }
    +
    +    /**
    +     * Set automatedSubject
    +     *
    +     * @param boolean $automatedSubject Flag, subject will be auto generated if
    +     *                                  set.
    +     *
    +     * @return Notification
    +     */
    +    public function setAutomatedSubject($automatedSubject = true)
    +    {
    +        $this->automatedSubject = (bool) $automatedSubject;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get automatedSubject
    +     *
    +     * @return boolean
    +     */
    +    public function isAutomatedSubject()
    +    {
    +        return $this->automatedSubject;
    +    }
    +
    +    /**
    +     * Set published
    +     *
    +     * @param boolean $published Flag, allow everybody to subscribe if set.
    +     *
    +     * @return Notification
    +     */
    +    public function setPublished($published = true)
    +    {
    +        $this->published = $published;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get published
    +     *
    +     * @return boolean
    +     */
    +    public function isPublished()
    +    {
    +        return $this->published;
    +    }
    +
    +    /**
    +     * Set allowUnsubscribe
    +     *
    +     * @param boolean $allowUnsubscribe Allow to unsubscribe.
    +     *
    +     * @return Notification
    +     */
    +    public function setAllowUnsubscribe($allowUnsubscribe = true)
    +    {
    +        $this->allowUnsubscribe = $allowUnsubscribe;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get allowUnsubscribe
    +     *
    +     * @return boolean
    +     */
    +    public function isAllowUnsubscribe()
    +    {
    +        return $this->allowUnsubscribe;
    +    }
    +
    +    /**
    +     * Set unsubscribeNotification
    +     *
    +     * @param boolean $unsubscribeNotification Notify owner if somebody is
    +     *                                         unsubscribed.
    +     *
    +     * @return Notification
    +     */
    +    public function setUnsubscribeNotification($unsubscribeNotification = true)
    +    {
    +        $this->unsubscribeNotification = $unsubscribeNotification;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get unsubscribeNotification
    +     *
    +     * @return boolean
    +     */
    +    public function isUnsubscribeNotification()
    +    {
    +        return $this->unsubscribeNotification;
    +    }
    +
    +    /**
    +     * Set sendWhenEmpty
    +     *
    +     * @param boolean $sendWhenEmpty Flag, render notification even if we don't
    +     *                               get new documents.
    +     *
    +     * @return Notification
    +     */
    +    public function setSendWhenEmpty($sendWhenEmpty = true)
    +    {
    +        $this->sendWhenEmpty = $sendWhenEmpty;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get sendWhenEmpty
    +     *
    +     * @return boolean
    +     */
    +    public function isSendWhenEmpty()
    +    {
    +        return $this->sendWhenEmpty;
    +    }
    +
    +    /**
    +     * Set timezone
    +     *
    +     * @param \DateTimeZone $timezone Notification timezone.
    +     *
    +     * @return Notification
    +     */
    +    public function setTimezone(\DateTimeZone $timezone)
    +    {
    +        $this->timezone = $timezone;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get timezone
    +     *
    +     * @return \DateTimeZone
    +     */
    +    public function getTimezone()
    +    {
    +        return $this->timezone;
    +    }
    +
    +    /**
    +     * Set sendUntil
    +     *
    +     * @param \DateTime $sendUntil Date until we render notification.
    +     *
    +     * @return Notification
    +     */
    +    public function setSendUntil(\DateTime $sendUntil = null)
    +    {
    +        $this->sendUntil = $sendUntil;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get sendUntil
    +     *
    +     * @return \DateTime|null
    +     */
    +    public function getSendUntil()
    +    {
    +        return $this->sendUntil;
    +    }
    +
    +    /**
    +     * Add recipient
    +     *
    +     * @param AbstractRecipient $recipient Who will receive this notifications.
    +     *
    +     * @return Notification
    +     */
    +    public function addRecipient(AbstractRecipient $recipient)
    +    {
    +        $this->recipients[] = $recipient;
    +        $recipient->addNotification($this);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove recipient
    +     *
    +     * @param AbstractRecipient $recipient Who will not receive notifications.
    +     *
    +     * @return Notification
    +     */
    +    public function removeRecipient(AbstractRecipient $recipient)
    +    {
    +        $this->recipients->removeElement($recipient);
    +        $recipient->removeNotification($this);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get recipients
    +     *
    +     * @return Collection
    +     */
    +    public function getRecipients()
    +    {
    +        return $this->recipients;
    +    }
    +
    +    /**
    +     * Set feeds.
    +     *
    +     * @param array $feeds Array of AbstractFeed entity instances.
    +     *
    +     * @return Notification
    +     */
    +    public function setFeeds(array $feeds = [])
    +    {
    +        $this->feeds = new ArrayCollection($feeds);
    +        $this->recomputeSourceCount();
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Add feed
    +     *
    +     * @param AbstractFeed $feed A AbstractFeed instance.
    +     *
    +     * @return Notification
    +     */
    +    public function addFeed(AbstractFeed $feed)
    +    {
    +        $this->feeds[] = $feed;
    +        $this->sourcesCount++;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove feed
    +     *
    +     * @param AbstractFeed $feed A AbstractFeed instance.
    +     *
    +     * @return Notification
    +     */
    +    public function removeFeed(AbstractFeed $feed)
    +    {
    +        $this->feeds->removeElement($feed);
    +        $this->sourcesCount--;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get feeds
    +     *
    +     * @return Collection
    +     */
    +    public function getFeeds()
    +    {
    +        return $this->feeds;
    +    }
    +
    +    /**
    +     * Add schedule
    +     *
    +     * @param AbstractNotificationSchedule $schedule A
    +     *                                               AbstractNotificationSchedule
    +     *                                               instance.
    +     *
    +     * @return Notification
    +     */
    +    public function addSchedule(AbstractNotificationSchedule $schedule)
    +    {
    +        $this->schedules[] = $schedule;
    +        $schedule->setNotification($this);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove schedule
    +     *
    +     * @param AbstractNotificationSchedule $schedule A
    +     *                                               AbstractNotificationSchedule
    +     *                                               instance.
    +     *
    +     * @return Notification
    +     */
    +    public function removeSchedule(AbstractNotificationSchedule $schedule)
    +    {
    +        $this->schedules->removeElement($schedule);
    +        $schedule->setNotification(null);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Set schedules
    +     *
    +     * @param AbstractNotificationSchedule[]|array $schedules Array of
    +     *                                                        AbstractNotificationSchedule
    +     *                                                        instance's.
    +     *
    +     * @return Notification
    +     */
    +    public function setSchedules(array $schedules)
    +    {
    +//        $valid = \Functional\every($schedules, function ($schedule) {
    +//            return $schedule instanceof AbstractNotificationSchedule;
    +//        });
    +        $valid = \nspl\a\all($schedules, \nspl\f\rpartial('\app\op\isInstanceOf', AbstractNotificationSchedule::class));
    +
    +        if (! $valid) {
    +            throw new \InvalidArgumentException('Expects array of AbstractNotificationSchedule instance\'s');
    +        }
    +
    +        $this->schedules = new ArrayCollection($schedules);
    +        /** @var AbstractNotificationSchedule $schedule */
    +        foreach ($this->schedules as $schedule) {
    +            $schedule->setNotification($this);
    +        }
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get schedules
    +     *
    +     * @return Collection
    +     */
    +    public function getSchedules()
    +    {
    +        return $this->schedules;
    +    }
    +
    +    /**
    +     * Add history
    +     *
    +     * @param NotificationSendHistory $history A NotificationSendHistory entity
    +     *                                         instance.
    +     *
    +     * @return Notification
    +     */
    +    public function addHistory(NotificationSendHistory $history)
    +    {
    +        $this->history[] = $history;
    +        $history->setNotification($this);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove history
    +     *
    +     * @param NotificationSendHistory $history A NotificationSendHistory entity
    +     *                                         instance.
    +     *
    +     * @return Notification
    +     */
    +    public function removeHistory(NotificationSendHistory $history)
    +    {
    +        $this->history->removeElement($history);
    +        $history->setNotification(null);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get history
    +     *
    +     * @return Collection
    +     */
    +    public function getHistory()
    +    {
    +        return $this->history;
    +    }
    +
    +    /**
    +     * Set sourcesCount
    +     *
    +     * @param integer $sourcesCount How mush sources bind to this notification.
    +     *
    +     * @return Notification
    +     */
    +    public function setSourcesCount($sourcesCount)
    +    {
    +        $this->sourcesCount = $sourcesCount;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get sourcesCount
    +     *
    +     * @return integer
    +     */
    +    public function getSourcesCount()
    +    {
    +        return $this->sourcesCount;
    +    }
    +
    +    /**
    +     * Get createdAt
    +     *
    +     * @return \DateTime
    +     */
    +    public function getCreatedAt()
    +    {
    +        return $this->createdAt;
    +    }
    +
    +    /**
    +     * Set lastSentAt
    +     *
    +     * @param \DateTime|null $lastSentAt When this notification was last sent.
    +     *
    +     * @return static
    +     */
    +    public function setLastSentAt(\DateTime $lastSentAt = null)
    +    {
    +        $this->lastSentAt = $lastSentAt;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get lastSentAt
    +     *
    +     * @return \DateTime
    +     */
    +    public function getLastSentAt()
    +    {
    +        return $this->lastSentAt;
    +    }
    +
    +    /**
    +     * Checks that this notification can be sent.
    +     *
    +     * @param \DateTime $sendDate When this notification is attempted to render.
    +     *
    +     * @return boolean
    +     */
    +    public function isCanBeSent(\DateTime $sendDate)
    +    {
    +        //
    +        // Notification can be render if:
    +        // * have at least one source, schedule and recipient.
    +        // * is active.
    +        // * if render until field is defined and render date is before it.
    +        //
    +        return ($this->sourcesCount > 0) && (count($this->schedules) > 0)
    +            && (count($this->recipients) > 0) && $this->active
    +            && (($this->sendUntil === null) || ($this->sendUntil >= $sendDate));
    +    }
    +
    +    /**
    +     * Recompute source count.
    +     *
    +     * @return void
    +     */
    +    private function recomputeSourceCount()
    +    {
    +        $this->sourcesCount = count($this->feeds);
    +    }
    +
    +    /**
    +     * Return metadata for current entity.
    +     *
    +     * @return \ApiBundle\Serializer\Metadata\Metadata
    +     */
    +    public function getMetadata()
    +    {
    +        return new Metadata(static::class, [
    +            PropertyMetadata::createInteger('id', [ 'id' ]),
    +            PropertyMetadata::createString('name', [ 'notification', 'notification_list' ]),
    +            PropertyMetadata::createCollection('recipients', AbstractRecipient::class, [ 'notification', 'notification_list' ]),
    +            PropertyMetadata::createEntity('owner', User::class, [ 'notification', 'notification_list' ]),
    +            PropertyMetadata::createString('subject', [ 'notification' ])
    +                ->setNullable(true),
    +            PropertyMetadata::createEnum('themeType', ThemeTypeEnum::class, [ 'notification', 'notification_list' ]),
    +            PropertyMetadata::createInteger('theme', [ 'notification' ])
    +                ->setField(function () {
    +                    return $this->theme->getId();
    +                }),
    +            PropertyMetadata::createBoolean('automatedSubject', [ 'notification' ]),
    +            PropertyMetadata::createBoolean('published', [ 'notification', 'notification_list' ]),
    +            PropertyMetadata::createBoolean('allowUnsubscribe', [ 'notification', 'notification_list' ]),
    +            PropertyMetadata::createBoolean('unsubscribeNotification', [ 'notification' ]),
    +            PropertyMetadata::createArray('sources', [ 'notification' ])
    +                ->setField(function () {
    +                    $feeds = $this->feeds->map(function (AbstractFeed $feed) {
    +                        return [
    +                            'type' => 'feed',
    +                            'id' => $feed->getId(),
    +                            'name' => $feed->getName(),
    +                            'class' => $feed->getSpecificType(),
    +                        ];
    +                    })->toArray();
    +
    +                    return $feeds;
    +                }),
    +
    +            PropertyMetadata::createBoolean('sendWhenEmpty', [ 'notification' ]),
    +            PropertyMetadata::createString('timezone', [ 'notification' ])
    +                ->setField(function () {
    +                    return $this->timezone->getName();
    +                }),
    +            PropertyMetadata::createCollection('automatic', AbstractNotificationSchedule::class, [ 'notification', 'notification_list' ])
    +                ->setField('schedules'),
    +            PropertyMetadata::createString('sendUntil', [ 'notification' ])
    +                ->setNullable(true)
    +                ->setField(function () {
    +                    return \app\op\invokeIf($this->sendUntil, 'format', [ 'Y-m-d' ]);
    +                }),
    +            PropertyMetadata::createBoolean('active', [ 'notification', 'notification_list' ]),
    +            PropertyMetadata::createInteger('sourcesCount', [ 'notification_list' ]),
    +            PropertyMetadata::createBoolean('subscribed', [ 'notification_list' ]),
    +            PropertyMetadata::createObject('plainDiff', [ 'notification' ])
    +                ->setField('plainThemeOptionsDiff'),
    +            PropertyMetadata::createObject('enhancedDiff', [ 'notification' ])
    +                ->setField('enhancedThemeOptionsDiff'),
    +        ]);
    +    }
    +
    +    /**
    +     * Return default normalization groups.
    +     *
    +     * @return array
    +     */
    +    public function defaultGroups()
    +    {
    +        return [ 'notification', 'schedule', 'id' ];
    +    }
    +
    +    /**
    +     * Get entity type
    +     *
    +     * @return string
    +     */
    +    public function getEntityType()
    +    {
    +        return (string) $this->notificationType;
    +    }
    +
    +    /**
    +     * Set notificationType
    +     *
    +     * @param NotificationTypeEnum $notificationType A NotificationTypeEnum instance.
    +     *
    +     * @return Notification
    +     */
    +    public function setNotificationType(NotificationTypeEnum $notificationType)
    +    {
    +        $this->notificationType = $notificationType;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get notificationType
    +     *
    +     * @return NotificationTypeEnum
    +     */
    +    public function getNotificationType()
    +    {
    +        return $this->notificationType;
    +    }
    +
    +    /**
    +     * Set themeType
    +     *
    +     * @param ThemeTypeEnum $themeType A ThemeTypeEnum instance.
    +     *
    +     * @return Notification
    +     */
    +    public function setThemeType(ThemeTypeEnum $themeType)
    +    {
    +        $this->themeType = $themeType;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get themeType
    +     *
    +     * @return ThemeTypeEnum
    +     */
    +    public function getThemeType()
    +    {
    +        return $this->themeType;
    +    }
    +
    +    /**
    +     * Set enhancedThemeOptionsDiff
    +     *
    +     * @param array $enhancedThemeOptionsDiff Difference between theme and current
    +     *                                        notification options for enhanced
    +     *                                        layout.
    +     *
    +     * @return Notification
    +     */
    +    public function setEnhancedThemeOptionsDiff(array $enhancedThemeOptionsDiff)
    +    {
    +        $this->enhancedThemeOptionsDiff = $enhancedThemeOptionsDiff;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get enhancedThemeOptionsDiff
    +     *
    +     * @return array
    +     */
    +    public function getEnhancedThemeOptionsDiff()
    +    {
    +        return $this->enhancedThemeOptionsDiff;
    +    }
    +
    +    /**
    +     * Set plainThemeOptionsDiff
    +     *
    +     * @param array $plainThemeOptionsDiff Difference between theme and current
    +     *                                     notification options for plain layout.
    +     *
    +     * @return Notification
    +     */
    +    public function setPlainThemeOptionsDiff(array $plainThemeOptionsDiff)
    +    {
    +        $this->plainThemeOptionsDiff = $plainThemeOptionsDiff;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get plainThemeOptionsDiff
    +     *
    +     * @return array
    +     */
    +    public function getPlainThemeOptionsDiff()
    +    {
    +        return $this->plainThemeOptionsDiff;
    +    }
    +
    +    /**
    +     * Set theme
    +     *
    +     * @param NotificationTheme $theme A NotificationTheme entity instance.
    +     *
    +     * @return Notification
    +     */
    +    public function setTheme(NotificationTheme $theme = null)
    +    {
    +        $this->theme = $theme;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get theme
    +     *
    +     * @return NotificationTheme
    +     */
    +    public function getTheme()
    +    {
    +        return $this->theme;
    +    }
    +
    +    /**
    +     * Get actual theme options.
    +     *
    +     * It's merge between selected layout theme options and notification specific
    +     * changes.
    +     *
    +     * @return NotificationThemeOptions
    +     */
    +    public function getActualThemeOptions()
    +    {
    +        $isEnhanced = $this->themeType->is(ThemeTypeEnum::ENHANCED);
    +
    +        $baseOptions = $isEnhanced ? clone $this->theme->getEnhanced() : clone $this->theme->getPlain();
    +        $diff = $isEnhanced ? $this->enhancedThemeOptionsDiff : $this->plainThemeOptionsDiff;
    +
    +        $accessor = PropertyAccess::createPropertyAccessorBuilder()
    +            ->disableMagicCall()
    +            ->getPropertyAccessor();
    +        foreach ($diff as $path => $value) {
    +            if ($value !== null) {
    +                $path = str_replace(':', '.', $path);
    +                $accessor->setValue($baseOptions, $path, $value);
    +            }
    +        }
    +
    +        return $baseOptions;
    +    }
    +
    +    /**
    +     * Set billingSubscription
    +     *
    +     * @param AbstractSubscription $billingSubscription A billing subscription
    +     *                                                  entity instance.
    +     *
    +     * @return Notification
    +     */
    +    public function setBillingSubscription(AbstractSubscription $billingSubscription = null)
    +    {
    +        $this->billingSubscription = $billingSubscription;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get billingSubscription
    +     *
    +     * @return AbstractSubscription
    +     */
    +    public function getBillingSubscription()
    +    {
    +        return $this->billingSubscription;
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/NotificationSendHistory.php b/src/UserBundle/Entity/Notification/NotificationSendHistory.php
    new file mode 100644
    index 0000000..ccd4a99
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/NotificationSendHistory.php
    @@ -0,0 +1,199 @@
    +notification = $notification;
    +
    +        foreach ($schedule as $item) {
    +            $this->addSchedule($item);
    +        }
    +        $this->date = $notification->getLastSentAt();
    +    }
    +
    +    /**
    +     * Set notification
    +     *
    +     * @param Notification $notification A Notification entity instance.
    +     *
    +     * @return NotificationSendHistory
    +     */
    +    public function setNotification(Notification $notification = null)
    +    {
    +        $this->notification = $notification;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get notification
    +     *
    +     * @return Notification
    +     */
    +    public function getNotification()
    +    {
    +        return $this->notification;
    +    }
    +
    +    /**
    +     * Add schedule
    +     *
    +     * @param AbstractNotificationSchedule $schedule A AbstractNotificationSchedule
    +     *                                               instance.
    +     *
    +     * @return NotificationSendHistory
    +     */
    +    public function addSchedule(AbstractNotificationSchedule $schedule)
    +    {
    +        $this->schedules[] = $schedule;
    +        $schedule->setHistory($this);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove schedule
    +     *
    +     * @param AbstractNotificationSchedule $schedule A AbstractNotificationSchedule
    +     *                                               instance.
    +     *
    +     * @return  NotificationSendHistory
    +     */
    +    public function removeSchedule(AbstractNotificationSchedule $schedule)
    +    {
    +        $this->schedules->removeElement($schedule);
    +        $schedule->setHistory(null);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get schedules
    +     *
    +     * @return Collection
    +     */
    +    public function getSchedules()
    +    {
    +        return $this->schedules;
    +    }
    +
    +    /**
    +     * Set date
    +     *
    +     * @param \DateTime $date Sent date.
    +     *
    +     * @return NotificationSendHistory
    +     */
    +    public function setDate(\DateTime $date)
    +    {
    +        $this->date = $date;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get date
    +     *
    +     * @return \DateTime
    +     */
    +    public function getDate()
    +    {
    +        return $this->date;
    +    }
    +
    +    /**
    +     * Return metadata for current entity.
    +     *
    +     * @return \ApiBundle\Serializer\Metadata\Metadata
    +     */
    +    public function getMetadata()
    +    {
    +        return new Metadata(static::class, [
    +            PropertyMetadata::createInteger('notification', [ 'history' ])
    +                ->setField(function () {
    +                    return $this->notification->getId();
    +                }),
    +            PropertyMetadata::createString('name', [ 'history' ])
    +                ->setField(function () {
    +                    return $this->notification->getName();
    +                }),
    +            PropertyMetadata::createString('type', [ 'history' ])
    +                ->setField(function () {
    +                    return $this->notification->getNotificationType()->getValue();
    +                }),
    +            PropertyMetadata::createCollection('schedule', AbstractNotificationSchedule::class, [ 'history' ])
    +                ->setField('schedules'),
    +            PropertyMetadata::createDate('date', [ 'history' ]),
    +        ]);
    +    }
    +
    +    /**
    +     * Return default normalization groups.
    +     *
    +     * @return array
    +     */
    +    public function defaultGroups()
    +    {
    +        return [ 'id', 'history' ];
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/NotificationTheme.php b/src/UserBundle/Entity/Notification/NotificationTheme.php
    new file mode 100644
    index 0000000..a6e6092
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/NotificationTheme.php
    @@ -0,0 +1,211 @@
    +name = $name;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get name
    +     *
    +     * @return string
    +     */
    +    public function getName()
    +    {
    +        return $this->name;
    +    }
    +
    +    /**
    +     * Set enhanced
    +     *
    +     * @param NotificationThemeOptions $enhanced A NotificationThemeOptions instance.
    +     *
    +     * @return NotificationTheme
    +     */
    +    public function setEnhanced(NotificationThemeOptions $enhanced)
    +    {
    +        $this->enhanced = $enhanced;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get enhanced
    +     *
    +     * @return NotificationThemeOptions
    +     */
    +    public function getEnhanced()
    +    {
    +        return $this->enhanced;
    +    }
    +
    +    /**
    +     * Set plain
    +     *
    +     * @param NotificationThemeOptions $plain A NotificationThemeOptions instance.
    +     *
    +     * @return NotificationTheme
    +     */
    +    public function setPlain(NotificationThemeOptions $plain)
    +    {
    +        $this->plain = $plain;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get plainOptions
    +     *
    +     * @return NotificationThemeOptions
    +     */
    +    public function getPlain()
    +    {
    +        return $this->plain;
    +    }
    +
    +    /**
    +     * @param boolean $published Should this notification be published or not.
    +     *
    +     * @return NotificationTheme
    +     */
    +    public function setPublished($published = true)
    +    {
    +        $this->published = $published;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isPublished()
    +    {
    +        return $this->published;
    +    }
    +
    +    /**
    +     * Set default
    +     *
    +     * @param boolean $default Set theme as default.
    +     *
    +     * @return NotificationTheme
    +     */
    +    public function setDefault($default = true)
    +    {
    +        $this->default = $default;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get default
    +     *
    +     * @return boolean
    +     */
    +    public function isDefault()
    +    {
    +        return $this->default;
    +    }
    +
    +    /**
    +     * Return metadata for current entity.
    +     *
    +     * @return \ApiBundle\Serializer\Metadata\Metadata
    +     */
    +    public function getMetadata()
    +    {
    +        return new Metadata(static::class, [
    +            PropertyMetadata::createInteger('id', [ 'id' ]),
    +            PropertyMetadata::createString('name', [ 'notification_theme', 'notification_theme_list' ]),
    +            PropertyMetadata::createBoolean('published', [ 'notification_theme' ]),
    +            PropertyMetadata::createObject('enhanced', [ 'notification_theme' ])
    +                ->setField('enhanced')
    +                ->setActualType(NotificationThemeOptions::class),
    +            PropertyMetadata::createObject('plain', [ 'notification_theme' ])
    +                ->setField('plain')
    +                ->setActualType(NotificationThemeOptions::class),
    +        ]);
    +    }
    +
    +    /**
    +     * Return default normalization groups.
    +     *
    +     * @return array
    +     */
    +    public function defaultGroups()
    +    {
    +        return [ 'id', 'notification_theme' ];
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/NotificationThemeOptions.php b/src/UserBundle/Entity/Notification/NotificationThemeOptions.php
    new file mode 100644
    index 0000000..fefddc3
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/NotificationThemeOptions.php
    @@ -0,0 +1,300 @@
    +summary = trim($summary);
    +        $this->conclusion = trim($conclusion);
    +        $this->header = $header;
    +        $this->fonts = $fonts;
    +        $this->content = $content;
    +        $this->colors = $colors;
    +    }
    +
    +    /**
    +     * @return static
    +     */
    +    public static function createDefault()
    +    {
    +        return new static(
    +            '',
    +            '',
    +            new ThemeOptionHeader(
    +                ThemeOptionHeader::DEFAULT_IMAGE,
    +                '',
    +                'Newsletter'
    +            ),
    +            new ThemeOptionFonts(
    +                new ThemeOptionFont(FontFamilyEnum::arial(), self::DEFAULT_HEADER_SIZE),
    +                new ThemeOptionFont(FontFamilyEnum::arial(), self::DEFAULT_TABLE_OF_CONTENTS_SIZE),
    +                new ThemeOptionFont(FontFamilyEnum::arial(), self::DEFAULT_FEED_TITLE_SIZE),
    +                new ThemeOptionFont(FontFamilyEnum::arial(), self::DEFAULT_ARTICLE_HEADLINE_SIZE),
    +                new ThemeOptionFont(FontFamilyEnum::arial(), self::DEFAULT_SOURCE_SIZE),
    +                new ThemeOptionFont(FontFamilyEnum::arial(), self::DEFAULT_AUTHOR_SIZE),
    +                new ThemeOptionFont(FontFamilyEnum::arial(), self::DEFAULT_DATE_SIZE),
    +                new ThemeOptionFont(FontFamilyEnum::arial(), self::DEFAULT_ARTICLE_CONTENT_SIZE)
    +            ),
    +            new ThemeOptionContent(
    +                new ThemeOptionHighlightKeywords(),
    +                new ThemeOptionShowInfo(
    +                    ThemeOptionsUserCommentsEnum::no(),
    +                    ThemeOptionsTableOfContentsEnum::simple()
    +                ),
    +                'en',
    +                ThemeOptionExtractEnum::context()
    +            ),
    +            new ThemeOptionColors(
    +                new ThemeOptionColorsBackground(),
    +                new ThemeOptionColorsText()
    +            )
    +        );
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getSummary()
    +    {
    +        return $this->summary;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function hasSummary()
    +    {
    +        return $this->summary !== '';
    +    }
    +
    +    /**
    +     * @param string $summary Summary text.
    +     *
    +     * @return NotificationThemeOptions
    +     */
    +    public function setSummary($summary)
    +    {
    +        $this->summary = $summary;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getConclusion()
    +    {
    +        return $this->conclusion;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function hasConclusion()
    +    {
    +        return $this->conclusion !== '';
    +    }
    +
    +    /**
    +     * @param string $conclusion Conclusion text.
    +     *
    +     * @return NotificationThemeOptions
    +     */
    +    public function setConclusion($conclusion)
    +    {
    +        $this->conclusion = $conclusion;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionHeader
    +     */
    +    public function getHeader()
    +    {
    +        return $this->header;
    +    }
    +
    +    /**
    +     * @param ThemeOptionHeader $header A ThemeOptionHeader instance.
    +     *
    +     * @return NotificationThemeOptions
    +     */
    +    public function setHeader(ThemeOptionHeader $header)
    +    {
    +        $this->header = $header;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionFonts
    +     */
    +    public function getFonts()
    +    {
    +        return $this->fonts;
    +    }
    +
    +    /**
    +     * @param ThemeOptionFonts $fonts A ThemeOptionFonts instance.
    +     *
    +     * @return NotificationThemeOptions
    +     */
    +    public function setFonts(ThemeOptionFonts $fonts)
    +    {
    +        $this->fonts = $fonts;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionContent
    +     */
    +    public function getContent()
    +    {
    +        return $this->content;
    +    }
    +
    +    /**
    +     * @param ThemeOptionContent $content A ThemeOptionContent instance.
    +     *
    +     * @return NotificationThemeOptions
    +     */
    +    public function setContent(ThemeOptionContent $content)
    +    {
    +        $this->content = $content;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionColors
    +     */
    +    public function getColors()
    +    {
    +        return $this->colors;
    +    }
    +
    +    /**
    +     * @param ThemeOptionColors $colors A ThemeOptionColors instace.
    +     *
    +     * @return NotificationThemeOptions
    +     */
    +    public function setColors(ThemeOptionColors $colors)
    +    {
    +        $this->colors = $colors;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return array
    +     */
    +    public function toArray()
    +    {
    +        return [
    +            'summary' => $this->summary,
    +            'conclusion' => $this->conclusion,
    +            'header' => $this->header->toArray(),
    +            'fonts' => $this->fonts->toArray(),
    +            'content' => $this->content->toArray(),
    +            'colors' => $this->colors->toArray(),
    +        ];
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/Schedule/AbstractNotificationSchedule.php b/src/UserBundle/Entity/Notification/Schedule/AbstractNotificationSchedule.php
    new file mode 100644
    index 0000000..413280e
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/Schedule/AbstractNotificationSchedule.php
    @@ -0,0 +1,140 @@
    +notification = $notification;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get notification
    +     *
    +     * @return Notification
    +     */
    +    public function getNotification()
    +    {
    +        return $this->notification;
    +    }
    +
    +    /**
    +     * Set history
    +     *
    +     * @param NotificationSendHistory $history A NotificationSendHistory instance.
    +     *
    +     * @return AbstractNotificationSchedule
    +     */
    +    public function setHistory(NotificationSendHistory $history = null)
    +    {
    +        $this->history = $history;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get history
    +     *
    +     * @return NotificationSendHistory
    +     */
    +    public function getHistory()
    +    {
    +        return $this->history;
    +    }
    +
    +    /**
    +     * Compute all date's for this schedule in specified period.
    +     *
    +     * @param \DateTime $start Start of computing period.
    +     * @param \DateTime $end   End of computing period.
    +     *
    +     * @return \DateTime[]
    +     */
    +    public function computeDates(\DateTime $start, \DateTime $end)
    +    {
    +        $modifiedStart = clone $start;
    +        $modifiedEnd = clone $end;
    +
    +        $modifiedStart
    +            ->setTime($modifiedStart->format('H'), $modifiedStart->format('i'), 0);
    +
    +        // Add 1 seconds in order to catch end date if it should exists in result's.
    +        $modifiedEnd
    +            ->setTime($modifiedEnd->format('H'), $modifiedEnd->format('i'), 1);
    +
    +        return $this->doComputeDates($modifiedStart, $modifiedEnd);
    +    }
    +
    +    /**
    +     * Return key identifier for current schedule.
    +     *
    +     * @return string
    +     */
    +    abstract public function getKey();
    +
    +    /**
    +     * Compute all date's for this schedule in specified period.
    +     *
    +     * @param \DateTime $start Start of computing period.
    +     * @param \DateTime $end   End of computing period.
    +     *
    +     * @return \DateTime[]
    +     */
    +    abstract protected function doComputeDates(\DateTime $start, \DateTime $end);
    +}
    diff --git a/src/UserBundle/Entity/Notification/Schedule/DailyNotificationSchedule.php b/src/UserBundle/Entity/Notification/Schedule/DailyNotificationSchedule.php
    new file mode 100644
    index 0000000..22d150e
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/Schedule/DailyNotificationSchedule.php
    @@ -0,0 +1,251 @@
    + 'T15M',
    +        self::TIME_30_M => 'T30M',
    +        self::TIME_1_H => 'T1H',
    +        self::TIME_2_H => 'T2H',
    +        self::TIME_3_H => 'T3H',
    +        self::TIME_4_H => 'T4H',
    +        self::TIME_6_H => 'T6H',
    +        self::TIME_12_H => 'T12H',
    +        self::TIME_ONCE => '1D',
    +    ];
    +
    +
    +    /**
    +     * Get available time values.
    +     *
    +     * @return string[]
    +     */
    +    public static function getAvailableTime()
    +    {
    +        return [
    +            self::TIME_15_M,
    +            self::TIME_30_M,
    +            self::TIME_1_H,
    +            self::TIME_2_H,
    +            self::TIME_3_H,
    +            self::TIME_4_H,
    +            self::TIME_6_H,
    +            self::TIME_12_H,
    +            self::TIME_ONCE,
    +        ];
    +    }
    +
    +    /**
    +     * Get available days values.
    +     *
    +     * @return string[]
    +     */
    +    public static function getAvailableDays()
    +    {
    +        return [
    +            self::DAYS_ALL,
    +            self::DAYS_WEEKDAYS,
    +            self::DAYS_WEEKENDS,
    +        ];
    +    }
    +
    +    /**
    +     * Set time
    +     *
    +     * @param string $time One of TIME_ constant's.
    +     *
    +     * @return DailyNotificationSchedule
    +     */
    +    public function setTime($time)
    +    {
    +        $this->time = $time;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get time
    +     *
    +     * @return string
    +     */
    +    public function getTime()
    +    {
    +        return $this->time;
    +    }
    +
    +    /**
    +     * Set days
    +     *
    +     * @param string $days One of DAYS_ constant's.
    +     *
    +     * @return DailyNotificationSchedule
    +     */
    +    public function setDays($days)
    +    {
    +        $this->days = $days;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get days
    +     *
    +     * @return string
    +     */
    +    public function getDays()
    +    {
    +        return $this->days;
    +    }
    +
    +    /**
    +     * Get entity type
    +     *
    +     * @return string
    +     */
    +    public function getEntityType()
    +    {
    +        return 'daily';
    +    }
    +
    +    /**
    +     * Return metadata for current entity.
    +     *
    +     * @return \ApiBundle\Serializer\Metadata\Metadata
    +     */
    +    public function getMetadata()
    +    {
    +        return new Metadata(static::class, [
    +            PropertyMetadata::createString('time', [ 'schedule' ]),
    +            PropertyMetadata::createString('days', [ 'schedule' ]),
    +        ]);
    +    }
    +
    +    /**
    +     * Return default normalization groups.
    +     *
    +     * @return array
    +     */
    +    public function defaultGroups()
    +    {
    +        return [ 'schedule' ];
    +    }
    +
    +    /**
    +     * @param array $data Normalized DailyNotificationSchedule.
    +     *
    +     * @return DailyNotificationSchedule
    +     */
    +    public static function denormalize(array $data)
    +    {
    +        if (! isset($data['time'], $data['days'])) {
    +            throw new \LogicException('Normalized DailyNotificationSchedule data must have \'time\' and \'days\' fields.');
    +        }
    +
    +        return DailyNotificationSchedule::create()
    +            ->setTime($data['time'])
    +            ->setDays($data['days']);
    +    }
    +
    +    /**
    +     * Return key identifier for current schedule.
    +     *
    +     * @return string
    +     */
    +    public function getKey()
    +    {
    +        return sprintf('daily_%s_%s', $this->time, $this->days);
    +    }
    +
    +    /**
    +     * Compute all date's for this schedule in specified period.
    +     *
    +     * @param \DateTime $start Start of computing period.
    +     * @param \DateTime $end   End of computing period.
    +     *
    +     * @return \DateTime[]
    +     */
    +    protected function doComputeDates(\DateTime $start, \DateTime $end)
    +    {
    +        $period = new \DatePeriod(
    +            $start,
    +            new \DateInterval('P'. self::$timeMap[$this->time]),
    +            $end,
    +            \DatePeriod::EXCLUDE_START_DATE
    +        );
    +
    +        $results = [];
    +        switch ($this->days) {
    +            case self::DAYS_ALL:
    +                $results = iterator_to_array($period);
    +                break;
    +
    +            case self::DAYS_WEEKDAYS:
    +                /** @var \DateTime $date */
    +                foreach ($period as $date) {
    +                    if ($date->format('N') <= 5) {
    +                        $results[] = $date;
    +                    }
    +                }
    +                break;
    +
    +            case self::DAYS_WEEKENDS:
    +                /** @var \DateTime $date */
    +                foreach ($period as $date) {
    +                    if ($date->format('N') > 5) {
    +                        $results[] = $date;
    +                    }
    +                }
    +                break;
    +        }
    +
    +        return $results;
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/Schedule/MonthlyNotificationSchedule.php b/src/UserBundle/Entity/Notification/Schedule/MonthlyNotificationSchedule.php
    new file mode 100644
    index 0000000..f69fda4
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/Schedule/MonthlyNotificationSchedule.php
    @@ -0,0 +1,235 @@
    +day = $day;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get day
    +     *
    +     * @return string
    +     */
    +    public function getDay()
    +    {
    +        return $this->day;
    +    }
    +
    +    /**
    +     * Set hour
    +     *
    +     * @param integer $hour Schedule hours.
    +     *
    +     * @return MonthlyNotificationSchedule
    +     */
    +    public function setHour($hour)
    +    {
    +        $this->hour = $hour;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get hour
    +     *
    +     * @return integer
    +     */
    +    public function getHour()
    +    {
    +        return $this->hour;
    +    }
    +
    +    /**
    +     * Set minutes
    +     *
    +     * @param integer $minute Schedule minutes.
    +     *
    +     * @return MonthlyNotificationSchedule
    +     */
    +    public function setMinute($minute)
    +    {
    +        $this->minute = $minute;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get minute
    +     *
    +     * @return integer
    +     */
    +    public function getMinute()
    +    {
    +        return $this->minute;
    +    }
    +
    +    /**
    +     * Get entity type
    +     *
    +     * @return string
    +     */
    +    public function getEntityType()
    +    {
    +        return 'monthly';
    +    }
    +
    +    /**
    +     * Return metadata for current entity.
    +     *
    +     * @return \ApiBundle\Serializer\Metadata\Metadata
    +     */
    +    public function getMetadata()
    +    {
    +        return new Metadata(static::class, [
    +            PropertyMetadata::createString('day', [ 'schedule' ]),
    +            PropertyMetadata::createString('hour', [ 'schedule' ]),
    +            PropertyMetadata::createString('minute', [ 'schedule' ]),
    +        ]);
    +    }
    +
    +    /**
    +     * Return default normalization groups.
    +     *
    +     * @return array
    +     */
    +    public function defaultGroups()
    +    {
    +        return [ 'schedule' ];
    +    }
    +
    +    /**
    +     * @param array $data Normalized MonthlyNotificationSchedule.
    +     *
    +     * @return MonthlyNotificationSchedule
    +     */
    +    public static function denormalize(array $data)
    +    {
    +        if (! isset($data['day'], $data['hour'], $data['minute'])) {
    +            throw new \LogicException('Normalized MonthlyNotificationSchedule data must have \'day\', \'hour\' and \'minute\' fields.');
    +        }
    +
    +        return MonthlyNotificationSchedule::create()
    +            ->setDay($data['day'])
    +            ->setHour($data['hour'])
    +            ->setMinute($data['minute']);
    +    }
    +
    +    /**
    +     * Return key identifier for current schedule.
    +     *
    +     * @return string
    +     */
    +    public function getKey()
    +    {
    +        return sprintf(
    +            'monthly_%s_%s_%s',
    +            $this->day,
    +            $this->hour,
    +            $this->minute
    +        );
    +    }
    +
    +    /**
    +     * Compute all date's for this schedule in specified period.
    +     *
    +     * @param \DateTime $start Start of computing period.
    +     * @param \DateTime $end   End of computing period.
    +     *
    +     * @return \DateTime[]
    +     */
    +    protected function doComputeDates(\DateTime $start, \DateTime $end)
    +    {
    +        $dates = [];
    +        $date = clone $start;
    +
    +        if ($this->day === self::DAY_LAST) {
    +            $date->modify('last day of this month');
    +            while ($date <= $end) {
    +                $tmp = clone $date;
    +                $dates[] = $tmp->setTime($this->hour, $this->minute);
    +                $date->modify('last day of next month');
    +            }
    +        } else {
    +            $currentNum = $date->format('j');
    +
    +            if ($currentNum > $this->day) {
    +                $date
    +                    ->modify('first day of next month')
    +                    ->modify(sprintf('%d day', $this->day - 1));
    +            } elseif ($currentNum < $this->day) {
    +                $date->modify(sprintf('%d day', $this->day - $currentNum));
    +            }
    +
    +            while ($date <= $end) {
    +                $tmp = clone $date;
    +                $dates[] = $tmp->setTime($this->hour, $this->minute);
    +                $date
    +                    ->modify('first day of next month')
    +                    ->modify(sprintf('%d day', $this->day - 1));
    +            }
    +        }
    +
    +        return $dates;
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/Schedule/WeeklyNotificationSchedule.php b/src/UserBundle/Entity/Notification/Schedule/WeeklyNotificationSchedule.php
    new file mode 100644
    index 0000000..b175e31
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/Schedule/WeeklyNotificationSchedule.php
    @@ -0,0 +1,331 @@
    + 1,
    +        self::DAY_TUESDAY => 2,
    +        self::DAY_WEDNESDAY => 3,
    +        self::DAY_TUESDAY => 4,
    +        self::DAY_FRIDAY => 5,
    +        self::DAY_SATURDAY => 6,
    +        self::DAY_SUNDAY => 7,
    +    ];
    +
    +    /**
    +     * Get available period's.
    +     *
    +     * @return string[]
    +     */
    +    public static function getAvailablePeriod()
    +    {
    +        return [
    +            self::PERIOD_EVERY,
    +            self::PERIOD_FIRST,
    +            self::PERIOD_SECOND,
    +            self::PERIOD_THIRD,
    +            self::PERIOD_FOURTH,
    +            self::PERIOD_LAST,
    +        ];
    +    }
    +
    +    /**
    +     * Get available day's.
    +     *
    +     * @return string[]
    +     */
    +    public static function getAvailableDay()
    +    {
    +        return [
    +            self::DAY_MONDAY,
    +            self::DAY_TUESDAY,
    +            self::DAY_WEDNESDAY,
    +            self::DAY_TUESDAY,
    +            self::DAY_FRIDAY,
    +            self::DAY_SATURDAY,
    +            self::DAY_SUNDAY,
    +        ];
    +    }
    +
    +    /**
    +     * Set period
    +     *
    +     * @param string $period One of PERIOD_ const's.
    +     *
    +     * @return WeeklyNotificationSchedule
    +     */
    +    public function setPeriod($period)
    +    {
    +        $this->period = $period;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get period
    +     *
    +     * @return string
    +     */
    +    public function getPeriod()
    +    {
    +        return $this->period;
    +    }
    +
    +    /**
    +     * Set day
    +     *
    +     * @param string $day One of DAY_ const's.
    +     *
    +     * @return WeeklyNotificationSchedule
    +     */
    +    public function setDay($day)
    +    {
    +        $this->day = $day;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get day
    +     *
    +     * @return string
    +     */
    +    public function getDay()
    +    {
    +        return $this->day;
    +    }
    +
    +    /**
    +     * Set hour
    +     *
    +     * @param integer $hour Schedule hours.
    +     *
    +     * @return WeeklyNotificationSchedule
    +     */
    +    public function setHour($hour)
    +    {
    +        $this->hour = $hour;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get hour
    +     *
    +     * @return integer
    +     */
    +    public function getHour()
    +    {
    +        return $this->hour;
    +    }
    +
    +    /**
    +     * Set minute
    +     *
    +     * @param integer $minute Schedule minute.
    +     *
    +     * @return WeeklyNotificationSchedule
    +     */
    +    public function setMinute($minute)
    +    {
    +        $this->minute = $minute;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get minute
    +     *
    +     * @return integer
    +     */
    +    public function getMinute()
    +    {
    +        return $this->minute;
    +    }
    +
    +    /**
    +     * Get entity type
    +     *
    +     * @return string
    +     */
    +    public function getEntityType()
    +    {
    +        return 'weekly';
    +    }
    +
    +    /**
    +     * Return metadata for current entity.
    +     *
    +     * @return \ApiBundle\Serializer\Metadata\Metadata
    +     */
    +    public function getMetadata()
    +    {
    +        return new Metadata(static::class, [
    +            PropertyMetadata::createString('period', [ 'schedule' ]),
    +            PropertyMetadata::createString('day', [ 'schedule' ]),
    +            PropertyMetadata::createString('hour', [ 'schedule' ]),
    +            PropertyMetadata::createString('minute', [ 'schedule' ]),
    +        ]);
    +    }
    +
    +    /**
    +     * Return default normalization groups.
    +     *
    +     * @return array
    +     */
    +    public function defaultGroups()
    +    {
    +        return [ 'schedule' ];
    +    }
    +
    +    /**
    +     * @param array $data Normalized WeeklyNotificationSchedule.
    +     *
    +     * @return WeeklyNotificationSchedule
    +     */
    +    public static function denormalize(array $data)
    +    {
    +        if (! isset($data['period'], $data['day'], $data['hour'], $data['minute'])) {
    +            throw new \LogicException('Normalized WeeklyNotificationSchedule data must have \'period\', \'day\', \'hour\' and \'minute\' fields.');
    +        }
    +
    +        return WeeklyNotificationSchedule::create()
    +            ->setPeriod($data['period'])
    +            ->setDay($data['day'])
    +            ->setHour($data['hour'])
    +            ->setMinute($data['minute']);
    +    }
    +
    +    /**
    +     * Return key identifier for current schedule.
    +     *
    +     * @return string
    +     */
    +    public function getKey()
    +    {
    +        return sprintf(
    +            'weekly_%s_%s_%s_%s',
    +            $this->period,
    +            $this->day,
    +            $this->hour,
    +            $this->minute
    +        );
    +    }
    +
    +    /**
    +     * Compute all date's for this schedule in specified period.
    +     *
    +     * @param \DateTime $start Start of computing period.
    +     * @param \DateTime $end   End of computing period.
    +     *
    +     * @return \DateTime[]
    +     */
    +    protected function doComputeDates(\DateTime $start, \DateTime $end)
    +    {
    +        $dates = [];
    +        $date = clone $start;
    +
    +        if ($this->period === self::PERIOD_EVERY) {
    +            //
    +            // Process every weekdays days.
    +            //
    +
    +            if ($date->format('N') !== self::$dayMap[$this->day]) {
    +                //
    +                // If start date is not required weekday we should proceed to next
    +                // required weekday.
    +                //
    +                $date->modify('next '. $this->day);
    +            }
    +            while ($date <= $end) {
    +                $tmp = clone $date;
    +                $dates[] = $tmp->setTime($this->hour, $this->minute);
    +                $date->modify('next '. $this->day);
    +            }
    +        } else {
    +            //
    +            // Process specified weekday like second monday, third friday and etc.
    +            //
    +
    +            $date->modify(sprintf(
    +                '%s %s of this month',
    +                $this->period,
    +                $this->day
    +            ));
    +            while ($date <= $end) {
    +                $tmp = clone $date;
    +                $dates[] = $tmp->setTime($this->hour, $this->minute);
    +                $date->modify(sprintf(
    +                    '%s %s of next month',
    +                    $this->period,
    +                    $this->day
    +                ));
    +            }
    +        }
    +
    +        return $dates;
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionColors.php b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionColors.php
    new file mode 100644
    index 0000000..4a5c4f0
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionColors.php
    @@ -0,0 +1,117 @@
    +background = $background;
    +        $this->text = $text;
    +    }
    +
    +    /**
    +     * @return ThemeOptionColorsBackground
    +     */
    +    public function getBackground()
    +    {
    +        return $this->background;
    +    }
    +
    +    /**
    +     * @param ThemeOptionColorsBackground $background A ThemeOptionColorsBackground
    +     *                                                instance.
    +     *
    +     * @return ThemeOptionColors
    +     */
    +    public function setBackground(ThemeOptionColorsBackground $background)
    +    {
    +        $this->background = $background;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionColorsText
    +     */
    +    public function getText()
    +    {
    +        return $this->text;
    +    }
    +
    +    /**
    +     * @param ThemeOptionColorsText $text A ThemeOptionColorsText instance.
    +     *
    +     * @return ThemeOptionColors
    +     */
    +    public function setText(ThemeOptionColorsText $text)
    +    {
    +        $this->text = $text;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * String representation of object.
    +     *
    +     * @return string the string representation of the object or null.
    +     */
    +    public function serialize()
    +    {
    +        return serialize([
    +            $this->background,
    +            $this->text,
    +        ]);
    +    }
    +
    +    /**
    +     * Constructs the object
    +     *
    +     * @param string $serialized The string representation of the object.
    +     *
    +     * @return void
    +     */
    +    public function unserialize($serialized)
    +    {
    +        $data = unserialize($serialized);
    +
    +        $this->background = $data[0];
    +        $this->text = $data[1];
    +    }
    +
    +    /**
    +     * @return array
    +     */
    +    public function toArray()
    +    {
    +        return [
    +            'background' => $this->background->toArray(),
    +            'text' => $this->text->toArray(),
    +        ];
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionColorsBackground.php b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionColorsBackground.php
    new file mode 100644
    index 0000000..a757819
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionColorsBackground.php
    @@ -0,0 +1,167 @@
    +header = trim($header);
    +        $this->emailBody = trim($emailBody);
    +        $this->accent = trim($accent);
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getHeader()
    +    {
    +        return $this->header;
    +    }
    +
    +    /**
    +     * @param string $header Header background color.
    +     *
    +     * @return ThemeOptionColorsBackground
    +     */
    +    public function setHeader($header)
    +    {
    +        $this->header = $header;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getEmailBody()
    +    {
    +        return $this->emailBody;
    +    }
    +
    +    /**
    +     * @param string $emailBody Email body color.
    +     *
    +     * @return ThemeOptionColorsBackground
    +     */
    +    public function setEmailBody($emailBody)
    +    {
    +        $this->emailBody = $emailBody;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getAccent()
    +    {
    +        return $this->accent;
    +    }
    +
    +    /**
    +     * @param string $accent Accent background color.
    +     *
    +     * @return ThemeOptionColorsBackground
    +     */
    +    public function setAccent($accent)
    +    {
    +        $this->accent = $accent;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * String representation of object.
    +     *
    +     * @return string the string representation of the object or null.
    +     */
    +    public function serialize()
    +    {
    +        return serialize([
    +            $this->header,
    +            $this->emailBody,
    +            $this->accent,
    +        ]);
    +    }
    +
    +    /**
    +     * Constructs the object
    +     *
    +     * @param string $serialized The string representation of the object.
    +     *
    +     * @return void
    +     */
    +    public function unserialize($serialized)
    +    {
    +        $data = unserialize($serialized);
    +
    +        $this->header = $data[0];
    +        $this->emailBody = $data[1];
    +        $this->accent = $data[2];
    +    }
    +
    +    /**
    +     * @return array
    +     */
    +    public function toArray()
    +    {
    +        return [
    +            'header' => $this->header,
    +            'emailBody' => $this->emailBody,
    +            'accent' => $this->accent,
    +        ];
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionColorsText.php b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionColorsText.php
    new file mode 100644
    index 0000000..3866b8c
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionColorsText.php
    @@ -0,0 +1,281 @@
    +header = trim($header);
    +        $this->articleHeadline = trim($articleHeadline);
    +        $this->articleContent = trim($articleContent);
    +        $this->author = trim($author);
    +        $this->publishDate = trim($publishDate);
    +        $this->source = trim($source);
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getHeader()
    +    {
    +        return $this->header;
    +    }
    +
    +    /**
    +     * @param string $header Header text color.
    +     *
    +     * @return ThemeOptionColorsText
    +     */
    +    public function setHeader($header)
    +    {
    +        $this->header = $header;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getArticleHeadline()
    +    {
    +        return $this->articleHeadline;
    +    }
    +
    +    /**
    +     * @param string $articleHeadline Article headline text color.
    +     *
    +     * @return ThemeOptionColorsText
    +     */
    +    public function setArticleHeadline($articleHeadline)
    +    {
    +        $this->articleHeadline = $articleHeadline;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getArticleContent()
    +    {
    +        return $this->articleContent;
    +    }
    +
    +    /**
    +     * @param string $articleContent Article content text color.
    +     *
    +     * @return ThemeOptionColorsText
    +     */
    +    public function setArticleContent($articleContent)
    +    {
    +        $this->articleContent = $articleContent;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getAuthor()
    +    {
    +        return $this->author;
    +    }
    +
    +    /**
    +     * @param string $author Author text color.
    +     *
    +     * @return ThemeOptionColorsText
    +     */
    +    public function setAuthor($author)
    +    {
    +        $this->author = $author;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getPublishDate()
    +    {
    +        return $this->publishDate;
    +    }
    +
    +    /**
    +     * @param string $publishDate Publish date text color.
    +     *
    +     * @return ThemeOptionColorsText
    +     */
    +    public function setPublishDate($publishDate)
    +    {
    +        $this->publishDate = $publishDate;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getSource()
    +    {
    +        return $this->source;
    +    }
    +
    +    /**
    +     * @param string $source Source text color.
    +     *
    +     * @return ThemeOptionColorsText
    +     */
    +    public function setSource($source)
    +    {
    +        $this->source = $source;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * String representation of object.
    +     *
    +     * @return string the string representation of the object or null.
    +     */
    +    public function serialize()
    +    {
    +        return serialize([
    +            $this->header,
    +            $this->articleHeadline,
    +            $this->articleContent,
    +            $this->author,
    +            $this->publishDate,
    +            $this->source,
    +        ]);
    +    }
    +
    +    /**
    +     * Constructs the object
    +     *
    +     * @param string $serialized The string representation of the object.
    +     *
    +     * @return void
    +     */
    +    public function unserialize($serialized)
    +    {
    +        $data = unserialize($serialized);
    +
    +        $this->header = $data[0];
    +        $this->articleHeadline = $data[1];
    +        $this->articleContent = $data[2];
    +        $this->author = $data[3];
    +        $this->publishDate = $data[4];
    +        $this->source = $data[5];
    +    }
    +
    +    /**
    +     * @return array
    +     */
    +    public function toArray()
    +    {
    +        return [
    +            'header' => $this->header,
    +            'articleHeadline' => $this->articleHeadline,
    +            'articleContent' => $this->articleContent,
    +            'author' => $this->author,
    +            'publishDate' => $this->publishDate,
    +            'source' => $this->source,
    +        ];
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionContent.php b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionContent.php
    new file mode 100644
    index 0000000..fe56b76
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionContent.php
    @@ -0,0 +1,184 @@
    +highlightKeywords = $highlightKeywords;
    +        $this->showInfo = $showInfo;
    +        $this->language = trim($language);
    +        $this->extract = $extract;
    +    }
    +
    +    /**
    +     * @return ThemeOptionHighlightKeywords
    +     */
    +    public function getHighlightKeywords()
    +    {
    +        return $this->highlightKeywords;
    +    }
    +
    +    /**
    +     * @param ThemeOptionHighlightKeywords $highlightKeywords A ThemeOptionHighlightKeywords
    +     *                                                        instance.
    +     *
    +     * @return ThemeOptionContent
    +     */
    +    public function setHighlightKeywords(ThemeOptionHighlightKeywords $highlightKeywords)
    +    {
    +        $this->highlightKeywords = $highlightKeywords;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionShowInfo
    +     */
    +    public function getShowInfo()
    +    {
    +        return $this->showInfo;
    +    }
    +
    +    /**
    +     * @param ThemeOptionShowInfo $showInfo A ThemeOptionShowInfo instance.
    +     *
    +     * @return ThemeOptionContent
    +     */
    +    public function setShowInfo(ThemeOptionShowInfo $showInfo)
    +    {
    +        $this->showInfo = $showInfo;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getLanguage()
    +    {
    +        return $this->language;
    +    }
    +
    +    /**
    +     * @param string $language Selected theme language.
    +     *
    +     * @return ThemeOptionContent
    +     */
    +    public function setLanguage($language)
    +    {
    +        $this->language = $language;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionExtractEnum
    +     */
    +    public function getExtract()
    +    {
    +        return $this->extract;
    +    }
    +
    +    /**
    +     * @param ThemeOptionExtractEnum $extract A ThemeOptionExtractEnum
    +     *                                        instance.
    +     *
    +     * @return ThemeOptionContent
    +     */
    +    public function setExtract(ThemeOptionExtractEnum $extract)
    +    {
    +        $this->extract = $extract;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * String representation of object.
    +     *
    +     * @return string the string representation of the object or null.
    +     */
    +    public function serialize()
    +    {
    +        return serialize([
    +            $this->highlightKeywords,
    +            $this->showInfo,
    +            $this->language,
    +            $this->extract,
    +        ]);
    +    }
    +
    +    /**
    +     * Constructs the object
    +     *
    +     * @param string $serialized The string representation of the object.
    +     *
    +     * @return void
    +     */
    +    public function unserialize($serialized)
    +    {
    +        $data = unserialize($serialized);
    +
    +        $this->highlightKeywords = $data[0];
    +        $this->showInfo = $data[1];
    +        $this->language = $data[2];
    +        $this->extract = $data[3];
    +    }
    +
    +    /**
    +     * @return array
    +     */
    +    public function toArray()
    +    {
    +        return [
    +            'highlightKeywords' => $this->highlightKeywords->toArray(),
    +            'showInfo' => $this->showInfo->toArray(),
    +            'language' => $this->language,
    +            'extract' => $this->extract->getValue(),
    +        ];
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionFont.php b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionFont.php
    new file mode 100644
    index 0000000..459866e
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionFont.php
    @@ -0,0 +1,144 @@
    +family = $family;
    +        $this->size = (int) trim($size);
    +        $this->style = $style === null ? new ThemeOptionFontStyle() : $style;
    +    }
    +
    +    /**
    +     * @return FontFamilyEnum
    +     */
    +    public function getFamily()
    +    {
    +        return $this->family;
    +    }
    +
    +    /**
    +     * @param FontFamilyEnum $family Font family name.
    +     *
    +     * @return ThemeOptionFont
    +     */
    +    public function setFamily(FontFamilyEnum $family)
    +    {
    +        $this->family = $family;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return integer
    +     */
    +    public function getSize()
    +    {
    +        return $this->size;
    +    }
    +
    +    /**
    +     * @param integer $size Font size.
    +     *
    +     * @return ThemeOptionFont
    +     */
    +    public function setSize($size)
    +    {
    +        $this->size = $size;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionFontStyle
    +     */
    +    public function getStyle()
    +    {
    +        return $this->style;
    +    }
    +
    +    /**
    +     * @param ThemeOptionFontStyle $style A ThemeOptionFontStyle instance.
    +     *
    +     * @return ThemeOptionFont
    +     */
    +    public function setStyle(ThemeOptionFontStyle $style)
    +    {
    +        $this->style = $style;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * String representation of object.
    +     *
    +     * @return string the string representation of the object or null.
    +     */
    +    public function serialize()
    +    {
    +        return serialize([
    +            $this->family,
    +            $this->size,
    +            $this->style,
    +        ]);
    +    }
    +
    +    /**
    +     * Constructs the object
    +     *
    +     * @param string $serialized The string representation of the object.
    +     *
    +     * @return void
    +     */
    +    public function unserialize($serialized)
    +    {
    +        $data = unserialize($serialized);
    +
    +        $this->family = $data[0];
    +        $this->size = $data[1];
    +        $this->style = $data[2];
    +    }
    +
    +    /**
    +     * @return array
    +     */
    +    public function toArray()
    +    {
    +        return [
    +            'family' => $this->family->getCss(),
    +            'size' => $this->size,
    +            'style' => $this->style->toArray(),
    +        ];
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionFontStyle.php b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionFontStyle.php
    new file mode 100644
    index 0000000..0ad6c51
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionFontStyle.php
    @@ -0,0 +1,142 @@
    +bold = (bool) $bold;
    +        $this->italic = (bool) $italic;
    +        $this->underline = (bool) $underline;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isBold()
    +    {
    +        return $this->bold;
    +    }
    +
    +    /**
    +     * @param boolean $bold Should be text bold or not.
    +     *
    +     * @return static
    +     */
    +    public function setBold($bold = true)
    +    {
    +        $this->bold = $bold;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isItalic()
    +    {
    +        return $this->italic;
    +    }
    +
    +    /**
    +     * @param boolean $italic Should be text italic or not.
    +     *
    +     * @return static
    +     */
    +    public function setItalic($italic = true)
    +    {
    +        $this->italic = $italic;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isUnderline()
    +    {
    +        return $this->underline;
    +    }
    +
    +    /**
    +     * @param boolean $underline Should be text underlined or not.
    +     *
    +     * @return static
    +     */
    +    public function setUnderline($underline = true)
    +    {
    +        $this->underline = $underline;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * String representation of object.
    +     *
    +     * @return string the string representation of the object or null.
    +     */
    +    public function serialize()
    +    {
    +        return serialize([
    +            $this->bold,
    +            $this->italic,
    +            $this->underline,
    +        ]);
    +    }
    +
    +    /**
    +     * Constructs the object
    +     *
    +     * @param string $serialized The string representation of the object.
    +     *
    +     * @return void
    +     */
    +    public function unserialize($serialized)
    +    {
    +        $data = unserialize($serialized);
    +
    +        $this->bold = $data[0];
    +        $this->italic = $data[1];
    +        $this->underline = $data[2];
    +    }
    +
    +    /**
    +     * @return array
    +     */
    +    public function toArray()
    +    {
    +        return [
    +            'bold' => $this->bold,
    +            'italic' => $this->italic,
    +            'underline' => $this->underline,
    +        ];
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionFonts.php b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionFonts.php
    new file mode 100644
    index 0000000..55ed208
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionFonts.php
    @@ -0,0 +1,300 @@
    +header = $header;
    +        $this->tableOfContents = $tableOfContents;
    +        $this->feedTitle = $feedTitle;
    +        $this->articleHeadline = $articleHeadline;
    +        $this->source = $source;
    +        $this->author = $author;
    +        $this->date = $date;
    +        $this->articleContent = $articleContent;
    +    }
    +
    +    /**
    +     * @return ThemeOptionFont
    +     */
    +    public function getHeader()
    +    {
    +        return $this->header;
    +    }
    +
    +    /**
    +     * @param ThemeOptionFont $header A ThemeOptionFont instance.
    +     *
    +     * @return ThemeOptionFonts
    +     */
    +    public function setHeader(ThemeOptionFont $header)
    +    {
    +        $this->header = $header;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionFont
    +     */
    +    public function getTableOfContents()
    +    {
    +        return $this->tableOfContents;
    +    }
    +
    +    /**
    +     * @param ThemeOptionFont $tableOfContents A ThemeOptionFont instance.
    +     *
    +     * @return ThemeOptionFonts
    +     */
    +    public function setTableOfContents(ThemeOptionFont $tableOfContents)
    +    {
    +        $this->tableOfContents = $tableOfContents;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionFont
    +     */
    +    public function getFeedTitle()
    +    {
    +        return $this->feedTitle;
    +    }
    +
    +    /**
    +     * @param ThemeOptionFont $feedTitle A ThemeOptionFont instance.
    +     *
    +     * @return ThemeOptionFonts
    +     */
    +    public function setFeedTitle(ThemeOptionFont $feedTitle)
    +    {
    +        $this->feedTitle = $feedTitle;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionFont
    +     */
    +    public function getArticleHeadline()
    +    {
    +        return $this->articleHeadline;
    +    }
    +
    +    /**
    +     * @param ThemeOptionFont $articleHeadline A ThemeOptionFont instance.
    +     *
    +     * @return ThemeOptionFonts
    +     */
    +    public function setArticleHeadline(ThemeOptionFont $articleHeadline)
    +    {
    +        $this->articleHeadline = $articleHeadline;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionFont A ThemeOptionFont instance.
    +     */
    +    public function getSource()
    +    {
    +        return $this->source;
    +    }
    +
    +    /**
    +     * @param ThemeOptionFont $source A ThemeOptionFont instance.
    +     *
    +     * @return ThemeOptionFonts
    +     */
    +    public function setSource(ThemeOptionFont $source)
    +    {
    +        $this->source = $source;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionFont
    +     */
    +    public function getAuthor()
    +    {
    +        return $this->author;
    +    }
    +
    +    /**
    +     * @param ThemeOptionFont $author A ThemeOptionFont instance.
    +     *
    +     * @return ThemeOptionFonts
    +     */
    +    public function setAuthor(ThemeOptionFont $author)
    +    {
    +        $this->author = $author;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionFont
    +     */
    +    public function getDate()
    +    {
    +        return $this->date;
    +    }
    +
    +    /**
    +     * @param ThemeOptionFont $date A ThemeOptionFont instance.
    +     *
    +     * @return ThemeOptionFonts
    +     */
    +    public function setDate(ThemeOptionFont $date)
    +    {
    +        $this->date = $date;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionFont
    +     */
    +    public function getArticleContent()
    +    {
    +        return $this->articleContent;
    +    }
    +
    +    /**
    +     * @param ThemeOptionFont $articleContent A ThemeOptionFont instance.
    +     *
    +     * @return ThemeOptionFonts
    +     */
    +    public function setArticleContent(ThemeOptionFont $articleContent)
    +    {
    +        $this->articleContent = $articleContent;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * String representation of object.
    +     *
    +     * @return string the string representation of the object or null.
    +     */
    +    public function serialize()
    +    {
    +        return serialize([
    +            $this->header,
    +            $this->tableOfContents,
    +            $this->feedTitle,
    +            $this->articleHeadline,
    +            $this->source,
    +            $this->author,
    +            $this->date,
    +            $this->articleContent,
    +        ]);
    +    }
    +
    +    /**
    +     * Constructs the object
    +     *
    +     * @param string $serialized The string representation of the object.
    +     *
    +     * @return void
    +     */
    +    public function unserialize($serialized)
    +    {
    +        $data = unserialize($serialized);
    +
    +        $this->header = $data[0];
    +        $this->tableOfContents = $data[1];
    +        $this->feedTitle = $data[2];
    +        $this->articleHeadline = $data[3];
    +        $this->source = $data[4];
    +        $this->author = $data[5];
    +        $this->date = $data[6];
    +        $this->articleContent = $data[7];
    +    }
    +
    +    /**
    +     * @return array
    +     */
    +    public function toArray()
    +    {
    +        return [
    +            'header' => $this->header->toArray(),
    +            'tableOfContents' => $this->tableOfContents->toArray(),
    +            'feedTitle' => $this->feedTitle->toArray(),
    +            'articleHeadline' => $this->articleHeadline->toArray(),
    +            'source' => $this->source->toArray(),
    +            'author' => $this->author->toArray(),
    +            'date' => $this->date->toArray(),
    +            'articleContent' => $this->articleContent->toArray(),
    +        ];
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionHeader.php b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionHeader.php
    new file mode 100644
    index 0000000..78e32e3
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionHeader.php
    @@ -0,0 +1,152 @@
    +imageUrl = trim($imageUrl);
    +        $this->logoLink = trim($logoLink);
    +        $this->title = trim($title);
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getImageUrl()
    +    {
    +        return $this->imageUrl;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function hasImageUrl()
    +    {
    +        return $this->imageUrl !== '';
    +    }
    +
    +    /**
    +     * @param string $imageUrl Url to header image.
    +     *
    +     * @return $this
    +     */
    +    public function setImageUrl($imageUrl)
    +    {
    +        $this->imageUrl = $imageUrl;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getLogoLink()
    +    {
    +        return $this->logoLink;
    +    }
    +
    +    /**
    +     * @param string $logoLink Header image link address.
    +     *
    +     * @return $this
    +     */
    +    public function setLogoLink($logoLink)
    +    {
    +        $this->logoLink = $logoLink;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getTitle()
    +    {
    +        return $this->title;
    +    }
    +
    +    /**
    +     * @param string $title Custom title in header.
    +     *
    +     * @return $this
    +     */
    +    public function setTitle($title)
    +    {
    +        $this->title = $title;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * String representation of object.
    +     *
    +     * @return string the string representation of the object or null.
    +     */
    +    public function serialize()
    +    {
    +        return serialize([
    +            $this->imageUrl,
    +            $this->logoLink,
    +            $this->title,
    +        ]);
    +    }
    +
    +    /**
    +     * Constructs the object
    +     *
    +     * @param string $serialized The string representation of the object.
    +     *
    +     * @return void
    +     */
    +    public function unserialize($serialized)
    +    {
    +        $data = unserialize($serialized);
    +
    +        $this->imageUrl = $data[0];
    +        $this->logoLink = $data[1];
    +        $this->title = $data[2];
    +    }
    +
    +    /**
    +     * @return array
    +     */
    +    public function toArray()
    +    {
    +        return [
    +            'imageUrl' => $this->imageUrl,
    +            'logoLink' => $this->logoLink,
    +            'title' => $this->title,
    +        ];
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionHighlightKeywords.php b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionHighlightKeywords.php
    new file mode 100644
    index 0000000..5aa3563
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionHighlightKeywords.php
    @@ -0,0 +1,148 @@
    +highlight = (bool) $highlight;
    +        $this->bold = (bool) $bold;
    +        $this->color = trim($color);
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isHighlight()
    +    {
    +        return $this->highlight;
    +    }
    +
    +    /**
    +     * @param boolean $highlight Should system highlight keywords or not.
    +     *
    +     * @return ThemeOptionHighlightKeywords
    +     */
    +    public function setHighlight($highlight = true)
    +    {
    +        $this->highlight = $highlight;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isBold()
    +    {
    +        return $this->bold;
    +    }
    +
    +    /**
    +     * @param boolean $bold Should highlight keyword be bold or not.
    +     *
    +     * @return ThemeOptionHighlightKeywords
    +     */
    +    public function setBold($bold = true)
    +    {
    +        $this->bold = $bold;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getColor()
    +    {
    +        return $this->color;
    +    }
    +
    +    /**
    +     * @param string $color Highlight color.
    +     *
    +     * @return ThemeOptionHighlightKeywords
    +     */
    +    public function setColor($color)
    +    {
    +        $this->color = $color;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * String representation of object.
    +     *
    +     * @return string the string representation of the object or null.
    +     */
    +    public function serialize()
    +    {
    +        return serialize([
    +            $this->highlight,
    +            $this->bold,
    +            $this->color,
    +        ]);
    +    }
    +
    +    /**
    +     * Constructs the object
    +     *
    +     * @param string $serialized The string representation of the object.
    +     *
    +     * @return void
    +     */
    +    public function unserialize($serialized)
    +    {
    +        $data = unserialize($serialized);
    +
    +        $this->highlight = $data[0];
    +        $this->bold = $data[1];
    +        $this->color = $data[2];
    +    }
    +
    +    /**
    +     * @return array
    +     */
    +    public function toArray()
    +    {
    +        return [
    +            'highlight' => $this->highlight,
    +            'bold' => $this->bold,
    +            'color' => $this->color,
    +        ];
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionShowInfo.php b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionShowInfo.php
    new file mode 100644
    index 0000000..9693f91
    --- /dev/null
    +++ b/src/UserBundle/Entity/Notification/ThemeOption/ThemeOptionShowInfo.php
    @@ -0,0 +1,313 @@
    +sourceCountry = (bool) $sourceCountry;
    +        $this->articleSentiment = (bool) $articleSentiment;
    +        $this->articleCount = (bool) $articleCount;
    +        $this->images = (bool) $images;
    +        $this->sharingOptions = (bool) $sharingOptions;
    +        $this->sectionDivider = (bool) $sectionDivider;
    +        $this->setUserComments($userComments);
    +        $this->setTableOfContents($tableOfContents);
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isSourceCountry()
    +    {
    +        return $this->sourceCountry;
    +    }
    +
    +    /**
    +     * @param boolean $sourceCountry Show source country.
    +     *
    +     * @return ThemeOptionShowInfo
    +     */
    +    public function setSourceCountry($sourceCountry = true)
    +    {
    +        $this->sourceCountry = $sourceCountry;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isArticleSentiment()
    +    {
    +        return $this->articleSentiment;
    +    }
    +
    +    /**
    +     * @param boolean $articleSentiment Show article sentiment.
    +     *
    +     * @return ThemeOptionShowInfo
    +     */
    +    public function setArticleSentiment($articleSentiment = true)
    +    {
    +        $this->articleSentiment = $articleSentiment;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isArticleCount()
    +    {
    +        return $this->articleCount;
    +    }
    +
    +    /**
    +     * @param boolean $articleCount Show article count.
    +     *
    +     * @return ThemeOptionShowInfo
    +     */
    +    public function setArticleCount($articleCount = true)
    +    {
    +        $this->articleCount = $articleCount;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isImages()
    +    {
    +        return $this->images;
    +    }
    +
    +    /**
    +     * @param boolean $images Show or not articles images.
    +     *
    +     * @return ThemeOptionShowInfo
    +     */
    +    public function setImages($images = true)
    +    {
    +        $this->images = $images;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isSharingOptions()
    +    {
    +        return $this->sharingOptions;
    +    }
    +
    +    /**
    +     * @param boolean $sharingOptions Show or not sharing options.
    +     *
    +     * @return ThemeOptionShowInfo
    +     */
    +    public function setSharingOptions($sharingOptions = true)
    +    {
    +        $this->sharingOptions = $sharingOptions;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isSectionDivider()
    +    {
    +        return $this->sectionDivider;
    +    }
    +
    +    /**
    +     * @param boolean $sectionDivider Show section divider or not.
    +     *
    +     * @return ThemeOptionShowInfo
    +     */
    +    public function setSectionDivider($sectionDivider = true)
    +    {
    +        $this->sectionDivider = $sectionDivider;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionsUserCommentsEnum
    +     */
    +    public function getUserComments()
    +    {
    +        return $this->userComments;
    +    }
    +
    +    /**
    +     * @param ThemeOptionsUserCommentsEnum|string $userComments A ThemeOptionsUserCommentsEnum
    +     *                                                          instance.
    +     *
    +     * @return ThemeOptionShowInfo
    +     */
    +    public function setUserComments($userComments)
    +    {
    +        if (is_string($userComments)) {
    +            $userComments = new ThemeOptionsUserCommentsEnum($userComments);
    +        }
    +        $this->userComments = $userComments;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return ThemeOptionsTableOfContentsEnum
    +     */
    +    public function getTableOfContents()
    +    {
    +        return $this->tableOfContents;
    +    }
    +
    +    /**
    +     * @param ThemeOptionsTableOfContentsEnum|string $tableOfContents A ThemeOptionsTableOfContentsEnum
    +     *                                                                instance.
    +     *
    +     * @return ThemeOptionShowInfo
    +     */
    +    public function setTableOfContents($tableOfContents)
    +    {
    +        if (is_string($tableOfContents)) {
    +            $tableOfContents = new ThemeOptionsTableOfContentsEnum($tableOfContents);
    +        }
    +        $this->tableOfContents = $tableOfContents;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * String representation of object.
    +     *
    +     * @return string the string representation of the object or null.
    +     */
    +    public function serialize()
    +    {
    +        return serialize([
    +            $this->sourceCountry,
    +            $this->articleSentiment,
    +            $this->articleCount,
    +            $this->images,
    +            $this->sharingOptions,
    +            $this->sectionDivider,
    +            $this->userComments,
    +            $this->tableOfContents,
    +        ]);
    +    }
    +
    +    /**
    +     * Constructs the object
    +     *
    +     * @param string $serialized The string representation of the object.
    +     *
    +     * @return void
    +     */
    +    public function unserialize($serialized)
    +    {
    +        $data = unserialize($serialized);
    +
    +        $this->sourceCountry = $data[0];
    +        $this->articleSentiment = $data[1];
    +        $this->articleCount = $data[2];
    +        $this->images = $data[3];
    +        $this->sharingOptions = $data[4];
    +        $this->sectionDivider = $data[5];
    +        $this->userComments = $data[6];
    +        $this->tableOfContents = $data[7];
    +    }
    +
    +    /**
    +     * @return array
    +     */
    +    public function toArray()
    +    {
    +        return [
    +            'sourceCountry' => $this->sourceCountry,
    +            'articleSentiment' => $this->articleSentiment,
    +            'articleCount' => $this->articleCount,
    +            'images' => $this->images,
    +            'sharingOptions' => $this->sharingOptions,
    +            'sectionDivider' => $this->sectionDivider,
    +            'userComments' => $this->userComments->getValue(),
    +            'tableOfContents' => $this->tableOfContents->getValue(),
    +        ];
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Organization.php b/src/UserBundle/Entity/Organization.php
    new file mode 100644
    index 0000000..e30068e
    --- /dev/null
    +++ b/src/UserBundle/Entity/Organization.php
    @@ -0,0 +1,134 @@
    +id;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getName()
    +    {
    +        return $this->name;
    +    }
    +
    +    /**
    +     * @param string $name A Organization name.
    +     *
    +     * @return Organization
    +     */
    +    public function setName($name)
    +    {
    +        $this->name = $name;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function __toString()
    +    {
    +        return (string) $this->name;
    +    }
    +
    +    /**
    +     * Constructor
    +     */
    +    public function __construct()
    +    {
    +        $this->subscriptions = new ArrayCollection();
    +    }
    +
    +    /**
    +     * Add subscription
    +     *
    +     * @param OrganizationSubscription $subscription A new OrganizationSubscription
    +     *                                               entity instance.
    +     *
    +     * @return Organization
    +     */
    +    public function addSubscription(OrganizationSubscription $subscription)
    +    {
    +        $this->subscriptions[] = $subscription;
    +        $subscription->setOrganization($this);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove subscription
    +     *
    +     * @param OrganizationSubscription $subscription A removed OrganizationSubscription
    +     *                                               entity instance.
    +     *
    +     * @return Organization
    +     */
    +    public function removeSubscription(OrganizationSubscription $subscription)
    +    {
    +        $this->subscriptions->removeElement($subscription);
    +        $subscription->setOrganization(null);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get subscriptions
    +     *
    +     * @return Collection
    +     */
    +    public function getSubscriptions()
    +    {
    +        return $this->subscriptions;
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Plan.php b/src/UserBundle/Entity/Plan.php
    new file mode 100644
    index 0000000..56fab4d
    --- /dev/null
    +++ b/src/UserBundle/Entity/Plan.php
    @@ -0,0 +1,532 @@
    +subscriptions = new ArrayCollection();
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function __toString()
    +    {
    +        return $this->title;
    +    }
    +
    +    /**
    +     * Set name
    +     *
    +     * @param string $title A Human readable plan name.
    +     *
    +     * @return Plan
    +     */
    +    public function setTitle($title)
    +    {
    +        $this->title = $title;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get name
    +     *
    +     * @return string
    +     */
    +    public function getTitle()
    +    {
    +        return $this->title;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getInnerName()
    +    {
    +        return $this->innerName;
    +    }
    +
    +    /**
    +     * @param string $innerName A plan inner name used for binding with plans on
    +     *                          payment gateways.
    +     *
    +     * @return Plan
    +     */
    +    public function setInnerName($innerName)
    +    {
    +        $this->innerName = $innerName;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Set price
    +     *
    +     * @param float $price Monthly plan price.
    +     *
    +     * @return Plan
    +     */
    +    public function setPrice($price)
    +    {
    +        $this->price = $price;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get price
    +     *
    +     * @return float
    +     */
    +    public function getPrice()
    +    {
    +        return $this->price;
    +    }
    +
    +    /**
    +     * Checks that current plan is free.
    +     *
    +     * @return boolean
    +     */
    +    public function isFree()
    +    {
    +        return $this->price <= 0.000001;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isAnalytics()
    +    {
    +        return $this->analytics;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isDefault()
    +    {
    +        return $this->is_default;
    +    }
    +
    +    /**
    +     * @param boolean $analytics Allow to use analytics or not.
    +     *
    +     * @return static
    +     */
    +    public function setAnalytics($analytics)
    +    {
    +        $this->analytics = $analytics;
    +
    +        return $this;
    +    }
    +
    +
    +
    +    /**
    +     * @param boolean $is_default Allow to use default or not.
    +     *
    +     * @return static
    +     */
    +    public function setIsDefault(bool $is_default)
    +    {
    +        $this->is_default = $is_default;
    +        return $this;
    +    }
    +
    +    /**
    +     * Get specified permission.
    +     *
    +     * @param AppPermissionEnum $appPermission A requested permission name.
    +     *
    +     * @return boolean
    +     */
    +    public function getPermission(AppPermissionEnum $appPermission)
    +    {
    +        return $this->{$appPermission->getValue()};
    +    }
    +
    +    /**
    +     * Change specified permission.
    +     *
    +     * @param AppPermissionEnum $appPermission A changed permission name.
    +     * @param boolean           $permission    New permission value.
    +     *
    +     * @return $this
    +     */
    +    public function setPermission(AppPermissionEnum $appPermission, $permission)
    +    {
    +        $this->{$appPermission->getValue()} = $permission;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Add subscription
    +     *
    +     * @param AbstractSubscription $subscription A new subscription entity instance.
    +     *
    +     * @return Plan
    +     */
    +    public function addSubscription(AbstractSubscription $subscription)
    +    {
    +        $this->subscriptions[] = $subscription;
    +        $subscription->setPlan($this);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove subscription
    +     *
    +     * @param AbstractSubscription $subscription A removed subscription entity
    +     *                                           instance.
    +     *
    +     * @return Plan
    +     */
    +    public function removeSubscription(AbstractSubscription $subscription)
    +    {
    +        $this->subscriptions->removeElement($subscription);
    +        $subscription->setPlan(null);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get subscriptions
    +     *
    +     * @return Collection
    +     */
    +    public function getSubscriptions()
    +    {
    +        return $this->subscriptions;
    +    }
    +
    +    /**
    +     * Return default normalization groups.
    +     *
    +     * @return array
    +     */
    +    public function defaultGroups()
    +    {
    +        return [ 'plan', 'id', 'name' ];
    +    }
    +
    +    /**
    +     * Get entity type
    +     *
    +     * @return string
    +     */
    +    public function getEntityType()
    +    {
    +        return 'plan';
    +    }
    +
    +    /**
    +     * Return metadata for current entity.
    +     *
    +     * @return Metadata
    +     */
    +    public function getMetadata()
    +    {
    +        return new Metadata(static::class, [
    +            PropertyMetadata::createInteger('id', [ 'id' ]),
    +            PropertyMetadata::createString('name', [ 'plan' ])
    +                ->setField('title'),
    +            PropertyMetadata::createInteger('searchesPerDay', [ 'plan' ]),
    +            PropertyMetadata::createInteger('savedFeeds', [ 'plan' ]),
    +            PropertyMetadata::createInteger('masterAccounts', [ 'plan' ]),
    +            PropertyMetadata::createInteger('subscriberAccounts', [ 'plan' ]),
    +            PropertyMetadata::createInteger('alerts', [ 'plan' ]),
    +            PropertyMetadata::createInteger('newsletters', [ 'plan' ]),
    +            PropertyMetadata::createBoolean('analytics', [ 'plan' ]),
    +            PropertyMetadata::createDouble('price', [ 'plan' ]),
    +            PropertyMetadata::createBoolean('free', [ 'plan' ])
    +                ->setField(function () {
    +                    return $this->isFree();
    +                }),
    +            PropertyMetadata::createBoolean('is_default', [ 'plan' ]),
    +            PropertyMetadata::createBoolean('news', [ 'plan' ]),
    +            PropertyMetadata::createBoolean('blog', [ 'plan' ]),
    +            PropertyMetadata::createBoolean('reddit', [ 'plan' ]),
    +            PropertyMetadata::createBoolean('instagram', [ 'plan' ]),
    +            PropertyMetadata::createBoolean('twitter', [ 'plan' ]),
    +        ]);
    +    }
    +
    +        /**
    +     * Set news
    +     *
    +     * @return $this
    +     */
    +    public function setNews($news)
    +    {
    +        $this->news = $news;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isNews()
    +    {
    +        return $this->news;
    +    }
    +
    +    /**
    +     * Set blog
    +     *
    +     * @return $this
    +     */
    +    public function setBlog($blog)
    +    {
    +        $this->blog = $blog;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isBlog()
    +    {
    +        return $this->blog;
    +    }
    +
    +     /**
    +     * Set reddit
    +     *
    +     * @return $this
    +     */
    +    public function setReddit($reddit)
    +    {
    +        $this->reddit = $reddit;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isReddit()
    +    {
    +        return $this->reddit;
    +    }
    +
    +     /**
    +     * Set instagram
    +     *
    +     * @return $this
    +     */
    +    public function setInstagram($instagram)
    +    {
    +        $this->instagram = $instagram;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isInstagram()
    +    {
    +        return $this->instagram;
    +    }
    +
    +    /**
    +     * Set twitter
    +     *
    +     * @return $this
    +     */
    +    public function setTwitter($twitter)
    +    {
    +        $this->twitter = $twitter;
    +
    +        return $this;
    +    }
    +
    +   /**
    +     * @return boolean
    +     */
    +    public function isTwitter()
    +    {
    +        return $this->twitter;
    +    }
    +
    +    /**
    +     * @return bool
    +     */
    +    public function isPlanDowngrade(): bool
    +    {
    +        return $this->isPlanDowngrade;
    +    }
    +
    +    /**
    +     * @param bool $isPlanDowngrade
    +     */
    +    public function setIsPlanDowngrade(bool $isPlanDowngrade): void
    +    {
    +        $this->isPlanDowngrade = $isPlanDowngrade;
    +    }
    +
    +    /**
    +     * Set user
    +     *
    +     * @param User $user A User entity instance.
    +     *
    +     * @return User
    +     */
    +    public function setUser(User $user = null)
    +    {
    +        $this->user = $user;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get user
    +     *
    +     * @return User
    +     */
    +    public function getUser()
    +    {
    +        return $this->user;
    +    }
    +
    +}
    diff --git a/src/UserBundle/Entity/RecentlyUsedFeed.php b/src/UserBundle/Entity/RecentlyUsedFeed.php
    new file mode 100644
    index 0000000..d3df20d
    --- /dev/null
    +++ b/src/UserBundle/Entity/RecentlyUsedFeed.php
    @@ -0,0 +1,124 @@
    +usedAt = new \DateTime();
    +    }
    +
    +    /**
    +     * Set feed
    +     *
    +     * @param AbstractFeed $feed A AbstractFeed entity instance.
    +     *
    +     * @return RecentlyUsedFeed
    +     */
    +    public function setFeed(AbstractFeed $feed = null)
    +    {
    +        $this->feed = $feed;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get feed
    +     *
    +     * @return AbstractFeed
    +     */
    +    public function getFeed()
    +    {
    +        return $this->feed;
    +    }
    +
    +    /**
    +     * Set user
    +     *
    +     * @param User $user A User entity instance.
    +     *
    +     * @return RecentlyUsedFeed
    +     */
    +    public function setUser(User $user = null)
    +    {
    +        $this->user = $user;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get user
    +     *
    +     * @return User
    +     */
    +    public function getUser()
    +    {
    +        return $this->user;
    +    }
    +
    +    /**
    +     * Set usedAt
    +     *
    +     * @param \DateTime $usedAt A DateTime instance.
    +     *
    +     * @return RecentlyUsedFeed
    +     */
    +    public function setUsedAt(\DateTime $usedAt = null)
    +    {
    +        $this->usedAt = $usedAt;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get usedAt
    +     *
    +     * @return \DateTime
    +     */
    +    public function getUsedAt()
    +    {
    +        return $this->usedAt;
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Recipient/AbstractRecipient.php b/src/UserBundle/Entity/Recipient/AbstractRecipient.php
    new file mode 100644
    index 0000000..4fb3853
    --- /dev/null
    +++ b/src/UserBundle/Entity/Recipient/AbstractRecipient.php
    @@ -0,0 +1,310 @@
    +createdAt = new \DateTime();
    +        $this->notifications = new ArrayCollection();
    +
    +        foreach (NotificationTypeEnum::getAvailables() as $available) {
    +            $this->subscribedCount[$available] = 0;
    +        }
    +    }
    +
    +    /**
    +     * Set owner
    +     *
    +     * @param User $owner The owner of this notification.
    +     *
    +     * @return static
    +     */
    +    public function setOwner(User $owner = null)
    +    {
    +        $this->owner = $owner;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get owner
    +     *
    +     * @return User
    +     */
    +    public function getOwner()
    +    {
    +        return $this->owner;
    +    }
    +
    +    /**
    +     * Checks that this entity is owned by specified user.
    +     *
    +     * @param User $user A User entity instance.
    +     *
    +     * @return boolean
    +     */
    +    public function isOwnedBy(User $user)
    +    {
    +        return $this->owner->getId() === $user->getId();
    +    }
    +
    +    /**
    +     * Set name
    +     *
    +     * @param string $name Group name.
    +     *
    +     * @return static
    +     */
    +    public function setName($name)
    +    {
    +        $this->name = $name;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get name
    +     *
    +     * @return string
    +     */
    +    public function getName()
    +    {
    +        return $this->name;
    +    }
    +
    +    /**
    +     * Set createdAt
    +     *
    +     * @param \DateTime $createdAt When this recipient is created.
    +     *
    +     * @return static
    +     */
    +    public function setCreatedAt(\DateTime $createdAt = null)
    +    {
    +        $this->createdAt = $createdAt;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get createdAt
    +     *
    +     * @return \DateTime
    +     */
    +    public function getCreatedAt()
    +    {
    +        return $this->createdAt;
    +    }
    +
    +    /**
    +     * Set subscribedCount
    +     *
    +     * @param array $subscribedCount Array of subscribed notification counts by
    +     *                               type.
    +     *
    +     * @return static
    +     */
    +    public function setSubscribedCount(array $subscribedCount)
    +    {
    +        $this->subscribedCount = $subscribedCount;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get subscribedCount
    +     *
    +     * @return integer[]
    +     */
    +    public function getSubscribedCount()
    +    {
    +        return $this->subscribedCount;
    +    }
    +
    +    /**
    +     * @param NotificationTypeEnum $type  A NotificationTypeEnum instance.
    +     * @param integer              $count New subscription count.
    +     *
    +     * @return static
    +     */
    +    public function setSubscribedCountByType(NotificationTypeEnum $type, $count)
    +    {
    +        $this->subscribedCount[(string) $type] = $count;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @param NotificationTypeEnum $type A NotificationTypeEnum instance.
    +     *
    +     * @return integer
    +     */
    +    public function getSubscribedCountByType(NotificationTypeEnum $type)
    +    {
    +        return $this->subscribedCount[(string) $type];
    +    }
    +
    +    /**
    +     * @param NotificationTypeEnum $type A NotificationTypeEnum instance.
    +     *
    +     * @return static
    +     */
    +    public function incSubscribedCountByType(NotificationTypeEnum $type)
    +    {
    +        $this->subscribedCount[(string) $type]++;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @param NotificationTypeEnum $type A NotificationTypeEnum instance.
    +     *
    +     * @return static
    +     */
    +    public function decSubscribedCountByType(NotificationTypeEnum $type)
    +    {
    +        $this->subscribedCount[(string) $type]--;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Add notification
    +     *
    +     * @param Notification $notification A Notification entity instance.
    +     *
    +     * @return static
    +     */
    +    public function addNotification(Notification $notification)
    +    {
    +        $this->notifications[] = $notification;
    +        $this->incSubscribedCountByType($notification->getNotificationType());
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove notification
    +     *
    +     * @param Notification $notification A Notification entity instance.
    +     *
    +     * @return static
    +     */
    +    public function removeNotification(Notification $notification)
    +    {
    +        $this->notifications->removeElement($notification);
    +        $this->decSubscribedCountByType($notification->getNotificationType());
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get notifications
    +     *
    +     * @return Collection
    +     */
    +    public function getNotifications()
    +    {
    +        return $this->notifications;
    +    }
    +
    +    /**
    +     * Return default normalization groups.
    +     *
    +     * @return array
    +     */
    +    public function defaultGroups()
    +    {
    +        return [ 'id', 'recipient' ];
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Recipient/GroupRecipient.php b/src/UserBundle/Entity/Recipient/GroupRecipient.php
    new file mode 100644
    index 0000000..b7c34ab
    --- /dev/null
    +++ b/src/UserBundle/Entity/Recipient/GroupRecipient.php
    @@ -0,0 +1,230 @@
    +recipients = new ArrayCollection();
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function __toString()
    +    {
    +        return $this->name;
    +    }
    +
    +    /**
    +     * Set description
    +     *
    +     * @param string $description Group description.
    +     *
    +     * @return GroupRecipient
    +     */
    +    public function setDescription($description)
    +    {
    +        $this->description = $description;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get description
    +     *
    +     * @return string
    +     */
    +    public function getDescription()
    +    {
    +        return $this->description;
    +    }
    +
    +    /**
    +     * Set personsCount
    +     *
    +     * @param integer $recipientsNumber Count of person in group.
    +     *
    +     * @return GroupRecipient
    +     */
    +    public function setRecipientsNumber($recipientsNumber)
    +    {
    +        $this->recipientsNumber = $recipientsNumber;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get personsCount
    +     *
    +     * @return integer
    +     */
    +    public function getRecipientsNumber()
    +    {
    +        return $this->recipientsNumber;
    +    }
    +
    +    /**
    +     * Add person
    +     *
    +     * @param PersonRecipient $person A PersonRecipient entity instance.
    +     *
    +     * @return GroupRecipient
    +     */
    +    public function addRecipient(PersonRecipient $person)
    +    {
    +        $this->recipients[] = $person;
    +        $this->recipientsNumber++;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove person
    +     *
    +     * @param PersonRecipient $person A PersonRecipient entity instance.
    +     *
    +     * @return GroupRecipient
    +     */
    +    public function removeRecipient(PersonRecipient $person)
    +    {
    +        $this->recipients->removeElement($person);
    +        $this->recipientsNumber--;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get persons
    +     *
    +     * @return Collection
    +     */
    +    public function getRecipients()
    +    {
    +        return $this->recipients;
    +    }
    +
    +    /**
    +     * Return metadata for current entity.
    +     *
    +     * @return \ApiBundle\Serializer\Metadata\Metadata
    +     */
    +    public function getMetadata()
    +    {
    +        $subscriptions = array_map(function ($type) {
    +            return PropertyMetadata::createInteger($type, [ 'recipient' ])
    +                ->setField(function () use ($type) {
    +                    return $this->subscribedCount[$type];
    +                });
    +        }, NotificationTypeEnum::getAvailables());
    +        $subscriptions[] = PropertyMetadata::createArray('ids', [ 'recipient' ])
    +            ->setField(function () {
    +                return $this->getNotifications()->map(function (Notification $notification) {
    +                    return $notification->getId();
    +                })->toArray();
    +            });
    +
    +        return new Metadata(static::class, [
    +            PropertyMetadata::createInteger('id', [ 'id' ]),
    +            PropertyMetadata::createString('name', [ 'recipient', 'notification', 'notification_list', 'recipient_autocompletion' ]),
    +            PropertyMetadata::createString('email', [ 'notification', 'notification_list', 'recipient_autocompletion' ])
    +                ->setField(function () {
    +                    return '';
    +                }),
    +            PropertyMetadata::createString('description', [ 'recipient' ]),
    +            PropertyMetadata::groupProperties('subscriptions', $subscriptions, [ 'recipient' ]),
    +            PropertyMetadata::createArray('recipients', [ 'recipient' ])
    +                ->setField(function () {
    +                    return $this->getRecipients()->map(function (PersonRecipient $recipient) {
    +                        return $recipient->getId();
    +                    })->toArray();
    +                }),
    +            PropertyMetadata::createBoolean('active', [ 'recipient' ]),
    +            PropertyMetadata::createBoolean('enrolled', [ 'sublist' ]),
    +        ]);
    +    }
    +
    +    /**
    +     * Get entity type
    +     *
    +     * @return string
    +     */
    +    public function getEntityType()
    +    {
    +        return RecipientTypeEnum::GROUP;
    +    }
    +
    +    /**
    +     * Return fqcn of form used for creating this entity.
    +     *
    +     * @return string
    +     */
    +    public function getCreateFormClass()
    +    {
    +        return GroupRecipientType::class;
    +    }
    +
    +    /**
    +     * Return fqcn of form used for updating this entity.
    +     *
    +     * @return string
    +     */
    +    public function getUpdateFormClass()
    +    {
    +        return GroupRecipientType::class;
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Recipient/PersonRecipient.php b/src/UserBundle/Entity/Recipient/PersonRecipient.php
    new file mode 100644
    index 0000000..6bc28b9
    --- /dev/null
    +++ b/src/UserBundle/Entity/Recipient/PersonRecipient.php
    @@ -0,0 +1,370 @@
    +groups = new ArrayCollection();
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function __toString()
    +    {
    +        return $this->firstName .' '. $this->lastName;
    +    }
    +
    +    /**
    +     * Create recipient from user.
    +     *
    +     * @param User $user A User entity instance.
    +     *
    +     * @return PersonRecipient
    +     */
    +    public static function createFromUser(User $user)
    +    {
    +        return static::create()
    +            ->setFirstName($user->getFirstName())
    +            ->setLastName($user->getLastName())
    +            ->setEmail($user->getEmail());
    +    }
    +
    +    /**
    +     * Set firstName
    +     *
    +     * @param string $firstName Person first name.
    +     *
    +     * @return PersonRecipient
    +     */
    +    public function setFirstName($firstName)
    +    {
    +        $this->firstName = trim($firstName);
    +        $this->name = $this->firstName .' '. $this->lastName;
    +
    +        if (($this->associatedUser !== null) && ($this->associatedUser->getFirstName() !== $firstName)) {
    +            $this->associatedUser->setFirstName($firstName);
    +        }
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get firstName
    +     *
    +     * @return string
    +     */
    +    public function getFirstName()
    +    {
    +        return $this->firstName;
    +    }
    +
    +    /**
    +     * Set lastName
    +     *
    +     * @param string $lastName Person last name.
    +     *
    +     * @return PersonRecipient
    +     */
    +    public function setLastName($lastName)
    +    {
    +        $this->lastName = trim($lastName);
    +        $this->name = $this->firstName .' '. $this->lastName;
    +
    +        if (($this->associatedUser !== null) && ($this->associatedUser->getLastName() !== $lastName)) {
    +            $this->associatedUser->setLastName($lastName);
    +        }
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Set name
    +     *
    +     * @param string $name Group name.
    +     *
    +     * @return AbstractRecipient
    +     */
    +    public function setName($name)
    +    {
    +        list($firstName, $lastName) = explode(' ', $name, 2);
    +
    +        $this->firstName = trim($firstName);
    +        $this->lastName = trim($lastName);
    +
    +        $this->name = $this->firstName .' '. $this->lastName;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get lastName
    +     *
    +     * @return string
    +     */
    +    public function getLastName()
    +    {
    +        return $this->lastName;
    +    }
    +
    +    /**
    +     * Set email
    +     *
    +     * @param string $email Person email.
    +     *
    +     * @return PersonRecipient
    +     */
    +    public function setEmail($email)
    +    {
    +        $this->email = $email;
    +
    +        if (($this->associatedUser !== null) && ($this->associatedUser->getEmail() !== $email)) {
    +            $this->associatedUser->setEmail($email);
    +        }
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get email
    +     *
    +     * @return string
    +     */
    +    public function getEmail()
    +    {
    +        return $this->email;
    +    }
    +
    +    /**
    +     * Get active
    +     *
    +     * @return boolean
    +     */
    +    public function getActive()
    +    {
    +        return $this->active;
    +    }
    +
    +    /**
    +     * Add group
    +     *
    +     * @param GroupRecipient $group A GroupRecipient entity instance.
    +     *
    +     * @return PersonRecipient
    +     */
    +    public function addGroup(GroupRecipient $group)
    +    {
    +        $this->groups[] = $group;
    +        $group->addRecipient($this);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove group
    +     *
    +     * @param GroupRecipient $group A GroupRecipient entity instance.
    +     *
    +     * @return PersonRecipient
    +     */
    +    public function removeGroup(GroupRecipient $group)
    +    {
    +        $this->groups->removeElement($group);
    +        $group->removeRecipient($this);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get groups
    +     *
    +     * @return Collection
    +     */
    +    public function getGroups()
    +    {
    +        return $this->groups;
    +    }
    +
    +    /**
    +     * @return User $associatedUser
    +     */
    +    public function getAssociatedUser()
    +    {
    +        return $this->associatedUser;
    +    }
    +
    +    /**
    +     * @param User $associatedUser Associated user.
    +     *
    +     * @return PersonRecipient
    +     */
    +    public function setAssociatedUser(User $associatedUser = null)
    +    {
    +        if ($associatedUser === null) {
    +            $this->associatedUser->setRecipient(null);
    +        } else {
    +            $associatedUser->setRecipient($this);
    +        }
    +
    +        $this->associatedUser = $associatedUser;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Return metadata for current entity.
    +     *
    +     * @return \ApiBundle\Serializer\Metadata\Metadata
    +     */
    +    public function getMetadata()
    +    {
    +        $subscriptions = array_map(function ($type) {
    +            return PropertyMetadata::createInteger($type, [ 'recipient' ])
    +                ->setField(function () use ($type) {
    +                    return $this->subscribedCount[$type];
    +                });
    +        }, NotificationTypeEnum::getAvailables());
    +        $subscriptions[] = PropertyMetadata::createArray('ids', [ 'recipient' ])
    +            ->setField(function () {
    +                return $this->getNotifications()->map(function (Notification $notification) {
    +                    return $notification->getId();
    +                })->toArray();
    +            });
    +
    +        return new Metadata(static::class, [
    +            PropertyMetadata::createInteger('id', [ 'id' ]),
    +            PropertyMetadata::createString('firstName', [ 'recipient' ]),
    +            PropertyMetadata::createString('lastName', [ 'recipient' ]),
    +            PropertyMetadata::createString('name', [ 'notification', 'notification_list', 'recipient_autocompletion' ]),
    +            PropertyMetadata::createString('email', [ 'recipient', 'notification', 'notification_list', 'recipient_autocompletion' ]),
    +            PropertyMetadata::createDate('creationDate', [ 'recipient' ])
    +                ->setField('createdAt'),
    +            PropertyMetadata::groupProperties('subscriptions', $subscriptions, [ 'recipient' ]),
    +            PropertyMetadata::createCollection('groups', GroupRecipient::class, [ 'recipient' ]),
    +            PropertyMetadata::createBoolean('active', [ 'recipient' ]),
    +            PropertyMetadata::createBoolean('enrolled', [ 'sublist' ]),
    +        ]);
    +    }
    +
    +    /**
    +     * Get entity type
    +     *
    +     * @return string
    +     */
    +    public function getEntityType()
    +    {
    +        return RecipientTypeEnum::PERSON;
    +    }
    +
    +    /**
    +     * Return fqcn of form used for creating this entity.
    +     *
    +     * @return string
    +     */
    +    public function getCreateFormClass()
    +    {
    +        return PersonRecipientType::class;
    +    }
    +
    +    /**
    +     * Return fqcn of form used for updating this entity.
    +     *
    +     * @return string
    +     */
    +    public function getUpdateFormClass()
    +    {
    +        return PersonRecipientType::class;
    +    }
    +
    +    /**
    +     * @ORM\PreRemove
    +     *
    +     * @param LifecycleEventArgs $event A LifecycleEventArgs instance.
    +     *
    +     * @return void
    +     */
    +    public function preRemove(LifecycleEventArgs $event)
    +    {
    +        $em = $event->getObjectManager();
    +
    +        foreach ($this->groups as $group) {
    +            $group->removeRecipient($this);
    +            $em->persist($group);
    +        }
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Subscription/AbstractSubscription.php b/src/UserBundle/Entity/Subscription/AbstractSubscription.php
    new file mode 100644
    index 0000000..008a089
    --- /dev/null
    +++ b/src/UserBundle/Entity/Subscription/AbstractSubscription.php
    @@ -0,0 +1,401 @@
    +notifications = new ArrayCollection();
    +    }
    +
    +    /**
    +     * @return Plan
    +     */
    +    public function getPlan()
    +    {
    +        return $this->plan;
    +    }
    +
    +    /**
    +     * @param Plan $plan A Plan entity instance.
    +     *
    +     * @return $this
    +     */
    +    public function setPlan(Plan $plan = null)
    +    {
    +        $this->plan = $plan;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return PaymentGatewayEnum
    +     */
    +    public function getGateway()
    +    {
    +        return $this->gateway;
    +    }
    +
    +    /**
    +     * @param PaymentGatewayEnum $gateway A used payment gateway.
    +     *
    +     * @return static
    +     */
    +    public function setGateway(PaymentGatewayEnum $gateway)
    +    {
    +        $this->gateway = $gateway;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Add notification
    +     *
    +     * @param Notification $notification A new Notification entity instance.
    +     *
    +     * @return AbstractSubscription
    +     */
    +    public function addNotification(Notification $notification)
    +    {
    +        $this->notifications[] = $notification;
    +        $notification->setBillingSubscription($this);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove notification
    +     *
    +     * @param Notification $notification A removed Notification entity instance.
    +     *
    +     * @return AbstractSubscription
    +     */
    +    public function removeNotification(Notification $notification)
    +    {
    +        $this->notifications->removeElement($notification);
    +        $notification->setBillingSubscription(null);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get notifications
    +     *
    +     * @return Collection
    +     */
    +    public function getNotifications()
    +    {
    +        return $this->notifications;
    +    }
    +
    +    /**
    +     * Add payment
    +     *
    +     * @param Payment $payment A new Payment entity instance.
    +     *
    +     * @return AbstractSubscription
    +     */
    +    public function addPayment(Payment $payment)
    +    {
    +        $this->payments[] = $payment;
    +        $payment->setSubscription($this);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove payment
    +     *
    +     * @param Payment $payment A removed Payment entity instance.
    +     *
    +     * @return AbstractSubscription
    +     */
    +    public function removePayment(Payment $payment)
    +    {
    +        $this->payments->removeElement($payment);
    +        $payment->setSubscription(null);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get payments
    +     *
    +     * @return Collection
    +     */
    +    public function getPayments()
    +    {
    +        return $this->payments;
    +    }
    +
    +    /**
    +     * Add user
    +     *
    +     * @param User $user A new User entity instance.
    +     *
    +     * @return static
    +     */
    +    public function addUser(User $user)
    +    {
    +        $this->users[] = $user;
    +        $user->setBillingSubscription($this);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove user
    +     *
    +     * @param User $user A removed User entity instance.
    +     *
    +     * @return static
    +     */
    +    public function removeUser(User $user)
    +    {
    +        $this->users->removeElement($user);
    +        $user->setBillingSubscription(null);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get users
    +     *
    +     * @return Collection
    +     */
    +    public function getUsers()
    +    {
    +        return $this->users;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isPayed()
    +    {
    +        return $this->payed;
    +    }
    +
    +    /**
    +     * @param boolean $payed Has owner paid for this subscription or not.
    +     *
    +     * @return $this
    +     */
    +    public function setPayed($payed)
    +    {
    +        $this->payed = $payed;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @param PaymentGatewayFactoryInterface $factory A PaymentGatewayFactoryInterface
    +     *                                                instance.
    +     * @param string                         $note    Cancel note.
    +     *
    +     * @return void
    +     */
    +    public function cancel(PaymentGatewayFactoryInterface $factory, $note)
    +    {
    +        $factory->getGateway($this->getGateway())->cancelSubscription($this, $note);
    +    }
    +
    +
    +      /**
    +     * @return bool
    +     */
    +    public function isSubscriptionCancelled(): bool
    +    {
    +        return $this->isSubscriptionCancelled;
    +    }
    +
    +    /**
    +     * @param bool $isSubscriptionCancelled
    +     */
    +    public function setIsSubscriptionCancelled(bool $isSubscriptionCancelled): void
    +    {
    +        $this->isSubscriptionCancelled = $isSubscriptionCancelled;
    +    }
    +
    +    /**
    +     * @return bool
    +     */
    +    public function isPlanDowngrade(): bool
    +    {
    +        return $this->isPlanDowngrade;
    +    }
    +
    +    /**
    +     * @param bool $isPlanDowngrade
    +     */
    +    public function setIsPlanDowngrade(bool $isPlanDowngrade): void
    +    {
    +        $this->isPlanDowngrade = $isPlanDowngrade;
    +    }
    +
    +    /**
    +     * @return startDate
    +     */
    +    public function getStartDate(): ?\DateTimeInterface
    +    {
    +        return $this->startDate;
    +    }
    +
    +    /**
    +     * @param startDate 
    +     *
    +     * @return startDate
    +     */
    +    public function setStartDate(\DateTimeInterface $startDate)
    +    {
    +        $this->startDate = $startDate;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return endDate
    +     */
    +    public function getEndDate(): ?\DateTimeInterface
    +    {
    +        return $this->endDate;
    +    }
    +
    +    /**
    +     * @param endDate 
    +     *
    +     * @return endDate
    +     */
    +    public function setEndDate(\DateTimeInterface $endDate)
    +    {
    +        $this->endDate = $endDate;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return BillingSubscriptionTypeEnum
    +     */
    +    abstract public function getSubscriptionType();
    +}
    diff --git a/src/UserBundle/Entity/Subscription/OrganizationSubscription.php b/src/UserBundle/Entity/Subscription/OrganizationSubscription.php
    new file mode 100644
    index 0000000..e1d82d5
    --- /dev/null
    +++ b/src/UserBundle/Entity/Subscription/OrganizationSubscription.php
    @@ -0,0 +1,134 @@
    +organization;
    +    }
    +
    +    /**
    +     * @param Organization $organization A Organization entity instance.
    +     *
    +     * @return OrganizationSubscription
    +     */
    +    public function setOrganization(Organization $organization)
    +    {
    +        $this->organization = $organization;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getOrganizationAddress()
    +    {
    +        return $this->organizationAddress;
    +    }
    +
    +    /**
    +     * @param string $organizationAddress Organization department address.
    +     *
    +     * @return OrganizationSubscription
    +     */
    +    public function setOrganizationAddress($organizationAddress)
    +    {
    +        $this->organizationAddress = $organizationAddress;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getOrganizationEmail()
    +    {
    +        return $this->organizationEmail;
    +    }
    +
    +    /**
    +     * @param string $organizationEmail Organization department email.
    +     *
    +     * @return OrganizationSubscription
    +     */
    +    public function setOrganizationEmail($organizationEmail)
    +    {
    +        $this->organizationEmail = $organizationEmail;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getOrganizationPhone()
    +    {
    +        return $this->organizationPhone;
    +    }
    +
    +    /**
    +     * @param string $organizationPhone Organization department phone.
    +     *
    +     * @return OrganizationSubscription
    +     */
    +    public function setOrganizationPhone($organizationPhone)
    +    {
    +        $this->organizationPhone = $organizationPhone;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return BillingSubscriptionTypeEnum
    +     */
    +    public function getSubscriptionType()
    +    {
    +        return BillingSubscriptionTypeEnum::organization();
    +    }
    +}
    diff --git a/src/UserBundle/Entity/Subscription/PersonalSubscription.php b/src/UserBundle/Entity/Subscription/PersonalSubscription.php
    new file mode 100644
    index 0000000..7000c16
    --- /dev/null
    +++ b/src/UserBundle/Entity/Subscription/PersonalSubscription.php
    @@ -0,0 +1,21 @@
    +searchesPerDay;
    +    }
    +
    +    /**
    +     * Set searchesPerDay
    +     *
    +     * @param integer $searchesPerDay Search per day limit.
    +     *
    +     * @return static
    +     */
    +    public function setSearchesPerDay($searchesPerDay)
    +    {
    +        $this->searchesPerDay = $searchesPerDay;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return mixed
    +     */
    +    public function getSavedFeeds()
    +    {
    +        return $this->savedFeeds;
    +    }
    +
    +    /**
    +     * Set savedFeeds
    +     *
    +     * @param integer $savedFeeds Saved feed limit.
    +     *
    +     * @return static
    +     */
    +    public function setSavedFeeds($savedFeeds)
    +    {
    +        $this->savedFeeds = $savedFeeds;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return mixed
    +     */
    +    public function getWebFeeds()
    +    {
    +        return $this->webFeeds;
    +    }
    +
    +    /**
    +     * Set webFeeds
    +     *
    +     * @param integer $webFeeds Saved feed limit.
    +     *
    +     * @return static
    +     */
    +    public function setWebFeeds($webFeeds)
    +    {
    +        $this->webFeeds = $webFeeds;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return integer
    +     */
    +    public function getMasterAccounts()
    +    {
    +        return $this->masterAccounts;
    +    }
    +
    +    /**
    +     * @param integer $masterAccounts Master accounts limit.
    +     *
    +     * @return static
    +     */
    +    public function setMasterAccounts($masterAccounts)
    +    {
    +        $this->masterAccounts = $masterAccounts;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return integer
    +     */
    +    public function getSubscriberAccounts()
    +    {
    +        return $this->subscriberAccounts;
    +    }
    +
    +    /**
    +     * @param integer $subscriberAccounts Subscriber account limit.
    +     *
    +     * @return static
    +     */
    +    public function setSubscriberAccounts($subscriberAccounts)
    +    {
    +        $this->subscriberAccounts = $subscriberAccounts;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return integer
    +     */
    +    public function getAlerts()
    +    {
    +        return $this->alerts;
    +    }
    +
    +    /**
    +     * @param integer $alerts Alerts count.
    +     *
    +     * @return static
    +     */
    +    public function setAlerts($alerts)
    +    {
    +        $this->alerts = $alerts;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * @return integer
    +     */
    +    public function getNewsletters()
    +    {
    +        return $this->newsletters;
    +    }
    +
    +    /**
    +     * @param integer $newsletters Newsletters count.
    +     *
    +     * @return static
    +     */
    +    public function setNewsletters($newsletters)
    +    {
    +        $this->newsletters = $newsletters;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get limit value for specified limit name.
    +     *
    +     * @param AppLimitEnum $appLimit Requested limit name.
    +     *
    +     * @return integer
    +     */
    +    public function getLimitValue(AppLimitEnum $appLimit)
    +    {
    +        return $this->{'get'. ucfirst($appLimit->getValue())}();
    +    }
    +
    +    /**
    +     * Set limit value for specified limit name.
    +     *
    +     * @param AppLimitEnum $appLimit Changed limit name.
    +     * @param integer      $newValue New value for limit.
    +     *
    +     * @return $this
    +     */
    +    public function setLimitValue(AppLimitEnum $appLimit, $newValue)
    +    {
    +        $this->{'set'. ucfirst($appLimit->getValue())}($newValue);
    +
    +        return $this;
    +    }
    +}
    diff --git a/src/UserBundle/Entity/User.php b/src/UserBundle/Entity/User.php
    new file mode 100644
    index 0000000..72c0931
    --- /dev/null
    +++ b/src/UserBundle/Entity/User.php
    @@ -0,0 +1,1090 @@
    +categories = new ArrayCollection();
    +        $this->sourcesLists = new ArrayCollection();
    +        $this->subscribers = new ArrayCollection();
    +        $this->recipients = new ArrayCollection();
    +    }
    +
    +    /**
    +     * Returns the roles granted to the user.
    +     *
    +     * 
    +     * public function getRoles()
    +     * {
    +     *     return array('ROLE_USER');
    +     * }
    +     * 
    +     *
    +     * Alternatively, the roles might be stored on a ``roles`` property,
    +     * and populated in any number of different ways when the user object
    +     * is created.
    +     *
    +     * @return string[]
    +     */
    +    public function getRoles()
    +    {
    +        $roles = $this->roles;
    +
    +        if (count($roles) === 0) {
    +            $roles = [UserRoleEnum::SUBSCRIBER];
    +        }
    +
    +        return array_unique($roles);
    +    }
    +
    +    /**
    +     * Create new user i    
    +
    +     */
    +    public static function create($email, $password = null)
    +    {
    +        $entity = new User();
    +        $entity
    +            ->setPlainPassword($password)
    +            ->setEmail($email);
    +
    +        if ($password === null) {
    +            $entity->generatePassword();
    +        }
    +
    +        return $entity;
    +    }
    +
    +    /**
    +     * Create new subscriber.
    +     *
    +     * @param string $email A user email address.
    +     * @param string $password A user plain password.
    +     *
    +     * @return User
    +     */
    +    public static function createSubscriber($email, $password = null)
    +    {
    +        return User::create($email, $password)
    +            ->setRoles([UserRoleEnum::SUBSCRIBER]);
    +    }
    +    
    +
    +    public static function createMasterUser($email, $password = null)
    +    {
    +        return User::create($email, $password)
    +            ->setRoles([UserRoleEnum::MASTER_USER]);
    +    }
    +
    +    /**
    +     * Create new admin.
    +     *
    +     * @param string $email A user email address.
    +     * @param string $password A user plain password.
    +     *
    +     * @return User
    +     */
    +    public static function createAdmin($email, $password = null)
    +    {
    +        return User::create($email, $password)
    +            ->setRoles([UserRoleEnum::ADMIN]);
    +    }
    +
    +    /**
    +     * Set firstName
    +     *
    +     * @param string $firstName User first name.
    +     *
    +     * @return User
    +     */
    +    public function setFirstName($firstName)
    +    {
    +        $this->firstName = $firstName;
    +
    +        if (($this->recipient !== null) && ($this->recipient->getFirstName() !== $firstName)) {
    +            $this->recipient->setFirstName($firstName);
    +        }
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get firstName
    +     *
    +     * @return string
    +     */
    +    public function getFirstName()
    +    {
    +        return $this->firstName;
    +    }
    +
    +    /**
    +     * Set lastName
    +     *
    +     * @param string $lastName User last name.
    +     *
    +     * @return User
    +     */
    +    public function setLastName($lastName)
    +    {
    +        $this->lastName = $lastName;
    +
    +        if (($this->recipient !== null) && ($this->recipient->getLastName() !== $lastName)) {
    +            $this->recipient->setLastName($lastName);
    +        }
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get lastName
    +     *
    +     * @return string
    +     */
    +    public function getLastName()
    +    {
    +        return $this->lastName;
    +    }
    +
    +    /**
    +     * Get full user name.
    +     *    
    +
    +     * @return string
    +     */
    +    public function getFullName()
    +    {
    +        return $this->firstName . ' ' . $this->lastName;
    +    }
    +
    +    /**
    +     * Sets the username.
    +     *
    +     * @param string $username New username.
    +     *
    +     * @return User
    +     */
    +    public function setUsername($username)
    +    {
    +        parent::setUsername($username);
    +        $this->email = $username;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Sets the canonical username.
    +     *
    +     * @param string $usernameCanonical New canonical username.
    +     *
    +     * @return User
    +     */
    +    public function setUsernameCanonical($usernameCanonical)
    +    {
    +        parent::setUsernameCanonical($usernameCanonical);
    +        $this->emailCanonical = $usernameCanonical;
    +
    +        return $this;
    +    }
    +
    +
    +    /**
    +     * Sets the email.
    +     *
    +     * @param string $email New user email.
    +     *
    +     * @return User
    +     */
    +    public function setEmail($email)
    +    {
    +        parent::setEmail($email);
    +        // Copy email into username.
    +        $this->username = $email;
    +
    +        if (($this->recipient !== null) && ($this->recipient->getEmail() !== $email)) {
    +            $this->recipient->setEmail($email);
    +        }
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Sets the canonical email.
    +     *
    +     * @param string $emailCanonical New canonicla email.
    +     *
    +     * @return User
    +     */
    +    public function setEmailCanonical($emailCanonical)
    +    {
    +        parent::setEmailCanonical($emailCanonical);
    +        $this->usernameCanonical = $emailCanonical;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Add category
    +     *
    +     * @param Category $category A Category entity instance.
    +     *
    +     * @return User
    +     */
    +    public function addCategory(Category $category)
    +    {
    +        $this->categories[] = $category;
    +        $category->setUser($this);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove category
    +     *
    +     * @param Category $category A Category entity instance.
    +     *
    +     * @return User
    +     */
    +    public function removeCategory(Category $category)
    +    {
    +        $this->categories->removeElement($category);
    +        $category->setUser(null);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get categories
    +     *
    +     * @return Category[]|Collection
    +     */
    +    public function getCategories()
    +    {
    +        return $this->categories;
    +    }
    +
    +    /**
    +     * Generate random password for this user.
    +     *
    +     * @return User
    +     */
    +    public function generatePassword()
    +    {
    +        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+=-';
    +        $this->plainPassword = substr(str_shuffle($chars), 0, 12);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Add sourcesList
    +     *
    +     * @param SourceList $sourcesList A SourceList instance.
    +     *
    +     * @return User
    +     */
    +    public function addSourcesList(SourceList $sourcesList)
    +    {
    +        $this->sourcesLists[] = $sourcesList;
    +
    +        return $this;
    +    }
    +    
    +
    +    /**
    +     * Remove sourcesList
    +     *
    +     * @param SourceList $sourcesList A SourceList instance.
    +     *
    +     * @return User
    +     */
    +    public function removeSourcesList(SourceList $sourcesList)
    +    {
    +        $this->sourcesLists->removeElement($sourcesList);
    +
    +        return $this;
    +    }
    +
    +    /**    
    +
    +        return $this->sourcesLists;
    +    }
    +
    +
    +    /**
    +     * Set expirationDay
    +     *
    +     * @param \DateTime $expirationDay When user is expires.
    +     *
    +     * @return User
    +     */
    +    public function setExpirationDay(\DateTime $expirationDay = null)
    +    {
    +        $this->expirationDay = $expirationDay;
    +    
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get expirationDay
    +     *
    +     * @return \DateTime
    +     */
    +    public function getExpirationDay()
    +    {
    +        return $this->expirationDay;
    +    }
    +
    +    /**
    +     * Set masterUser
    +     *
    +     * @param User $masterUser A master User entity instance.
    +     *
    +     * @return User
    +     */
    +    public function setMasterUser(User $masterUser = null)
    +    {
    +        $this->masterUser = $masterUser;
    +    
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get masterUser
    +     *
    +     * @return User
    +     */
    +    public function getMasterUser()
    +    {
    +        return $this->masterUser;
    +    }
    +
    +    /**
    +     * Add subscriber
    +     *
    +     * @param User $subscriber A subscriber User entity instance.
    +     *
    +     * @return User
    +     */
    +    public function addSubscriber(User $subscriber)
    +    {
    +        $this->subscribers[] = $subscriber;
    +        $subscriber->setMasterUser($this);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Remove subscriber
    +     *
    +     * @param User $subscriber A subscriber User entity instance.
    +     *
    +     * @return User
    +     */
    +    public function removeSubscriber(User $subscriber)
    +    {
    +        $this->subscribers->removeElement($subscriber);
    +        $subscriber->setMasterUser(null);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get subscribers
    +     *
    +     * @return \Doctrine\Common\Collections\Collection
    +     */
    +    public function getSubscribers()
    +    {
    +        return $this->subscribers;
    +    }
    +
    +    /**
    +     * Set position
    +     *
    +     * @param string $position User position.
    +     *
    +     * @return User
    +     */
    +    public function setPosition($position)
    +    {
    +        $this->position = $position;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get position
    +     *
    +     * @return string
    +     */
    +    public function getPosition()
    +    {
    +        return $this->position;
    +    }
    +
    +    /**
    +     * Set phoneNumber
    +     *
    +     * @param string $phoneNumber Phone number.
    +     *
    +     * @return User
    +     */
    +    public function setPhoneNumber($phoneNumber)
    +    {
    +        $this->phoneNumber = $phoneNumber;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get phoneNumber
    +     *
    +     * @return string
    +     */
    +    public function getPhoneNumber()
    +    {
    +        return $this->phoneNumber;
    +    }
    +
    +    /**
    +     * @return PersonRecipient
    +     */
    +    public function getRecipient()
    +    {
    +        return $this->recipient;
    +    }
    +
    +    /**
    +     * @param PersonRecipient $recipient A Associated recipient.
    +     *
    +     * @return User
    +     */
    +    public function setRecipient(PersonRecipient $recipient = null)
    +    {
    +        $this->recipient = $recipient;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Return metadata for current entity.
    +     *
    +     * @return Metadata
    +     */
    +    public function getMetadata()
    +    {
    +        return new Metadata(static::class, [
    +            PropertyMetadata::createInteger('id', ['id']),
    +            PropertyMetadata::createString('firstName', ['comment', 'user', 'subscriber', 'source_list']),
    +            PropertyMetadata::createString('lastName', ['comment', 'user', 'subscriber', 'source_list']),
    +            PropertyMetadata::createString('email', ['user', 'subscriber', 'notification_list']),
    +            PropertyMetadata::createString('role', ['user'])
    +                ->setField(function () {
    +                    $roles = $this->getRoles();
    +                    return $roles[0];
    +                }),
    +            PropertyMetadata::createDate('lastLogin', ['user', 'subscriber'])
    +                ->setNullable(true),
    +            PropertyMetadata::createBoolean('enabled', ['user', 'subscriber']),
    +            PropertyMetadata::createString('position', ['subscriber']),
    +            PropertyMetadata::createString('phoneNumber', ['subscriber']),
    +            PropertyMetadata::createBoolean('allowToCreateSavedFeeds', ['subscriber']),
    +            PropertyMetadata::createEntity('recipient', PersonRecipient::class, ['recipient']),
    +            PropertyMetadata::createObject('restrictions', ['restrictions'])
    +                ->setField(function () {
    +                    return $this->getRestrictions();
    +                }),
    +        ]);
    +    }
    +
    +    /**
    +     * Return default normalization groups.
    +     *
    +     * @return array
    +     */
    +    public function defaultGroups()
    +    {
    +        return ['user', 'id'];
    +    }
    +
    +    /**
    +     * Get entity type
    +     *
    +     * @return string
    +     */
    +    public function getEntityType()
    +    {
    +        return 'user';
    +    }
    +
    +    /**
    +     * Set billingSubscription
    +     *
    +     * @param AbstractSubscription $billingSubscription A billing subscription
    +     *                                                  entity instance.
    +     *
    +     * @return User
    +     */
    +    public function setBillingSubscription(AbstractSubscription $billingSubscription = null)
    +    {
    +        $this->billingSubscription = $billingSubscription;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get billingSubscription
    +     *
    +     * @return AbstractSubscription
    +     */
    +    public function getBillingSubscription()
    +    {
    +        return $this->billingSubscription;
    +    }
    +
    +    /**
    +     * @return boolean
    +     */
    +    public function isVerified()
    +    {
    +        return $this->verified;
    +    }
    +
    +    /**
    +     * @param boolean $verified Is this account verified or not.
    +     *
    +     * @return User
    +     */
    +    public function setVerified($verified = true)
    +    {
    +        $this->verified = $verified;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Checks that passed permission is allowed for this user.
    +     *
    +     * @param AppPermissionEnum $appPermission Requested permission.
    +     *
    +     * @return boolean
    +     */
    +    public function isAllowedTo(AppPermissionEnum $appPermission)
    +    {
    +        switch ($appPermission->getValue()) {
    +            case AppPermissionEnum::ANALYTICS:
    +                return $this->getBillingSubscription()->getPlan()->isAnalytics();
    +
    +            default:
    +                throw new \LogicException(sprintf(
    +                    'Unhandled app permission \'%s\'',
    +                    $appPermission->getValue()
    +                ));
    +        }
    +    }
    +
    +    /**
    +     * Get current used limit value.
    +     *
    +     * @param AppLimitEnum $appLimit Requested limit name.
    +     *
    +     * @return integer
    +     */
    +    public function getUsedLimit(AppLimitEnum $appLimit)
    +    {
    +        return $this->billingSubscription->getLimitValue($appLimit);
    +    }
    +
    +    /**
    +     * Get allowed limit value.
    +     *
    +     * @param AppLimitEnum $appLimit Requested limit name.
    +     *
    +     * @return integer
    +     */
    +    public function getAllowedLimit(AppLimitEnum $appLimit)
    +    {
    +        return $this->billingSubscription->getPlan()->getLimitValue($appLimit);
    +    }
    +
    +    /**
    +     * Use specified limit for current user.
    +     *
    +     * @param AppLimitEnum $appLimit A required limit.
    +     * @param integer $count How much limit should be used.
    +     *
    +     * @return $this
    +     *
    +     * @throws LimitExceedException If requested limit is exceeded.
    +     */
    +    public function useLimit(AppLimitEnum $appLimit, $count = 1)
    +    {
    +        $newValue = $this->checkLimit($appLimit, $count);
    +        $this->billingSubscription->setLimitValue($appLimit, $newValue);
    +        return $this;
    +    }
    +
    +    /**
    +     * Use specified limit for current user.
    +     *
    +     * @param AppLimitEnum $appLimit A required limit.
    +     * @param integer $count How much limit should be used.
    +     *
    +     * @return integer A new limit value
    +     *
    +     * @throws LimitExceedException If requested limit is exceeded.
    +     */
    +    public function checkLimit(AppLimitEnum $appLimit, $count = 1)
    +    {
    +        $currValue = $this->getUsedLimit($appLimit);
    +        $newValue = $currValue + $count;
    +        $allowed = $this->getAllowedLimit($appLimit);
    +
    +        if ($newValue > $allowed) {
    +            throw new LimitExceedException($this, $appLimit, $currValue, $count, $allowed);
    +        }
    +        return $newValue;
    +    }
    +
    +    /**
    +     * Release specific user limit.
    +     *
    +     * @param AppLimitEnum $appLimit A required limit.
    +     * @param integer $count How much limit should be released.
    +     *
    +     * @return $this
    +     */
    +    public function releaseLimit(AppLimitEnum $appLimit, $count = 1)
    +    {
    +        $newValue = $this->getUsedLimit($appLimit) - $count;
    +
    +        if ($newValue < 0) {
    +            $newValue = 0;
    +        }
    +
    +        $this->billingSubscription->setLimitValue($appLimit, $newValue);
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get restrictions for current user.
    +     *
    +     * @return array
    +     */
    +    public function getRestrictions()
    +    {
    +        $limits = [];
    +        /** @var AppLimitEnum $value */
    +        foreach (AppLimitEnum::getValues() as $value) {
    +            $limits[$value->getValue()] = [
    +                'limit' => $this->getAllowedLimit($value),
    +                'current' => $this->getUsedLimit($value),
    +            ];
    +        }
    +
    +        $permissions = [];
    +        /** @var AppPermissionEnum $value */
    +        foreach (AppPermissionEnum::getValues() as $value) {
    +            $permissions[$value->getValue()] = $this->isAllowedTo($value);
    +        }
    +        $planData = [];
    +        $plan = $this->getBillingSubscription()->getPlan();
    +        if ($plan) {
    +            $planData = [
    +                'news' => $plan->isNews(),
    +                'blogs' => $plan->isBlog(),
    +                'reddit' => $plan->isReddit(),
    +                'instagram' => $plan->isInstagram(),
    +                'twitter' => $plan->isTwitter(),
    +                'price' => $plan->getPrice(),
    +                'analytics' => $plan->isAnalytics(),
    +                'savedFeeds' => $plan->getSavedFeeds(),
    +                'masterAccounts' => $plan->getMasterAccounts(),
    +            ];
    +        }
    +        return [
    +            'limits' => $limits,
    +            'permissions' => $permissions,
    +            'plans' => $planData,
    +            'isPaymentId' => !empty($this->getStripeUserId()) ? true :false,
    +            'isPlanCancelled' => $this->getBillingSubscription()->isSubscriptionCancelled(),
    +            'isPlanDowngrade' => $this->getBillingSubscription()->isPlanDowngrade(),
    +            'subStartDate' => ($this->getBillingSubscription()->getStartDate() instanceof \DateTime) ? $this->getBillingSubscription()->getStartDate()->format('Y-m-d h:i:s') : '',
    +            'subEndDate' => ($this->getBillingSubscription()->getEndDate() instanceof \DateTime) ? $this->getBillingSubscription()->getEndDate()->format('Y-m-d h:i:s') : '',
    +        ];
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getCompanyName(): string
    +    {
    +        return $this->companyName;
    +    }
    +
    +    /**
    +     * @param string $companyName
    +     */
    +    public function setCompanyName($companyName)
    +    {
    +        $this->companyName = $companyName;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getJobFunction(): string
    +    {
    +        return $this->jobFunction;
    +    }
    +
    +    /**
    +     * @param string $jobFunction
    +     */
    +    public function setJobFunction($jobFunction)
    +    {
    +        $this->jobFunction = $jobFunction;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getNumberOfEmployee(): string
    +    {
    +        return $this->numberOfEmployee;
    +    }
    +
    +    /**
    +     * @param string $numberOfEmployee
    +     */
    +    public function setNumberOfEmployee($numberOfEmployee)
    +    {
    +        $this->numberOfEmployee = $numberOfEmployee;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getIndustry(): string
    +    {
    +        return $this->industry;
    +    }
    +
    +    /**
    +     * @param string $industry
    +     */
    +    public function setIndustry($industry)
    +    {
    +        $this->industry = $industry;
    +    }
    +
    +    /**
    +     * @return bool
    +     */
    +    public function isHubSpot(): bool
    +    {
    +        return $this->hubSpot;
    +    }
    +
    +    /**
    +     * @param bool $hubSpot
    +     */
    +    public function setHubSpot(bool $hubSpot): void
    +    {
    +        $this->hubSpot = $hubSpot;
    +    }
    +
    +    /**
    +     * @return string
    +     */
    +    public function getWebsiteUrl(): string
    +    {
    +        return $this->websiteUrl;
    +    }
    +
    +    /**
    +     * @param string $websiteUrl
    +     */
    +    public function setWebsiteUrl($websiteUrl)
    +    {
    +        $this->websiteUrl = $websiteUrl;
    +    }
    +
    +    /**
    +     * Set stripeUserId
    +     *
    +     * @param string $stripeUserId User stripeUserId.
    +     *
    +     * @return User
    +     */
    +    public function setStripeUserId($stripeUserId)
    +    {
    +        $this->stripeUserId = $stripeUserId;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Get stripeUserId
    +     *
    +     * @return string
    +     */
    +    public function getStripeUserId()
    +    {
    +        return $this->stripeUserId;
    +    }
    +
    +    /**
    +     * Get plan
    +     *
    +     * @return Collection
    +     */
    +    public function getPlans()
    +    {
    +        return $this->plan;
    +    }
    +}
    diff --git a/src/UserBundle/Enum/AppLimitEnum.php b/src/UserBundle/Enum/AppLimitEnum.php
    new file mode 100644
    index 0000000..aa5d83c
    --- /dev/null
    +++ b/src/UserBundle/Enum/AppLimitEnum.php
    @@ -0,0 +1,29 @@
    + 'Arial,helvetica,sans-serif',
    +        FontFamilyEnum::CALIBRI => 'Calibri,Helvetica,sans-serif',
    +        FontFamilyEnum::CENTURY_GOTHIC => '\'Century Gothic\',CenturyGothic,AppleGothic,sans-serif',
    +        FontFamilyEnum::COURIER_NEW => '\'Courier new\',courier,monospace',
    +        FontFamilyEnum::GEORGIA => 'Georgia,serif',
    +        FontFamilyEnum::LUCIDA_SANS_UNICODE => '\'Lucida Sans Unicode\',sans-serif',
    +        FontFamilyEnum::MYRIAD_PRO_REGULAR => 'MyriadPro-Regular,\'Lucida Sans Unicode\',\'Lucida Grande\',sans-serif',
    +        FontFamilyEnum::TAHOMA => '\'Tahoma Verdana\',Segoe,sans-serif',
    +        FontFamilyEnum::TIMES_NEW_ROMAN => 'TimesNewRoman,serif',
    +        FontFamilyEnum::TREBUCHET => 'Trebuchet,Trebuchet MS,sans-serif',
    +        FontFamilyEnum::VERDANA => 'Verdana,geneva,sans-serif',
    +    ];
    +
    +    /**
    +     * Get css font family option.
    +     *
    +     * @return string
    +     */
    +    public function getCss()
    +    {
    +        return self::$nameToFamilyMap[$this->value];
    +    }
    +}
    diff --git a/src/UserBundle/Enum/NotificationTypeEnum.php b/src/UserBundle/Enum/NotificationTypeEnum.php
    new file mode 100644
    index 0000000..ff020e5
    --- /dev/null
    +++ b/src/UserBundle/Enum/NotificationTypeEnum.php
    @@ -0,0 +1,31 @@
    +value === self::ALERT) {
    +            return AppLimitEnum::alerts();
    +        }
    +
    +        return AppLimitEnum::newsletters();
    +    }
    +}
    diff --git a/src/UserBundle/Enum/RecipientTypeEnum.php b/src/UserBundle/Enum/RecipientTypeEnum.php
    new file mode 100644
    index 0000000..a327c08
    --- /dev/null
    +++ b/src/UserBundle/Enum/RecipientTypeEnum.php
    @@ -0,0 +1,36 @@
    + PersonRecipient::class,
    +        self::GROUP => GroupRecipient::class,
    +    ];
    +
    +    /**
    +     * Get entity class for this type.
    +     *
    +     * @return string
    +     */
    +    public function getEntityClass()
    +    {
    +        return self::$map[$this->value];
    +    }
    +}
    diff --git a/src/UserBundle/Enum/StatusFilterEnum.php b/src/UserBundle/Enum/StatusFilterEnum.php
    new file mode 100644
    index 0000000..dfffc7c
    --- /dev/null
    +++ b/src/UserBundle/Enum/StatusFilterEnum.php
    @@ -0,0 +1,21 @@
    +userPasswordEncoder = $userPasswordEncoder;
    +    }
    +
    +    /**
    +     * Builds the form.
    +     *
    +     * This method is called for each type in the hierarchy starting from the
    +     * top most type. Type extensions can further modify the form.
    +     *
    +     * @see FormTypeExtensionInterface::buildForm()
    +     *
    +     * @param FormBuilderInterface $builder The form builder.
    +     * @param array                $options The options.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function buildForm(FormBuilderInterface $builder, array $options)
    +    {
    +        $builder
    +            ->add('password', PasswordType::class, [
    +                'trim' => true,
    +                'property_path' => 'plainPassword',
    +                'constraints' => new NotBlank(),
    +            ])
    +            ->add('oldPassword', PasswordType::class, [
    +                'trim' => true,
    +                'mapped' => false,
    +                'constraints' => [
    +                    new NotBlank(),
    +                    new Callback([ $this, 'checkPassword' ]),
    +                ],
    +            ]);
    +    }
    +
    +    /**
    +     * Configures the options for this type.
    +     *
    +     * @param OptionsResolver $resolver The resolver for the options.
    +     *
    +     * @return void
    +     */
    +    public function configureOptions(OptionsResolver $resolver)
    +    {
    +        $resolver->setDefaults([
    +            'data_class' => User::class,
    +        ]);
    +    }
    +
    +    /**
    +     * @param string|null               $value   Old user password.
    +     * @param ExecutionContextInterface $context A ExecutionContextInterface
    +     *                                           instance.
    +     *
    +     * @return void
    +     */
    +    public function checkPassword($value, ExecutionContextInterface $context)
    +    {
    +        if ($value === null) {
    +            return;
    +        }
    +
    +        /** @var FormInterface $form */
    +        $form = $context->getRoot();
    +        /** @var User $user */
    +        $user = $form->getData();
    +
    +        if (! $this->userPasswordEncoder->isPasswordValid($user, $value)) {
    +            $context->buildViolation('Old password don\'t match to current password')
    +                ->addViolation();
    +        }
    +    }
    +}
    diff --git a/src/UserBundle/Form/Extension/Core/DataMapper/NotificationDataMapper.php b/src/UserBundle/Form/Extension/Core/DataMapper/NotificationDataMapper.php
    new file mode 100644
    index 0000000..3371a74
    --- /dev/null
    +++ b/src/UserBundle/Form/Extension/Core/DataMapper/NotificationDataMapper.php
    @@ -0,0 +1,114 @@
    +getParent(), Notification::class);
    +        }
    +
    +        //
    +        // Map sources.
    +        //
    +        $sources = $forms['sources']->getData();
    +
    +        // todo uncomment and refactor when analytics is added
    +        // if (($sources['feeds'] !== null) && ($sources['charts'] !== null)) {
    +        if (isset($sources['feeds'])) {
    +            // Do not map data if we have null.
    +            $data
    +                ->setFeeds($sources['feeds']);
    +        }
    +
    +        //
    +        // Map schedules.
    +        //
    +        $data->setSchedules($forms['automatic']->getData());
    +
    +        //
    +        // Map diffs.
    +        //
    +        $data->setPlainThemeOptionsDiff($forms['plainDiff']->getData());
    +        $data->setEnhancedThemeOptionsDiff($forms['enhancedDiff']->getData());
    +
    +        //
    +        // Remove all processed fields.
    +        //
    +//        \Functional\each(self::$requiredFields, function ($name) use (&$forms) {
    +//            unset($forms[$name]);
    +//        });
    +        foreach (self::$requiredFields as $name) {
    +            unset($forms[$name]);
    +        }
    +
    +        //
    +        // Map other fields.
    +        //
    +        parent::mapFormsToData($forms, $data);
    +    }
    +}
    diff --git a/src/UserBundle/Form/GroupRecipientType.php b/src/UserBundle/Form/GroupRecipientType.php
    new file mode 100644
    index 0000000..e4a963b
    --- /dev/null
    +++ b/src/UserBundle/Form/GroupRecipientType.php
    @@ -0,0 +1,102 @@
    +storage = $storage;
    +    }
    +
    +    /**
    +     * Builds the form.
    +     *
    +     * This method is called for each type in the hierarchy starting from the
    +     * top most type. Type extensions can further modify the form.
    +     *
    +     * @see FormTypeExtensionInterface::buildForm()
    +     *
    +     * @param FormBuilderInterface $builder The form builder.
    +     * @param array                $options The options.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function buildForm(FormBuilderInterface $builder, array $options)
    +    {
    +        $user = \app\op\invokeIf($this->storage->getToken(), 'getUser');
    +
    +        $builder
    +            ->add('name')
    +            ->add('description')
    +            ->add('active', CheckboxType::class)
    +            ->add('recipients', EntityType::class, [
    +                'class' => PersonRecipient::class,
    +                'multiple' => true,
    +                'by_reference' => false,
    +                'query_builder' => function (PersonRecipientRepository $repository) use ($user) {
    +                    if ($user instanceof User) {
    +                        return $repository->getAvailableForUser($user);
    +                    }
    +
    +                    return $repository->createQueryBuilder('Person');
    +                },
    +            ])
    +            ->add('notifications', EntityType::class, [
    +                'class' => Notification::class,
    +                'multiple' => true,
    +                'by_reference' => false,
    +                'query_builder' => function (NotificationRepository $repository) use ($user) {
    +                    if ($user instanceof User) {
    +                        return $repository->getQueryBuilderForForm($user);
    +                    }
    +
    +                    return $repository->createQueryBuilder('Notification');
    +                },
    +            ]);
    +    }
    +
    +    /**
    +     * Configures the options for this type.
    +     *
    +     * @param OptionsResolver $resolver The resolver for the options.
    +     *
    +     * @return void
    +     */
    +    public function configureOptions(OptionsResolver $resolver)
    +    {
    +        $resolver->setDefaults([
    +            'data_class' => GroupRecipient::class,
    +        ]);
    +    }
    +}
    diff --git a/src/UserBundle/Form/HubSpotRegistrationType.php b/src/UserBundle/Form/HubSpotRegistrationType.php
    new file mode 100644
    index 0000000..3084dda
    --- /dev/null
    +++ b/src/UserBundle/Form/HubSpotRegistrationType.php
    @@ -0,0 +1,151 @@
    +em = $em;
    +    }
    +
    +    /**
    +     * Builds the form.
    +     *
    +     * This method is called for each type in the hierarchy starting from the
    +     * top most type. Type extensions can further modify the form.
    +     *
    +     * @see FormTypeExtensionInterface::buildForm()
    +     *
    +     * @param FormBuilderInterface $builder The form builder.
    +     * @param array $options The options.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function buildForm(FormBuilderInterface $builder, array $options)
    +    {
    +        $builder
    +            ->add('email', EmailType::class, [
    +                'constraints' => [
    +                    new Regex([
    +                        'pattern' => '/^[a-zA-Z0-9!#$%&\'*+\/=?^_` {|}~;."-]+@[a-zA-Z0-9!#$%&\'*+\/=?^_` {|}~;."-]+\.[a-zA-Z0-9]+$/',
    +                        'message' => 'This value is not a valid email address',
    +                    ]),
    +                    new Length([
    +                        'max' => 160,
    +                        'maxMessage' => 'Email address is too long. It should have {{ limit }} character or less',
    +                    ]),
    +                ],
    +            ])
    +            ->add('firstName')
    +            ->add('lastName')
    +            ->add('companyName')
    +            ->add('jobFunction')
    +            ->add('numberOfEmployee')
    +            ->add('industry')
    +            ->add('websiteUrl')
    +//            ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
    +//                $data = $event->getData();
    +//                $form = $event->getForm();
    +//            })
    +            ->setDataMapper($this);
    +    }
    +
    +    /**
    +     * Configures the options for this type.
    +     *
    +     * @param OptionsResolver $resolver The resolver for the options.
    +     *
    +     * @return void
    +     */
    +    public function configureOptions(OptionsResolver $resolver)
    +    {
    +        $resolver->setDefaults([
    +            'data_class' => User::class,
    +        ]);
    +    }
    +
    +    /**
    +     * Maps properties of some data to a list of forms.
    +     *
    +     * @param User|null $data Structured data.
    +     * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of
    +     *                                                          {@link FormInterface}
    +     *                                                          instances.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function mapDataToForms($data, $forms)
    +    {
    +        // Do nothing because it's not necessary method.
    +    }
    +
    +    /**
    +     * Maps the data of a list of forms into the properties of some data.
    +     *
    +     * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of
    +     *                                                          {@link FormInterface}
    +     *                                                          instances.
    +     * @param User|null $data Structured data.
    +     *
    +     * @return void
    +     */
    +    public function mapFormsToData($forms, &$data)
    +    {
    +        $forms = iterator_to_array($forms);
    +
    +        $data->setEmail($forms['email']->getData());
    +        $data->setFirstName($forms['firstName']->getData());
    +        $data->setLastName($forms['lastName']->getData());
    +        $data->setJobFunction($forms['jobFunction']->getData());
    +        $data->setCompanyName($forms['companyName']->getData());
    +        $data->setNumberOfEmployee($forms['numberOfEmployee']->getData());
    +        $data->setIndustry($forms['industry']->getData());
    +        $data->setWebsiteUrl($forms['websiteUrl']->getData());
    +        $data->setPhoneNumber(' ');
    +        $data->setHubSpot(true);
    +
    +    }
    +}
    +
    diff --git a/src/UserBundle/Form/NotificationType.php b/src/UserBundle/Form/NotificationType.php
    new file mode 100644
    index 0000000..b3b3dd2
    --- /dev/null
    +++ b/src/UserBundle/Form/NotificationType.php
    @@ -0,0 +1,113 @@
    +add('name')
    +            ->add('notificationType', EnumType::class, [
    +                'enum_class' => NotificationTypeEnum::class,
    +            ])
    +            ->add('recipients', CurrentUserOwnedEntityType::class, [
    +                'class' => AbstractRecipient::class,
    +                'multiple' => true,
    +                'expanded' => true,
    +                    'by_reference' => 'false',
    +                'user_property' => 'owner',
    +            ])
    +            ->add('themeType', EnumType::class, [
    +                'enum_class' => ThemeTypeEnum::class,
    +            ])
    +            ->add('theme', EntityType::class, [
    +                'class' => NotificationTheme::class,
    +            ])
    +            ->add('subject', null, [ 'required' => false ])
    +            ->add('automatedSubject', FormType\CheckboxType::class)
    +            ->add('published', FormType\CheckboxType::class)
    +            ->add('allowUnsubscribe', FormType\CheckboxType::class)
    +            ->add('unsubscribeNotification', FormType\CheckboxType::class)
    +            ->add('sources', SourcesType::class)
    +            ->add('sendWhenEmpty', FormType\CheckboxType::class)
    +            ->add('timezone', SimpleTimeZoneType::class)
    +            ->add('automatic', FormType\CollectionType::class, [
    +                'entry_type' => ScheduleType::class,
    +                'description' => 'Array of daily, weekly or monthly scheduling objects.',
    +                'allow_add' => true,
    +                'allow_delete' => true,
    +                'empty_data' => [],
    +            ])
    +            ->add('sendUntil', FormType\DateType::class, [ 'widget' => 'single_text' ])
    +            ->add('plainDiff', NotificationDiffType::class)
    +            ->add('enhancedDiff', NotificationDiffType::class)
    +            ->setDataMapper(new NotificationDataMapper())
    +            ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
    +                $data = $event->getData();
    +                $form = $event->getForm();
    +
    +                if (isset($data['automatedSubject']) && ($data['automatedSubject'] === false)) {
    +                    $options = $form->get('subject')->getConfig()->getOptions();
    +                    $options['constraints'] = new NotBlank([ 'message' => 'Subject should not be blank' ]);
    +
    +                    $form
    +                        ->remove('subject')
    +                        ->add('subject', null, $options);
    +                }
    +            });
    +    }
    +
    +    /**
    +     * Configures the options for this type.
    +     *
    +     * @param OptionsResolver $resolver The resolver for the options.
    +     *
    +     * @return void
    +     */
    +    public function configureOptions(OptionsResolver $resolver)
    +    {
    +        $resolver->setDefault('data_class', Notification::class);
    +    }
    +}
    diff --git a/src/UserBundle/Form/OrganizationType.php b/src/UserBundle/Form/OrganizationType.php
    new file mode 100644
    index 0000000..0a90fc2
    --- /dev/null
    +++ b/src/UserBundle/Form/OrganizationType.php
    @@ -0,0 +1,60 @@
    +add('organizationAddress')
    +            ->add('organizationEmail')
    +            ->add('organizationPhone')
    +            ->add('organizationPhone')
    +            ->add('organization', EntityType::class, array('class' => Organization::class))
    +            ->add('billingPlanId', EntityType::class, array('class' => Plan::class, 'property_path' => 'plan'));
    +    }
    +
    +    /**
    +     * Configures the options for this type.
    +     *
    +     * @param OptionsResolver $resolver The resolver for the options.
    +     *
    +     * @return void
    +     */
    +    public function configureOptions(OptionsResolver $resolver)
    +    {
    +        $resolver->setDefaults([
    +            'data_class' => OrganizationSubscription::class,
    +        ]);
    +    }
    +}
    diff --git a/src/UserBundle/Form/PaymentDataType.php b/src/UserBundle/Form/PaymentDataType.php
    new file mode 100644
    index 0000000..cf0e516
    --- /dev/null
    +++ b/src/UserBundle/Form/PaymentDataType.php
    @@ -0,0 +1,169 @@
    +em = $em;
    +    }
    +
    +    /**
    +     * Builds the form.
    +     *
    +     * This method is called for each type in the hierarchy starting from the
    +     * top most type. Type extensions can further modify the form.
    +     *
    +     * @see FormTypeExtensionInterface::buildForm()
    +     *
    +     * @param FormBuilderInterface $builder The form builder.
    +     * @param array                $options The options.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function buildForm(FormBuilderInterface $builder, array $options)
    +    {
    +        $builder
    +            ->add('paymentGateway', ChoiceType::class, [
    +                'choices' => PaymentGatewayEnum::getAvailables(),
    +                'empty_data' => PaymentGatewayEnum::PAYPAL,
    +            ])
    +            ->add('code', null, [
    +                'constraints' => [
    +                    new Constraint\NotBlank(),
    +                    new Constraint\Callback([ $this, 'validateCode' ]),
    +                ],
    +            ])
    +            ->add('card', CreditCardType::class)
    +            ->setDataMapper($this);
    +    }
    +
    +    /**
    +     * Configures the options for this type.
    +     *
    +     * @param OptionsResolver $resolver The resolver for the options.
    +     *
    +     * @return void
    +     */
    +    public function configureOptions(OptionsResolver $resolver)
    +    {
    +        $resolver->setDefaults([
    +            'data_class' => PaymentData::class,
    +            'empty_data' => new PaymentData(PaymentGatewayEnum::paypal()),
    +        ]);
    +    }
    +
    +    /**
    +     * Maps properties of some data to a list of forms.
    +     *
    +     * @param PaymentData|null          $data  Structured data.
    +     * @param FormInterface[]|\Iterator $forms A list of {@link FormInterface}
    +     *                                         instances.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function mapDataToForms($data, $forms)
    +    {
    +        // Do nothing.
    +    }
    +
    +    /**
    +     * Maps the data of a list of forms into the properties of some data.
    +     *
    +     * @param FormInterface[]|\Iterator $forms A list of {@link FormInterface}
    +     *                                         instances.
    +     * @param PaymentData|null          $data  Structured data.
    +     *
    +     * @return void
    +     */
    +    public function mapFormsToData($forms, &$data)
    +    {
    +        $forms = iterator_to_array($forms);
    +        $user = $this->getUserByCode($forms['code']->getData());
    +
    +        try {
    +            $data = new PaymentData(
    +                new PaymentGatewayEnum($forms['paymentGateway']->getData()),
    +                $user,
    +                $forms['card']->getData()
    +            );
    +        } catch (\InvalidArgumentException $e) {
    +            //
    +            // This may occurred 'cause form don't validate values before mapping
    +            // it data to source object.
    +            //
    +            throw new \RuntimeException('Can\'t create payment data', 0, $e);
    +        }
    +    }
    +
    +    /**
    +     * @param mixed                     $code    Validated code.
    +     * @param ExecutionContextInterface $context A ExecutionContextInterface
    +     *                                           instance.
    +     *
    +     * @return void
    +     */
    +    public function validateCode($code, ExecutionContextInterface $context)
    +    {
    +        $user = $this->getUserByCode($code);
    +
    +        if ($user === null) {
    +            $context->buildViolation('Invalid code. Can\'t find user with specified code')
    +                ->addViolation();
    +        }
    +    }
    +
    +    /**
    +     * @param mixed $code User confirmation code.
    +     *
    +     * @return User
    +     */
    +    private function getUserByCode($code)
    +    {
    +        if (! array_key_exists($code, $this->userCache)) {
    +            $this->userCache[$code] = $this->em->getRepository(User::class)
    +                ->findOneBy(['confirmationToken' => $code]);
    +        }
    +
    +        return $this->userCache[$code];
    +    }
    +}
    diff --git a/src/UserBundle/Form/PersonRecipientType.php b/src/UserBundle/Form/PersonRecipientType.php
    new file mode 100644
    index 0000000..954bb85
    --- /dev/null
    +++ b/src/UserBundle/Form/PersonRecipientType.php
    @@ -0,0 +1,104 @@
    +storage = $storage;
    +    }
    +
    +    /**
    +     * Builds the form.
    +     *
    +     * This method is called for each type in the hierarchy starting from the
    +     * top most type. Type extensions can further modify the form.
    +     *
    +     * @see FormTypeExtensionInterface::buildForm()
    +     *
    +     * @param FormBuilderInterface $builder The form builder.
    +     * @param array                $options The options.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function buildForm(FormBuilderInterface $builder, array $options)
    +    {
    +        $user = \app\op\invokeIf($this->storage->getToken(), 'getUser');
    +
    +        $builder
    +            ->add('firstName')
    +            ->add('lastName')
    +            ->add('email', EmailType::class)
    +            ->add('active', CheckboxType::class)
    +            ->add('groups', EntityType::class, [
    +                'class' => GroupRecipient::class,
    +                'multiple' => true,
    +                'by_reference' => false,
    +                'query_builder' => function (GroupRecipientRepository $repository) use ($user) {
    +                    if ($user instanceof User) {
    +                        return $repository->getAvailableForUser($user);
    +                    }
    +
    +                    return $repository->createQueryBuilder('Grp');
    +                },
    +            ])
    +            ->add('notifications', EntityType::class, [
    +                'class' => Notification::class,
    +                'multiple' => true,
    +                'by_reference' => false,
    +                'query_builder' => function (NotificationRepository $repository) use ($user) {
    +                    if ($user instanceof User) {
    +                        return $repository->getQueryBuilderForForm($user);
    +                    }
    +
    +                    return $repository->createQueryBuilder('Notification');
    +                },
    +            ]);
    +    }
    +
    +    /**
    +     * Configures the options for this type.
    +     *
    +     * @param OptionsResolver $resolver The resolver for the options.
    +     *
    +     * @return void
    +     */
    +    public function configureOptions(OptionsResolver $resolver)
    +    {
    +        $resolver->setDefaults([
    +            'data_class' => PersonRecipient::class,
    +        ]);
    +    }
    +}
    diff --git a/src/UserBundle/Form/PlanType.php b/src/UserBundle/Form/PlanType.php
    new file mode 100644
    index 0000000..56e7be7
    --- /dev/null
    +++ b/src/UserBundle/Form/PlanType.php
    @@ -0,0 +1,67 @@
    +add('title', null, [
    +                'constraints' => new NotBlank(),
    +            ])
    +            ->add('price', MoneyType::class, [
    +                'currency' => 'USD',
    +            ])
    +            ->add('searchesPerDay', IntegerType::class, [ 'attr' => [ 'min' => 0 ] ])
    +            ->add('savedFeeds', IntegerType::class, [ 'attr' => [ 'min' => 0 ] ])
    +            ->add('masterAccounts', IntegerType::class, [ 'attr' => [ 'min' => 0 ] ])
    +            ->add('subscriberAccounts', IntegerType::class, [ 'attr' => [ 'min' => 0 ] ])
    +            ->add('alerts', IntegerType::class, [ 'attr' => [ 'min' => 0 ] ])
    +            ->add('newsletters', IntegerType::class, [ 'attr' => [ 'min' => 0 ] ])
    +            ->add('analytics', CheckboxType::class, [ 'required' => false ]);
    +    }
    +
    +    /**
    +     * Configures the options for this type.
    +     *
    +     * @param OptionsResolver $resolver The resolver for the options.
    +     *
    +     * @return void
    +     */
    +    public function configureOptions(OptionsResolver $resolver)
    +    {
    +        $resolver->setDefaults([
    +            'data_class' => Plan::class,
    +        ]);
    +    }
    +}
    diff --git a/src/UserBundle/Form/RegistrationType.php b/src/UserBundle/Form/RegistrationType.php
    new file mode 100644
    index 0000000..a35bf82
    --- /dev/null
    +++ b/src/UserBundle/Form/RegistrationType.php
    @@ -0,0 +1,392 @@
    +em = $em;
    +    }
    +
    +    /**
    +     * Builds the form.
    +     *
    +     * This method is called for each type in the hierarchy starting from the
    +     * top most type. Type extensions can further modify the form.
    +     *
    +     * @see FormTypeExtensionInterface::buildForm()
    +     *
    +     * @param FormBuilderInterface $builder The form builder.
    +     * @param array                $options The options.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function buildForm(FormBuilderInterface $builder, array $options)
    +    {
    +        $builder
    +            ->add('email', EmailType::class, [
    +                'constraints' => [
    +                    new Regex([
    +                        'pattern' => '/^[a-zA-Z0-9!#$%&\'*+\/=?^_` {|}~;."-]+@[a-zA-Z0-9!#$%&\'*+\/=?^_` {|}~;."-]+\.[a-zA-Z0-9]+$/',
    +                        'message' => 'This value is not a valid email address',
    +                    ]),
    +                    new Length([
    +                        'max' => 160,
    +                        'maxMessage' => 'Email address is too long. It should have {{ limit }} character or less',
    +                    ]),
    +                    new NotBlank([
    +                        'message' => 'Email address should not be blank',
    +                    ]),
    +                ],
    +            ])
    +            ->add('firstName')
    +            ->add('lastName')
    +            ->add('companyName')
    +            ->add('jobFunction')
    +            ->add('industry')
    +            ->add('websiteUrl')
    +            ->add('password',PasswordType::class)
    +            ->add('numberOfEmployee');
    +
    +        if (!empty($options['paymentID'])) {
    +            //->add('phoneNumber')
    +           $builder->add('searchesPerDay', TextType::class, [
    +                'constraints' => [
    +                    new NotBlank([
    +                        'message' => 'Searches Per Day should not be blank',
    +                    ]),
    +                ],
    +            ])
    +            ->add('savedFeeds', TextType::class, [
    +                'constraints' => [
    +                    new NotBlank([
    +                        'message' => 'Saved Feeds should not be blank',
    +                    ]),
    +                ],
    +            ])
    +            ->add('masterAccounts', TextType::class, [
    +                'constraints' => [
    +                    new NotBlank([
    +                        'message' => 'Master Accounts should not be blank',
    +                    ]),
    +                ],
    +            ])
    +            ->add('subscriberAccounts', TextType::class, [
    +                'constraints' => [
    +                    new NotBlank([
    +                        'message' => 'Subscriber Accounts should not be blank',
    +                    ]),
    +                ],
    +            ])
    +            ->add('webFeeds', TextType::class, [
    +                'constraints' => [
    +                    new NotBlank([
    +                        'message' => 'Web Feeds should not be blank',
    +                    ]),
    +                ],
    +            ])
    +            ->add('alerts', TextType::class, [
    +                'constraints' => [
    +                    new NotBlank([
    +                        'message' => 'Alerts should not be blank',
    +                    ]),
    +                ],
    +            ])
    +            ->add('news', TextType::class, [
    +                'constraints' => [
    +                    new NotBlank([
    +                        'message' => 'News should not be blank',
    +                    ]),
    +                ],
    +            ])
    +            ->add('blog', TextType::class, [
    +                'constraints' => [
    +                    new NotBlank([
    +                        'message' => 'Blog should not be blank',
    +                    ]),
    +                ],
    +            ])
    +            ->add('reddit', TextType::class, [
    +                'constraints' => [
    +                    new NotBlank([
    +                        'message' => 'Reddit should not be blank',
    +                    ]),
    +                ],
    +            ])
    +            ->add('instagram', TextType::class, [
    +                'constraints' => [
    +                    new NotBlank([
    +                        'message' => 'Instagram should not be blank',
    +                    ]),
    +                ],
    +            ])
    +            ->add('twitter', TextType::class, [
    +                'constraints' => [
    +                    new NotBlank([
    +                        'message' => 'Twitter should not be blank',
    +                    ]),
    +                ],
    +            ])
    +            ->add('analytics', TextType::class, [
    +                'constraints' => [
    +                    new NotBlank([
    +                        'message' => 'Analytics should not be blank',
    +                    ]),
    +                ],
    +            ])
    +            ->add('paymentID');
    +            //->add('billingPlanId', EntityType::class, [ 'class' => Plan::class ])
    +            // ->add('privatePerson', CheckboxType::class, [ 'mapped' => false ])
    +            // ->add('organizationName', null, [ 'description' => 'Used only for organization subscription.' ])
    +            // ->add('organizationAddress', null, [ 'description' => 'Used only for organization subscription.' ])
    +            // ->add('organizationEmail', null, [ 'description' => 'Used only for organization subscription.' ])
    +            // ->add('organizationPhone', null, [ 'description' => 'Used only for organization subscription.' ])
    +            // ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
    +            //     $data = $event->getData();
    +            //     $form = $event->getForm();
    +            //     if (isset($data['privatePerson']) && $data['privatePerson']) {
    +            //         $form
    +            //             ->remove('organizationName')
    +            //             ->remove('organizationAddress')
    +            //             ->remove('organizationEmail')
    +            //             ->remove('organizationPhone');
    +            //     }
    +            // })
    +        }  
    +
    +        $builder->setDataMapper($this);
    +    }
    +
    +    /**
    +     * Configures the options for this type.
    +     *
    +     * @param OptionsResolver $resolver The resolver for the options.
    +     *
    +     * @return void
    +     */
    +    public function configureOptions(OptionsResolver $resolver)
    +    {
    +        $resolver->setDefaults([
    +            'data_class' => User::class,
    +            'paymentID' => null
    +        ]);
    +    }
    +
    +    /**
    +     * Maps properties of some data to a list of forms.
    +     *
    +     * @param User|null                                  $data  Structured data.
    +     * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of
    +     *                                                          {@link FormInterface}
    +     *                                                          instances.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function mapDataToForms($data, $forms)
    +    {
    +        // Do nothing because it's not necessary method.
    +    }
    +
    +    /**
    +     * Maps the data of a list of forms into the properties of some data.
    +     *
    +     * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of
    +     *                                                          {@link FormInterface}
    +     *                                                          instances.
    +     * @param User|null                                  $data  Structured data.
    +     *
    +     * @return void
    +     */
    +    public function mapFormsToData($forms, &$data)
    +    {
    +        $forms = iterator_to_array($forms);
    +        // TODO for now it's ok, but used gateway should be selected by users.
    +        $gateway = PaymentGatewayEnum::paypal();
    +        //Add plan
    +        if (isset(
    +            $forms['news'],
    +            $forms['blog'],
    +            $forms['reddit'],
    +            $forms['instagram'],
    +            $forms['twitter'],
    +            $forms['analytics'],
    +            $forms['searchesPerDay'],
    +            $forms['savedFeeds'],
    +            $forms['masterAccounts'],
    +            $forms['subscriberAccounts'],
    +            $forms['webFeeds'],
    +            $forms['alerts'],
    +            $forms['paymentID']
    +            ) && 
    +            ($forms['searchesPerDay']->getData() >= 0) &&
    +            ($forms['savedFeeds']->getData() >= 0) &&
    +            ($forms['masterAccounts']->getData() >= 0) &&
    +            ($forms['subscriberAccounts']->getData() >= 0) &&
    +            ($forms['webFeeds']->getData() >= 0) &&
    +            ($forms['alerts']->getData() >= 0)
    +            ) {
    +                $plan = Plan::create()
    +                    ->setTitle($forms['companyName']->getData())
    +                    ->setInnerName('Starter')
    +                    ->setPrice(0)
    +                    ->setNews($forms['news']->getData())
    +                    ->setBlog($forms['blog']->getData())
    +                    ->setReddit($forms['reddit']->getData())
    +                    ->setInstagram($forms['instagram']->getData())
    +                    ->setTwitter($forms['twitter']->getData())
    +                    ->setAnalytics($forms['analytics']->getData())
    +                    ->setSearchesPerDay($forms['searchesPerDay']->getData())
    +                    ->setSavedFeeds($forms['savedFeeds']->getData())
    +                    ->setMasterAccounts($forms['masterAccounts']->getData())
    +                    ->setSubscriberAccounts($forms['subscriberAccounts']->getData())
    +                    ->setWebFeeds($forms['webFeeds']->getData())
    +                    ->setAlerts($forms['alerts']->getData());                    
    +                $this->em->persist($plan);  
    +
    +                $subscription = OrganizationSubscription::create()
    +                ->setPlan($plan)
    +                ->setGateway($gateway)
    +                ->addUser($data)
    +                ->setOwner($data);
    +                
    +                //Category add default
    +                $category = new Category($data, 'My Hose');
    +                $category->setType(Category::TYPE_MY_CONTENT);
    +                $category = new Category($data, 'Shared Hose');
    +                $category->setType(Category::TYPE_SHARED_CONTENT);
    +                $category = new Category($data, 'Deleted Hose');
    +                $category->setType(Category::TYPE_DELETED_CONTENT);     
    +        } else if (!isset($forms['paymentID'])) {
    +            $gateway = PaymentGatewayEnum::free();
    +            //Get a free plan
    +            $repository = $this->em->getRepository(Plan::class);
    +            $planObj = $repository->findOneBy([ 'title' => 'Free' ]);
    +            $plan = Plan::create()
    +                    ->setTitle($forms['companyName']->getData())
    +                    ->setInnerName('Starter')
    +                    ->setPrice(0)
    +                    ->setNews($planObj->isNews())
    +                    ->setBlog($planObj->isBlog())
    +                    ->setReddit($planObj->isReddit())
    +                    ->setInstagram($planObj->isInstagram())
    +                    ->setTwitter($planObj->isTwitter())
    +                    ->setAnalytics($planObj->isAnalytics())
    +                    ->setSearchesPerDay($planObj->getSearchesPerDay())
    +                    ->setSavedFeeds($planObj->getSavedFeeds())
    +                    ->setMasterAccounts($planObj->getMasterAccounts())
    +                    ->setSubscriberAccounts($planObj->getSubscriberAccounts())
    +                    ->setAlerts($planObj->getAlerts())
    +                    ->setNewsLetters($planObj->getNewsLetters())
    +                    ->setWebFeeds($planObj->getWebFeeds())
    +                    ->setAlerts($planObj->getAlerts());
    +            $this->em->persist($plan);
    +
    +            $subscription = OrganizationSubscription::create()
    +            ->setPlan($plan)
    +            ->setGateway($gateway)
    +            ->addUser($data)
    +            ->setOwner($data);
    +            
    +            //Category add default
    +            $category = new Category($data, 'My Hose');
    +            $category->setType(Category::TYPE_MY_CONTENT);
    +            $category = new Category($data, 'Shared Hose');
    +            $category->setType(Category::TYPE_SHARED_CONTENT);
    +            $category = new Category($data, 'Deleted Hose');
    +            $category->setType(Category::TYPE_DELETED_CONTENT); 
    +
    +        }
    +        // if (isset(
    +        //     $forms['organizationName'],
    +        //     $forms['organizationAddress'],
    +        //     $forms['organizationEmail'],
    +        //     $forms['organizationPhone']
    +        //     )) {
    +        //         //
    +        //         // Try to find already exists organization.
    +        //         //
    +        //         $orgName = $forms['organizationName']->getData();
    +                
    +        //         /** @var OrganizationRepository $repository */
    +        //         $repository = $this->em->getRepository(Organization::class);
    +        //         $organization = $repository->findOneBy([ 'name' => $orgName ]);
    +                
    +        //         if (! $organization instanceof Organization) {
    +        //             $organization = Organization::create()->setName($orgName);
    +        //             $data->setRoles([ UserRoleEnum::MASTER_USER ]);
    +        //             $this->em->persist($organization);
    +        //         } else {
    +        //             $data->setRoles([ UserRoleEnum::SUBSCRIBER ]);
    +        //         }
    +                
    +        //         $subscription = OrganizationSubscription::create()
    +        //             ->setOrganization($organization)
    +        //             ->setOrganizationAddress($forms['organizationAddress']->getData())
    +        //             ->setOrganizationEmail($forms['organizationEmail']->getData())
    +        //             ->setOrganizationPhone($forms['organizationPhone']->getData());
    +        //     } 
    +            // else {
    +            //     $subscription = PersonalSubscription::create();
    +            //     $data->setRoles([ UserRoleEnum::MASTER_USER ]);
    +            // }
    +            
    +           
    +
    +            $data->setRoles([ UserRoleEnum::MASTER_USER ]);
    +            $data->setEmail($forms['email']->getData());
    +            $data->setFirstName($forms['firstName']->getData());
    +            $data->setIndustry($forms['industry']->getData());
    +            $data->setLastName($forms['lastName']->getData());
    +            $data->setCompanyName($forms['companyName']->getData());
    +            $data->setJobFunction($forms['jobFunction']->getData());
    +            $data->setWebsiteUrl($forms['websiteUrl']->getData());
    +            $data->setNumberOfEmployee($forms['numberOfEmployee']->getData());
    +            
    +    }
    +}
    diff --git a/src/UserBundle/Form/ResetPasswordType.php b/src/UserBundle/Form/ResetPasswordType.php
    new file mode 100644
    index 0000000..9bcb9a2
    --- /dev/null
    +++ b/src/UserBundle/Form/ResetPasswordType.php
    @@ -0,0 +1,43 @@
    +add('email', EmailType::class, [
    +                'constraints' => [
    +                    new NotBlank(),
    +                    new Email(),
    +                ],
    +            ]);
    +    }
    +}
    diff --git a/src/UserBundle/Form/ResettingConfirmType.php b/src/UserBundle/Form/ResettingConfirmType.php
    new file mode 100644
    index 0000000..edd73ad
    --- /dev/null
    +++ b/src/UserBundle/Form/ResettingConfirmType.php
    @@ -0,0 +1,41 @@
    +add('confirmationToken', null, [ 'constraints' => new NotBlank() ])
    +            ->add('password', null, [ 'constraints' => new NotBlank() ]);
    +    }
    +}
    diff --git a/src/UserBundle/Form/ResettingRequestType.php b/src/UserBundle/Form/ResettingRequestType.php
    new file mode 100644
    index 0000000..7385de5
    --- /dev/null
    +++ b/src/UserBundle/Form/ResettingRequestType.php
    @@ -0,0 +1,42 @@
    +add('email', EmailType::class, [
    +            'constraints' => [
    +                new NotBlank(),
    +                new Email(),
    +            ],
    +        ]);
    +    }
    +}
    diff --git a/src/UserBundle/Form/SubscriberType.php b/src/UserBundle/Form/SubscriberType.php
    new file mode 100644
    index 0000000..4e0728c
    --- /dev/null
    +++ b/src/UserBundle/Form/SubscriberType.php
    @@ -0,0 +1,58 @@
    +add('email', EmailType::class)
    +            ->add('firstName')
    +            ->add('lastName')
    +            ->add('position')
    +            ->add('phoneNumber')
    +            ->add('allowToCreateSavedFeeds');
    +    }
    +
    +    /**
    +     * Configures the options for this type.
    +     *
    +     * @param OptionsResolver $resolver The resolver for the options.
    +     *
    +     * @return void
    +     */
    +    public function configureOptions(OptionsResolver $resolver)
    +    {
    +        $resolver->setDefaults([
    +            'data_class' => User::class,
    +            'validation_groups' => [ 'subscribers_creation' ],
    +        ]);
    +    }
    +}
    diff --git a/src/UserBundle/Form/Type/ColorType.php b/src/UserBundle/Form/Type/ColorType.php
    new file mode 100644
    index 0000000..30366c0
    --- /dev/null
    +++ b/src/UserBundle/Form/Type/ColorType.php
    @@ -0,0 +1,106 @@
    +setDefault('constraints', new Callback([ $this, 'validate' ]));
    +    }
    +
    +    /**
    +     * Returns the name of the parent type.
    +     *
    +     * @return string|null The name of the parent type if any, null otherwise.
    +     */
    +    public function getParent()
    +    {
    +        return TextType::class;
    +    }
    +
    +    /**
    +     * Validate sources.
    +     *
    +     * @param mixed                     $color   Color.
    +     * @param ExecutionContextInterface $context A ExecutionContextInterface
    +     *                                           instance.
    +     *
    +     * @return void
    +     */
    +    public function validate($color, ExecutionContextInterface $context)
    +    {
    +        if ($color === null) {
    +            // Do not validate null values.
    +            return;
    +        }
    +
    +        $matches = [];
    +
    +        if (is_string($color) && preg_match('/rgba\(([0-9%.,\s]+)\)/', $color, $matches)) {
    +            $arguments = array_map('trim', explode(',', $matches[1]));
    +
    +            if (count($arguments) === 4) {
    +                $alpha = array_pop($arguments);
    +
    +                //
    +                // Validate color components.
    +                //
    +                if (is_numeric($alpha) && $this->containsColorDigits($arguments)) {
    +                    //
    +                    // Validate alpha component.
    +                    //
    +
    +                    $alpha = (float) $alpha;
    +
    +                    if (($alpha >= 0.0) && ($alpha <= 1.0)) {
    +                        return;
    +                    }
    +                }
    +            }
    +        }
    +
    +        // It's not valid 'rgba' color.
    +        $context
    +            ->buildViolation('Color should be valid css color definition string')
    +            ->addViolation();
    +    }
    +
    +    /**
    +     * @param array $array Checked array.
    +     *
    +     * @return boolean
    +     */
    +    private function containsColorDigits(array $array)
    +    {
    +//        return \Functional\every($array, function ($item) {
    +        return \nspl\a\all($array, function ($item) {
    +            if (! is_numeric($item)) {
    +                return false;
    +            }
    +
    +            $item = (int) $item;
    +
    +            return ($item >= 0) && ($item <= 255);
    +        });
    +    }
    +}
    diff --git a/src/UserBundle/Form/Type/CreditCardAddressType.php b/src/UserBundle/Form/Type/CreditCardAddressType.php
    new file mode 100644
    index 0000000..52db8c0
    --- /dev/null
    +++ b/src/UserBundle/Form/Type/CreditCardAddressType.php
    @@ -0,0 +1,90 @@
    +add('country', CountryType::class, [
    +                'constraints' => new NotBlank(),
    +            ])
    +            ->add('city', null, [ 'constraints' => new NotBlank() ])
    +            ->add('street', null, [ 'constraints' => new NotBlank() ])
    +            ->add('postalCode', null, [ 'constraints' => new NotBlank() ])
    +            ->setDataMapper($this);
    +    }
    +
    +    /**
    +     * Maps properties of some data to a list of forms.
    +     *
    +     * @param CreditCardAddress|null    $data  Structured data.
    +     * @param FormInterface[]|\Iterator $forms A list of {@link FormInterface}
    +     *                                         instances.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function mapDataToForms($data, $forms)
    +    {
    +        if ($data !== null) {
    +            $forms = iterator_to_array($forms);
    +            $forms['country']->setData($data->getCountry());
    +            $forms['city']->setData($data->getCity());
    +            $forms['street']->setData($data->getStreet());
    +            $forms['postalCode']->setData($data->getPostalCode());
    +        }
    +    }
    +
    +    /**
    +     * Maps the data of a list of forms into the properties of some data.
    +     *
    +     * @param FormInterface[]|\Iterator $forms A list of {@link FormInterface}
    +     *                                         instances.
    +     * @param CreditCardAddress|null    $data  Structured data.
    +     *
    +     * @return void
    +     */
    +    public function mapFormsToData($forms, &$data)
    +    {
    +        $forms = iterator_to_array($forms);
    +
    +        $data = new CreditCardAddress(
    +            $forms['country']->getData(),
    +            $forms['city']->getData(),
    +            $forms['street']->getData(),
    +            $forms['postalCode']->getData()
    +        );
    +    }
    +}
    diff --git a/src/UserBundle/Form/Type/CreditCardType.php b/src/UserBundle/Form/Type/CreditCardType.php
    new file mode 100644
    index 0000000..84db5ed
    --- /dev/null
    +++ b/src/UserBundle/Form/Type/CreditCardType.php
    @@ -0,0 +1,168 @@
    +add('creditCardNumber', null, [
    +                'constraints' => [
    +                    new Constraint\Luhn(),
    +                    new Constraint\CardScheme([ 'schemes' => [ 'VISA', 'MASTERCARD', 'AMEX' ] ]),
    +                ],
    +            ])
    +            ->add('CVV', null, [
    +                'constraints' => [
    +                    new Constraint\Length([
    +                        'min' => 3,
    +                        'max' => 4,
    +                        'minMessage' => 'Card Verification Code is too short. It should have 3 or 4 characters.',
    +                        'maxMessage' => 'Card Verification Code is too long. It should have 3 or 4 characters.',
    +                    ]),
    +                    new Constraint\Type([ 'type' => 'numeric' ]),
    +                ],
    +            ])
    +            ->add('expireMonth', ChoiceType::class, [
    +                'choices' => self::$availableMonth,
    +                'constraints' => new Constraint\NotBlank(),
    +            ])
    +            ->add('expireYear', ChoiceType::class, [
    +                'choices' => range($currentYear, $currentYear + 10),
    +                'constraints' => new Constraint\NotBlank(),
    +            ])
    +            ->add('address', CreditCardAddressType::class)
    +            ->setDataMapper($this);
    +    }
    +
    +    /**
    +     * Configures the options for this type.
    +     *
    +     * @param OptionsResolver $resolver The resolver for the options.
    +     *
    +     * @return void
    +     */
    +    public function configureOptions(OptionsResolver $resolver)
    +    {
    +        $resolver->setDefaults([
    +            'constraints' => new Constraint\Callback([ $this, 'validate' ]),
    +        ]);
    +    }
    +
    +    /**
    +     * Maps properties of some data to a list of forms.
    +     *
    +     * @param CreditCard|null           $data  Structured data.
    +     * @param FormInterface[]|\Iterator $forms A list of {@link FormInterface}
    +     *                                         instances.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function mapDataToForms($data, $forms)
    +    {
    +        if ($data !== null) {
    +            $forms = iterator_to_array($forms);
    +            $forms['creditCardNumber']->setData($data->getNumber());
    +            $forms['CVV']->setData($data->getCvv());
    +            $forms['expireMonth']->setData($data->getExpiresAt()->format('m'));
    +            $forms['expireYear']->setData($data->getExpiresAt()->format('Y'));
    +            $forms['address']->setData($data->getAddress());
    +        }
    +    }
    +
    +    /**
    +     * Maps the data of a list of forms into the properties of some data.
    +     *
    +     * @param FormInterface[]|\Iterator $forms A list of {@link FormInterface}
    +     *                                         instances.
    +     * @param CreditCard|null           $data  Structured data.
    +     *
    +     * @return void
    +     */
    +    public function mapFormsToData($forms, &$data)
    +    {
    +        $forms = iterator_to_array($forms);
    +
    +        $expiresAt = \DateTime::createFromFormat(
    +            'Y-m-d',
    +            $forms['expireYear']->getData(). '-'. $forms['expireMonth']->getData() .'-01'
    +        )->setTime(0, 0);
    +
    +        $data = new CreditCard(
    +            'First',
    +            'Second',
    +            $forms['creditCardNumber']->getData(),
    +            $forms['CVV']->getData(),
    +            $expiresAt,
    +            $forms['address']->getData()
    +        );
    +    }
    +
    +    /**
    +     * @param CreditCard|mixed          $data    Validated payment data.
    +     * @param ExecutionContextInterface $context A ExecutionContextInterface
    +     *                                           instance.
    +     *
    +     * @return void
    +     */
    +    public function validate($data, ExecutionContextInterface $context)
    +    {
    +        if (($data instanceof CreditCard)
    +            && ($data->getExpiresAt() < date_create('first day of this month 00:00:00'))) {
    +            $context->buildViolation('Card has already expired')
    +                ->atPath('expireMonth')
    +                ->addViolation();
    +        }
    +    }
    +}
    diff --git a/src/UserBundle/Form/Type/NotificationDiffType.php b/src/UserBundle/Form/Type/NotificationDiffType.php
    new file mode 100644
    index 0000000..17b9807
    --- /dev/null
    +++ b/src/UserBundle/Form/Type/NotificationDiffType.php
    @@ -0,0 +1,490 @@
    + [
    +            'class' => TextType::class,
    +            'options' => [
    +                'description' => 'Summary text at top of notification.',
    +            ],
    +        ],
    +        'conclusion' => [
    +            'class' => TextType::class,
    +            'options' => [
    +                'description' => 'Notification conclusion text.',
    +            ],
    +        ],
    +
    +        'header:imageUrl' => [
    +            'class' => TextType::class,
    +            'options' => [
    +                'description' => 'Path to notification logo image.',
    +            ],
    +        ],
    +        'header:logoLink' => [
    +            'class' => TextType::class,
    +            'options' => [
    +                'description' => 'Logo href.',
    +            ],
    +        ],
    +        'header:title' => [
    +            'class' => TextType::class,
    +            'options' => [
    +                'description' => 'Notification title. Enhanced only',
    +            ],
    +        ],
    +
    +        'fonts:header:size' => [
    +            'class' => NumberType::class,
    +            'options' => [
    +                'description' => 'Header font size.',
    +            ],
    +        ],
    +        'fonts:header:family' => [
    +            'class' => EnumType::class,
    +            'options' => [
    +                'enum_class' => FontFamilyEnum::class,
    +                'description' => 'Header font family.',
    +            ],
    +        ],
    +        'fonts:header:style:bold' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should header text bold or not.',
    +            ],
    +        ],
    +        'fonts:header:style:italic' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should header text italic or not.',
    +            ],
    +        ],
    +        'fonts:header:style:underline' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should header text underlined or not.',
    +            ],
    +        ],
    +        'fonts:tableOfContents:size' => [
    +            'class' => NumberType::class,
    +            'options' => [
    +                'description' => 'Table of contents font size.',
    +            ],
    +        ],
    +        'fonts:tableOfContents:family' => [
    +            'class' => EnumType::class,
    +            'options' => [
    +                'enum_class' => FontFamilyEnum::class,
    +                'description' => 'Table of contents font family.',
    +            ],
    +        ],
    +        'fonts:tableOfContents:style:bold' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should table of contents text bold or not.',
    +            ],
    +        ],
    +        'fonts:tableOfContents:style:italic' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should table of contents text italic or not.',
    +            ],
    +        ],
    +        'fonts:tableOfContents:style:underline' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should table of contents text underlined or not.',
    +            ],
    +        ],
    +        'fonts:feedTitle:size' => [
    +            'class' => NumberType::class,
    +            'options' => [
    +                'description' => 'Feed title font size.',
    +            ],
    +        ],
    +        'fonts:feedTitle:family' => [
    +            'class' => EnumType::class,
    +            'options' => [
    +                'enum_class' => FontFamilyEnum::class,
    +                'description' => 'Feed title font family.',
    +            ],
    +        ],
    +        'fonts:feedTitle:style:bold' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should feed title text bold or not.',
    +            ],
    +        ],
    +        'fonts:feedTitle:style:italic' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should feed text text italic or not.',
    +            ],
    +        ],
    +        'fonts:feedTitle:style:underline' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should feed title text underlined or not.',
    +            ],
    +        ],
    +        'fonts:articleHeadline:size' => [
    +            'class' => NumberType::class,
    +            'options' => [
    +                'description' => 'Article headline font size.',
    +            ],
    +        ],
    +        'fonts:articleHeadline:family' => [
    +            'class' => EnumType::class,
    +            'options' => [
    +                'enum_class' => FontFamilyEnum::class,
    +                'description' => 'Article headline font family.',
    +            ],
    +        ],
    +        'fonts:articleHeadline:style:bold' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should article headline text bold or not.',
    +            ],
    +        ],
    +        'fonts:articleHeadline:style:italic' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should article headline text italic or not.',
    +            ],
    +        ],
    +        'fonts:articleHeadline:style:underline' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should article headline text underlined or not.',
    +            ],
    +        ],
    +        'fonts:source:size' => [
    +            'class' => NumberType::class,
    +            'options' => [
    +                'description' => 'Source font size.',
    +            ],
    +        ],
    +        'fonts:source:family' => [
    +            'class' => EnumType::class,
    +            'options' => [
    +                'enum_class' => FontFamilyEnum::class,
    +                'description' => 'Source font family.',
    +            ],
    +        ],
    +        'fonts:source:style:bold' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should source text bold or not.',
    +            ],
    +        ],
    +        'fonts:source:style:italic' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should source text italic or not.',
    +            ],
    +        ],
    +        'fonts:source:style:underline' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should source text underlined or not.',
    +            ],
    +        ],
    +        'fonts:author:size' => [
    +            'class' => NumberType::class,
    +            'options' => [
    +                'description' => 'Author font size.',
    +            ],
    +        ],
    +        'fonts:author:family' => [
    +            'class' => EnumType::class,
    +            'options' => [
    +                'enum_class' => FontFamilyEnum::class,
    +                'description' => 'Author font family.',
    +            ],
    +        ],
    +        'fonts:author:style:bold' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should author text bold or not.',
    +            ],
    +        ],
    +        'fonts:author:style:italic' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should author text italic or not.',
    +            ],
    +        ],
    +        'fonts:author:style:underline' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should author text underlined or not.',
    +            ],
    +        ],
    +        'fonts:date:size' => [
    +            'class' => NumberType::class,
    +            'options' => [
    +                'description' => 'Date font size.',
    +            ],
    +        ],
    +        'fonts:date:family' => [
    +            'class' => EnumType::class,
    +            'options' => [
    +                'enum_class' => FontFamilyEnum::class,
    +                'description' => 'Date font family.',
    +            ],
    +        ],
    +        'fonts:date:style:bold' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should date text bold or not.',
    +            ],
    +        ],
    +        'fonts:date:style:italic' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should date text italic or not.',
    +            ],
    +        ],
    +        'fonts:date:style:underline' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should date text underlined or not.',
    +            ],
    +        ],
    +        'fonts:articleContent:size' => [
    +            'class' => NumberType::class,
    +            'options' => [
    +                'description' => 'Article content font size.',
    +            ],
    +        ],
    +        'fonts:articleContent:family' => [
    +            'class' => EnumType::class,
    +            'options' => [
    +                'enum_class' => FontFamilyEnum::class,
    +                'description' => 'Article content font family.',
    +            ],
    +        ],
    +        'fonts:articleContent:style:bold' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should article content text bold or not.',
    +            ],
    +        ],
    +        'fonts:articleContent:style:italic' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should article content text italic or not.',
    +            ],
    +        ],
    +        'fonts:articleContent:style:underline' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should article content text underlined or not.',
    +            ],
    +        ],
    +
    +        'content:highlightKeywords:highlight' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should highlights search keywords or not',
    +            ],
    +        ],
    +        'content:highlightKeywords:bold' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Should highlighted search keywords bold or not.',
    +            ],
    +        ],
    +        'content:highlightKeywords:color' => [
    +            'class' => ColorType::class,
    +            'options' => [
    +                'description' => 'Highlight color.',
    +            ],
    +        ],
    +        'content:showInfo:userComments' => [
    +            'class' => EnumType::class,
    +            'options' => [
    +                'enum_class' => ThemeOptionsUserCommentsEnum::class,
    +                'description' => 'How user comments should shown.',
    +            ],
    +        ],
    +        'content:showInfo:tableOfContents' => [
    +            'class' => EnumType::class,
    +            'options' => [
    +                'enum_class' => ThemeOptionsTableOfContentsEnum::class,
    +                'description' => 'How table of contents should shown.',
    +            ],
    +        ],
    +        'content:showInfo:sourceCountry' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Show source country or not.',
    +            ],
    +        ],
    +        'content:showInfo:articleSentiment' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Show article sentiment or not.',
    +            ],
    +        ],
    +        'content:showInfo:articleCount' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Show article count or not.',
    +            ],
    +        ],
    +        'content:showInfo:images' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Show images or not.',
    +            ],
    +        ],
    +        'content:showInfo:sharingOptions' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Show section divider or not.',
    +            ],
    +        ],
    +        'content:showInfo:sectionDivider' => [
    +            'class' => CheckboxType::class,
    +            'options' => [
    +                'description' => 'Show section divider or not.',
    +            ],
    +        ],
    +        'content:language' => [
    +            'class' => TextType::class,
    +            'options' => [
    +                'description' => 'Notification language.',
    +            ],
    +        ],
    +        'content:extract' => [
    +            'class' => EnumType::class,
    +            'options' => [
    +                'enum_class' => ThemeOptionExtractEnum::class,
    +                'description' => 'How article content should be extracted',
    +            ],
    +        ],
    +
    +        'colors:background:header' => [
    +            'class' => ColorType::class,
    +            'options' => [
    +                'description' => 'Header background color.',
    +            ],
    +        ],
    +        'colors:background:emailBody' => [
    +            'class' => ColorType::class,
    +            'options' => [
    +                'description' => 'Email body background color.',
    +            ],
    +        ],
    +        'colors:background:accent' => [
    +            'class' => ColorType::class,
    +            'options' => [
    +                'description' => 'Accent background color.',
    +            ],
    +        ],
    +
    +        'colors:text:header' => [
    +            'class' => ColorType::class,
    +            'options' => [
    +                'description' => 'Header text color.',
    +            ],
    +        ],
    +        'colors:text:articleHeadline' => [
    +            'class' => ColorType::class,
    +            'options' => [
    +                'description' => 'Article headline text color.',
    +            ],
    +        ],
    +        'colors:text:articleContent' => [
    +            'class' => ColorType::class,
    +            'options' => [
    +                'description' => 'Article content text color.',
    +            ],
    +        ],
    +        'colors:text:author' => [
    +            'class' => ColorType::class,
    +            'options' => [
    +                'description' => 'Author text color.',
    +            ],
    +        ],
    +        'colors:text:publishDate' => [
    +            'class' => ColorType::class,
    +            'options' => [
    +                'description' => 'Publish date text color.',
    +            ],
    +        ],
    +        'colors:text:source' => [
    +            'class' => ColorType::class,
    +            'options' => [
    +                'description' => 'Source text color.',
    +            ],
    +        ],
    +    ];
    +
    +    /**
    +     * Builds the form.
    +     *
    +     * This method is called for each type in the hierarchy starting from the
    +     * top most type. Type extensions can further modify the form.
    +     *
    +     * @see FormTypeExtensionInterface::buildForm()
    +     *
    +     * @param FormBuilderInterface $builder The form builder.
    +     * @param array                $options The options.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function buildForm(FormBuilderInterface $builder, array $options)
    +    {
    +        foreach (self::$typesMap as $name => $config) {
    +            $class = $config['class'];
    +            $typeOptions = array_merge($config['options'], [ 'required' => false ]);
    +
    +            $builder->add($name, $class, $typeOptions);
    +        }
    +
    +        $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
    +            //
    +            // Remove not form field which don't submit.
    +            //
    +            $data = $event->getData();
    +            $form = $event->getForm();
    +
    +            $availableDiffs = array_keys(self::$typesMap);
    +            $submittedDiffs = array_keys(($data === null) ? [] : $data);
    +
    +            $notProvidedDiffs = array_diff($availableDiffs, $submittedDiffs);
    +
    +            foreach ($notProvidedDiffs as $name) {
    +                $form->remove($name);
    +            }
    +        });
    +    }
    +}
    diff --git a/src/UserBundle/Form/Type/NotificationSourceType.php b/src/UserBundle/Form/Type/NotificationSourceType.php
    new file mode 100644
    index 0000000..31736d9
    --- /dev/null
    +++ b/src/UserBundle/Form/Type/NotificationSourceType.php
    @@ -0,0 +1,111 @@
    + AbstractFeed::class,
    +    ];
    +
    +    /**
    +     * Builds the form.
    +     *
    +     * This method is called for each type in the hierarchy starting from the
    +     * top most type. Type extensions can further modify the form.
    +     *
    +     * @see FormTypeExtensionInterface::buildForm()
    +     *
    +     * @param FormBuilderInterface $builder The form builder.
    +     * @param array                $options The options.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function buildForm(FormBuilderInterface $builder, array $options)
    +    {
    +        $builder
    +            ->add('type', ChoiceType::class, [
    +                'choices' => array_keys(self::$types),
    +                'constraints' => new NotBlank(),
    +            ])
    +            ->add('id', null, [
    +                'constraints' => [
    +                    new NotBlank(),
    +                    new GreaterThan([ 'value' => 0 ]),
    +                ],
    +            ])
    +            ->addModelTransformer($this);
    +    }
    +
    +    /**
    +     * Transforms a value from the transformed representation to its original
    +     * representation.
    +     *
    +     * This method is called when {@link Form::submit()} is called to transform
    +     * the requests tainted data into an acceptable format for your data
    +     * processing/model layer.
    +     *
    +     * This method must be able to deal with empty values. Usually this will
    +     * be an empty string, but depending on your implementation other empty
    +     * values are possible as well (such as NULL). The reasoning behind
    +     * this is that value transformers must be chainable. If the
    +     * reverseTransform() method of the first value transformer outputs an
    +     * empty string, the second value transformer must be able to process that
    +     * value.
    +     *
    +     * By convention, reverseTransform() should return NULL if an empty string
    +     * is passed.
    +     *
    +     * @param mixed $data The value in the transformed representation.
    +     *
    +     * @return mixed The value in the original representation
    +     *
    +     * @throws TransformationFailedException When the transformation fails.
    +     */
    +    public function reverseTransform($data)
    +    {
    +        //
    +        // Unfortunately we can't use here 'getPartialReference' or
    +        // 'getReference' methods for creating partial entity or proxy.
    +        //
    +        // * getPartialReference method is trying to instantiate specified
    +        // entity but for feeds we use base abstract class.
    +        //
    +        // getReference perform query to database for AbstractFeed entity.
    +        // https://github.com/doctrine/doctrine2/blob/v2.5.6/lib/Doctrine/ORM/EntityManager.php#L493
    +        //
    +        // So we just replace 'type' by entity class.
    +        //
    +        if (! isset($data['type'], self::$types[$data['type']])) {
    +            return null;
    +        }
    +
    +        $data['type'] = self::$types[$data['type']];
    +
    +        return $data;
    +    }
    +}
    diff --git a/src/UserBundle/Form/Type/ScheduleType.php b/src/UserBundle/Form/Type/ScheduleType.php
    new file mode 100644
    index 0000000..b088a36
    --- /dev/null
    +++ b/src/UserBundle/Form/Type/ScheduleType.php
    @@ -0,0 +1,206 @@
    +add('type', ChoiceType::class, [
    +                'choices' => [
    +                    'daily',
    +                    'weekly',
    +                    'monthly',
    +                ],
    +                'description' => 'Notification schedule type.',
    +            ])
    +
    +            // DailyNotificationSchedule
    +            ->add('time', ChoiceType::class, [
    +                'choices' => DailyNotificationSchedule::getAvailableTime(),
    +                'description' => 'Daily schedule time.',
    +            ])
    +            ->add('days', ChoiceType::class, [
    +                'choices' => DailyNotificationSchedule::getAvailableDays(),
    +                'description' => 'Daily schedule days.',
    +            ])
    +
    +            // WeeklyNotificationSchedule
    +            ->add('period', ChoiceType::class, [
    +                'choices' => WeeklyNotificationSchedule::getAvailablePeriod(),
    +                'description' => 'Weekly schedule period.',
    +            ])
    +
    +            // Common for WeeklyNotificationSchedule and MonthlyNotificationSchedule
    +            ->add('day', ChoiceType::class, [
    +                'choices' => [], // Filled on submitting, when we known schedule
    +                                 // type.
    +                'description' => 'Weekly and monthly schedule day. For weekly: day name. For monthly: numbers from 1 to 31 and word last.',
    +            ])
    +            ->add('hour', ChoiceType::class, [
    +                'choices' => range(0, 23),
    +                'description' => 'Weekly and monthly schedule hour.',
    +            ])
    +            ->add('minute', ChoiceType::class, [
    +                'choices' => range(0, 55, 5),
    +                'description' => 'Weekly and monthly schedule minute.',
    +            ])
    +            ->setDataMapper($this)
    +            ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
    +                $data = $event->getData();
    +                $form = $event->getForm();
    +
    +                if (isset($data['type'])) {
    +                    //
    +                    // We should update form only if we got valid type.
    +                    //
    +                    switch ($data['type']) {
    +                        case 'daily':
    +                            $form
    +                                ->remove('period')
    +                                ->remove('day')
    +                                ->remove('hour')
    +                                ->remove('minute');
    +                            break;
    +
    +                        case 'weekly':
    +                            $options = $form->get('day')->getConfig()->getOptions();
    +
    +                            $form
    +                                ->remove('time')
    +                                ->remove('days')
    +                                ->remove('day');
    +
    +                            $options['choices'] = array_combine(
    +                                WeeklyNotificationSchedule::getAvailableDay(),
    +                                WeeklyNotificationSchedule::getAvailableDay()
    +                            );
    +                            $form->add('day', ChoiceType::class, $options);
    +                            break;
    +
    +                        case 'monthly':
    +                            $options = $form->get('day')->getConfig()->getOptions();
    +
    +                            $form
    +                                ->remove('time')
    +                                ->remove('days')
    +                                ->remove('period')
    +                                ->remove('day');
    +
    +                            $options['choices'] = array_combine(
    +                                MonthlyNotificationSchedule::getAvailableDay(),
    +                                MonthlyNotificationSchedule::getAvailableDay()
    +                            );
    +                            $form->add('day', ChoiceType::class, $options);
    +                            break;
    +                    }
    +                }
    +            });
    +    }
    +
    +    /**
    +     * Maps properties of some data to a list of forms.
    +     *
    +     * @param AbstractNotificationSchedule|null          $data  Structured data.
    +     * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of
    +     *                                                          {@link FormInterface}
    +     *                                                          instances.
    +     *
    +     * @return void
    +     */
    +    public function mapDataToForms($data, $forms)
    +    {
    +        $forms = iterator_to_array($forms);
    +
    +        switch (true) {
    +            case ($data instanceof DailyNotificationSchedule):
    +                $forms['time']->setData($data->getTime());
    +                $forms['days']->setData($data->getDays());
    +                break;
    +
    +            case ($data instanceof WeeklyNotificationSchedule):
    +                $forms['period']->setData($data->getPeriod());
    +                $forms['day']->setData($data->getDay());
    +                $forms['hour']->setData($data->getHour());
    +                $forms['minute']->setData($data->getMinute());
    +                break;
    +
    +            case ($data instanceof MonthlyNotificationSchedule):
    +                $forms['day']->setData($data->getDay());
    +                $forms['hour']->setData($data->getHour());
    +                $forms['minute']->setData($data->getMinute());
    +                break;
    +        }
    +    }
    +
    +    /**
    +     * Maps the data of a list of forms into the properties of some data.
    +     *
    +     * @param FormInterface[]|\RecursiveIteratorIterator $forms A list of
    +     *                                                          {@link FormInterface}
    +     *                                                          instances.
    +     * @param AbstractNotificationSchedule|null          $data  Structured data.
    +     *
    +     * @return void
    +     */
    +    public function mapFormsToData($forms, &$data)
    +    {
    +        $forms = iterator_to_array($forms);
    +
    +        switch ($forms['type']->getData()) {
    +            case 'daily':
    +                $data = DailyNotificationSchedule::create()
    +                    ->setTime($forms['time']->getData())
    +                    ->setDays($forms['days']->getData());
    +                break;
    +
    +            case 'weekly':
    +                $data = WeeklyNotificationSchedule::create()
    +                    ->setPeriod($forms['period']->getData())
    +                    ->setDay($forms['day']->getData())
    +                    ->setHour($forms['hour']->getData())
    +                    ->setMinute($forms['minute']->getData());
    +                break;
    +
    +            case 'monthly':
    +                $data = MonthlyNotificationSchedule::create()
    +                    ->setDay($forms['day']->getData())
    +                    ->setHour($forms['hour']->getData())
    +                    ->setMinute($forms['minute']->getData());
    +                break;
    +        }
    +    }
    +}
    diff --git a/src/UserBundle/Form/Type/SimpleTimeZoneType.php b/src/UserBundle/Form/Type/SimpleTimeZoneType.php
    new file mode 100644
    index 0000000..ace8792
    --- /dev/null
    +++ b/src/UserBundle/Form/Type/SimpleTimeZoneType.php
    @@ -0,0 +1,93 @@
    +addModelTransformer($this);
    +    }
    +
    +    /**
    +     * Configures the options for this type.
    +     *
    +     * @param OptionsResolver $resolver The resolver for the options.
    +     *
    +     * @return void
    +     */
    +    public function configureOptions(OptionsResolver $resolver)
    +    {
    +        $resolver->setDefault('choices', \DateTimeZone::listIdentifiers(\DateTimeZone::ALL_WITH_BC));
    +    }
    +
    +    /**
    +     * Returns the name of the parent type.
    +     *
    +     * @return string|null The name of the parent type if any, null otherwise
    +     */
    +    public function getParent()
    +    {
    +        return ChoiceType::class;
    +    }
    +
    +    /**
    +     * Transforms a value from the transformed representation to its original
    +     * representation.
    +     *
    +     * This method is called when {@link Form::submit()} is called to transform
    +     * the requests tainted data into an acceptable format for your data
    +     * processing/model layer.
    +     *
    +     * This method must be able to deal with empty values. Usually this will
    +     * be an empty string, but depending on your implementation other empty
    +     * values are possible as well (such as NULL). The reasoning behind
    +     * this is that value transformers must be chainable. If the
    +     * reverseTransform() method of the first value transformer outputs an
    +     * empty string, the second value transformer must be able to process that
    +     * value.
    +     *
    +     * By convention, reverseTransform() should return NULL if an empty string
    +     * is passed.
    +     *
    +     * @param mixed $timezone The value in the transformed representation.
    +     *
    +     * @return mixed The value in the original representation
    +     *
    +     * @throws TransformationFailedException When the transformation fails.
    +     */
    +    public function reverseTransform($timezone)
    +    {
    +        return ($timezone !== null) ? new \DateTimeZone($timezone) : null;
    +    }
    +}
    diff --git a/src/UserBundle/Form/Type/SourcesType.php b/src/UserBundle/Form/Type/SourcesType.php
    new file mode 100644
    index 0000000..d5ef795
    --- /dev/null
    +++ b/src/UserBundle/Form/Type/SourcesType.php
    @@ -0,0 +1,207 @@
    +em = $em;
    +        $this->storage = $storage;
    +    }
    +
    +    /**
    +     * Builds the form.
    +     *
    +     * This method is called for each type in the hierarchy starting from the
    +     * top most type. Type extensions can further modify the form.
    +     *
    +     * @see FormTypeExtensionInterface::buildForm()
    +     *
    +     * @param FormBuilderInterface $builder The form builder.
    +     * @param array                $options The options.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function buildForm(FormBuilderInterface $builder, array $options)
    +    {
    +        $builder->addModelTransformer($this);
    +    }
    +
    +    /**
    +     * Configures the options for this type.
    +     *
    +     * @param OptionsResolver $resolver The resolver for the options.
    +     *
    +     * @return void
    +     */
    +    public function configureOptions(OptionsResolver $resolver)
    +    {
    +        $resolver->setDefaults([
    +            'entry_type' => NotificationSourceType::class,
    +            'allow_add' => true,
    +            'by_reference' => true,
    +            'constraints' => new Callback([ $this, 'validate' ]),
    +        ]);
    +    }
    +
    +    /**
    +     * Validate sources.
    +     *
    +     * @param array                     $sources Array of transformed sources.
    +     * @param ExecutionContextInterface $context A ExecutionContextInterface
    +     *                                           instance.
    +     *
    +     * @return void
    +     */
    +    public function validate(array $sources, ExecutionContextInterface $context)
    +    {
    +        // todo uncomment and rewrite when analytic is added
    +//        if (($sources['feeds'] === null) || ($sources['charts'] === null)) {
    +        if ($sources['feeds'] === null) {
    +            $context
    +                ->buildViolation('Some of sources has invalid id.')
    +                ->addViolation();
    +        }
    +    }
    +
    +    /**
    +     * Returns the name of the parent type.
    +     *
    +     * @return string|null The name of the parent type if any, null otherwise.
    +     */
    +    public function getParent()
    +    {
    +        return CollectionType::class;
    +    }
    +
    +    /**
    +     * Transforms a value from the transformed representation to its original
    +     * representation.
    +     *
    +     * This method is called when {@link Form::submit()} is called to transform
    +     * the requests tainted data into an acceptable format for your data
    +     * processing/model layer.
    +     *
    +     * This method must be able to deal with empty values. Usually this will
    +     * be an empty string, but depending on your implementation other empty
    +     * values are possible as well (such as NULL). The reasoning behind
    +     * this is that value transformers must be chainable. If the
    +     * reverseTransform() method of the first value transformer outputs an
    +     * empty string, the second value transformer must be able to process that
    +     * value.
    +     *
    +     * By convention, reverseTransform() should return NULL if an empty string
    +     * is passed.
    +     *
    +     * @param mixed $data The value in the transformed representation.
    +     *
    +     * @return mixed The value in the original representation
    +     *
    +     * @throws TransformationFailedException When the transformation fails.
    +     */
    +    public function reverseTransform($data)
    +    {
    +        //
    +        // Split feeds and charts.
    +        // todo uncomment when analytic is added
    +        //
    +//            list($feedsIds, $chartsIds) = \nspl\a\partition(function (array $row) {
    +//                return $row['type'] === AbstractFeed::class;
    +//            }, $data);
    +
    +        //
    +        // Fetch proper entities from database.
    +        //
    +        $feedsIds = \nspl\a\map(\nspl\op\itemGetter('id'), $data);
    +        $feeds = $this->getEntities(AbstractFeed::class, $feedsIds);
    +
    +        //
    +        // We return hash here to simplify further processing.
    +        //
    +        return [
    +            'feeds' => (count($feeds) === count($feedsIds)) ? $feeds : null,
    +            'charts' => null,
    +        ];
    +    }
    +
    +    /**
    +     * Check that all specified id is exists.
    +     *
    +     * @param string $class Entity fqcn.
    +     * @param array  $ids   Array of entities ids.
    +     *
    +     * @return array
    +     */
    +    private function getEntities($class, array $ids)
    +    {
    +        /** @var EntityRepository $repository */
    +        $repository = $this->em->getRepository($class);
    +        $expr = $this->em->getExpressionBuilder();
    +
    +        $condition = $expr->andX($expr->in('Source.id', ':ids'));
    +        $parameters = [ 'ids' => $ids ];
    +
    +        //
    +        // Filter by user only if we have it.
    +        //
    +        $user = \app\op\invokeIf($this->storage->getToken(), 'getUser');
    +        if ($user instanceof User) {
    +            $condition->add($expr->eq('Source.user', ':user'));
    +            $parameters['user'] = $user->getId();
    +        }
    +
    +        //
    +        // We should get only ids and names of sources 'cause it will be used
    +        // for generating response to client.
    +        //
    +        return $repository->createQueryBuilder('Source')
    +            ->select('partial Source.{id, name}')
    +            ->where($condition)
    +            ->setParameters($parameters)
    +            ->getQuery()
    +            ->getResult();
    +    }
    +}
    diff --git a/src/UserBundle/Mailer/LoggableMailer.php b/src/UserBundle/Mailer/LoggableMailer.php
    new file mode 100644
    index 0000000..387fdd9
    --- /dev/null
    +++ b/src/UserBundle/Mailer/LoggableMailer.php
    @@ -0,0 +1,161 @@
    +mailer = $mailer;
    +        $this->logger = $logger;
    +    }
    +
    +    /**
    +     * Send generated password to user.
    +     *
    +     * @param User   $user     A User entity instance.
    +     * @param string $password Generated password.
    +     *
    +     * @return boolean
    +     */
    +    public function sendPassword(User $user, $password)
    +    {
    +        $this->logger->info('Send password to '. $user->getId());
    +
    +        return $this->mailer->sendPassword($user, $password);
    +    }
    +
    +    /**
    +     * Send password resetting confirmation email.
    +     *
    +     * @param User $user A User entity instance.
    +     *
    +     * @return boolean
    +     */
    +    public function sendPasswordResettingConfirmation(User $user)
    +    {
    +        $this->logger->info('Send password resetting confirmation to '. $user->getId());
    +
    +        return $this->mailer->sendPasswordResettingConfirmation($user);
    +    }
    +
    +    /**
    +     * Send notification email to specified addresses.
    +     *
    +     * @param array  $addresses Array of recipient emails.
    +     * @param string $subject   Notification subject.
    +     * @param string $body      Notification body.
    +     *
    +     * @return boolean
    +     */
    +    public function sendNotificationEmail(array $addresses, $subject, $body)
    +    {
    +        $this->logger->info(
    +            'Send notification to '. implode(', ', $addresses) .' recipients'
    +        );
    +
    +        return $this->mailer->sendNotificationEmail($addresses, $subject, $body);
    +    }
    +
    +    /**
    +     * Send emailed document to recipients.
    +     *
    +     * @param EmailedDocument $emailedDocument A EmailedDocument instance.
    +     *
    +     * @return boolean
    +     */
    +    public function sendEmailedDocument(EmailedDocument $emailedDocument)
    +    {
    +        $this->logger->info('Send emailed document to '. implode(', ', $emailedDocument->getEmailTo()));
    +
    +        return $this->mailer->sendEmailedDocument($emailedDocument);
    +    }
    +
    +    /**
    +     * Send generated password and confirm url to user.
    +     *
    +     * @param User   $user       A User entity instance.
    +     * @param string $confirmUrl Generated confirm url.
    +     *
    +     * @return boolean
    +     */
    +    public function sendVerificationSuccess(User $user, $confirmUrl)
    +    {
    +        $this->logger->info('Send verification success email to '. $user->getId());
    +
    +        return $this->mailer->sendVerificationSuccess($user, $confirmUrl);
    +    }
    +
    +    /**
    +     * Send email about failed verification.
    +     *
    +     * @param User $user A User entity instance.
    +     *
    +     * @return boolean
    +     */
    +    public function sendVerificationRejected(User $user)
    +    {
    +        $this->logger->info('Send verification success email to '. $user->getId());
    +
    +        return $this->mailer->sendVerificationRejected($user);
    +    }
    +
    +    /**
    +     * Send unsubscribe notification.
    +     *
    +     * @param Notification $notification A Notification entity.
    +     * @param User         $user         A User entity who unsubscribe from
    +     *                                   specified notification.
    +     *
    +     * @return boolean
    +     */
    +    public function sendUnsubscribe(Notification $notification, User $user)
    +    {
    +        $this->logger->info('Send unsubscribe notification to '. $notification->getOwner()->getFullName());
    +
    +        return $this->mailer->sendUnsubscribe($notification, $user);
    +    }
    +
    +    /**
    +     * Send mail messages.
    +     *
    +     * Should be called only in command.
    +     *
    +     * @return void
    +     */
    +    public function flushQueue()
    +    {
    +        $this->logger->info('Spool messages');
    +
    +        $this->mailer->flushQueue();
    +    }
    +}
    diff --git a/src/UserBundle/Mailer/Mailer.php b/src/UserBundle/Mailer/Mailer.php
    new file mode 100644
    index 0000000..4772958
    --- /dev/null
    +++ b/src/UserBundle/Mailer/Mailer.php
    @@ -0,0 +1,337 @@
    +mailer = $mailer;
    +        $this->transport = $transport;
    +        $this->twig = $twig;
    +        $this->configuration = $configuration;
    +        $this->urlGenerator = $urlGenerator;
    +    }
    +
    +    /**
    +     * Send generated password to user.
    +     *
    +     * @param User   $user     A User entity instance.
    +     * @param string $password Generated password.
    +     *
    +     * @return boolean
    +     */
    +    public function sendPassword(User $user, $password)
    +    {
    +        return $this->sendEmail(
    +            $user->getEmail(),
    +            'Password is changed',
    +            ParametersName::MAIL_PASSWORD,
    +            [
    +                'user' => $user,
    +                'password' => $password,
    +            ]
    +        );
    +    }
    +
    +    /**
    +     * Send generated password and confirm url to user.
    +     *
    +     * @param User   $user     A User entity instance.
    +     * @param string $password A user plain password.
    +     *
    +     * @return boolean
    +     */
    +    public function sendVerificationSuccess(User $user, $password)
    +    {
    +        return $this->sendEmail(
    +            $user->getEmail(),
    +            'Verification status',
    +            ParametersName::MAIL_VERIFICATION_SUCCESS,
    +            [
    +                'user' => $user,
    +                'password' => $password,
    +            ]
    +        );
    +    }
    +
    +    /**
    +     * Send email about failed verification.
    +     *
    +     * @param User $user A User entity instance.
    +     *
    +     * @return boolean
    +     */
    +    public function sendVerificationRejected(User $user)
    +    {
    +        return $this->sendEmail(
    +            $user->getEmail(),
    +            'Verification status',
    +            ParametersName::MAIL_VERIFICATION_REJECT,
    +            [
    +                'user' => $user,
    +            ]
    +        );
    +    }
    +
    +    /**
    +     * Send password resetting confirmation email.
    +     *
    +     * @param User $user A User entity instance.
    +     *
    +     * @return boolean
    +     */
    +    public function sendPasswordResettingConfirmation(User $user)
    +    {
    +        return $this->sendEmail(
    +            $user->getEmail(),
    +            'Password resetting',
    +            ParametersName::MAIL_RESETTING_CONFIRMATION,
    +            [
    +                'user' => $user,
    +                'confirmationUrl' => $this->urlGenerator->generate('app_index_index', [
    +                    'part' => 'auth/reset-password',
    +                    'resetting_token' => $user->getConfirmationToken(),
    +                ], UrlGeneratorInterface::ABSOLUTE_URL),
    +            ]
    +        );
    +    }
    +
    +    /**
    +     * Send notification email to specified addresses.
    +     *
    +     * @param array  $addresses Array of recipient emails.
    +     * @param string $subject   Notification subject.
    +     * @param string $body      Notification body.
    +     *
    +     * @return boolean
    +     */
    +    public function sendNotificationEmail(array $addresses, $subject, $body)
    +    {
    +        $from = $this->configuration->getParameter(ParametersName::MAILER_ADDRESS);
    +        $fromName = $this->configuration->getParameter(ParametersName::MAILER_SENDER_NAME);
    +
    +        $message = \Swift_Message::newInstance()
    +            ->setTo($addresses)
    +            ->setFrom($from, $fromName)
    +            ->setSubject($subject)
    +            ->setBody($body, 'text/html');
    +
    +        return $this->send($message) > 0;
    +    }
    +
    +
    +
    +    /**
    +     * @param string       $renderedTemplate
    +     * @param array|string $fromEmail
    +     * @param array|string $toEmail
    +     */
    +    public function sendEmailMessage(UserInterface $user, $baseurl)
    +    {
    +        // Render the email, use the first line as the subject, and the rest as the body
    +        $parameters = array(
    +            'user' => $user,
    +            'confirmationUrl' => $baseurl.'/auth/confirm-account/'.$user->getConfirmationToken(),
    +        );
    +        $toEmail = (string) $user->getEmail();
    +        $template = $this->twig->load('@FOSUser/Registration/email.txt.twig');
    +
    +        $message = (new \Swift_Message())
    +            ->setSubject('Verify your email address')
    +            ->setFrom("support@socialhose.io","SOCIALHOSE.IO")
    +            ->setTo($toEmail)
    +            ->setBody($template->render(
    +                $parameters
    +            ));
    +
    +        $this->send($message);
    +    }
    +
    +    /**
    +     * Send emailed document to recipients.
    +     *
    +     * @param EmailedDocument $emailedDocument A EmailedDocument instance.
    +     *
    +     * @return boolean
    +     */
    +    public function sendEmailedDocument(EmailedDocument $emailedDocument)
    +    {
    +        $subject = $emailedDocument->getSubject() === ''
    +            ? 'Emailed document content'
    +            : $emailedDocument->getSubject();
    +
    +        $from = $this->configuration->getParameter(ParametersName::MAILER_ADDRESS);
    +        $fromName = $this->configuration->getParameter(ParametersName::MAILER_SENDER_NAME);
    +
    +        $message = \Swift_Message::newInstance()
    +            ->setTo($emailedDocument->getEmailTo())
    +            ->setFrom($from, $fromName)
    +            ->setReplyTo($emailedDocument->getEmailReplyTo())
    +            ->setSubject($subject)
    +            ->setBody($emailedDocument->getContent(), 'text/html');
    +
    +        return $this->send($message) > 0;
    +    }
    +
    +    /**
    +     * Send unsubscribe notification.
    +     *
    +     * @param Notification $notification A Notification entity.
    +     * @param User         $user         A User entity who unsubscribe from
    +     *                                   specified notification.
    +     *
    +     * @return boolean
    +     */
    +    public function sendUnsubscribe(Notification $notification, User $user)
    +    {
    +        return $this->sendEmail(
    +            $user->getEmail(),
    +            sprintf(
    +                'User %s unsubscribed from %s',
    +                $user->getFullName(),
    +                $notification->getName()
    +            ),
    +            ParametersName::MAIL_UNSUBSCRIBE,
    +            [
    +                'user' => $user,
    +                'notification' => $notification,
    +            ]
    +        );
    +    }
    +
    +    /**
    +     * Send mail messages.
    +     *
    +     * Should be called only in command.
    +     *
    +     * @return void
    +     */
    +    public function flushQueue()
    +    {
    +        $transport = $this->mailer->getTransport();
    +        if ($transport instanceof  \Swift_Transport_SpoolTransport) {
    +            $spool = $transport->getSpool();
    +            if ($spool instanceof \Swift_MemorySpool) {
    +                $spool->flushQueue($this->transport);
    +            }
    +        }
    +    }
    +
    +    /**
    +     * @param string $recipient         Recipient email address.
    +     * @param string $subject           Email subject.
    +     * @param string $bodyParameterName Name of configuration parameter which is
    +     *                                  holds body text.
    +     * @param array  $parameters        Template parameters.
    +     *
    +     * @return boolean
    +     */
    +    private function sendEmail($recipient, $subject, $bodyParameterName, array $parameters = [])
    +    {
    +        $from = $this->configuration->getParameter(ParametersName::MAILER_ADDRESS);
    +        $fromName = $this->configuration->getParameter(ParametersName::MAILER_SENDER_NAME);
    +
    +        $message = \Swift_Message::newInstance($subject, $this->twig->render(
    +            'UserBundle::email_layout.html.twig',
    +            [
    +                'body' => $this->twig->createTemplate(
    +                    $this->configuration->getParameter($bodyParameterName)
    +                )->render($parameters),
    +            ]
    +        ), 'text/html')
    +            ->setTo($recipient)
    +            ->setFrom($from, $fromName);
    +
    +        return $this->send($message) > 0;
    +    }
    +
    +    /**
    +     * @param \Swift_Message $message
    +     * @return int
    +     */
    +    private function send($message)
    +    {
    +        $this->resetTransport();
    +
    +        return $this->mailer->send($message);
    +    }
    +
    +    /**
    +     * Reset SMTP connection for each send so that connections
    +     * are not dropped during long-running processes.
    +     *
    +     * See https://github.com/swiftmailer/swiftmailer/issues/490#issuecomment-72492442
    +     *
    +     * @return void
    +     */
    +    private function resetTransport()
    +    {
    +        try {
    +            if ($this->transport instanceof \Swift_Transport_AbstractSmtpTransport) {
    +                $this->transport->reset();
    +            }
    +        } catch (\Exception $e) {
    +            try {
    +                $this->transport->stop();
    +            } catch (\Exception $e) {
    +                // pass
    +            }
    +            // $this->transport->start();
    +        }
    +    }
    +}
    diff --git a/src/UserBundle/Mailer/MailerInterface.php b/src/UserBundle/Mailer/MailerInterface.php
    new file mode 100644
    index 0000000..a3c9731
    --- /dev/null
    +++ b/src/UserBundle/Mailer/MailerInterface.php
    @@ -0,0 +1,95 @@
    +computeDates($start, $to);
    +            foreach ($tmp as $date) {
    +                $key = $date->format('Y-m-d H:i');
    +
    +                if (! isset($dates[$key])) {
    +                    $dates[$key] = [
    +                        'date' => $date,
    +                        'ids' => [],
    +                    ];
    +                }
    +
    +                $dates[$key]['ids'][] = $schedule->getId();
    +            }
    +        }
    +
    +        //
    +        // Now we should convert date's back to current timezone in order to
    +        // simplify further processing.
    +        //
    +        $defaultTZ = new \DateTimeZone(date_default_timezone_get());
    +        return array_map(function (array $row) use ($defaultTZ) {
    +            $row['date']->setTimezone($defaultTZ);
    +
    +            return $row;
    +        }, $dates);
    +    }
    +}
    diff --git a/src/UserBundle/Manager/Notification/Computer/NotificationScheduleComputerInterface.php b/src/UserBundle/Manager/Notification/Computer/NotificationScheduleComputerInterface.php
    new file mode 100644
    index 0000000..d0cd566
    --- /dev/null
    +++ b/src/UserBundle/Manager/Notification/Computer/NotificationScheduleComputerInterface.php
    @@ -0,0 +1,37 @@
    +name = $name;
    +
    +        if (! \nspl\a\all($documents, \nspl\f\rpartial(\app\op\isInstanceOf, ArticleDocumentInterface::class))) {
    +            throw new UnexpectedValueException(sprintf(
    +                'All documents should be instances of %s',
    +                ArticleDocumentInterface::class
    +            ));
    +        }
    +
    +        $this->documents = \nspl\a\map(\nspl\op\methodCaller('normalize'), $documents);
    +    }
    +
    +    /**
    +     * Get name.
    +     *
    +     * @return string
    +     */
    +    public function getName()
    +    {
    +        return $this->name;
    +    }
    +
    +    /**
    +     * Fet documents.
    +     *
    +     * @return ArticleDocumentInterface[]
    +     */
    +    public function getDocuments()
    +    {
    +        return $this->documents;
    +    }
    +
    +    /**
    +     * Get documents count.
    +     *
    +     * @return integer
    +     */
    +    public function getDocumentsCount()
    +    {
    +        if ($this->documentsCount === null) {
    +            $this->documentsCount = count($this->documents);
    +        }
    +
    +        return $this->documentsCount;
    +    }
    +
    +    /**
    +     * Count elements of an object
    +     *
    +     * @return integer The custom count as an integer.
    +     *
    +     * The return value is cast to an integer.
    +     */
    +    public function count()
    +    {
    +        return $this->getDocumentsCount();
    +    }
    +
    +    /**
    +     * Retrieve an external iterator.
    +     *
    +     * @return \Traversable An instance of an object implementing Iterator or Traversable.
    +     */
    +    public function getIterator()
    +    {
    +        return new \ArrayIterator($this->documents);
    +    }
    +
    +    /**
    +     * @return array
    +     */
    +    public function toArray()
    +    {
    +        return [
    +            'name' => $this->name,
    +            'documents' => $this->documents,
    +            'documentsCount' => $this->documentsCount,
    +        ];
    +    }
    +}
    diff --git a/src/UserBundle/Manager/Notification/NotificationManager.php b/src/UserBundle/Manager/Notification/NotificationManager.php
    new file mode 100644
    index 0000000..1ef2446
    --- /dev/null
    +++ b/src/UserBundle/Manager/Notification/NotificationManager.php
    @@ -0,0 +1,524 @@
    +em = $em;
    +        $this->feedFetcherFactory = $feedFetcherFactory;
    +        $this->configuration = $configuration;
    +        $this->extractor = $extractor;
    +
    +        $this->computer = new NotificationScheduleComputer();
    +    }
    +
    +    /**
    +     * Add new notification or update exists.
    +     *
    +     * @param Notification $notification A Notification instance.
    +     *
    +     * @return void
    +     */
    +    public function persists(Notification $notification)
    +    {
    +        /**
    +         * @param array|\Traversable $collection Filtered collection.
    +         * @param string             $keyMethod  Method used for getting unique
    +         *                                       key.
    +         *
    +         * @return array
    +         */
    +        $unique = static function ($collection, $keyMethod) {
    +            $unique = [];
    +            foreach ($collection as $item) {
    +                $unique[$item->$keyMethod()] = $item;
    +            }
    +
    +            return array_values($unique);
    +        };
    +
    +        //
    +        // We should check all schedule's record's and remove duplicates.
    +        // Same for feeds.
    +        //
    +
    +        $schedules = $unique($notification->getSchedules(), 'getKey');
    +        $feeds = $unique($notification->getFeeds(), 'getId');
    +        $notification
    +            ->setSchedules($schedules)
    +            ->setFeeds($feeds);
    +
    +        //
    +        // Persist schedule.
    +        //
    +        $this->em->persist($notification);
    +        $this->em->flush();
    +
    +        //
    +        // Get notification id for further processing.
    +        //
    +        $id = $notification->getId();
    +
    +        //
    +        // We should remove previously computed values.
    +        //
    +        $this->removeComputedScheduling($notification->getId());
    +
    +        if ($notification->isCanBeSent(date_create())) {
    +            //
    +            // We should'nt compute render date's if specified notification is can't
    +            // be sent.
    +            //
    +            $timezone = $notification->getTimezone();
    +            $bound = new \DateTime('+ 1 month');
    +            $sendUntil = $notification->getSendUntil();
    +            $bound = (($sendUntil === null) || ($bound <= $sendUntil))
    +                ? $bound
    +                : $notification->getSendUntil();
    +
    +            $dates = $this->computer->compute(
    +                $schedules,
    +                $bound->setTimezone($timezone),
    +                $timezone
    +            );
    +
    +            $this->em->getConnection()->transactional(function (Connection $con) use ($id, $dates) {
    +                $bucket = [];
    +                $count = 0;
    +                foreach ($dates as $date) {
    +                    $bucket[] = sprintf(
    +                        "('%s', %d, '%s')",
    +                        $date['date']->format('Y-m-d H:i:s'),
    +                        $id,
    +                        implode(',', $date['ids'])
    +                    );
    +                    if (++$count === self::BUCKET_SIZE) {
    +                        $con->executeQuery(
    +                            'INSERT INTO internal_notification_scheduling (date, notification_id, schedules) VALUES ' .
    +                            implode(',', $bucket)
    +                        );
    +                        $count = 0;
    +                    }
    +                }
    +
    +                if ($count > 0) {
    +                    $con->executeQuery(
    +                        'INSERT INTO internal_notification_scheduling (date, notification_id, schedules)  VALUES ' .
    +                        implode(',', $bucket)
    +                    );
    +                }
    +            });
    +        }
    +    }
    +
    +    /**
    +     * Activate specified notifications.
    +     *
    +     * @param Notification|Notification[] $notifications A activated Notification
    +     *                                                   entity instance or array
    +     *                                                   of instances.
    +     * @param boolean                     $active        Activate or deactivate
    +     *                                                   specified notifications.
    +     *
    +     * @return void
    +     */
    +    public function activatedToggle($notifications, $active = true)
    +    {
    +        $notifications = $this->normalizeNotifications($notifications);
    +
    +        foreach ($notifications as $notification) {
    +            $notification->setActive($active);
    +            $this->em->persist($notification);
    +        }
    +
    +        $this->em->flush();
    +    }
    +
    +    /**
    +     * Publish specified notifications.
    +     *
    +     * @param Notification|Notification[] $notifications A activated Notification
    +     *                                                   entity instance or array
    +     *                                                   of instances.
    +     * @param boolean                     $publish       Publish or make private
    +     *                                                   specified notifications.
    +     *
    +     * @return void
    +     */
    +    public function publishedToggle($notifications, $publish = true)
    +    {
    +        $notifications = $this->normalizeNotifications($notifications);
    +
    +        foreach ($notifications as $notification) {
    +            $notification->setPublished($publish);
    +            $this->em->persist($notification);
    +        }
    +
    +        $this->em->flush();
    +    }
    +
    +    /**
    +     * Publish specified notifications.
    +     *
    +     * @param AbstractRecipient           $recipient     Who try to subscribe or
    +     *                                                   unsubscribe from specified
    +     *                                                   notifications.
    +     * @param Notification|Notification[] $notifications A Notification entity
    +     *                                                   instance or array of
    +     *                                                   instances.
    +     * @param boolean                     $subscribe     Subscribe or unsubscribe
    +     *                                                   from specified notifications.
    +     *
    +     * @return void
    +     */
    +    public function subscriptionToggle(AbstractRecipient $recipient, $notifications, $subscribe = true)
    +    {
    +        $notifications = $this->normalizeNotifications($notifications);
    +
    +        if ($subscribe) {
    +            //
    +            // User should not be subscribed to notification twice so we remove
    +            // all notification on which he already subscribed.
    +            //
    +            $checker = \nspl\f\compose(\nspl\f\rpartial('\nspl\a\all', function (AbstractRecipient $checked) use ($recipient) {
    +                return $checked->getId() !== $recipient->getId();
    +            }), \nspl\op\methodCaller('getRecipients'));
    +
    +            $notifications = \nspl\a\filter($checker, $notifications);
    +
    +            $method = \nspl\op\methodCaller('addRecipient', [ $recipient ]);
    +        } else {
    +            $method = \nspl\op\methodCaller('removeRecipient', [ $recipient ]);
    +        }
    +
    +        foreach ($notifications as $notification) {
    +            $method($notification);
    +            $this->em->persist($notification);
    +        }
    +        $this->em->flush();
    +    }
    +
    +    /**
    +     * Remove specified notifications.
    +     *
    +     * @param Notification|Notification[] $notifications A removed Notification entity instance.
    +     *
    +     * @return void
    +     */
    +    public function remove($notifications)
    +    {
    +        $notifications = $this->normalizeNotifications($notifications);
    +
    +        foreach ($notifications as $notification) {
    +            $this->em->remove($notification);
    +        }
    +
    +        $this->removeComputedScheduling(\nspl\a\map(\nspl\op\methodCaller('getId'), $notifications));
    +        $this->em->flush();
    +    }
    +
    +    /**
    +     * Prepare specified notification for sending.
    +     *
    +     * @param Notification $notification A Notification instance.
    +     *
    +     * @return SendableNotification
    +     */
    +    public function prepareToSend(Notification $notification)
    +    {
    +        //
    +        // We should sync parameters.
    +        //
    +        $this->configuration->syncParameters();
    +        $config = SendableNotificationConfig::fromConfiguration($this->configuration);
    +
    +        //
    +        // We should not render notification if it shouldn't be rendered.
    +        //
    +        if (! $notification->isCanBeSent(new \DateTime())) {
    +            return new SendableNotification($config, $notification, [], false);
    +        }
    +
    +        //
    +        // Get used notification theme with applied diff.
    +        //
    +        $themeOptions = $notification->getActualThemeOptions();
    +
    +        /**
    +         * @param Document $document A Document entity instance.
    +         *
    +         * @return ArticleDocumentInterface
    +         */
    +        $commentsFetcherFn = $this->createCommentsFetcherFn($themeOptions, $config);
    +
    +        //
    +        // Now we should get requested number of documents for every notification
    +        // feed.
    +        //
    +        $feeds = [];
    +        /** @var AbstractFeed $feed */
    +        foreach ($notification->getFeeds() as $feed) {
    +            //
    +            // Get all documents ids.
    +            //
    +            $builder = $this->feedFetcherFactory->get($feed)
    +                ->createRequestBuilder($feed);
    +
    +            if (! $builder instanceof SearchRequestBuilderInterface) {
    +                return new SendableNotification($config, $notification, [], false);
    +            }
    +
    +            $filterFactory = $builder->getIndex()->getFilterFactory();
    +            $lastSentUTC = clone $notification->getLastSentAt();
    +            $lastSentUTC->setTimezone(new \DateTimeZone('UTC'));
    +
    +            $documents = $builder
    +                //
    +                // We should get documents which were added after last notification
    +                // sending.
    +                //
    +                ->addFilter($filterFactory->gte('date_found', $lastSentUTC->format('c')))
    +                //
    +                // Set document limit.
    +                // This limit is configured by super admin.
    +                //
    +                ->setLimit($config->documentsPerFeed)
    +                ->build()
    +                ->execute()
    +                ->getDocuments();
    +
    +            //
    +            // Obviously, we should not try to fetch information from database if
    +            // we don't get any documents.
    +            //
    +            if (count($documents) > 0) {
    +                //
    +                // Get documents with necessary fields by ids which we fetch from
    +                // index.
    +                //
    +                // Also we should fetch comments, extract content and convert to
    +                // article instances.
    +                //
    +                $extract = $themeOptions->getContent()->getExtract();
    +
    +                $documents = \nspl\a\map(function (ArticleDocumentInterface $document) use ($commentsFetcherFn, $feed, $extract) {
    +                    $id = $document->getId();
    +
    +                    return $document
    +                        ->mapRawData(function (array $data) use ($commentsFetcherFn, $id) {
    +                            $data['__comments'] = $commentsFetcherFn($id);
    +                            $data['__commentsCount'] = count($data['__comments']);
    +
    +                            return $data;
    +                        })
    +                        ->mapNormalizedData(function (array $data) use ($feed, $extract) {
    +                            $query = '';
    +                            if ($feed instanceof QueryFeed) {
    +                                $query = $feed->getQuery()->getRaw();
    +                            }
    +
    +                            $result =$this->extractor->extract(
    +                                $data['content'],
    +                                $query,
    +                                $extract,
    +                                true
    +                            );
    +
    +                            $data['content'] = $result->getText() . (
    +                                mb_strlen($data['content']) < $result->getLength()
    +                                    ? '...'
    +                                    : ''
    +                                );
    +
    +                            return $data;
    +                        });
    +                }, $documents);
    +
    +                $feeds[] = new FeedData(
    +                    $feed->getName(),
    +                    $documents
    +                );
    +            }
    +        }
    +
    +        //
    +        // Clear entity manager to avoid memory consuming grow and possible
    +        // side-effects on flush.
    +        //
    +        $this->em->clear();
    +
    +        return new SendableNotification($config, $notification, $feeds);
    +    }
    +
    +    /**
    +     * @param integer|integer[] $notification A Notification id or array of ids.
    +     *
    +     * @return void
    +     */
    +    private function removeComputedScheduling($notification)
    +    {
    +        $filteredNotifications = array_filter((array) $notification);
    +
    +        if (count($filteredNotifications) > 0) {
    +            $this->em->getConnection()->executeQuery(sprintf('
    +                DELETE FROM internal_notification_scheduling
    +                WHERE notification_id in (%s)
    +            ', implode(',', $filteredNotifications)));
    +        }
    +    }
    +
    +    /**
    +     * Normalize 'notifications' parameter.
    +     *
    +     * @param array|object $notifications Passed parameters.
    +     *
    +     * @return Notification[]
    +     */
    +    private function normalizeNotifications($notifications)
    +    {
    +        if ($notifications instanceof Notification) {
    +            $notifications = [ $notifications ];
    +        }
    +
    +        $checkerFn = function ($object) {
    +            return ! $object instanceof Notification;
    +        };
    +
    +        if (! is_array($notifications) || \nspl\a\any($notifications, $checkerFn)) {
    +            throw new \InvalidArgumentException(sprintf(
    +                'Expects single %s or array of instances',
    +                Notification::class
    +            ));
    +        }
    +
    +        return $notifications;
    +    }
    +
    +    /**
    +     * Create proper comment fetcher for current notification.
    +     *
    +     * @param NotificationThemeOptions   $options A NotificationThemeOptions
    +     *                                            instance.
    +     * @param SendableNotificationConfig $config  A SendableNotificationConfig
    +     *                                            instance.
    +     *
    +     * @return \Closure
    +     */
    +    private function createCommentsFetcherFn(
    +        NotificationThemeOptions $options,
    +        SendableNotificationConfig $config
    +    ) {
    +        $userComments = $options->getContent()->getShowInfo()->getUserComments();
    +
    +        //
    +        // We should not fetch comments if notification don't require they.
    +        //
    +        if (! $userComments->is(ThemeOptionsUserCommentsEnum::no())) {
    +            return function (Document $document) {
    +                return $document;
    +            };
    +        }
    +
    +        /** @var CommentRepository $repository */
    +        $repository = $this->em->getRepository(Comment::class);
    +
    +        //
    +        // Find out which fields do we need for processing current notification.
    +        //
    +        $commentFields = [
    +            'title',
    +            'content',
    +        ];
    +        if ($userComments->is(ThemeOptionsUserCommentsEnum::WITH_AUTHOR_DATE)) {
    +            $commentFields[] = 'createdAt';
    +            $commentFields['author'] = [
    +                'firstName',
    +                'lastName',
    +            ];
    +        }
    +
    +        //
    +        // Create proper fetcher.
    +        //
    +        return function ($id) use ($repository, $commentFields, $config) {
    +            return $repository->getListForDocument(
    +                $id,
    +                $commentFields,
    +                $config->commentsPerDocument
    +            )->getQuery()->getResult();
    +        };
    +    }
    +}
    diff --git a/src/UserBundle/Manager/Notification/NotificationManagerInterface.php b/src/UserBundle/Manager/Notification/NotificationManagerInterface.php
    new file mode 100644
    index 0000000..f681bcf
    --- /dev/null
    +++ b/src/UserBundle/Manager/Notification/NotificationManagerInterface.php
    @@ -0,0 +1,85 @@
    +config = $config;
    +        $this->notification = $notification;
    +        $this->data = $data;
    +        $this->success = $success;
    +    }
    +
    +    /**
    +     * Send notification.
    +     *
    +     * @param MailerInterface        $mailer     A MailerInterface instance.
    +     * @param EngineInterface        $templating A templating EngineInterface
    +     *                                           instance.
    +     * @param EntityManagerInterface $em         A EntityManagerInterface
    +     *                                           instance.
    +     * @param integer[]|array        $schedules  Array of schedules entity ids.
    +     *
    +     * @return boolean
    +     */
    +    public function send(
    +        MailerInterface $mailer,
    +        EngineInterface $templating,
    +        EntityManagerInterface $em,
    +        array $schedules
    +    ) {
    +        // if (! $this->success) {
    +        //     return false;
    +        // }
    +
    +        $body = $this->render($templating);
    +        if ($body === null) {
    +            return false;
    +        }
    +
    +        //
    +        // Get recipient's emails.
    +        //
    +        $recipients = $this->notification->getRecipients()->map(function (AbstractRecipient $recipient) use ($em) {
    +            $emails = null;
    +            if ($recipient instanceof GroupRecipient) {
    +                /** @var PersonRecipientRepository $repository */
    +                $repository = $em->getRepository(PersonRecipient::class);
    +
    +                $emails = $repository->getEmailsByGroup($recipient->getId());
    +            } elseif ($recipient instanceof PersonRecipient) {
    +                $emails = $recipient->getEmail();
    +            }
    +
    +            return $emails;
    +        })->toArray();
    +
    +//        $recipients = array_filter(\Functional\flatten($recipients));
    +        $recipients = array_filter(\nspl\a\flatten($recipients));
    +
    +        //
    +        // Send notification and flush queue.
    +        //
    +        $sent = $mailer->sendNotificationEmail(
    +            $recipients,
    +            $this->notification->getSubject(),
    +            $body
    +        );
    +        $mailer->flushQueue();
    +
    +        if ($sent) {
    +            //
    +            // We should change date of last notification sending and store it to
    +            // history.
    +            //
    +            /** @var Notification $notificationReference */
    +            $notificationReference = $em->getReference(Notification::class, $this->notification->getId());
    +            $schedules = $em->getRepository(AbstractNotificationSchedule::class)
    +                ->findBy([ 'id' => $schedules ]);
    +            $schedules = array_map(function (AbstractNotificationSchedule $schedule) {
    +                $historySchedule = clone $schedule;
    +                $historySchedule->setNotification(null);
    +
    +                return $historySchedule;
    +            }, $schedules);
    +
    +            $notificationReference->setLastSentAt(new \DateTime());
    +            $history = new NotificationSendHistory(
    +                $notificationReference,
    +                $schedules
    +            );
    +
    +            $em->persist($notificationReference);
    +            $em->persist($history);
    +            $em->flush();
    +
    +            //
    +            // Remove old history.
    +            //
    +            $em->createQueryBuilder()
    +                ->delete()
    +                ->from(NotificationSendHistory::class, 'History')
    +                ->where('History.date <= :date')
    +                ->setParameter('date', date_create()->modify($this->config->historyStorePeriod))
    +                ->getQuery()
    +                ->execute();
    +        }
    +
    +        return $sent;
    +    }
    +
    +    /**
    +     * Render notification template.
    +     *
    +     * @param EngineInterface $templating A templating EngineInterface instance.
    +     *
    +     * @return string|null
    +     */
    +    public function render(EngineInterface $templating)
    +    {
    +        // if (! $this->success) {
    +        //     return null;
    +        // }
    +
    +        $body = null;
    +        if (count($this->data) > 0) {
    +            //
    +            // Render proper notification template.
    +            //
    +            $isEnhanced = $this->notification->getThemeType()->is(ThemeTypeEnum::ENHANCED);
    +
    +            $themeOptions = $this->notification->getActualThemeOptions();
    +
    +            //
    +            // Set default logo image for enhanced layout.
    +            //
    +            $header = $themeOptions->getHeader();
    +
    +            if ($isEnhanced && ($header->getImageUrl() === '')) {
    +                $header->setImageUrl(ThemeOptionHeader::DEFAULT_IMAGE);
    +            } elseif (! $isEnhanced && ($header->getImageUrl() === ThemeOptionHeader::DEFAULT_IMAGE)) {
    +                $header->setImageUrl('');
    +            }
    +
    +            $body = $templating->render(self::TEMPLATE, [
    +                'feeds' => $this->data,
    +                'theme' => [
    +                    'options' => $themeOptions->toArray(),
    +                    'type' => $this->notification->getThemeType()->getValue(),
    +                ],
    +            ]);
    +        } elseif ($this->notification->isSendWhenEmpty()) {
    +            //
    +            // Render empty notification template if notification allow empty sending.
    +            //
    +            $body = $this->config->emptyMessage;
    +        }
    +
    +        return $body;
    +    }
    +}
    diff --git a/src/UserBundle/Manager/Notification/SendableNotificationConfig.php b/src/UserBundle/Manager/Notification/SendableNotificationConfig.php
    new file mode 100644
    index 0000000..1da64d1
    --- /dev/null
    +++ b/src/UserBundle/Manager/Notification/SendableNotificationConfig.php
    @@ -0,0 +1,121 @@
    +config = [
    +            'documentsPerFeed' => $documentsPerFeed,
    +            'commentsPerDocument' => $commentsPerDocument,
    +            'extractContextualCharacter' => $extractContextualCharacter,
    +            'extractFromStartCharacter' => $extractFromStartCharacter,
    +            'emptyMessage' => $emptyMessage,
    +            'historyStorePeriod' => $historyStorePeriod,
    +        ];
    +    }
    +
    +    /**
    +     * @param ConfigurationImmutableInterface $configuration A ConfigurationImmutableInterface
    +     *                                                       instance.
    +     *
    +     * @return static
    +     */
    +    public static function fromConfiguration(ConfigurationImmutableInterface $configuration)
    +    {
    +        return new static(
    +            $configuration->getParameter(ParametersName::NOTIFICATION_DOCUMENT_PER_FEED),
    +            $configuration->getParameter(ParametersName::NOTIFICATION_COMMENTS_PER_DOCUMENT),
    +            0, // TODO add proper parameter.
    +            $configuration->getParameter(ParametersName::NOTIFICATION_START_EXTRACT_LENGTH),
    +            $configuration->getParameter(ParametersName::NOTIFICATION_EMPTY_MESSAGE),
    +            $configuration->getParameter(ParametersName::NOTIFICATION_SEND_HISTORY_MODIFY)
    +        );
    +    }
    +
    +    /**
    +     * @param string $name Parameter name.
    +     *
    +     * @return integer
    +     */
    +    public function __get($name)
    +    {
    +        if (isset($this->{$name})) {
    +            return $this->config[$name];
    +        }
    +
    +        throw new \InvalidArgumentException('Unknown parameter name '. $name);
    +    }
    +
    +    /**
    +     * @param string $name  Parameter name.
    +     * @param mixed  $value Parameter value.
    +     *
    +     * @return void
    +     *
    +     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
    +     */
    +    public function __set($name, $value)
    +    {
    +        throw new \LogicException('SendableNotificationConfig is immutable.');
    +    }
    +
    +    /**
    +     * @param string $name Parameter name.
    +     *
    +     * @return boolean
    +     */
    +    public function __isset($name)
    +    {
    +        return isset($this->config[$name]);
    +    }
    +}
    diff --git a/src/UserBundle/Manager/User/UserManager.php b/src/UserBundle/Manager/User/UserManager.php
    new file mode 100644
    index 0000000..ac8b646
    --- /dev/null
    +++ b/src/UserBundle/Manager/User/UserManager.php
    @@ -0,0 +1,99 @@
    +objectManager->remove($user->getRecipient());
    +        $user->setRecipient(null);
    +
    +        $billingSubscriptions = $user->getBillingSubscription();
    +        $billingSubscriptions->removeUser($user);
    +        $user->setBillingSubscription(null);
    +
    +        if ($billingSubscriptions->isOwnedBy($user)) {
    +            $this->objectManager->remove($billingSubscriptions);
    +        }
    +
    +        parent::deleteUser($user);
    +    }
    +
    +    /**
    +     * Updates a user.
    +     *
    +     * @param UserInterface $user     A UserInterface entity instance.
    +     * @param boolean       $andFlush Flush data to storage.
    +     *
    +     * @return void
    +     */
    +    public function updateUser(UserInterface $user, $andFlush = true)
    +    {
    +        if (! $user instanceof User) {
    +            throw new \InvalidArgumentException('Expects instance of ' . User::class);
    +        }
    +
    +        if (($user->getId() === null)
    +            && ($user->hasRole(UserRoleEnum::SUBSCRIBER)
    +            || $user->hasRole(UserRoleEnum::MASTER_USER))
    +        ) {
    +            //
    +            // For all new users we create recipient with their emails.
    +            //
    +            $recipient = PersonRecipient::createFromUser($user)
    +                ->setAssociatedUser($user)
    +                ->setOwner($user);
    +            $this->objectManager->persist($recipient);
    +        }
    +
    +        parent::updateUser($user, $andFlush);
    +    }
    +
    +    /**
    +     * @param User $user A Confirmed user instance.
    +     *
    +     * @return string New password.
    +     */
    +    public function confirmUser(User $user)
    +    {
    +
    +        Category::createMainCategory($user);
    +        Category::createSharedCategory($user);
    +        Category::createTrashCategory($user);
    +
    +        $user
    +            ->setVerified()
    +            ->setEnabled(true)
    +            ->generatePassword();
    +
    +        $password = $user->getPlainPassword();
    +        $this->updateUser($user);
    +
    +        return $password;
    +    }
    +}
    diff --git a/src/UserBundle/Manager/User/UserManagerInterface.php b/src/UserBundle/Manager/User/UserManagerInterface.php
    new file mode 100644
    index 0000000..4d3f10c
    --- /dev/null
    +++ b/src/UserBundle/Manager/User/UserManagerInterface.php
    @@ -0,0 +1,22 @@
    +createQueryBuilder('Grp')
    +            ->where('Grp.owner = :user')
    +            ->setParameter('user', $user->getId());
    +    }
    +
    +    /**
    +     * @param integer        $user           A User entity id.
    +     * @param SortingOptions $sortingOptions A SortingOptions instance.
    +     * @param string         $nameFilter     Filter recipient groups by name.
    +     *
    +     * @return QueryBuilder
    +     */
    +    public function getQueryBuilderForUser(
    +        $user,
    +        SortingOptions $sortingOptions,
    +        $nameFilter = ''
    +    ) {
    +        $sortField = $this->resolveSortField($sortingOptions);
    +        $expr = $this->_em->getExpressionBuilder();
    +
    +        $condition = $expr->andX($expr->eq('Grp.owner', ':user'));
    +        $parameters = new ArrayCollection([ new Parameter('user', $user) ]);
    +
    +        if ($nameFilter !== '') {
    +            $condition->add($expr->like('Grp.name', ':filter'));
    +            $parameters[] = new Parameter('filter', '%'. $nameFilter .'%');
    +        }
    +
    +        return $this->createQueryBuilder('Grp')
    +            ->where($condition)
    +            ->setParameters($parameters)
    +            ->orderBy($sortField, $sortingOptions->getSortDirection());
    +    }
    +
    +    /**
    +     * @param integer              $user                 A User entity id.
    +     * @param integer              $person               A PersonRecipient entity
    +     *                                                   id.
    +     * @param StatusFilterEnum     $statusFilter         A StatusFilterEnum instance.
    +     * @param SortingOptions       $sortingOptions       A SortingOptions instance.
    +     * @param string               $filter               Filter recipient groups
    +     *                                                   by name.
    +     * @param AdditionalConditions $additionalConditions A AdditionalConditions
    +     *                                                   instance.
    +     *
    +     * @return QueryBuilder
    +     */
    +    public function getQueryBuilderForPerson(
    +        $user,
    +        $person,
    +        StatusFilterEnum $statusFilter,
    +        SortingOptions $sortingOptions,
    +        $filter,
    +        AdditionalConditions $additionalConditions
    +    ) {
    +        $sortField = $this->resolveSortField($sortingOptions);
    +        $expr = $this->_em->getExpressionBuilder();
    +
    +        $condition = $expr->andX($expr->eq('Grp.owner', ':user'));
    +        $parameters = new ArrayCollection([
    +            new Parameter('user', $user),
    +            new Parameter('person', $person),
    +        ]);
    +        $parameters = $additionalConditions->addToParameters($parameters);
    +
    +        if ($filter !== '') {
    +            $condition->add($expr->like('Grp.name', ':filter'));
    +            $parameters[] = new Parameter('filter', '%'. $filter .'%');
    +        }
    +
    +        $qb = $this->createQueryBuilder('Grp');
    +
    +        switch ($statusFilter->getValue()) {
    +            //
    +            // Show only not enrolled groups.
    +            //
    +            case StatusFilterEnum::NO:
    +                //
    +                // Select groups ids which has association with specified recipient
    +                // and remove them from results.
    +                //
    +                $subCondition = $expr->andX(
    +                    $expr->eq('_Person.id', ':person'),
    +                    $expr->eq('_Grp.owner', ':user')
    +                );
    +                $subCondition = $additionalConditions->addToConditions($subCondition, '_Grp');
    +
    +                $subDql = $this->createQueryBuilder('_Grp')
    +                    ->select('_Grp.id')
    +                    ->leftJoin('_Grp.recipients', '_Person')
    +                    ->where($subCondition)
    +                    ->getDQL();
    +
    +                $condition->add($expr->notIn('Grp', $subDql));
    +                $qb->addSelect('0 AS enrolled');
    +                break;
    +
    +            //
    +            // Fetch only enrolled groups.
    +            //
    +            case StatusFilterEnum::YES:
    +                $condition->add($expr->eq('Person.id', ':person'));
    +                $condition = $additionalConditions->addToConditions($condition, 'Grp');
    +
    +
    +                $qb
    +                    ->join('Grp.recipients', 'Person')
    +                    ->addSelect('1 AS enrolled');
    +                break;
    +
    +            //
    +            // If we not apply filters we should check in which groups specified
    +            // recipient is enrolled.
    +            //
    +            case StatusFilterEnum::ALL:
    +                $countCondition = $expr->andX(
    +                    $expr->eq('_Person.id', ':person'),
    +                    $expr->eq('_Grp.id', 'Grp.id')
    +                );
    +
    +                $countCondition = $additionalConditions->addToConditions($countCondition, 'Grp');
    +
    +                $countDQL = $this->createQueryBuilder('_Grp')
    +                    ->select('COUNT(_Grp.id)')
    +                    ->join('_Grp.recipients', '_Person')
    +                    ->where($countCondition)
    +                    ->getDQL();
    +
    +                $qb->addSelect("(CASE WHEN ({$countDQL}) > 0 THEN 1 ELSE 0 END) AS enrolled");
    +                break;
    +        }
    +
    +        return $qb
    +            ->where($condition)
    +            ->setParameters($parameters)
    +            ->orderBy($sortField, $sortingOptions->getSortDirection());
    +    }
    +
    +    /**
    +     * Get group recipient by id.
    +     *
    +     * @param integer $id A GroupRecipient entity id.
    +     *
    +     * @return GroupRecipient|null
    +     */
    +    public function get($id)
    +    {
    +        return $this->createQueryBuilder('Grp')
    +            ->where('Grp.id = :id')
    +            ->setParameter('id', $id)
    +            ->getQuery()
    +            ->getOneOrNullResult();
    +    }
    +
    +    /**
    +     * @param SortingOptions $sortingOptions A SortingOptions instance.
    +     *
    +     * @return string
    +     */
    +    private function resolveSortField(SortingOptions $sortingOptions)
    +    {
    +        $sortField = $sortingOptions->getFieldName();
    +        switch ($sortField) {
    +            case 'active':
    +            case 'name':
    +            case 'recipientsNumber':
    +                $sortField = "Grp.{$sortField}";
    +                break;
    +
    +            case 'creationDate':
    +                $sortField = 'Grp.createdAt';
    +                break;
    +
    +            default:
    +                throw new \InvalidArgumentException("Unknown field name '{$sortField}'.");
    +        }
    +
    +        return $sortField;
    +    }
    +}
    diff --git a/src/UserBundle/Repository/NotificationRepository.php b/src/UserBundle/Repository/NotificationRepository.php
    new file mode 100644
    index 0000000..bc624f6
    --- /dev/null
    +++ b/src/UserBundle/Repository/NotificationRepository.php
    @@ -0,0 +1,439 @@
    +_em->getExpressionBuilder();
    +
    +        $condition = $expr->andX(
    +            $expr->eq('Notification.id', ':id')
    +        );
    +
    +        $parameters = new ArrayCollection([
    +            new Parameter('id', $id),
    +        ]);
    +
    +        if ($type !== null) {
    +            $condition->add($expr->eq(
    +                'Notification.notificationType',
    +                ':type'
    +            ));
    +            $parameters[] = new Parameter('type', (string) $type);
    +        }
    +
    +        return $this->createQueryBuilder('Notification')
    +//            ->addSelect('Feed, Chart, Owner, Recipient')
    +            ->addSelect('Feed, Owner, Recipient')
    +            ->leftJoin('Notification.feeds', 'Feed')
    +//            ->leftJoin('Notification.charts', 'Chart')
    +            ->leftJoin('Notification.owner', 'Owner')
    +            ->leftJoin('Notification.recipients', 'Recipient')
    +            ->where($condition)
    +            ->setParameters($parameters)
    +            ->getQuery()
    +            ->getOneOrNullResult();
    +    }
    +
    +    /**
    +     * Get notification instance for sending.
    +     *
    +     * @param integer $id A Notification entity id.
    +     *
    +     * @return Notification|null
    +     */
    +    public function getForSending($id)
    +    {
    +        return $this->createQueryBuilder('Notification')
    +            ->addSelect(
    +                'partial Feed.{id, name}, Owner, Recipient',
    +                'partial Schedule.{id}'
    +            )
    +            ->leftJoin('Notification.feeds', 'Feed')
    +            ->leftJoin('Notification.owner', 'Owner')
    +            ->leftJoin('Notification.recipients', 'Recipient')
    +            ->leftJoin('Notification.schedules', 'Schedule')
    +            ->where('Notification.id = :id')
    +            ->setParameter('id', $id)
    +            ->getQuery()
    +            ->getOneOrNullResult();
    +    }
    +
    +    /**
    +     * @return QueryBuilder
    +     */
    +    public function getQueryBuilderForSubscription()
    +    {
    +        return $this->createQueryBuilder('Notification')
    +            ->addSelect('partial Owner.{id, email}')
    +            ->join('Notification.owner', 'Owner');
    +    }
    +
    +    /**
    +     * Get query builder for fetching available notifications for forms.
    +     *
    +     * @param User $user A User entity instance, who ask.
    +     *
    +     * @return QueryBuilder
    +     */
    +    public function getQueryBuilderForForm(User $user)
    +    {
    +        $expr = $this->_em->getExpressionBuilder();
    +
    +        return $this->createQueryBuilder('Notification')
    +            ->where($expr->orX(
    +                $expr->eq('Notification.owner', ':user'),
    +                $expr->andX(
    +                    $expr->eq('Notification.published', 1),
    +                    $expr->eq('Notification.billingSubscription', ':subscription')
    +                )
    +            ))
    +            ->setParameter('user', $user->getId())
    +            ->setParameter('subscription', $user->getBillingSubscription()->getId());
    +    }
    +
    +    /**
    +     * @param AbstractRecipient $recipient      Requested AbstractRecipient
    +     *                                          entity instance.
    +     * @param User              $owner          A User entity id.
    +     * @param SortingOptions    $sortingOptions A SortingOptions instance.
    +     * @param StatusFilterEnum  $statusFilter   A StatusFilterEnum instance.
    +     * @param string            $nameFilter     Part of Notification entity name
    +     *                                          for filtering.
    +     *
    +     * @return QueryBuilder
    +     */
    +    public function getQueryBuilderForRecipient(
    +        AbstractRecipient $recipient,
    +        User $owner,
    +        SortingOptions $sortingOptions,
    +        StatusFilterEnum $statusFilter,
    +        $nameFilter
    +    ) {
    +        $expr = $this->_em->getExpressionBuilder();
    +
    +        $sortField = $sortingOptions->getFieldName();
    +
    +        $qb = $this->getQueryBuilderForForm($owner)
    +            ->addSelect(
    +                'RecipientList',
    +                'Schedule',
    +                'Owner'
    +            )
    +            ->join('Notification.owner', 'Owner')
    +            ->leftJoin('Notification.recipients', 'RecipientList')
    +            ->leftJoin('Notification.schedules', 'Schedule')
    +            ->orderBy($sortField, $sortingOptions->getSortDirection())
    +            ->setParameter('recipient', $recipient->getId());
    +
    +        if ($nameFilter !== '') {
    +            $qb
    +                ->andWhere($expr->like('Notification.name', ':name'))
    +                ->setParameter('name', '%'. $nameFilter .'%');
    +        }
    +
    +        switch ($statusFilter->getValue()) {
    +            //
    +            // Select notification ids which has association with specified recipient
    +            // and remove them from results.
    +            //
    +            case StatusFilterEnum::NO:
    +                $subDql = $this->createQueryBuilder('_Notification')
    +                    ->select('_Notification.id')
    +                    ->join('_Notification.recipients', '_Recipient', Join::WITH, '_Recipient.id = :recipient')
    +                    ->getDQL();
    +
    +                $qb
    +                    ->addSelect('0 AS subscribed')
    +                    ->andWhere($expr->notIn('Notification.id', $subDql));
    +                break;
    +
    +            //
    +            // Fetch only subscribed notifications.
    +            //
    +            case StatusFilterEnum::YES:
    +                $qb
    +                    ->addSelect('1 AS subscribed')
    +                    ->join('Notification.recipients', 'Recipient', Join::WITH, 'Recipient.id = :recipient');
    +                break;
    +
    +            case StatusFilterEnum::ALL:
    +                $countDQL = $this->createQueryBuilder('_Notification')
    +                    ->select('COUNT(_Notification.id)')
    +                    ->join('_Notification.recipients', '_Recipient', Join::WITH, '_Recipient.id = :recipient')
    +                    ->where('_Notification.id = Notification.id')
    +                    ->getDQL();
    +
    +                $qb
    +                    ->addSelect("({$countDQL}) AS subscribed")
    +                    ->join('Notification.recipients', 'Recipient');
    +                break;
    +        }
    +
    +        $qb = $this->resolveSortingOptions($qb, $sortingOptions);
    +
    +        return $qb;
    +    }
    +
    +    /**
    +     * Get query builder for notifications.
    +     *
    +     * @param SortingOptions $sortingOptions A SortingOptions instance.
    +     * @param User           $owner          A User entity instance or null.
    +     * @param boolean        $onlyPublished  Fetch only published notifications.
    +     * @param string         $nameFilter     Filter notification by name.
    +     *
    +     * @return QueryBuilder
    +     */
    +    public function getQueryBuilder(
    +        SortingOptions $sortingOptions,
    +        User $owner,
    +        $onlyPublished = false,
    +        $nameFilter = null
    +    ) {
    +        $expr = $this->_em->getExpressionBuilder();
    +        $condition = $expr->andX(
    +            $expr->eq('Notification.billingSubscription', ':subscription'),
    +            $expr->eq('Notification.owner', ':owner')
    +        );
    +        $parameters = new ArrayCollection([
    +            new Parameter('subscription', $owner->getBillingSubscription()->getId()),
    +            new Parameter('recipient', $owner->getRecipient()->getId()),
    +            new Parameter('owner', $owner->getId()),
    +        ]);
    +
    +        if ($onlyPublished) {
    +            $condition->add($expr->eq('Notification.published', 1));
    +        }
    +
    +        $qb = $this->createQueryBuilder('Notification')
    +            ->addSelect(
    +                'Recipient',
    +                'Schedule',
    +                'Owner',
    +                '(CASE WHEN Recipient.id = :recipient THEN 1 ELSE 0 END) AS subscribed'
    +            )
    +            ->join('Notification.owner', 'Owner')
    +            ->leftJoin('Notification.recipients', 'Recipient')
    +            ->leftJoin('Notification.schedules', 'Schedule')
    +            ->where($condition)
    +            ->setParameters($parameters);
    +
    +        if ($nameFilter !== null) {
    +            $qb
    +                ->andWhere($expr->like('Notification.name', ':name'))
    +                ->setParameter('name', '%'. $nameFilter .'%');
    +        }
    +        return $this->resolveSortingOptions(
    +            $qb,
    +            $sortingOptions
    +        );
    +    }
    +
    +    /**
    +     * @param QueryBuilder   $qb             A QueryBuilder instance.
    +     * @param SortingOptions $sortingOptions A SortingOptions instance.
    +     *
    +     * @return QueryBuilder
    +     */
    +    private function resolveSortingOptions(QueryBuilder $qb, SortingOptions $sortingOptions)
    +    {
    +        $sortField = $sortingOptions->getFieldName();
    +        switch ($sortField) {
    +            case 'published':
    +            case 'active':
    +            case 'sourcesCount':
    +            case 'owner':
    +            case 'name':
    +                $sortField = "Notification.{$sortField}";
    +                break;
    +
    +            case 'type':
    +                $sortField = 'Notification.notificationType';
    +                break;
    +
    +            default:
    +                throw new \InvalidArgumentException("Unknown field name '{$sortField}'.");
    +        }
    +
    +        return $qb
    +            ->orderBy($sortField, $sortingOptions->getSortDirection());
    +    }
    +
    +    /**
    +     * Get count notifications for user.
    +     *
    +     * @param SortingOptions $sortingOptions A SortingOptions instance.
    +     *
    +     * @return \Doctrine\ORM\QueryBuilder
    +     */
    +    public function computeUserNotificationsCount(SortingOptions $sortingOptions)
    +    {
    +        $sortField = $sortingOptions->getFieldName();
    +
    +        return $this->createQueryBuilder('Notification')
    +            ->select(
    +                'COUNT(Notification.id) as notifications',
    +                'IDENTITY(Notification.owner) as id',
    +                'Owner.email as name',
    +                '\'owner\' as type'
    +            )
    +            ->join('Notification.owner', 'Owner')
    +            ->orderBy($sortField, $sortingOptions->getSortDirection())
    +            ->groupBy('Notification.owner')
    +            ->getQuery()
    +            ->execute();
    +    }
    +
    +    /**
    +     * Get count notifications for recipient.
    +     *
    +     * @param SortingOptions $sortingOptions A SortingOptions instance.
    +     *
    +     * @return \Doctrine\ORM\QueryBuilder
    +     */
    +    public function computeRecipientNotificationsCount(SortingOptions $sortingOptions)
    +    {
    +        $sortField = $sortingOptions->getFieldName();
    +        return $this->createQueryBuilder('Notification')
    +            ->select(
    +                'COUNT(Notification.id) as notifications',
    +                'Recipient.id as id',
    +                'Recipient.name as name',
    +                '\'recipient\' as type'
    +            )
    +            ->leftJoin('Notification.recipients', 'Recipient')
    +            ->orderBy($sortField, $sortingOptions->getSortDirection())
    +            ->groupBy('Recipient.id')
    +            ->getQuery()
    +            ->execute();
    +    }
    +
    +    /**
    +     * Get count notifications for feed.
    +     *
    +     * @param SortingOptions $sortingOptions A SortingOptions instance.
    +     *
    +     * @return \Doctrine\ORM\QueryBuilder
    +     */
    +    public function getCountFeedNotifications(SortingOptions $sortingOptions)
    +    {
    +        $sortField = $sortingOptions->getFieldName();
    +        return $this->createQueryBuilder('Notification')
    +            ->select(
    +                'COUNT(Notification.id) as notifications',
    +                'Feed.id as id',
    +                'Feed.name as name',
    +                '\'feed\' as type'
    +            )
    +            ->leftJoin('Notification.feeds', 'Feed')
    +            ->orderBy($sortField, $sortingOptions->getSortDirection())
    +            ->groupBy('Feed.id')
    +            ->getQuery()
    +            ->execute();
    +    }
    +
    +    /**
    +     * Get query builder for all notifications.
    +     *
    +     * @param SortingOptions       $sortingOptions A SortingOptions instance.
    +     * @param AbstractSubscription $subscription   A AbstractSubscription instance.
    +     *
    +     * @return QueryBuilder
    +     */
    +    public function getNotificationsAllQueryBuilder(
    +        SortingOptions $sortingOptions,
    +        AbstractSubscription $subscription
    +    ) {
    +        return $this->resolveSortingOptions(
    +            $this->createQueryBuilder('Notification')
    +                ->addSelect(
    +                    'Recipient',
    +                    'Schedule',
    +                    'Owner'
    +                )
    +                ->join('Notification.owner', 'Owner')
    +                ->leftJoin('Notification.recipients', 'Recipient')
    +                ->leftJoin('Notification.schedules', 'Schedule')
    +                ->where('Notification.billingSubscription = :subscription')
    +                ->setParameter('subscription', $subscription->getId())
    +                ->groupBy('Notification.id'),
    +            $sortingOptions
    +        );
    +    }
    +
    +    /**
    +     * @param SortingOptions $sortingOptions A SortingOptions instance.
    +     * @param string         $typeFilter     One of available filter.
    +     * @param string         $filterId       Filter id.
    +     * @param User           $user           Filter owner.
    +     *
    +     * @return QueryBuilder
    +     */
    +    public function getQueryBuilderForFilter(
    +        SortingOptions $sortingOptions,
    +        $typeFilter,
    +        $filterId,
    +        User $user
    +    ) {
    +
    +        $sortField = $sortingOptions->getFieldName();
    +        $qb = $this->createQueryBuilder('Notification')
    +            ->addSelect(
    +                'RecipientList',
    +                'Schedule',
    +                'Owner'
    +            )
    +            ->leftJoin('Notification.owner', 'Owner')
    +            ->leftJoin('Notification.recipients', 'RecipientList')
    +            ->leftJoin('Notification.schedules', 'Schedule')
    +            ->leftJoin('Notification.billingSubscription', 'Subscription', Join::WITH, 'Subscription.masterAccounts =:masterUser')
    +            ->setParameter('masterUser', $user->getId())
    +            ->orderBy($sortField, $sortingOptions->getSortDirection());
    +
    +        switch ($typeFilter) {
    +            case 'owner':
    +                $qb->andWhere('Notification.owner =:owner')
    +                    ->setParameter(':owner', $filterId);
    +                break;
    +            case 'recipient':
    +                $qb->join('Notification.recipients', 'Recipient', Join::WITH, 'Recipient.id = :recipient')
    +                    ->setParameter('recipient', $filterId);
    +                break;
    +            case 'feed':
    +                $qb->join('Notification.feeds', 'Feed', Join::WITH, 'Feed.id = :feed')
    +                    ->setParameter('feed', $filterId);
    +                break;
    +        }
    +        $qb = $this->resolveSortingOptions($qb, $sortingOptions);
    +        return $qb;
    +    }
    +}
    diff --git a/src/UserBundle/Repository/NotificationSendHistoryRepository.php b/src/UserBundle/Repository/NotificationSendHistoryRepository.php
    new file mode 100644
    index 0000000..0f1cc77
    --- /dev/null
    +++ b/src/UserBundle/Repository/NotificationSendHistoryRepository.php
    @@ -0,0 +1,103 @@
    +createQueryBuilder('History')
    +            ->select('History.date')
    +            ->where('History.notification = :notification')
    +            ->setParameter('notification', $notification);
    +    }
    +
    +    /**
    +     * @param AbstractRecipient $recipient      A AbstractRecipient entity instance.
    +     * @param SortingOptions    $sortingOptions A SortingOptions instance.
    +     * @param string            $typeFilter     Notification type filter.
    +     *
    +     * @return QueryBuilder
    +     */
    +    public function getListForRecipient(
    +        AbstractRecipient $recipient,
    +        SortingOptions $sortingOptions,
    +        $typeFilter
    +    ) {
    +        $qb = $this->createQueryBuilder('History')
    +            ->select(
    +                'partial History.{id, date}',
    +                'partial Notification.{id, name, notificationType}',
    +                'Schedule'
    +            )
    +            ->join('History.notification', 'Notification')
    +            ->join('History.schedules', 'Schedule')
    +            ->join('Notification.recipients', 'Recipient', Join::WITH, 'Recipient.id = :recipient')
    +            ->setParameter('recipient', $recipient->getId());
    +
    +        $sortField = $sortingOptions->getFieldName();
    +        switch ($sortField) {
    +            case 'name':
    +                $sortField = 'Notification.name';
    +                break;
    +
    +            case 'type':
    +                $sortField = 'Notification.notificationType';
    +                break;
    +
    +            case 'scheduleTime':
    +                $countDql = $this->_em->createQueryBuilder()
    +                    ->select('COUNT(_Schedule.id)')
    +                    ->from(AbstractNotificationSchedule::class, '_Schedule')
    +                    ->where('_Schedule.history = History.id')
    +                    ->getDQL();
    +
    +                $qb->addSelect("($countDql) AS HIDDEN scheduleCount");
    +                $sortField = 'scheduleCount';
    +                break;
    +
    +            case 'sentTime':
    +                $sortField = 'History.date';
    +                break;
    +
    +            default:
    +                throw new \InvalidArgumentException("Unknown field name '{$sortField}'.");
    +        }
    +
    +        switch ($typeFilter) {
    +            case NotificationTypeEnum::ALERT:
    +                $qb
    +                    ->andWhere('Notification.notificationType = :type')
    +                    ->setParameter('type', NotificationTypeEnum::ALERT);
    +                break;
    +
    +            case NotificationTypeEnum::NEWSLETTER:
    +                $qb
    +                    ->andWhere('Notification.notificationType = :type')
    +                    ->setParameter('type', NotificationTypeEnum::NEWSLETTER);
    +                break;
    +        }
    +
    +        return $qb->orderBy($sortField, $sortingOptions->getSortDirection());
    +    }
    +}
    diff --git a/src/UserBundle/Repository/NotificationThemeRepository.php b/src/UserBundle/Repository/NotificationThemeRepository.php
    new file mode 100644
    index 0000000..a2ff713
    --- /dev/null
    +++ b/src/UserBundle/Repository/NotificationThemeRepository.php
    @@ -0,0 +1,29 @@
    +createQueryBuilder('Theme')
    +            ->where('Theme.default = 1')
    +            ->getQuery()
    +            ->getOneOrNullResult();
    +    }
    +}
    diff --git a/src/UserBundle/Repository/OrganizationRepository.php b/src/UserBundle/Repository/OrganizationRepository.php
    new file mode 100644
    index 0000000..3aff990
    --- /dev/null
    +++ b/src/UserBundle/Repository/OrganizationRepository.php
    @@ -0,0 +1,39 @@
    +_em->createQueryBuilder()
    +            ->select('COUNT(_User.id)')
    +            ->from(OrganizationSubscription::class, '_Subscription')
    +            ->join('_Subscription.users', '_User')
    +            ->where('_Subscription.organization = Organization')
    +            ->getDQL();
    +
    +        return $this->createQueryBuilder('Organization')
    +            ->select(
    +                'Organization.id, Organization.name',
    +                'COUNT(Subscription) as subscriptionCount',
    +                '('. $subDQL .') as usersCount'
    +            )
    +            ->leftJoin('Organization.subscriptions', 'Subscription')
    +            ->groupBy('Organization.id')
    +            ->orderBy('Organization.id');
    +    }
    +}
    diff --git a/src/UserBundle/Repository/PersonRecipientRepository.php b/src/UserBundle/Repository/PersonRecipientRepository.php
    new file mode 100644
    index 0000000..29422b5
    --- /dev/null
    +++ b/src/UserBundle/Repository/PersonRecipientRepository.php
    @@ -0,0 +1,261 @@
    +createQueryBuilder('Person')
    +            ->where('Person.id = :id AND Person.owner = :user')
    +            ->setParameter('id', $id)
    +            ->setParameter('user', $user)
    +            ->getQuery()
    +            ->getOneOrNullResult();
    +    }
    +
    +    /**
    +     * @param User $user A User entity instance.
    +     *
    +     * @return QueryBuilder
    +     */
    +    public function getAvailableForUser(User $user)
    +    {
    +        return $this->createQueryBuilder('Person')
    +            ->where('Person.owner = :user')
    +            ->setParameter('user', $user->getId());
    +    }
    +
    +    /**
    +     * Get recipient by id.
    +     *
    +     * @param integer $id A person recipient entity id.
    +     *
    +     * @return PersonRecipient|null
    +     */
    +    public function get($id)
    +    {
    +        return $this->createQueryBuilder('Person')
    +            ->addSelect('Group')
    +            ->join('Person.groups', 'Group')
    +            ->where('Person.id = :id')
    +            ->setParameter('id', $id)
    +            ->getQuery()
    +            ->getOneOrNullResult();
    +    }
    +
    +    /**
    +     * @param integer        $user           A User entity id.
    +     * @param SortingOptions $sortingOptions A SortingOptions instance.
    +     * @param string         $filter         Filter person recipients by name or
    +     *                                       email.
    +     *
    +     * @return QueryBuilder
    +     */
    +    public function getQueryBuilderForUser(
    +        $user,
    +        SortingOptions $sortingOptions,
    +        $filter = ''
    +    ) {
    +        $sortField = $this->resolveSortField($sortingOptions);
    +        $expr = $this->_em->getExpressionBuilder();
    +
    +        $condition = $expr->andX($expr->eq('Person.owner', ':user'));
    +        $parameters = new ArrayCollection([ new Parameter('user', $user) ]);
    +
    +        if ($filter !== '') {
    +            $condition->add($expr->orX(
    +                $expr->like('Person.firstName', ':filter'),
    +                $expr->like('Person.lastName', ':filter'),
    +                $expr->like('Person.email', ':filter')
    +            ));
    +            $parameters[] = new Parameter('filter', '%' . $filter . '%');
    +        }
    +
    +        return $this->createQueryBuilder('Person')
    +            ->addSelect('RecipientGroup')
    +            ->leftJoin('Person.groups', 'RecipientGroup')
    +            ->where($condition)
    +            ->setParameters($parameters)
    +            ->orderBy($sortField, $sortingOptions->getSortDirection());
    +    }
    +
    +    /**
    +     * @param integer              $user                 A User entity id.
    +     * @param integer              $group                A GroupRecipient entity
    +     *                                                   id.
    +     * @param StatusFilterEnum     $statusFilter         A StatusFilterEnum instance.
    +     * @param SortingOptions       $sortingOptions       A SortingOptions instance.
    +     * @param string               $filter               Filter person recipients
    +     *                                                   by name or email.
    +     * @param AdditionalConditions $additionalConditions A AdditionalConditions
    +     *                                                   instance.
    +     *
    +     * @return QueryBuilder
    +     */
    +    public function getQueryBuilderForGroup(
    +        $user,
    +        $group,
    +        StatusFilterEnum $statusFilter,
    +        SortingOptions $sortingOptions,
    +        $filter,
    +        AdditionalConditions $additionalConditions
    +    ) {
    +        $sortField = $this->resolveSortField($sortingOptions);
    +        $expr = $this->_em->getExpressionBuilder();
    +
    +        $condition = $expr->andX($expr->eq('Person.owner', ':user'));
    +        $parameters = new ArrayCollection([
    +            new Parameter('user', $user),
    +            new Parameter('group', $group),
    +        ]);
    +        $parameters = $additionalConditions->addToParameters($parameters);
    +
    +        if ($filter !== '') {
    +            $condition->add($expr->orX(
    +                $expr->like('Person.firstName', ':filter'),
    +                $expr->like('Person.lastName', ':filter'),
    +                $expr->like('Person.email', ':filter')
    +            ));
    +            $parameters[] = new Parameter('filter', '%' . $filter . '%');
    +        }
    +
    +        $qb = $this->createQueryBuilder('Person');
    +
    +        switch ($statusFilter->getValue()) {
    +            //
    +            // Show only recipients which not enrolled in group.
    +            //
    +            case StatusFilterEnum::NO:
    +                //
    +                // Select recipients which have association with specified group
    +                // and remove them from results.
    +                //
    +                $subCondition = $expr->andX(
    +                    $expr->eq('_Grp.id', ':group'),
    +                    $expr->eq('_Person.owner', ':user')
    +                );
    +                $subCondition = $additionalConditions->addToConditions($subCondition, '_Person');
    +
    +                $subDql = $this->createQueryBuilder('_Person')
    +                    ->select('_Person.id')
    +                    ->leftJoin('_Person.groups', '_Grp')
    +                    ->where($subCondition)
    +                    ->getDQL();
    +
    +                $condition->add($expr->notIn('Person', $subDql));
    +                $qb->addSelect('0 AS enrolled');
    +                break;
    +
    +            //
    +            // Show only recipients which enrolled in groups.
    +            //
    +            case StatusFilterEnum::YES:
    +                $condition->add($expr->eq('RecipientGroup.id', ':group'));
    +                $condition = $additionalConditions->addToConditions($condition, 'Person');
    +
    +                $qb->addSelect('1 AS enrolled');
    +                break;
    +
    +
    +            //
    +            // If we not apply filters we should check which recipient is enrolled
    +            // in specified group.
    +            //
    +            case StatusFilterEnum::ALL:
    +                $countCondition = $expr->andX(
    +                    $expr->eq('_Grp.id', ':group'),
    +                    $expr->eq('_Person.id', 'Person.id')
    +                );
    +
    +                $countCondition = $additionalConditions->addToConditions($countCondition, 'Person');
    +
    +                $countDQL = $this->createQueryBuilder('_Person')
    +                    ->select('COUNT(_Person.id)')
    +                    ->join('_Person.groups', '_Grp')
    +                    ->where($countCondition)
    +                    ->getDQL();
    +
    +                $qb->addSelect("(CASE WHEN ({$countDQL}) > 0 THEN 1 ELSE 0 END) AS enrolled");
    +                break;
    +        }
    +
    +        return $qb
    +            ->addSelect('RecipientGroup')
    +            ->leftJoin('Person.groups', 'RecipientGroup')
    +            ->where($condition)
    +            ->setParameters($parameters)
    +            ->orderBy($sortField, $sortingOptions->getSortDirection());
    +    }
    +
    +    /**
    +     * @param integer $group A GroupRecipient entity id.
    +     *
    +     * @return string[] Fetched emails.
    +     */
    +    public function getEmailsByGroup($group)
    +    {
    +        return array_map(function (array $row) {
    +            return $row['email'];
    +        }, $this->createQueryBuilder('Person')
    +            ->select('Person.email')
    +            ->join('Person.groups', 'Grp')
    +            ->where('Grp.id = :group')
    +            ->setParameter('group', $group)
    +            ->getQuery()
    +            ->getArrayResult());
    +    }
    +
    +    /**
    +     * @param SortingOptions $sortingOptions A SortingOptions instance.
    +     *
    +     * @return string
    +     */
    +    private function resolveSortField(SortingOptions $sortingOptions)
    +    {
    +        $sortField = $sortingOptions->getFieldName();
    +        switch ($sortField) {
    +            case 'active':
    +            case 'email':
    +            case 'name':
    +                $sortField = "Person.{$sortField}";
    +                break;
    +
    +            case 'creationDate':
    +                $sortField = 'Person.createdAt';
    +                break;
    +
    +            default:
    +                throw new \InvalidArgumentException("Unknown field name '{$sortField}'.");
    +        }
    +
    +        return $sortField;
    +    }
    +}
    diff --git a/src/UserBundle/Repository/PlanRepository.php b/src/UserBundle/Repository/PlanRepository.php
    new file mode 100644
    index 0000000..a8de516
    --- /dev/null
    +++ b/src/UserBundle/Repository/PlanRepository.php
    @@ -0,0 +1,15 @@
    +createQueryBuilder('Recently')
    +            ->where('Recently.user = :user AND Recently.feed = :feed')
    +            ->setParameters([
    +                'user' => $user,
    +                'feed' => $feed,
    +            ])
    +            ->getQuery()
    +            ->getOneOrNullResult();
    +    }
    +
    +    /**
    +     * Get recently used feeds for specified user.
    +     *
    +     * @param integer $user A User entity instance.
    +     *
    +     * @return AbstractFeed[]
    +     */
    +    public function getRecentlyUsedFor($user)
    +    {
    +        return array_map(function (RecentlyUsedFeed $usedFeed) {
    +            return $usedFeed->getFeed();
    +        }, $this->createQueryBuilder('Recently')
    +            ->select('partial Recently.{id}, Feed')
    +            ->join('Recently.feed', 'Feed')
    +            ->where('Recently.user = :user')
    +            ->setParameter('user', $user)
    +            ->orderBy('Recently.usedAt', 'desc')
    +            ->getQuery()
    +            ->getResult());
    +    }
    +
    +    /**
    +     * Add feed to recently used for specified user.
    +     *
    +     * @param User         $user A User entity instance.
    +     * @param AbstractFeed $feed A AbstractFeed entity instance.
    +     *
    +     * @return void
    +     */
    +    public function addRecentlyUsedFor(User $user, AbstractFeed $feed)
    +    {
    +        $entity = RecentlyUsedFeed::create()
    +            ->setUser($user)
    +            ->setFeed($feed);
    +
    +        $this->_em->persist($entity);
    +        $this->_em->flush($entity);
    +
    +        $this->_em->getConnection()->executeQuery(sprintf('
    +            DELETE FROM recently_used_feeds
    +            WHERE id IN (
    +                SELECT id FROM (
    +                    SELECT id
    +                    FROM recently_used_feeds
    +                    WHERE user_id = :user
    +                    ORDER BY used_at DESC LIMIT %d, %d
    +                ) x
    +            )
    +        ', RecentlyUsedFeed::POOL_SIZE, 1000), [ 'user' => $user->getId() ]);
    +        //
    +        // We set limit because mysql can't make offset
    +        // without limit ...
    +        // Limit it's a magic number. I think is biggest enough.
    +        //
    +    }
    +
    +    /**
    +     * Remove recently used feed for feed with specified id.
    +     *
    +     * @param integer $feed A Feed entity id.
    +     *
    +     * @return void
    +     */
    +    public function removeForFeed($feed)
    +    {
    +        $this->createQueryBuilder('Recently')
    +            ->delete()
    +            ->where('Recently.feed = :feed')
    +            ->setParameter('feed', $feed)
    +            ->getQuery()
    +            ->execute();
    +    }
    +}
    diff --git a/src/UserBundle/Repository/RecipientRepository.php b/src/UserBundle/Repository/RecipientRepository.php
    new file mode 100644
    index 0000000..df8bd4f
    --- /dev/null
    +++ b/src/UserBundle/Repository/RecipientRepository.php
    @@ -0,0 +1,69 @@
    +_em->getExpressionBuilder();
    +        $condition = $expr->andX($expr->eq('Recipient.owner', ':owner'));
    +        $parameters = new ArrayCollection([
    +            new Parameter('owner', $owner),
    +        ]);
    +
    +        if ($keyword !== '') {
    +            $condition->add($expr->like('Recipient.name', ':keyword'));
    +            $parameters->add(new Parameter('keyword', '%'. $keyword .'%'));
    +        }
    +
    +        if (count($exclude) > 0) {
    +            $condition->add($expr->notIn('Recipient.id', ':ids'));
    +            $parameters->add(new Parameter('ids', $exclude));
    +        }
    +
    +        return $this->createQueryBuilder('Recipient')
    +            ->where($condition)
    +            ->setParameters($parameters)
    +            ->setMaxResults($limit)
    +            ->orderBy('Recipient.name')
    +            ->getQuery()
    +            ->getResult();
    +    }
    +
    +    /**
    +     * @param integer $owner A User entity id.
    +     *
    +     * @return \Doctrine\ORM\QueryBuilder
    +     */
    +    public function getListQueryBuilder($owner)
    +    {
    +        return $this->createQueryBuilder('Recipient')
    +            ->where('Recipient.owner = :owner')
    +            ->setParameter('owner', $owner);
    +    }
    +}
    diff --git a/src/UserBundle/Repository/SubscriptionRepository.php b/src/UserBundle/Repository/SubscriptionRepository.php
    new file mode 100644
    index 0000000..0054cb3
    --- /dev/null
    +++ b/src/UserBundle/Repository/SubscriptionRepository.php
    @@ -0,0 +1,61 @@
    +createQueryBuilder('Subscription')
    +            ->where('Subscription.plan = :plan')
    +            ->setParameter('plan', $planId)
    +            ->getQuery()
    +            ->getResult();
    +    }
    +
    +    /**
    +     * Set search per days limit to zero.
    +     *
    +     * @return void
    +     */
    +    public function renewSearchLimits()
    +    {
    +        $this->createQueryBuilder('Subscription')
    +            ->update()
    +            ->set('Subscription.searchesPerDay', 0)
    +            ->getQuery()
    +            ->execute();
    +    }
    +
    +    /**
    +     * @param integer $organization A Organization entity id.
    +     *
    +     * @return \Doctrine\ORM\QueryBuilder
    +     */
    +    public function getForOrganization($organization)
    +    {
    +        return $this->createQueryBuilder('Subscription')
    +            ->addSelect('Owner, partial Plan.{id, name}')
    +            ->join('Subscription.owner', 'Owner')
    +            ->join('Subscription.plan', 'Plan')
    +            ->where('Subscription.organization = :organization')
    +            ->setParameter('organization', $organization);
    +    }
    +}
    diff --git a/src/UserBundle/Repository/UserRepository.php b/src/UserBundle/Repository/UserRepository.php
    new file mode 100644
    index 0000000..4585d76
    --- /dev/null
    +++ b/src/UserBundle/Repository/UserRepository.php
    @@ -0,0 +1,151 @@
    +createQueryBuilder('User')
    +            ->join('User.billingSubscription', 'Subscription')
    +            ->where('User.verified = 0 AND Subscription.payed = 1');
    +    }
    +
    +    /**
    +     * Get user with his billing subscription.
    +     *
    +     * @param integer $id User entity id.
    +     *
    +     * @return User|null
    +     */
    +    public function getWithBillingSubscription($id)
    +    {
    +        return $this->createQueryBuilder('User')
    +            ->addSelect('BillingSubscription, Plan')
    +            ->join('User.billingSubscription', 'BillingSubscription')
    +            ->join('BillingSubscription.plan', 'Plan')
    +            ->where('User.id = :id')
    +            ->setParameter('id', $id)
    +            ->getQuery()
    +            ->getOneOrNullResult();
    +    }
    +
    +   /**
    +     * Get user with his billing subscription.
    +     *
    +     * @param integer $id User entity id.
    +     *
    +     * @return User|null
    +     */
    +    public function getAllUserBillingSubscription($currentDate)
    +    {
    +        $startDate = $currentDate.' 00:00:00';
    +        $endDate = $currentDate.' 23:59:59';
    +        return $this->createQueryBuilder('User')
    +            ->addSelect('BillingSubscription')
    +            ->join('User.billingSubscription', 'BillingSubscription')
    +            ->where('BillingSubscription.isSubscriptionCancelled = :isSubscriptionCancelled')
    +            ->setParameter('isSubscriptionCancelled', true)
    +            ->andWhere('BillingSubscription.endDate >= :endDate1')
    +            ->setParameter('endDate1', $startDate)
    +            ->andWhere('BillingSubscription.endDate <= :endDate2')
    +            ->setParameter('endDate2', $endDate)
    +            ->getQuery()
    +            ->getResult();
    +    }
    +
    +    /**
    +     * Get user with his billing subscription.
    +     *
    +     * @param integer $id User entity id.
    +     *
    +     * @return User|null
    +     */
    +    public function getAllUserBillingSubscriptionPlanDowngrade($currentDate)
    +    {
    +        $startDate = $currentDate.' 00:00:00';
    +        $endDate = $currentDate.' 23:59:59';
    +        return $this->createQueryBuilder('User')
    +            ->addSelect('BillingSubscription')
    +            ->join('User.billingSubscription', 'BillingSubscription')
    +            ->where('BillingSubscription.isPlanDowngrade = :isPlanDowngrade')
    +            ->setParameter('isPlanDowngrade', true)
    +            ->andWhere('BillingSubscription.isSubscriptionCancelled = :isSubscriptionCancelled')
    +            ->setParameter('isSubscriptionCancelled', false)
    +            ->andWhere('BillingSubscription.endDate >= :endDate1')
    +            ->setParameter('endDate1', $startDate)
    +            ->andWhere('BillingSubscription.endDate <= :endDate2')
    +            ->setParameter('endDate2', $endDate)
    +            ->getQuery()
    +            ->getResult();
    +    }
    +
    +    /**
    +     * Get user by role and search words
    +     *
    +     * @param UserRoleEnum $role        Requested user role.
    +     * @param array        $searchWords Search words which should be contained in
    +     *                                  user full name or email.
    +     *
    +     * @return \Doctrine\ORM\QueryBuilder
    +     */
    +    public function getUserByRoleQB(UserRoleEnum $role, array $searchWords)
    +    {
    +        $expr = $this->_em->getExpressionBuilder();
    +        $qb = $this->createQueryBuilder('User')
    +            ->addSelect('BillingSubscription, Plan')
    +            ->leftJoin('User.billingSubscription', 'BillingSubscription')
    +            ->leftJoin('BillingSubscription.plan', 'Plan')
    +            ->where($expr->andX(
    +                $expr->like('User.roles', ':role'),
    +                $expr->andX('User.verified = 1')
    +            ))
    +            ->setParameter(':role', '%'.$role.'%');
    +
    +        if (count($searchWords)) {
    +            $condition = $expr->orX();
    +            foreach ($searchWords as $key => $word) {
    +                $condition->add($expr->like('User.firstName', ':word_'.$key));
    +                $condition->add($expr->like('User.lastName', ':word_'.$key));
    +                $condition->add($expr->like('User.email', ':word_'.$key));
    +                $qb->setParameter('word_'.$key, '%'.$word.'%');
    +            }
    +            $qb->andWhere($condition);
    +        }
    +
    +        return $qb;
    +    }
    +
    +    /**
    +     * Get all subscribers fro given user.
    +     *
    +     * @param integer $user A master User entity id.
    +     *
    +     * @return \Doctrine\ORM\QueryBuilder
    +     */
    +    public function getSubscribersQueryBuilder($user)
    +    {
    +        $expr = $this->_em->getExpressionBuilder();
    +
    +        return $this->createQueryBuilder('Subscriber')
    +            ->where($expr->eq('Subscriber.masterUser', ':user'))
    +            ->setParameter('user', $user);
    +    }
    +}
    diff --git a/src/UserBundle/Resources/config/controllers.yml b/src/UserBundle/Resources/config/controllers.yml
    new file mode 100644
    index 0000000..af3c984
    --- /dev/null
    +++ b/src/UserBundle/Resources/config/controllers.yml
    @@ -0,0 +1,59 @@
    +services:
    +  user.controller.user:
    +    class: 'UserBundle\Controller\V1\UserController'
    +    arguments:
    +      - '@security.token_storage'
    +      - '@form.factory'
    +      - '@fos_user.user_manager'
    +      - '@service_container'
    +
    +  user.controller.registration:
    +    class: 'UserBundle\Controller\Security\RegistrationController'
    +    parent: api.controller.abstract
    +
    +  user.controller.plan:
    +    class: 'UserBundle\Controller\Security\PlanController'
    +    parent: api.controller.abstract
    +
    +  user.controller.cost_calculation:
    +    class: 'UserBundle\Controller\Security\CostCalculationController'
    +    parent: api.controller.abstract
    +
    +  user.controller.resetting:
    +    class: 'UserBundle\Controller\Security\ResettingController'
    +    parent: api.controller.abstract
    +
    +  user.controller.notification:
    +    class: 'UserBundle\Controller\V1\NotificationController'
    +    parent: api.controller.abstract_crud
    +    arguments:
    +      index_1: 'UserBundle\Entity\Notification\Notification'
    +
    +  user.controller.notification_theme:
    +    class: 'UserBundle\Controller\V1\NotificationThemeController'
    +    parent: api.controller.abstract
    +
    +  user.controller.receiver:
    +    class: 'UserBundle\Controller\V1\ReceiverController'
    +    arguments:
    +      - '@security.token_storage'
    +      - '@doctrine.orm.default_entity_manager'
    +
    +  user.controller.person_recipient:
    +    class: 'UserBundle\Controller\V1\PersonRecipientController'
    +    parent: api.controller.abstract_crud
    +    arguments:
    +      index_1: 'UserBundle\Entity\Recipient\PersonRecipient'
    +
    +  user.controller.group_recipient:
    +    class: 'UserBundle\Controller\V1\GroupRecipientController'
    +    parent: api.controller.abstract_crud
    +    arguments:
    +      index_1: 'UserBundle\Entity\Recipient\GroupRecipient'
    +
    +  user.controller.current_subscriber:
    +    class: 'UserBundle\Controller\V1\CurrentSubscriberController'
    +    parent: api.controller.abstract
    +  user.controller.hubspot_registration:
    +    class: 'UserBundle\Controller\Security\HubSpotRegistrationController'
    +    parent: api.controller.abstract
    \ No newline at end of file
    diff --git a/src/UserBundle/Resources/config/forms.yml b/src/UserBundle/Resources/config/forms.yml
    new file mode 100644
    index 0000000..58e3558
    --- /dev/null
    +++ b/src/UserBundle/Resources/config/forms.yml
    @@ -0,0 +1,52 @@
    +imports:
    +  - { resource: controllers.yml }
    +
    +services:
    +  user.form.registration:
    +    class: 'UserBundle\Form\RegistrationType'
    +    arguments:
    +      - '@doctrine.orm.default_entity_manager'
    +    tags:
    +      - { name: form.type }
    +  user.form.hubspot_registration:
    +    class: 'UserBundle\Form\HubSpotRegistrationType'
    +    arguments:
    +      - '@doctrine.orm.default_entity_manager'
    +    tags:
    +      - { name: form.type }
    +
    +  user.form.sources:
    +    class: 'UserBundle\Form\Type\SourcesType'
    +    arguments:
    +      - '@doctrine.orm.entity_manager'
    +      - '@security.token_storage'
    +    tags:
    +      - { name: form.type }
    +
    +  user.form.change_password:
    +    class: 'UserBundle\Form\ChangePasswordType'
    +    arguments:
    +      - '@security.password_encoder'
    +    tags:
    +      - { name: form.type }
    +
    +  user.form.person_recipient:
    +    class: 'UserBundle\Form\PersonRecipientType'
    +    arguments:
    +      - '@security.token_storage'
    +    tags:
    +      - { name: form.type }
    +
    +  user.form.group_recipient:
    +    class: 'UserBundle\Form\GroupRecipientType'
    +    arguments:
    +      - '@security.token_storage'
    +    tags:
    +      - { name: form.type }
    +
    +  user.form.payment_data:
    +    class: 'UserBundle\Form\PaymentDataType'
    +    arguments:
    +      - '@doctrine.orm.default_entity_manager'
    +    tags:
    +      - { name: form.type }
    \ No newline at end of file
    diff --git a/src/UserBundle/Resources/config/repositories.yml b/src/UserBundle/Resources/config/repositories.yml
    new file mode 100644
    index 0000000..ab01297
    --- /dev/null
    +++ b/src/UserBundle/Resources/config/repositories.yml
    @@ -0,0 +1,6 @@
    +services:
    +  user.repository.plan:
    +    class: 'UserBundle\Repository\PlanRepository'
    +    factory: [ '@doctrine.orm.default_entity_manager', 'getRepository' ]
    +    arguments:
    +      - 'UserBundle\Entity\Plan'
    \ No newline at end of file
    diff --git a/src/UserBundle/Resources/config/routing/security.yml b/src/UserBundle/Resources/config/routing/security.yml
    new file mode 100644
    index 0000000..3bc56ab
    --- /dev/null
    +++ b/src/UserBundle/Resources/config/routing/security.yml
    @@ -0,0 +1,3 @@
    +security:
    +    resource: '@UserBundle/Controller/Security/'
    +    type: annotation
    diff --git a/src/UserBundle/Resources/config/routing/v1.yml b/src/UserBundle/Resources/config/routing/v1.yml
    new file mode 100644
    index 0000000..12e785d
    --- /dev/null
    +++ b/src/UserBundle/Resources/config/routing/v1.yml
    @@ -0,0 +1,3 @@
    +v1:
    +  resource: '@UserBundle/Controller/V1'
    +  type: annotation
    \ No newline at end of file
    diff --git a/src/UserBundle/Resources/config/services.yml b/src/UserBundle/Resources/config/services.yml
    new file mode 100644
    index 0000000..d4f2c94
    --- /dev/null
    +++ b/src/UserBundle/Resources/config/services.yml
    @@ -0,0 +1,105 @@
    +imports:
    +  - { resource: controllers.yml }
    +  - { resource: forms.yml }
    +  - { resource: repositories.yml }
    +
    +services:
    +  user.command.renew_search_limits:
    +    class: 'UserBundle\Command\RenewSearchLimitsCommand'
    +    arguments:
    +      - '@doctrine.orm.default_entity_manager'
    +      - '@monolog.logger.queue_command'
    +    tags:
    +      - { name: console.command }
    +
    +  user.command.cancel_subscription:
    +    class: 'UserBundle\Command\CancelSubscriptionCommand'
    +    arguments:
    +      - '@doctrine.orm.default_entity_manager'
    +      - '@monolog.logger.queue_command'
    +    tags:
    +      - { name: console.command }    
    +  
    +  user.command.downgrade_subscription_plan:
    +    class: 'UserBundle\Command\DowngradeSubscriptionPlanCommand'
    +    arguments:
    +      - '@doctrine.orm.default_entity_manager'
    +      - '@monolog.logger.queue_command'
    +      - '@service_container'
    +    tags:
    +      - { name: console.command }    
    +
    +  user.mailer.default:
    +    class: 'UserBundle\Mailer\Mailer'
    +    arguments:
    +      - '@mailer'
    +      - '@swiftmailer.transport.real'
    +      - '@twig'
    +      - '@app.configuration'
    +      - '@router'
    +    lazy: true
    +
    +  user.mailer:
    +    class: 'UserBundle\Mailer\LoggableMailer'
    +    arguments:
    +      - '@user.mailer.default'
    +      - '@monolog.logger.mailer'
    +
    +  user.role_checker:
    +    class: 'UserBundle\Utils\RoleChecker\RoleChecker'
    +    arguments:
    +      - '@security.role_hierarchy'
    +      - '%security.role_hierarchy.roles%'
    +
    +  user.notification_manager:
    +    class: 'UserBundle\Manager\Notification\NotificationManager'
    +    arguments:
    +      - '@doctrine.orm.default_entity_manager'
    +      - '@cache.feed_fetcher_factory'
    +      - '@app.configuration'
    +      - '@cache.document_content_extractor'
    +
    +  user.user_manager:
    +    class: 'UserBundle\Manager\User\UserManager'
    +    arguments:
    +      - '@fos_user.util.password_updater'
    +      - '@fos_user.util.canonical_fields_updater'
    +      - '@fos_user.object_manager'
    +      - '%fos_user.model.user.class%'
    +
    +  user.twig_extension.htmlcompress:
    +    class: 'nochso\HtmlCompressTwig\Extension'
    +    tags:
    +      - { name: twig.extension }
    +
    +  user.twig_extension:
    +    class: 'UserBundle\Twig\TwigExtension'
    +    tags:
    +      - { name: twig.extension }
    +
    +  user.inspector.notification:
    +    class: 'UserBundle\Security\Inspector\NotificationInspector'
    +    tags:
    +      - { name: socialhose.inspector }
    +
    +  user.inspector.group_recipient:
    +    class: 'UserBundle\Security\Inspector\GroupRecipientInspector'
    +    tags:
    +      - { name: socialhose.inspector }
    +
    +  user.inspector.person_recipient:
    +    class: 'UserBundle\Security\Inspector\PersonRecipientInspector'
    +    tags:
    +      - { name: socialhose.inspector }
    +
    +  stripe.service:
    +      class: 'UserBundle\Services\StripeService'
    +      arguments:
    +          $stripe_auth_api_secret_key: '%stripe_secret_key%'
    +          $stripe_auth_api_publish_key: '%stripe_publish_key%'    
    +      lazy: true  
    +
    +  cost.calculation:
    +      class: UserBundle\Controller\Security\CostCalculationController
    +      arguments:
    +        - '@service_container'
    \ No newline at end of file
    diff --git a/src/UserBundle/Resources/config/validation.yml b/src/UserBundle/Resources/config/validation.yml
    new file mode 100644
    index 0000000..489b9d2
    --- /dev/null
    +++ b/src/UserBundle/Resources/config/validation.yml
    @@ -0,0 +1,98 @@
    +UserBundle\Entity\User:
    +    constraints:
    +        - Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity:
    +            fields: email
    +            groups:
    +                - registration
    +                - subscribers_creation
    +                - admin_users_creation
    +                - admin_administrator_creation
    +
    +    properties:
    +        firstName:
    +            - NotBlank:
    +                groups:
    +                    - registration
    +                    - subscribers_creation
    +                    - admin_users_creation
    +                    - admin_administrator_creation
    +
    +        lastName:
    +            - NotBlank:
    +                groups:
    +                    - registration
    +                    - subscribers_creation
    +                    - admin_users_creation
    +                    - admin_administrator_creation
    +
    +        email:
    +            - NotBlank:
    +                groups:
    +                    - registration
    +                    - subscribers_creation
    +                    - admin_users_creation
    +                    - admin_administrator_creation
    +            - Email:
    +                groups:
    +                    - registration
    +                    - subscribers_creation
    +                    - admin_users_creation
    +                    - admin_administrator_creation
    +
    +        position:
    +            - NotBlank:
    +                groups:
    +                    - subscribers_creation
    +                    - admin_subscribers_creation
    +
    +        phoneNumber:
    +            - NotBlank:
    +                groups:
    +                    - subscribers_creation
    +                    - admin_subscribers_creation
    +
    +        password:
    +            - NotBlank:
    +                groups:
    +                    - resetting
    +
    +        plainPassword:
    +            - NotBlank:
    +                groups:
    +                    - resetting
    +                    - admin_administrator_creation_password
    +        masterUser:
    +            - NotNull:
    +                groups:
    +                    - admin_subscribers_creation
    +        
    +        companyName:
    +            - NotBlank:
    +                groups:
    +                    - registration
    +                    - subscribers_creation
    +                    - admin_users_creation
    +                    - admin_administrator_creation
    +        
    +        jobFunction:
    +            - NotBlank:
    +                groups:
    +                    - registration
    +                    - subscribers_creation
    +                    - admin_users_creation
    +                    - admin_administrator_creation
    +        industry:
    +            - NotBlank:
    +                groups:
    +                    - registration
    +                    - subscribers_creation
    +                    - admin_users_creation
    +                    - admin_administrator_creation  
    +        
    +        numberOfEmployee:
    +            - NotBlank:
    +                groups:
    +                    - registration
    +                    - subscribers_creation
    +                    - admin_users_creation
    +                    - admin_administrator_creation
    \ No newline at end of file
    diff --git a/src/UserBundle/Resources/translations/FOSUserBundle.en.yml b/src/UserBundle/Resources/translations/FOSUserBundle.en.yml
    new file mode 100644
    index 0000000..a8be31d
    --- /dev/null
    +++ b/src/UserBundle/Resources/translations/FOSUserBundle.en.yml
    @@ -0,0 +1,16 @@
    +
    +
    +registration:
    +    email:
    +        message: |
    +            Hi %username%!
    +
    +            You have recently registered for an account with SOCIALHOSE.IO under this email address. 
    +            Please verify your account by clicking here:
    +            %confirmationUrl%
    +
    +            This link can only be used once to validate this account.
    +
    +            Regards,
    +            SOCIALHOSE.IO  Team.
    +
    diff --git a/src/UserBundle/Resources/translations/email.en.yml b/src/UserBundle/Resources/translations/email.en.yml
    new file mode 100644
    index 0000000..38fd8ad
    --- /dev/null
    +++ b/src/UserBundle/Resources/translations/email.en.yml
    @@ -0,0 +1,19 @@
    +password_change:
    +  subject: Password is changed
    +  message: |
    +    

    Hello %firstName% %lastName%!

    You new password is %password%

    Regards, the Team.

    + +resetting: + subject: Password resetting + message: | +

    Hello %firstName% %lastName%!

    To reset your password - please visit %confirmationUrl%

    Regards, the Team.

    + +verification_success: + subject: 'Verification status' + message: | +

    Hello %firstName% %lastName%!

    You registration is verified and you may proceed login with you credentials

    Email: %email% Password: %password%

    Regards, the Team.

    + +verification_rejected: + subject: 'Verification status' + message: | +

    Hello %firstName% %lastName%!

    Unfortunately you registration is rejected. Payments will be refund.

    Regards, the Team.

    diff --git a/src/UserBundle/Resources/translations/notification.en.yml b/src/UserBundle/Resources/translations/notification.en.yml new file mode 100644 index 0000000..a08a891 --- /dev/null +++ b/src/UserBundle/Resources/translations/notification.en.yml @@ -0,0 +1,2 @@ +notification: + articles: articles \ No newline at end of file diff --git a/src/UserBundle/Resources/views/Notification/Partial/Content/comments.html.twig b/src/UserBundle/Resources/views/Notification/Partial/Content/comments.html.twig new file mode 100644 index 0000000..a97b20c --- /dev/null +++ b/src/UserBundle/Resources/views/Notification/Partial/Content/comments.html.twig @@ -0,0 +1,36 @@ +{# + Content document comments. +#} + +{%- if (showUserComments != 'no') and (comments|length > 0) -%} + +{%- if themeType == 'enhanced' -%} +
    + Comments +{%- endif -%} +
    + {%- for comment in comments -%} +
    + {%- if comment.title is not empty -%} +
    + {{- comment.title -}} +
    + {%- endif -%} +
    + By + {{- comment.author.firstName -}}  + {{- comment.author.lastName -}} +   + {%- if showUserComments == 'with_author_date' -%} + on + {{- comment.createdAt|date('F d, Y H:i') -}} + + {%- endif -%} +
    +
    + {{- comment.content -}} +
    +
    + {%- endfor -%} +
    +{%- endif -%} diff --git a/src/UserBundle/Resources/views/Notification/Partial/Content/document.html.twig b/src/UserBundle/Resources/views/Notification/Partial/Content/document.html.twig new file mode 100644 index 0000000..0a9f1ad --- /dev/null +++ b/src/UserBundle/Resources/views/Notification/Partial/Content/document.html.twig @@ -0,0 +1,60 @@ +{# + Content document element. +#} + +{%- set isEnhanced = themeType == 'enhanced' -%} +{%- set showImage = isEnhanced and showImages and (document.image is not empty) -%} + +
    + {%- if isEnhanced -%} +
    + +
    + {%- endif -%} +
    +
    + +
    + + {{- document.source.title -}}  + {%- if showSourceCountry and document.source is not empty and document.source.country is not empty -%} + ({{ document.source.country }})  + {%- endif -%} + + {%- if document.author is not empty and document.author.name is not empty -%} + + {%- if not isEnhanced -%} +  -  + {%- endif -%} + {{- document.author.name -}} + + {%- endif -%} + + {%- if isEnhanced -%} +  |  + {%- else -%} +  -  + {%- endif -%} + {{- document.published|date('F d, Y H:i') -}} + +
    +
    + {{- document.content -}} +
    + {%- include _root ~ '/Partial/Content/comments.html.twig' with { + comments: document.comments, + showUserComments: showUserComments, + themeType: themeType + } -%} +
    + {%- if showImage -%} +
    + +
    + {%- endif -%} +
    +
    diff --git a/src/UserBundle/Resources/views/Notification/Partial/Content/index.html.twig b/src/UserBundle/Resources/views/Notification/Partial/Content/index.html.twig new file mode 100644 index 0000000..28c79e0 --- /dev/null +++ b/src/UserBundle/Resources/views/Notification/Partial/Content/index.html.twig @@ -0,0 +1,32 @@ +{# + Content component. +#} +{%- set isPlain = themeType == 'plain' -%} + +
    + {%- for feed in feeds -%} +
    +
    + {%- if not isPlain -%} + + {%- endif -%} + {{- feed.name -}} + {%- if isPlain -%}:{%- endif -%} +
    +
    +
    + {%- for document in feed.documents -%} + {%- include _root ~ '/Partial/Content/document.html.twig' with { + document: document, + showImages: showImages, + showSourceCountry: showSourceCountry, + showUserComments: showUserComments, + themeType: themeType + } -%} + {%- endfor -%} +
    + {%- if showSectionDivider and not loop.last and isPlain -%} +
    + {%- endif -%} + {%- endfor -%} +
    diff --git a/src/UserBundle/Resources/views/Notification/Partial/Content/style.css.twig b/src/UserBundle/Resources/views/Notification/Partial/Content/style.css.twig new file mode 100644 index 0000000..913602f --- /dev/null +++ b/src/UserBundle/Resources/views/Notification/Partial/Content/style.css.twig @@ -0,0 +1,145 @@ +{# + + Content component styles. + +#} +{%- import _root ~ '/macros.css.twig' as macro -%} + +.content .feed-title { + {{- macro.renderFont(fonts.feedTitle) -}} + margin-top: 5px; + {%- if themeType == 'enhanced' -%} + background: {{- colors.background.accent|raw -}}; + padding: 10px; + color: {{- colors.text.header|raw -}}; + {%- endif -%} +} + +.content .feed-title img { + padding-right: 10px; +} + +.content .feed-divider { + margin: 1.8em 0.4em 1em; + border-bottom: 1px solid black; + display: block; + color: rgb(55, 55, 57); +} + +.content .documents .document { +{%- if themeType == 'plain'-%} + margin-top: 10px; + margin-left: 5px; +{%- else -%} + margin-top: 5px; + display: flex; + flex: auto; +{%- endif -%} +} + +{%- if themeType == 'plain' -%} +.content .documents .document:last-child { + margin-bottom: 25px; +} +{%- endif -%} + +{%- if themeType == 'enhanced' -%} +.content .documents .document-aside { + padding: 10px 5px 2px 5px; +} + +.content .documents .document-main { + padding: 0 5px 10px 0; + background: white; + border-bottom: 1px solid #e6e6e6; + display: flex; + width: 100%; +} +.content .documents .document-body { + flex: auto; + padding: 8px; +} + +.content .documents .document-image { + flex-basis: 170px; +} + +.content .documents .document-image img { + width: 160px; +} +{%- endif -%} + +.content .documents .document-headline a, +.content .documents .document-headline a:hover, +.content .documents .document-headline a:visited, +.content .documents .document-headline a:active { + {{- macro.renderFont(fonts.articleHeadline) -}} + color: {{- colors.text.articleHeadline|raw -}}; +} + +.content .documents .document-headline, +.content .documents .document-info { + margin-bottom: 5px; +} + +.content .documents .document-source { + color: {{- colors.text.source|raw -}}; + {{- macro.renderFont(fonts.source) -}}; +} + +.content .documents .document-author { + color: {{- colors.text.author|raw -}}; + {{- macro.renderFont(fonts.author) -}}; +} + +.content .documents .document-date { + {%- if themeType == 'plain' -%} + color: {{- colors.text.publishDate|raw -}}; + {%- else -%} + color: {{- colors.text.articleContent|raw -}}; + {%- endif -%} + {{- macro.renderFont(fonts.date) -}}; +} + +.content .document .document-content { + {{- macro.renderFont(fonts.articleContent) -}}; + text-align: justify; +} + +.content .comments { + padding-left: 20px; +} + +.content .comments .comment { + margin-top: 15px; +} + +{%- if themeType == 'enhanced' -%} +.content .comments .comment-title { + font-weight: bold; +} +{%- endif -%} + +.content .comments .comment-author { + color: {{- colors.text.author|raw -}}; +{%- if themeType == 'plain' -%} +{{- macro.renderFont(fonts.author) -}}; +{%- endif -%} +} + +.content .comments .comment-date { +{%- if themeType == 'plain' -%} + color: {{- colors.text.publishDate|raw -}}; + {{- macro.renderFont(fonts.date) -}}; +{%- else -%} + color: {{- colors.text.articleContent|raw -}}; +{%- endif -%} +} + +.content .comments .comment-info { + margin: 5px 0 ; +} + +.content .comments .comment-body { + padding-left: 20px; +} diff --git a/src/UserBundle/Resources/views/Notification/Partial/Footer/index.html.twig b/src/UserBundle/Resources/views/Notification/Partial/Footer/index.html.twig new file mode 100644 index 0000000..b416ab8 --- /dev/null +++ b/src/UserBundle/Resources/views/Notification/Partial/Footer/index.html.twig @@ -0,0 +1,13 @@ +{# + Footer component. +#} + + \ No newline at end of file diff --git a/src/UserBundle/Resources/views/Notification/Partial/Footer/style.css.twig b/src/UserBundle/Resources/views/Notification/Partial/Footer/style.css.twig new file mode 100644 index 0000000..79f160b --- /dev/null +++ b/src/UserBundle/Resources/views/Notification/Partial/Footer/style.css.twig @@ -0,0 +1,40 @@ +{# + + Footer component styles. + +#} + +.footer { + padding: 0 20px; +{%- if themeType == 'plain' -%} + border-top: 3px double #FFF; +{%- endif -%} + background: #e9e9ea; + height: 54px; + margin-top: 20px; + box-sizing: border-box; +} + +.footer__list { + margin: 0; + padding: 0; +} + +.footer__item { + display: inline-block; + margin-right: 5px; + padding: 0; +} + +.footer__link { + text-shadow: 1px 2px 1px #FFF; + line-height: 49px; + display: block; + padding: 0 10px; + color: #373739; + font-size: 12px; +} + +.footer__link:hover { + background: #D9D9D9; +} diff --git a/src/UserBundle/Resources/views/Notification/Partial/Header/index.html.twig b/src/UserBundle/Resources/views/Notification/Partial/Header/index.html.twig new file mode 100644 index 0000000..49c6ea3 --- /dev/null +++ b/src/UserBundle/Resources/views/Notification/Partial/Header/index.html.twig @@ -0,0 +1,29 @@ +{# + + Header component. + +#} + +{% set enhanced = themeType == 'enhanced' %} + +{%- if enhanced or header.imageUrl is not empty -%} + +{%- endif -%} diff --git a/src/UserBundle/Resources/views/Notification/Partial/Header/style.css.twig b/src/UserBundle/Resources/views/Notification/Partial/Header/style.css.twig new file mode 100644 index 0000000..3405be3 --- /dev/null +++ b/src/UserBundle/Resources/views/Notification/Partial/Header/style.css.twig @@ -0,0 +1,56 @@ +{# + + Header component styles. + +#} +{%- import _root ~ '/macros.css.twig' as macro -%} + +{% set headerHeight = '105px' %} + +.email-header { + {%- if themeType == 'plain' -%} + color: white; + {%- if imageUrl != '' -%}height: {{- headerHeight -}};{%- endif -%} + {%- else -%} + height: {{- headerHeight -}}; + background: {{- colors.background.header|raw -}}; + {%- endif -%} +} + +.email-header-logo { + display: inline-block; + height: 100%; + width: 75%; + border: none; +} +.email-header-logo img { + padding-top: 25px; + padding-left: 26px; + vertical-align: top; +} + +.email-header-info { + text-align: right; + float: right; + display: inline-block; + width: 25%; + height: 80%; + border: none; + position: relative; +} + +.email-header-info-title { + {{- macro.renderFont(fonts.header) -}} + color: {{- colors.text.header|raw -}}; + margin-bottom: 5px; +} + +.email-header-info-date { + color: {{- colors.background.accent|raw -}}; +} + +.email-header-info-content { + position: absolute; + right: 20px; + bottom: 0; +} \ No newline at end of file diff --git a/src/UserBundle/Resources/views/Notification/Partial/TableOfContents/document.html.twig b/src/UserBundle/Resources/views/Notification/Partial/TableOfContents/document.html.twig new file mode 100644 index 0000000..8bfe3bd --- /dev/null +++ b/src/UserBundle/Resources/views/Notification/Partial/TableOfContents/document.html.twig @@ -0,0 +1,16 @@ +{# + + Table of contents document element. + +#} + diff --git a/src/UserBundle/Resources/views/Notification/Partial/TableOfContents/feed.html.twig b/src/UserBundle/Resources/views/Notification/Partial/TableOfContents/feed.html.twig new file mode 100644 index 0000000..4bb078b --- /dev/null +++ b/src/UserBundle/Resources/views/Notification/Partial/TableOfContents/feed.html.twig @@ -0,0 +1,32 @@ +{# + + Table of contents feed element. + +#} + +
    + + {{- feed.name -}} + + + {%- if showArticlesCount -%} + {%- if themeType == 'plain' -%} + ({{- feed.documentsCount }} articles) + {%- else -%} + {{- feed.documentsCount }} articles + {%- endif -%} + {%- endif -%} + + {%- if tableOfContents != 'simple' -%} +
      + {%- for document in feed.documents -%} +
    • + {%- include _root ~ '/Partial/TableOfContents/document.html.twig' with { + tableOfContents: tableOfContents, + document: document + } -%} +
    • + {%- endfor -%} +
    + {%- endif -%} +
    \ No newline at end of file diff --git a/src/UserBundle/Resources/views/Notification/Partial/TableOfContents/index.html.twig b/src/UserBundle/Resources/views/Notification/Partial/TableOfContents/index.html.twig new file mode 100644 index 0000000..1496016 --- /dev/null +++ b/src/UserBundle/Resources/views/Notification/Partial/TableOfContents/index.html.twig @@ -0,0 +1,21 @@ +{# + + Table of contents. + +#} +{%- if tableOfContents != 'no' -%} +
    +
      + {%- for feed in feeds -%} +
    • + {%- include _root ~ '/Partial/TableOfContents/feed.html.twig' with { + tableOfContents: tableOfContents, + showArticlesCount: showArticlesCount, + feed: feed, + themeType: themeType + } -%} +
    • + {%- endfor -%} +
    +
    +{%- endif -%} diff --git a/src/UserBundle/Resources/views/Notification/Partial/TableOfContents/style.css.twig b/src/UserBundle/Resources/views/Notification/Partial/TableOfContents/style.css.twig new file mode 100644 index 0000000..b365bf3 --- /dev/null +++ b/src/UserBundle/Resources/views/Notification/Partial/TableOfContents/style.css.twig @@ -0,0 +1,122 @@ +{# + + Table of contents component styles. + +#} +{%- import _root ~ '/macros.css.twig' as macro -%} + +{%- if themeType == 'plain' -%} + .table-of-contents { + margin-left: 5px; + } + + .table-of-contents li:before { + content: '• '; + color: #636363; + font-size: {{- fonts.tableOfContents.size -}}px; + {%- if fonts.tableOfContents.style.bold -%} + font-weight: bold; + {%- endif -%} + {%- if fonts.tableOfContents.style.italic -%} + font-style: italic; + {%- endif -%} + text-decoration: none; + } +{%- else -%} + .table-of-contents .feeds li { + background: white; + display: block; + padding: 5px 10px; + } + + .table-of-contents .feeds > li { + margin-bottom: 1px; + border-bottom: 1px solid #e6e6e6; + } + + .table-of-contents .feeds > li:before { + width: 6px; + height: 8px; + content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAYAAAAICAQAAABwz0azAAAAN0lEQVQIW2NIm5rmlGbHAAFp/4EQJgDmgODkNCcE53/aagSnDqgw7TuQcTrNB6LnZloz3DRkAAC1LiXJoeG8xgAAAABJRU5ErkJggg==); + } + + .table-of-contents .documents > li:before { + content: '• '; + color: #636363; + margin-right: 10px; + font-size: {{- fonts.articleContent.size -}}px; + {%- if fonts.articleContent.style.bold -%} + font-weight: bold; + {%- endif -%} + {%- if fonts.articleContent.style.italic -%} + font-style: italic; + {%- endif -%} + text-decoration: none; + } + +{%- endif -%} + + +.table-of-contents ul { + list-style: none; +} + +.table-of-contents .feeds { + padding: 0; + margin: 0; +} + +.table-of-contents .feeds .feed, +.table-of-contents .documents .document { + display: inline; +} + +.table-of-contents .feeds .feed .feed-name, +.table-of-contents .feeds .feed .feed-document-count { + {{- macro.renderFont(fonts.tableOfContents) -}}; +} +{%- if themeType == 'plain' -%} + .table-of-contents .feeds .feed .feed-document-count:before { + content: ' '; + } +{%- else -%} + .table-of-contents .feeds .feed .feed-name { + margin-left: 20px; + } + + .table-of-contents .feeds .feed .feed-name, + .table-of-contents .feeds .feed .feed-document-count { + width: 48%; + display: inline-block; + } + +{%- endif -%} + +.table-of-contents .documents { + padding: 0 0 0 15px; +} + +.table-of-contents .documents .document .source { + color: {{- colors.text.source|raw -}}; +} + +.table-of-contents .documents .document a, +.table-of-contents .documents .document a:hover, +.table-of-contents .documents .document a:visited, +.table-of-contents .documents .document a:active { + {%- if themeType == 'plain' -%} + color: {{- colors.text.articleHeadline|raw -}}; + {{- macro.renderFont(fonts.tableOfContents) -}}; + {%- else -%} + color: {{- colors.text.articleContent|raw -}}; + {{- macro.renderFont(fonts.articleContent) -}}; + {%- endif -%} + +} + +{%- if themeType == 'plain' -%} +.table-of-contents .documents .document a:after { + content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAAAAACoWZBhAAAAAnRSTlMAAHaTzTgAAAArSURBVAgdBcGBAAAAAMOg/A3mdKgXqCpYECikqiHRLEmzKlkrI5YgEFRVOB2zIawhqiEzAAAAAElFTkSuQmCC); + padding-left: 3px; +} +{%- endif -%} diff --git a/src/UserBundle/Resources/views/Notification/index.html.twig b/src/UserBundle/Resources/views/Notification/index.html.twig new file mode 100644 index 0000000..7ae2584 --- /dev/null +++ b/src/UserBundle/Resources/views/Notification/index.html.twig @@ -0,0 +1,223 @@ +{% set _root = _root|default('UserBundle:Notification:') %} + + + + + + + + + + + + {%- block title -%}{%- endblock title -%} + {# + + CSS normalization. + + #} + + {%- block stylesheets -%}{%- endblock stylesheets -%} + + + + + + + diff --git a/src/UserBundle/Resources/views/Notification/macros.css.twig b/src/UserBundle/Resources/views/Notification/macros.css.twig new file mode 100644 index 0000000..7a11775 --- /dev/null +++ b/src/UserBundle/Resources/views/Notification/macros.css.twig @@ -0,0 +1,17 @@ +{%- macro renderFont(font) -%} +font-family: {{- font.family|raw -}}; +font-size: {{- font.size -}}px; + +{%- if (font.style.bold) -%} + font-weight: bold; +{%- endif -%} + +{%- if (font.style.italic) -%} + font-style: italic; +{%- endif -%} + +{%- if (font.style.underline) -%} + text-decoration: underline; +{%- endif -%} + +{%- endmacro -%} \ No newline at end of file diff --git a/src/UserBundle/Resources/views/Notification/verification_email.html.twig b/src/UserBundle/Resources/views/Notification/verification_email.html.twig new file mode 100644 index 0000000..e69de29 diff --git a/src/UserBundle/Resources/views/Registration/check_email.html.twig b/src/UserBundle/Resources/views/Registration/check_email.html.twig new file mode 100644 index 0000000..41af21d --- /dev/null +++ b/src/UserBundle/Resources/views/Registration/check_email.html.twig @@ -0,0 +1,7 @@ +{% extends "@FOSUser/layout.html.twig" %} + +{% trans_default_domain 'FOSUserBundle' %} + +{% block fos_user_content %} +

    {{ 'registration.check_email'|trans({'%email%': user.email}) }}

    +{% endblock fos_user_content %} diff --git a/src/UserBundle/Resources/views/Registration/confirmed.html.twig b/src/UserBundle/Resources/views/Registration/confirmed.html.twig new file mode 100644 index 0000000..4402b4f --- /dev/null +++ b/src/UserBundle/Resources/views/Registration/confirmed.html.twig @@ -0,0 +1,10 @@ +{% extends "@FOSUser/layout.html.twig" %} + +{% trans_default_domain 'FOSUserBundle' %} + +{% block fos_user_content %} +

    {{ 'registration.confirmed'|trans({'%username%': user.username}) }}

    + {% if targetUrl %} +

    {{ 'registration.back'|trans }}

    + {% endif %} +{% endblock fos_user_content %} diff --git a/src/UserBundle/Resources/views/Registration/email.txt.twig b/src/UserBundle/Resources/views/Registration/email.txt.twig new file mode 100644 index 0000000..43b157b --- /dev/null +++ b/src/UserBundle/Resources/views/Registration/email.txt.twig @@ -0,0 +1,12 @@ +{% trans_default_domain 'FOSUserBundle' %} +{% block subject %} +{%- autoescape false -%} +{%- endautoescape -%} +{% endblock %} + +{% block body_text %} +{% autoescape false %} +{{ 'registration.email.message'|trans({'%username%': user.firstName, '%confirmationUrl%': confirmationUrl, '%email%' :user.email}) }} +{% endautoescape %} +{% endblock %} +{% block body_html %}{% endblock %} diff --git a/src/UserBundle/Resources/views/Registration/register.html.twig b/src/UserBundle/Resources/views/Registration/register.html.twig new file mode 100644 index 0000000..92b6878 --- /dev/null +++ b/src/UserBundle/Resources/views/Registration/register.html.twig @@ -0,0 +1,5 @@ +{% extends "@FOSUser/layout.html.twig" %} + +{% block fos_user_content %} +{% include "@FOSUser/Registration/register_content.html.twig" %} +{% endblock fos_user_content %} diff --git a/src/UserBundle/Resources/views/Registration/register_content.html.twig b/src/UserBundle/Resources/views/Registration/register_content.html.twig new file mode 100644 index 0000000..ecedeb9 --- /dev/null +++ b/src/UserBundle/Resources/views/Registration/register_content.html.twig @@ -0,0 +1,8 @@ +{% trans_default_domain 'FOSUserBundle' %} + +{{ form_start(form, {'method': 'post', 'action': path('fos_user_registration_register'), 'attr': {'class': 'fos_user_registration_register'}}) }} + {{ form_widget(form) }} +
    + +
    +{{ form_end(form) }} diff --git a/src/UserBundle/Resources/views/change.password.txt.twig b/src/UserBundle/Resources/views/change.password.txt.twig new file mode 100644 index 0000000..5610bc0 --- /dev/null +++ b/src/UserBundle/Resources/views/change.password.txt.twig @@ -0,0 +1,10 @@ +{% trans_default_domain 'email' %} +{% block body_text %} +{% autoescape false %} +{{- 'password_change.message'|trans({ + '%firstName%': user.firstName, + '%lastName%': user.lastName, + '%password%': password +}) -}} +{% endautoescape %} +{% endblock %} diff --git a/src/UserBundle/Resources/views/email_layout.html.twig b/src/UserBundle/Resources/views/email_layout.html.twig new file mode 100644 index 0000000..fb4e05c --- /dev/null +++ b/src/UserBundle/Resources/views/email_layout.html.twig @@ -0,0 +1,37 @@ + + + + + + + + + + +{# Common styles #} +{%- set tableStyles = 'border="0" cellspacing="0" cellpadding="0"' -%} +{%- set tableStylesFullWidth = 'width="100%" '~tableStyles -%} + +{%- autoescape false -%} + + + + + +
    + + + + +
    + {# Body #} + + {%- block body -%} + {{- body|raw -}} + {%- endblock body -%} +
    +
    +
    + +{%- endautoescape -%} + \ No newline at end of file diff --git a/src/UserBundle/Resources/views/resetting.txt.twig b/src/UserBundle/Resources/views/resetting.txt.twig new file mode 100644 index 0000000..81f33d0 --- /dev/null +++ b/src/UserBundle/Resources/views/resetting.txt.twig @@ -0,0 +1,19 @@ +{% trans_default_domain 'email' %} +{% block subject %} +{%- autoescape false -%} +{{- 'resetting.subject'|trans({ + '%firstName%': user.firstName, + '%lastName%': user.lastName +}) -}} +{%- endautoescape -%} +{% endblock %} + +{% block body_text %} +{% autoescape false %} +{{- 'resetting.message'|trans({ + '%firstName%': user.firstName, + '%lastName%': user.lastName, + '%confirmationUrl%': confirmationUrl +}) -}} +{% endautoescape %} +{% endblock %} diff --git a/src/UserBundle/Resources/views/verificationRejected.html.twig b/src/UserBundle/Resources/views/verificationRejected.html.twig new file mode 100644 index 0000000..6994a76 --- /dev/null +++ b/src/UserBundle/Resources/views/verificationRejected.html.twig @@ -0,0 +1,10 @@ +{% trans_default_domain 'email' %} +{% block body_text %} +{% autoescape false %} +{{- 'verification_rejected.message'|trans({ + '%firstName%': user.firstName, + '%lastName%': user.lastName +}) +-}} +{% endautoescape %} +{% endblock %} diff --git a/src/UserBundle/Resources/views/verificationSuccess.html.twig b/src/UserBundle/Resources/views/verificationSuccess.html.twig new file mode 100644 index 0000000..1ae2dcf --- /dev/null +++ b/src/UserBundle/Resources/views/verificationSuccess.html.twig @@ -0,0 +1,12 @@ +{% trans_default_domain 'email' %} +{% block body_text %} +{% autoescape false %} +{{- 'verification_success.message'|trans({ + '%firstName%': user.firstName, + '%lastName%': user.lastName, + '%email%': user.email, + '%password%': password +}) +-}} +{% endautoescape %} +{% endblock %} diff --git a/src/UserBundle/Security/Inspector/GroupRecipientInspector.php b/src/UserBundle/Security/Inspector/GroupRecipientInspector.php new file mode 100644 index 0000000..a6433eb --- /dev/null +++ b/src/UserBundle/Security/Inspector/GroupRecipientInspector.php @@ -0,0 +1,93 @@ +addReasonIf( + "Can't create recipient group for other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can read specified entity. + * + * @param User $user A user who try to create entity. + * @param GroupRecipient|object $entity A Entity instance. + * + * @return void + */ + protected function canRead(User $user, $entity) + { + $this->addReasonIf( + "Can't read recipient group owned by other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can update specified entity. + * + * @param User $user A user who try to create entity. + * @param GroupRecipient|object $entity A Entity instance. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function canUpdate(User $user, $entity) + { + $this + ->addReasonIf( + "Can't update recipient group owned by other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can delete specified entity. + * + * @param User $user A user who try to create entity. + * @param GroupRecipient|object $entity A Entity instance. + * + * @return void + */ + protected function canDelete(User $user, $entity) + { + $this + ->addReasonIf( + "Can't delete recipient group owned by other user.", + ! $entity->isOwnedBy($user) + ); + } +} diff --git a/src/UserBundle/Security/Inspector/NotificationInspector.php b/src/UserBundle/Security/Inspector/NotificationInspector.php new file mode 100644 index 0000000..de1a9d2 --- /dev/null +++ b/src/UserBundle/Security/Inspector/NotificationInspector.php @@ -0,0 +1,131 @@ +addReasonIf( + "Can't subscribe to notification owned by other user and not published.", + ! $entity->isPublished() && ! $entity->isOwnedBy($user) + ); + } elseif ($action === self::UNSUBSCRIBE) { + $this->addReasonIf( + "Can't unsubscribe from notification owned by other user and not published.", + ! $entity->isPublished() && ! $entity->isOwnedBy($user) + ); + + if (count($this->reasons) === 0) { + $this->addReasonIf( + "You can't unsubscribe from notification.", + !$entity->isAllowUnsubscribe() + ); + } + } + + return $this->reasons; + } + + /** + * Check that user can create specified entity. + * + * @param User $user A user who try to create entity. + * @param Notification|object $entity A Entity instance. + * + * @return void + */ + protected function canCreate(User $user, $entity) + { + $this->addReasonIf( + "Can't create notification for other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can read specified entity. + * + * @param User $user A user who try to create entity. + * @param Notification|object $entity A Entity instance. + * + * @return void + */ + protected function canRead(User $user, $entity) + { + $this->addReasonIf( + "Can't read notification owned by other user and not published.", + ! $entity->isPublished() && ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can update specified entity. + * + * @param User $user A user who try to create entity. + * @param Notification|object $entity A Entity instance. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function canUpdate(User $user, $entity) + { + $this + ->addReasonIf( + "Can't update notification owned by other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can delete specified entity. + * + * @param User $user A user who try to create entity. + * @param Notification|object $entity A Entity instance. + * + * @return void + */ + protected function canDelete(User $user, $entity) + { + $this + ->addReasonIf( + "Can't delete notification owned by other user.", + ! $entity->isOwnedBy($user) + ); + } +} diff --git a/src/UserBundle/Security/Inspector/PersonRecipientInspector.php b/src/UserBundle/Security/Inspector/PersonRecipientInspector.php new file mode 100644 index 0000000..693d9ee --- /dev/null +++ b/src/UserBundle/Security/Inspector/PersonRecipientInspector.php @@ -0,0 +1,97 @@ +addReasonIf( + "Can't create recipient for other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can read specified entity. + * + * @param User $user A user who try to create entity. + * @param PersonRecipient|object $entity A Entity instance. + * + * @return void + */ + protected function canRead(User $user, $entity) + { + $this->addReasonIf( + "Can't read recipient owned by other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can update specified entity. + * + * @param User $user A user who try to create entity. + * @param PersonRecipient|object $entity A Entity instance. + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function canUpdate(User $user, $entity) + { + $this + ->addReasonIf( + "Can't update recipient owned by other user.", + ! $entity->isOwnedBy($user) + ); + } + + /** + * Check that user can delete specified entity. + * + * @param User $user A user who try to create entity. + * @param PersonRecipient|object $entity A Entity instance. + * + * @return void + */ + protected function canDelete(User $user, $entity) + { + $this + ->addReasonIf( + "Can't delete recipient owned by other user.", + ! $entity->isOwnedBy($user) + ) + ->addReasonIf( + "Can't delete recipient which has associations to user", + $entity->getAssociatedUser() !== null + ); + } +} diff --git a/src/UserBundle/Services/StripeService.php b/src/UserBundle/Services/StripeService.php new file mode 100644 index 0000000..8f979e6 --- /dev/null +++ b/src/UserBundle/Services/StripeService.php @@ -0,0 +1,291 @@ +stripe_auth_api_secret_key = $stripe_auth_api_secret_key; + $this->stripe_auth_api_publish_key = $stripe_auth_api_publish_key; + } + + public function setApiKey(){ + Stripe::setApiKey($this->stripe_auth_api_secret_key); + } + + public function createSource($parentId, $params = null, $opts = null){ + try { + return Customer::createSource($parentId, $params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function paymentMethodAttachToCustomer($parentId, $params = null, $opts = null){ + try { + $paymentMethod = new PaymentMethod($parentId); + return $paymentMethod->attach($params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function getPaymentMethodById($params = null, $opts = null){ + try { + $paymentMethod = new PaymentMethod(); + return $paymentMethod->retrieve($params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function getAllPaymentMethodUpdateByCustomerId($parentId, $params = null, $opts = null){ + try { + $paymentMethod = new PaymentMethod(); + return $paymentMethod->update($parentId, $params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function paymentMethodDetachToCustomer($parentId, $params = null, $opts = null){ + try { + $paymentMethod = new PaymentMethod($parentId); + return $paymentMethod->detach($params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + + public function addProduct($params = null, $opts = null){ + try { + return Product::create($params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function getProduct($id = null, $opts = null){ + try { + return Product::retrieve($id, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function getProducts($params = null, $opts = null){ + try { + return Product::all($params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function getPlans($params = null, $opts = null){ + try { + return Plan::all($params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function addPlan($params = null, $opts = null){ + try { + return Plan::create($params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function getPlan($params = null, $opts = null){ + try { + return Plan::retrieve($params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function addPrice($params = null, $opts = null){ + try { + return Price::create($params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function getPrice($params = null, $opts = null){ + try { + return Price::retrieve($params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function removePrice($params = null, $opts = null){ + try { + return Price::retrieve($params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function getUpdatePrice($id, $params = null, $opts = null){ + try { + return Price::update($id, $params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + + public function createCustomer($params = null, $options = null) + { + try { + return Customer::create($params, $options); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function updateCustomer($id, $params = null, $options = null) + { + try { + return Customer::update($id, $params, $options); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function getCustomer($params = null, $options = null) + { + try { + return Customer::retrieve($params, $options); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function deleteCustomer($params = null, $options = null) + { + try { + $customer = Customer::retrieve($params, $options); + return $customer->delete(); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function createSubscription($params = null, $options = null){ + try { + return Subscription::create($params, $options); + } catch (ApiErrorException $e) { + return $e; + } + + } + + public function getSubscription($id, $params = null, $options = null){ + try { + return Subscription::retrieve($id, $params, $options); + } catch (ApiErrorException $e) { + return $e; + } + + } + + public function createSubscriptionItem($params = null, $options = null){ + try { + return SubscriptionItem::create($params, $options); + } catch (ApiErrorException $e) { + return $e; + } + + } + + public function updateSubscription($id, $params = null, $options = null){ + try { + return Subscription::update($id, $params, $options); + } catch (ApiErrorException $e) { + return $e; + } + + } + + public function updateSubscriptionItem($id, $params = null, $options = null){ + try { + return SubscriptionItem::update($id, $params, $options); + } catch (ApiErrorException $e) { + return $e; + } + + } + + public function cancelSubscription($id, $params = null, $options = null){ + try { + $subscription = new Subscription($id); + return $subscription->cancel($params, $options); + } catch (ApiErrorException $e) { + return $e; + } + + } + + public function createUsageRecord($subscriptionItemId, $params = null, $opts = null){ + try { + return SubscriptionItem::createUsageRecord($subscriptionItemId, $params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function getPaymentMethod($params = null, $opts = null){ + try { + return PaymentMethod::all($params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function getUpcomingInvoice($params = null, $opts = null){ + try { + return Invoice::upcoming($params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } + + public function getAllInvoice($params = null, $opts = null){ + try { + return Invoice::all($params, $opts); + } catch (ApiErrorException $e) { + return $e; + } + } +} \ No newline at end of file diff --git a/src/UserBundle/Twig/TwigExtension.php b/src/UserBundle/Twig/TwigExtension.php new file mode 100644 index 0000000..6e3fe7c --- /dev/null +++ b/src/UserBundle/Twig/TwigExtension.php @@ -0,0 +1,28 @@ +getCountryName($code); + }), + ]; + } +} diff --git a/src/UserBundle/UserBundle.php b/src/UserBundle/UserBundle.php new file mode 100644 index 0000000..3cc8041 --- /dev/null +++ b/src/UserBundle/UserBundle.php @@ -0,0 +1,44 @@ +addCompilerPass(new RemoveLastLoginListenerPass()); + } +} diff --git a/src/UserBundle/UserBundleServices.php b/src/UserBundle/UserBundleServices.php new file mode 100644 index 0000000..86583c7 --- /dev/null +++ b/src/UserBundle/UserBundleServices.php @@ -0,0 +1,35 @@ +included = $included; + $this->excluded = $excluded; + } + + /** + * @param Request $request A HTTP Request instance. + * + * @return static + */ + public static function fromRequest(Request $request) + { + $included = array_filter(\nspl\a\map('trim', explode(',', trim($request->query->get('include'))))); + $excluded = array_filter(\nspl\a\map('trim', explode(',', trim($request->query->get('exclude'))))); + + // @codingStandardsIgnoreStart + return new static($included, $excluded); + // @codingStandardsIgnoreEnd + } + + /** + * @param ArrayCollection $parameters Array of conditions parameters. + * + * @return array|ArrayCollection + */ + public function addToParameters(ArrayCollection $parameters) + { + if (count($this->included) > 0) { + $parameters[] = new Parameter('included', $this->included); + } + + if (count($this->excluded) > 0) { + $parameters[] = new Parameter('excluded', $this->excluded); + } + + return $parameters; + } + + /** + * @param Expr\Base $condition A expression condition. + * @param string $alias Used entity alias. + * + * @return Expr\Base + */ + public function addToConditions(Expr\Base $condition, $alias) + { + $expr = new Expr(); + + if (count($this->included) > 0) { + // + // If we got additional included person recipients we should + // transform condition and fetch all person enrolled in specified + // group or in provided list. + // + $condition = $expr->orX( + $condition, + $expr->in($alias. '.id', ':included') + ); + } + + if (count($this->excluded) > 0) { + // + // If we should exclude some person recipients we should + // transform condition and fetch all person enrolled in specified + // group (or in additionally provided 'include' list) but not + // exists in 'exclude' list. + // + $condition = $expr->andX( + $condition, + $expr->notIn($alias. '.id', ':excluded') + ); + } + + return $condition; + } +} diff --git a/src/UserBundle/Utils/RoleChecker/RoleChecker.php b/src/UserBundle/Utils/RoleChecker/RoleChecker.php new file mode 100644 index 0000000..0039fbf --- /dev/null +++ b/src/UserBundle/Utils/RoleChecker/RoleChecker.php @@ -0,0 +1,104 @@ +hierarchy = $hierarchy; + $this->raw = $raw; + } + + /** + * Checks that specified user has necessary role. + * + * @param User $user A checked User entity instance. + * @param string|RoleInterface $role A role name or Role instance. + * + * @return boolean + */ + public function has(User $user, $role) + { + return in_array($role, $this->getUserRole($user), true); + } + + /** + * Checks that specified user has given role or lower. + * + * @param User $user A checked User entity instance. + * @param string|RoleInterface $role A role name or Role instance. + * + * @return boolean + */ + public function hasNotHigherThen(User $user, $role) + { + $actual = $this->getUserRole($user); + $actualOrder = max(array_map(function ($role) { + return $this->computeRoleOrder($role); + }, $actual)); + $expectedOrder = $this->computeRoleOrder($role); + + return $actualOrder <= $expectedOrder; + } + + /** + * Get reachable roles for specified user. + * + * @param User $user A User entity instance. + * + * @return array + */ + private function getUserRole(User $user) + { + return array_map(function (RoleInterface $role) { + return $role->getRole(); + }, $this->hierarchy->getReachableRoles(array_map(function ($role) { + return new Role($role); + }, $user->getRoles()))); + } + + /** + * @param string $role Role name. + * @param integer $order Current order. + * + * @return integer + */ + private function computeRoleOrder($role, $order = 0) + { + $roles = $this->raw[$role]; + if (count($roles) === 0) { + return $order; + } + + return max(array_map(function ($role) use ($order) { + return $this->computeRoleOrder($role, $order + 1); + }, $this->raw[$role])); + } +} diff --git a/src/UserBundle/Utils/RoleChecker/RoleCheckerInterface.php b/src/UserBundle/Utils/RoleChecker/RoleCheckerInterface.php new file mode 100644 index 0000000..d023769 --- /dev/null +++ b/src/UserBundle/Utils/RoleChecker/RoleCheckerInterface.php @@ -0,0 +1,36 @@ + LanguageEnum::BENGALI, + 'count' => 44, + ], + [ + 'value' => LanguageEnum::VIETNAMESE, + 'count' => 40, + ], + [ + 'value' => LanguageEnum::DUTCH, + 'count' => 35, + ], + [ + 'value' => LanguageEnum::GERMAN, + 'count' => 20, + ], + [ + 'value' => LanguageEnum::GREEK, + 'count' => 21, + ], + [ + 'value' => LanguageEnum::FINNISH, + 'count' => 14, + ], + [ + 'value' => LanguageEnum::RUSSIAN, + 'count' => 10, + ], + [ + 'value' => LanguageEnum::ESTONIAN, + 'count' => 7, + ], + [ + 'value' => LanguageEnum::AFRIKAANS, + 'count' => 5, + ], + [ + 'value' => LanguageEnum::ARABIC, + 'count' => 1, + ], + [ + 'value' => LanguageEnum::BULGARIAN, + 'count' => 1, + ], + [ + 'value' => LanguageEnum::NORWEGIAN, + 'count' => 1, + ], + ]; + + /** + * Fixtures for 'articleDate' advanced filter. + * + * @var array + */ + private static $dates = [ + [ + 'value' => '1 Hour', + 'count' => 20, + ], + [ + 'value' => '24 Hour', + 'count' => 45, + ], + [ + 'value' => '7 Days', + 'count' => 124, + ], + [ + 'values' => '31 Days', + 'count' => 2355, + ], + [ + 'values' => '60 Days', + 'count' => 1254151, + ], + ]; + + /** + * @var AFResolver + */ + private $resolver; + + /** + * @var Generator + */ + private $faker; + + /** + * @return void + */ + public function testGetAllAvailable() + { + /** @var SearchRequest $request */ + $request = $this->getMockBuilder(SearchRequest::class) + ->disableOriginalConstructor() + ->getMock(); + + $values = $this->resolver->getAvailables($request); + + self::assertArrayHasKey(DocumentsAFNameEnum::ARTICLE_LANGUAGE, $values); + self::assertArrayHasKey(DocumentsAFNameEnum::ARTICLE_DATE, $values); + + self::assertMatch( + $values[DocumentsAFNameEnum::ARTICLE_LANGUAGE], + [ + 'data' => '@array@', + ] + ); + self::assertMatch( + $values[DocumentsAFNameEnum::ARTICLE_DATE], + [ + 'data' => '@array@', + ] + ); + } + + /** + * @return void + */ + public function testGenerateFilterForLanguage() + { + /** @var OrFilter $filter */ + $filter = $this->resolver->generateFilter( + AdvancedFiltersConfig::getConfig(AFSourceEnum::FEED), + DocumentsAFNameEnum::ARTICLE_LANGUAGE, + new AdvancedFilterParameters(LanguageEnum::DUTCH, []) + ); + + /** @var EqFilter $eqFilter */ + $eqFilter = $filter->getFilters()[0]; + + self::assertInstanceOf(OrFilter::class, $filter); + self::assertInstanceOf(EqFilter::class, $eqFilter); + self::assertSame(FieldNameEnum::LANG, $eqFilter->getFieldName()); + self::assertSame(LanguageEnum::DUTCH, $eqFilter->getValue()); + } + + /** + * @return void + */ + public function testGenerateFilterForDateWithTwoBounds() + { + /** @var AndFilter $filter */ + $filter = $this->resolver->generateFilter( + AdvancedFiltersConfig::getConfig(AFSourceEnum::FEED), + DocumentsAFNameEnum::ARTICLE_DATE, + AdvancedFilterParameters::queryFilterParameters('24 Hour') + ); + + self::assertInstanceOf(AndFilter::class, $filter); + + $filters = $filter->getFilters(); + + self::assertCount(2, $filters); + self::assertInstanceOf(GteFilter::class, $filters[0]); + self::assertInstanceOf(LteFilter::class, $filters[1]); + + /** @var GteFilter $gteFilter */ + $gteFilter = $filters[0]; + self::assertSame(FieldNameEnum::PUBLISHED, $gteFilter->getFieldName()); + self::assertSame('now-1d', $gteFilter->getValue()); + + /** @var LteFilter $lteFilter */ + $lteFilter = $filters[1]; + self::assertSame(FieldNameEnum::PUBLISHED, $lteFilter->getFieldName()); + self::assertSame('now-2H', $lteFilter->getValue()); + } + + /** + * @return void + */ + public function testGenerateFilterForDateWithOneBound() + { + /** @var GteFilter $filter */ + $filter = $this->resolver->generateFilter( + AdvancedFiltersConfig::getConfig(AFSourceEnum::FEED), + DocumentsAFNameEnum::ARTICLE_DATE, + AdvancedFilterParameters::queryFilterParameters('1 Hour') + ); + + self::assertInstanceOf(GteFilter::class, $filter); + self::assertSame(FieldNameEnum::PUBLISHED, $filter->getFieldName()); + self::assertSame('now-1H', $filter->getValue()); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Unknown filter 'unknown'. + * + * @return void + */ + public function testGenerateFilterNameException() + { + $this->resolver->generateFilter( + AdvancedFiltersConfig::getConfig(AFSourceEnum::FEED), + 'unknown', + new AdvancedFilterParameters('1 Hour', []) + ); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Invalid value 'invalid'. Expects one of 1 Hour, 24 Hour, 7 Days, 31 Days, 60 Days. + * + * @return void + */ + public function testGenerateFilterForDateInvalidValueException() + { + $this->resolver->generateFilter( + AdvancedFiltersConfig::getConfig(AFSourceEnum::FEED), + DocumentsAFNameEnum::ARTICLE_DATE, + new AdvancedFilterParameters('invalid', []) + ); + } + + /** + * Sets up the fixture, for example, open a network connection. + * This method is called before a test is executed. + * + * @return void + */ + protected function setUp() + { + $this->faker = Factory::create(); + + $aggregator = new TestAFAggregator([ + DocumentsAFNameEnum::ARTICLE_LANGUAGE => self::$languages, + DocumentsAFNameEnum::ARTICLE_DATE => self::$dates, + ]); + $factory = new FilterFactory(); + + $this->resolver = new AFResolver($aggregator, $factory); + } +} diff --git a/tests/AppBundle/AdvancedFilters/TestAFAggregator.php b/tests/AppBundle/AdvancedFilters/TestAFAggregator.php new file mode 100644 index 0000000..a6cbe76 --- /dev/null +++ b/tests/AppBundle/AdvancedFilters/TestAFAggregator.php @@ -0,0 +1,41 @@ +values = $values; + } + + /** + * Return available filters values for specified request. + * + * @param SearchRequestInterface $request A SearchRequestInterface instance. + * + * @return array + */ + public function getValues(SearchRequestInterface $request) + { + return $this->values; + } +} diff --git a/tests/AppFunctional/ArrayTest.php b/tests/AppFunctional/ArrayTest.php new file mode 100644 index 0000000..d38a8d0 --- /dev/null +++ b/tests/AppFunctional/ArrayTest.php @@ -0,0 +1,144 @@ +assertSame(2, binarySearch($collection, 3)); + $this->assertSame(0, binarySearch($collection, 1)); + $this->assertSame(1, binarySearch($collection, 2)); + $this->assertSame(4, binarySearch($collection, 5)); + } + + /** + * @return void + */ + public function testBinarySearchForCollectionWithEvenItemsNumber() + { + $collection = [ 1, 2, 3, 4, 5, 6]; + + $this->assertSame(2, binarySearch($collection, 3)); + $this->assertSame(0, binarySearch($collection, 1)); + $this->assertSame(1, binarySearch($collection, 2)); + $this->assertSame(5, binarySearch($collection, 6)); + } + + /** + * @return void + */ + public function testBinarySearchSearchUnknownItem() + { + $collection = [ 1, 2, 3, 4, 5, 6]; + + $this->assertFalse(binarySearch($collection, 0)); + $this->assertFalse(binarySearch($collection, 7)); + $this->assertFalse(binarySearch($collection, -10)); + $this->assertFalse(binarySearch($collection, 20)); + } + + /** + * @return void + */ + public function testBinarySearchInObject() + { + $collection = [ + (object) [ 'id' => 1 ], + (object) [ 'id' => 2 ], + (object) [ 'id' => 3 ], + (object) [ 'id' => 4 ], + (object) [ 'id' => 5 ], + ]; + + $this->assertSame(2, binarySearch($collection, 3, 'id')); + $this->assertSame(0, binarySearch($collection, 1, 'id')); + $this->assertSame(1, binarySearch($collection, 2, 'id')); + $this->assertSame(4, binarySearch($collection, 5, 'id')); + } + + /** + * @return void + */ + public function testBinarySearchInObjectWithGetter() + { + $collection = [ + new TestFixture(1), + new TestFixture(2), + new TestFixture(3), + new TestFixture(4), + new TestFixture(5), + ]; + + $this->assertSame(2, binarySearch($collection, 3, \nspl\op\methodCaller('getId'))); + $this->assertSame(0, binarySearch($collection, 1, \nspl\op\methodCaller('getId'))); + $this->assertSame(1, binarySearch($collection, 2, \nspl\op\methodCaller('getId'))); + $this->assertSame(4, binarySearch($collection, 5, \nspl\op\methodCaller('getId'))); + } + + /** + * @return void + */ + public function testBinarySearchInDocument() + { + /** @var IndexStrategyInterface $strategy */ + $strategy = $this->getMockForInterface(IndexStrategyInterface::class); + + $collection = [ + new ArticleDocument($strategy, ['sequence' => 1]), + new ArticleDocument($strategy, ['sequence' => 2]), + new ArticleDocument($strategy, ['sequence' => 3]), + new ArticleDocument($strategy, ['sequence' => 4]), + new ArticleDocument($strategy, ['sequence' => 5]), + ]; + + $this->assertSame(2, binarySearch($collection, 3, 'sequence')); + $this->assertSame(0, binarySearch($collection, 1, 'sequence')); + $this->assertSame(1, binarySearch($collection, 2, 'sequence')); + $this->assertSame(4, binarySearch($collection, 5, 'sequence')); + } +} + +/** + * Class TestFixture + * @package AppFunctional + */ +class TestFixture +{ + /** + * @var integer + */ + private $id; + + /** + * TestFixture constructor. + * + * @param integer $id Identifier. + */ + public function __construct($id) + { + $this->id = $id; + } + + /** + * @return integer + */ + public function getId() + { + return $this->id; + } +} diff --git a/tests/AppTestCase.php b/tests/AppTestCase.php new file mode 100644 index 0000000..2f81ea7 --- /dev/null +++ b/tests/AppTestCase.php @@ -0,0 +1,15 @@ +setAccessible(true); + + return $methodReflection->invokeArgs($object, $params); + } + + /** + * Get public, protected or private property of specified object. + * + * @param object $object Object from which we should get property. + * @param string $property Required property name. + * + * @return mixed + */ + protected function getProperty($object, $property) + { + $propertyReflection = new \ReflectionProperty($object, $property); + $propertyReflection->setAccessible(true); + + return $propertyReflection->getValue($object); + } + + /** + * Assert that specified value matched to given pattern. + * Uses coduo/php-matcher. + * + * @param mixed $value Matched value. + * @param mixed $pattern Expected pattern. + * @param string $message Custom error message. + * + * @return void + */ + protected static function assertMatch($value, $pattern, $message = null) + { + self::assertTrue( + AppMatcher::match($value, $pattern, $error), + $message ? $message . PHP_EOL . $error : $error + ); + } + + /** + * @param string $className Interface fqcn. + * + * @return \PHPUnit_Framework_MockObject_MockObject + */ + protected function getMockForInterface($className) + { + if (! $this instanceof TestCase) { + throw new \LogicException('AppTestCaseTrait should be used by subclasses od TestCase'); + } + + return $this->getMockBuilder($className) + ->disableOriginalConstructor() + ->setMethods($this->getClassMethods($className)) + ->getMock(); + } + + /** + * @param string $class A abstract class fqcn. + * + * @return string[] + */ + protected function getClassMethods($class) + { + $reflection = new \ReflectionClass($class); + + return \nspl\a\map(\nspl\op\methodCaller('getName'), $reflection->getMethods()); + } +} diff --git a/tests/CacheBundle/Document/Extractor/BasicDocumentContentExtractorTest.php b/tests/CacheBundle/Document/Extractor/BasicDocumentContentExtractorTest.php new file mode 100644 index 0000000..bef8f5c --- /dev/null +++ b/tests/CacheBundle/Document/Extractor/BasicDocumentContentExtractorTest.php @@ -0,0 +1,325 @@ +extract( + self::SIMPLE_TEXT, + $query, + ThemeOptionExtractEnum::no() + ); + + $this->assertEquals('', $actual->getText()); + } + + $queries = [ + 'فنلندا', + 'ويكيبيديا', + 'مسارح', + ]; + + foreach ($queries as $query) { + $actual = $extractor->extract( + self::ARABIC_TEXT, + $query, + ThemeOptionExtractEnum::no() + ); + + $this->assertEquals('', $actual->getText()); + } + + $queries = [ + 'ひすう', + '列りな', + '留屋れこ', + ]; + + foreach ($queries as $query) { + $actual = $extractor->extract( + self::HIEROGLYPHIC_TEXT, + $query, + ThemeOptionExtractEnum::no() + ); + + $this->assertEquals('', $actual->getText()); + } + } + + /** + * @dataProvider startExtractProvider + * + * @param string $text Document content text. + * @param string $query Search query. + * @param integer $startExtractLen How many characters extract from beginning + * of content. + * @param string $expected Expected result. + * + * @return void + */ + public function testExtractStart($text, $query, $startExtractLen, $expected) + { + $extractor = new BasicDocumentContentExtractor($startExtractLen, random_int(0, 100)); + $actual = $extractor->extract($text, $query, ThemeOptionExtractEnum::start()); + + $this->assertEquals($expected, $actual->getText()); + } + + /** + * @return array + */ + public function startExtractProvider() + { + return [ + [ + self::SIMPLE_TEXT, + 'cat', + 20, + 'Outside a character ', + ], + [ + self::SIMPLE_TEXT, + 'some', + 1, + 'O', + ], + [ + self::SIMPLE_TEXT, + '', + 100000000, + self::SIMPLE_TEXT, + ], + [ + self::SIMPLE_TEXT, + 'long query', + 0, + '', + ], + [ + self::ARABIC_TEXT, + '', + 25, + 'مع مدن يرتبط المؤلّفة, حي', + ], + [ + self::ARABIC_TEXT, + '', + 1, + 'م', + ], + [ + self::ARABIC_TEXT, + '', + 10000000, + self::ARABIC_TEXT, + ], + [ + self::HIEROGLYPHIC_TEXT, + '', + 25, + '絵列個列氏。さおひまきんせふこ二「夜等鵜もぬねゃ舳', + ], + [ + self::HIEROGLYPHIC_TEXT, + '', + 1, + '絵', + ], + [ + self::HIEROGLYPHIC_TEXT, + '', + 10000000, + self::HIEROGLYPHIC_TEXT, + ], + ]; + } + + /** + * @dataProvider contextExtractProvider + * + * @param string $text Document content text. + * @param string $query Search query. + * @param integer $contextExtractLen How many characters extract before and + * after search keyword. + * @param string $expected Expected result. + * + * @return void + */ + public function testExtractContext($text, $query, $contextExtractLen, $expected) + { + $extractor = new BasicDocumentContentExtractor(random_int(0, 100), $contextExtractLen); + $actual = $extractor->extract($text, $query, ThemeOptionExtractEnum::context()); + + $this->assertEquals($expected, $actual->getText()); + } + + /** + * @return array + */ + public function contextExtractProvider() + { + return [ + [ + self::SIMPLE_TEXT, + 'mode', + 5, + 'hing mode, the', + ], + [ + self::SIMPLE_TEXT, + 'mode', + 0, + 'mode', + ], + [ + self::SIMPLE_TEXT, + 'character mode', + 25, + 'Outside a character class, in the default ma', + ], + [ + self::SIMPLE_TEXT, + 'cat PCRE_MULTILINE dog', + 7, + 'if the PCRE_MULTILINE option', + ], + [ + self::SIMPLE_TEXT, + 'mode Outside PCRE_MULTILINE', + 1000000000, + self::SIMPLE_TEXT, + ], + [ + self::SIMPLE_TEXT, + 'cat', + 25, + 'MULTILINE is set or not. Cat also been here.', + ], + [ + self::ARABIC_TEXT, + 'المؤلّفة حين', + 25, + 'مع مدن يرتبط المؤلّفة, حين تونس تحرّكت في. بعض', + ], + [ + self::ARABIC_TEXT, + 'المؤلّفة', + 0, + 'المؤلّفة', + ], + [ + self::ARABIC_TEXT, + 'المؤلّفة', + 10000000, + self::ARABIC_TEXT, + ], + [ + self::HIEROGLYPHIC_TEXT, + '派阿ヌ ノきぬ離日み さおひまき', + 25, + '絵列個列氏。さおひまきんせふこ二「夜等鵜もぬねゃ舳カ」ラュャコ。ほゅ。る', + ], + [ + self::HIEROGLYPHIC_TEXT, + 'ラュャ', + 0, + 'ラュャ', + ], + [ + self::HIEROGLYPHIC_TEXT, + '派阿ヌ ノきぬ離日み', + 10000000, + self::HIEROGLYPHIC_TEXT, + ], + ]; + } + + /** + * @return void + */ + public function testExtractEmptyContent() + { + $extractor = new BasicDocumentContentExtractor(100, 100); + + $this->assertEquals('', $extractor->extract('', 'mode', ThemeOptionExtractEnum::no())->getText()); + $this->assertEquals('', $extractor->extract('', 'mode', ThemeOptionExtractEnum::start())->getText()); + $this->assertEquals('', $extractor->extract('', 'mode', ThemeOptionExtractEnum::context())->getText()); + } +} diff --git a/tests/Helper/AccessorTrait.php b/tests/Helper/AccessorTrait.php new file mode 100644 index 0000000..f2db8df --- /dev/null +++ b/tests/Helper/AccessorTrait.php @@ -0,0 +1,30 @@ +bindTo($object, $object); + + return $caller(); + } +} diff --git a/tests/Helper/CssAssertBuilder.php b/tests/Helper/CssAssertBuilder.php new file mode 100644 index 0000000..be97f62 --- /dev/null +++ b/tests/Helper/CssAssertBuilder.php @@ -0,0 +1,261 @@ +selector = $selector; + } + + /** + * @return CssAssertBuilder + */ + public function shouldExists() + { + $this->shouldExists = true; + + return $this; + } + + /** + * @return CssAssertBuilder + */ + public function shouldNotExists() + { + $this->shouldExists = false; + + return $this; + } + + /** + * @param string $selector A base css element selector. + * + * @return CssAssertBuilder + */ + public static function create($selector) + { + return new CssAssertBuilder($selector); + } + + /** + * @param ThemeOptionFont $font Font which should be rendered. + * + * @return CssAssertBuilder + */ + public function hasFont(ThemeOptionFont $font) + { + $this + ->propertyShouldBe('font-family', $font->getFamily()->getCss()) + ->propertyShouldBe('font-size', $font->getSize()); + + $style = $font->getStyle(); + + if ($style->isBold()) { + $this->propertyShouldBe('font-weight', 'bold'); + } else { + $this->propertyShouldNotBe('font-weight', 'bold'); + } + + if ($style->isItalic()) { + $this->propertyShouldBe('font-style', 'italic'); + } else { + $this->propertyShouldNotBe('font-style', 'italic'); + } + + if ($style->isUnderline()) { + $this->propertyShouldBe('text-decoration', 'underline'); + } else { + $this->propertyShouldNotBe('text-decoration', 'underline'); + } + + return $this; + } + + /** + * @return static + */ + public function hasNotAnyFonts() + { + return $this + ->propertyShouldNotExists('font-family') + ->propertyShouldNotExists('font-size') + ->propertyShouldNotExists('font-weight') + ->propertyShouldNotExists('font-style') + ->propertyShouldNotExists('text-decoration'); + } + + /** + * @param string $name Property name. + * @param string $message Error message. + * + * @return static + */ + public function propertyShouldExists($name, $message = '') + { + $this->asserts[] = [ + 'method' => 'assertRegExp', + 'arguments' => [ + sprintf(self::PROPERTY_PATTERN_TPL, $this->selector, $name), + '___', + $message, + ], + ]; + + return $this; + } + + /** + * @param string $name Property name. + * @param string $message Error message. + * + * @return static + */ + public function propertyShouldNotExists($name, $message = '') + { + $this->asserts[] = [ + 'method' => 'assertNotRegExp', + 'arguments' => [ + sprintf(self::PROPERTY_PATTERN_TPL, $this->selector, $name), + '___', + $message, + ], + ]; + + return $this; + } + + /** + * @param string $name Property name. + * @param string $value Expected property value. + * @param string $message Error message. + * + * @return static + */ + public function propertyShouldBe($name, $value, $message = '') + { + $name = str_replace(self::$searchedCharacters, self::$replaceCharacters, $name); + $value = str_replace(self::$searchedCharacters, self::$replaceCharacters, $value); + + $this->asserts[] = [ + 'method' => 'assertRegExp', + 'arguments' => [ + sprintf(self::PROPERTY_WITH_VALUE_PATTERN_TPL, $this->selector, $name, $value), + '___', + $message, + ], + ]; + + return $this; + } + + /** + * @param string $name Property name. + * @param string $value Expected property value. + * @param string $message Error message. + * + * @return static + */ + public function propertyShouldNotBe($name, $value = '', $message = '') + { + $name = str_replace(self::$searchedCharacters, self::$replaceCharacters, $name); + $value = str_replace(self::$searchedCharacters, self::$replaceCharacters, $value); + + $this->asserts[] = [ + 'method' => 'assertNotRegExp', + 'arguments' => [ + sprintf(self::PROPERTY_WITH_VALUE_PATTERN_TPL, $this->selector, $name, $value), + '___', + $message, + ], + ]; + + return $this; + } + + /** + * @param string $html HTML content. + * + * @return void + */ + public function assert($html) + { + if (! $this->shouldExists) { + TestCase::assertNotRegExp("/{$this->selector}/", $html); + + // Don't assert property 'cause it's not necessary. + return; + } + + foreach ($this->asserts as $config) { + $arguments = \nspl\a\map(function ($argument) use ($html) { + if (is_string($argument) && ($argument === '___')) { + $argument = $html; + } + + return $argument; + }, $config['arguments']); + call_user_func_array([ TestCase::class, $config['method'] ], $arguments); + } + } +} diff --git a/tests/Helper/CssAsserter.php b/tests/Helper/CssAsserter.php new file mode 100644 index 0000000..c71ff1b --- /dev/null +++ b/tests/Helper/CssAsserter.php @@ -0,0 +1,67 @@ +styles = $styles; + } + + /** + * @param string $html Raw html. + * + * @return CssAsserter + */ + public static function createFromHtml($html) + { + $matches = []; + preg_match('##i', $html, $matches); + array_shift($matches); + + return new CssAsserter(implode("\n", $matches)); + } + + /** + * @param string $selector Css selector. + * + * @return CssAsserter + */ + public function hasSelector($selector) + { + TestCase::assertRegExp('/'.$selector.'/i', $this->styles); + + return $this; + } + + /** + * @param string $selector Css selector. + * + * @return CssPropertiesAsserter + */ + public function with($selector) + { + $this->hasSelector($selector); + + return new CssPropertiesAsserter(preg_replace('/%s[^\{]*?\{([^\}]*?)\}/i', '$1', $this->styles), $this); + } +} diff --git a/tests/Helper/CssPropertiesAsserter.php b/tests/Helper/CssPropertiesAsserter.php new file mode 100644 index 0000000..817864c --- /dev/null +++ b/tests/Helper/CssPropertiesAsserter.php @@ -0,0 +1,130 @@ +properties = $properties; + $this->cssAsserter = $cssAsserter; + } + + /** + * @param string $name Property name. + * @param string|null $value Property value. + * + * @return CssPropertiesAsserter + */ + public function has($name, $value = null) + { + if ($value !== null) { + $value = str_replace([ '(', ')' ], [ '\(', '\)' ], $value); + } + + $pattern = $value === null + ? sprintf('/%s:\s*[^;];/i', $name) + : sprintf('/%s:\s*%s;/i', $name, $value); + + TestCase::assertRegExp($pattern, $this->properties); + + return $this; + } + + /** + * @param string $name Property name. + * @param string|null $value Property value. + * + * @return CssPropertiesAsserter + */ + public function hasNot($name, $value = null) + { + $pattern = $value === null + ? sprintf('/%s:\s*[^;];/i', $name) + : sprintf('/%s:\s*%s;/i', $name, $value); + + TestCase::assertNotRegExp($pattern, $this->properties); + + return $this; + } + + /** + * @param ThemeOptionFont $font A ThemeOptionFont instance. + * + * @return CssPropertiesAsserter + */ + public function hasFont(ThemeOptionFont $font) + { + $this + ->has('font-family', $font->getFamily()->getCss()) + ->has('font-size', $font->getSize()); + + $style = $font->getStyle(); + + if ($style->isBold()) { + $this->has('font-weight', 'bold'); + } else { + $this->hasNot('font-weight'); + } + + if ($style->isItalic()) { + $this->has('font-style', 'italic'); + } else { + $this->hasNot('font-style'); + } + + if ($style->isUnderline()) { + $this->has('text-decoration', 'underline'); + } else { + $this->hasNot('text-decoration'); + } + + return $this; + } + + /** + * @return CssPropertiesAsserter + */ + public function hasNotFontAtAll() + { + return $this + ->hasNot('font-family') + ->hasNot('font-size') + ->hasNot('font-weight') + ->hasNot('font-style') + ->hasNot('text-decoration'); + } + + /** + * @return null|CssAsserter + */ + public function end() + { + return $this->cssAsserter; + } +} diff --git a/tests/Helper/HtmlAsserter.php b/tests/Helper/HtmlAsserter.php new file mode 100644 index 0000000..7d432f1 --- /dev/null +++ b/tests/Helper/HtmlAsserter.php @@ -0,0 +1,226 @@ +root = $root; + $this->parent = $parent; + } + + /** + * @param string $selector Node selector. + * @param integer $count Expected node numbers. Check that at least one + * exists if -1. + * + * @return HtmlAsserter + */ + public function hasNode($selector, $count = 1) + { + $nodes = $this->root->filter($selector); + + if ($count === -1) { + TestCase::assertGreaterThan(0, count($nodes), sprintf( + 'Expects at least one node \'%s\' but got zero', + $selector + )); + } else { + TestCase::assertCount($count, $nodes, sprintf( + 'Expects %d of \'%s\' nodes but got another count', + $count, + $selector + )); + } + + return $this; + } + + /** + * @param string $selector Node selector. + * + * @return HtmlAsserter + */ + public function hasNotNode($selector) + { + TestCase::assertCount(0, $this->root->filter($selector), sprintf( + 'Node \'%s\' is exists but should\'nt', + $selector + )); + + return $this; + } + + /** + * @param string $class Expected node class. + * + * @return HtmlAsserter + */ + public function hasClass($class) + { + TestCase::assertContains($class, $this->root->attr('class'), sprintf( + 'Current node should has \'%s\' class but it has not', + $class + )); + + return $this; + } + + /** + * @param string $name HTML node attribute name. + * @param string $value Expected value. + * + * @return HtmlAsserter + */ + public function hasAttr($name, $value = null) + { + $nodeValue = $this->root->attr($name); + + if ($value === null) { + TestCase::assertNotNull($nodeValue, sprintf( + 'Node attribute \'%s\' should exists but it has not', + $name + )); + } else { + TestCase::assertEquals($value, $nodeValue, sprintf( + 'Node attribute \'%s\' should match to \'%s\' but it has \'%s\'', + $name, + $value, + $nodeValue + )); + } + + return $this; + } + + /** + * @param string $content Expected text content. + * @param boolean $strict Should content only specified values or not. + * + * @return HtmlAsserter + */ + public function contains($content, $strict = false) + { + $nodeContent = $this->root->getNode(0)->textContent; + + if ($strict) { + TestCase::assertEquals($content, $nodeContent, sprintf( + 'Node should contains only \'%s\' but it has not', + $content + )); + } else { + TestCase::assertContains($content, $nodeContent, sprintf( + 'Node should contains \'%s\' but it has not', + $content + )); + } + + return $this; + } + + /** + * @param string $content Unexpected text content. + * @param boolean $strict Should content only specified values or not. + * + * @return $this + */ + public function notContains($content, $strict = false) + { + $nodeContent = $this->root->getNode(0)->textContent; + + if ($strict) { + TestCase::assertNotEquals($content, $nodeContent, sprintf( + 'Node should\'nt contains \'%s\' but it has', + $content + )); + } else { + TestCase::assertNotContains($content, $nodeContent, sprintf( + 'Node should\'nt contains \'%s\' but it has', + $content + )); + } + + return $this; + } + + /** + * @param string $regex Regular expression. + * + * @return HtmlAsserter + */ + public function regexContent($regex) + { + TestCase::assertRegExp($regex, $this->root->getNode(0)->textContent, sprintf( + 'Content of node should match to \'%s\' but it has not', + $regex + )); + + return $this; + } + + /** + * @param string $selector Node selector. + * + * @return HtmlAsserter + */ + public function with($selector) + { + $this->hasNode($selector, -1); + + return new static($this->root->filter($selector), $this); + } + + /** + * @param integer $idx Child node index. + * + * @return HtmlAsserter + */ + public function child($idx) + { + return new static(new Crawler($this->root->getNode($idx)), $this); + } + + /** + * @return HtmlAsserter + */ + public function end() + { + return $this->parent !== null ? $this->parent : $this; + } + + /** + * @return HtmlAsserter + */ + public function dump() + { + echo $this->root->html(); + + return $this; + } +} diff --git a/tests/IndexBundle/Filter/Filters/GroupFilterTest.php b/tests/IndexBundle/Filter/Filters/GroupFilterTest.php new file mode 100644 index 0000000..03ba931 --- /dev/null +++ b/tests/IndexBundle/Filter/Filters/GroupFilterTest.php @@ -0,0 +1,205 @@ +getMockForAbstractClass(AbstractGroupFilter::class, [ [ new GteFilter('foo', 10), new AndFilter() ]]); + $this->getMockForAbstractClass(AbstractGroupFilter::class, [ new GteFilter('foo', 10) ]); + $this->getMockForAbstractClass(AbstractGroupFilter::class, [ new AndFilter() ]); + $this->getMockForAbstractClass(AbstractGroupFilter::class); + $this->getMockForAbstractClass(AbstractGroupFilter::class); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage '$filters' should be array of FilterInterface instances or single instance + * + * @return void + */ + public function testCreateWithArrayOfInvalid() + { + $this->getMockForAbstractClass(AbstractGroupFilter::class, [ [ new GteFilter('foo', 10), 10 ] ]); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage '$filters' should be array of FilterInterface instances or single instance + * + * @return void + */ + public function testCreateWithSingleInvalid() + { + $this->getMockForAbstractClass(AbstractGroupFilter::class, [ 1 ]); + } + + /** + * @return void + */ + public function testSerialize() + { + $date = new \DateTime(); + + $filter1 = new EqFilter('some', 10); + $filter21 = new EqFilter('another', 'foo'); + $filter22 = new NotFilter(new GteFilter('bar', $date)); + $filter2 = $this->getMockForAbstractClass(AbstractGroupFilter::class, [ [ $filter21, $filter22 ] ]); + $filter = $this->getMockForAbstractClass(AbstractGroupFilter::class, [ [ $filter1, $filter2 ] ]); + + /** @var AbstractGroupFilter $unserializedFilter */ + $unserializedFilter = unserialize(serialize($filter)); + + $this->assertInstanceOf(AbstractGroupFilter::class, $unserializedFilter); + $this->assertCount(2, $unserializedFilter->getFilters()); + + /** @var EqFilter $unserializedInnerFilter1 */ + $unserializedInnerFilter1 = $unserializedFilter->getFilters()[0]; + /** @var AbstractGroupFilter $unserializedInnerFilter2 */ + $unserializedInnerFilter2 = $unserializedFilter->getFilters()[1]; + + $this->assertInstanceOf(EqFilter::class, $unserializedInnerFilter1); + $this->assertSame('some', $unserializedInnerFilter1->getFieldName()); + $this->assertSame(10, $unserializedInnerFilter1->getValue()); + + $this->assertInstanceOf(AbstractGroupFilter::class, $unserializedInnerFilter2); + $this->assertCount(2, $unserializedInnerFilter2->getFilters()); + + /** @var EqFilter $unserializedInnerFilter21 */ + $unserializedInnerFilter21 = $unserializedInnerFilter2->getFilters()[0]; + /** @var NotFilter $unserializedInnerFilter22 */ + $unserializedInnerFilter22 = $unserializedInnerFilter2->getFilters()[1]; + + $this->assertInstanceOf(EqFilter::class, $unserializedInnerFilter21); + $this->assertSame('another', $unserializedInnerFilter21->getFieldName()); + $this->assertSame('foo', $unserializedInnerFilter21->getValue()); + + $this->assertInstanceOf(NotFilter::class, $unserializedInnerFilter22); + + /** @var GteFilter $unserializedInnerFilter22Inner */ + $unserializedInnerFilter22Inner = $unserializedInnerFilter22->getFilter(); + + $this->assertInstanceOf(GteFilter::class, $unserializedInnerFilter22Inner); + $this->assertSame('bar', $unserializedInnerFilter22Inner->getFieldName()); + $this->assertEquals( + $date->format('c'), + $unserializedInnerFilter22Inner->getValue()->format('c') + ); + } + + /** + * @return void + */ + public function testSort() + { + $date = new \DateTime(); + + $filter1 = new EqFilter('some', 10); + $filter21 = new EqFilter('another', 'foo'); + $filter22 = new NotFilter(new GteFilter('bar', $date)); + $filter2 = new AndFilter([ $filter22, $filter21 ]); + $filter = new OrFilter([ $filter2, $filter1 ]); + + $filter->sort(); + + $this->assertSame([ $filter1, $filter2 ], $filter->getFilters()); + $this->assertSame([ $filter21, $filter22 ], $filter2->getFilters()); + } + + /** + * @return void + */ + public function testCompareValueFilters() + { + $filter = $this->getMockForAbstractClass(AbstractGroupFilter::class); + + $this->assertGreaterThan(0, $this->call($filter, 'compareValueFilters', [ + new EqFilter('b', 10), + new EqFilter('a', 10), + ])); + + $this->assertLessThan(0, $this->call($filter, 'compareValueFilters', [ + new EqFilter('a', 9), + new EqFilter('a', 10), + ])); + + $this->assertEquals(0, $this->call($filter, 'compareValueFilters', [ + new GteFilter('a', 10), + new LteFilter('a', 10), + ])); + } + + /** + * @return void + */ + public function testCompareInFilters() + { + $filter = $this->getMockForAbstractClass(AbstractGroupFilter::class); + + $this->assertGreaterThan(0, $this->call($filter, 'compareInFilters', [ + new InFilter('b', [ 10 ]), + new InFilter('a', [ 10 ]), + ])); + + $this->assertLessThan(0, $this->call($filter, 'compareInFilters', [ + new InFilter('a', [ 9 ]), + new InFilter('a', [ 10 ]), + ])); + + $this->assertEquals(0, $this->call($filter, 'compareInFilters', [ + new InFilter('a', [ 10 ]), + new InFilter('a', [ 10 ]), + ])); + + $this->assertLessThan(0, $this->call($filter, 'compareInFilters', [ + new InFilter('a', [ 10 ]), + new InFilter('a', [ 10, 10 ]), + ])); + } + + /** + * @return void + */ + public function testCompareGroupFilters() + { + $filter = $this->getMockForAbstractClass(AbstractGroupFilter::class); + + $this->assertEquals(0, $this->call($filter, 'compareGroupFilters', [ + new AndFilter(), + new AndFilter(), + ])); + + $this->assertEquals(0, $this->call($filter, 'compareGroupFilters', [ + new AndFilter([ new AndFilter([ new EqFilter('a', 10) ]) ]), + new AndFilter([ new AndFilter([ new GteFilter('a', 10) ]) ]), + ])); + + $this->assertGreaterThan(0, $this->call($filter, 'compareGroupFilters', [ + new AndFilter([ new EqFilter('a', 10) ]), + new AndFilter([ new EqFilter('a', 9) ]), + ])); + + $this->assertLessThan(0, $this->call($filter, 'compareGroupFilters', [ + new AndFilter([ new EqFilter('a', 10) ]), + new AndFilter([ new AndFilter() ]), + ])); + + $this->assertLessThan(0, $this->call($filter, 'compareGroupFilters', [ + new AndFilter(), + new AndFilter([ new AndFilter() ]), + ])); + } +} diff --git a/tests/IndexBundle/Filter/Filters/InFilterTest.php b/tests/IndexBundle/Filter/Filters/InFilterTest.php new file mode 100644 index 0000000..3e73707 --- /dev/null +++ b/tests/IndexBundle/Filter/Filters/InFilterTest.php @@ -0,0 +1,106 @@ +assertEquals('foo', $filter->getFieldName()); + $this->assertEquals([], $filter->getValue()); + + $dateMut = date_create(); + $dateImm = date_create_immutable(' + 1 day'); + + $filter = new InFilter('bar', [ + 12, + true, + 2.3, + 'string', + $dateMut, + $dateImm, + PublisherTypeEnum::unknown(), + ]); + $this->assertEquals('bar', $filter->getFieldName()); + $this->assertEquals([ + 12, + true, + 2.3, + 'string', + $dateMut, + $dateImm, + PublisherTypeEnum::UNKNOWN, + ], $filter->getValue()); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage '$field' should be string + * + * @return void + */ + public function testCreateInvalidField() + { + new InFilter(1, []); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage '$values' should be an array of scalar values, AbstractEnum or \DateTimeInterface instances + + * + * @return void + */ + public function testCreateInvalidValues() + { + new InFilter('foo', [ [ 1 ] ]); + } + + /** + * @return void + */ + public function testSerialize() + { + $values = [ 10, 20, 100 ]; + + $filter = new InFilter('foo', $values); + + /** @var InFilter $unserializedFilter */ + $unserializedFilter = unserialize(serialize($filter)); + + $this->assertInstanceOf(InFilter::class, $unserializedFilter); + $this->assertSame('foo', $unserializedFilter->getFieldName()); + $this->assertSame($values, $unserializedFilter->getValue()); + } + + /** + * @return void + */ + public function testSort() + { + $filter = new InFilter('foo', [ 11, -3, 30]); + $filter->sort(); + $this->assertSame([-3, 11, 30], $filter->getValue()); + + $date1 = new \DateTime('+ 1 day'); + $date2 = new \DateTime(); + $date3 = new \DateTime('- 1 month'); + + $filter = new InFilter('some', [ $date1, $date2, $date3 ]); + $filter->sort(); + $this->assertSame([ $date3, $date2, $date1 ], $filter->getValue()); + } +} diff --git a/tests/IndexBundle/Filter/Filters/ValueFilterTest.php b/tests/IndexBundle/Filter/Filters/ValueFilterTest.php new file mode 100644 index 0000000..5ccd64b --- /dev/null +++ b/tests/IndexBundle/Filter/Filters/ValueFilterTest.php @@ -0,0 +1,102 @@ +getMockForAbstractClass(AbstractValueFilter::class, [ 'foo', 10 ]); + $this->assertEquals('foo', $mock->getFieldName()); + $this->assertEquals(10, $mock->getValue()); + + $mock = $this->getMockForAbstractClass(AbstractValueFilter::class, [ 'foo', 'string' ]); + $this->assertEquals('foo', $mock->getFieldName()); + $this->assertEquals('string', $mock->getValue()); + + $mock = $this->getMockForAbstractClass(AbstractValueFilter::class, [ 'foo', 2.4 ]); + $this->assertEquals('foo', $mock->getFieldName()); + $this->assertEquals(2.4, $mock->getValue()); + + $mock = $this->getMockForAbstractClass(AbstractValueFilter::class, [ 'foo', true ]); + $this->assertEquals('foo', $mock->getFieldName()); + $this->assertEquals(true, $mock->getValue()); + + $date = date_create(); + $mock = $this->getMockForAbstractClass(AbstractValueFilter::class, [ 'foo', $date ]); + $this->assertEquals('foo', $mock->getFieldName()); + $this->assertEquals($date, $mock->getValue()); + + $date = date_create_immutable(); + $mock = $this->getMockForAbstractClass(AbstractValueFilter::class, [ 'foo', $date ]); + $this->assertEquals('foo', $mock->getFieldName()); + $this->assertEquals($date, $mock->getValue()); + + $mock = $this->getMockForAbstractClass(AbstractValueFilter::class, [ 'foo', PublisherTypeEnum::blogs() ]); + $this->assertEquals('foo', $mock->getFieldName()); + $this->assertEquals(PublisherTypeEnum::BLOGS, $mock->getValue()); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage '$field' should be string + * + * @return void + */ + public function testCreateInvalidField() + { + $this->getMockForAbstractClass(AbstractValueFilter::class, [ 1, 10 ]); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage '$value' should be scalar, AbstractEnum or \DateTimeInterface instance + * + * @return void + */ + public function testCreateInvalidValues() + { + $this->getMockForAbstractClass(AbstractValueFilter::class, [ 'foo', [ 1 ] ]); + } + + /** + * @return void + */ + public function testSerialize() + { + $filter = $this->getMockForAbstractClass(AbstractValueFilter::class, [ + 'foo', + 20, + ]); + + /** @var AbstractValueFilter $unserializedFilter */ + $unserializedFilter = unserialize(serialize($filter)); + + $this->assertInstanceOf(AbstractValueFilter::class, $unserializedFilter); + $this->assertSame('foo', $unserializedFilter->getFieldName()); + $this->assertSame(20, $unserializedFilter->getValue()); + } + + /** + * @return void + */ + public function testSort() + { + $filter = $this->getMockForAbstractClass(AbstractValueFilter::class, [ 'foo', 10 ]); + $filter->sort(); + $this->assertSame(10, $filter->getValue()); + } +} diff --git a/tests/IndexBundle/Index/External/Spinn3rIndexTest.php b/tests/IndexBundle/Index/External/Spinn3rIndexTest.php new file mode 100644 index 0000000..f1aba67 --- /dev/null +++ b/tests/IndexBundle/Index/External/Spinn3rIndexTest.php @@ -0,0 +1,163 @@ + [ 'query' => [ 'query_string' => [ 'query' => sprintf( + 'published:[%s TO *]', + date_create('+ 36 days')->format('c') + ), ], ], ], + ]; + + $parameters = $this->call($this->index, 'beforeSearch', [ $parameters ]); + + $this->assertArrayHasKey('index', $parameters); + $this->assertEquals( + implode(',', [ HoseIndex::HOT, HoseIndex::WARM ]), + $parameters['index'] + ); + } + + /** + * @dataProvider getQueryRangeEndProvider + * + * @param \DateTime|null $expected Expected result. + * @param string $query ES query string. + * + * @return void + */ + public function testGetQueryRangeEnd(\DateTime $expected = null, $query) + { + $this->assertEquals( + $expected, + $this->call($this->index, 'getQueryRangeEnd', [ $query ]) + ); + } + + /** + * @return array + */ + public function getQueryRangeEndProvider() + { + $date1 = date_create('+ 36 days 00:00:00'); + + return [ + [ + $date1, + sprintf('published:[%s TO *]', $date1->format('c')), + ], + [ + date_create('2017-11-27T00:00:00+07:00'), + '{"body":{"query":{"query_string":{"query":"(main:("Clarion School" -"North Clarion") OR title:("Clarion School" -"North Clarion")) AND ((source_publisher_type:(MAINSTREAM_NEWS OR REVIEW OR CLASSIFIED OR UNKNOWN OR WEBLOG OR MICROBLOG OR UNKNOWN OR SOCIAL_MEDIA OR UNKNOWN OR VIDEO OR UNKNOWN OR FORUM OR MEMETRACKER OR UNKNOWN OR PHOTO OR UNKNOWN)) AND ((published:[2017-11-27T00:00:00+07:00 TO *]) AND (published:[* TO 2018-01-26T23:59:59+07:00])) AND (published:[* TO 2018-01-26T19:20:26+07:00]))"}},"sort":{"published":{"order":"desc"}}},"index":"content_*","type":"","size":100,"from":0}', + ], + [ + null, + '', + ], + [ + null, + '{"body":{"query":{"query_string":{"query":"(main:("Clarion School" -"North Clarion") OR title:("Clarion School" -"North Clarion")) AND ((source_publisher_type:(MAINSTREAM_NEWS OR REVIEW OR CLASSIFIED OR UNKNOWN OR WEBLOG OR MICROBLOG OR UNKNOWN OR SOCIAL_MEDIA OR UNKNOWN OR VIDEO OR UNKNOWN OR FORUM OR MEMETRACKER OR UNKNOWN OR PHOTO OR UNKNOWN)))"}},"sort":{"published":{"order":"desc"}}},"index":"content_*","type":"","size":100,"from":0}', + ], + ]; + } + + /** + * @dataProvider determineIndexProvider + * + * @param array $expected Expected hose indices. + * @param array $arguments Tested method arguments. + * + * @return void + */ + public function testDetermineIndex(array $expected, array $arguments) + { + $this->assertEquals( + implode(',', $expected), + $this->call($this->index, 'determineIndex', $arguments) + ); + } + + /** + * @return array + */ + public function determineIndexProvider() + { + return [ + 'without date' => [ + [ HoseIndex::HOT, HoseIndex::WARM, HoseIndex::COLD ], + [], + ], + '72 days ahead' => [ + [ HoseIndex::HOT, HoseIndex::WARM, HoseIndex::COLD ], + [ date_create('+ 72 days') ], + ], + '60 days and 1 hour ahead' => [ + [ HoseIndex::HOT, HoseIndex::WARM, HoseIndex::COLD ], + [ date_create('+ 60 days 01:00:00') ], + ], + '60 days ahead' => [ + [ HoseIndex::HOT, HoseIndex::WARM ], + [ date_create('+ 60 days 00:00:00') ], + ], + '36 days ahead' => [ + [ HoseIndex::HOT, HoseIndex::WARM ], + [ date_create('+ 36 days') ], + ], + '30 days and 1 hour ahead' => [ + [ HoseIndex::HOT, HoseIndex::WARM ], + [ date_create('+ 30 days 01:00:00') ], + ], + '30 days ahead' => [ + [ HoseIndex::HOT ], + [ date_create('+ 30 days 00:00:00') ], + ], + '21 days ahead' => [ + [ HoseIndex::HOT ], + [ date_create('+ 21 days') ], + ], + 'current date' => [ + [ HoseIndex::HOT ], + [ date_create() ], + ], + ]; + } + + /** + * Sets up the fixture, for example, open a network connection. + * This method is called before a test is executed. + * + * @return void + */ + protected function setUp() + { + $this->index = new HoseIndex( + new NullLogger(), + new NullAdapter(), + 'host', + 'vendor', + 'auth' + ); + } +} diff --git a/tests/IndexBundle/Model/AbstractIndexDocumentTest.php b/tests/IndexBundle/Model/AbstractIndexDocumentTest.php new file mode 100644 index 0000000..fcbef73 --- /dev/null +++ b/tests/IndexBundle/Model/AbstractIndexDocumentTest.php @@ -0,0 +1,169 @@ +getMockForInterface(IndexStrategyInterface::class); + + $strategy + ->method('normalizeDocumentData') + ->willReturnCallback(function (array $data) { + return $data; + }); + + $strategy + ->method('getIndexableData') + ->willReturnCallback(function (array $data) { + return $data; + }); + + $strategy + ->method('normalizeFieldName') + ->willReturnCallback(function ($fieldName) { + return $fieldName; + }); + + $strategy + ->method('denormalizeFieldName') + ->willReturnCallback(function ($fieldName) { + return $fieldName; + }); + + $strategy + ->method('normalizePublisherType') + ->willReturnCallback(function ($type) { + return $type; + }); + + $strategy + ->method('denormalizePublisherType') + ->willReturnCallback(function ($type) { + return $type; + }); + + $this->model = new TestModel($strategy, [ + 'first' => 1, + 'second' => 'two', + 'third' => [ 3, 2, 1 ], + ]); + } + + /** + * @return void + */ + public function testAccessAsArray() + { + $this->assertSame(1, $this->model['first']); + $this->assertSame('two', $this->model['second']); + $this->assertSame([ 3, 2, 1], $this->model['third']); + + $this->model['first'] = 2; + $this->model['second'] = 'third'; + $this->model['third'] = [ 1, 2, 3 ]; + + $this->assertSame(2, $this->model['first']); + $this->assertSame('third', $this->model['second']); + $this->assertSame([ 1, 2, 3], $this->model['third']); + } + + /** + * @return void + */ + public function testIterate() + { + $expected = [ + 'first' => 1, + 'second' => 'two', + 'third' => [ 3, 2, 1 ], + ]; + + reset($expected); + foreach ($this->model as $name => $value) { + $this->assertSame(key($expected), $name); + $this->assertSame(current($expected), $value); + + next($expected); + } + + $this->model['first'] = 2; + $this->model['second'] = 'third'; + $this->model['third'] = [ 1, 2, 3 ]; + + $expected = [ + 'first' => 2, + 'second' => 'third', + 'third' => [ 1, 2, 3 ], + ]; + + reset($expected); + foreach ($this->model as $name => $value) { + $this->assertSame(key($expected), $name); + $this->assertSame(current($expected), $value); + + next($expected); + } + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Unknown property 'fourth' + * + * @return void + */ + public function testAccessAsArrayInvalidProperty() + { + $this->model['fourth']; + } + + /** + * @return void + */ + public function testAccessAsProperty() + { + $this->assertSame(1, $this->model->first); + $this->assertSame('two', $this->model->second); + $this->assertSame([ 3, 2, 1], $this->model->third); + + $this->model->first = 2; + $this->model->second = 'third'; + $this->model->third = [ 1, 2, 3 ]; + + $this->assertSame(2, $this->model->first); + $this->assertSame('third', $this->model->second); + $this->assertSame([ 1, 2, 3], $this->model->third); + } +} diff --git a/tests/IndexBundle/Model/TestModel.php b/tests/IndexBundle/Model/TestModel.php new file mode 100644 index 0000000..f9f4736 --- /dev/null +++ b/tests/IndexBundle/Model/TestModel.php @@ -0,0 +1,27 @@ +normalizer = new QueryNormalizer(); + } + + /** + * @dataProvider queriesProvider + * + * @param string $query1 First search query. + * @param string $query2 Second search query. + * @param boolean $expects Match or don't. + * + * @return void + */ + public function testGenerateUniqueKey($query1, $query2, $expects) + { + $query1 = $this->normalizer->normalize($query1); + $query2 = $this->normalizer->normalize($query2); + + $message = "Key's must be ". ($expects ? 'same' : 'differ') + .', but first query is '. $query1 .' and second is '. $query2; + $this->assertEquals($query1 === $query2, $expects, $message); + } + + /** + * @return array + */ + public function queriesProvider() + { + return [ + 'cat dog === dog cat' => [ + 'cat dog', + 'dog cat', + true, + ], + 'cat~ dog~0.8 === dog~0.8 cat~' => [ + 'cat~ dog~0.8', + 'dog~0.8 cat~', + true, + ], + 'cat~ dog~0.7 !== dog~0.8 cat~' => [ + 'cat~ dog~0.7', + 'dog~0.8 cat~', + false, + ], + 'ca?t dog === dog ca?t' => [ + 'ca?t dog', + 'dog ca?t', + true, + ], + 'c?at dog !== dog ca?t' => [ + 'c?at dog', + 'dog ca?t', + false, + ], + 'cat^4 dog === dog cat^4' => [ + 'cat^4 dog', + 'dog cat^4', + true, + ], + 'cat^3 dog !== dog cat^4' => [ + 'cat^3 dog', + 'dog cat^4', + false, + ], + '* dog === dog *' => [ + '* dog', + 'dog *', + true, + ], + '+cat dog === dog +cat' => [ + '+cat dog', + 'dog +cat', + true, + ], + 'NOT cat AND dog === dog AND NOT cat' => [ + 'NOT cat AND dog', + 'dog AND NOT cat', + true, + ], + 'NOT (cat AND (dog OR fish)) === NOT ((fish OR dog) AND cat)' => [ + 'NOT (cat AND (dog OR fish))', + 'NOT ((fish OR dog) AND cat)', + true, + ], + ' AND or NOT !== NOT or AND' => [ + ' AND or NOT', + 'NOT or AND', + false, + ], + 'OR AND NOT === NOT OR AND' => [ + 'OR AND NOT', + 'NOT OR AND', + true, + ], + 'NOT cat !== cat NOT' => [ + 'NOT cat', + 'cat NOT', + false, + ], + 'cat AND dog !== cat and dog' => [ + 'cat AND dog', + 'cat and dog', + false, + ], + '"cat fly" AND dog === dog AND "cat fly"' => [ + '"cat fly" AND dog', + 'dog AND "cat fly"', + true, + ], + '"cat fly"^3 AND dog === dog AND "cat fly"^3' => [ + '"cat fly"^3 AND dog', + 'dog AND "cat fly"^3', + true, + ], + '"cat fly"^4 AND dog !== dog AND "cat fly"^3' => [ + '"cat fly"^4 AND dog', + 'dog AND "cat fly"^3', + false, + ], + '"cat fly"~0.4 AND dog === dog AND "cat fly"~0.4' => [ + '"cat fly"~0.4 AND dog', + 'dog AND "cat fly"~0.4', + true, + ], + '"cat fly"~0.3 AND dog !== dog AND "cat fly"~0.4' => [ + '"cat fly"~0.3 AND dog', + 'dog AND "cat fly"~0.4', + false, + ], + '"cat fly"+ AND dog !== dog AND "cat fly"' => [ + '"cat fly"+ AND dog', + 'dog AND "cat fly"', + false, + ], + '"cat fly"+ AND dog === dog AND "cat fly"+' => [ + '"cat fly"+ AND dog', + 'dog AND "cat fly"+', + true, + ], + 'cat AND dog !== cat dog' => [ + 'cat AND dog', + 'cat dog', + false, + ], + 'cat OR dog === cat dog' => [ + 'cat OR dog', + 'cat dog', + true, + ], + '(cat) dog === cat dog' => [ + '(cat) dog', + 'cat dog', + true, + ], + '(cat AND dog) === cat AND dog' => [ + '(cat AND dog)', + 'cat AND dog', + true, + ], + '(cat dog) === dog cat' => [ + '(cat dog)', + 'dog cat', + true, + ], + '(((cat OR dog))) === cat dog' => [ + '(((cat OR dog)))', + 'cat dog', + true, + ], + '(cat AND dog) OR fish === cat AND dog OR fish' => [ + '(cat AND dog) OR fish', + 'cat AND dog OR fish', + true, + ], + 'cat AND (dog OR fish) !== cat AND dog OR fish' => [ + 'cat AND (dog OR fish)', + 'cat AND dog OR fish', + false, + ], + '(cat AND dog) (fish AND bird) === (cAt AND dog) (fish AND BIRd)' => [ + '(cat AND dog) (fish AND bird)', + '(cAt AND dog) (fish AND BIRd)', + true, + ], + '(cat AND dog) AND bird === cAt AND BIRD AND dOg' => [ + '(cat AND dog) AND bird', + 'cAt AND BIRD AND dOg', + true, + ], + '(cat OR dog) bird === cAt bird doG' => [ + '(cat OR dog) bird', + 'cAt bird doG', + true, + ], + '(dog OR dog) AND cat === cat AND doG' => [ + '(dog OR dog) AND cat', + 'cat AND doG', + true, + ], + '(dog AND dog) AND cat === cat AND doG' => [ + '(dog AND dog) AND cat', + 'cat AND doG', + true, + ], + '(dog AND dog) AND cat AND (fish OR fish) === fish AND cat AND doG' => [ + '(dog AND dog) AND cat AND (fish OR fish)', + 'fish AND cat AND doG', + true, + ], + '(dog AND dog) OR (cat AND cat) === dog OR (cat)' => [ + '(dog AND dog) OR (cat AND cat)', + 'dog OR (cat)', + true, + ], + '"NOT ("dog cat"^3 AND (Alice OR "fish"~0.3)) OR NOT ("dog cat fish"~ AND NOT (+"Alice" OR dog~0.3 OR man^3))' => [ + 'NOT ("dog cat"^3 AND (Alice OR "fish"~0.3)) OR NOT ("dog cat fish"~ AND NOT (+"Alice" OR dog~0.3 OR man^3))', + 'NOT (NOT (man^3 OR +"Alice" OR dog~0.3) AND "dog cat fish"~) OR NOT ("dog cat"^3 AND ("fish"~0.3 OR Alice))', + true, + ], + '+Ethiopia +Sudan -”South Africa” === -”South Africa” +Ethiopia +Sudan' => [ + '+Ethiopia +Sudan -”South Africa”', + '-”South Africa” +Ethiopia +Sudan', + true, + ], + ]; + } +} diff --git a/tests/UserBundle/Entity/Notification/Schedule/DailyNotificationScheduleTest.php b/tests/UserBundle/Entity/Notification/Schedule/DailyNotificationScheduleTest.php new file mode 100644 index 0000000..dbc5874 --- /dev/null +++ b/tests/UserBundle/Entity/Notification/Schedule/DailyNotificationScheduleTest.php @@ -0,0 +1,119 @@ +setDays(DailyNotificationSchedule::DAYS_ALL) + ->setTime(DailyNotificationSchedule::TIME_30_M); + + $start = new \DateTime(); + $start->setTime($start->format('H'), $start->format('i'), 0); + $end = clone $start; + $end->modify('+ 1 day'); + + $dates = $schedule->computeDates($start, $end); + + /** @var \DateTime $date */ + $checkStart = clone $start; + foreach ($dates as $date) { + $this->assertSame( + $checkStart->modify('+ 30 minute')->format('c'), + $date->format('c') + ); + } + } + + /** + * @return void + */ + public function testComputeDateWeekends() + { + $schedule = DailyNotificationSchedule::create() + ->setDays(DailyNotificationSchedule::DAYS_WEEKENDS) + ->setTime(DailyNotificationSchedule::TIME_4_H); + + $start = new \DateTime(); + $start->modify('first friday'); + $start->setTime(23, 0, 0); + $end = clone $start; + $end->modify('+ 1 day'); + + $dates = $schedule->computeDates($start, $end); + + /** @var \DateTime $date */ + $checkStart = clone $start; + foreach ($dates as $date) { + $this->assertSame( + $checkStart->modify('+ 4 hour')->format('c'), + $date->format('c') + ); + $this->assertLessThanOrEqual((int) $date->format('N'), 5); + } + } + + /** + * @return void + */ + public function testComputeDateWeekdays() + { + $schedule = DailyNotificationSchedule::create() + ->setDays(DailyNotificationSchedule::DAYS_WEEKDAYS) + ->setTime(DailyNotificationSchedule::TIME_1_H); + + $start = new \DateTime(); + $start->modify('first sunday'); + $start->setTime(23, 0, 0); + $end = clone $start; + $end->modify('+ 1 day'); + + $dates = $schedule->computeDates($start, $end); + + /** @var \DateTime $date */ + $checkStart = clone $start; + foreach ($dates as $date) { + $this->assertSame( + $checkStart->modify('+ 1 hour')->format('c'), + $date->format('c') + ); + $this->assertGreaterThan((int) $date->format('N'), 5); + } + } + + /** + * @return void + */ + public function testComputeDateWithSpecifiedDates() + { + $schedule = DailyNotificationSchedule::create() + ->setDays(DailyNotificationSchedule::DAYS_ALL) + ->setTime(DailyNotificationSchedule::TIME_15_M); + + $start = new \DateTime(); + $start + ->setDate(2017, 6, 9) + ->setTime(0, 0, 0); + $end = clone $start; + $end->modify('+ 30 minutes'); + + $dates = $schedule->computeDates($start, $end); + + $this->assertCount(2, $dates); + $this->assertSame('2017-06-09 00:15:00', $dates[0]->format('Y-m-d H:i:s')); + $this->assertSame('2017-06-09 00:30:00', $dates[1]->format('Y-m-d H:i:s')); + } +} diff --git a/tests/UserBundle/Entity/Notification/Schedule/MonthlyNotificationScheduleTest.php b/tests/UserBundle/Entity/Notification/Schedule/MonthlyNotificationScheduleTest.php new file mode 100644 index 0000000..19f3642 --- /dev/null +++ b/tests/UserBundle/Entity/Notification/Schedule/MonthlyNotificationScheduleTest.php @@ -0,0 +1,109 @@ +setDay(5) + ->setHour(12) + ->setMinute(35); + + $start = date_create()->modify('first day of this month')->setTime(0, 0, 0); + $end = date_create()->modify('last day of next month')->setTime(0, 0, 0); + + $dates = $schedule->computeDates($start, $end); + + $this->assertCount(2, $dates); + + $this->assertSame($dates[0]->format('Y-m-d'), date_create()->modify('first day of this month')->modify('4 day')->format('Y-m-d')); + $this->assertSame(5, (int) $dates[0]->format('j')); + $this->assertSame(12, (int) $dates[0]->format('H')); + $this->assertSame(35, (int) $dates[0]->format('i')); + $this->assertSame($dates[1]->format('Y-m-d'), date_create()->modify('first day of next month')->modify('4 day')->format('Y-m-d')); + $this->assertSame(5, (int) $dates[1]->format('j')); + $this->assertSame(12, (int) $dates[1]->format('H')); + $this->assertSame(35, (int) $dates[1]->format('i')); + + $schedule = MonthlyNotificationSchedule::create() + ->setDay(2) + ->setHour(8) + ->setMinute(50); + + $start = date_create()->modify('first day of this month')->modify('1 day')->setTime(0, 0, 0); + $end = date_create()->modify('next month')->modify('first day of next month')->modify('1 day')->setTime(0, 0, 0); + + $dates = $schedule->computeDates($start, $end); + + $this->assertCount(3, $dates); + + $this->assertSame($dates[0]->format('Y-m-d'), date_create()->modify('first day of this month')->modify('1 day')->format('Y-m-d')); + $this->assertSame(2, (int) $dates[0]->format('j')); + $this->assertSame(8, (int) $dates[0]->format('H')); + $this->assertSame(50, (int) $dates[0]->format('i')); + $this->assertSame($dates[1]->format('Y-m-d'), date_create()->modify('first day of next month')->modify('1 day')->format('Y-m-d')); + $this->assertSame(2, (int) $dates[1]->format('j')); + $this->assertSame(8, (int) $dates[1]->format('H')); + $this->assertSame(50, (int) $dates[1]->format('i')); + $this->assertSame($dates[2]->format('Y-m-d'), date_create()->modify('next month')->modify('first day of next month')->modify('1 day')->format('Y-m-d')); + $this->assertSame(2, (int) $dates[2]->format('j')); + $this->assertSame(8, (int) $dates[2]->format('H')); + $this->assertSame(50, (int) $dates[2]->format('i')); + } + + /** + * @return void + */ + public function testComputeDateLast() + { + $schedule = MonthlyNotificationSchedule::create() + ->setDay(MonthlyNotificationSchedule::DAY_LAST) + ->setHour(12) + ->setMinute(35); + + $start = date_create()->modify('first day of this month')->setTime(0, 0, 0); + $end = date_create()->modify('last day of next month')->setTime(0, 0, 0); + + $dates = $schedule->computeDates($start, $end); + + $this->assertCount(2, $dates); + + $this->assertSame($dates[0]->format('Y-m-d'), date_create()->modify('last day of this month')->format('Y-m-d')); + $this->assertSame(12, (int) $dates[0]->format('H')); + $this->assertSame(35, (int) $dates[0]->format('i')); + $this->assertSame($dates[1]->format('Y-m-d'), date_create()->modify('last day of next month')->format('Y-m-d')); + $this->assertSame(12, (int) $dates[1]->format('H')); + $this->assertSame(35, (int) $dates[1]->format('i')); + + $schedule = MonthlyNotificationSchedule::create() + ->setDay(MonthlyNotificationSchedule::DAY_LAST) + ->setHour(2) + ->setMinute(15); + + $start = date_create()->modify('last day of this month')->setTime(0, 0, 0); + $end = date_create()->modify('first day of next month')->setTime(0, 0, 0); + + $dates = $schedule->computeDates($start, $end); + + $this->assertCount(1, $dates); + + $this->assertSame($dates[0]->format('Y-m-d'), date_create()->modify('last day of this month')->format('Y-m-d')); + $this->assertSame(2, (int) $dates[0]->format('H')); + $this->assertSame(15, (int) $dates[0]->format('i')); + } +} diff --git a/tests/UserBundle/Entity/Notification/Schedule/WeeklyNotificationScheduleTest.php b/tests/UserBundle/Entity/Notification/Schedule/WeeklyNotificationScheduleTest.php new file mode 100644 index 0000000..2d06cc1 --- /dev/null +++ b/tests/UserBundle/Entity/Notification/Schedule/WeeklyNotificationScheduleTest.php @@ -0,0 +1,117 @@ +setPeriod(WeeklyNotificationSchedule::PERIOD_SECOND) + ->setDay(WeeklyNotificationSchedule::DAY_FRIDAY) + ->setHour(12) + ->setMinute(35); + + $start = date_create()->modify('first day of this month')->setTime(0, 0, 0); + $end = date_create()->modify('second friday of next month')->setTime(0, 0, 0); + + $dates = $schedule->computeDates($start, $end); + + $this->assertCount(2, $dates); + + $this->assertSame($dates[0]->format('Y-m-d'), date_create()->modify('second friday of this month')->format('Y-m-d')); + $this->assertSame(12, (int) $dates[0]->format('H')); + $this->assertSame(35, (int) $dates[0]->format('i')); + $this->assertSame($dates[1]->format('Y-m-d'), date_create()->modify('second friday of next month')->format('Y-m-d')); + $this->assertSame(12, (int) $dates[1]->format('H')); + $this->assertSame(35, (int) $dates[1]->format('i')); + + + $schedule = WeeklyNotificationSchedule::create() + ->setPeriod(WeeklyNotificationSchedule::PERIOD_FIRST) + ->setDay(WeeklyNotificationSchedule::DAY_TUESDAY); + + $start = date_create()->modify('first day of this month')->setTime(0, 0, 0); + $end = date_create()->modify('second friday of next month')->setTime(0, 0, 0); + + $dates = $schedule->computeDates($start, $end); + + $this->assertCount(2, $dates); + + $this->assertSame($dates[0]->format('Y-m-d'), date_create()->modify('first tuesday of this month')->format('Y-m-d')); + $this->assertSame(0, (int) $dates[0]->format('H')); + $this->assertSame(0, (int) $dates[0]->format('i')); + $this->assertSame($dates[1]->format('Y-m-d'), date_create()->modify('first tuesday of next month')->format('Y-m-d')); + $this->assertSame(0, (int) $dates[1]->format('H')); + $this->assertSame(0, (int) $dates[1]->format('i')); + } + + /** + * @return void + */ + public function testComputeDateEvery() + { + $schedule = WeeklyNotificationSchedule::create() + ->setPeriod(WeeklyNotificationSchedule::PERIOD_EVERY) + ->setDay(WeeklyNotificationSchedule::DAY_MONDAY) + ->setHour(10) + ->setMinute(45); + + $start = date_create()->modify('first day of this month')->setTime(0, 0, 0); + $end = date_create()->modify('last day of next month')->setTime(0, 0, 0); + + $dates = $schedule->computeDates($start, $end); + + /** @var \DateTime $date */ + foreach ($dates as $date) { + $this->assertSame( + 1, + (int) $date->format('N') + ); + $this->assertSame(10, (int) $date->format('H')); + $this->assertSame(45, (int) $date->format('i')); + } + } + + /** + * @return void + */ + public function testComputeDateLast() + { + $schedule = WeeklyNotificationSchedule::create() + ->setPeriod(WeeklyNotificationSchedule::PERIOD_LAST) + ->setDay(WeeklyNotificationSchedule::DAY_SUNDAY) + ->setHour(10) + ->setMinute(45); + + $start = date_create()->modify('first day of this month')->setTime(0, 0, 0); + $end = date_create()->modify('next month')->modify('last day of next month')->setTime(0, 0, 0); + + $dates = $schedule->computeDates($start, $end); + + $this->assertCount(3, $dates); + + $this->assertSame($dates[0]->format('Y-m-d'), date_create()->modify('last sunday of this month')->format('Y-m-d')); + $this->assertSame(10, (int) $dates[0]->format('H')); + $this->assertSame(45, (int) $dates[0]->format('i')); + $this->assertSame($dates[1]->format('Y-m-d'), date_create()->modify('last sunday of next month')->format('Y-m-d')); + $this->assertSame(10, (int) $dates[1]->format('H')); + $this->assertSame(45, (int) $dates[1]->format('i')); + $this->assertSame($dates[2]->format('Y-m-d'), date_create()->modify('next month')->modify('last sunday of next month')->format('Y-m-d')); + $this->assertSame(10, (int) $dates[2]->format('H')); + $this->assertSame(45, (int) $dates[2]->format('i')); + } +} diff --git a/tests/UserBundle/Entity/Recipient/PersonRecipientTest.php b/tests/UserBundle/Entity/Recipient/PersonRecipientTest.php new file mode 100644 index 0000000..edccf80 --- /dev/null +++ b/tests/UserBundle/Entity/Recipient/PersonRecipientTest.php @@ -0,0 +1,103 @@ +recipient->setFirstName('first name'); + + $this->assertEquals('first name', $this->recipient->getFirstName()); + } + + /** + * @return void + */ + public function testSetFirstNameWithAssignedUser() + { + $user = new User(); + $this->recipient + ->setAssociatedUser($user) + ->setFirstName('first name'); + + $this->assertEquals('first name', $this->recipient->getFirstName()); + $this->assertEquals('first name', $user->getFirstName()); + } + + /** + * @return void + */ + public function testSetLastNameWithoutAssignedUser() + { + $this->recipient->setLastName('last name'); + + $this->assertEquals('last name', $this->recipient->getLastName()); + } + + /** + * @return void + */ + public function testSetLastNameWithAssignedUser() + { + $user = new User(); + $this->recipient + ->setAssociatedUser($user) + ->setLastName('last name'); + + $this->assertEquals('last name', $this->recipient->getLastName()); + $this->assertEquals('last name', $user->getLastName()); + } + + /** + * @return void + */ + public function testSetEmailWithoutAssignedUser() + { + $this->recipient->setEmail('test@test.test'); + + $this->assertEquals('test@test.test', $this->recipient->getEmail()); + } + + /** + * @return void + */ + public function testSetEmailWithAssignedUser() + { + $user = new User(); + $this->recipient + ->setAssociatedUser($user) + ->setEmail('test@test.test'); + + $this->assertEquals('test@test.test', $this->recipient->getEmail()); + $this->assertEquals('test@test.test', $user->getEmail()); + } + + /** + * Sets up the fixture, for example, open a network connection. + * This method is called before a test is executed. + * + * @return void + */ + protected function setUp() + { + $this->recipient = new PersonRecipient(); + } +} diff --git a/tests/UserBundle/Entity/Traits/LimitAwareTraitTest.php b/tests/UserBundle/Entity/Traits/LimitAwareTraitTest.php new file mode 100644 index 0000000..e623143 --- /dev/null +++ b/tests/UserBundle/Entity/Traits/LimitAwareTraitTest.php @@ -0,0 +1,43 @@ +trait->setLimitValue($value, 1); + $this->assertEquals(1, $this->trait->getLimitValue($value)); + } + } + + /** + * Sets up the fixture, for example, open a network connection. + * This method is called before a test is executed. + * + * @return void + */ + protected function setUp() + { + $this->trait = $this->getMockForTrait(LimitAwareTrait::class); + } +} diff --git a/tests/UserBundle/Entity/UserTest.php b/tests/UserBundle/Entity/UserTest.php new file mode 100644 index 0000000..304f855 --- /dev/null +++ b/tests/UserBundle/Entity/UserTest.php @@ -0,0 +1,221 @@ +plan->setAnalytics(true); + $this->assertTrue($this->user->isAllowedTo(AppPermissionEnum::analytics())); + + $this->plan->setAnalytics(false); + $this->assertFalse($this->user->isAllowedTo(AppPermissionEnum::analytics())); + } + + /** + * @return void + */ + public function testUseLimit() + { + /** @var AppLimitEnum $value */ + foreach (AppLimitEnum::getValues() as $value) { + $this->plan->setLimitValue($value, 4); + $this->subscription->setLimitValue($value, 1); + + $this->user->useLimit($value); + $this->assertEquals(2, $this->user->getUsedLimit($value)); + $this->user->useLimit($value, 2); + $this->assertEquals(4, $this->user->getUsedLimit($value)); + } + } + + /** + * @return void + */ + public function testReleaseLimit() + { + /** @var AppLimitEnum $value */ + foreach (AppLimitEnum::getValues() as $value) { + $this->subscription->setLimitValue($value, 4); + + $this->user->releaseLimit($value); + $this->assertEquals(3, $this->user->getUsedLimit($value)); + $this->user->releaseLimit($value, 2); + $this->assertEquals(1, $this->user->getUsedLimit($value)); + } + } + + /** + * @expectedException \AppBundle\Exception\LimitExceedException + * + * @return void + */ + public function testUseLimitExceed() + { + $this->plan->setSearchesPerDay(4); + $this->subscription->setSearchesPerDay(3); + + $this->user->useLimit(AppLimitEnum::searches(), 2); + } + + /** + * @return void + */ + public function testGetRestrictions() + { + $limits = []; + /** @var AppLimitEnum $value */ + foreach (AppLimitEnum::getValues() as $value) { + $limit = mt_rand(5, 10); + $current = mt_rand(0, $limit); + + $limits[$value->getValue()] = [ + 'limit' => $limit, + 'current' => $current, + ]; + + $this->plan->setLimitValue($value, $limit); + $this->subscription->setLimitValue($value, $current); + } + + $permissions = []; + /** @var AppPermissionEnum $value */ + foreach (AppPermissionEnum::getValues() as $value) { + $allow = (boolean) mt_rand(0, 1); + + $permissions[$value->getValue()] = $allow; + $this->plan->setPermission($value, $allow); + } + + $restrictions = $this->user->getRestrictions(); + + $this->assertCount(2, $restrictions); + $this->assertArrayHasKey('limits', $restrictions); + $this->assertArrayHasKey('permissions', $restrictions); + $this->assertEquals($limits, $restrictions['limits']); + $this->assertEquals($permissions, $restrictions['permissions']); + } + + /** + * @return void + */ + public function setFirstNameWithoutRecipient() + { + $this->user->setFirstName('first name'); + + $this->assertEquals('first name', $this->user->getFirstName()); + } + + /** + * @return void + */ + public function setFirstNameWithRecipient() + { + $recipient = new PersonRecipient(); + + $this->user + ->setRecipient($recipient) + ->setFirstName('first name'); + + $this->assertEquals('first name', $this->user->getFirstName()); + $this->assertEquals('first name', $recipient->getFirstName()); + } + + /** + * @return void + */ + public function setLastNameWithoutRecipient() + { + $this->user->setLastName('last name'); + + $this->assertEquals('last name', $this->user->getLastName()); + } + + /** + * @return void + */ + public function setLastNameWithRecipient() + { + $recipient = new PersonRecipient(); + + $this->user + ->setRecipient($recipient) + ->setLastName('last name'); + + $this->assertEquals('last name', $this->user->getLastName()); + $this->assertEquals('last name', $recipient->getLastName()); + } + + /** + * @return void + */ + public function setEmailWithoutRecipient() + { + $this->user->setEmail('test@test.test'); + + $this->assertEquals('test@test.test', $this->user->getEmail()); + } + + /** + * @return void + */ + public function setEmailWithRecipient() + { + $recipient = new PersonRecipient(); + + $this->user + ->setRecipient($recipient) + ->setEmail('test@test.test'); + + $this->assertEquals('test@test.test', $this->user->getEmail()); + $this->assertEquals('test@test.test', $recipient->getEmail()); + } + + /** + * Sets up the fixture, for example, open a network connection. + * This method is called before a test is executed. + * + * @return void + */ + protected function setUp() + { + $this->plan = new Plan(); + $this->subscription = new PersonalSubscription(); + $this->subscription->setPlan($this->plan); + + $this->user = new User(); + $this->user->setBillingSubscription($this->subscription); + } +} diff --git a/tests/UserBundle/Form/Type/ColorTypeTest.php b/tests/UserBundle/Form/Type/ColorTypeTest.php new file mode 100644 index 0000000..848d0eb --- /dev/null +++ b/tests/UserBundle/Form/Type/ColorTypeTest.php @@ -0,0 +1,126 @@ +type = new ColorType(); + } + + /** + * @dataProvider validProvider + * + * @param mixed $color Validated color. + * + * @return void + */ + public function testValidateSuccess($color) + { + $context = $this->getMockBuilder(ExecutionContext::class) + ->disableOriginalConstructor() + ->setMethods([ 'buildViolation' ]) + ->getMock(); + + $context->expects($this->never()) + ->method('buildViolation') + ->willReturnCallback(function () { + $mock = $this->getMockBuilder(ConstraintViolationBuilder::class) + ->disableOriginalConstructor() + ->setMethods([ 'addViolation' ]) + ->getMock(); + + $mock->expects($this->never()) + ->method('addViolation'); + + return $mock; + }); + + $this->type->validate($color, $context); + } + + /** + * @dataProvider notValidProvider + * + * @param mixed $color Validated color. + * + * @return void + */ + public function testValidateFail($color) + { + + $context = $this->getMockBuilder(ExecutionContext::class) + ->disableOriginalConstructor() + ->setMethods([ 'buildViolation' ]) + ->getMock(); + + $context->expects($this->once()) + ->method('buildViolation') + ->willReturnCallback(function () { + $mock = $this->getMockBuilder(ConstraintViolationBuilder::class) + ->disableOriginalConstructor() + ->setMethods([ 'addViolation' ]) + ->getMock(); + + $mock->expects($this->once()) + ->method('addViolation'); + + return $mock; + }); + + $this->type->validate($color, $context); + } + + /** + * @return array + */ + public function validProvider() + { + return [ + [ 'rgba(100, 100, 100, .0)' ], + [ 'rgba(125, 100, 100, 1.0)' ], + [ 'rgba(255, 255, 255, 1)' ], + [ 'rgba(1, 12, 123, .0000034)' ], + [ 'rgba(43, 234, 23, .2)' ], + [ 'rgba(0, 0, 0, .1)' ], + ]; + } + + /** + * @return array + */ + public function notValidProvider() + { + return [ + [ 'rgba(100, 100, 100, .0' ], + [ 'rgba(125, 256, 100, 1.0)' ], + [ 'rgba(255, 255, 255)' ], + [ 'rgba(1, 12, 123, -1)' ], + [ 'rgba(43, 234, -23, .2)' ], + [ 'rgba(0, 0, 0, 1.001)' ], + [ 'rgba(0, 20, 0, 100%)' ], + ]; + } +} diff --git a/tests/UserBundle/Manager/Notification/AbstractSendableNotificationTest.php b/tests/UserBundle/Manager/Notification/AbstractSendableNotificationTest.php new file mode 100644 index 0000000..f8c212e --- /dev/null +++ b/tests/UserBundle/Manager/Notification/AbstractSendableNotificationTest.php @@ -0,0 +1,88 @@ +getContainer()->get('templating'); + } + + /** + * @param ThemeTypeEnum $themeType A ThemeTypeEnum instance. + * @param array $diffs Notification theme diffs. + * @param FeedData[] $data Array of feed data. + * + * @return string + */ + protected function render(ThemeTypeEnum $themeType, array $diffs = [], array $data = []) + { + $notification = Notification::create() + ->setTheme( + NotificationTheme::create() + ->setEnhanced(NotificationThemeOptions::createDefault()) + ->setPlain(NotificationThemeOptions::createDefault()) + ) + ->setThemeType($themeType); + + switch ($themeType->getValue()) { + case ThemeTypeEnum::ENHANCED: + $notification->setEnhancedThemeOptionsDiff($diffs); + break; + + case ThemeTypeEnum::PLAIN: + $notification->setPlainThemeOptionsDiff($diffs); + break; + + default: + throw new \DomainException('Unhandled theme type: '. $themeType->getValue()); + } + + $sendableNotification = new SendableNotification( + new SendableNotificationConfig( + 0, + 0, + 0, + 0, + '

    empty

    ', + 0 + ), + $notification, + $data + ); + + return $sendableNotification->render(self::$templating); + } +} diff --git a/tests/UserBundle/Manager/Notification/NotificationContentRenderTest.php b/tests/UserBundle/Manager/Notification/NotificationContentRenderTest.php new file mode 100644 index 0000000..98e81f7 --- /dev/null +++ b/tests/UserBundle/Manager/Notification/NotificationContentRenderTest.php @@ -0,0 +1,862 @@ +createAsserter(ThemeTypeEnum::plain(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::simple(), + ]) + ->hasNotNode('.email-summary') + ->hasNotNode('.email-conclusion'); + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'summary' => 'summary text', + 'conclusion' => 'conclusion text', + ]) + ->with('.email-summary')->contains('summary text')->end() + ->with('.email-conclusion')->contains('conclusion text')->end(); + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'conclusion' => 'conclusion text', + ]) + ->hasNotNode('.email-summary') + ->with('.email-conclusion')->contains('conclusion text')->end(); + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'summary' => 'summary text', + ]) + ->with('.email-summary')->contains('summary text')->end() + ->hasNotNode('.email-conclusion'); + + // + // Enhanced + // + $this->createAsserter(ThemeTypeEnum::enhanced()) + ->hasNotNode('.email-summary') + ->hasNotNode('.email-conclusion'); + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'summary' => 'summary text', + 'conclusion' => 'conclusion text', + ]) + ->with('.email-summary')->contains('summary text')->end() + ->with('.email-conclusion')->contains('conclusion text')->end(); + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'conclusion' => 'conclusion text', + ]) + ->hasNotNode('.email-summary') + ->with('.email-conclusion')->contains('conclusion text')->end(); + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'summary' => 'summary text', + ]) + ->with('.email-summary')->contains('summary text')->end() + ->hasNotNode('.email-conclusion'); + } + + /** + * @return void + */ + public function testTableOfContentsPlain() + { + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::no(), + 'content:showInfo:articleCount' => true, + ]) + ->hasNotNode('.table-of-contents')->end() + ->hasNotNode('.table-of-contents .feed-document-count')->end(); + + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::no(), + 'content:showInfo:articleCount' => false, + ]) + ->hasNotNode('.table-of-contents')->end() + ->hasNotNode('.table-of-contents .feed-document-count')->end(); + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::simple(), + 'content:showInfo:articleCount' => true, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->with('.feed-document-count') + ->child(0)->contains(sprintf('(%d articles)', self::FIRST_FEED_COUNT))->end() + ->child(1)->contains(sprintf('(%d articles)', self::SECOND_FEED_COUNT))->end() + ->child(2)->contains(sprintf('(%d articles)', self::THIRD_FEED_COUNT))->end() + ->end() + ->hasNotNode('.documents .document') + ->end(); + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::simple(), + 'content:showInfo:articleCount' => false, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->hasNotNode('.feed-document-count') + ->hasNotNode('.documents .document') + ->end(); + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::headline(), + 'content:showInfo:articleCount' => true, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->with('.feed-document-count') + ->child(0)->contains(sprintf('(%d articles)', self::FIRST_FEED_COUNT))->end() + ->child(1)->contains(sprintf('(%d articles)', self::SECOND_FEED_COUNT))->end() + ->child(2)->contains(sprintf('(%d articles)', self::THIRD_FEED_COUNT))->end() + ->end() + ->with('.documents .document') + ->child(0) + ->with('a') + ->hasAttr('href', 'http://permalink') + ->contains('Feed1 Document1', true) + ->end() + ->end() + ->end() + ->end(); + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::headline(), + 'content:showInfo:articleCount' => false, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->hasNotNode('.feed-document-count') + ->with('.documents .document') + ->child(0) + ->with('a') + ->hasAttr('href', 'http://permalink') + ->contains('Feed1 Document1', true) + ->end() + ->end() + ->end() + ->end(); + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::headlineSourceDate(), + 'content:showInfo:articleCount' => true, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->with('.feed-document-count') + ->child(0)->contains(sprintf('(%d articles)', self::FIRST_FEED_COUNT))->end() + ->child(1)->contains(sprintf('(%d articles)', self::SECOND_FEED_COUNT))->end() + ->child(2)->contains(sprintf('(%d articles)', self::THIRD_FEED_COUNT))->end() + ->end() + ->with('.documents .document') + ->child(0) + ->with('a') + ->hasAttr('href', 'http://permalink') + ->contains('Feed1 Document1 | CNN | January 01, 2017') + ->end() + ->end() + ->end() + ->end(); + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::headlineSourceDate(), + 'content:showInfo:articleCount' => false, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->hasNotNode('.feed-document-count') + ->with('.documents .document') + ->child(0) + ->with('a') + ->hasAttr('href', 'http://permalink') + ->contains('Feed1 Document1 | CNN | January 01, 2017') + ->end() + ->end() + ->end() + ->end(); + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::sourceHeadlineDate(), + 'content:showInfo:articleCount' => true, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->with('.feed-document-count') + ->child(0)->contains(sprintf('(%d articles)', self::FIRST_FEED_COUNT))->end() + ->child(1)->contains(sprintf('(%d articles)', self::SECOND_FEED_COUNT))->end() + ->child(2)->contains(sprintf('(%d articles)', self::THIRD_FEED_COUNT))->end() + ->end() + ->with('.documents .document') + ->child(0) + ->with('a') + ->hasAttr('href', 'http://permalink') + ->contains('CNN | Feed1 Document1 | January 01, 2017') + ->end() + ->end() + ->end() + ->end(); + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::sourceHeadlineDate(), + 'content:showInfo:articleCount' => false, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->hasNotNode('.feed-document-count') + ->with('.documents .document') + ->child(0) + ->with('a') + ->hasAttr('href', 'http://permalink') + ->contains('CNN | Feed1 Document1 | January 01, 2017') + ->end() + ->end() + ->end() + ->end(); + } + + /** + * @return void + */ + public function testTableOfContainsEnhanced() + { + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::no(), + 'content:showInfo:articleCount' => true, + ]) + ->hasNotNode('.table-of-contents')->end() + ->hasNotNode('.table-of-contents .feed-document-count')->end(); + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::no(), + 'content:showInfo:articleCount' => false, + ]) + ->hasNotNode('.table-of-contents')->end() + ->hasNotNode('.table-of-contents .feed-document-count')->end(); + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::simple(), + 'content:showInfo:articleCount' => true, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->with('.feed-document-count') + ->child(0)->contains(sprintf('%d articles', self::FIRST_FEED_COUNT))->end() + ->child(1)->contains(sprintf('%d articles', self::SECOND_FEED_COUNT))->end() + ->child(2)->contains(sprintf('%d articles', self::THIRD_FEED_COUNT))->end() + ->end() + ->hasNotNode('.documents .document') + ->end(); + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::simple(), + 'content:showInfo:articleCount' => false, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->hasNotNode('.feed-document-count') + ->hasNotNode('.documents .document') + ->end(); + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::headline(), + 'content:showInfo:articleCount' => true, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->with('.feed-document-count') + ->child(0)->contains(sprintf('%d articles', self::FIRST_FEED_COUNT))->end() + ->child(1)->contains(sprintf('%d articles', self::SECOND_FEED_COUNT))->end() + ->child(2)->contains(sprintf('%d articles', self::THIRD_FEED_COUNT))->end() + ->end() + ->with('.documents .document') + ->child(0) + ->with('a') + ->hasAttr('href', 'http://permalink') + ->contains('Feed1 Document1', true) + ->end() + ->end() + ->end() + ->end(); + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::headline(), + 'content:showInfo:articleCount' => false, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->hasNotNode('.feed-document-count') + ->with('.documents .document') + ->child(0) + ->with('a') + ->hasAttr('href', 'http://permalink') + ->contains('Feed1 Document1', true) + ->end() + ->end() + ->end() + ->end(); + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::headlineSourceDate(), + 'content:showInfo:articleCount' => true, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->with('.feed-document-count') + ->child(0)->contains(sprintf('%d articles', self::FIRST_FEED_COUNT))->end() + ->child(1)->contains(sprintf('%d articles', self::SECOND_FEED_COUNT))->end() + ->child(2)->contains(sprintf('%d articles', self::THIRD_FEED_COUNT))->end() + ->end() + ->with('.documents .document') + ->child(0) + ->with('a') + ->hasAttr('href', 'http://permalink') + ->contains('Feed1 Document1 | CNN | January 01, 2017') + ->end() + ->end() + ->end() + ->end(); + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::headlineSourceDate(), + 'content:showInfo:articleCount' => false, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->hasNotNode('.feed-document-count') + ->with('.documents .document') + ->child(0) + ->with('a') + ->hasAttr('href', 'http://permalink') + ->contains('Feed1 Document1 | CNN | January 01, 2017') + ->end() + ->end() + ->end() + ->end(); + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::sourceHeadlineDate(), + 'content:showInfo:articleCount' => true, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->with('.feed-document-count') + ->child(0)->contains(sprintf('%d articles', self::FIRST_FEED_COUNT))->end() + ->child(1)->contains(sprintf('%d articles', self::SECOND_FEED_COUNT))->end() + ->child(2)->contains(sprintf('%d articles', self::THIRD_FEED_COUNT))->end() + ->end() + ->with('.documents .document') + ->child(0) + ->with('a') + ->hasAttr('href', 'http://permalink') + ->contains('CNN | Feed1 Document1 | January 01, 2017') + ->end() + ->end() + ->end() + ->end(); + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'content:showInfo:tableOfContents' => ThemeOptionsTableOfContentsEnum::sourceHeadlineDate(), + 'content:showInfo:articleCount' => false, + ]) + ->with('.table-of-contents') + ->with('.feed-name') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->hasNotNode('.feed-document-count') + ->with('.documents .document') + ->child(0) + ->with('a') + ->hasAttr('href', 'http://permalink') + ->contains('CNN | Feed1 Document1 | January 01, 2017') + ->end() + ->end() + ->end() + ->end(); + } + + /** + * @return void + */ + public function testContentPlain() + { + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'content:showInfo:sectionDivider' => false, + 'content:showInfo:sourceCountry' => false, + 'content:showInfo:userComments' => ThemeOptionsUserCommentsEnum::no(), + 'content:showInfo:images' => false, + ]) + ->with('.content') + ->with('.feed-title') + ->child(0)->contains('feed1:')->end() + ->child(1)->contains('feed2:')->end() + ->child(2)->contains('feed3:')->end() + ->end() + ->hasNotNode('.feed-title img') + ->notContains('Comments') + ->with('.documents') + ->hasNotNode('.document-aside') + ->with('.document') + ->child(0) + ->with('.document-headline a') + ->hasAttr('href', 'http://permalink') + ->contains('Feed1 Document1') + ->end() + ->with('.document-source') + ->contains('CNN') + ->notContains('(Russian)') + ->end() + ->with('.document-author')->contains('John Smith')->end() + ->with('.document-date') + ->contains('-') + ->contains(date_create('2017-01-01 10:00:00')->format('F d, Y H:i')) + ->end() + ->with('.document-content')->contains('Feed1 Document1 Main')->end() + ->hasNotNode('.comments') + ->hasNotNode('.document-image') + ->end() + ->child(1) + ->with('.document-headline a') + ->hasAttr('href', 'http://permalink_next') + ->contains('Feed2 Document1') + ->end() + ->with('.document-source') + ->contains('Test') + ->notContains('(USA)') + ->end() + ->hasNotNode('.document-author') + ->with('.document-date') + ->contains('-') + ->contains(date_create('2017-01-10 10:00:00')->format('F d, Y H:i')) + ->end() + ->with('.document-content')->contains('Feed2 Document1 Main')->end() + ->hasNotNode('.comments') + ->hasNotNode('.document-image') + ->end() + ->end() + ->end() + ->hasNotNode('.feed-divider') + ->end(); + + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'content:showInfo:sectionDivider' => true, + 'content:showInfo:sourceCountry' => true, + 'content:showInfo:userComments' => ThemeOptionsUserCommentsEnum::withAuthorDate(), + 'content:showInfo:images' => true, + ]) + ->with('.content') + ->with('.feed-title') + ->child(0)->contains('feed1:')->end() + ->child(1)->contains('feed2:')->end() + ->child(2)->contains('feed3:')->end() + ->end() + ->hasNotNode('.feed-title img') + ->notContains('Comments') + ->with('.documents') + ->hasNotNode('.document-aside') + ->with('.document') + ->child(0) + ->with('.document-headline a') + ->hasAttr('href', 'http://permalink') + ->contains('Feed1 Document1') + ->end() + ->with('.document-source') + ->contains('CNN') + ->contains('(Russian)') + ->end() + ->with('.document-author')->contains('John Smith')->end() + ->with('.document-date') + ->contains('-') + ->contains(date_create('2017-01-01 10:00:00')->format('F d, Y H:i')) + ->end() + ->with('.document-content')->contains('Feed1 Document1 Main')->end() + ->hasNotNode('.comments') + ->hasNotNode('.document-image') + ->end() + ->child(1) + ->with('.document-headline a') + ->hasAttr('href', 'http://permalink_next') + ->contains('Feed2 Document1') + ->end() + ->with('.document-source')->contains('Test')->end() + ->hasNotNode('.document-author') + ->with('.document-date') + ->contains('-') + ->contains(date_create('2017-01-10 10:00:00')->format('F d, Y H:i')) + ->end() + ->with('.document-content')->contains('Feed2 Document1 Main')->end() + ->with('.comments .comment') + ->child(0) + ->with('.comment-title')->contains('Feed2 Document1 comment1 title')->end() + ->with('.comment-author') + ->contains('User1 first name') + ->contains('User1 last name') + ->end() + ->with('.comment-date') + ->contains(date_create('2017-01-02 10:00:00')->format('F d, Y H:i')) + ->end() + ->with('.comment-body')->contains('Feed2 Document1 comment1')->end() + ->end() + ->child(1) + ->hasNotNode('.comment-title') + ->with('.comment-author') + ->contains('User2 first name') + ->contains('User2 last name') + ->end() + ->with('.comment-date') + ->contains(date_create('2017-01-03 10:00:00')->format('F d, Y H:i')) + ->end() + ->with('.comment-body')->contains('Feed2 Document1 comment2')->end() + ->end() + ->end() + ->hasNotNode('.document-image') + ->end() + ->end() + ->end() + ->hasNode('.feed-divider', 2) + ->end(); + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'content:showInfo:userComments' => ThemeOptionsUserCommentsEnum::withoutAuthorDate(), + ]) + ->with('.content') + ->with('.document') + ->child(1) + ->with('.comments') + ->hasNotNode('.comment-date') + ->end() + ->end() + ->end() + ->end(); + } + + /** + * @return void + */ + public function testContentEnhanced() + { + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'content:showInfo:sectionDivider' => false, + 'content:showInfo:sourceCountry' => false, + 'content:showInfo:userComments' => ThemeOptionsUserCommentsEnum::no(), + 'content:showInfo:images' => false, + ]) + ->with('.content') + ->with('.feed-title') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->hasNode('.feed-title img', 3) + ->with('.documents') + ->hasNode('.document-aside', 2) + ->with('.document') + ->child(0) + ->with('.document-headline a') + ->hasAttr('href', 'http://permalink') + ->contains('Feed1 Document1') + ->end() + ->with('.document-source') + ->contains('CNN') + ->notContains('(Russian)') + ->end() + ->with('.document-author')->contains('John Smith')->end() + ->with('.document-date') + ->contains('|') + ->contains(date_create('2017-01-01 10:00:00')->format('F d, Y H:i')) + ->end() + ->with('.document-content')->contains('Feed1 Document1 Main')->end() + ->hasNotNode('.comments') + ->hasNotNode('.document-image') + ->end() + ->child(1) + ->with('.document-headline a') + ->hasAttr('href', 'http://permalink_next') + ->contains('Feed2 Document1') + ->end() + ->with('.document-source') + ->contains('Test') + ->notContains('(USA)') + ->end() + ->hasNotNode('.document-author') + ->with('.document-date') + ->contains('|') + ->contains(date_create('2017-01-10 10:00:00')->format('F d, Y H:i')) + ->end() + ->with('.document-content')->contains('Feed2 Document1 Main')->end() + ->hasNotNode('.comments') + ->hasNotNode('.document-image') + ->end() + ->end() + ->end() + ->hasNotNode('.feed-divider') + ->end(); + + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'content:showInfo:sectionDivider' => true, + 'content:showInfo:sourceCountry' => true, + 'content:showInfo:userComments' => ThemeOptionsUserCommentsEnum::withAuthorDate(), + 'content:showInfo:images' => true, + ]) + ->with('.content') + ->with('.feed-title') + ->child(0)->contains('feed1')->end() + ->child(1)->contains('feed2')->end() + ->child(2)->contains('feed3')->end() + ->end() + ->hasNode('.feed-title img', 3) + ->with('.documents') + ->hasNode('.document-aside', 2) + ->with('.document') + ->child(0) + ->with('.document-headline a') + ->hasAttr('href', 'http://permalink') + ->contains('Feed1 Document1') + ->end() + ->with('.document-source') + ->contains('CNN') + ->contains('(Russian)') + ->end() + ->with('.document-author')->contains('John Smith')->end() + ->with('.document-date') + ->contains('|') + ->contains(date_create('2017-01-01 10:00:00')->format('F d, Y H:i')) + ->end() + ->with('.document-content')->contains('Feed1 Document1 Main')->end() + ->hasNotNode('.comments') + ->hasNode('.document-image') + ->end() + ->child(1) + ->with('.document-headline a') + ->hasAttr('href', 'http://permalink_next') + ->contains('Feed2 Document1') + ->end() + ->with('.document-source')->contains('Test')->end() + ->hasNotNode('.document-author') + ->with('.document-date') + ->contains('|') + ->contains(date_create('2017-01-10 10:00:00')->format('F d, Y H:i')) + ->end() + ->with('.document-content')->contains('Feed2 Document1 Main')->end() + ->with('.comments .comment') + ->child(0) + ->with('.comment-title')->contains('Feed2 Document1 comment1 title')->end() + ->with('.comment-author') + ->contains('User1 first name') + ->contains('User1 last name') + ->end() + ->with('.comment-date') + ->contains(date_create('2017-01-02 10:00:00')->format('F d, Y H:i')) + ->end() + ->with('.comment-body')->contains('Feed2 Document1 comment1')->end() + ->end() + ->child(1) + ->hasNotNode('.comment-title') + ->with('.comment-author') + ->contains('User2 first name') + ->contains('User2 last name') + ->end() + ->with('.comment-date') + ->contains(date_create('2017-01-03 10:00:00')->format('F d, Y H:i')) + ->end() + ->with('.comment-body')->contains('Feed2 Document1 comment2')->end() + ->end() + ->end() + ->hasNotNode('.document-image') + ->end() + ->end() + ->end() + ->hasNotNode('.feed-divider') + ->end(); + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'content:showInfo:userComments' => ThemeOptionsUserCommentsEnum::withoutAuthorDate(), + ]) + ->with('.content') + ->with('.document') + ->child(1) + ->with('.comments') + ->hasNotNode('.comment-date') + ->end() + ->end() + ->end() + ->end(); + } + + /** + * @param ThemeTypeEnum $themeType A ThemeTypeEnum instance. + * @param array $diffs Notification theme diffs. + * + * @return HtmlAsserter + */ + private function createAsserter(ThemeTypeEnum $themeType, array $diffs = []) + { + $comment1 = new Comment( + User::create('some@main.com') + ->setFirstName('User1 first name') + ->setLastName('User1 last name'), + 'Feed2 Document1 comment1', + 'Feed2 Document1 comment1 title' + ); + $comment1->setCreatedAt(date_create('2017-01-02 10:00:00')); + $comment2 = new Comment( + User::create('some@main.com') + ->setFirstName('User2 first name') + ->setLastName('User2 last name'), + 'Feed2 Document1 comment2' + ); + $comment2->setCreatedAt(date_create('2017-01-03 10:00:00')); + + /** @var IndexStrategyInterface|\PHPUnit_Framework_MockObject_MockObject $strategy */ + $strategy = $this->getMockForInterface(IndexStrategyInterface::class); + + $strategy + ->method('normalizeDocumentData') + ->willReturnCallback(function (array $data) { + return $data; + }); + + $strategy + ->method('normalizeFieldName') + ->willReturnCallback(function ($fieldName) { + return $fieldName; + }); + + $strategy + ->method('normalizePublisherType') + ->willReturnCallback(function ($type) { + return $type; + }); + + $crawler = new Crawler($this->render($themeType, $diffs, [ + new FeedData('feed1', [ + new ArticleDocument($strategy, [ + 'title' => 'Feed1 Document1', + 'permalink' => 'http://permalink', + 'content' => 'Feed1 Document1 Main', + 'published' => date_create('2017-01-01 10:00:00'), + 'source' => [ + 'title' => 'CNN', + 'country' => 'Russian', + ], + 'author' => [ + 'name' => 'John Smith', + ], + 'image' => 'http://image.dev', + ]), + ]), + new FeedData('feed2', [ + new ArticleDocument($strategy, [ + 'title' => 'Feed2 Document1', + 'permalink' => 'http://permalink_next', + 'content' => 'Feed2 Document1 Main', + 'published' => date_create('2017-01-10 10:00:00'), + 'source' => [ + 'title' => 'Test', + 'country' => 'USA', + ], + 'comments' => [ + $comment1, + $comment2, + ], + ]), + ]), + new FeedData('feed3', []), + ])); + + return new HtmlAsserter($crawler); + } +} diff --git a/tests/UserBundle/Manager/Notification/NotificationManagerTest.php b/tests/UserBundle/Manager/Notification/NotificationManagerTest.php new file mode 100644 index 0000000..d3adeb5 --- /dev/null +++ b/tests/UserBundle/Manager/Notification/NotificationManagerTest.php @@ -0,0 +1,502 @@ +getMockBuilder(Notification::class)->getMock(); + + $this->assertEquals([ $notification ], $this->call($this->manager, 'normalizeNotifications', [ $notification ])); + } + + /** + * @return void + */ + public function testNormalizeNotificationsMany() + { + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification1 */ + $notification1 = $this->getMockBuilder(Notification::class)->getMock(); + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification2 */ + $notification2 = $this->getMockBuilder(Notification::class)->getMock(); + + $notifications = [ $notification1, $notification2 ]; + + $this->assertEquals($notifications, $this->call($this->manager, 'normalizeNotifications', [ $notifications ])); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Expects single + * + * @return void + */ + public function testNormalizeNotificationsManyFail() + { + $notifications = [ 'invalid', 123 ]; + + $this->assertEquals($notifications, $this->call($this->manager, 'normalizeNotifications', [ $notifications ])); + } + + /** + * @return void + */ + public function testActivatedToggleSingleTrue() + { + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification = $this->getMockBuilder(Notification::class) + ->setMethods([ 'setActive' ]) + ->getMock(); + + $notification + ->expects($this->once()) + ->method('setActive') + ->with($this->equalTo(true)); + + $this->em + ->expects($this->once()) + ->method('persist') + ->with($this->equalTo($notification)); + + $this->em + ->expects($this->once()) + ->method('flush'); + + $this->manager->activatedToggle($notification); + } + + /** + * @return void + */ + public function testActivatedToggleSingleFalse() + { + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification = $this->getMockBuilder(Notification::class) + ->setMethods([ 'setActive' ]) + ->getMock(); + + $notification + ->expects($this->once()) + ->method('setActive') + ->with($this->equalTo(false)); + + $this->em + ->expects($this->once()) + ->method('persist') + ->with($this->equalTo($notification)); + + $this->em + ->expects($this->once()) + ->method('flush'); + + $this->manager->activatedToggle($notification, false); + } + + /** + * @return void + */ + public function testActivatedToggleMany() + { + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification1 = $this->getMockBuilder(Notification::class) + ->setMethods([ 'setActive' ]) + ->getMock(); + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification2 = $this->getMockBuilder(Notification::class) + ->setMethods([ 'setActive' ]) + ->getMock(); + + $notification1 + ->expects($this->once()) + ->method('setActive') + ->with($this->equalTo(false)); + + $notification2 + ->expects($this->once()) + ->method('setActive') + ->with($this->equalTo(false)); + + $this->em + ->expects($this->at(0)) + ->method('persist') + ->with($this->equalTo($notification1)); + + $this->em + ->expects($this->at(1)) + ->method('persist') + ->with($this->equalTo($notification2)); + + $this->em + ->expects($this->once()) + ->method('flush'); + + $this->manager->activatedToggle([ $notification1, $notification2 ], false); + } + + /** + * @return void + */ + public function testPublishedToggleSingleTrue() + { + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification = $this->getMockBuilder(Notification::class) + ->setMethods([ 'setPublished' ]) + ->getMock(); + + $notification + ->expects($this->once()) + ->method('setPublished') + ->with($this->equalTo(true)); + + $this->em + ->expects($this->once()) + ->method('persist') + ->with($this->equalTo($notification)); + + $this->em + ->expects($this->once()) + ->method('flush'); + + $this->manager->publishedToggle($notification); + } + + /** + * @return void + */ + public function testPublishedToggleSingleFalse() + { + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification = $this->getMockBuilder(Notification::class) + ->setMethods([ 'setPublished' ]) + ->getMock(); + + $notification + ->expects($this->once()) + ->method('setPublished') + ->with($this->equalTo(false)); + + $this->em + ->expects($this->once()) + ->method('persist') + ->with($this->equalTo($notification)); + + $this->em + ->expects($this->once()) + ->method('flush'); + + $this->manager->publishedToggle($notification, false); + } + + /** + * @return void + */ + public function testPublishedToggleManyFalse() + { + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification1 = $this->getMockBuilder(Notification::class) + ->setMethods([ 'setPublished' ]) + ->getMock(); + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification2 = $this->getMockBuilder(Notification::class) + ->setMethods([ 'setPublished' ]) + ->getMock(); + + $notification1 + ->expects($this->once()) + ->method('setPublished') + ->with($this->equalTo(false)); + + $notification2 + ->expects($this->once()) + ->method('setPublished') + ->with($this->equalTo(false)); + + $this->em + ->expects($this->at(0)) + ->method('persist') + ->with($this->equalTo($notification1)); + + $this->em + ->expects($this->at(1)) + ->method('persist') + ->with($this->equalTo($notification2)); + + $this->em + ->expects($this->once()) + ->method('flush'); + + $this->manager->publishedToggle([ $notification1, $notification2 ], false); + } + + /** + * @return void + */ + public function testSubscriptionToggleTrue() + { + $recipient = $this->getMockForAbstractClass(AbstractRecipient::class); + + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification = $this->getMockBuilder(Notification::class) + ->setMethods([ 'getRecipients', 'addRecipient' ]) + ->getMock(); + + $notification + ->expects($this->once()) + ->method('getRecipients') + ->willReturn([]); + + $notification + ->expects($this->once()) + ->method('addRecipient') + ->with($this->equalTo($recipient)); + + $this->em + ->expects($this->once()) + ->method('persist') + ->with($this->equalTo($notification)); + + $this->em + ->expects($this->once()) + ->method('flush'); + + $this->manager->subscriptionToggle($recipient, $notification); + } + + /** + * @return void + */ + public function testSubscriptionToggleTrueWithExists() + { + $recipient1 = new RecipientFixture(1); + $recipient2 = new RecipientFixture(2); + + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification1 */ + $notification1 = $this->getMockBuilder(Notification::class) + ->setMethods([ 'getRecipients', 'addRecipient' ]) + ->getMock(); + + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification2 */ + $notification2 = $this->getMockBuilder(Notification::class) + ->setMethods([ 'getRecipients', 'addRecipient' ]) + ->getMock(); + + $notification1 + ->expects($this->once()) + ->method('getRecipients') + ->willReturn([ $recipient1, $recipient2 ]); + + $notification1 + ->expects($this->never()) + ->method('addRecipient'); + + $notification2 + ->expects($this->once()) + ->method('getRecipients') + ->willReturn([ $recipient2 ]); + + $notification2 + ->expects($this->once()) + ->method('addRecipient') + ->with($this->equalTo($recipient1)); + + $this->em + ->expects($this->once()) + ->method('persist') + ->with($this->equalTo($notification2)); + + $this->em + ->expects($this->once()) + ->method('flush'); + + $this->manager->subscriptionToggle($recipient1, [ $notification1, $notification2 ]); + } + + /** + * @return void + */ + public function testSubscriptionToggleFalse() + { + $recipient = $this->getMockForAbstractClass(AbstractRecipient::class); + + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification = $this->getMockBuilder(Notification::class) + ->setMethods([ 'getRecipients', 'removeRecipient' ]) + ->getMock(); + + $notification + ->expects($this->never()) + ->method('getRecipients'); + + $notification + ->expects($this->once()) + ->method('removeRecipient') + ->with($this->equalTo($recipient)); + + $this->em + ->expects($this->once()) + ->method('persist') + ->with($this->equalTo($notification)); + + $this->em + ->expects($this->once()) + ->method('flush'); + + $this->manager->subscriptionToggle($recipient, $notification, false); + } + + /** + * @return void + */ + public function testRemoveSingle() + { + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification = $this->getMockBuilder(Notification::class) + ->setMethods([ 'getId' ]) + ->getMock(); + + $notification + ->expects($this->once()) + ->method('getId') + ->willReturn(1); + + $this->em + ->expects($this->once()) + ->method('remove') + ->with($this->equalTo($notification)); + + $this->em + ->expects($this->once()) + ->method('flush'); + + $this->conn + ->expects($this->once()) + ->method('executeQuery') + ->with($this->stringContains('WHERE notification_id in (1)')); + + $this->manager->remove($notification); + } + + /** + * @return void + */ + public function testRemoveMany() + { + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification1 */ + $notification1 = $this->getMockBuilder(Notification::class) + ->setMethods([ 'getId' ]) + ->getMock(); + + /** @var Notification|\PHPUnit_Framework_MockObject_MockObject $notification2 */ + $notification2 = $this->getMockBuilder(Notification::class) + ->setMethods([ 'getId' ]) + ->getMock(); + + $notification1 + ->expects($this->once()) + ->method('getId') + ->willReturn(1); + + $notification2 + ->expects($this->once()) + ->method('getId') + ->willReturn(2); + + $this->em + ->expects($this->at(0)) + ->method('remove') + ->with($this->equalTo($notification1)); + + $this->em + ->expects($this->at(1)) + ->method('remove') + ->with($this->equalTo($notification2)); + + $this->em + ->expects($this->once()) + ->method('flush'); + + $this->conn + ->expects($this->once()) + ->method('executeQuery') + ->with($this->stringContains('WHERE notification_id in (1,2)')); + + $this->manager->remove([ $notification1, $notification2 ]); + } + + /** + * Sets up the fixture, for example, open a network connection. + * This method is called before a test is executed. + * + * @return void + */ + protected function setUp() + { + $this->conn = $this->getMockForInterface(Connection::class); + $this->em = $this->getMockForInterface(EntityManagerInterface::class); + $this->feedFetcherFactory = $this->getMockForInterface(FeedFetcherFactoryInterface::class); + $this->configuration = $this->getMockForInterface(ConfigurationImmutableInterface::class); + $this->extractor = $this->getMockForInterface(DocumentContentExtractorInterface::class); + + $this->em + ->expects($this->any()) + ->method('getConnection') + ->willReturn($this->conn); + + $this->manager = new NotificationManager( + $this->em, + $this->feedFetcherFactory, + $this->configuration, + $this->extractor + ); + } +} diff --git a/tests/UserBundle/Manager/Notification/NotificationStylesRenderTest.php b/tests/UserBundle/Manager/Notification/NotificationStylesRenderTest.php new file mode 100644 index 0000000..ec2c276 --- /dev/null +++ b/tests/UserBundle/Manager/Notification/NotificationStylesRenderTest.php @@ -0,0 +1,756 @@ +createAsserter(ThemeTypeEnum::plain()) + ->with('.email')->hasNot('border')->end() + ->with('html')->hasNot('background')->end() + ->with('body')->hasNot('background')->end() + ->with('.email-body-content')->has('color', $defaultArticleContentFG)->end(); + + $this->createAsserter(ThemeTypeEnum::plain(), [ + 'colors:background:emailBody' => $customEmailBodyBG, + 'colors:background:accent' => $customAccentBG, + 'colors:text:articleContent' => $customArticleContentFG, + ]) + ->with('.email')->hasNot('border')->end() + ->with('html')->hasNot('background')->end() + ->with('body')->hasNot('background')->end() + ->with('.email-body-content')->has('color', $customArticleContentFG)->end(); + + $this->createAsserter(ThemeTypeEnum::enhanced()) + ->with('.email')->has('border', '4px solid '. $defaultAccentBG)->end() + ->with('html')->has('background', $defaultEmailBodyBG)->end() + ->with('body')->has('background', $defaultEmailBodyBG)->end() + ->with('.email-body-content')->has('color', $defaultArticleContentFG)->end(); + + $this->createAsserter(ThemeTypeEnum::enhanced(), [ + 'colors:background:emailBody' => $customEmailBodyBG, + 'colors:background:accent' => $customAccentBG, + 'colors:text:articleContent' => $customArticleContentFG, + ]) + ->with('.email')->has('border', '4px solid '. $customAccentBG)->end() + ->with('html')->has('background', $customEmailBodyBG)->end() + ->with('body')->has('background', $customEmailBodyBG)->end() + ->with('.email-body-content')->has('color', $customArticleContentFG)->end(); + } + + /** + * @return void + */ + public function testHeader() + { + $this->assertCssRender(ThemeTypeEnum::plain(), [ + $this->createCssAssertBuilder('.email-header') + ->propertyShouldBe('color', 'white') + ->propertyShouldNotBe('height', '105px'), + $this->createCssAssertBuilder('.email-header-info-title') + ->hasFont(new ThemeOptionFont( + FontFamilyEnum::arial(), + NotificationThemeOptions::DEFAULT_HEADER_SIZE + )) + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_HEADER), + $this->createCssAssertBuilder('.email-header-info-date') + ->propertyShouldBe('color', ThemeOptionColorsBackground::DEFAULT_ACCENT), + ]); + + $this->assertCssRender(ThemeTypeEnum::plain(), [ + $this->createCssAssertBuilder('.email-header') + ->propertyShouldBe('color', 'white') + ->propertyShouldBe('height', '105px'), + $this->createCssAssertBuilder('.email-header-info-title') + ->hasFont(new ThemeOptionFont( + FontFamilyEnum::calibri(), + 10, + new ThemeOptionFontStyle(true, true, true) + )) + ->propertyShouldBe('color', 'rgba(124, 45, 56, 0.33)'), + $this->createCssAssertBuilder('.email-header-info-date') + ->propertyShouldBe('color', 'rgba(123, 44, 55, 0.32)'), + ], [ + 'header:imageUrl' => 'http://pic.com', + 'colors:background:accent' => 'rgba(123, 44, 55, 0.32)', + 'colors:text:header' => 'rgba(124, 45, 56, 0.33)', + + 'fonts:header:family' => FontFamilyEnum::calibri(), + 'fonts:header:size' => 10, + 'fonts:header:style:bold' => true, + 'fonts:header:style:italic' => true, + 'fonts:header:style:underline' => true, + ]); + } + + /** + * @return void + */ + public function testTableOfContents() + { + $this->tableOfContentsDefault(); + $this->tableOfContentsCustom(); + } + + /** + * @return void + */ + public function testContents() + { + $this->contentsDefault(); + $this->contentsCustom(); + } + + /** + * @return void + */ + public function testFooter() + { + $this->createAsserter(ThemeTypeEnum::plain()) + ->with('footer') + ->has('border-top', '3px double #fff') + ->end(); + + $this->createAsserter(ThemeTypeEnum::plain()) + ->with('footer') + ->hasNot('border-top') + ->end(); + } + + /** + * @return void + */ + private function tableOfContentsDefault() + { + $defaultFont = new ThemeOptionFont( + FontFamilyEnum::arial(), + NotificationThemeOptions::DEFAULT_TABLE_OF_CONTENTS_SIZE + ); + + $this->assertCssRender(ThemeTypeEnum::plain(), [ + // .table-of-contents + $this->createCssAssertBuilder('.table-of-contents .feeds li')->shouldNotExists(), + $this->createCssAssertBuilder('.table-of-contents .feeds > li')->shouldNotExists(), + $this->createCssAssertBuilder('.table-of-contents .feeds > li:before')->shouldNotExists(), + $this->createCssAssertBuilder('.table-of-contents .documents > li:before')->shouldNotExists(), + $this->createCssAssertBuilder('.table-of-contents li:before') + ->propertyShouldBe('font-size', NotificationThemeOptions::DEFAULT_TABLE_OF_CONTENTS_SIZE) + ->propertyShouldNotBe('font-weight', 'bold') + ->propertyShouldNotBe('font-style', 'italic') + ->propertyShouldBe('text-decoration', 'none'), + + // .feed-name + $this->createCssAssertBuilder('.table-of-contents .feeds .feed .feed-name') + ->propertyShouldNotBe('width', '48%') + ->propertyShouldNotBe('display', 'inline-block') + ->propertyShouldNotBe('margin-left', '20px') + ->hasFont($defaultFont), + + // .feed-document-count + $this->createCssAssertBuilder('.table-of-contents .feeds .feed .feed-document-count') + ->propertyShouldNotBe('width', '48%') + ->propertyShouldNotBe('display', 'inline-block') + ->hasFont($defaultFont), + + // Document link + $this->createCssAssertBuilder('.table-of-contents .documents .document a') + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_ARTICLE_HEADLINE) + ->hasFont($defaultFont), + + // Misc + $this->createCssAssertBuilder('.table-of-contents .feeds .feed .feed-document-count:before') + ->propertyShouldBe('content', ' '), + $this->createCssAssertBuilder('.table-of-contents .documents .document a:after') + ->propertyShouldBe('content', 'url(data:image') + ->propertyShouldBe('padding-left', '3px'), + $this->createCssAssertBuilder('.table-of-contents .documents .document .source') + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_SOURCE), + ]); + + $this->assertCssRender(ThemeTypeEnum::enhanced(), [ + // .table-of-contents + $this->createCssAssertBuilder('.table-of-contents .feeds li') + ->propertyShouldBe('background', 'white') + ->propertyShouldBe('display', 'block') + ->propertyShouldBe('padding', '5px 10px'), + $this->createCssAssertBuilder('.table-of-contents .feeds > li') + ->propertyShouldBe('margin-bottom', '1px') + ->propertyShouldBe('border-bottom', '1px solid #e6e6e6'), + $this->createCssAssertBuilder('.table-of-contents .feeds > li:before') + ->propertyShouldBe('width', '6px') + ->propertyShouldBe('height', '8px') + ->propertyShouldBe('content', 'url(data:image'), + $this->createCssAssertBuilder('.table-of-contents .documents > li:before') + ->propertyShouldBe('font-size', NotificationThemeOptions::DEFAULT_ARTICLE_CONTENT_SIZE) + ->propertyShouldNotBe('font-weight', 'bold') + ->propertyShouldNotBe('font-style', 'italic') + ->propertyShouldBe('text-decoration', 'none'), + $this->createCssAssertBuilder('.table-of-contents li:before')->shouldNotExists(), + + // .feed-name + $this->createCssAssertBuilder('.table-of-contents .feeds .feed .feed-name') + ->propertyShouldBe('width', '48%') + ->propertyShouldBe('display', 'inline-block') + ->propertyShouldBe('margin-left', '20px') + ->hasFont($defaultFont), + + // .feed-document-count + $this->createCssAssertBuilder('.table-of-contents .feeds .feed .feed-document-count') + ->propertyShouldBe('width', '48%') + ->propertyShouldBe('display', 'inline-block') + ->hasFont($defaultFont), + + // Document link + $this->createCssAssertBuilder('.table-of-contents .documents .document a') + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_ARTICLE_CONTENT) + ->hasFont(new ThemeOptionFont( + FontFamilyEnum::arial(), + NotificationThemeOptions::DEFAULT_ARTICLE_CONTENT_SIZE + )), + + // Misc + $this->createCssAssertBuilder('.table-of-contents .feeds .feed .feed-document-count:before')->shouldNotExists(), + $this->createCssAssertBuilder('.table-of-contents .documents .document a:after')->shouldNotExists(), + $this->createCssAssertBuilder('.table-of-contents .documents .document .source') + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_SOURCE), + ]); + } + + /** + * @return void + */ + private function tableOfContentsCustom() + { + $customFont = new ThemeOptionFont( + FontFamilyEnum::calibri(), + 10, + new ThemeOptionFontStyle(true, true, true) + ); + + $this->assertCssRender(ThemeTypeEnum::plain(), [ + // .table-of-contents + $this->createCssAssertBuilder('.table-of-contents .feeds li')->shouldNotExists(), + $this->createCssAssertBuilder('.table-of-contents .feeds > li')->shouldNotExists(), + $this->createCssAssertBuilder('.table-of-contents .feeds > li:before')->shouldNotExists(), + $this->createCssAssertBuilder('.table-of-contents .documents > li:before')->shouldNotExists(), + $this->createCssAssertBuilder('.table-of-contents li:before') + ->propertyShouldBe('font-size', 10) + ->propertyShouldBe('font-weight', 'bold') + ->propertyShouldBe('font-style', 'italic') + ->propertyShouldBe('text-decoration', 'none'), + + // .feed-name + $this->createCssAssertBuilder('.table-of-contents .feeds .feed .feed-name') + ->propertyShouldNotBe('width', '48%') + ->propertyShouldNotBe('display', 'inline-block') + ->propertyShouldNotBe('margin-left', '20px') + ->hasFont($customFont), + + // .feed-document-count + $this->createCssAssertBuilder('.table-of-contents .feeds .feed .feed-document-count') + ->propertyShouldNotBe('width', '48%') + ->propertyShouldNotBe('display', 'inline-block') + ->hasFont($customFont), + + // Document link + $this->createCssAssertBuilder('.table-of-contents .documents .document a') + ->propertyShouldBe('color', 'rgba(123, 44, 55, 0.32)') + ->hasFont($customFont), + + // Misc + $this->createCssAssertBuilder('.table-of-contents .feeds .feed .feed-document-count:before') + ->propertyShouldBe('content', ' '), + $this->createCssAssertBuilder('.table-of-contents .documents .document a:after') + ->propertyShouldBe('content', 'url(data:image') + ->propertyShouldBe('padding-left', '3px'), + $this->createCssAssertBuilder('.table-of-contents .documents .document .source') + ->propertyShouldBe('color', 'rgba(124, 45, 56, 0.33)'), + ], [ + 'colors:text:articleHeadline' => 'rgba(123, 44, 55, 0.32)', + 'colors:text:source' => 'rgba(124, 45, 56, 0.33)', + 'fonts:tableOfContents:family' => $customFont->getFamily(), + 'fonts:tableOfContents:size' => $customFont->getSize(), + 'fonts:tableOfContents:style:bold' => $customFont->getStyle()->isBold(), + 'fonts:tableOfContents:style:italic' => $customFont->getStyle()->isItalic(), + 'fonts:tableOfContents:style:underline' => $customFont->getStyle()->isUnderline(), + ]); + + $this->assertCssRender(ThemeTypeEnum::enhanced(), [ + // .table-of-contents + $this->createCssAssertBuilder('.table-of-contents .feeds li') + ->propertyShouldBe('background', 'white') + ->propertyShouldBe('display', 'block') + ->propertyShouldBe('padding', '5px 10px'), + $this->createCssAssertBuilder('.table-of-contents .feeds > li') + ->propertyShouldBe('margin-bottom', '1px') + ->propertyShouldBe('border-bottom', '1px solid #e6e6e6'), + $this->createCssAssertBuilder('.table-of-contents .feeds > li:before') + ->propertyShouldBe('width', '6px') + ->propertyShouldBe('height', '8px') + ->propertyShouldBe('content', 'url(data:image'), + $this->createCssAssertBuilder('.table-of-contents .documents > li:before') + ->propertyShouldBe('font-size', 11) + ->propertyShouldNotBe('font-weight', 'bold') + ->propertyShouldBe('font-style', 'italic') + ->propertyShouldBe('text-decoration', 'none'), + $this->createCssAssertBuilder('.table-of-contents li:before')->shouldNotExists(), + + // .feed-name + $this->createCssAssertBuilder('.table-of-contents .feeds .feed .feed-name') + ->propertyShouldBe('width', '48%') + ->propertyShouldBe('display', 'inline-block') + ->propertyShouldBe('margin-left', '20px') + ->hasFont($customFont), + + // .feed-document-count + $this->createCssAssertBuilder('.table-of-contents .feeds .feed .feed-document-count') + ->propertyShouldBe('width', '48%') + ->propertyShouldBe('display', 'inline-block') + ->hasFont($customFont), + + // Document link + $this->createCssAssertBuilder('.table-of-contents .documents .document a') + ->propertyShouldBe('color', 'rgba(123, 44, 55, 0.32)') + ->hasFont(new ThemeOptionFont( + FontFamilyEnum::courierNew(), + 11, + new ThemeOptionFontStyle(false, true, true) + )), + + // Misc + $this->createCssAssertBuilder('.table-of-contents .feeds .feed .feed-document-count:before')->shouldNotExists(), + $this->createCssAssertBuilder('.table-of-contents .documents .document a:after')->shouldNotExists(), + $this->createCssAssertBuilder('.table-of-contents .documents .document .source') + ->propertyShouldBe('color', 'rgba(124, 45, 56, 0.33)'), + ], [ + 'colors:text:articleContent' => 'rgba(123, 44, 55, 0.32)', + 'colors:text:source' => 'rgba(124, 45, 56, 0.33)', + + 'fonts:tableOfContents:family' => $customFont->getFamily(), + 'fonts:tableOfContents:size' => $customFont->getSize(), + 'fonts:tableOfContents:style:bold' => $customFont->getStyle()->isBold(), + 'fonts:tableOfContents:style:italic' => $customFont->getStyle()->isItalic(), + 'fonts:tableOfContents:style:underline' => $customFont->getStyle()->isUnderline(), + + 'fonts:articleContent:family' => FontFamilyEnum::courierNew(), + 'fonts:articleContent:size' => 11, + 'fonts:articleContent:style:bold' => false, + 'fonts:articleContent:style:italic' => true, + 'fonts:articleContent:style:underline' => true, + ]); + } + + /** + * @return void + */ + private function contentsDefault() + { + // + // Plain + // + $feedTitleFont = new ThemeOptionFont( + FontFamilyEnum::arial(), + NotificationThemeOptions::DEFAULT_FEED_TITLE_SIZE + ); + $dateFont = new ThemeOptionFont( + FontFamilyEnum::arial(), + NotificationThemeOptions::DEFAULT_DATE_SIZE + ); + $articleContentFont = new ThemeOptionFont( + FontFamilyEnum::arial(), + NotificationThemeOptions::DEFAULT_ARTICLE_CONTENT_SIZE + ); + $articleHeadlineFont = new ThemeOptionFont( + FontFamilyEnum::arial(), + NotificationThemeOptions::DEFAULT_ARTICLE_HEADLINE_SIZE + ); + $sourceFont = new ThemeOptionFont( + FontFamilyEnum::arial(), + NotificationThemeOptions::DEFAULT_SOURCE_SIZE + ); + $authorFont = new ThemeOptionFont( + FontFamilyEnum::arial(), + NotificationThemeOptions::DEFAULT_AUTHOR_SIZE + ); + + $this->assertCssRender(ThemeTypeEnum::plain(), [ + // .feed-title + $this->createCssAssertBuilder('.content .feed-title') + ->hasFont($feedTitleFont) + ->propertyShouldNotExists('background') + ->propertyShouldNotExists('color'), + + // .document + $this->createCssAssertBuilder('.content .documents .document') + ->propertyShouldBe('margin-top', '10px') + ->propertyShouldBe('margin-left', '5px') + ->propertyShouldNotExists('display') + ->propertyShouldNotExists('flex'), + $this->createCssAssertBuilder('.content .documents .document:last-child'), + $this->createCssAssertBuilder('.content .documents .document-aside')->shouldNotExists(), + $this->createCssAssertBuilder('.content .documents .document-main')->shouldNotExists(), + $this->createCssAssertBuilder('.content .documents .document-body')->shouldNotExists(), + $this->createCssAssertBuilder('.content .documents .document-image')->shouldNotExists(), + $this->createCssAssertBuilder('.content .documents .document-image img')->shouldNotExists(), + + // .document-headline link + $this->createCssAssertBuilder('.content .documents .document-headline a') + ->hasFont($articleHeadlineFont) + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_ARTICLE_HEADLINE), + + // .document-source + $this->createCssAssertBuilder('.content .documents .document-source') + ->hasFont($sourceFont) + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_SOURCE), + + // .document-author + $this->createCssAssertBuilder('.content .documents .document-author') + ->hasFont($authorFont) + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_AUTHOR), + + // .document-date + $this->createCssAssertBuilder('.content .documents .document-date') + ->hasFont($dateFont) + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_PUBLISH_DATE), + + // .document-content + $this->createCssAssertBuilder('.content .document .document-content') + ->hasFont($articleContentFont), + + // Comments + $this->createCssAssertBuilder('.content .comments .comment-title')->shouldNotExists(), + $this->createCssAssertBuilder('.content .comments .comment-author') + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_AUTHOR) + ->hasFont($authorFont), + $this->createCssAssertBuilder('.content .comments .comment-date') + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_PUBLISH_DATE) + ->hasFont($dateFont), + ]); + + // + // Enhanced + // + + $this->assertCssRender(ThemeTypeEnum::enhanced(), [ + // .feed-title + $this->createCssAssertBuilder('.content .feed-title') + ->hasFont($feedTitleFont) + ->propertyShouldBe('background', ThemeOptionColorsBackground::DEFAULT_ACCENT) + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_HEADER), + + // .document + $this->createCssAssertBuilder('.content .documents .document') + ->propertyShouldBe('margin-top', '5px') + ->propertyShouldNotExists('margin-left', '5px') + ->propertyShouldExists('display') + ->propertyShouldExists('flex'), + $this->createCssAssertBuilder('.content .documents .document:last-child')->shouldNotExists(), + $this->createCssAssertBuilder('.content .documents .document-aside'), + $this->createCssAssertBuilder('.content .documents .document-main'), + $this->createCssAssertBuilder('.content .documents .document-body'), + $this->createCssAssertBuilder('.content .documents .document-image'), + $this->createCssAssertBuilder('.content .documents .document-image img'), + + // .document-headline link + $this->createCssAssertBuilder('.content .documents .document-headline a') + ->hasFont($articleHeadlineFont) + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_ARTICLE_HEADLINE), + + // .document-source + $this->createCssAssertBuilder('.content .documents .document-source') + ->hasFont($sourceFont) + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_SOURCE), + + // .document-author + $this->createCssAssertBuilder('.content .documents .document-author') + ->hasFont($authorFont) + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_AUTHOR), + + // .document-date + $this->createCssAssertBuilder('.content .documents .document-date') + ->hasFont($articleContentFont) + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_ARTICLE_CONTENT), + + // .document-content + $this->createCssAssertBuilder('.content .document .document-content') + ->hasFont($articleContentFont), + + // Comments + $this->createCssAssertBuilder('.content .comments .comment-title'), + $this->createCssAssertBuilder('.content .comments .comment-author') + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_AUTHOR) + ->hasNotAnyFonts(), + $this->createCssAssertBuilder('.content .comments .comment-date') + ->propertyShouldBe('color', ThemeOptionColorsText::DEFAULT_ARTICLE_CONTENT) + ->hasNotAnyFonts(), + ]); + } + + /** + * @return void + */ + private function contentsCustom() + { + $feedTitleFont = new ThemeOptionFont( + FontFamilyEnum::calibri(), + 11, + new ThemeOptionFontStyle(true, true, true) + ); + $dateFont = new ThemeOptionFont( + FontFamilyEnum::centuryGothic(), + 12, + new ThemeOptionFontStyle(true, false, true) + ); + $articleContentFont = new ThemeOptionFont( + FontFamilyEnum::georgia(), + 13, + new ThemeOptionFontStyle(true, true, false) + ); + $articleHeadlineFont = new ThemeOptionFont( + FontFamilyEnum::lucidaSansUnicode(), + 14, + new ThemeOptionFontStyle(false, true, true) + ); + $sourceFont = new ThemeOptionFont( + FontFamilyEnum::courierNew(), + 15, + new ThemeOptionFontStyle(false, true, false) + ); + $authorFont = new ThemeOptionFont( + FontFamilyEnum::tahoma(), + 16, + new ThemeOptionFontStyle(true, true, false) + ); + + $diffs = [ + 'colors:background:accent' => 'rgba(123, 44, 55, 0.32)', + + 'colors:text:header' => 'rgba(124, 45, 56, 0.33)', + 'colors:text:publishDate' => 'rgba(125, 46, 57, 0.34)', + 'colors:text:articleContent' => 'rgba(126, 47, 58, 0.35)', + 'colors:text:articleHeadline' => 'rgba(127, 48, 59, 0.36)', + 'colors:text:source' => 'rgba(128, 49, 60, 0.37)', + 'colors:text:author' => 'rgba(129, 50, 61, 0.38)', + + 'fonts:feedTitle:family' => $feedTitleFont->getFamily(), + 'fonts:feedTitle:size' => $feedTitleFont->getSize(), + 'fonts:feedTitle:style:bold' => $feedTitleFont->getStyle()->isBold(), + 'fonts:feedTitle:style:italic' => $feedTitleFont->getStyle()->isItalic(), + 'fonts:feedTitle:style:underline' => $feedTitleFont->getStyle()->isUnderline(), + + 'fonts:date:family' => $dateFont->getFamily(), + 'fonts:date:size' => $dateFont->getSize(), + 'fonts:date:style:bold' => $dateFont->getStyle()->isBold(), + 'fonts:date:style:italic' => $dateFont->getStyle()->isItalic(), + 'fonts:date:style:underline' => $dateFont->getStyle()->isUnderline(), + + 'fonts:articleContent:family' => $articleContentFont->getFamily(), + 'fonts:articleContent:size' => $articleContentFont->getSize(), + 'fonts:articleContent:style:bold' => $articleContentFont->getStyle()->isBold(), + 'fonts:articleContent:style:italic' => $articleContentFont->getStyle()->isItalic(), + 'fonts:articleContent:style:underline' => $articleContentFont->getStyle()->isUnderline(), + + 'fonts:articleHeadline:family' => $articleHeadlineFont->getFamily(), + 'fonts:articleHeadline:size' => $articleHeadlineFont->getSize(), + 'fonts:articleHeadline:style:bold' => $articleHeadlineFont->getStyle()->isBold(), + 'fonts:articleHeadline:style:italic' => $articleHeadlineFont->getStyle()->isItalic(), + 'fonts:articleHeadline:style:underline' => $articleHeadlineFont->getStyle()->isUnderline(), + + 'fonts:source:family' => $sourceFont->getFamily(), + 'fonts:source:size' => $sourceFont->getSize(), + 'fonts:source:style:bold' => $sourceFont->getStyle()->isBold(), + 'fonts:source:style:italic' => $sourceFont->getStyle()->isItalic(), + 'fonts:source:style:underline' => $sourceFont->getStyle()->isUnderline(), + + 'fonts:author:family' => $authorFont->getFamily(), + 'fonts:author:size' => $authorFont->getSize(), + 'fonts:author:style:bold' => $authorFont->getStyle()->isBold(), + 'fonts:author:style:italic' => $authorFont->getStyle()->isItalic(), + 'fonts:author:style:underline' => $authorFont->getStyle()->isUnderline(), + ]; + + // + // Plain + // + $this->assertCssRender(ThemeTypeEnum::plain(), [ + // .feed-title + $this->createCssAssertBuilder('.content .feed-title') + ->hasFont($feedTitleFont) + ->propertyShouldNotExists('background') + ->propertyShouldNotExists('color'), + + // .document + $this->createCssAssertBuilder('.content .documents .document') + ->propertyShouldBe('margin-top', '10px') + ->propertyShouldBe('margin-left', '5px') + ->propertyShouldNotExists('display') + ->propertyShouldNotExists('flex'), + $this->createCssAssertBuilder('.content .documents .document:last-child'), + $this->createCssAssertBuilder('.content .documents .document-aside')->shouldNotExists(), + $this->createCssAssertBuilder('.content .documents .document-main')->shouldNotExists(), + $this->createCssAssertBuilder('.content .documents .document-body')->shouldNotExists(), + $this->createCssAssertBuilder('.content .documents .document-image')->shouldNotExists(), + $this->createCssAssertBuilder('.content .documents .document-image img')->shouldNotExists(), + + // .document-headline link + $this->createCssAssertBuilder('.content .documents .document-headline a') + ->hasFont($articleHeadlineFont) + ->propertyShouldBe('color', 'rgba(127, 48, 59, 0.36)'), + + // .document-source + $this->createCssAssertBuilder('.content .documents .document-source') + ->hasFont($sourceFont) + ->propertyShouldBe('color', 'rgba(128, 49, 60, 0.37)'), + + // .document-author + $this->createCssAssertBuilder('.content .documents .document-author') + ->hasFont($authorFont) + ->propertyShouldBe('color', 'rgba(129, 50, 61, 0.38)'), + + // .document-date + $this->createCssAssertBuilder('.content .documents .document-date') + ->hasFont($dateFont) + ->propertyShouldBe('color', 'rgba(125, 46, 57, 0.34)'), + + // .document-content + $this->createCssAssertBuilder('.content .document .document-content') + ->hasFont($articleContentFont), + + // Comments + $this->createCssAssertBuilder('.content .comments .comment-title')->shouldNotExists(), + $this->createCssAssertBuilder('.content .comments .comment-author') + ->propertyShouldBe('color', 'rgba(129, 50, 61, 0.38)') + ->hasFont($authorFont), + $this->createCssAssertBuilder('.content .comments .comment-date') + ->propertyShouldBe('color', 'rgba(125, 46, 57, 0.34)') + ->hasFont($dateFont), + ], $diffs); + + // + // Enhanced + // + + $this->assertCssRender(ThemeTypeEnum::enhanced(), [ + // .feed-title + $this->createCssAssertBuilder('.content .feed-title') + ->hasFont($feedTitleFont) + ->propertyShouldBe('background', 'rgba(123, 44, 55, 0.32)') + ->propertyShouldBe('color', 'rgba(124, 45, 56, 0.33)'), + + // .document + $this->createCssAssertBuilder('.content .documents .document') + ->propertyShouldBe('margin-top', '5px') + ->propertyShouldNotExists('margin-left', '5px') + ->propertyShouldExists('display') + ->propertyShouldExists('flex'), + $this->createCssAssertBuilder('.content .documents .document:last-child')->shouldNotExists(), + $this->createCssAssertBuilder('.content .documents .document-aside'), + $this->createCssAssertBuilder('.content .documents .document-main'), + $this->createCssAssertBuilder('.content .documents .document-body'), + $this->createCssAssertBuilder('.content .documents .document-image'), + $this->createCssAssertBuilder('.content .documents .document-image img'), + + // .document-headline link + $this->createCssAssertBuilder('.content .documents .document-headline a') + ->hasFont($articleHeadlineFont) + ->propertyShouldBe('color', 'rgba(127, 48, 59, 0.36)'), + + // .document-source + $this->createCssAssertBuilder('.content .documents .document-source') + ->hasFont($sourceFont) + ->propertyShouldBe('color', 'rgba(128, 49, 60, 0.37)'), + + // .document-author + $this->createCssAssertBuilder('.content .documents .document-author') + ->hasFont($authorFont) + ->propertyShouldBe('color', 'rgba(129, 50, 61, 0.38)'), + + // .document-date + $this->createCssAssertBuilder('.content .documents .document-date') + ->hasFont($dateFont) + ->propertyShouldBe('color', 'rgba(126, 47, 58, 0.35)'), + + // .document-content + $this->createCssAssertBuilder('.content .document .document-content') + ->hasFont($articleContentFont), + + // Comments + $this->createCssAssertBuilder('.content .comments .comment-title'), + $this->createCssAssertBuilder('.content .comments .comment-author') + ->propertyShouldBe('color', 'rgba(129, 50, 61, 0.38)') + ->hasNotAnyFonts(), + $this->createCssAssertBuilder('.content .comments .comment-date') + ->propertyShouldBe('color', 'rgba(126, 47, 58, 0.35)') + ->hasNotAnyFonts(), + ], $diffs); + } + + /** + * @param string $selector A base css element selector. + * @param boolean $escape Should escape specific pattern symbols or not. + * + * @return CssAssertBuilder + */ + private function createCssAssertBuilder($selector, $escape = true) + { + return new CssAssertBuilder($selector, $escape); + } + + /** + * @param ThemeTypeEnum $themeType A ThemeTypeEnum instance. + * @param CssAssertBuilder[] $asserts Array of css assert builders. + * + * @param array $diffs Notification theme diffs. + * + * @return void + */ + private function assertCssRender(ThemeTypeEnum $themeType, array $asserts, array $diffs = []) + { + $html = $this->render($themeType, $diffs, [ new FeedData('test', []) ]); + + /** @var CssAssertBuilder $assert */ + foreach ($asserts as $assert) { + $assert->assert($html); + } + } + + /** + * @param ThemeTypeEnum $themeType A ThemeTypeEnum instance. + * @param array $diffs Notification theme diffs. + * + * @return CssAsserter + */ + private function createAsserter(ThemeTypeEnum $themeType, array $diffs = []) + { + return CssAsserter::createFromHtml($this->render($themeType, $diffs, [ new FeedData('test', []) ])); + } +} diff --git a/tests/UserBundle/Manager/Notification/RecipientFixture.php b/tests/UserBundle/Manager/Notification/RecipientFixture.php new file mode 100644 index 0000000..9266389 --- /dev/null +++ b/tests/UserBundle/Manager/Notification/RecipientFixture.php @@ -0,0 +1,55 @@ +id = $id; + } + + /** + * Return fqcn of form used for creating this entity. + * + * @return string + */ + public function getCreateFormClass() + { + return ''; + } + + /** + * Return fqcn of form used for updating this entity. + * + * @return string + */ + public function getUpdateFormClass() + { + return ''; + } + + /** + * Return metadata for current entity. + * + * @return \ApiBundle\Serializer\Metadata\Metadata + */ + public function getMetadata() + { + return new Metadata(static::class); + } +} diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..c4ba5f3 --- /dev/null +++ b/update.sh @@ -0,0 +1,99 @@ +#!/bin/bash + +# show help text +function showHelp() +{ + echo "Usage:"; + echo " ${txtbld}./update.sh${txtrst} - update without '${txtyel}composer install${txtrst}' and '${txtyel}composer update${txtrst}'"; + echo " ${txtbld}./update.sh -c${txtrst} - update with '${txtyel}composer install${txtrst}' and '${txtyel}composer update${txtrst}'"; + echo ; +} + +# set terminal variable +export TERM=xterm; + +txtred=$(tput setaf 1) # Red +txtgrn=$(tput setaf 2) # Green +txtyel=$(tput setaf 3) # Yellow +txtbld=$(tput bold) # Bold +txtrst=$(tput sgr0) # Text reset + + +COMPOSER=false; # not using composer by default + +optname='?'; + +while getopts "ch" optname + do + case "$optname" in + "c") + # run with composer + COMPOSER=true + ;; + "?") + # wrong option + showHelp + exit 127; + ;; + "h") + # show help + showHelp + exit 0; + ;; + *) + # Should not occur + echo "Unknown error while processing options"; + showHelp + exit 127; + ;; + esac + done + +if $COMPOSER ; then + + printf "%-120s %s\n" "Use composer: [${txtyel}Yes${txtrst}]" + + # install composer + if [ ! -f ./composer.phar ] ; then + curl -sS https://getcomposer.org/installer | php 2>&1 + if [ ! -f ./composer.phar ] ; then + echo "${txtred}Install composer Error${txtrst}" + exit 1; + fi + printf "%-120s %s\n" "Install composer [${txtgrn}Ok${txtrst}]" + fi + + # run install + ./composer.phar install + if [ $? -ne 0 ] ; then + echo "${txtred}Update: Error${txtrst}" + exit 2; + fi + printf "%-120s %s\n" "Update: [${txtgrn}Ok${txtrst}]"; +else + printf "%-120s %s\n" "Use composer: [${txtyel}No${txtrst}]" +fi + +# set permissions for cache and logs, and group permissions for all files +rm -rf var/cache/* +rm -rf var/logs/* + +APACHEUSER=`ps aux | grep -E '[a]pache|[h]ttpd' | grep -v root | head -1 | cut -d\ -f1` + +if [ `command -v setfacl 2>&1` ] ; then + setfacl -R -m u:$APACHEUSER:rwX -m u:`whoami`:rwX var + setfacl -dR -m u:$APACHEUSER:rwX -m u:`whoami`:rwX var + if [ $(getent group phpteam ) ]; then + setfacl -R -m g:phpteam:rw ./; + printf "%-120s %s\n" "Setted permissions by setfacl:" + getfacl ./ | grep phpteam | grep -v "#" + fi + printf "%-120s %s\n" "Set permissions by setfacl [${txtgrn}Ok${txtrst}]" +else + chmod +a "$APACHEUSER allow delete,write,append,file_inherit,directory_inherit" var + chmod +a "`whoami` allow delete,write,append,file_inherit,directory_inherit" var + printf "%-120s %s\n" "Set permissions by chmod [${txtgrn}Ok${txtrst}]" +fi +printf "%-120s %s\n" "Set permissions to cache and logs for $APACHEUSER: [${txtgrn}Ok${txtrst}]" + +exit 0; diff --git a/var/SymfonyRequirements.php b/var/SymfonyRequirements.php new file mode 100644 index 0000000..4a1fcc6 --- /dev/null +++ b/var/SymfonyRequirements.php @@ -0,0 +1,810 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/* + * Users of PHP 5.2 should be able to run the requirements checks. + * This is why the file and all classes must be compatible with PHP 5.2+ + * (e.g. not using namespaces and closures). + * + * ************** CAUTION ************** + * + * DO NOT EDIT THIS FILE as it will be overridden by Composer as part of + * the installation/update process. The original file resides in the + * SensioDistributionBundle. + * + * ************** CAUTION ************** + */ + +/** + * Represents a single PHP requirement, e.g. an installed extension. + * It can be a mandatory requirement or an optional recommendation. + * There is a special subclass, named PhpIniRequirement, to check a php.ini configuration. + * + * @author Tobias Schultze + */ +class Requirement +{ + private $fulfilled; + private $testMessage; + private $helpText; + private $helpHtml; + private $optional; + + /** + * Constructor that initializes the requirement. + * + * @param bool $fulfilled Whether the requirement is fulfilled + * @param string $testMessage The message for testing the requirement + * @param string $helpHtml The help text formatted in HTML for resolving the problem + * @param string|null $helpText The help text (when null, it will be inferred from $helpHtml, i.e. stripped from HTML tags) + * @param bool $optional Whether this is only an optional recommendation not a mandatory requirement + */ + public function __construct($fulfilled, $testMessage, $helpHtml, $helpText = null, $optional = false) + { + $this->fulfilled = (bool) $fulfilled; + $this->testMessage = (string) $testMessage; + $this->helpHtml = (string) $helpHtml; + $this->helpText = null === $helpText ? strip_tags($this->helpHtml) : (string) $helpText; + $this->optional = (bool) $optional; + } + + /** + * Returns whether the requirement is fulfilled. + * + * @return bool true if fulfilled, otherwise false + */ + public function isFulfilled() + { + return $this->fulfilled; + } + + /** + * Returns the message for testing the requirement. + * + * @return string The test message + */ + public function getTestMessage() + { + return $this->testMessage; + } + + /** + * Returns the help text for resolving the problem. + * + * @return string The help text + */ + public function getHelpText() + { + return $this->helpText; + } + + /** + * Returns the help text formatted in HTML. + * + * @return string The HTML help + */ + public function getHelpHtml() + { + return $this->helpHtml; + } + + /** + * Returns whether this is only an optional recommendation and not a mandatory requirement. + * + * @return bool true if optional, false if mandatory + */ + public function isOptional() + { + return $this->optional; + } +} + +/** + * Represents a PHP requirement in form of a php.ini configuration. + * + * @author Tobias Schultze + */ +class PhpIniRequirement extends Requirement +{ + /** + * Constructor that initializes the requirement. + * + * @param string $cfgName The configuration name used for ini_get() + * @param bool|callback $evaluation Either a boolean indicating whether the configuration should evaluate to true or false, + * or a callback function receiving the configuration value as parameter to determine the fulfillment of the requirement + * @param bool $approveCfgAbsence If true the Requirement will be fulfilled even if the configuration option does not exist, i.e. ini_get() returns false. + * This is helpful for abandoned configs in later PHP versions or configs of an optional extension, like Suhosin. + * Example: You require a config to be true but PHP later removes this config and defaults it to true internally. + * @param string|null $testMessage The message for testing the requirement (when null and $evaluation is a boolean a default message is derived) + * @param string|null $helpHtml The help text formatted in HTML for resolving the problem (when null and $evaluation is a boolean a default help is derived) + * @param string|null $helpText The help text (when null, it will be inferred from $helpHtml, i.e. stripped from HTML tags) + * @param bool $optional Whether this is only an optional recommendation not a mandatory requirement + */ + public function __construct($cfgName, $evaluation, $approveCfgAbsence = false, $testMessage = null, $helpHtml = null, $helpText = null, $optional = false) + { + $cfgValue = ini_get($cfgName); + + if (is_callable($evaluation)) { + if (null === $testMessage || null === $helpHtml) { + throw new InvalidArgumentException('You must provide the parameters testMessage and helpHtml for a callback evaluation.'); + } + + $fulfilled = call_user_func($evaluation, $cfgValue); + } else { + if (null === $testMessage) { + $testMessage = sprintf('%s %s be %s in php.ini', + $cfgName, + $optional ? 'should' : 'must', + $evaluation ? 'enabled' : 'disabled' + ); + } + + if (null === $helpHtml) { + $helpHtml = sprintf('Set %s to %s in php.ini*.', + $cfgName, + $evaluation ? 'on' : 'off' + ); + } + + $fulfilled = $evaluation == $cfgValue; + } + + parent::__construct($fulfilled || ($approveCfgAbsence && false === $cfgValue), $testMessage, $helpHtml, $helpText, $optional); + } +} + +/** + * A RequirementCollection represents a set of Requirement instances. + * + * @author Tobias Schultze + */ +class RequirementCollection implements IteratorAggregate +{ + /** + * @var Requirement[] + */ + private $requirements = array(); + + /** + * Gets the current RequirementCollection as an Iterator. + * + * @return Traversable A Traversable interface + */ + public function getIterator() + { + return new ArrayIterator($this->requirements); + } + + /** + * Adds a Requirement. + * + * @param Requirement $requirement A Requirement instance + */ + public function add(Requirement $requirement) + { + $this->requirements[] = $requirement; + } + + /** + * Adds a mandatory requirement. + * + * @param bool $fulfilled Whether the requirement is fulfilled + * @param string $testMessage The message for testing the requirement + * @param string $helpHtml The help text formatted in HTML for resolving the problem + * @param string|null $helpText The help text (when null, it will be inferred from $helpHtml, i.e. stripped from HTML tags) + */ + public function addRequirement($fulfilled, $testMessage, $helpHtml, $helpText = null) + { + $this->add(new Requirement($fulfilled, $testMessage, $helpHtml, $helpText, false)); + } + + /** + * Adds an optional recommendation. + * + * @param bool $fulfilled Whether the recommendation is fulfilled + * @param string $testMessage The message for testing the recommendation + * @param string $helpHtml The help text formatted in HTML for resolving the problem + * @param string|null $helpText The help text (when null, it will be inferred from $helpHtml, i.e. stripped from HTML tags) + */ + public function addRecommendation($fulfilled, $testMessage, $helpHtml, $helpText = null) + { + $this->add(new Requirement($fulfilled, $testMessage, $helpHtml, $helpText, true)); + } + + /** + * Adds a mandatory requirement in form of a php.ini configuration. + * + * @param string $cfgName The configuration name used for ini_get() + * @param bool|callback $evaluation Either a boolean indicating whether the configuration should evaluate to true or false, + * or a callback function receiving the configuration value as parameter to determine the fulfillment of the requirement + * @param bool $approveCfgAbsence If true the Requirement will be fulfilled even if the configuration option does not exist, i.e. ini_get() returns false. + * This is helpful for abandoned configs in later PHP versions or configs of an optional extension, like Suhosin. + * Example: You require a config to be true but PHP later removes this config and defaults it to true internally. + * @param string $testMessage The message for testing the requirement (when null and $evaluation is a boolean a default message is derived) + * @param string $helpHtml The help text formatted in HTML for resolving the problem (when null and $evaluation is a boolean a default help is derived) + * @param string|null $helpText The help text (when null, it will be inferred from $helpHtml, i.e. stripped from HTML tags) + */ + public function addPhpIniRequirement($cfgName, $evaluation, $approveCfgAbsence = false, $testMessage = null, $helpHtml = null, $helpText = null) + { + $this->add(new PhpIniRequirement($cfgName, $evaluation, $approveCfgAbsence, $testMessage, $helpHtml, $helpText, false)); + } + + /** + * Adds an optional recommendation in form of a php.ini configuration. + * + * @param string $cfgName The configuration name used for ini_get() + * @param bool|callback $evaluation Either a boolean indicating whether the configuration should evaluate to true or false, + * or a callback function receiving the configuration value as parameter to determine the fulfillment of the requirement + * @param bool $approveCfgAbsence If true the Requirement will be fulfilled even if the configuration option does not exist, i.e. ini_get() returns false. + * This is helpful for abandoned configs in later PHP versions or configs of an optional extension, like Suhosin. + * Example: You require a config to be true but PHP later removes this config and defaults it to true internally. + * @param string $testMessage The message for testing the requirement (when null and $evaluation is a boolean a default message is derived) + * @param string $helpHtml The help text formatted in HTML for resolving the problem (when null and $evaluation is a boolean a default help is derived) + * @param string|null $helpText The help text (when null, it will be inferred from $helpHtml, i.e. stripped from HTML tags) + */ + public function addPhpIniRecommendation($cfgName, $evaluation, $approveCfgAbsence = false, $testMessage = null, $helpHtml = null, $helpText = null) + { + $this->add(new PhpIniRequirement($cfgName, $evaluation, $approveCfgAbsence, $testMessage, $helpHtml, $helpText, true)); + } + + /** + * Adds a requirement collection to the current set of requirements. + * + * @param RequirementCollection $collection A RequirementCollection instance + */ + public function addCollection(RequirementCollection $collection) + { + $this->requirements = array_merge($this->requirements, $collection->all()); + } + + /** + * Returns both requirements and recommendations. + * + * @return Requirement[] + */ + public function all() + { + return $this->requirements; + } + + /** + * Returns all mandatory requirements. + * + * @return Requirement[] + */ + public function getRequirements() + { + $array = array(); + foreach ($this->requirements as $req) { + if (!$req->isOptional()) { + $array[] = $req; + } + } + + return $array; + } + + /** + * Returns the mandatory requirements that were not met. + * + * @return Requirement[] + */ + public function getFailedRequirements() + { + $array = array(); + foreach ($this->requirements as $req) { + if (!$req->isFulfilled() && !$req->isOptional()) { + $array[] = $req; + } + } + + return $array; + } + + /** + * Returns all optional recommendations. + * + * @return Requirement[] + */ + public function getRecommendations() + { + $array = array(); + foreach ($this->requirements as $req) { + if ($req->isOptional()) { + $array[] = $req; + } + } + + return $array; + } + + /** + * Returns the recommendations that were not met. + * + * @return Requirement[] + */ + public function getFailedRecommendations() + { + $array = array(); + foreach ($this->requirements as $req) { + if (!$req->isFulfilled() && $req->isOptional()) { + $array[] = $req; + } + } + + return $array; + } + + /** + * Returns whether a php.ini configuration is not correct. + * + * @return bool php.ini configuration problem? + */ + public function hasPhpIniConfigIssue() + { + foreach ($this->requirements as $req) { + if (!$req->isFulfilled() && $req instanceof PhpIniRequirement) { + return true; + } + } + + return false; + } + + /** + * Returns the PHP configuration file (php.ini) path. + * + * @return string|false php.ini file path + */ + public function getPhpIniConfigPath() + { + return get_cfg_var('cfg_file_path'); + } +} + +/** + * This class specifies all requirements and optional recommendations that + * are necessary to run the Symfony Standard Edition. + * + * @author Tobias Schultze + * @author Fabien Potencier + */ +class SymfonyRequirements extends RequirementCollection +{ + const LEGACY_REQUIRED_PHP_VERSION = '5.3.3'; + const REQUIRED_PHP_VERSION = '5.5.9'; + + /** + * Constructor that initializes the requirements. + */ + public function __construct() + { + /* mandatory requirements follow */ + + $installedPhpVersion = PHP_VERSION; + $requiredPhpVersion = $this->getPhpRequiredVersion(); + + $this->addRecommendation( + $requiredPhpVersion, + 'Vendors should be installed in order to check all requirements.', + 'Run the composer install command.', + 'Run the "composer install" command.' + ); + + if (false !== $requiredPhpVersion) { + $this->addRequirement( + version_compare($installedPhpVersion, $requiredPhpVersion, '>='), + sprintf('PHP version must be at least %s (%s installed)', $requiredPhpVersion, $installedPhpVersion), + sprintf('You are running PHP version "%s", but Symfony needs at least PHP "%s" to run. + Before using Symfony, upgrade your PHP installation, preferably to the latest version.', + $installedPhpVersion, $requiredPhpVersion), + sprintf('Install PHP %s or newer (installed version is %s)', $requiredPhpVersion, $installedPhpVersion) + ); + } + + $this->addRequirement( + version_compare($installedPhpVersion, '5.3.16', '!='), + 'PHP version must not be 5.3.16 as Symfony won\'t work properly with it', + 'Install PHP 5.3.17 or newer (or downgrade to an earlier PHP version)' + ); + + $this->addRequirement( + is_dir(__DIR__.'/../vendor/composer'), + 'Vendor libraries must be installed', + 'Vendor libraries are missing. Install composer following instructions from http://getcomposer.org/. '. + 'Then run "php composer.phar install" to install them.' + ); + + $cacheDir = is_dir(__DIR__.'/../var/cache') ? __DIR__.'/../var/cache' : __DIR__.'/cache'; + + $this->addRequirement( + is_writable($cacheDir), + 'app/cache/ or var/cache/ directory must be writable', + 'Change the permissions of either "app/cache/" or "var/cache/" directory so that the web server can write into it.' + ); + + $logsDir = is_dir(__DIR__.'/../var/logs') ? __DIR__.'/../var/logs' : __DIR__.'/logs'; + + $this->addRequirement( + is_writable($logsDir), + 'app/logs/ or var/logs/ directory must be writable', + 'Change the permissions of either "app/logs/" or "var/logs/" directory so that the web server can write into it.' + ); + + if (version_compare($installedPhpVersion, '7.0.0', '<')) { + $this->addPhpIniRequirement( + 'date.timezone', true, false, + 'date.timezone setting must be set', + 'Set the "date.timezone" setting in php.ini* (like Europe/Paris).' + ); + } + + if (false !== $requiredPhpVersion && version_compare($installedPhpVersion, $requiredPhpVersion, '>=')) { + $this->addRequirement( + in_array(@date_default_timezone_get(), DateTimeZone::listIdentifiers(), true), + sprintf('Configured default timezone "%s" must be supported by your installation of PHP', @date_default_timezone_get()), + 'Your default timezone is not supported by PHP. Check for typos in your php.ini file and have a look at the list of deprecated timezones at http://php.net/manual/en/timezones.others.php.' + ); + } + + $this->addRequirement( + function_exists('iconv'), + 'iconv() must be available', + 'Install and enable the iconv extension.' + ); + + $this->addRequirement( + function_exists('json_encode'), + 'json_encode() must be available', + 'Install and enable the JSON extension.' + ); + + $this->addRequirement( + function_exists('session_start'), + 'session_start() must be available', + 'Install and enable the session extension.' + ); + + $this->addRequirement( + function_exists('ctype_alpha'), + 'ctype_alpha() must be available', + 'Install and enable the ctype extension.' + ); + + $this->addRequirement( + function_exists('token_get_all'), + 'token_get_all() must be available', + 'Install and enable the Tokenizer extension.' + ); + + $this->addRequirement( + function_exists('simplexml_import_dom'), + 'simplexml_import_dom() must be available', + 'Install and enable the SimpleXML extension.' + ); + + if (function_exists('apc_store') && ini_get('apc.enabled')) { + if (version_compare($installedPhpVersion, '5.4.0', '>=')) { + $this->addRequirement( + version_compare(phpversion('apc'), '3.1.13', '>='), + 'APC version must be at least 3.1.13 when using PHP 5.4', + 'Upgrade your APC extension (3.1.13+).' + ); + } else { + $this->addRequirement( + version_compare(phpversion('apc'), '3.0.17', '>='), + 'APC version must be at least 3.0.17', + 'Upgrade your APC extension (3.0.17+).' + ); + } + } + + $this->addPhpIniRequirement('detect_unicode', false); + + if (extension_loaded('suhosin')) { + $this->addPhpIniRequirement( + 'suhosin.executor.include.whitelist', + create_function('$cfgValue', 'return false !== stripos($cfgValue, "phar");'), + false, + 'suhosin.executor.include.whitelist must be configured correctly in php.ini', + 'Add "phar" to suhosin.executor.include.whitelist in php.ini*.' + ); + } + + if (extension_loaded('xdebug')) { + $this->addPhpIniRequirement( + 'xdebug.show_exception_trace', false, true + ); + + $this->addPhpIniRequirement( + 'xdebug.scream', false, true + ); + + $this->addPhpIniRecommendation( + 'xdebug.max_nesting_level', + create_function('$cfgValue', 'return $cfgValue > 100;'), + true, + 'xdebug.max_nesting_level should be above 100 in php.ini', + 'Set "xdebug.max_nesting_level" to e.g. "250" in php.ini* to stop Xdebug\'s infinite recursion protection erroneously throwing a fatal error in your project.' + ); + } + + $pcreVersion = defined('PCRE_VERSION') ? (float) PCRE_VERSION : null; + + $this->addRequirement( + null !== $pcreVersion, + 'PCRE extension must be available', + 'Install the PCRE extension (version 8.0+).' + ); + + if (extension_loaded('mbstring')) { + $this->addPhpIniRequirement( + 'mbstring.func_overload', + create_function('$cfgValue', 'return (int) $cfgValue === 0;'), + true, + 'string functions should not be overloaded', + 'Set "mbstring.func_overload" to 0 in php.ini* to disable function overloading by the mbstring extension.' + ); + } + + /* optional recommendations follow */ + + if (file_exists(__DIR__.'/../vendor/composer')) { + require_once __DIR__.'/../vendor/autoload.php'; + + try { + $r = new ReflectionClass('Sensio\Bundle\DistributionBundle\SensioDistributionBundle'); + + $contents = file_get_contents(dirname($r->getFileName()).'/Resources/skeleton/app/SymfonyRequirements.php'); + } catch (ReflectionException $e) { + $contents = ''; + } + $this->addRecommendation( + file_get_contents(__FILE__) === $contents, + 'Requirements file should be up-to-date', + 'Your requirements file is outdated. Run composer install and re-check your configuration.' + ); + } + + $this->addRecommendation( + version_compare($installedPhpVersion, '5.3.4', '>='), + 'You should use at least PHP 5.3.4 due to PHP bug #52083 in earlier versions', + 'Your project might malfunction randomly due to PHP bug #52083 ("Notice: Trying to get property of non-object"). Install PHP 5.3.4 or newer.' + ); + + $this->addRecommendation( + version_compare($installedPhpVersion, '5.3.8', '>='), + 'When using annotations you should have at least PHP 5.3.8 due to PHP bug #55156', + 'Install PHP 5.3.8 or newer if your project uses annotations.' + ); + + $this->addRecommendation( + version_compare($installedPhpVersion, '5.4.0', '!='), + 'You should not use PHP 5.4.0 due to the PHP bug #61453', + 'Your project might not work properly due to the PHP bug #61453 ("Cannot dump definitions which have method calls"). Install PHP 5.4.1 or newer.' + ); + + $this->addRecommendation( + version_compare($installedPhpVersion, '5.4.11', '>='), + 'When using the logout handler from the Symfony Security Component, you should have at least PHP 5.4.11 due to PHP bug #63379 (as a workaround, you can also set invalidate_session to false in the security logout handler configuration)', + 'Install PHP 5.4.11 or newer if your project uses the logout handler from the Symfony Security Component.' + ); + + $this->addRecommendation( + (version_compare($installedPhpVersion, '5.3.18', '>=') && version_compare($installedPhpVersion, '5.4.0', '<')) + || + version_compare($installedPhpVersion, '5.4.8', '>='), + 'You should use PHP 5.3.18+ or PHP 5.4.8+ to always get nice error messages for fatal errors in the development environment due to PHP bug #61767/#60909', + 'Install PHP 5.3.18+ or PHP 5.4.8+ if you want nice error messages for all fatal errors in the development environment.' + ); + + if (null !== $pcreVersion) { + $this->addRecommendation( + $pcreVersion >= 8.0, + sprintf('PCRE extension should be at least version 8.0 (%s installed)', $pcreVersion), + 'PCRE 8.0+ is preconfigured in PHP since 5.3.2 but you are using an outdated version of it. Symfony probably works anyway but it is recommended to upgrade your PCRE extension.' + ); + } + + $this->addRecommendation( + class_exists('DomDocument'), + 'PHP-DOM and PHP-XML modules should be installed', + 'Install and enable the PHP-DOM and the PHP-XML modules.' + ); + + $this->addRecommendation( + function_exists('mb_strlen'), + 'mb_strlen() should be available', + 'Install and enable the mbstring extension.' + ); + + $this->addRecommendation( + function_exists('utf8_decode'), + 'utf8_decode() should be available', + 'Install and enable the XML extension.' + ); + + $this->addRecommendation( + function_exists('filter_var'), + 'filter_var() should be available', + 'Install and enable the filter extension.' + ); + + if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->addRecommendation( + function_exists('posix_isatty'), + 'posix_isatty() should be available', + 'Install and enable the php_posix extension (used to colorize the CLI output).' + ); + } + + $this->addRecommendation( + extension_loaded('intl'), + 'intl extension should be available', + 'Install and enable the intl extension (used for validators).' + ); + + if (extension_loaded('intl')) { + // in some WAMP server installations, new Collator() returns null + $this->addRecommendation( + null !== new Collator('fr_FR'), + 'intl extension should be correctly configured', + 'The intl extension does not behave properly. This problem is typical on PHP 5.3.X x64 WIN builds.' + ); + + // check for compatible ICU versions (only done when you have the intl extension) + if (defined('INTL_ICU_VERSION')) { + $version = INTL_ICU_VERSION; + } else { + $reflector = new ReflectionExtension('intl'); + + ob_start(); + $reflector->info(); + $output = strip_tags(ob_get_clean()); + + preg_match('/^ICU version +(?:=> )?(.*)$/m', $output, $matches); + $version = $matches[1]; + } + + $this->addRecommendation( + version_compare($version, '4.0', '>='), + 'intl ICU version should be at least 4+', + 'Upgrade your intl extension with a newer ICU version (4+).' + ); + + if (class_exists('Symfony\Component\Intl\Intl')) { + $this->addRecommendation( + \Symfony\Component\Intl\Intl::getIcuDataVersion() <= \Symfony\Component\Intl\Intl::getIcuVersion(), + sprintf('intl ICU version installed on your system is outdated (%s) and does not match the ICU data bundled with Symfony (%s)', \Symfony\Component\Intl\Intl::getIcuVersion(), \Symfony\Component\Intl\Intl::getIcuDataVersion()), + 'To get the latest internationalization data upgrade the ICU system package and the intl PHP extension.' + ); + if (\Symfony\Component\Intl\Intl::getIcuDataVersion() <= \Symfony\Component\Intl\Intl::getIcuVersion()) { + $this->addRecommendation( + \Symfony\Component\Intl\Intl::getIcuDataVersion() === \Symfony\Component\Intl\Intl::getIcuVersion(), + sprintf('intl ICU version installed on your system (%s) does not match the ICU data bundled with Symfony (%s)', \Symfony\Component\Intl\Intl::getIcuVersion(), \Symfony\Component\Intl\Intl::getIcuDataVersion()), + 'To avoid internationalization data inconsistencies upgrade the symfony/intl component.' + ); + } + } + + $this->addPhpIniRecommendation( + 'intl.error_level', + create_function('$cfgValue', 'return (int) $cfgValue === 0;'), + true, + 'intl.error_level should be 0 in php.ini', + 'Set "intl.error_level" to "0" in php.ini* to inhibit the messages when an error occurs in ICU functions.' + ); + } + + $accelerator = + (extension_loaded('eaccelerator') && ini_get('eaccelerator.enable')) + || + (extension_loaded('apc') && ini_get('apc.enabled')) + || + (extension_loaded('Zend Optimizer+') && ini_get('zend_optimizerplus.enable')) + || + (extension_loaded('Zend OPcache') && ini_get('opcache.enable')) + || + (extension_loaded('xcache') && ini_get('xcache.cacher')) + || + (extension_loaded('wincache') && ini_get('wincache.ocenabled')) + ; + + $this->addRecommendation( + $accelerator, + 'a PHP accelerator should be installed', + 'Install and/or enable a PHP accelerator (highly recommended).' + ); + + if ('WIN' === strtoupper(substr(PHP_OS, 0, 3))) { + $this->addRecommendation( + $this->getRealpathCacheSize() >= 5 * 1024 * 1024, + 'realpath_cache_size should be at least 5M in php.ini', + 'Setting "realpath_cache_size" to e.g. "5242880" or "5M" in php.ini* may improve performance on Windows significantly in some cases.' + ); + } + + $this->addPhpIniRecommendation('short_open_tag', false); + + $this->addPhpIniRecommendation('magic_quotes_gpc', false, true); + + $this->addPhpIniRecommendation('register_globals', false, true); + + $this->addPhpIniRecommendation('session.auto_start', false); + + $this->addRecommendation( + class_exists('PDO'), + 'PDO should be installed', + 'Install PDO (mandatory for Doctrine).' + ); + + if (class_exists('PDO')) { + $drivers = PDO::getAvailableDrivers(); + $this->addRecommendation( + count($drivers) > 0, + sprintf('PDO should have some drivers installed (currently available: %s)', count($drivers) ? implode(', ', $drivers) : 'none'), + 'Install PDO drivers (mandatory for Doctrine).' + ); + } + } + + /** + * Loads realpath_cache_size from php.ini and converts it to int. + * + * (e.g. 16k is converted to 16384 int) + * + * @return int + */ + protected function getRealpathCacheSize() + { + $size = ini_get('realpath_cache_size'); + $size = trim($size); + $unit = ''; + if (!ctype_digit($size)) { + $unit = strtolower(substr($size, -1, 1)); + $size = (int) substr($size, 0, -1); + } + switch ($unit) { + case 'g': + return $size * 1024 * 1024 * 1024; + case 'm': + return $size * 1024 * 1024; + case 'k': + return $size * 1024; + default: + return (int) $size; + } + } + + /** + * Defines PHP required version from Symfony version. + * + * @return string|false The PHP required version or false if it could not be guessed + */ + protected function getPhpRequiredVersion() + { + if (!file_exists($path = __DIR__.'/../composer.lock')) { + return false; + } + + $composerLock = json_decode(file_get_contents($path), true); + foreach ($composerLock['packages'] as $package) { + $name = $package['name']; + if ('symfony/symfony' !== $name && 'symfony/http-kernel' !== $name) { + continue; + } + + return (int) $package['version'][1] > 2 ? self::REQUIRED_PHP_VERSION : self::LEGACY_REQUIRED_PHP_VERSION; + } + + return false; + } +} diff --git a/var/cache/.gitkeep b/var/cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/var/logs/.gitkeep b/var/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/var/sessions/.gitkeep b/var/sessions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/.htaccess b/web/.htaccess new file mode 100644 index 0000000..1fcf4bb --- /dev/null +++ b/web/.htaccess @@ -0,0 +1,68 @@ +# Use the front controller as index file. It serves as a fallback solution when +# every other rewrite/redirect fails (e.g. in an aliased environment without +# mod_rewrite). Additionally, this reduces the matching process for the +# start page (path "/") because otherwise Apache will apply the rewriting rules +# to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl). +DirectoryIndex app_dev.php + +# By default, Apache does not evaluate symbolic links if you did not enable this +# feature in your server configuration. Uncomment the following line if you +# install assets as symlinks or if you experience problems related to symlinks +# when compiling LESS/Sass/CoffeScript assets. +# Options FollowSymlinks + +# Disabling MultiViews prevents unwanted negotiation, e.g. "/app" should not resolve +# to the front controller "/app.php" but be rewritten to "/app.php/app". + + Options -MultiViews + + + + RewriteEngine On + + # Determine the RewriteBase automatically and set it as environment variable. + # If you are using Apache aliases to do mass virtual hosting or installed the + # project in a subdirectory, the base path will be prepended to allow proper + # resolution of the app.php file and to redirect to the correct URI. It will + # work in environments without path prefix as well, providing a safe, one-size + # fits all solution. But as you do not need it in this case, you can comment + # the following 2 lines to eliminate the overhead. + RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$ + RewriteRule ^(.*) - [E=BASE:%1] + + # Sets the HTTP_AUTHORIZATION header removed by Apache + RewriteCond %{HTTP:Authorization} . + RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Redirect to URI without front controller to prevent duplicate content + # (with and without `/app.php`). Only do this redirect on the initial + # rewrite by Apache and not on subsequent cycles. Otherwise we would get an + # endless redirect loop (request -> rewrite to front controller -> + # redirect -> request -> ...). + # So in case you get a "too many redirects" error or you always get redirected + # to the start page because your Apache does not expose the REDIRECT_STATUS + # environment variable, you have 2 choices: + # - disable this feature by commenting the following 2 lines or + # - use Apache >= 2.3.9 and replace all L flags by END flags and remove the + # following RewriteCond (best solution) + RewriteCond %{ENV:REDIRECT_STATUS} ^$ + RewriteRule ^app_dev\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L] + + # If the requested filename exists, simply serve it. + # We only want to let Apache serve files and not directories. + RewriteCond %{REQUEST_FILENAME} -f + RewriteRule ^ - [L] + + # Rewrite all other queries to the front controller. + RewriteRule ^ %{ENV:BASE}/app_dev.php [L] + + + + + # When mod_rewrite is not available, we instruct a temporary redirect of + # the start page to the front controller explicitly so that the website + # and the generated links can still be used. + RedirectMatch 302 ^/$ /app_dev.php/ + # RedirectTemp cannot be used instead + + diff --git a/web/.htaccess.development b/web/.htaccess.development new file mode 100644 index 0000000..ecee9b6 --- /dev/null +++ b/web/.htaccess.development @@ -0,0 +1,68 @@ +# Use the front controller as index file. It serves as a fallback solution when +# every other rewrite/redirect fails (e.g. in an aliased environment without +# mod_rewrite). Additionally, this reduces the matching process for the +# start page (path "/") because otherwise Apache will apply the rewriting rules +# to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl). +DirectoryIndex app_stage.php + +# By default, Apache does not evaluate symbolic links if you did not enable this +# feature in your server configuration. Uncomment the following line if you +# install assets as symlinks or if you experience problems related to symlinks +# when compiling LESS/Sass/CoffeScript assets. +# Options FollowSymlinks + +# Disabling MultiViews prevents unwanted negotiation, e.g. "/app" should not resolve +# to the front controller "/app_stage.php" but be rewritten to "/app_stage.php/app". + + Options -MultiViews + + + + RewriteEngine On + + # Determine the RewriteBase automatically and set it as environment variable. + # If you are using Apache aliases to do mass virtual hosting or installed the + # project in a subdirectory, the base path will be prepended to allow proper + # resolution of the app_stage.php file and to redirect to the correct URI. It will + # work in environments without path prefix as well, providing a safe, one-size + # fits all solution. But as you do not need it in this case, you can comment + # the following 2 lines to eliminate the overhead. + RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$ + RewriteRule ^(.*) - [E=BASE:%1] + + # Sets the HTTP_AUTHORIZATION header removed by Apache + RewriteCond %{HTTP:Authorization} . + RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Redirect to URI without front controller to prevent duplicate content + # (with and without `/app_stage.php`). Only do this redirect on the initial + # rewrite by Apache and not on subsequent cycles. Otherwise we would get an + # endless redirect loop (request -> rewrite to front controller -> + # redirect -> request -> ...). + # So in case you get a "too many redirects" error or you always get redirected + # to the start page because your Apache does not expose the REDIRECT_STATUS + # environment variable, you have 2 choices: + # - disable this feature by commenting the following 2 lines or + # - use Apache >= 2.3.9 and replace all L flags by END flags and remove the + # following RewriteCond (best solution) + RewriteCond %{ENV:REDIRECT_STATUS} ^$ + RewriteRule ^app_stage\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L] + + # If the requested filename exists, simply serve it. + # We only want to let Apache serve files and not directories. + RewriteCond %{REQUEST_FILENAME} -f + RewriteRule ^ - [L] + + # Rewrite all other queries to the front controller. + RewriteRule ^ %{ENV:BASE}/app_stage.php [L] + + + + + # When mod_rewrite is not available, we instruct a temporary redirect of + # the start page to the front controller explicitly so that the website + # and the generated links can still be used. + RedirectMatch 302 ^/$ /app_stage.php/ + # RedirectTemp cannot be used instead + + diff --git a/web/.htaccess.production b/web/.htaccess.production new file mode 100644 index 0000000..4dc7251 --- /dev/null +++ b/web/.htaccess.production @@ -0,0 +1,68 @@ +# Use the front controller as index file. It serves as a fallback solution when +# every other rewrite/redirect fails (e.g. in an aliased environment without +# mod_rewrite). Additionally, this reduces the matching process for the +# start page (path "/") because otherwise Apache will apply the rewriting rules +# to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl). +DirectoryIndex app.php + +# By default, Apache does not evaluate symbolic links if you did not enable this +# feature in your server configuration. Uncomment the following line if you +# install assets as symlinks or if you experience problems related to symlinks +# when compiling LESS/Sass/CoffeScript assets. +# Options FollowSymlinks + +# Disabling MultiViews prevents unwanted negotiation, e.g. "/app" should not resolve +# to the front controller "/app.php" but be rewritten to "/app.php/app". + + Options -MultiViews + + + + RewriteEngine On + + # Determine the RewriteBase automatically and set it as environment variable. + # If you are using Apache aliases to do mass virtual hosting or installed the + # project in a subdirectory, the base path will be prepended to allow proper + # resolution of the app.php file and to redirect to the correct URI. It will + # work in environments without path prefix as well, providing a safe, one-size + # fits all solution. But as you do not need it in this case, you can comment + # the following 2 lines to eliminate the overhead. + RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$ + RewriteRule ^(.*) - [E=BASE:%1] + + # Sets the HTTP_AUTHORIZATION header removed by Apache + RewriteCond %{HTTP:Authorization} . + RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Redirect to URI without front controller to prevent duplicate content + # (with and without `/app.php`). Only do this redirect on the initial + # rewrite by Apache and not on subsequent cycles. Otherwise we would get an + # endless redirect loop (request -> rewrite to front controller -> + # redirect -> request -> ...). + # So in case you get a "too many redirects" error or you always get redirected + # to the start page because your Apache does not expose the REDIRECT_STATUS + # environment variable, you have 2 choices: + # - disable this feature by commenting the following 2 lines or + # - use Apache >= 2.3.9 and replace all L flags by END flags and remove the + # following RewriteCond (best solution) + RewriteCond %{ENV:REDIRECT_STATUS} ^$ + RewriteRule ^app\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L] + + # If the requested filename exists, simply serve it. + # We only want to let Apache serve files and not directories. + RewriteCond %{REQUEST_FILENAME} -f + RewriteRule ^ - [L] + + # Rewrite all other queries to the front controller. + RewriteRule ^ %{ENV:BASE}/app.php [L] + + + + + # When mod_rewrite is not available, we instruct a temporary redirect of + # the start page to the front controller explicitly so that the website + # and the generated links can still be used. + RedirectMatch 302 ^/$ /app.php/ + # RedirectTemp cannot be used instead + + diff --git a/web/app.php b/web/app.php new file mode 100644 index 0000000..4c2c465 --- /dev/null +++ b/web/app.php @@ -0,0 +1,20 @@ +loadClassCache(); +//$kernel = new AppCache($kernel); + +// When using the HttpCache, you need to call the method in your front controller instead of relying on the configuration parameter +//Request::enableHttpMethodParameterOverride(); +$request = Request::createFromGlobals(); +$response = $kernel->handle($request); +$response->send(); +$kernel->terminate($request, $response); diff --git a/web/app_dev.php b/web/app_dev.php new file mode 100644 index 0000000..2663ba8 --- /dev/null +++ b/web/app_dev.php @@ -0,0 +1,32 @@ +loadClassCache(); +$request = Request::createFromGlobals(); +$response = $kernel->handle($request); +$response->send(); +$kernel->terminate($request, $response); diff --git a/web/app_stage.php b/web/app_stage.php new file mode 100644 index 0000000..be061fa --- /dev/null +++ b/web/app_stage.php @@ -0,0 +1,20 @@ +loadClassCache(); +//$kernel = new AppCache($kernel); + +// When using the HttpCache, you need to call the method in your front controller instead of relying on the configuration parameter +//Request::enableHttpMethodParameterOverride(); +$request = Request::createFromGlobals(); +$response = $kernel->handle($request); +$response->send(); +$kernel->terminate($request, $response); diff --git a/web/app_test.php b/web/app_test.php new file mode 100644 index 0000000..83596f9 --- /dev/null +++ b/web/app_test.php @@ -0,0 +1,20 @@ +loadClassCache(); +//$kernel = new AppCache($kernel); + +// When using the HttpCache, you need to call the method in your front controller instead of relying on the configuration parameter +//Request::enableHttpMethodParameterOverride(); +$request = Request::createFromGlobals(); +$response = $kernel->handle($request); +$response->send(); +$kernel->terminate($request, $response); diff --git a/web/apple-touch-icon.png b/web/apple-touch-icon.png new file mode 100644 index 0000000..ca3cebf Binary files /dev/null and b/web/apple-touch-icon.png differ diff --git a/web/assets/.gitkeep b/web/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/config.php b/web/config.php new file mode 100644 index 0000000..fd7e17e --- /dev/null +++ b/web/config.php @@ -0,0 +1,422 @@ +getFailedRequirements(); +$minorProblems = $symfonyRequirements->getFailedRecommendations(); +$hasMajorProblems = (bool) count($majorProblems); +$hasMinorProblems = (bool) count($minorProblems); + +?> + + + + + + Symfony Configuration Checker + + + +
    +
    + + + +
    + +
    +
    +
    +

    Configuration Checker

    +

    + This script analyzes your system to check whether is + ready to run Symfony applications. +

    + + +

    Major problems

    +

    Major problems have been detected and must be fixed before continuing:

    +
      + +
    1. getTestMessage() ?> +

      getHelpHtml() ?>

      +
    2. + +
    + + + +

    Recommendations

    +

    + Additionally, toTo enhance your Symfony experience, + it’s recommended that you fix the following: +

    +
      + +
    1. getTestMessage() ?> +

      getHelpHtml() ?>

      +
    2. + +
    + + + hasPhpIniConfigIssue()): ?> +

    * + getPhpIniConfigPath()): ?> + Changes to the php.ini file must be done in "getPhpIniConfigPath() ?>". + + To change settings, create a "php.ini". + +

    + + + +

    All checks passed successfully. Your system is ready to run Symfony applications.

    + + + +
    +
    +
    +
    Symfony Standard Edition
    +
    + + diff --git a/web/favicon.ico b/web/favicon.ico new file mode 100644 index 0000000..ed134cc Binary files /dev/null and b/web/favicon.ico differ diff --git a/web/fonts/FontAwesome.otf b/web/fonts/FontAwesome.otf new file mode 100644 index 0000000..401ec0f Binary files /dev/null and b/web/fonts/FontAwesome.otf differ diff --git a/web/fonts/fontawesome-webfont.eot b/web/fonts/fontawesome-webfont.eot new file mode 100644 index 0000000..e9f60ca Binary files /dev/null and b/web/fonts/fontawesome-webfont.eot differ diff --git a/web/fonts/fontawesome-webfont.svg b/web/fonts/fontawesome-webfont.svg new file mode 100644 index 0000000..855c845 --- /dev/null +++ b/web/fonts/fontawesome-webfont.svg @@ -0,0 +1,2671 @@ + + + + +Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 + By ,,, +Copyright Dave Gandy 2016. All rights reserved. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/fonts/fontawesome-webfont.ttf b/web/fonts/fontawesome-webfont.ttf new file mode 100644 index 0000000..35acda2 Binary files /dev/null and b/web/fonts/fontawesome-webfont.ttf differ diff --git a/web/fonts/fontawesome-webfont.woff b/web/fonts/fontawesome-webfont.woff new file mode 100644 index 0000000..400014a Binary files /dev/null and b/web/fonts/fontawesome-webfont.woff differ diff --git a/web/fonts/fontawesome-webfont.woff2 b/web/fonts/fontawesome-webfont.woff2 new file mode 100644 index 0000000..4d13fc6 Binary files /dev/null and b/web/fonts/fontawesome-webfont.woff2 differ diff --git a/web/fonts/glyphicons-halflings-regular.eot b/web/fonts/glyphicons-halflings-regular.eot new file mode 100644 index 0000000..b93a495 Binary files /dev/null and b/web/fonts/glyphicons-halflings-regular.eot differ diff --git a/web/fonts/glyphicons-halflings-regular.svg b/web/fonts/glyphicons-halflings-regular.svg new file mode 100644 index 0000000..94fb549 --- /dev/null +++ b/web/fonts/glyphicons-halflings-regular.svg @@ -0,0 +1,288 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/fonts/glyphicons-halflings-regular.ttf b/web/fonts/glyphicons-halflings-regular.ttf new file mode 100644 index 0000000..1413fc6 Binary files /dev/null and b/web/fonts/glyphicons-halflings-regular.ttf differ diff --git a/web/fonts/glyphicons-halflings-regular.woff b/web/fonts/glyphicons-halflings-regular.woff new file mode 100644 index 0000000..9e61285 Binary files /dev/null and b/web/fonts/glyphicons-halflings-regular.woff differ diff --git a/web/fonts/glyphicons-halflings-regular.woff2 b/web/fonts/glyphicons-halflings-regular.woff2 new file mode 100644 index 0000000..64539b5 Binary files /dev/null and b/web/fonts/glyphicons-halflings-regular.woff2 differ diff --git a/web/libs/css/bootstrap-theme.min.css b/web/libs/css/bootstrap-theme.min.css new file mode 100644 index 0000000..5e39401 --- /dev/null +++ b/web/libs/css/bootstrap-theme.min.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap v3.3.7 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */.btn-danger,.btn-default,.btn-info,.btn-primary,.btn-success,.btn-warning{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning:active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-danger.disabled,.btn-danger[disabled],.btn-default.disabled,.btn-default[disabled],.btn-info.disabled,.btn-info[disabled],.btn-primary.disabled,.btn-primary[disabled],.btn-success.disabled,.btn-success[disabled],.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-danger,fieldset[disabled] .btn-default,fieldset[disabled] .btn-info,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-success,fieldset[disabled] .btn-warning{-webkit-box-shadow:none;box-shadow:none}.btn-danger .badge,.btn-default .badge,.btn-info .badge,.btn-primary .badge,.btn-success .badge,.btn-warning .badge{text-shadow:none}.btn.active,.btn:active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-o-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#e0e0e0));background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc}.btn-default:focus,.btn-default:hover{background-color:#e0e0e0;background-position:0 -15px}.btn-default.active,.btn-default:active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#e0e0e0;background-image:none}.btn-primary{background-image:-webkit-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-o-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#265a88));background-image:linear-gradient(to bottom,#337ab7 0,#265a88 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#245580}.btn-primary:focus,.btn-primary:hover{background-color:#265a88;background-position:0 -15px}.btn-primary.active,.btn-primary:active{background-color:#265a88;border-color:#245580}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#265a88;background-image:none}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#419641));background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:focus,.btn-success:hover{background-color:#419641;background-position:0 -15px}.btn-success.active,.btn-success:active{background-color:#419641;border-color:#3e8f3e}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#419641;background-image:none}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#2aabd2));background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:focus,.btn-info:hover{background-color:#2aabd2;background-position:0 -15px}.btn-info.active,.btn-info:active{background-color:#2aabd2;border-color:#28a4c9}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#2aabd2;background-image:none}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#eb9316));background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:focus,.btn-warning:hover{background-color:#eb9316;background-position:0 -15px}.btn-warning.active,.btn-warning:active{background-color:#eb9316;border-color:#e38d13}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#eb9316;background-image:none}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c12e2a));background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:focus,.btn-danger:hover{background-color:#c12e2a;background-position:0 -15px}.btn-danger.active,.btn-danger:active{background-color:#c12e2a;border-color:#b92c28}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#c12e2a;background-image:none}.img-thumbnail,.thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{background-color:#2e6da4;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-o-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#f8f8f8));background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-o-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dbdbdb),to(#e2e2e2));background-image:linear-gradient(to bottom,#dbdbdb 0,#e2e2e2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-o-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#3c3c3c),to(#222));background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-o-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#080808),to(#0f0f0f));background-image:linear-gradient(to bottom,#080808 0,#0f0f0f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#c8e5bc));background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);background-repeat:repeat-x;border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#b9def0));background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);background-repeat:repeat-x;border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#f8efc0));background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);background-repeat:repeat-x;border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-o-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#e7c3c3));background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);background-repeat:repeat-x;border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#ebebeb),to(#f5f5f5));background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x}.progress-bar{background-image:-webkit-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-o-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#286090));background-image:linear-gradient(to bottom,#337ab7 0,#286090 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);background-repeat:repeat-x}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#449d44));background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);background-repeat:repeat-x}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#31b0d5));background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);background-repeat:repeat-x}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#ec971f));background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);background-repeat:repeat-x}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c9302c));background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);background-repeat:repeat-x}.progress-bar-striped{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{text-shadow:0 -1px 0 #286090;background-image:-webkit-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2b669a));background-image:linear-gradient(to bottom,#337ab7 0,#2b669a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);background-repeat:repeat-x;border-color:#2b669a}.list-group-item.active .badge,.list-group-item.active:focus .badge,.list-group-item.active:hover .badge{text-shadow:none}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#d0e9c6));background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);background-repeat:repeat-x}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#c4e3f3));background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);background-repeat:repeat-x}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#faf2cc));background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);background-repeat:repeat-x}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-o-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#ebcccc));background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);background-repeat:repeat-x}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#e8e8e8),to(#f5f5f5));background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x;border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} +/*# sourceMappingURL=bootstrap-theme.min.css.map */ \ No newline at end of file diff --git a/web/libs/css/bootstrap-theme.min.css.map b/web/libs/css/bootstrap-theme.min.css.map new file mode 100644 index 0000000..94813e9 --- /dev/null +++ b/web/libs/css/bootstrap-theme.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["less/theme.less","less/mixins/vendor-prefixes.less","less/mixins/gradients.less","less/mixins/reset-filter.less"],"names":[],"mappings":";;;;AAmBA,YAAA,aAAA,UAAA,aAAA,aAAA,aAME,YAAA,EAAA,KAAA,EAAA,eC2CA,mBAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBDvCR,mBAAA,mBAAA,oBAAA,oBAAA,iBAAA,iBAAA,oBAAA,oBAAA,oBAAA,oBAAA,oBAAA,oBCsCA,mBAAA,MAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,iBDlCR,qBAAA,sBAAA,sBAAA,uBAAA,mBAAA,oBAAA,sBAAA,uBAAA,sBAAA,uBAAA,sBAAA,uBAAA,+BAAA,gCAAA,6BAAA,gCAAA,gCAAA,gCCiCA,mBAAA,KACQ,WAAA,KDlDV,mBAAA,oBAAA,iBAAA,oBAAA,oBAAA,oBAuBI,YAAA,KAyCF,YAAA,YAEE,iBAAA,KAKJ,aErEI,YAAA,EAAA,IAAA,EAAA,KACA,iBAAA,iDACA,iBAAA,4CAAA,iBAAA,qEAEA,iBAAA,+CCnBF,OAAA,+GH4CA,OAAA,0DACA,kBAAA,SAuC2C,aAAA,QAA2B,aAAA,KArCtE,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAgBN,aEtEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAiBN,aEvEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAkBN,UExEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,gBAAA,gBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,iBAAA,iBAEE,iBAAA,QACA,aAAA,QAMA,mBAAA,0BAAA,yBAAA,0BAAA,yBAAA,yBAAA,oBAAA,2BAAA,0BAAA,2BAAA,0BAAA,0BAAA,6BAAA,oCAAA,mCAAA,oCAAA,mCAAA,mCAME,iBAAA,QACA,iBAAA,KAmBN,aEzEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAoBN,YE1EI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,kBAAA,kBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,mBAAA,mBAEE,iBAAA,QACA,aAAA,QAMA,qBAAA,4BAAA,2BAAA,4BAAA,2BAAA,2BAAA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,+BAAA,sCAAA,qCAAA,sCAAA,qCAAA,qCAME,iBAAA,QACA,iBAAA,KA2BN,eAAA,WClCE,mBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,EAAA,IAAA,IAAA,iBD2CV,0BAAA,0BE3FI,iBAAA,QACA,iBAAA,oDACA,iBAAA,+CAAA,iBAAA,wEACA,iBAAA,kDACA,OAAA,+GF0FF,kBAAA,SAEF,yBAAA,+BAAA,+BEhGI,iBAAA,QACA,iBAAA,oDACA,iBAAA,+CAAA,iBAAA,wEACA,iBAAA,kDACA,OAAA,+GFgGF,kBAAA,SASF,gBE7GI,iBAAA,iDACA,iBAAA,4CACA,iBAAA,qEAAA,iBAAA,+CACA,OAAA,+GACA,OAAA,0DCnBF,kBAAA,SH+HA,cAAA,ICjEA,mBAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBD6DV,sCAAA,oCE7GI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SD2CF,mBAAA,MAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,iBD0EV,cAAA,iBAEE,YAAA,EAAA,IAAA,EAAA,sBAIF,gBEhII,iBAAA,iDACA,iBAAA,4CACA,iBAAA,qEAAA,iBAAA,+CACA,OAAA,+GACA,OAAA,0DCnBF,kBAAA,SHkJA,cAAA,IAHF,sCAAA,oCEhII,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SD2CF,mBAAA,MAAA,EAAA,IAAA,IAAA,gBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,gBDgFV,8BAAA,iCAYI,YAAA,EAAA,KAAA,EAAA,gBAKJ,qBAAA,kBAAA,mBAGE,cAAA,EAqBF,yBAfI,mDAAA,yDAAA,yDAGE,MAAA,KE7JF,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,UFqKJ,OACE,YAAA,EAAA,IAAA,EAAA,qBC3HA,mBAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,gBACQ,WAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,gBDsIV,eEtLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAKF,YEvLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAMF,eExLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAOF,cEzLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAeF,UEjMI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFuMJ,cE3MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFwMJ,sBE5MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFyMJ,mBE7MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF0MJ,sBE9MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF2MJ,qBE/MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF+MJ,sBElLI,iBAAA,yKACA,iBAAA,oKACA,iBAAA,iKFyLJ,YACE,cAAA,IC9KA,mBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,EAAA,IAAA,IAAA,iBDgLV,wBAAA,8BAAA,8BAGE,YAAA,EAAA,KAAA,EAAA,QEnOE,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFiOF,aAAA,QALF,+BAAA,qCAAA,qCAQI,YAAA,KAUJ,OCnME,mBAAA,EAAA,IAAA,IAAA,gBACQ,WAAA,EAAA,IAAA,IAAA,gBD4MV,8BE5PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFyPJ,8BE7PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF0PJ,8BE9PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF2PJ,2BE/PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF4PJ,8BEhQI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF6PJ,6BEjQI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFoQJ,MExQI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFsQF,aAAA,QC3NA,mBAAA,MAAA,EAAA,IAAA,IAAA,gBAAA,EAAA,IAAA,EAAA,qBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,gBAAA,EAAA,IAAA,EAAA","sourcesContent":["/*!\n * Bootstrap v3.3.7 (http://getbootstrap.com)\n * Copyright 2011-2016 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n\n//\n// Load core variables and mixins\n// --------------------------------------------------\n\n@import \"variables.less\";\n@import \"mixins.less\";\n\n\n//\n// Buttons\n// --------------------------------------------------\n\n// Common styles\n.btn-default,\n.btn-primary,\n.btn-success,\n.btn-info,\n.btn-warning,\n.btn-danger {\n text-shadow: 0 -1px 0 rgba(0,0,0,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 1px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n // Reset the shadow\n &:active,\n &.active {\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n }\n\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n .box-shadow(none);\n }\n\n .badge {\n text-shadow: none;\n }\n}\n\n// Mixin for generating new styles\n.btn-styles(@btn-color: #555) {\n #gradient > .vertical(@start-color: @btn-color; @end-color: darken(@btn-color, 12%));\n .reset-filter(); // Disable gradients for IE9 because filter bleeds through rounded corners; see https://github.com/twbs/bootstrap/issues/10620\n background-repeat: repeat-x;\n border-color: darken(@btn-color, 14%);\n\n &:hover,\n &:focus {\n background-color: darken(@btn-color, 12%);\n background-position: 0 -15px;\n }\n\n &:active,\n &.active {\n background-color: darken(@btn-color, 12%);\n border-color: darken(@btn-color, 14%);\n }\n\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n &,\n &:hover,\n &:focus,\n &.focus,\n &:active,\n &.active {\n background-color: darken(@btn-color, 12%);\n background-image: none;\n }\n }\n}\n\n// Common styles\n.btn {\n // Remove the gradient for the pressed/active state\n &:active,\n &.active {\n background-image: none;\n }\n}\n\n// Apply the mixin to the buttons\n.btn-default { .btn-styles(@btn-default-bg); text-shadow: 0 1px 0 #fff; border-color: #ccc; }\n.btn-primary { .btn-styles(@btn-primary-bg); }\n.btn-success { .btn-styles(@btn-success-bg); }\n.btn-info { .btn-styles(@btn-info-bg); }\n.btn-warning { .btn-styles(@btn-warning-bg); }\n.btn-danger { .btn-styles(@btn-danger-bg); }\n\n\n//\n// Images\n// --------------------------------------------------\n\n.thumbnail,\n.img-thumbnail {\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n\n\n//\n// Dropdowns\n// --------------------------------------------------\n\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-hover-bg; @end-color: darken(@dropdown-link-hover-bg, 5%));\n background-color: darken(@dropdown-link-hover-bg, 5%);\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n background-color: darken(@dropdown-link-active-bg, 5%);\n}\n\n\n//\n// Navbar\n// --------------------------------------------------\n\n// Default navbar\n.navbar-default {\n #gradient > .vertical(@start-color: lighten(@navbar-default-bg, 10%); @end-color: @navbar-default-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered\n border-radius: @navbar-border-radius;\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 5px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: darken(@navbar-default-link-active-bg, 5%); @end-color: darken(@navbar-default-link-active-bg, 2%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.075));\n }\n}\n.navbar-brand,\n.navbar-nav > li > a {\n text-shadow: 0 1px 0 rgba(255,255,255,.25);\n}\n\n// Inverted navbar\n.navbar-inverse {\n #gradient > .vertical(@start-color: lighten(@navbar-inverse-bg, 10%); @end-color: @navbar-inverse-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered; see https://github.com/twbs/bootstrap/issues/10257\n border-radius: @navbar-border-radius;\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: @navbar-inverse-link-active-bg; @end-color: lighten(@navbar-inverse-link-active-bg, 2.5%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.25));\n }\n\n .navbar-brand,\n .navbar-nav > li > a {\n text-shadow: 0 -1px 0 rgba(0,0,0,.25);\n }\n}\n\n// Undo rounded corners in static and fixed navbars\n.navbar-static-top,\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n border-radius: 0;\n}\n\n// Fix active state of dropdown items in collapsed mode\n@media (max-width: @grid-float-breakpoint-max) {\n .navbar .navbar-nav .open .dropdown-menu > .active > a {\n &,\n &:hover,\n &:focus {\n color: #fff;\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n }\n }\n}\n\n\n//\n// Alerts\n// --------------------------------------------------\n\n// Common styles\n.alert {\n text-shadow: 0 1px 0 rgba(255,255,255,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 2px rgba(0,0,0,.05);\n .box-shadow(@shadow);\n}\n\n// Mixin for generating new styles\n.alert-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 7.5%));\n border-color: darken(@color, 15%);\n}\n\n// Apply the mixin to the alerts\n.alert-success { .alert-styles(@alert-success-bg); }\n.alert-info { .alert-styles(@alert-info-bg); }\n.alert-warning { .alert-styles(@alert-warning-bg); }\n.alert-danger { .alert-styles(@alert-danger-bg); }\n\n\n//\n// Progress bars\n// --------------------------------------------------\n\n// Give the progress background some depth\n.progress {\n #gradient > .vertical(@start-color: darken(@progress-bg, 4%); @end-color: @progress-bg)\n}\n\n// Mixin for generating new styles\n.progress-bar-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 10%));\n}\n\n// Apply the mixin to the progress bars\n.progress-bar { .progress-bar-styles(@progress-bar-bg); }\n.progress-bar-success { .progress-bar-styles(@progress-bar-success-bg); }\n.progress-bar-info { .progress-bar-styles(@progress-bar-info-bg); }\n.progress-bar-warning { .progress-bar-styles(@progress-bar-warning-bg); }\n.progress-bar-danger { .progress-bar-styles(@progress-bar-danger-bg); }\n\n// Reset the striped class because our mixins don't do multiple gradients and\n// the above custom styles override the new `.progress-bar-striped` in v3.2.0.\n.progress-bar-striped {\n #gradient > .striped();\n}\n\n\n//\n// List groups\n// --------------------------------------------------\n\n.list-group {\n border-radius: @border-radius-base;\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n text-shadow: 0 -1px 0 darken(@list-group-active-bg, 10%);\n #gradient > .vertical(@start-color: @list-group-active-bg; @end-color: darken(@list-group-active-bg, 7.5%));\n border-color: darken(@list-group-active-border, 7.5%);\n\n .badge {\n text-shadow: none;\n }\n}\n\n\n//\n// Panels\n// --------------------------------------------------\n\n// Common styles\n.panel {\n .box-shadow(0 1px 2px rgba(0,0,0,.05));\n}\n\n// Mixin for generating new styles\n.panel-heading-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 5%));\n}\n\n// Apply the mixin to the panel headings only\n.panel-default > .panel-heading { .panel-heading-styles(@panel-default-heading-bg); }\n.panel-primary > .panel-heading { .panel-heading-styles(@panel-primary-heading-bg); }\n.panel-success > .panel-heading { .panel-heading-styles(@panel-success-heading-bg); }\n.panel-info > .panel-heading { .panel-heading-styles(@panel-info-heading-bg); }\n.panel-warning > .panel-heading { .panel-heading-styles(@panel-warning-heading-bg); }\n.panel-danger > .panel-heading { .panel-heading-styles(@panel-danger-heading-bg); }\n\n\n//\n// Wells\n// --------------------------------------------------\n\n.well {\n #gradient > .vertical(@start-color: darken(@well-bg, 5%); @end-color: @well-bg);\n border-color: darken(@well-bg, 10%);\n @shadow: inset 0 1px 3px rgba(0,0,0,.05), 0 1px 0 rgba(255,255,255,.1);\n .box-shadow(@shadow);\n}\n","// Vendor Prefixes\n//\n// All vendor mixins are deprecated as of v3.2.0 due to the introduction of\n// Autoprefixer in our Gruntfile. They have been removed in v4.\n\n// - Animations\n// - Backface visibility\n// - Box shadow\n// - Box sizing\n// - Content columns\n// - Hyphens\n// - Placeholder text\n// - Transformations\n// - Transitions\n// - User Select\n\n\n// Animations\n.animation(@animation) {\n -webkit-animation: @animation;\n -o-animation: @animation;\n animation: @animation;\n}\n.animation-name(@name) {\n -webkit-animation-name: @name;\n animation-name: @name;\n}\n.animation-duration(@duration) {\n -webkit-animation-duration: @duration;\n animation-duration: @duration;\n}\n.animation-timing-function(@timing-function) {\n -webkit-animation-timing-function: @timing-function;\n animation-timing-function: @timing-function;\n}\n.animation-delay(@delay) {\n -webkit-animation-delay: @delay;\n animation-delay: @delay;\n}\n.animation-iteration-count(@iteration-count) {\n -webkit-animation-iteration-count: @iteration-count;\n animation-iteration-count: @iteration-count;\n}\n.animation-direction(@direction) {\n -webkit-animation-direction: @direction;\n animation-direction: @direction;\n}\n.animation-fill-mode(@fill-mode) {\n -webkit-animation-fill-mode: @fill-mode;\n animation-fill-mode: @fill-mode;\n}\n\n// Backface visibility\n// Prevent browsers from flickering when using CSS 3D transforms.\n// Default value is `visible`, but can be changed to `hidden`\n\n.backface-visibility(@visibility) {\n -webkit-backface-visibility: @visibility;\n -moz-backface-visibility: @visibility;\n backface-visibility: @visibility;\n}\n\n// Drop shadows\n//\n// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's\n// supported browsers that have box shadow capabilities now support it.\n\n.box-shadow(@shadow) {\n -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1\n box-shadow: @shadow;\n}\n\n// Box sizing\n.box-sizing(@boxmodel) {\n -webkit-box-sizing: @boxmodel;\n -moz-box-sizing: @boxmodel;\n box-sizing: @boxmodel;\n}\n\n// CSS3 Content Columns\n.content-columns(@column-count; @column-gap: @grid-gutter-width) {\n -webkit-column-count: @column-count;\n -moz-column-count: @column-count;\n column-count: @column-count;\n -webkit-column-gap: @column-gap;\n -moz-column-gap: @column-gap;\n column-gap: @column-gap;\n}\n\n// Optional hyphenation\n.hyphens(@mode: auto) {\n word-wrap: break-word;\n -webkit-hyphens: @mode;\n -moz-hyphens: @mode;\n -ms-hyphens: @mode; // IE10+\n -o-hyphens: @mode;\n hyphens: @mode;\n}\n\n// Placeholder text\n.placeholder(@color: @input-color-placeholder) {\n // Firefox\n &::-moz-placeholder {\n color: @color;\n opacity: 1; // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526\n }\n &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+\n &::-webkit-input-placeholder { color: @color; } // Safari and Chrome\n}\n\n// Transformations\n.scale(@ratio) {\n -webkit-transform: scale(@ratio);\n -ms-transform: scale(@ratio); // IE9 only\n -o-transform: scale(@ratio);\n transform: scale(@ratio);\n}\n.scale(@ratioX; @ratioY) {\n -webkit-transform: scale(@ratioX, @ratioY);\n -ms-transform: scale(@ratioX, @ratioY); // IE9 only\n -o-transform: scale(@ratioX, @ratioY);\n transform: scale(@ratioX, @ratioY);\n}\n.scaleX(@ratio) {\n -webkit-transform: scaleX(@ratio);\n -ms-transform: scaleX(@ratio); // IE9 only\n -o-transform: scaleX(@ratio);\n transform: scaleX(@ratio);\n}\n.scaleY(@ratio) {\n -webkit-transform: scaleY(@ratio);\n -ms-transform: scaleY(@ratio); // IE9 only\n -o-transform: scaleY(@ratio);\n transform: scaleY(@ratio);\n}\n.skew(@x; @y) {\n -webkit-transform: skewX(@x) skewY(@y);\n -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+\n -o-transform: skewX(@x) skewY(@y);\n transform: skewX(@x) skewY(@y);\n}\n.translate(@x; @y) {\n -webkit-transform: translate(@x, @y);\n -ms-transform: translate(@x, @y); // IE9 only\n -o-transform: translate(@x, @y);\n transform: translate(@x, @y);\n}\n.translate3d(@x; @y; @z) {\n -webkit-transform: translate3d(@x, @y, @z);\n transform: translate3d(@x, @y, @z);\n}\n.rotate(@degrees) {\n -webkit-transform: rotate(@degrees);\n -ms-transform: rotate(@degrees); // IE9 only\n -o-transform: rotate(@degrees);\n transform: rotate(@degrees);\n}\n.rotateX(@degrees) {\n -webkit-transform: rotateX(@degrees);\n -ms-transform: rotateX(@degrees); // IE9 only\n -o-transform: rotateX(@degrees);\n transform: rotateX(@degrees);\n}\n.rotateY(@degrees) {\n -webkit-transform: rotateY(@degrees);\n -ms-transform: rotateY(@degrees); // IE9 only\n -o-transform: rotateY(@degrees);\n transform: rotateY(@degrees);\n}\n.perspective(@perspective) {\n -webkit-perspective: @perspective;\n -moz-perspective: @perspective;\n perspective: @perspective;\n}\n.perspective-origin(@perspective) {\n -webkit-perspective-origin: @perspective;\n -moz-perspective-origin: @perspective;\n perspective-origin: @perspective;\n}\n.transform-origin(@origin) {\n -webkit-transform-origin: @origin;\n -moz-transform-origin: @origin;\n -ms-transform-origin: @origin; // IE9 only\n transform-origin: @origin;\n}\n\n\n// Transitions\n\n.transition(@transition) {\n -webkit-transition: @transition;\n -o-transition: @transition;\n transition: @transition;\n}\n.transition-property(@transition-property) {\n -webkit-transition-property: @transition-property;\n transition-property: @transition-property;\n}\n.transition-delay(@transition-delay) {\n -webkit-transition-delay: @transition-delay;\n transition-delay: @transition-delay;\n}\n.transition-duration(@transition-duration) {\n -webkit-transition-duration: @transition-duration;\n transition-duration: @transition-duration;\n}\n.transition-timing-function(@timing-function) {\n -webkit-transition-timing-function: @timing-function;\n transition-timing-function: @timing-function;\n}\n.transition-transform(@transition) {\n -webkit-transition: -webkit-transform @transition;\n -moz-transition: -moz-transform @transition;\n -o-transition: -o-transform @transition;\n transition: transform @transition;\n}\n\n\n// User select\n// For selecting text on the page\n\n.user-select(@select) {\n -webkit-user-select: @select;\n -moz-user-select: @select;\n -ms-user-select: @select; // IE10+\n user-select: @select;\n}\n","// Gradients\n\n#gradient {\n\n // Horizontal gradient, from left to right\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .horizontal(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n // Vertical gradient, from top to bottom\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .vertical(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n .directional(@start-color: #555; @end-color: #333; @deg: 45deg) {\n background-repeat: repeat-x;\n background-image: -webkit-linear-gradient(@deg, @start-color, @end-color); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(@deg, @start-color, @end-color); // Opera 12\n background-image: linear-gradient(@deg, @start-color, @end-color); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n }\n .horizontal-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .vertical-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .radial(@inner-color: #555; @outer-color: #333) {\n background-image: -webkit-radial-gradient(circle, @inner-color, @outer-color);\n background-image: radial-gradient(circle, @inner-color, @outer-color);\n background-repeat: no-repeat;\n }\n .striped(@color: rgba(255,255,255,.15); @angle: 45deg) {\n background-image: -webkit-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n }\n}\n","// Reset filters for IE\n//\n// When you need to remove a gradient background, do not forget to use this to reset\n// the IE filter for IE9 and below.\n\n.reset-filter() {\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(enabled = false)\"));\n}\n"]} \ No newline at end of file diff --git a/web/libs/css/bootstrap.min.css b/web/libs/css/bootstrap.min.css new file mode 100644 index 0000000..4f24fc8 --- /dev/null +++ b/web/libs/css/bootstrap.min.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap v3.3.7 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0;font-size:2em}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;-webkit-box-shadow:none!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-plus:before{content:"\002b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:focus,a:hover{color:#23527c;text-decoration:underline}a:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{display:inline-block;max-width:100%;height:auto;padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role=button]{cursor:pointer}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{padding:.2em;background-color:#fcf8e3}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:focus,a.text-primary:hover{color:#286090}.text-success{color:#3c763d}a.text-success:focus,a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:focus,a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:focus,a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:focus,a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:focus,a.bg-primary:hover{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ol,ul{margin-top:0;margin-bottom:10px}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;margin-left:-5px;list-style:none}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}dl{margin-top:0;margin-bottom:20px}dd,dt{line-height:1.42857143}dt{font-weight:700}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;overflow:hidden;clear:left;text-align:right;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[data-original-title],abbr[title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote .small:before,blockquote footer:before,blockquote small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;text-align:right;border-right:5px solid #eee;border-left:0}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:''}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;font-weight:700;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{margin-right:-15px;margin-left:-15px}.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*=col-]{position:static;display:table-column;float:none}table td[class*=col-],table th[class*=col-]{position:static;display:table-cell;float:none}.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{min-height:.01%;overflow-x:auto}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:700}input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=file]:focus,input[type=checkbox]:focus,input[type=radio]:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control::-ms-expand{background-color:transparent;border:0}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type=search]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=time].form-control,input[type=datetime-local].form-control,input[type=month].form-control{line-height:34px}.input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{line-height:30px}.input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox label,.radio label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-top:4px\9;margin-left:-20px}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;font-weight:400;vertical-align:middle;cursor:pointer}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{cursor:not-allowed}.checkbox.disabled label,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .radio label{cursor:not-allowed}.form-control-static{min-height:34px;padding-top:7px;padding-bottom:7px;margin-bottom:0}.form-control-static.input-lg,.form-control-static.input-sm{padding-right:0;padding-left:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.has-success .form-control-feedback{color:#3c763d}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.form-horizontal .control-label{padding-top:7px;margin-bottom:0;text-align:right}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:11px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:400;line-height:1.42857143;text-align:center;white-space:nowrap;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-image:none;border:1px solid transparent;border-radius:4px}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#333;text-decoration:none}.btn.active,.btn:active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none;opacity:.65}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default.focus,.btn-default:focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.dropdown-toggle.btn-default.focus,.open>.dropdown-toggle.btn-default:focus,.open>.dropdown-toggle.btn-default:hover{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled.focus,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled].focus,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.dropdown-toggle.btn-primary.focus,.open>.dropdown-toggle.btn-primary:focus,.open>.dropdown-toggle.btn-primary:hover{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled.focus,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled].focus,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.dropdown-toggle.btn-success.focus,.open>.dropdown-toggle.btn-success:focus,.open>.dropdown-toggle.btn-success:hover{color:#fff;background-color:#398439;border-color:#255625}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled.focus,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled].focus,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.dropdown-toggle.btn-info.focus,.open>.dropdown-toggle.btn-info:focus,.open>.dropdown-toggle.btn-info:hover{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled.focus,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled].focus,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.dropdown-toggle.btn-warning.focus,.open>.dropdown-toggle.btn-warning:focus,.open>.dropdown-toggle.btn-warning:hover{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled.focus,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled].focus,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.dropdown-toggle.btn-danger.focus,.open>.dropdown-toggle.btn-danger:focus,.open>.dropdown-toggle.btn-danger:hover{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled.focus,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled].focus,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{font-weight:400;color:#337ab7;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease;-webkit-transition-duration:.35s;-o-transition-duration:.35s;transition-duration:.35s;-webkit-transition-property:height,visibility;-o-transition-property:height,visibility;transition-property:height,visibility}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown,.dropup{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;text-align:left;list-style:none;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175)}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;background-color:#337ab7;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{right:0;left:auto}.dropdown-menu-left{right:auto;left:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{content:"";border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{right:auto;left:0}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-top-right-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-right:0;padding-left:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group .form-control:focus{z-index:3}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group .form-control,.input-group-addon,.input-group-btn{display:table-cell}.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{padding-right:15px;padding-left:15px;overflow-x:visible;-webkit-overflow-scrolling:touch;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1)}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-right:0;padding-left:0}}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;height:50px;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-top:8px;margin-right:15px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{padding:10px 15px;margin-top:8px;margin-right:-15px;margin-bottom:8px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1)}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-left-radius:0;border-top-right-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}}@media (min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#555;background-color:#e7e7e7}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#080808}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.42857143;color:#337ab7;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:4px;border-bottom-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:2;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:3;color:#fff;cursor:default;background-color:#337ab7;border-color:#337ab7}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:6px;border-bottom-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:3px;border-bottom-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:focus,.label-success[href]:hover{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:middle;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#eee}.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{padding-right:15px;padding-left:15px;border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-right:60px;padding-left:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail a>img,.thumbnail>img{margin-right:auto;margin-left:auto}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#337ab7}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:700}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{overflow:hidden;zoom:1}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{color:#555;text-decoration:none;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#777;cursor:not-allowed;background-color:#eee}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-left-radius:3px;border-top-right-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-left-radius:3px;border-top-right-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px;padding-left:15px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;filter:alpha(opacity=20);opacity:.2}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;filter:alpha(opacity=50);opacity:.5}button.close{-webkit-appearance:none;padding:0;cursor:pointer;background:0 0;border:0}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;overflow:hidden;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transition:-webkit-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out;-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%)}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;outline:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5)}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{filter:alpha(opacity=0);opacity:0}.modal-backdrop.in{filter:alpha(opacity=50);opacity:.5}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:12px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;filter:alpha(opacity=0);opacity:0;line-break:auto}.tooltip.in{filter:alpha(opacity=90);opacity:.9}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px;bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2);line-break:auto}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{padding:8px 14px;margin:0;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{content:"";border-width:10px}.popover.top>.arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,.25);border-bottom-width:0}.popover.top>.arrow:after{bottom:1px;margin-left:-10px;content:" ";border-top-color:#fff;border-bottom-width:0}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,.25);border-left-width:0}.popover.right>.arrow:after{bottom:-10px;left:1px;content:" ";border-right-color:#fff;border-left-width:0}.popover.bottom>.arrow{top:-11px;left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25)}.popover.bottom>.arrow:after{top:1px;margin-left:-10px;content:" ";border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{right:1px;bottom:-10px;content:" ";border-right-width:0;border-left-color:#fff}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>a>img,.carousel-inner>.item>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.active.right,.carousel-inner>.item.next{left:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{left:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{left:0;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);background-color:rgba(0,0,0,0);filter:alpha(opacity=50);opacity:.5}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001)));background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);background-repeat:repeat-x}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5)));background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);background-repeat:repeat-x}.carousel-control:focus,.carousel-control:hover{color:#fff;text-decoration:none;filter:alpha(opacity=90);outline:0;opacity:.9}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;z-index:5;display:inline-block;margin-top:-10px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;font-family:serif;line-height:1}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000\9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-10px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-10px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.modal-header:after,.modal-header:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{display:table;content:" "}.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.modal-header:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-lg,.visible-md,.visible-sm,.visible-xs{display:none!important}.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}}@media (max-width:767px){.visible-xs-block{display:block!important}}@media (max-width:767px){.visible-xs-inline{display:inline!important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}}@media (min-width:1200px){.visible-lg-block{display:block!important}}@media (min-width:1200px){.visible-lg-inline{display:inline!important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media (min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/web/libs/css/bootstrap.min.css.map b/web/libs/css/bootstrap.min.css.map new file mode 100644 index 0000000..6c7fa40 --- /dev/null +++ b/web/libs/css/bootstrap.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["less/normalize.less","less/print.less","bootstrap.css","dist/css/bootstrap.css","less/glyphicons.less","less/scaffolding.less","less/mixins/vendor-prefixes.less","less/mixins/tab-focus.less","less/mixins/image.less","less/type.less","less/mixins/text-emphasis.less","less/mixins/background-variant.less","less/mixins/text-overflow.less","less/code.less","less/grid.less","less/mixins/grid.less","less/mixins/grid-framework.less","less/tables.less","less/mixins/table-row.less","less/forms.less","less/mixins/forms.less","less/buttons.less","less/mixins/buttons.less","less/mixins/opacity.less","less/component-animations.less","less/dropdowns.less","less/mixins/nav-divider.less","less/mixins/reset-filter.less","less/button-groups.less","less/mixins/border-radius.less","less/input-groups.less","less/navs.less","less/navbar.less","less/mixins/nav-vertical-align.less","less/utilities.less","less/breadcrumbs.less","less/pagination.less","less/mixins/pagination.less","less/pager.less","less/labels.less","less/mixins/labels.less","less/badges.less","less/jumbotron.less","less/thumbnails.less","less/alerts.less","less/mixins/alerts.less","less/progress-bars.less","less/mixins/gradients.less","less/mixins/progress-bar.less","less/media.less","less/list-group.less","less/mixins/list-group.less","less/panels.less","less/mixins/panels.less","less/responsive-embed.less","less/wells.less","less/close.less","less/modals.less","less/tooltip.less","less/mixins/reset-text.less","less/popovers.less","less/carousel.less","less/mixins/clearfix.less","less/mixins/center-block.less","less/mixins/hide-text.less","less/responsive-utilities.less","less/mixins/responsive-visibility.less"],"names":[],"mappings":";;;;4EAQA,KACE,YAAA,WACA,yBAAA,KACA,qBAAA,KAOF,KACE,OAAA,EAaF,QAAA,MAAA,QAAA,WAAA,OAAA,OAAA,OAAA,OAAA,KAAA,KAAA,IAAA,QAAA,QAaE,QAAA,MAQF,MAAA,OAAA,SAAA,MAIE,QAAA,aACA,eAAA,SAQF,sBACE,QAAA,KACA,OAAA,EAQF,SAAA,SAEE,QAAA,KAUF,EACE,iBAAA,YAQF,SAAA,QAEE,QAAA,EAUF,YACE,cAAA,IAAA,OAOF,EAAA,OAEE,YAAA,IAOF,IACE,WAAA,OAQF,GACE,OAAA,MAAA,EACA,UAAA,IAOF,KACE,MAAA,KACA,WAAA,KAOF,MACE,UAAA,IAOF,IAAA,IAEE,SAAA,SACA,UAAA,IACA,YAAA,EACA,eAAA,SAGF,IACE,IAAA,MAGF,IACE,OAAA,OAUF,IACE,OAAA,EAOF,eACE,SAAA,OAUF,OACE,OAAA,IAAA,KAOF,GACE,OAAA,EAAA,mBAAA,YAAA,gBAAA,YACA,WAAA,YAOF,IACE,SAAA,KAOF,KAAA,IAAA,IAAA,KAIE,YAAA,UAAA,UACA,UAAA,IAkBF,OAAA,MAAA,SAAA,OAAA,SAKE,OAAA,EACA,KAAA,QACA,MAAA,QAOF,OACE,SAAA,QAUF,OAAA,OAEE,eAAA,KAWF,OAAA,wBAAA,kBAAA,mBAIE,mBAAA,OACA,OAAA,QAOF,iBAAA,qBAEE,OAAA,QAOF,yBAAA,wBAEE,QAAA,EACA,OAAA,EAQF,MACE,YAAA,OAWF,qBAAA,kBAEE,mBAAA,WAAA,gBAAA,WAAA,WAAA,WACA,QAAA,EASF,8CAAA,8CAEE,OAAA,KAQF,mBACE,mBAAA,YACA,gBAAA,YAAA,WAAA,YAAA,mBAAA,UASF,iDAAA,8CAEE,mBAAA,KAOF,SACE,QAAA,MAAA,OAAA,MACA,OAAA,EAAA,IACA,OAAA,IAAA,MAAA,OAQF,OACE,QAAA,EACA,OAAA,EAOF,SACE,SAAA,KAQF,SACE,YAAA,IAUF,MACE,eAAA,EACA,gBAAA,SAGF,GAAA,GAEE,QAAA,uFCjUF,aA7FI,EAAA,OAAA,QAGI,MAAA,eACA,YAAA,eACA,WAAA,cAAA,mBAAA,eACA,WAAA,eAGJ,EAAA,UAEI,gBAAA,UAGJ,cACI,QAAA,KAAA,WAAA,IAGJ,kBACI,QAAA,KAAA,YAAA,IAKJ,6BAAA,mBAEI,QAAA,GAGJ,WAAA,IAEI,OAAA,IAAA,MAAA,KC4KL,kBAAA,MDvKK,MC0KL,QAAA,mBDrKK,IE8KN,GDLC,kBAAA,MDrKK,ICwKL,UAAA,eCUD,GF5KM,GE2KN,EF1KM,QAAA,ECuKL,OAAA,ECSD,GF3KM,GCsKL,iBAAA,MD/JK,QCkKL,QAAA,KCSD,YFtKU,oBCiKT,iBAAA,eD7JK,OCgKL,OAAA,IAAA,MAAA,KD5JK,OC+JL,gBAAA,mBCSD,UFpKU,UC+JT,iBAAA,eDzJS,mBEkKV,mBDLC,OAAA,IAAA,MAAA,gBEjPD,WACA,YAAA,uBFsPD,IAAA,+CE7OC,IAAK,sDAAuD,4BAA6B,iDAAkD,gBAAiB,gDAAiD,eAAgB,+CAAgD,mBAAoB,2EAA4E,cAE7W,WACA,SAAA,SACA,IAAA,IACA,QAAA,aACA,YAAA,uBACA,WAAA,OACA,YAAA,IACA,YAAA,EAIkC,uBAAA,YAAW,wBAAA,UACX,2BAAW,QAAA,QAEX,uBDuPlC,QAAS,QCtPyB,sBFiPnC,uBEjP8C,QAAA,QACX,wBAAW,QAAA,QACX,wBAAW,QAAA,QACX,2BAAW,QAAA,QACX,yBAAW,QAAA,QACX,wBAAW,QAAA,QACX,wBAAW,QAAA,QACX,yBAAW,QAAA,QACX,wBAAW,QAAA,QACX,uBAAW,QAAA,QACX,6BAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,2BAAW,QAAA,QACX,qBAAW,QAAA,QACX,0BAAW,QAAA,QACX,qBAAW,QAAA,QACX,yBAAW,QAAA,QACX,0BAAW,QAAA,QACX,2BAAW,QAAA,QACX,sBAAW,QAAA,QACX,yBAAW,QAAA,QACX,sBAAW,QAAA,QACX,wBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,+BAAW,QAAA,QACX,2BAAW,QAAA,QACX,yBAAW,QAAA,QACX,wBAAW,QAAA,QACX,8BAAW,QAAA,QACX,yBAAW,QAAA,QACX,0BAAW,QAAA,QACX,2BAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,6BAAW,QAAA,QACX,6BAAW,QAAA,QACX,8BAAW,QAAA,QACX,4BAAW,QAAA,QACX,yBAAW,QAAA,QACX,0BAAW,QAAA,QACX,sBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,2BAAW,QAAA,QACX,wBAAW,QAAA,QACX,yBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,yBAAW,QAAA,QACX,8BAAW,QAAA,QACX,6BAAW,QAAA,QACX,6BAAW,QAAA,QACX,+BAAW,QAAA,QACX,8BAAW,QAAA,QACX,gCAAW,QAAA,QACX,uBAAW,QAAA,QACX,8BAAW,QAAA,QACX,+BAAW,QAAA,QACX,iCAAW,QAAA,QACX,0BAAW,QAAA,QACX,6BAAW,QAAA,QACX,yBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,wBAAW,QAAA,QACX,wBAAW,QAAA,QACX,uBAAW,QAAA,QACX,gCAAW,QAAA,QACX,gCAAW,QAAA,QACX,2BAAW,QAAA,QACX,uBAAW,QAAA,QACX,wBAAW,QAAA,QACX,uBAAW,QAAA,QACX,0BAAW,QAAA,QACX,+BAAW,QAAA,QACX,+BAAW,QAAA,QACX,wBAAW,QAAA,QACX,+BAAW,QAAA,QACX,gCAAW,QAAA,QACX,4BAAW,QAAA,QACX,6BAAW,QAAA,QACX,8BAAW,QAAA,QACX,0BAAW,QAAA,QACX,gCAAW,QAAA,QACX,4BAAW,QAAA,QACX,6BAAW,QAAA,QACX,gCAAW,QAAA,QACX,4BAAW,QAAA,QACX,6BAAW,QAAA,QACX,6BAAW,QAAA,QACX,8BAAW,QAAA,QACX,2BAAW,QAAA,QACX,6BAAW,QAAA,QACX,4BAAW,QAAA,QACX,8BAAW,QAAA,QACX,+BAAW,QAAA,QACX,mCAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,2BAAW,QAAA,QACX,4BAAW,QAAA,QACX,+BAAW,QAAA,QACX,wBAAW,QAAA,QACX,2BAAW,QAAA,QACX,yBAAW,QAAA,QACX,0BAAW,QAAA,QACX,yBAAW,QAAA,QACX,6BAAW,QAAA,QACX,+BAAW,QAAA,QACX,0BAAW,QAAA,QACX,gCAAW,QAAA,QACX,+BAAW,QAAA,QACX,8BAAW,QAAA,QACX,kCAAW,QAAA,QACX,oCAAW,QAAA,QACX,sBAAW,QAAA,QACX,2BAAW,QAAA,QACX,uBAAW,QAAA,QACX,8BAAW,QAAA,QACX,4BAAW,QAAA,QACX,8BAAW,QAAA,QACX,6BAAW,QAAA,QACX,4BAAW,QAAA,QACX,0BAAW,QAAA,QACX,4BAAW,QAAA,QACX,qCAAW,QAAA,QACX,oCAAW,QAAA,QACX,kCAAW,QAAA,QACX,oCAAW,QAAA,QACX,wBAAW,QAAA,QACX,yBAAW,QAAA,QACX,wBAAW,QAAA,QACX,yBAAW,QAAA,QACX,4BAAW,QAAA,QACX,6BAAW,QAAA,QACX,4BAAW,QAAA,QACX,4BAAW,QAAA,QACX,8BAAW,QAAA,QACX,uBAAW,QAAA,QACX,wBAAW,QAAA,QACX,0BAAW,QAAA,QACX,sBAAW,QAAA,QACX,sBAAW,QAAA,QACX,uBAAW,QAAA,QACX,mCAAW,QAAA,QACX,uCAAW,QAAA,QACX,gCAAW,QAAA,QACX,oCAAW,QAAA,QACX,qCAAW,QAAA,QACX,yCAAW,QAAA,QACX,4BAAW,QAAA,QACX,yBAAW,QAAA,QACX,gCAAW,QAAA,QACX,8BAAW,QAAA,QACX,yBAAW,QAAA,QACX,wBAAW,QAAA,QACX,0BAAW,QAAA,QACX,6BAAW,QAAA,QACX,yBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,wBAAW,QAAA,QACX,yBAAW,QAAA,QACX,yBAAW,QAAA,QACX,uBAAW,QAAA,QACX,8BAAW,QAAA,QACX,+BAAW,QAAA,QACX,gCAAW,QAAA,QACX,8BAAW,QAAA,QACX,8BAAW,QAAA,QACX,8BAAW,QAAA,QACX,2BAAW,QAAA,QACX,0BAAW,QAAA,QACX,yBAAW,QAAA,QACX,6BAAW,QAAA,QACX,2BAAW,QAAA,QACX,4BAAW,QAAA,QACX,wBAAW,QAAA,QACX,wBAAW,QAAA,QACX,2BAAW,QAAA,QACX,2BAAW,QAAA,QACX,4BAAW,QAAA,QACX,+BAAW,QAAA,QACX,8BAAW,QAAA,QACX,4BAAW,QAAA,QACX,4BAAW,QAAA,QACX,4BAAW,QAAA,QACX,iCAAW,QAAA,QACX,oCAAW,QAAA,QACX,iCAAW,QAAA,QACX,+BAAW,QAAA,QACX,+BAAW,QAAA,QACX,iCAAW,QAAA,QACX,qBAAW,QAAA,QACX,4BAAW,QAAA,QACX,4BAAW,QAAA,QACX,2BAAW,QAAA,QACX,uBAAW,QAAA,QASX,wBAAW,QAAA,QACX,wBAAW,QAAA,QACX,4BAAW,QAAA,QACX,uBAAW,QAAA,QACX,wBAAW,QAAA,QACX,uBAAW,QAAA,QACX,yBAAW,QAAA,QACX,yBAAW,QAAA,QACX,+BAAW,QAAA,QACX,uBAAW,QAAA,QACX,6BAAW,QAAA,QACX,sBAAW,QAAA,QACX,wBAAW,QAAA,QACX,wBAAW,QAAA,QACX,4BAAW,QAAA,QACX,uBAAW,QAAA,QACX,4BAAW,QAAA,QACX,6BAAW,QAAA,QACX,2BAAW,QAAA,QACX,0BAAW,QAAA,QACX,sBAAW,QAAA,QACX,sBAAW,QAAA,QACX,sBAAW,QAAA,QACX,sBAAW,QAAA,QACX,wBAAW,QAAA,QACX,sBAAW,QAAA,QACX,wBAAW,QAAA,QACX,4BAAW,QAAA,QACX,mCAAW,QAAA,QACX,4BAAW,QAAA,QACX,oCAAW,QAAA,QACX,kCAAW,QAAA,QACX,iCAAW,QAAA,QACX,+BAAW,QAAA,QACX,sBAAW,QAAA,QACX,wBAAW,QAAA,QACX,6BAAW,QAAA,QACX,4BAAW,QAAA,QACX,6BAAW,QAAA,QACX,kCAAW,QAAA,QACX,mCAAW,QAAA,QACX,sCAAW,QAAA,QACX,0CAAW,QAAA,QACX,oCAAW,QAAA,QACX,wCAAW,QAAA,QACX,qCAAW,QAAA,QACX,iCAAW,QAAA,QACX,gCAAW,QAAA,QACX,kCAAW,QAAA,QACX,+BAAW,QAAA,QACX,0BAAW,QAAA,QACX,8BAAW,QAAA,QACX,4BAAW,QAAA,QACX,4BAAW,QAAA,QACX,6BAAW,QAAA,QACX,4BAAW,QAAA,QCtS/C,0BCgEE,QAAA,QHi+BF,EDNC,mBAAA,WGxhCI,gBAAiB,WFiiCZ,WAAY,WGl+BZ,OADL,QJg+BJ,mBAAA,WGthCI,gBAAiB,WACpB,WAAA,WHyhCD,KGrhCC,UAAW,KAEX,4BAAA,cAEA,KACA,YAAA,iBAAA,UAAA,MAAA,WHuhCD,UAAA,KGnhCC,YAAa,WF4hCb,MAAO,KACP,iBAAkB,KExhClB,OADA,MAEA,OHqhCD,SG/gCC,YAAa,QACb,UAAA,QACA,YAAA,QAEA,EFwhCA,MAAO,QEthCL,gBAAA,KAIF,QH8gCD,QKjkCC,MAAA,QACA,gBAAA,UF6DF,QACE,QAAA,IAAA,KAAA,yBHygCD,eAAA,KGlgCC,OHqgCD,OAAA,ECSD,IACE,eAAgB,ODDjB,4BM/kCC,0BLklCF,gBKnlCE,iBADA,eH4EA,QAAS,MACT,UAAA,KHugCD,OAAA,KGhgCC,aACA,cAAA,IAEA,eACA,QAAA,aC6FA,UAAA,KACK,OAAA,KACG,QAAA,IEvLR,YAAA,WACA,iBAAA,KACA,OAAA,IAAA,MAAA,KN+lCD,cAAA,IGjgCC,mBAAoB,IAAI,IAAI,YAC5B,cAAA,IAAA,IAAA,YHmgCD,WAAA,IAAA,IAAA,YG5/BC,YACA,cAAA,IAEA,GH+/BD,WAAA,KGv/BC,cAAe,KACf,OAAA,EACA,WAAA,IAAA,MAAA,KAEA,SACA,SAAA,SACA,MAAA,IACA,OAAA,IACA,QAAA,EHy/BD,OAAA,KGj/BC,SAAA,OF0/BA,KAAM,cEx/BJ,OAAA,EAEA,0BACA,yBACA,SAAA,OACA,MAAA,KHm/BH,OAAA,KGx+BC,OAAQ,EACR,SAAA,QH0+BD,KAAA,KCSD,cACE,OAAQ,QAQV,IACA,IMlpCE,IACA,IACA,IACA,INwoCF,GACA,GACA,GACA,GACA,GACA,GDAC,YAAA,QOlpCC,YAAa,IN2pCb,YAAa,IACb,MAAO,QAoBT,WAZA,UAaA,WAZA,UM5pCI,WN6pCJ,UM5pCI,WN6pCJ,UM5pCI,WN6pCJ,UDMC,WCLD,UACA,UAZA,SAaA,UAZA,SAaA,UAZA,SAaA,UAZA,SAaA,UAZA,SAaA,UAZA,SMppCE,YAAa,INwqCb,YAAa,EACb,MAAO,KAGT,IMxqCE,IAJF,IN2qCA,GAEA,GDLC,GCSC,WAAY,KACZ,cAAe,KASjB,WANA,UDCC,WCCD,UM5qCA,WN8qCA,UACA,UANA,SM5qCI,UN8qCJ,SM3qCA,UN6qCA,SAQE,UAAW,IAGb,IMprCE,IAJF,INurCA,GAEA,GDLC,GCSC,WAAY,KACZ,cAAe,KASjB,WANA,UDCC,WCCD,UMvrCA,WNyrCA,UACA,UANA,SMxrCI,UN0rCJ,SMtrCA,UNwrCA,SMxrCU,UAAA,IACV,IAAA,GAAU,UAAA,KACV,IAAA,GAAU,UAAA,KACV,IAAA,GAAU,UAAA,KACV,IAAA,GAAU,UAAA,KACV,IAAA,GAAU,UAAA,KAOR,IADF,GPssCC,UAAA,KCSD,EMzsCE,OAAA,EAAA,EAAA,KAEA,MPosCD,cAAA,KO/rCC,UAAW,KAwOX,YAAa,IA1OX,YAAA,IPssCH,yBO7rCC,MNssCE,UAAW,MMjsCf,OAAA,MAEE,UAAA,IAKF,MP0rCC,KO1rCsB,QAAA,KP6rCtB,iBAAA,QO5rCsB,WP+rCtB,WAAA,KO9rCsB,YPisCtB,WAAA,MOhsCsB,aPmsCtB,WAAA,OOlsCsB,cPqsCtB,WAAA,QOlsCsB,aPqsCtB,YAAA,OOpsCsB,gBPusCtB,eAAA,UOtsCsB,gBPysCtB,eAAA,UOrsCC,iBPwsCD,eAAA,WQ3yCC,YR8yCD,MAAA,KCSD,cOpzCI,MAAA,QAHF,qBDwGF,qBP6sCC,MAAA,QCSD,cO3zCI,MAAA,QAHF,qBD2GF,qBPitCC,MAAA,QCSD,WOl0CI,MAAA,QAHF,kBD8GF,kBPqtCC,MAAA,QCSD,cOz0CI,MAAA,QAHF,qBDiHF,qBPytCC,MAAA,QCSD,aOh1CI,MAAA,QDwHF,oBAHF,oBExHE,MAAA,QACA,YR01CA,MAAO,KQx1CL,iBAAA,QAHF,mBF8HF,mBP2tCC,iBAAA,QCSD,YQ/1CI,iBAAA,QAHF,mBFiIF,mBP+tCC,iBAAA,QCSD,SQt2CI,iBAAA,QAHF,gBFoIF,gBPmuCC,iBAAA,QCSD,YQ72CI,iBAAA,QAHF,mBFuIF,mBPuuCC,iBAAA,QCSD,WQp3CI,iBAAA,QF6IF,kBADF,kBAEE,iBAAA,QPsuCD,aO7tCC,eAAgB,INsuChB,OAAQ,KAAK,EAAE,KMpuCf,cAAA,IAAA,MAAA,KAFF,GPkuCC,GCSC,WAAY,EACZ,cAAe,KM9tCf,MP0tCD,MO3tCD,MAPI,MASF,cAAA,EAIF,eALE,aAAA,EACA,WAAA,KPkuCD,aO9tCC,aAAc,EAKZ,YAAA,KACA,WAAA,KP6tCH,gBOvtCC,QAAS,aACT,cAAA,IACA,aAAA,IAEF,GNguCE,WAAY,EM9tCZ,cAAA,KAGA,GADF,GP0tCC,YAAA,WOttCC,GPytCD,YAAA,IOnnCD,GAvFM,YAAA,EAEA,yBACA,kBGtNJ,MAAA,KACA,MAAA,MACA,SAAA,OVq6CC,MAAA,KO7nCC,WAAY,MAhFV,cAAA,SPgtCH,YAAA,OOtsCD,kBNgtCE,YAAa,OM1sCjB,0BPssCC,YOrsCC,OAAA,KA9IqB,cAAA,IAAA,OAAA,KAmJvB,YACE,UAAA,IACA,eAAA,UAEA,WPssCD,QAAA,KAAA,KOjsCG,OAAA,EAAA,EAAA,KN0sCF,UAAW,OACX,YAAa,IAAI,MAAM,KMptCzB,yBP+sCC,wBO/sCD,yBNytCE,cAAe,EMnsCb,kBAFA,kBACA,iBPksCH,QAAA,MO/rCG,UAAA,INwsCF,YAAa,WACb,MAAO,KMhsCT,yBP2rCC,yBO3rCD,wBAEE,QAAA,cAEA,oBACA,sBACA,cAAA,KP6rCD,aAAA,EOvrCG,WAAA,MNgsCF,aAAc,IAAI,MAAM,KACxB,YAAa,EMhsCX,kCNksCJ,kCMnsCe,iCACX,oCNmsCJ,oCDLC,mCCUC,QAAS,GMjsCX,iCNmsCA,iCMzsCM,gCAOJ,mCNmsCF,mCDLC,kCO7rCC,QAAA,cPksCD,QWv+CC,cAAe,KVg/Cf,WAAY,OACZ,YAAa,WU7+Cb,KXy+CD,IWr+CD,IACE,KACA,YAAA,MAAA,OAAA,SAAA,cAAA,UAEA,KACA,QAAA,IAAA,IXu+CD,UAAA,IWn+CC,MAAO,QACP,iBAAA,QACA,cAAA,IAEA,IACA,QAAA,IAAA,IACA,UAAA,IV4+CA,MU5+CA,KXq+CD,iBAAA,KW3+CC,cAAe,IASb,mBAAA,MAAA,EAAA,KAAA,EAAA,gBACA,WAAA,MAAA,EAAA,KAAA,EAAA,gBAEA,QV6+CF,QU7+CE,EXq+CH,UAAA,KWh+CC,YAAa,IACb,mBAAA,KACA,WAAA,KAEA,IACA,QAAA,MACA,QAAA,MACA,OAAA,EAAA,EAAA,KACA,UAAA,KACA,YAAA,WACA,MAAA,KACA,WAAA,UXk+CD,UAAA,WW7+CC,iBAAkB,QAehB,OAAA,IAAA,MAAA,KACA,cAAA,IAEA,SACA,QAAA,EACA,UAAA,QXi+CH,MAAA,QW59CC,YAAa,SACb,iBAAA,YACA,cAAA,EC1DF,gBCHE,WAAA,MACA,WAAA,OAEA,Wb8hDD,cAAA,KYxhDC,aAAA,KAqEA,aAAc,KAvEZ,YAAA,KZ+hDH,yBY1hDC,WAkEE,MAAO,OZ69CV,yBY5hDC,WA+DE,MAAO,OZk+CV,0BYzhDC,WCvBA,MAAA,QAGA,iBbmjDD,cAAA,KYthDC,aAAc,KCvBd,aAAA,KACA,YAAA,KCAE,KACE,aAAA,MAEA,YAAA,MAGA,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UdgjDL,SAAA,SchiDG,WAAA,IACE,cAAA,KdkiDL,aAAA,Kc1hDG,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,Ud6hDH,MAAA,Kc7hDG,WdgiDH,MAAA,KchiDG,WdmiDH,MAAA,acniDG,WdsiDH,MAAA,actiDG,UdyiDH,MAAA,IcziDG,Ud4iDH,MAAA,ac5iDG,Ud+iDH,MAAA,ac/iDG,UdkjDH,MAAA,IcljDG,UdqjDH,MAAA,acrjDG,UdwjDH,MAAA,acxjDG,Ud2jDH,MAAA,Ic3jDG,Ud8jDH,MAAA,ac/iDG,UdkjDH,MAAA,YcljDG,gBdqjDH,MAAA,KcrjDG,gBdwjDH,MAAA,acxjDG,gBd2jDH,MAAA,ac3jDG,ed8jDH,MAAA,Ic9jDG,edikDH,MAAA,acjkDG,edokDH,MAAA,acpkDG,edukDH,MAAA,IcvkDG,ed0kDH,MAAA,ac1kDG,ed6kDH,MAAA,ac7kDG,edglDH,MAAA,IchlDG,edmlDH,MAAA,ac9kDG,edilDH,MAAA,YchmDG,edmmDH,MAAA,KcnmDG,gBdsmDH,KAAA,KctmDG,gBdymDH,KAAA,aczmDG,gBd4mDH,KAAA,ac5mDG,ed+mDH,KAAA,Ic/mDG,edknDH,KAAA,aclnDG,edqnDH,KAAA,acrnDG,edwnDH,KAAA,IcxnDG,ed2nDH,KAAA,ac3nDG,ed8nDH,KAAA,ac9nDG,edioDH,KAAA,IcjoDG,edooDH,KAAA,ac/nDG,edkoDH,KAAA,YcnnDG,edsnDH,KAAA,KctnDG,kBdynDH,YAAA,KcznDG,kBd4nDH,YAAA,ac5nDG,kBd+nDH,YAAA,ac/nDG,iBdkoDH,YAAA,IcloDG,iBdqoDH,YAAA,acroDG,iBdwoDH,YAAA,acxoDG,iBd2oDH,YAAA,Ic3oDG,iBd8oDH,YAAA,ac9oDG,iBdipDH,YAAA,acjpDG,iBdopDH,YAAA,IcppDG,iBdupDH,YAAA,acvpDG,iBd0pDH,YAAA,Yc5rDG,iBACE,YAAA,EAOJ,yBACE,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,Ud0rDD,MAAA,Kc1rDC,Wd6rDD,MAAA,Kc7rDC,WdgsDD,MAAA,achsDC,WdmsDD,MAAA,acnsDC,UdssDD,MAAA,IctsDC,UdysDD,MAAA,aczsDC,Ud4sDD,MAAA,ac5sDC,Ud+sDD,MAAA,Ic/sDC,UdktDD,MAAA,acltDC,UdqtDD,MAAA,acrtDC,UdwtDD,MAAA,IcxtDC,Ud2tDD,MAAA,ac5sDC,Ud+sDD,MAAA,Yc/sDC,gBdktDD,MAAA,KcltDC,gBdqtDD,MAAA,acrtDC,gBdwtDD,MAAA,acxtDC,ed2tDD,MAAA,Ic3tDC,ed8tDD,MAAA,ac9tDC,ediuDD,MAAA,acjuDC,edouDD,MAAA,IcpuDC,eduuDD,MAAA,acvuDC,ed0uDD,MAAA,ac1uDC,ed6uDD,MAAA,Ic7uDC,edgvDD,MAAA,ac3uDC,ed8uDD,MAAA,Yc7vDC,edgwDD,MAAA,KchwDC,gBdmwDD,KAAA,KcnwDC,gBdswDD,KAAA,actwDC,gBdywDD,KAAA,aczwDC,ed4wDD,KAAA,Ic5wDC,ed+wDD,KAAA,ac/wDC,edkxDD,KAAA,aclxDC,edqxDD,KAAA,IcrxDC,edwxDD,KAAA,acxxDC,ed2xDD,KAAA,ac3xDC,ed8xDD,KAAA,Ic9xDC,ediyDD,KAAA,ac5xDC,ed+xDD,KAAA,YchxDC,edmxDD,KAAA,KcnxDC,kBdsxDD,YAAA,KctxDC,kBdyxDD,YAAA,aczxDC,kBd4xDD,YAAA,ac5xDC,iBd+xDD,YAAA,Ic/xDC,iBdkyDD,YAAA,aclyDC,iBdqyDD,YAAA,acryDC,iBdwyDD,YAAA,IcxyDC,iBd2yDD,YAAA,ac3yDC,iBd8yDD,YAAA,ac9yDC,iBdizDD,YAAA,IcjzDC,iBdozDD,YAAA,acpzDC,iBduzDD,YAAA,YY9yDD,iBE3CE,YAAA,GAQF,yBACE,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,Udw1DD,MAAA,Kcx1DC,Wd21DD,MAAA,Kc31DC,Wd81DD,MAAA,ac91DC,Wdi2DD,MAAA,acj2DC,Udo2DD,MAAA,Icp2DC,Udu2DD,MAAA,acv2DC,Ud02DD,MAAA,ac12DC,Ud62DD,MAAA,Ic72DC,Udg3DD,MAAA,ach3DC,Udm3DD,MAAA,acn3DC,Uds3DD,MAAA,Ict3DC,Udy3DD,MAAA,ac12DC,Ud62DD,MAAA,Yc72DC,gBdg3DD,MAAA,Kch3DC,gBdm3DD,MAAA,acn3DC,gBds3DD,MAAA,act3DC,edy3DD,MAAA,Icz3DC,ed43DD,MAAA,ac53DC,ed+3DD,MAAA,ac/3DC,edk4DD,MAAA,Icl4DC,edq4DD,MAAA,acr4DC,edw4DD,MAAA,acx4DC,ed24DD,MAAA,Ic34DC,ed84DD,MAAA,acz4DC,ed44DD,MAAA,Yc35DC,ed85DD,MAAA,Kc95DC,gBdi6DD,KAAA,Kcj6DC,gBdo6DD,KAAA,acp6DC,gBdu6DD,KAAA,acv6DC,ed06DD,KAAA,Ic16DC,ed66DD,KAAA,ac76DC,edg7DD,KAAA,ach7DC,edm7DD,KAAA,Icn7DC,eds7DD,KAAA,act7DC,edy7DD,KAAA,acz7DC,ed47DD,KAAA,Ic57DC,ed+7DD,KAAA,ac17DC,ed67DD,KAAA,Yc96DC,edi7DD,KAAA,Kcj7DC,kBdo7DD,YAAA,Kcp7DC,kBdu7DD,YAAA,acv7DC,kBd07DD,YAAA,ac17DC,iBd67DD,YAAA,Ic77DC,iBdg8DD,YAAA,ach8DC,iBdm8DD,YAAA,acn8DC,iBds8DD,YAAA,Ict8DC,iBdy8DD,YAAA,acz8DC,iBd48DD,YAAA,ac58DC,iBd+8DD,YAAA,Ic/8DC,iBdk9DD,YAAA,acl9DC,iBdq9DD,YAAA,YYz8DD,iBE9CE,YAAA,GAQF,0BACE,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,Uds/DD,MAAA,Kct/DC,Wdy/DD,MAAA,Kcz/DC,Wd4/DD,MAAA,ac5/DC,Wd+/DD,MAAA,ac//DC,UdkgED,MAAA,IclgEC,UdqgED,MAAA,acrgEC,UdwgED,MAAA,acxgEC,Ud2gED,MAAA,Ic3gEC,Ud8gED,MAAA,ac9gEC,UdihED,MAAA,acjhEC,UdohED,MAAA,IcphEC,UduhED,MAAA,acxgEC,Ud2gED,MAAA,Yc3gEC,gBd8gED,MAAA,Kc9gEC,gBdihED,MAAA,acjhEC,gBdohED,MAAA,acphEC,eduhED,MAAA,IcvhEC,ed0hED,MAAA,ac1hEC,ed6hED,MAAA,ac7hEC,edgiED,MAAA,IchiEC,edmiED,MAAA,acniEC,edsiED,MAAA,actiEC,edyiED,MAAA,IcziEC,ed4iED,MAAA,acviEC,ed0iED,MAAA,YczjEC,ed4jED,MAAA,Kc5jEC,gBd+jED,KAAA,Kc/jEC,gBdkkED,KAAA,aclkEC,gBdqkED,KAAA,acrkEC,edwkED,KAAA,IcxkEC,ed2kED,KAAA,ac3kEC,ed8kED,KAAA,ac9kEC,edilED,KAAA,IcjlEC,edolED,KAAA,acplEC,edulED,KAAA,acvlEC,ed0lED,KAAA,Ic1lEC,ed6lED,KAAA,acxlEC,ed2lED,KAAA,Yc5kEC,ed+kED,KAAA,Kc/kEC,kBdklED,YAAA,KcllEC,kBdqlED,YAAA,acrlEC,kBdwlED,YAAA,acxlEC,iBd2lED,YAAA,Ic3lEC,iBd8lED,YAAA,ac9lEC,iBdimED,YAAA,acjmEC,iBdomED,YAAA,IcpmEC,iBdumED,YAAA,acvmEC,iBd0mED,YAAA,ac1mEC,iBd6mED,YAAA,Ic7mEC,iBdgnED,YAAA,achnEC,iBdmnED,YAAA,YetrED,iBACA,YAAA,GAGA,MACA,iBAAA,YAEA,QfyrED,YAAA,IevrEC,eAAgB,IAChB,MAAA,KfyrED,WAAA,KelrEC,GACA,WAAA,KfsrED,OexrEC,MAAO,KdmsEP,UAAW,KACX,cAAe,KcvrET,mBd0rER,mBczrEQ,mBAHA,mBACA,mBd0rER,mBDHC,QAAA,IensEC,YAAa,WAoBX,eAAA,IACA,WAAA,IAAA,MAAA,KArBJ,mBdktEE,eAAgB,OAChB,cAAe,IAAI,MAAM,KDJ1B,uCCMD,uCcrtEA,wCdstEA,wCclrEI,2CANI,2CforEP,WAAA,EezqEG,mBf4qEH,WAAA,IAAA,MAAA,KCWD,cACE,iBAAkB,Kc/pEpB,6BdkqEA,6BcjqEE,6BAZM,6BfsqEP,6BCMD,6BDHC,QAAA,ICWD,gBACE,OAAQ,IAAI,MAAM,Kc1qEpB,4Bd6qEA,4Bc7qEA,4BAQQ,4Bf8pEP,4BCMD,4Bc7pEM,OAAA,IAAA,MAAA,KAYF,4BAFJ,4BfopEC,oBAAA,IevoEG,yCf0oEH,iBAAA,QehoEC,4BACA,iBAAA,QfooED,uBe9nEG,SAAA,OdyoEF,QAAS,acxoEL,MAAA,KAEA,sBfioEL,sBgB7wEC,SAAA,OfwxEA,QAAS,WACT,MAAO,KAST,0BerxEE,0Bf+wEF,0BAGA,0BexxEM,0BAMJ,0BfgxEF,0BAGA,0BACA,0BDNC,0BCAD,0BAGA,0BASE,iBAAkB,QDLnB,sCgBlyEC,sCAAA,oCfyyEF,sCetxEM,sCf2xEJ,iBAAkB,QASpB,2Be1yEE,2BfoyEF,2BAGA,2Be7yEM,2BAMJ,2BfqyEF,2BAGA,2BACA,2BDNC,2BCAD,2BAGA,2BASE,iBAAkB,QDLnB,uCgBvzEC,uCAAA,qCf8zEF,uCe3yEM,uCfgzEJ,iBAAkB,QASpB,wBe/zEE,wBfyzEF,wBAGA,wBel0EM,wBAMJ,wBf0zEF,wBAGA,wBACA,wBDNC,wBCAD,wBAGA,wBASE,iBAAkB,QDLnB,oCgB50EC,oCAAA,kCfm1EF,oCeh0EM,oCfq0EJ,iBAAkB,QASpB,2Bep1EE,2Bf80EF,2BAGA,2Bev1EM,2BAMJ,2Bf+0EF,2BAGA,2BACA,2BDNC,2BCAD,2BAGA,2BASE,iBAAkB,QDLnB,uCgBj2EC,uCAAA,qCfw2EF,uCer1EM,uCf01EJ,iBAAkB,QASpB,0Bez2EE,0Bfm2EF,0BAGA,0Be52EM,0BAMJ,0Bfo2EF,0BAGA,0BACA,0BDNC,0BCAD,0BAGA,0BASE,iBAAkB,QDLnB,sCehtEC,sCADF,oCdwtEA,sCe12EM,sCDoJJ,iBAAA,QA6DF,kBACE,WAAY,KA3DV,WAAA,KAEA,oCACA,kBACA,MAAA,KfotED,cAAA,Ke7pEC,WAAY,OAnDV,mBAAA,yBfmtEH,OAAA,IAAA,MAAA,KCWD,yBACE,cAAe,Ec5qEjB,qCd+qEA,qCcjtEI,qCARM,qCfktET,qCCMD,qCDHC,YAAA,OCWD,kCACE,OAAQ,EcvrEV,0Dd0rEA,0Dc1rEA,0DAzBU,0Df4sET,0DCMD,0DAME,YAAa,Ec/rEf,yDdksEA,yDclsEA,yDArBU,yDfgtET,yDCMD,yDAME,aAAc,EDLjB,yDe1sEW,yDEzNV,yDjBk6EC,yDiBj6ED,cAAA,GAMA,SjBk6ED,UAAA,EiB/5EC,QAAS,EACT,OAAA,EACA,OAAA,EAEA,OACA,QAAA,MACA,MAAA,KACA,QAAA,EACA,cAAA,KACA,UAAA,KjBi6ED,YAAA,QiB95EC,MAAO,KACP,OAAA,EACA,cAAA,IAAA,MAAA,QAEA,MjBg6ED,QAAA,aiBr5EC,UAAW,Kb4BX,cAAA,IACG,YAAA,IJ63EJ,mBiBr5EC,mBAAoB,WhBg6EjB,gBAAiB,WgB95EpB,WAAA,WjBy5ED,qBiBv5EC,kBAGA,OAAQ,IAAI,EAAE,EACd,WAAA,MjBs5ED,YAAA,OiBj5EC,iBACA,QAAA,MAIF,kBhB25EE,QAAS,MgBz5ET,MAAA,KAIF,iBAAA,ahB05EE,OAAQ,KI99ER,uBY2EF,2BjB64EC,wBiB54EC,QAAA,IAAA,KAAA,yBACA,eAAA,KAEA,OACA,QAAA,MjB+4ED,YAAA,IiBr3EC,UAAW,KACX,YAAA,WACA,MAAA,KAEA,cACA,QAAA,MACA,MAAA,KACA,OAAA,KACA,QAAA,IAAA,KACA,UAAA,KACA,YAAA,WACA,MAAA,KbxDA,iBAAA,KACQ,iBAAA,KAyHR,OAAA,IAAA,MAAA,KACK,cAAA,IACG,mBAAA,MAAA,EAAA,IAAA,IAAA,iBJwzET,WAAA,MAAA,EAAA,IAAA,IAAA,iBkBh8EC,mBAAA,aAAA,YAAA,KAAA,mBAAA,YAAA,KACE,cAAA,aAAA,YAAA,KAAA,WAAA,YAAA,KACA,WAAA,aAAA,YAAA,KAAA,WAAA,YAAA,KdWM,oBJy7ET,aAAA,QIx5EC,QAAA,EACE,mBAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,qBACA,WAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,qBAEF,gCAA0B,MAAA,KJ25E3B,QAAA,EI15EiC,oCJ65EjC,MAAA,KiBh4EG,yCACA,MAAA,KAQF,0BhBs4EA,iBAAkB,YAClB,OAAQ,EgBn4EN,wBjB63EH,wBiB13EC,iChBq4EA,iBAAkB,KgBn4EhB,QAAA,EAIF,wBACE,iCjB03EH,OAAA,YiB72EC,sBjBg3ED,OAAA,KiB91EG,mBhB02EF,mBAAoB,KAEtB,qDgB32EM,8BjBo2EH,8BiBj2EC,wCAAA,+BhB62EA,YAAa,KgB32EX,iCjBy2EH,iCiBt2EC,2CAAA,kChB02EF,0BACA,0BACA,oCACA,2BAKE,YAAa,KgBh3EX,iCjB82EH,iCACF,2CiBp2EC,kChBu2EA,0BACA,0BACA,oCACA,2BgBz2EA,YAAA,MhBi3EF,YgBv2EE,cAAA,KAGA,UADA,OjBi2ED,SAAA,SiBr2EC,QAAS,MhBg3ET,WAAY,KgBx2EV,cAAA,KAGA,gBADA,aAEA,WAAA,KjBi2EH,aAAA,KiB91EC,cAAe,EhBy2Ef,YAAa,IACb,OAAQ,QgBp2ER,+BjBg2ED,sCiBl2EC,yBACA,gCAIA,SAAU,ShBw2EV,WAAY,MgBt2EZ,YAAA,MAIF,oBAAA,cAEE,WAAA,KAGA,iBADA,cAEA,SAAA,SACA,QAAA,aACA,aAAA,KjB61ED,cAAA,EiB31EC,YAAa,IhBs2Eb,eAAgB,OgBp2EhB,OAAA,QAUA,kCjBo1ED,4BCWC,WAAY,EACZ,YAAa,KgBv1Eb,wCAAA,qCjBm1ED,8BCOD,+BgBh2EI,2BhB+1EJ,4BAME,OAAQ,YDNT,0BiBv1EG,uBAMF,oCAAA,iChB61EA,OAAQ,YDNT,yBiBp1EK,sBAaJ,mCAFF,gCAGE,OAAA,YAGA,qBjBy0ED,WAAA,KiBv0EC,YAAA,IhBk1EA,eAAgB,IgBh1Ed,cAAA,EjB00EH,8BiB5zED,8BCnQE,cAAA,EACA,aAAA,EAEA,UACA,OAAA,KlBkkFD,QAAA,IAAA,KkBhkFC,UAAA,KACE,YAAA,IACA,cAAA,IAGF,gBjB0kFA,OAAQ,KiBxkFN,YAAA,KD2PA,0BAFJ,kBAGI,OAAA,KAEA,6BACA,OAAA,KjBy0EH,QAAA,IAAA,KiB/0EC,UAAW,KAST,YAAA,IACA,cAAA,IAVJ,mChB81EE,OAAQ,KgBh1EN,YAAA,KAGA,6CAjBJ,qCAkBI,OAAA,KAEA,oCACA,OAAA,KjBy0EH,WAAA,KiBr0EC,QAAS,IAAI,KC/Rb,UAAA,KACA,YAAA,IAEA,UACA,OAAA,KlBumFD,QAAA,KAAA,KkBrmFC,UAAA,KACE,YAAA,UACA,cAAA,IAGF,gBjB+mFA,OAAQ,KiB7mFN,YAAA,KDuRA,0BAFJ,kBAGI,OAAA,KAEA,6BACA,OAAA,KjBk1EH,QAAA,KAAA,KiBx1EC,UAAW,KAST,YAAA,UACA,cAAA,IAVJ,mChBu2EE,OAAQ,KgBz1EN,YAAA,KAGA,6CAjBJ,qCAkBI,OAAA,KAEA,oCACA,OAAA,KjBk1EH,WAAA,KiBz0EC,QAAS,KAAK,KAEd,UAAA,KjB00ED,YAAA,UiBt0EG,cjBy0EH,SAAA,SiBp0EC,4BACA,cAAA,OAEA,uBACA,SAAA,SACA,IAAA,EACA,MAAA,EACA,QAAA,EACA,QAAA,MACA,MAAA,KjBu0ED,OAAA,KiBr0EC,YAAa,KhBg1Eb,WAAY,OACZ,eAAgB,KDLjB,oDiBv0EC,uCADA,iCAGA,MAAO,KhBg1EP,OAAQ,KACR,YAAa,KDLd,oDiBv0EC,uCADA,iCAKA,MAAO,KhB80EP,OAAQ,KACR,YAAa,KAKf,uBAEA,8BAJA,4BADA,yBAEA,oBAEA,2BDNC,4BkBruFG,mCAJA,yBD0ZJ,gCbvWE,MAAA,QJ2rFD,2BkBxuFG,aAAA,QACE,mBAAA,MAAA,EAAA,IAAA,IAAA,iBd4CJ,WAAA,MAAA,EAAA,IAAA,IAAA,iBJgsFD,iCiBz1EC,aAAc,QC5YZ,mBAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,QACA,WAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,QlByuFH,gCiB91EC,MAAO,QCtYL,iBAAA,QlBuuFH,aAAA,QCWD,oCACE,MAAO,QAKT,uBAEA,8BAJA,4BADA,yBAEA,oBAEA,2BDNC,4BkBnwFG,mCAJA,yBD6ZJ,gCb1WE,MAAA,QJytFD,2BkBtwFG,aAAA,QACE,mBAAA,MAAA,EAAA,IAAA,IAAA,iBd4CJ,WAAA,MAAA,EAAA,IAAA,IAAA,iBJ8tFD,iCiBp3EC,aAAc,QC/YZ,mBAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,QACA,WAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,QlBuwFH,gCiBz3EC,MAAO,QCzYL,iBAAA,QlBqwFH,aAAA,QCWD,oCACE,MAAO,QAKT,qBAEA,4BAJA,0BADA,uBAEA,kBAEA,yBDNC,0BkBjyFG,iCAJA,uBDgaJ,8Bb7WE,MAAA,QJuvFD,yBkBpyFG,aAAA,QACE,mBAAA,MAAA,EAAA,IAAA,IAAA,iBd4CJ,WAAA,MAAA,EAAA,IAAA,IAAA,iBJ4vFD,+BiB/4EC,aAAc,QClZZ,mBAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,QACA,WAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,QlBqyFH,8BiBp5EC,MAAO,QC5YL,iBAAA,QlBmyFH,aAAA,QiB/4EG,kCjBk5EH,MAAA,QiB/4EG,2CjBk5EH,IAAA,KiBv4EC,mDACA,IAAA,EAEA,YjB04ED,QAAA,MiBvzEC,WAAY,IAwEZ,cAAe,KAtIX,MAAA,QAEA,yBjBy3EH,yBiBrvEC,QAAS,aA/HP,cAAA,EACA,eAAA,OjBw3EH,2BiB1vEC,QAAS,aAxHP,MAAA,KjBq3EH,eAAA,OiBj3EG,kCACA,QAAA,aAmHJ,0BhB4wEE,QAAS,aACT,eAAgB,OgBr3Ed,wCjB82EH,6CiBtwED,2CjBywEC,MAAA,KiB72EG,wCACA,MAAA,KAmGJ,4BhBwxEE,cAAe,EgBp3Eb,eAAA,OAGA,uBADA,oBjB82EH,QAAA,aiBpxEC,WAAY,EhB+xEZ,cAAe,EgBr3EX,eAAA,OAsFN,6BAAA,0BAjFI,aAAA,EAiFJ,4CjB6xEC,sCiBx2EG,SAAA,SjB22EH,YAAA,EiBh2ED,kDhB42EE,IAAK,GgBl2EL,2BjB+1EH,kCiBh2EG,wBAEA,+BAXF,YAAa,IhBo3Eb,WAAY,EgBn2EV,cAAA,EJviBF,2BIshBF,wBJrhBE,WAAA,KI4jBA,6BAyBA,aAAc,MAnCV,YAAA,MAEA,yBjBw1EH,gCACF,YAAA,IiBx3EG,cAAe,EAwCf,WAAA,OAwBJ,sDAdQ,MAAA,KjB80EL,yBACF,+CiBn0EC,YAAA,KAEE,UAAW,MjBs0EZ,yBACF,+CmBp6FG,YAAa,IACf,UAAA,MAGA,KACA,QAAA,aACA,QAAA,IAAA,KAAA,cAAA,EACA,UAAA,KACA,YAAA,IACA,YAAA,WACA,WAAA,OC0CA,YAAA,OACA,eAAA,OACA,iBAAA,aACA,aAAA,ahB+JA,OAAA,QACG,oBAAA,KACC,iBAAA,KACI,gBAAA,KJ+tFT,YAAA,KmBv6FG,iBAAA,KlBm7FF,OAAQ,IAAI,MAAM,YAClB,cAAe,IkB96Ff,kBdzBA,kBACA,WLk8FD,kBCOD,kBADA,WAME,QAAS,IAAI,KAAK,yBAClB,eAAgB,KkBh7FhB,WnBy6FD,WmB56FG,WlBw7FF,MAAO,KkBn7FL,gBAAA,Kf6BM,YADR,YJk5FD,iBAAA,KmBz6FC,QAAA,ElBq7FA,mBAAoB,MAAM,EAAE,IAAI,IAAI,iBAC5B,WAAY,MAAM,EAAE,IAAI,IAAI,iBoBh+FpC,cAGA,ejB8DA,wBACQ,OAAA,YJ05FT,OAAA,kBmBz6FG,mBAAA,KlBq7FM,WAAY,KkBn7FhB,QAAA,IASN,eC3DE,yBACA,eAAA,KpBi+FD,aoB99FC,MAAA,KnB0+FA,iBAAkB,KmBx+FhB,aAAA,KpBk+FH,mBoBh+FO,mBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpBi+FH,mBoB99FC,MAAA,KnB0+FA,iBAAkB,QAClB,aAAc,QmBt+FR,oBADJ,oBpBi+FH,mCoB99FG,MAAA,KnB0+FF,iBAAkB,QAClB,aAAc,QmBt+FN,0BnB4+FV,0BAHA,0BmB1+FM,0BnB4+FN,0BAHA,0BDFC,yCoBx+FK,yCnB4+FN,yCmBv+FE,MAAA,KnB++FA,iBAAkB,QAClB,aAAc,QmBx+FZ,oBpBg+FH,oBoBh+FG,mCnB6+FF,iBAAkB,KmBz+FV,4BnB8+FV,4BAHA,4BDHC,6BCOD,6BAHA,6BkB39FA,sCClBM,sCnB8+FN,sCmBx+FI,iBAAA,KACA,aAAA,KDcJ,oBC9DE,MAAA,KACA,iBAAA,KpB0hGD,aoBvhGC,MAAA,KnBmiGA,iBAAkB,QmBjiGhB,aAAA,QpB2hGH,mBoBzhGO,mBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpB0hGH,mBoBvhGC,MAAA,KnBmiGA,iBAAkB,QAClB,aAAc,QmB/hGR,oBADJ,oBpB0hGH,mCoBvhGG,MAAA,KnBmiGF,iBAAkB,QAClB,aAAc,QmB/hGN,0BnBqiGV,0BAHA,0BmBniGM,0BnBqiGN,0BAHA,0BDFC,yCoBjiGK,yCnBqiGN,yCmBhiGE,MAAA,KnBwiGA,iBAAkB,QAClB,aAAc,QmBjiGZ,oBpByhGH,oBoBzhGG,mCnBsiGF,iBAAkB,KmBliGV,4BnBuiGV,4BAHA,4BDHC,6BCOD,6BAHA,6BkBjhGA,sCCrBM,sCnBuiGN,sCmBjiGI,iBAAA,QACA,aAAA,QDkBJ,oBClEE,MAAA,QACA,iBAAA,KpBmlGD,aoBhlGC,MAAA,KnB4lGA,iBAAkB,QmB1lGhB,aAAA,QpBolGH,mBoBllGO,mBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpBmlGH,mBoBhlGC,MAAA,KnB4lGA,iBAAkB,QAClB,aAAc,QmBxlGR,oBADJ,oBpBmlGH,mCoBhlGG,MAAA,KnB4lGF,iBAAkB,QAClB,aAAc,QmBxlGN,0BnB8lGV,0BAHA,0BmB5lGM,0BnB8lGN,0BAHA,0BDFC,yCoB1lGK,yCnB8lGN,yCmBzlGE,MAAA,KnBimGA,iBAAkB,QAClB,aAAc,QmB1lGZ,oBpBklGH,oBoBllGG,mCnB+lGF,iBAAkB,KmB3lGV,4BnBgmGV,4BAHA,4BDHC,6BCOD,6BAHA,6BkBtkGA,sCCzBM,sCnBgmGN,sCmB1lGI,iBAAA,QACA,aAAA,QDsBJ,oBCtEE,MAAA,QACA,iBAAA,KpB4oGD,UoBzoGC,MAAA,KnBqpGA,iBAAkB,QmBnpGhB,aAAA,QpB6oGH,gBoB3oGO,gBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpB4oGH,gBoBzoGC,MAAA,KnBqpGA,iBAAkB,QAClB,aAAc,QmBjpGR,iBADJ,iBpB4oGH,gCoBzoGG,MAAA,KnBqpGF,iBAAkB,QAClB,aAAc,QmBjpGN,uBnBupGV,uBAHA,uBmBrpGM,uBnBupGN,uBAHA,uBDFC,sCoBnpGK,sCnBupGN,sCmBlpGE,MAAA,KnB0pGA,iBAAkB,QAClB,aAAc,QmBnpGZ,iBpB2oGH,iBoB3oGG,gCnBwpGF,iBAAkB,KmBppGV,yBnBypGV,yBAHA,yBDHC,0BCOD,0BAHA,0BkB3nGA,mCC7BM,mCnBypGN,mCmBnpGI,iBAAA,QACA,aAAA,QD0BJ,iBC1EE,MAAA,QACA,iBAAA,KpBqsGD,aoBlsGC,MAAA,KnB8sGA,iBAAkB,QmB5sGhB,aAAA,QpBssGH,mBoBpsGO,mBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpBqsGH,mBoBlsGC,MAAA,KnB8sGA,iBAAkB,QAClB,aAAc,QmB1sGR,oBADJ,oBpBqsGH,mCoBlsGG,MAAA,KnB8sGF,iBAAkB,QAClB,aAAc,QmB1sGN,0BnBgtGV,0BAHA,0BmB9sGM,0BnBgtGN,0BAHA,0BDFC,yCoB5sGK,yCnBgtGN,yCmB3sGE,MAAA,KnBmtGA,iBAAkB,QAClB,aAAc,QmB5sGZ,oBpBosGH,oBoBpsGG,mCnBitGF,iBAAkB,KmB7sGV,4BnBktGV,4BAHA,4BDHC,6BCOD,6BAHA,6BkBhrGA,sCCjCM,sCnBktGN,sCmB5sGI,iBAAA,QACA,aAAA,QD8BJ,oBC9EE,MAAA,QACA,iBAAA,KpB8vGD,YoB3vGC,MAAA,KnBuwGA,iBAAkB,QmBrwGhB,aAAA,QpB+vGH,kBoB7vGO,kBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpB8vGH,kBoB3vGC,MAAA,KnBuwGA,iBAAkB,QAClB,aAAc,QmBnwGR,mBADJ,mBpB8vGH,kCoB3vGG,MAAA,KnBuwGF,iBAAkB,QAClB,aAAc,QmBnwGN,yBnBywGV,yBAHA,yBmBvwGM,yBnBywGN,yBAHA,yBDFC,wCoBrwGK,wCnBywGN,wCmBpwGE,MAAA,KnB4wGA,iBAAkB,QAClB,aAAc,QmBrwGZ,mBpB6vGH,mBoB7vGG,kCnB0wGF,iBAAkB,KmBtwGV,2BnB2wGV,2BAHA,2BDHC,4BCOD,4BAHA,4BkBruGA,qCCrCM,qCnB2wGN,qCmBrwGI,iBAAA,QACA,aAAA,QDuCJ,mBACE,MAAA,QACA,iBAAA,KnB+tGD,UmB5tGC,YAAA,IlBwuGA,MAAO,QACP,cAAe,EAEjB,UGzwGE,iBemCE,iBflCM,oBJkwGT,6BmB7tGC,iBAAA,YlByuGA,mBAAoB,KACZ,WAAY,KkBtuGlB,UAEF,iBAAA,gBnB6tGD,gBmB3tGG,aAAA,YnBiuGH,gBmB/tGG,gBAIA,MAAA,QlBuuGF,gBAAiB,UACjB,iBAAkB,YDNnB,0BmBhuGK,0BAUN,mCATM,mClB2uGJ,MAAO,KmB1yGP,gBAAA,KAGA,mBADA,QpBmyGD,QAAA,KAAA,KmBztGC,UAAW,KlBquGX,YAAa,UmBjzGb,cAAA,IAGA,mBADA,QpB0yGD,QAAA,IAAA,KmB5tGC,UAAW,KlBwuGX,YAAa,ImBxzGb,cAAA,IAGA,mBADA,QpBizGD,QAAA,IAAA,ImB3tGC,UAAW,KACX,YAAA,IACA,cAAA,IAIF,WACE,QAAA,MnB2tGD,MAAA,KCYD,sBACE,WAAY,IqBz3GZ,6BADF,4BtBk3GC,6BI7rGC,MAAA,KAEQ,MJisGT,QAAA,EsBr3GC,mBAAA,QAAA,KAAA,OACE,cAAA,QAAA,KAAA,OtBu3GH,WAAA,QAAA,KAAA,OsBl3GC,StBq3GD,QAAA,EsBn3Ga,UtBs3Gb,QAAA,KsBr3Ga,atBw3Gb,QAAA,MsBv3Ga,etB03Gb,QAAA,UsBt3GC,kBACA,QAAA,gBlBwKA,YACQ,SAAA,SAAA,OAAA,EAOR,SAAA,OACQ,mCAAA,KAAA,8BAAA,KAGR,2BAAA,KACQ,4BAAA,KAAA,uBAAA,KJ2sGT,oBAAA,KuBr5GC,4BAA6B,OAAQ,WACrC,uBAAA,OAAA,WACA,oBAAA,OAAA,WAEA,OACA,QAAA,aACA,MAAA,EACA,OAAA,EACA,YAAA,IACA,eAAA,OvBu5GD,WAAA,IAAA,OuBn5GC,WAAY,IAAI,QtBk6GhB,aAAc,IAAI,MAAM,YsBh6GxB,YAAA,IAAA,MAAA,YAKA,UADF,QvBo5GC,SAAA,SuB94GC,uBACA,QAAA,EAEA,eACA,SAAA,SACA,IAAA,KACA,KAAA,EACA,QAAA,KACA,QAAA,KACA,MAAA,KACA,UAAA,MACA,QAAA,IAAA,EACA,OAAA,IAAA,EAAA,EACA,UAAA,KACA,WAAA,KACA,WAAA,KnBsBA,iBAAA,KACQ,wBAAA,YmBrBR,gBAAA,YtB+5GA,OsB/5GA,IAAA,MAAA,KvBk5GD,OAAA,IAAA,MAAA,gBuB74GC,cAAA,IACE,mBAAA,EAAA,IAAA,KAAA,iBACA,WAAA,EAAA,IAAA,KAAA,iBAzBJ,0BCzBE,MAAA,EACA,KAAA,KAEA,wBxBo8GD,OAAA,IuB96GC,OAAQ,IAAI,EAmCV,SAAA,OACA,iBAAA,QAEA,oBACA,QAAA,MACA,QAAA,IAAA,KACA,MAAA,KvB84GH,YAAA,IuBx4GC,YAAA,WtBw5GA,MAAO,KsBt5GL,YAAA,OvB44GH,0BuB14GG,0BAMF,MAAA,QtBo5GA,gBAAiB,KACjB,iBAAkB,QsBj5GhB,yBAEA,+BADA,+BvBu4GH,MAAA,KuB73GC,gBAAA,KtB64GA,iBAAkB,QAClB,QAAS,EDZV,2BuB33GC,iCAAA,iCAEE,MAAA,KEzGF,iCF2GE,iCAEA,gBAAA,KvB63GH,OAAA,YuBx3GC,iBAAkB,YAGhB,iBAAA,KvBw3GH,OAAA,0DuBn3GG,qBvBs3GH,QAAA,MuB72GC,QACA,QAAA,EAQF,qBACE,MAAA,EACA,KAAA,KAIF,oBACE,MAAA,KACA,KAAA,EAEA,iBACA,QAAA,MACA,QAAA,IAAA,KvBw2GD,UAAA,KuBp2GC,YAAa,WACb,MAAA,KACA,YAAA,OAEA,mBACA,SAAA,MACA,IAAA,EvBs2GD,MAAA,EuBl2GC,OAAQ,EACR,KAAA,EACA,QAAA,IAQF,2BtB42GE,MAAO,EsBx2GL,KAAA,KAEA,eACA,sCvB41GH,QAAA,GuBn2GC,WAAY,EtBm3GZ,cAAe,IAAI,OsBx2GjB,cAAA,IAAA,QAEA,uBvB41GH,8CuBv0GC,IAAK,KAXL,OAAA,KApEA,cAAA,IvB25GC,yBuBv1GD,6BA1DA,MAAA,EACA,KAAA,KvBq5GD,kC0BpiHG,MAAO,KzBojHP,KAAM,GyBhjHR,W1BsiHD,oB0B1iHC,SAAU,SzB0jHV,QAAS,ayBpjHP,eAAA,OAGA,yB1BsiHH,gBCgBC,SAAU,SACV,MAAO,KyB7iHT,gC1BsiHC,gCCYD,+BAFA,+ByBhjHA,uBANM,uBzBujHN,sBAFA,sBAQE,QAAS,EyBljHP,qB1BuiHH,2B0BliHD,2BACE,iC1BoiHD,YAAA,KCgBD,aACE,YAAa,KDZd,kB0B1iHD,wBAAA,0BzB2jHE,MAAO,KDZR,kB0B/hHD,wBACE,0B1BiiHD,YAAA,I0B5hHC,yE1B+hHD,cAAA,E2BhlHC,4BACG,YAAA,EDsDL,mEzB6iHE,wBAAyB,E0B5lHzB,2BAAA,E3BilHD,6C0B5hHD,8CACE,uBAAA,E1B8hHD,0BAAA,E0B3hHC,sB1B8hHD,MAAA,KCgBD,8D0B/mHE,cAAA,E3BomHD,mE0B3hHD,oECjEE,wBAAA,EACG,2BAAA,EDqEL,oEzB0iHE,uBAAwB,EyBxiHxB,0BAAA,EAiBF,mCACE,iCACA,QAAA,EAEF,iCACE,cAAA,IACA,aAAA,IAKF,oCtB/CE,cAAA,KACQ,aAAA,KsBkDR,iCtBnDA,mBAAA,MAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,iBsByDV,0CACE,mBAAA,K1BugHD,WAAA,K0BngHC,YACA,YAAA,EAGF,eACE,aAAA,IAAA,IAAA,E1BqgHD,oBAAA,ECgBD,uBACE,aAAc,EAAE,IAAI,IyB1gHlB,yBACA,+BACA,oC1B+/GH,QAAA,M0BtgHC,MAAO,KAcH,MAAA,K1B2/GL,UAAA,KCgBD,oCACE,MAAO,KyBpgHL,8BACA,oC1By/GH,oC0Bp/GC,0CACE,WAAA,K1Bs/GH,YAAA,E2B/pHC,4DACC,cAAA,EAQA,sD3B4pHF,uBAAA,I0Bt/GC,wBAAA,IC/KA,2BAAA,EACC,0BAAA,EAQA,sD3BkqHF,uBAAA,E0Bv/GC,wBAAyB,EACzB,2BAAA,I1By/GD,0BAAA,ICgBD,uE0BtrHE,cAAA,E3B2qHD,4E0Bt/GD,6EC7LE,2BAAA,EACC,0BAAA,EDoMH,6EACE,uBAAA,EACA,wBAAA,EAEA,qB1Bo/GD,QAAA,M0Bx/GC,MAAO,KzBwgHP,aAAc,MyBjgHZ,gBAAA,SAEA,0B1Bq/GH,gC0B9/GC,QAAS,WAYP,MAAA,K1Bq/GH,MAAA,G0Bj/GG,qC1Bo/GH,MAAA,KCgBD,+CACE,KAAM,KyB7+GF,gDAFA,6C1Bs+GL,2D0Br+GK,wDEzOJ,SAAU,SACV,KAAA,cACA,eAAA,K5BitHD,a4B7sHC,SAAA,SACE,QAAA,MACA,gBAAA,S5BgtHH,0B4BxtHC,MAAO,KAeL,cAAA,EACA,aAAA,EAOA,2BACA,SAAA,S5BusHH,QAAA,E4BrsHG,MAAA,KACE,MAAA,K5BusHL,cAAA,ECgBD,iCACE,QAAS,EiBnrHT,8BACA,mCACA,sCACA,OAAA,KlBwqHD,QAAA,KAAA,KkBtqHC,UAAA,KjBsrHA,YAAa,UACb,cAAe,IiBrrHb,oClB0qHH,yCkBvqHC,4CjBurHA,OAAQ,KACR,YAAa,KDTd,8C4B/sHD,mDAAA,sD3B0tHA,sCACA,2CiBzrHI,8CjB8rHF,OAAQ,KiB1sHR,8BACA,mCACA,sCACA,OAAA,KlB+rHD,QAAA,IAAA,KkB7rHC,UAAA,KjB6sHA,YAAa,IACb,cAAe,IiB5sHb,oClBisHH,yCkB9rHC,4CjB8sHA,OAAQ,KACR,YAAa,KDTd,8C4B7tHD,mDAAA,sD3BwuHA,sCACA,2CiBhtHI,8CjBqtHF,OAAQ,K2BzuHR,2B5B6tHD,mB4B7tHC,iB3B8uHA,QAAS,W2BzuHX,8D5B6tHC,sD4B7tHD,oDAEE,cAAA,EAEA,mB5B+tHD,iB4B1tHC,MAAO,GACP,YAAA,OACA,eAAA,OAEA,mBACA,QAAA,IAAA,KACA,UAAA,KACA,YAAA,IACA,YAAA,EACA,MAAA,K5B4tHD,WAAA,O4BztHC,iBAAA,KACE,OAAA,IAAA,MAAA,KACA,cAAA,I5B4tHH,4B4BztHC,QAAA,IAAA,KACE,UAAA,KACA,cAAA,I5B4tHH,4B4B/uHC,QAAS,KAAK,K3B+vHd,UAAW,K2BruHT,cAAA,IAKJ,wCAAA,qC3BquHE,WAAY,EAEd,uCACA,+BACA,kC0B70HE,6CACG,8CC4GL,6D5BqtHC,wE4BptHC,wBAAA,E5ButHD,2BAAA,ECgBD,+BACE,aAAc,EAEhB,sCACA,8B2BhuHA,+D5BstHC,oDCWD,iC0Bl1HE,4CACG,6CCiHH,uBAAA,E5BwtHD,0BAAA,E4BltHC,8BAGA,YAAA,E5BotHD,iB4BxtHC,SAAU,SAUR,UAAA,E5BitHH,YAAA,O4B/sHK,sB5BktHL,SAAA,SCgBD,2BACE,YAAa,K2BxtHb,6BAAA,4B5B4sHD,4B4BzsHK,QAAA,EAGJ,kCAAA,wCAGI,aAAA,K5B4sHL,iC6B12HD,uCACE,QAAA,EACA,YAAA,K7B62HD,K6B/2HC,aAAc,EAOZ,cAAA,EACA,WAAA,KARJ,QAWM,SAAA,SACA,QAAA,M7B42HL,U6B12HK,SAAA,S5B03HJ,QAAS,M4Bx3HH,QAAA,KAAA,KAMJ,gB7Bu2HH,gB6Bt2HK,gBAAA,K7By2HL,iBAAA,KCgBD,mB4Br3HQ,MAAA,KAGA,yBADA,yB7B02HP,MAAA,K6Bl2HG,gBAAA,K5Bk3HF,OAAQ,YACR,iBAAkB,Y4B/2Hd,aAzCN,mB7B64HC,mBwBh5HC,iBAAA,KACA,aAAA,QAEA,kBxBm5HD,OAAA,I6Bn5HC,OAAQ,IAAI,EA0DV,SAAA,O7B41HH,iBAAA,Q6Bl1HC,c7Bq1HD,UAAA,K6Bn1HG,UAEA,cAAA,IAAA,MAAA,KALJ,aASM,MAAA,KACA,cAAA,KAEA,e7Bo1HL,aAAA,I6Bn1HK,YAAA,WACE,OAAA,IAAA,MAAA,Y7Bq1HP,cAAA,IAAA,IAAA,EAAA,ECgBD,qBACE,aAAc,KAAK,KAAK,K4B51HlB,sBAEA,4BADA,4BAEA,MAAA,K7Bi1HP,OAAA,Q6B50HC,iBAAA,KAqDA,OAAA,IAAA,MAAA,KA8BA,oBAAA,YAnFA,wBAwDE,MAAA,K7B2xHH,cAAA,E6BzxHK,2BACA,MAAA,KA3DJ,6BAgEE,cAAA,IACA,WAAA,OAYJ,iDA0DE,IAAK,KAjED,KAAA,K7B0xHH,yB6BztHD,2BA9DM,QAAA,W7B0xHL,MAAA,G6Bn2HD,6BAuFE,cAAA,GAvFF,6B5Bw3HA,aAAc,EACd,cAAe,IDZhB,kC6BtuHD,wCA3BA,wCATM,OAAA,IAAA,MAAA,K7B+wHH,yB6B3uHD,6B5B2vHE,cAAe,IAAI,MAAM,KACzB,cAAe,IAAI,IAAI,EAAE,EDZ1B,kC6B92HD,wC7B+2HD,wC6B72HG,oBAAA,MAIE,c7B+2HL,MAAA,K6B52HK,gB7B+2HL,cAAA,ICgBD,iBACE,YAAa,I4Bv3HP,uBAQR,6B7Bo2HC,6B6Bl2HG,MAAA,K7Bq2HH,iBAAA,Q6Bn2HK,gBACA,MAAA,KAYN,mBACE,WAAA,I7B41HD,YAAA,E6Bz1HG,e7B41HH,MAAA,K6B11HK,kBACA,MAAA,KAPN,oBAYI,cAAA,IACA,WAAA,OAYJ,wCA0DE,IAAK,KAjED,KAAA,K7B21HH,yB6B1xHD,kBA9DM,QAAA,W7B21HL,MAAA,G6Bl1HD,oBACA,cAAA,GAIE,oBACA,cAAA,EANJ,yB5B02HE,aAAc,EACd,cAAe,IDZhB,8B6B1yHD,oCA3BA,oCATM,OAAA,IAAA,MAAA,K7Bm1HH,yB6B/yHD,yB5B+zHE,cAAe,IAAI,MAAM,KACzB,cAAe,IAAI,IAAI,EAAE,EDZ1B,8B6Bx0HD,oC7By0HD,oC6Bv0HG,oBAAA,MAGA,uB7B00HH,QAAA,K6B/zHC,qBF3OA,QAAA,M3B+iID,yB8BxiIC,WAAY,KACZ,uBAAA,EACA,wBAAA,EAEA,Q9B0iID,SAAA,S8BliIC,WAAY,KA8nBZ,cAAe,KAhoBb,OAAA,IAAA,MAAA,Y9ByiIH,yB8BzhIC,QAgnBE,cAAe,K9B86GlB,yB8BjhIC,eACA,MAAA,MAGA,iBACA,cAAA,KAAA,aAAA,KAEA,WAAA,Q9BkhID,2BAAA,M8BhhIC,WAAA,IAAA,MAAA,YACE,mBAAA,MAAA,EAAA,IAAA,EAAA,qB9BkhIH,WAAA,MAAA,EAAA,IAAA,EAAA,qB8Bz7GD,oBArlBI,WAAA,KAEA,yBAAA,iB9BkhID,MAAA,K8BhhIC,WAAA,EACE,mBAAA,KACA,WAAA,KAEA,0B9BkhIH,QAAA,gB8B/gIC,OAAA,eACE,eAAA,E9BihIH,SAAA,kBCkBD,oBACE,WAAY,QDZf,sC8B/gIK,mC9B8gIH,oC8BzgIC,cAAe,E7B4hIf,aAAc,G6Bj+GlB,sCAnjBE,mC7ByhIA,WAAY,MDdX,4D8BngID,sC9BogID,mCCkBG,WAAY,O6B3gId,kCANE,gC9BsgIH,4B8BvgIG,0BAuiBF,aAAc,M7Bm/Gd,YAAa,MAEf,yBDZC,kC8B3gIK,gC9B0gIH,4B8B3gIG,0BAcF,aAAc,EAChB,YAAA,GAMF,mBA8gBE,QAAS,KAhhBP,aAAA,EAAA,EAAA,I9BkgIH,yB8B7/HC,mB7B+gIE,cAAe,G6B1gIjB,qBADA,kB9BggID,SAAA,M8Bz/HC,MAAO,EAggBP,KAAM,E7B4gHN,QAAS,KDdR,yB8B7/HD,qB9B8/HD,kB8B7/HC,cAAA,GAGF,kBACE,IAAA,EACA,aAAA,EAAA,EAAA,I9BigID,qB8B1/HC,OAAQ,EACR,cAAA,EACA,aAAA,IAAA,EAAA,EAEA,cACA,MAAA,K9B4/HD,OAAA,K8B1/HC,QAAA,KAAA,K7B4gIA,UAAW,K6B1gIT,YAAA,KAIA,oBAbJ,oB9BwgIC,gBAAA,K8Bv/HG,kB7B0gIF,QAAS,MDdR,yBACF,iC8Bh/HC,uCACA,YAAA,OAGA,eC9LA,SAAA,SACA,MAAA,MD+LA,QAAA,IAAA,KACA,WAAA,IACA,aAAA,KACA,cAAA,I9Bm/HD,iBAAA,Y8B/+HC,iBAAA,KACE,OAAA,IAAA,MAAA,Y9Bi/HH,cAAA,I8B5+HG,qBACA,QAAA,EAEA,yB9B++HH,QAAA,M8BrgIC,MAAO,KAyBL,OAAA,I9B++HH,cAAA,I8BpjHD,mCAvbI,WAAA,I9Bg/HH,yB8Bt+HC,eACA,QAAA,MAGE,YACA,OAAA,MAAA,M9By+HH,iB8B58HC,YAAA,KA2YA,eAAgB,KAjaZ,YAAA,KAEA,yBACA,iCACA,SAAA,OACA,MAAA,KACA,MAAA,KAAA,WAAA,E9Bs+HH,iBAAA,Y8B3kHC,OAAQ,E7B8lHR,mBAAoB,K6Bt/HhB,WAAA,KAGA,kDAqZN,sC9BklHC,QAAA,IAAA,KAAA,IAAA,KCmBD,sC6Bv/HQ,YAAA,KAmBR,4C9Bs9HD,4C8BvlHG,iBAAkB,M9B4lHnB,yB8B5lHD,YAtYI,MAAA,K9Bq+HH,OAAA,E8Bn+HK,eACA,MAAA,K9Bu+HP,iB8B39HG,YAAa,KACf,eAAA,MAGA,aACA,QAAA,KAAA,K1B9NA,WAAA,IACQ,aAAA,M2B/DR,cAAA,IACA,YAAA,M/B4vID,WAAA,IAAA,MAAA,YiBtuHC,cAAe,IAAI,MAAM,YAwEzB,mBAAoB,MAAM,EAAE,IAAI,EAAE,qBAAyB,EAAE,IAAI,EAAE,qBAtI/D,WAAA,MAAA,EAAA,IAAA,EAAA,qBAAA,EAAA,IAAA,EAAA,qBAEA,yBjBwyHH,yBiBpqHC,QAAS,aA/HP,cAAA,EACA,eAAA,OjBuyHH,2BiBzqHC,QAAS,aAxHP,MAAA,KjBoyHH,eAAA,OiBhyHG,kCACA,QAAA,aAmHJ,0BhBmsHE,QAAS,aACT,eAAgB,OgB5yHd,wCjB6xHH,6CiBrrHD,2CjBwrHC,MAAA,KiB5xHG,wCACA,MAAA,KAmGJ,4BhB+sHE,cAAe,EgB3yHb,eAAA,OAGA,uBADA,oBjB6xHH,QAAA,aiBnsHC,WAAY,EhBstHZ,cAAe,EgB5yHX,eAAA,OAsFN,6BAAA,0BAjFI,aAAA,EAiFJ,4CjB4sHC,sCiBvxHG,SAAA,SjB0xHH,YAAA,E8BngID,kDAmWE,IAAK,GAvWH,yBACE,yB9B8gIL,cAAA,I8B5/HD,oCAoVE,cAAe,GA1Vf,yBACA,aACA,MAAA,KACA,YAAA,E1BzPF,eAAA,EACQ,aAAA,EJmwIP,YAAA,EACF,OAAA,E8BngIG,mBAAoB,KACtB,WAAA,M9BugID,8B8BngIC,WAAY,EACZ,uBAAA,EHzUA,wBAAA,EAQA,mDACC,cAAA,E3By0IF,uBAAA,I8B//HC,wBAAyB,IChVzB,2BAAA,EACA,0BAAA,EDkVA,YCnVA,WAAA,IACA,cAAA,IDqVA,mBCtVA,WAAA,KACA,cAAA,KD+VF,mBChWE,WAAA,KACA,cAAA,KDuWF,aAsSE,WAAY,KA1SV,cAAA,KAEA,yB9B+/HD,aACF,MAAA,K8Bl+HG,aAAc,KAhBhB,YAAA,MACA,yBE5WA,aF8WE,MAAA,eAFF,cAKI,MAAA,gB9Bu/HH,aAAA,M8B7+HD,4BACA,aAAA,GADF,gBAKI,iBAAA,Q9Bg/HH,aAAA,QCmBD,8B6BhgIM,MAAA,KARN,oC9B0/HC,oC8B5+HG,MAAA,Q9B++HH,iBAAA,Y8B1+HK,6B9B6+HL,MAAA,KCmBD,iC6B5/HQ,MAAA,KAKF,uC9By+HL,uCCmBC,MAAO,KACP,iBAAkB,Y6Bz/HZ,sCAIF,4C9Bu+HL,4CCmBC,MAAO,KACP,iBAAkB,Q6Bv/HZ,wCAxCR,8C9BihIC,8C8Bn+HG,MAAA,K9Bs+HH,iBAAA,YCmBD,+B6Bt/HM,aAAA,KAGA,qCApDN,qC9B2hIC,iBAAA,KCmBD,yC6Bp/HI,iBAAA,KAOE,iCAAA,6B7Bk/HJ,aAAc,Q6B9+HR,oCAiCN,0C9B+7HD,0C8B3xHC,MAAO,KA7LC,iBAAA,QACA,yB7B8+HR,sD6B5+HU,MAAA,KAKF,4D9By9HP,4DCmBC,MAAO,KACP,iBAAkB,Y6Bz+HV,2DAIF,iE9Bu9HP,iECmBC,MAAO,KACP,iBAAkB,Q6Bv+HV,6D9B09HX,mEADE,mE8B1jIC,MAAO,KA8GP,iBAAA,aAEE,6B9Bi9HL,MAAA,K8B58HG,mC9B+8HH,MAAA,KCmBD,0B6B/9HM,MAAA,KAIA,gCAAA,gC7Bg+HJ,MAAO,K6Bt9HT,0CARQ,0CASN,mD9Bu8HD,mD8Bt8HC,MAAA,KAFF,gBAKI,iBAAA,K9B08HH,aAAA,QCmBD,8B6B19HM,MAAA,QARN,oC9Bo9HC,oC8Bt8HG,MAAA,K9By8HH,iBAAA,Y8Bp8HK,6B9Bu8HL,MAAA,QCmBD,iC6Bt9HQ,MAAA,QAKF,uC9Bm8HL,uCCmBC,MAAO,KACP,iBAAkB,Y6Bn9HZ,sCAIF,4C9Bi8HL,4CCmBC,MAAO,KACP,iBAAkB,Q6Bj9HZ,wCAxCR,8C9B2+HC,8C8B57HG,MAAA,K9B+7HH,iBAAA,YCmBD,+B6B/8HM,aAAA,KAGA,qCArDN,qC9Bq/HC,iBAAA,KCmBD,yC6B78HI,iBAAA,KAME,iCAAA,6B7B48HJ,aAAc,Q6Bx8HR,oCAuCN,0C9Bm5HD,0C8B33HC,MAAO,KAvDC,iBAAA,QAuDV,yBApDU,kE9Bs7HP,aAAA,Q8Bn7HO,0D9Bs7HP,iBAAA,QCmBD,sD6Bt8HU,MAAA,QAKF,4D9Bm7HP,4DCmBC,MAAO,KACP,iBAAkB,Y6Bn8HV,2DAIF,iE9Bi7HP,iECmBC,MAAO,KACP,iBAAkB,Q6Bj8HV,6D9Bo7HX,mEADE,mE8B1hIC,MAAO,KA+GP,iBAAA,aAEE,6B9Bg7HL,MAAA,Q8B36HG,mC9B86HH,MAAA,KCmBD,0B6B97HM,MAAA,QAIA,gCAAA,gC7B+7HJ,MAAO,KgCvkJT,0CH0oBQ,0CGzoBN,mDjCwjJD,mDiCvjJC,MAAA,KAEA,YACA,QAAA,IAAA,KjC2jJD,cAAA,KiChkJC,WAAY,KAQV,iBAAA,QjC2jJH,cAAA,IiCxjJK,eACA,QAAA,ajC4jJL,yBiCxkJC,QAAS,EAAE,IAkBT,MAAA,KjCyjJH,QAAA,SkC5kJC,oBACA,MAAA,KAEA,YlC+kJD,QAAA,akCnlJC,aAAc,EAOZ,OAAA,KAAA,ElC+kJH,cAAA,ICmBD,eiC/lJM,QAAA,OAEA,iBACA,oBACA,SAAA,SACA,MAAA,KACA,QAAA,IAAA,KACA,YAAA,KACA,YAAA,WlCglJL,MAAA,QkC9kJG,gBAAA,KjCimJF,iBAAkB,KiC9lJZ,OAAA,IAAA,MAAA,KPVH,6B3B2lJJ,gCkC7kJG,YAAA,EjCgmJF,uBAAwB,I0BvnJxB,0BAAA,I3BymJD,4BkCxkJG,+BjC2lJF,wBAAyB,IACzB,2BAA4B,IiCxlJxB,uBAFA,uBAGA,0BAFA,0BlC8kJL,QAAA,EkCtkJG,MAAA,QjCylJF,iBAAkB,KAClB,aAAc,KAEhB,sBiCvlJM,4BAFA,4BjC0lJN,yBiCvlJM,+BAFA,+BAGA,QAAA,ElC2kJL,MAAA,KkCloJC,OAAQ,QjCqpJR,iBAAkB,QAClB,aAAc,QiCnlJV,wBAEA,8BADA,8BjColJN,2BiCtlJM,iCjCulJN,iCDZC,MAAA,KkC/jJC,OAAQ,YjCklJR,iBAAkB,KkC7pJd,aAAA,KAEA,oBnC8oJL,uBmC5oJG,QAAA,KAAA,KlC+pJF,UAAW,K0B1pJX,YAAA,U3B4oJD,gCmC3oJG,mClC8pJF,uBAAwB,I0BvqJxB,0BAAA,I3BypJD,+BkC1kJD,kCjC6lJE,wBAAyB,IkC7qJrB,2BAAA,IAEA,oBnC8pJL,uBmC5pJG,QAAA,IAAA,KlC+qJF,UAAW,K0B1qJX,YAAA,I3B4pJD,gCmC3pJG,mClC8qJF,uBAAwB,I0BvrJxB,0BAAA,I3ByqJD,+BoC3qJD,kCACE,wBAAA,IACA,2BAAA,IAEA,OpC6qJD,aAAA,EoCjrJC,OAAQ,KAAK,EAOX,WAAA,OpC6qJH,WAAA,KCmBD,UmC7rJM,QAAA,OAEA,YACA,eACA,QAAA,apC8qJL,QAAA,IAAA,KoC5rJC,iBAAkB,KnC+sJlB,OAAQ,IAAI,MAAM,KmC5rJd,cAAA,KAnBN,kBpCisJC,kBCmBC,gBAAiB,KmCzrJb,iBAAA,KA3BN,eAAA,kBAkCM,MAAA,MAlCN,mBAAA,sBnC6tJE,MAAO,KmClrJH,mBAEA,yBADA,yBpCqqJL,sBqCltJC,MAAO,KACP,OAAA,YACA,iBAAA,KAEA,OACA,QAAA,OACA,QAAA,KAAA,KAAA,KACA,UAAA,IACA,YAAA,IACA,YAAA,EACA,MAAA,KrCotJD,WAAA,OqChtJG,YAAA,OpCmuJF,eAAgB,SoCjuJZ,cAAA,MrCotJL,cqCltJK,cAKJ,MAAA,KACE,gBAAA,KrC+sJH,OAAA,QqC1sJG,aACA,QAAA,KAOJ,YCtCE,SAAA,StC+uJD,IAAA,KCmBD,eqC7vJM,iBAAA,KALJ,2BD0CF,2BrC4sJC,iBAAA,QCmBD,eqCpwJM,iBAAA,QALJ,2BD8CF,2BrC+sJC,iBAAA,QCmBD,eqC3wJM,iBAAA,QALJ,2BDkDF,2BrCktJC,iBAAA,QCmBD,YqClxJM,iBAAA,QALJ,wBDsDF,wBrCqtJC,iBAAA,QCmBD,eqCzxJM,iBAAA,QALJ,2BD0DF,2BrCwtJC,iBAAA,QCmBD,cqChyJM,iBAAA,QCDJ,0BADF,0BAEE,iBAAA,QAEA,OACA,QAAA,aACA,UAAA,KACA,QAAA,IAAA,IACA,UAAA,KACA,YAAA,IACA,YAAA,EACA,MAAA,KACA,WAAA,OvCqxJD,YAAA,OuClxJC,eAAA,OACE,iBAAA,KvCoxJH,cAAA,KuC/wJG,aACA,QAAA,KAGF,YtCkyJA,SAAU,SsChyJR,IAAA,KAMA,0BvC4wJH,eCmBC,IAAK,EsC7xJD,QAAA,IAAA,IvCgxJL,cuC9wJK,cAKJ,MAAA,KtC4xJA,gBAAiB,KsC1xJf,OAAA,QvC4wJH,+BuCxwJC,4BACE,MAAA,QvC0wJH,iBAAA,KuCtwJG,wBvCywJH,MAAA,MuCrwJG,+BvCwwJH,aAAA,IwCj0JC,uBACA,YAAA,IAEA,WACA,YAAA,KxCo0JD,eAAA,KwCz0JC,cAAe,KvC41Jf,MAAO,QuCn1JL,iBAAA,KAIA,eAbJ,cAcI,MAAA,QxCo0JH,awCl1JC,cAAe,KAmBb,UAAA,KxCk0JH,YAAA,ICmBD,cuCh1JI,iBAAA,QAEA,sBxCi0JH,4BwC31JC,cAAe,KA8Bb,aAAA,KxCg0JH,cAAA,IwC7yJD,sBAfI,UAAA,KxCi0JD,oCwC9zJC,WvCi1JA,YAAa,KuC/0JX,eAAA,KxCi0JH,sBwCvzJD,4BvC00JE,cAAe,KuC90Jb,aAAA,KC5CJ,ezC42JD,cyC32JC,UAAA,MAGA,WACA,QAAA,MACA,QAAA,IACA,cAAA,KrCiLA,YAAA,WACK,iBAAA,KACG,OAAA,IAAA,MAAA,KJ8rJT,cAAA,IyCx3JC,mBAAoB,OAAO,IAAI,YxC24J1B,cAAe,OAAO,IAAI,YwC93J7B,WAAA,OAAA,IAAA,YAKF,iBzC22JD,eCmBC,aAAc,KACd,YAAa,KwCv3JX,mBA1BJ,kBzCk4JC,kByCv2JG,aAAA,QCzBJ,oBACE,QAAA,IACA,MAAA,KAEA,O1Cs4JD,QAAA,K0C14JC,cAAe,KAQb,OAAA,IAAA,MAAA,YAEA,cAAA,IAVJ,UAeI,WAAA,E1Ck4JH,MAAA,QCmBD,mByC/4JI,YAAA,IArBJ,SAyBI,U1C+3JH,cAAA,ECmBD,WyCx4JE,WAAA,IAFF,mBAAA,mBAMI,cAAA,KAEA,0BACA,0B1Cy3JH,SAAA,S0Cj3JC,IAAK,KCvDL,MAAA,MACA,MAAA,Q3C46JD,e0Ct3JC,MAAO,QClDL,iBAAA,Q3C26JH,aAAA,Q2Cx6JG,kB3C26JH,iBAAA,Q2Cn7JC,2BACA,MAAA,Q3Cu7JD,Y0C73JC,MAAO,QCtDL,iBAAA,Q3Cs7JH,aAAA,Q2Cn7JG,e3Cs7JH,iBAAA,Q2C97JC,wBACA,MAAA,Q3Ck8JD,e0Cp4JC,MAAO,QC1DL,iBAAA,Q3Ci8JH,aAAA,Q2C97JG,kB3Ci8JH,iBAAA,Q2Cz8JC,2BACA,MAAA,Q3C68JD,c0C34JC,MAAO,QC9DL,iBAAA,Q3C48JH,aAAA,Q2Cz8JG,iB3C48JH,iBAAA,Q4C78JC,0BAAQ,MAAA,QACR,wCAAQ,K5Cm9JP,oBAAA,KAAA,E4C/8JD,GACA,oBAAA,EAAA,GACA,mCAAQ,K5Cq9JP,oBAAA,KAAA,E4Cv9JD,GACA,oBAAA,EAAA,GACA,gCAAQ,K5Cq9JP,oBAAA,KAAA,E4C78JD,GACA,oBAAA,EAAA,GAGA,UACA,OAAA,KxCsCA,cAAA,KACQ,SAAA,OJ26JT,iBAAA,Q4C78JC,cAAe,IACf,mBAAA,MAAA,EAAA,IAAA,IAAA,eACA,WAAA,MAAA,EAAA,IAAA,IAAA,eAEA,cACA,MAAA,KACA,MAAA,EACA,OAAA,KACA,UAAA,KxCyBA,YAAA,KACQ,MAAA,KAyHR,WAAA,OACK,iBAAA,QACG,mBAAA,MAAA,EAAA,KAAA,EAAA,gBJ+zJT,WAAA,MAAA,EAAA,KAAA,EAAA,gB4C18JC,mBAAoB,MAAM,IAAI,K3Cq+JzB,cAAe,MAAM,IAAI,K4Cp+J5B,WAAA,MAAA,IAAA,KDEF,sBCAE,gCDAF,iBAAA,yK5C88JD,iBAAA,oK4Cv8JC,iBAAiB,iK3Cm+JjB,wBAAyB,KAAK,KG/gK9B,gBAAA,KAAA,KJy/JD,qBIv/JS,+BwCmDR,kBAAmB,qBAAqB,GAAG,OAAO,SErElD,aAAA,qBAAA,GAAA,OAAA,S9C4gKD,UAAA,qBAAA,GAAA,OAAA,S6Cz9JG,sBACA,iBAAA,Q7C69JH,wC4Cx8JC,iBAAkB,yKEzElB,iBAAA,oK9CohKD,iBAAA,iK6Cj+JG,mBACA,iBAAA,Q7Cq+JH,qC4C58JC,iBAAkB,yKE7ElB,iBAAA,oK9C4hKD,iBAAA,iK6Cz+JG,sBACA,iBAAA,Q7C6+JH,wC4Ch9JC,iBAAkB,yKEjFlB,iBAAA,oK9CoiKD,iBAAA,iK6Cj/JG,qBACA,iBAAA,Q7Cq/JH,uC+C5iKC,iBAAkB,yKAElB,iBAAA,oK/C6iKD,iBAAA,iK+C1iKG,O/C6iKH,WAAA,KC4BD,mB8CnkKE,WAAA,E/C4iKD,O+CxiKD,YACE,SAAA,O/C0iKD,KAAA,E+CtiKC,Y/CyiKD,MAAA,Q+CriKG,c/CwiKH,QAAA,MC4BD,4B8C9jKE,UAAA,KAGF,aAAA,mBAEE,aAAA,KAGF,YAAA,kB9C+jKE,cAAe,K8CxjKjB,YAHE,Y/CoiKD,a+ChiKC,QAAA,W/CmiKD,eAAA,I+C/hKC,c/CkiKD,eAAA,O+C7hKC,cACA,eAAA,OAMF,eACE,WAAA,EACA,cAAA,ICvDF,YAEE,aAAA,EACA,WAAA,KAQF,YACE,aAAA,EACA,cAAA,KAGA,iBACA,SAAA,SACA,QAAA,MhD6kKD,QAAA,KAAA,KgD1kKC,cAAA,KrB3BA,iBAAA,KACC,OAAA,IAAA,MAAA,KqB6BD,6BACE,uBAAA,IrBvBF,wBAAA,I3BsmKD,4BgDpkKC,cAAe,E/CgmKf,2BAA4B,I+C9lK5B,0BAAA,IAFF,kBAAA,uBAKI,MAAA,KAIF,2CAAA,gD/CgmKA,MAAO,K+C5lKL,wBAFA,wBhDykKH,6BgDxkKG,6BAKF,MAAO,KACP,gBAAA,KACA,iBAAA,QAKA,uB/C4lKA,MAAO,KACP,WAAY,K+CzlKV,0BhDmkKH,gCgDlkKG,gCALF,MAAA,K/CmmKA,OAAQ,YACR,iBAAkB,KDxBnB,mDgD5kKC,yDAAA,yD/CymKA,MAAO,QDxBR,gDgDhkKC,sDAAA,sD/C6lKA,MAAO,K+CzlKL,wBAEA,8BADA,8BhDmkKH,QAAA,EgDxkKC,MAAA,K/ComKA,iBAAkB,QAClB,aAAc,QAEhB,iDDpBC,wDCuBD,uDADA,uD+CzmKE,8DAYI,6D/C4lKN,uD+CxmKE,8D/C2mKF,6DAKE,MAAO,QDxBR,8CiD1qKG,oDADF,oDAEE,MAAA,QAEA,yBhDusKF,MAAO,QgDrsKH,iBAAA,QAFF,0BAAA,+BAKI,MAAA,QAGF,mDAAA,wDhDwsKJ,MAAO,QDtBR,gCiDhrKO,gCAGF,qCAFE,qChD2sKN,MAAO,QACP,iBAAkB,QAEpB,iCgDvsKQ,uCAFA,uChD0sKR,sCDtBC,4CiDnrKO,4CArBN,MAAA,KACE,iBAAA,QACA,aAAA,QAEA,sBhDouKF,MAAO,QgDluKH,iBAAA,QAFF,uBAAA,4BAKI,MAAA,QAGF,gDAAA,qDhDquKJ,MAAO,QDtBR,6BiD7sKO,6BAGF,kCAFE,kChDwuKN,MAAO,QACP,iBAAkB,QAEpB,8BgDpuKQ,oCAFA,oChDuuKR,mCDtBC,yCiDhtKO,yCArBN,MAAA,KACE,iBAAA,QACA,aAAA,QAEA,yBhDiwKF,MAAO,QgD/vKH,iBAAA,QAFF,0BAAA,+BAKI,MAAA,QAGF,mDAAA,wDhDkwKJ,MAAO,QDtBR,gCiD1uKO,gCAGF,qCAFE,qChDqwKN,MAAO,QACP,iBAAkB,QAEpB,iCgDjwKQ,uCAFA,uChDowKR,sCDtBC,4CiD7uKO,4CArBN,MAAA,KACE,iBAAA,QACA,aAAA,QAEA,wBhD8xKF,MAAO,QgD5xKH,iBAAA,QAFF,yBAAA,8BAKI,MAAA,QAGF,kDAAA,uDhD+xKJ,MAAO,QDtBR,+BiDvwKO,+BAGF,oCAFE,oChDkyKN,MAAO,QACP,iBAAkB,QAEpB,gCgD9xKQ,sCAFA,sChDiyKR,qCDtBC,2CiD1wKO,2CDkGN,MAAO,KACP,iBAAA,QACA,aAAA,QAEF,yBACE,WAAA,EACA,cAAA,IE1HF,sBACE,cAAA,EACA,YAAA,IAEA,O9C0DA,cAAA,KACQ,iBAAA,KJ6uKT,OAAA,IAAA,MAAA,YkDnyKC,cAAe,IACf,mBAAA,EAAA,IAAA,IAAA,gBlDqyKD,WAAA,EAAA,IAAA,IAAA,gBkD/xKC,YACA,QAAA,KvBnBC,e3BuzKF,QAAA,KAAA,KkDtyKC,cAAe,IAAI,MAAM,YAMvB,uBAAA,IlDmyKH,wBAAA,IkD7xKC,0CACA,MAAA,QAEA,alDgyKD,WAAA,EkDpyKC,cAAe,EjDg0Kf,UAAW,KACX,MAAO,QDtBR,oBkD1xKC,sBjDkzKF,eiDxzKI,mBAKJ,qBAEE,MAAA,QvBvCA,cACC,QAAA,KAAA,K3Bs0KF,iBAAA,QkDrxKC,WAAY,IAAI,MAAM,KjDizKtB,2BAA4B,IiD9yK1B,0BAAA,IAHJ,mBAAA,mCAMM,cAAA,ElDwxKL,oCkDnxKG,oDjD+yKF,aAAc,IAAI,EiD7yKZ,cAAA,EvBtEL,4D3B61KF,4EkDjxKG,WAAA,EjD6yKF,uBAAwB,IiD3yKlB,wBAAA,IvBtEL,0D3B21KF,0EkD1yKC,cAAe,EvB1Df,2BAAA,IACC,0BAAA,IuB0FH,+EAEI,uBAAA,ElD8wKH,wBAAA,EkD1wKC,wDlD6wKD,iBAAA,EC4BD,0BACE,iBAAkB,EiDlyKpB,8BlD0wKC,ckD1wKD,gCjDuyKE,cAAe,EiDvyKjB,sCAQM,sBlDwwKL,wCC4BC,cAAe,K0Br5Kf,aAAA,KuByGF,wDlDqxKC,0BC4BC,uBAAwB,IACxB,wBAAyB,IiDlzK3B,yFAoBQ,yFlDwwKP,2DkDzwKO,2DjDqyKN,uBAAwB,IACxB,wBAAyB,IAK3B,wGiD9zKA,wGjD4zKA,wGDtBC,wGCuBD,0EiD7zKA,0EjD2zKA,0EiDnyKU,0EjD2yKR,uBAAwB,IAK1B,uGiDx0KA,uGjDs0KA,uGDtBC,uGCuBD,yEiDv0KA,yEjDq0KA,yEiDzyKU,yEvB7HR,wBAAA,IuBiGF,sDlDqzKC,yBC4BC,2BAA4B,IAC5B,0BAA2B,IiDxyKrB,qFA1CR,qFAyCQ,wDlDmxKP,wDC4BC,2BAA4B,IAC5B,0BAA2B,IAG7B,oGDtBC,oGCwBD,oGiD91KA,oGjD21KA,uEiD7yKU,uEjD+yKV,uEiD71KA,uEjDm2KE,0BAA2B,IAG7B,mGDtBC,mGCwBD,mGiDx2KA,mGjDq2KA,sEiDnzKU,sEjDqzKV,sEiDv2KA,sEjD62KE,2BAA4B,IiDlzK1B,0BlD2xKH,qCkDt1KD,0BAAA,qCA+DI,WAAA,IAAA,MAAA,KA/DJ,kDAAA,kDAmEI,WAAA,EAnEJ,uBAAA,yCjD23KE,OAAQ,EiDjzKA,+CjDqzKV,+CiD/3KA,+CjDi4KA,+CAEA,+CANA,+CDjBC,iECoBD,iEiDh4KA,iEjDk4KA,iEAEA,iEANA,iEAWE,YAAa,EiD3zKL,8CjD+zKV,8CiD74KA,8CjD+4KA,8CAEA,8CANA,8CDjBC,gECoBD,gEiD94KA,gEjDg5KA,gEAEA,gEANA,gEAWE,aAAc,EAIhB,+CiD35KA,+CjDy5KA,+CiDl0KU,+CjDq0KV,iEiD55KA,iEjD05KA,iEDtBC,iEC6BC,cAAe,EAEjB,8CiDn0KU,8CjDq0KV,8CiDr6KA,8CjDo6KA,gEDtBC,gECwBD,gEiDh0KI,gEACA,cAAA,EAUJ,yBACE,cAAA,ElDmyKD,OAAA,EkD/xKG,aACA,cAAA,KANJ,oBASM,cAAA,ElDkyKL,cAAA,IkD7xKG,2BlDgyKH,WAAA,IC4BD,4BiDxzKM,cAAA,EAKF,wDAvBJ,wDlDqzKC,WAAA,IAAA,MAAA,KkD5xKK,2BlD+xKL,WAAA,EmDlhLC,uDnDqhLD,cAAA,IAAA,MAAA,KmDlhLG,eACA,aAAA,KnDshLH,8BmDxhLC,MAAA,KAMI,iBAAA,QnDqhLL,aAAA,KmDlhLK,0DACA,iBAAA,KAGJ,qCAEI,MAAA,QnDmhLL,iBAAA,KmDpiLC,yDnDuiLD,oBAAA,KmDpiLG,eACA,aAAA,QnDwiLH,8BmD1iLC,MAAA,KAMI,iBAAA,QnDuiLL,aAAA,QmDpiLK,0DACA,iBAAA,QAGJ,qCAEI,MAAA,QnDqiLL,iBAAA,KmDtjLC,yDnDyjLD,oBAAA,QmDtjLG,eACA,aAAA,QnD0jLH,8BmD5jLC,MAAA,QAMI,iBAAA,QnDyjLL,aAAA,QmDtjLK,0DACA,iBAAA,QAGJ,qCAEI,MAAA,QnDujLL,iBAAA,QmDxkLC,yDnD2kLD,oBAAA,QmDxkLG,YACA,aAAA,QnD4kLH,2BmD9kLC,MAAA,QAMI,iBAAA,QnD2kLL,aAAA,QmDxkLK,uDACA,iBAAA,QAGJ,kCAEI,MAAA,QnDykLL,iBAAA,QmD1lLC,sDnD6lLD,oBAAA,QmD1lLG,eACA,aAAA,QnD8lLH,8BmDhmLC,MAAA,QAMI,iBAAA,QnD6lLL,aAAA,QmD1lLK,0DACA,iBAAA,QAGJ,qCAEI,MAAA,QnD2lLL,iBAAA,QmD5mLC,yDnD+mLD,oBAAA,QmD5mLG,cACA,aAAA,QnDgnLH,6BmDlnLC,MAAA,QAMI,iBAAA,QnD+mLL,aAAA,QmD5mLK,yDACA,iBAAA,QAGJ,oCAEI,MAAA,QnD6mLL,iBAAA,QoD5nLC,wDACA,oBAAA,QAEA,kBACA,SAAA,SpD+nLD,QAAA,MoDpoLC,OAAQ,EnDgqLR,QAAS,EACT,SAAU,OAEZ,yCmDtpLI,wBADA,yBAEA,yBACA,wBACA,SAAA,SACA,IAAA,EACA,OAAA,EpD+nLH,KAAA,EoD1nLC,MAAO,KACP,OAAA,KpD4nLD,OAAA,EoDvnLC,wBpD0nLD,eAAA,OqDppLC,uBACA,eAAA,IAEA,MACA,WAAA,KACA,QAAA,KjDwDA,cAAA,KACQ,iBAAA,QJgmLT,OAAA,IAAA,MAAA,QqD/pLC,cAAe,IASb,mBAAA,MAAA,EAAA,IAAA,IAAA,gBACA,WAAA,MAAA,EAAA,IAAA,IAAA,gBAKJ,iBACE,aAAA,KACA,aAAA,gBAEF,SACE,QAAA,KACA,cAAA,ICtBF,SACE,QAAA,IACA,cAAA,IAEA,OACA,MAAA,MACA,UAAA,KjCRA,YAAA,IAGA,YAAA,ErBqrLD,MAAA,KsD7qLC,YAAA,EAAA,IAAA,EAAA,KrDysLA,OAAQ,kBqDvsLN,QAAA,GjCbF,aiCeE,ajCZF,MAAA,KrB6rLD,gBAAA,KsDzqLC,OAAA,QACE,OAAA,kBACA,QAAA,GAEA,aACA,mBAAA,KtD2qLH,QAAA,EuDhsLC,OAAQ,QACR,WAAA,IvDksLD,OAAA,EuD7rLC,YACA,SAAA,OAEA,OACA,SAAA,MACA,IAAA,EACA,MAAA,EACA,OAAA,EACA,KAAA,EAIA,QAAA,KvD6rLD,QAAA,KuD1rLC,SAAA,OnD+GA,2BAAA,MACI,QAAA,EAEI,0BAkER,mBAAA,kBAAA,IAAA,SAEK,cAAA,aAAA,IAAA,SACG,WAAA,UAAA,IAAA,SJ6gLT,kBAAA,kBuDhsLC,cAAA,kBnD2GA,aAAA,kBACI,UAAA,kBAEI,wBJwlLT,kBAAA,euDpsLK,cAAe,eACnB,aAAA,eACA,UAAA,eAIF,mBACE,WAAA,OACA,WAAA,KvDqsLD,cuDhsLC,SAAU,SACV,MAAA,KACA,OAAA,KAEA,eACA,SAAA,SnDaA,iBAAA,KACQ,wBAAA,YmDZR,gBAAA,YtD4tLA,OsD5tLA,IAAA,MAAA,KAEA,OAAA,IAAA,MAAA,evDksLD,cAAA,IuD9rLC,QAAS,EACT,mBAAA,EAAA,IAAA,IAAA,eACA,WAAA,EAAA,IAAA,IAAA,eAEA,gBACA,SAAA,MACA,IAAA,EACA,MAAA,EvDgsLD,OAAA,EuD9rLC,KAAA,ElCrEA,QAAA,KAGA,iBAAA,KkCmEA,qBlCtEA,OAAA,iBAGA,QAAA,EkCwEF,mBACE,OAAA,kBACA,QAAA,GAIF,cACE,QAAA,KvDgsLD,cAAA,IAAA,MAAA,QuD3rLC,qBACA,WAAA,KAKF,aACE,OAAA,EACA,YAAA,WAIF,YACE,SAAA,SACA,QAAA,KvD0rLD,cuD5rLC,QAAS,KAQP,WAAA,MACA,WAAA,IAAA,MAAA,QATJ,wBAaI,cAAA,EvDsrLH,YAAA,IuDlrLG,mCvDqrLH,YAAA,KuD/qLC,oCACA,YAAA,EAEA,yBACA,SAAA,SvDkrLD,IAAA,QuDhqLC,MAAO,KAZP,OAAA,KACE,SAAA,OvDgrLD,yBuD7qLD,cnDvEA,MAAA,MACQ,OAAA,KAAA,KmD2ER,eAAY,mBAAA,EAAA,IAAA,KAAA,evD+qLX,WAAA,EAAA,IAAA,KAAA,euDzqLD,UAFA,MAAA,OvDirLD,yBwD/zLC,UACA,MAAA,OCNA,SAEA,SAAA,SACA,QAAA,KACA,QAAA,MACA,YAAA,iBAAA,UAAA,MAAA,WACA,UAAA,KACA,WAAA,OACA,YAAA,IACA,YAAA,WACA,WAAA,KACA,WAAA,MACA,gBAAA,KACA,YAAA,KACA,eAAA,KACA,eAAA,ODHA,WAAA,OnCVA,aAAA,OAGA,UAAA,OrBs1LD,YAAA,OwD30LC,OAAA,iBnCdA,QAAA,ErB61LD,WAAA,KwD90LY,YAAmB,OAAA,kBxDk1L/B,QAAA,GwDj1LY,aAAmB,QAAA,IAAA,ExDq1L/B,WAAA,KwDp1LY,eAAmB,QAAA,EAAA,IxDw1L/B,YAAA,IwDv1LY,gBAAmB,QAAA,IAAA,ExD21L/B,WAAA,IwDt1LC,cACA,QAAA,EAAA,IACA,YAAA,KAEA,eACA,UAAA,MxDy1LD,QAAA,IAAA,IwDr1LC,MAAO,KACP,WAAA,OACA,iBAAA,KACA,cAAA,IAEA,exDu1LD,SAAA,SwDn1LC,MAAA,EACE,OAAA,EACA,aAAA,YACA,aAAA,MAEA,4BxDq1LH,OAAA,EwDn1LC,KAAA,IACE,YAAA,KACA,aAAA,IAAA,IAAA,EACA,iBAAA,KAEA,iCxDq1LH,MAAA,IwDn1LC,OAAA,EACE,cAAA,KACA,aAAA,IAAA,IAAA,EACA,iBAAA,KAEA,kCxDq1LH,OAAA,EwDn1LC,KAAA,IACE,cAAA,KACA,aAAA,IAAA,IAAA,EACA,iBAAA,KAEA,8BxDq1LH,IAAA,IwDn1LC,KAAA,EACE,WAAA,KACA,aAAA,IAAA,IAAA,IAAA,EACA,mBAAA,KAEA,6BxDq1LH,IAAA,IwDn1LC,MAAA,EACE,WAAA,KACA,aAAA,IAAA,EAAA,IAAA,IACA,kBAAA,KAEA,+BxDq1LH,IAAA,EwDn1LC,KAAA,IACE,YAAA,KACA,aAAA,EAAA,IAAA,IACA,oBAAA,KAEA,oCxDq1LH,IAAA,EwDn1LC,MAAA,IACE,WAAA,KACA,aAAA,EAAA,IAAA,IACA,oBAAA,KAEA,qCxDq1LH,IAAA,E0Dl7LC,KAAM,IACN,WAAA,KACA,aAAA,EAAA,IAAA,IACA,oBAAA,KAEA,SACA,SAAA,SACA,IAAA,EDXA,KAAA,EAEA,QAAA,KACA,QAAA,KACA,UAAA,MACA,QAAA,IACA,YAAA,iBAAA,UAAA,MAAA,WACA,UAAA,KACA,WAAA,OACA,YAAA,IACA,YAAA,WACA,WAAA,KACA,WAAA,MACA,gBAAA,KACA,YAAA,KACA,eAAA,KCAA,eAAA,OAEA,WAAA,OACA,aAAA,OAAA,UAAA,OACA,YAAA,OACA,iBAAA,KACA,wBAAA,YtD8CA,gBAAA,YACQ,OAAA,IAAA,MAAA,KJk5LT,OAAA,IAAA,MAAA,e0D77LC,cAAA,IAAY,mBAAA,EAAA,IAAA,KAAA,e1Dg8Lb,WAAA,EAAA,IAAA,KAAA,e0D/7La,WAAA,KACZ,aAAY,WAAA,MACZ,eAAY,YAAA,KAGd,gBACE,WAAA,KAEA,cACA,YAAA,MAEA,e1Dq8LD,QAAA,IAAA,K0Dl8LC,OAAQ,EACR,UAAA,K1Do8LD,iBAAA,Q0D57LC,cAAA,IAAA,MAAA,QzDy9LA,cAAe,IAAI,IAAI,EAAE,EyDt9LvB,iBACA,QAAA,IAAA,KAEA,gBACA,sB1D87LH,SAAA,S0D37LC,QAAS,MACT,MAAA,E1D67LD,OAAA,E0D37LC,aAAc,YACd,aAAA,M1D87LD,gB0Dz7LC,aAAA,KAEE,sBACA,QAAA,GACA,aAAA,KAEA,oB1D27LH,OAAA,M0D17LG,KAAA,IACE,YAAA,MACA,iBAAA,KACA,iBAAA,gBACA,oBAAA,E1D67LL,0B0Dz7LC,OAAA,IACE,YAAA,MACA,QAAA,IACA,iBAAA,KACA,oBAAA,EAEA,sB1D27LH,IAAA,I0D17LG,KAAA,MACE,WAAA,MACA,mBAAA,KACA,mBAAA,gBACA,kBAAA,E1D67LL,4B0Dz7LC,OAAA,MACE,KAAA,IACA,QAAA,IACA,mBAAA,KACA,kBAAA,EAEA,uB1D27LH,IAAA,M0D17LG,KAAA,IACE,YAAA,MACA,iBAAA,EACA,oBAAA,KACA,oBAAA,gB1D67LL,6B0Dx7LC,IAAA,IACE,YAAA,MACA,QAAA,IACA,iBAAA,EACA,oBAAA,KAEA,qB1D07LH,IAAA,I0Dz7LG,MAAA,MACE,WAAA,MACA,mBAAA,EACA,kBAAA,KACA,kBAAA,gB1D47LL,2B2DpjMC,MAAO,IACP,OAAA,M3DsjMD,QAAA,I2DnjMC,mBAAoB,EACpB,kBAAA,KAEA,U3DqjMD,SAAA,S2DljMG,gBACA,SAAA,SvD6KF,MAAA,KACK,SAAA,OJ04LN,sB2D/jMC,SAAU,S1D4lMV,QAAS,K0D9kML,mBAAA,IAAA,YAAA,K3DqjML,cAAA,IAAA,YAAA,K2D3hMC,WAAA,IAAA,YAAA,KvDmKK,4BAFL,0BAGQ,YAAA,EA3JA,qDA+GR,sBAEQ,mBAAA,kBAAA,IAAA,YJ86LP,cAAA,aAAA,IAAA,Y2DzjMG,WAAA,UAAA,IAAA,YvDmHJ,4BAAA,OACQ,oBAAA,OuDjHF,oBAAA,O3D4jML,YAAA,OI58LD,mCHs+LA,2BGr+LQ,KAAA,EuD5GF,kBAAA,sB3D6jML,UAAA,sBC2BD,kCADA,2BG5+LA,KAAA,EACQ,kBAAA,uBuDtGF,UAAA,uBArCN,6B3DomMD,gC2DpmMC,iC1D+nME,KAAM,E0DllMN,kBAAA,mB3D4jMH,UAAA,oBAGA,wB2D5mMD,sBAAA,sBAsDI,QAAA,MAEA,wB3D0jMH,KAAA,E2DtjMG,sB3DyjMH,sB2DrnMC,SAAU,SA+DR,IAAA,E3DyjMH,MAAA,KC0BD,sB0D/kMI,KAAA,KAnEJ,sBAuEI,KAAA,MAvEJ,2BA0EI,4B3DwjMH,KAAA,E2D/iMC,6BACA,KAAA,MAEA,8BACA,KAAA,KtC3FA,kBsC6FA,SAAA,SACA,IAAA,EACA,OAAA,EACA,KAAA,EACA,MAAA,I3DmjMD,UAAA,K2D9iMC,MAAA,KdnGE,WAAA,OACA,YAAA,EAAA,IAAA,IAAA,eACA,iBAAA,cAAA,OAAA,kBACA,QAAA,G7CqpMH,uB2DljMC,iBAAA,sEACE,iBAAA,iEACA,iBAAA,uFdxGA,iBAAA,kEACA,OAAA,+GACA,kBAAA,SACA,wBACA,MAAA,E7C6pMH,KAAA,K2DpjMC,iBAAA,sE1DglMA,iBAAiB,iE0D9kMf,iBAAA,uFACA,iBAAA,kEACA,OAAA,+GtCvHF,kBAAA,SsCyFF,wB3DslMC,wBC4BC,MAAO,KACP,gBAAiB,KACjB,OAAQ,kB0D7kMN,QAAA,EACA,QAAA,G3DwjMH,0C2DhmMD,2CA2CI,6BADA,6B1DklMF,SAAU,S0D7kMR,IAAA,IACA,QAAA,E3DqjMH,QAAA,a2DrmMC,WAAY,MAqDV,0CADA,6B3DsjMH,KAAA,I2D1mMC,YAAa,MA0DX,2CADA,6BAEA,MAAA,IACA,aAAA,MAME,6BADF,6B3DmjMH,MAAA,K2D9iMG,OAAA,KACE,YAAA,M3DgjML,YAAA,E2DriMC,oCACA,QAAA,QAEA,oCACA,QAAA,QAEA,qBACA,SAAA,SACA,OAAA,K3DwiMD,KAAA,I2DjjMC,QAAS,GAYP,MAAA,IACA,aAAA,EACA,YAAA,KACA,WAAA,OACA,WAAA,KAEA,wBACA,QAAA,aAWA,MAAA,KACA,OAAA,K3D8hMH,OAAA,I2D7jMC,YAAa,OAkCX,OAAA,QACA,iBAAA,OACA,iBAAA,cACA,OAAA,IAAA,MAAA,K3D8hMH,cAAA,K2DthMC,6BACA,MAAA,KACA,OAAA,KACA,OAAA,EACA,iBAAA,KAEA,kBACA,SAAA,SACA,MAAA,IACA,OAAA,K3DyhMD,KAAA,I2DxhMC,QAAA,GACE,YAAA,K3D0hMH,eAAA,K2Dj/LC,MAAO,KAhCP,WAAA,O1D8iMA,YAAa,EAAE,IAAI,IAAI,eAEzB,uB0D3iMM,YAAA,KAEA,oCACA,0C3DmhMH,2C2D3hMD,6BAAA,6BAYI,MAAA,K3DmhMH,OAAA,K2D/hMD,WAAA,M1D2jME,UAAW,KDxBZ,0C2D9gMD,6BACE,YAAA,MAEA,2C3DghMD,6B2D5gMD,aAAA,M3D+gMC,kBACF,MAAA,I4D7wMC,KAAA,I3DyyME,eAAgB,KAElB,qBACE,OAAQ,MAkBZ,qCADA,sCADA,mBADA,oBAXA,gBADA,iBAOA,uBADA,wBADA,iBADA,kBADA,wBADA,yBASA,mCADA,oC2DpzME,oBAAA,qBAAA,oBAAA,qB3D2zMF,WADA,YAOA,uBADA,wBADA,qBADA,sBADA,cADA,e2D/zMI,a3Dq0MJ,cDvBC,kB4D7yMG,mB3DqzMJ,WADA,YAwBE,QAAS,MACT,QAAS,IASX,qCADA,mBANA,gBAGA,uBADA,iBADA,wBAIA,mCDhBC,oB6D/0MC,oB5Dk2MF,W+B51MA,uBhCo0MC,qB4D5zMG,cChBF,aACA,kB5D+1MF,W+Br1ME,MAAO,KhCy0MR,cgCt0MC,QAAS,MACT,aAAA,KhCw0MD,YAAA,KgC/zMC,YhCk0MD,MAAA,gBgC/zMC,WhCk0MD,MAAA,egC/zMC,MhCk0MD,QAAA,e8Dz1MC,MACA,QAAA,gBAEA,WACA,WAAA,O9B8BF,WACE,KAAA,EAAA,EAAA,EhCg0MD,MAAA,YgCzzMC,YAAa,KACb,iBAAA,YhC2zMD,OAAA,E+D31MC,Q/D81MD,QAAA,eC4BD,OACE,SAAU,M+Dn4MV,chE42MD,MAAA,aC+BD,YADA,YADA,YADA,YAIE,QAAS,e+Dp5MT,kBhEs4MC,mBgEr4MD,yBhEi4MD,kB+Dl1MD,mBA6IA,yB9D4tMA,kBACA,mB8Dj3ME,yB9D62MF,kBACA,mBACA,yB+Dv5MY,QAAA,eACV,yBAAU,YhE04MT,QAAA,gBC4BD,iB+Dp6MU,QAAA,gBhE64MX,c+D51MG,QAAS,oB/Dg2MV,c+Dl2MC,c/Dm2MH,QAAA,sB+D91MG,yB/Dk2MD,kBACF,QAAA,iB+D91MG,yB/Dk2MD,mBACF,QAAA,kBgEh6MC,yBhEo6MC,yBgEn6MD,QAAA,wBACA,+CAAU,YhEw6MT,QAAA,gBC4BD,iB+Dl8MU,QAAA,gBhE26MX,c+Dr2MG,QAAS,oB/Dy2MV,c+D32MC,c/D42MH,QAAA,sB+Dv2MG,+C/D22MD,kBACF,QAAA,iB+Dv2MG,+C/D22MD,mBACF,QAAA,kBgE97MC,+ChEk8MC,yBgEj8MD,QAAA,wBACA,gDAAU,YhEs8MT,QAAA,gBC4BD,iB+Dh+MU,QAAA,gBhEy8MX,c+D92MG,QAAS,oB/Dk3MV,c+Dp3MC,c/Dq3MH,QAAA,sB+Dh3MG,gD/Do3MD,kBACF,QAAA,iB+Dh3MG,gD/Do3MD,mBACF,QAAA,kBgE59MC,gDhEg+MC,yBgE/9MD,QAAA,wBACA,0BAAU,YhEo+MT,QAAA,gBC4BD,iB+D9/MU,QAAA,gBhEu+MX,c+Dv3MG,QAAS,oB/D23MV,c+D73MC,c/D83MH,QAAA,sB+Dz3MG,0B/D63MD,kBACF,QAAA,iB+Dz3MG,0B/D63MD,mBACF,QAAA,kBgEl/MC,0BhEs/MC,yBACF,QAAA,wBgEv/MC,yBhE2/MC,WACF,QAAA,gBgE5/MC,+ChEggNC,WACF,QAAA,gBgEjgNC,gDhEqgNC,WACF,QAAA,gBAGA,0B+Dh3MC,WA4BE,QAAS,gBC5LX,eAAU,QAAA,eACV,aAAU,ehEyhNT,QAAA,gBC4BD,oB+DnjNU,QAAA,gBhE4hNX,iB+D93MG,QAAS,oBAMX,iB/D23MD,iB+Dt2MG,QAAS,sB/D22MZ,qB+D/3MC,QAAS,e/Dk4MV,a+D53MC,qBAcE,QAAS,iB/Dm3MZ,sB+Dh4MC,QAAS,e/Dm4MV,a+D73MC,sBAOE,QAAS,kB/D23MZ,4B+D53MC,QAAS,eCpLT,ahEojNC,4BACF,QAAA,wBC6BD,aACE,cACE,QAAS","sourcesContent":["/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */\n\n//\n// 1. Set default font family to sans-serif.\n// 2. Prevent iOS and IE text size adjust after device orientation change,\n// without disabling user zoom.\n//\n\nhtml {\n font-family: sans-serif; // 1\n -ms-text-size-adjust: 100%; // 2\n -webkit-text-size-adjust: 100%; // 2\n}\n\n//\n// Remove default margin.\n//\n\nbody {\n margin: 0;\n}\n\n// HTML5 display definitions\n// ==========================================================================\n\n//\n// Correct `block` display not defined for any HTML5 element in IE 8/9.\n// Correct `block` display not defined for `details` or `summary` in IE 10/11\n// and Firefox.\n// Correct `block` display not defined for `main` in IE 11.\n//\n\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmain,\nmenu,\nnav,\nsection,\nsummary {\n display: block;\n}\n\n//\n// 1. Correct `inline-block` display not defined in IE 8/9.\n// 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.\n//\n\naudio,\ncanvas,\nprogress,\nvideo {\n display: inline-block; // 1\n vertical-align: baseline; // 2\n}\n\n//\n// Prevent modern browsers from displaying `audio` without controls.\n// Remove excess height in iOS 5 devices.\n//\n\naudio:not([controls]) {\n display: none;\n height: 0;\n}\n\n//\n// Address `[hidden]` styling not present in IE 8/9/10.\n// Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22.\n//\n\n[hidden],\ntemplate {\n display: none;\n}\n\n// Links\n// ==========================================================================\n\n//\n// Remove the gray background color from active links in IE 10.\n//\n\na {\n background-color: transparent;\n}\n\n//\n// Improve readability of focused elements when they are also in an\n// active/hover state.\n//\n\na:active,\na:hover {\n outline: 0;\n}\n\n// Text-level semantics\n// ==========================================================================\n\n//\n// Address styling not present in IE 8/9/10/11, Safari, and Chrome.\n//\n\nabbr[title] {\n border-bottom: 1px dotted;\n}\n\n//\n// Address style set to `bolder` in Firefox 4+, Safari, and Chrome.\n//\n\nb,\nstrong {\n font-weight: bold;\n}\n\n//\n// Address styling not present in Safari and Chrome.\n//\n\ndfn {\n font-style: italic;\n}\n\n//\n// Address variable `h1` font-size and margin within `section` and `article`\n// contexts in Firefox 4+, Safari, and Chrome.\n//\n\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\n\n//\n// Address styling not present in IE 8/9.\n//\n\nmark {\n background: #ff0;\n color: #000;\n}\n\n//\n// Address inconsistent and variable font size in all browsers.\n//\n\nsmall {\n font-size: 80%;\n}\n\n//\n// Prevent `sub` and `sup` affecting `line-height` in all browsers.\n//\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsup {\n top: -0.5em;\n}\n\nsub {\n bottom: -0.25em;\n}\n\n// Embedded content\n// ==========================================================================\n\n//\n// Remove border when inside `a` element in IE 8/9/10.\n//\n\nimg {\n border: 0;\n}\n\n//\n// Correct overflow not hidden in IE 9/10/11.\n//\n\nsvg:not(:root) {\n overflow: hidden;\n}\n\n// Grouping content\n// ==========================================================================\n\n//\n// Address margin not present in IE 8/9 and Safari.\n//\n\nfigure {\n margin: 1em 40px;\n}\n\n//\n// Address differences between Firefox and other browsers.\n//\n\nhr {\n box-sizing: content-box;\n height: 0;\n}\n\n//\n// Contain overflow in all browsers.\n//\n\npre {\n overflow: auto;\n}\n\n//\n// Address odd `em`-unit font size rendering in all browsers.\n//\n\ncode,\nkbd,\npre,\nsamp {\n font-family: monospace, monospace;\n font-size: 1em;\n}\n\n// Forms\n// ==========================================================================\n\n//\n// Known limitation: by default, Chrome and Safari on OS X allow very limited\n// styling of `select`, unless a `border` property is set.\n//\n\n//\n// 1. Correct color not being inherited.\n// Known issue: affects color of disabled elements.\n// 2. Correct font properties not being inherited.\n// 3. Address margins set differently in Firefox 4+, Safari, and Chrome.\n//\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n color: inherit; // 1\n font: inherit; // 2\n margin: 0; // 3\n}\n\n//\n// Address `overflow` set to `hidden` in IE 8/9/10/11.\n//\n\nbutton {\n overflow: visible;\n}\n\n//\n// Address inconsistent `text-transform` inheritance for `button` and `select`.\n// All other form control elements do not inherit `text-transform` values.\n// Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.\n// Correct `select` style inheritance in Firefox.\n//\n\nbutton,\nselect {\n text-transform: none;\n}\n\n//\n// 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`\n// and `video` controls.\n// 2. Correct inability to style clickable `input` types in iOS.\n// 3. Improve usability and consistency of cursor style between image-type\n// `input` and others.\n//\n\nbutton,\nhtml input[type=\"button\"], // 1\ninput[type=\"reset\"],\ninput[type=\"submit\"] {\n -webkit-appearance: button; // 2\n cursor: pointer; // 3\n}\n\n//\n// Re-set default cursor for disabled elements.\n//\n\nbutton[disabled],\nhtml input[disabled] {\n cursor: default;\n}\n\n//\n// Remove inner padding and border in Firefox 4+.\n//\n\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n border: 0;\n padding: 0;\n}\n\n//\n// Address Firefox 4+ setting `line-height` on `input` using `!important` in\n// the UA stylesheet.\n//\n\ninput {\n line-height: normal;\n}\n\n//\n// It's recommended that you don't attempt to style these elements.\n// Firefox's implementation doesn't respect box-sizing, padding, or width.\n//\n// 1. Address box sizing set to `content-box` in IE 8/9/10.\n// 2. Remove excess padding in IE 8/9/10.\n//\n\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n box-sizing: border-box; // 1\n padding: 0; // 2\n}\n\n//\n// Fix the cursor style for Chrome's increment/decrement buttons. For certain\n// `font-size` values of the `input`, it causes the cursor style of the\n// decrement button to change from `default` to `text`.\n//\n\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n//\n// 1. Address `appearance` set to `searchfield` in Safari and Chrome.\n// 2. Address `box-sizing` set to `border-box` in Safari and Chrome.\n//\n\ninput[type=\"search\"] {\n -webkit-appearance: textfield; // 1\n box-sizing: content-box; //2\n}\n\n//\n// Remove inner padding and search cancel button in Safari and Chrome on OS X.\n// Safari (but not Chrome) clips the cancel button when the search input has\n// padding (and `textfield` appearance).\n//\n\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n//\n// Define consistent border, margin, and padding.\n//\n\nfieldset {\n border: 1px solid #c0c0c0;\n margin: 0 2px;\n padding: 0.35em 0.625em 0.75em;\n}\n\n//\n// 1. Correct `color` not being inherited in IE 8/9/10/11.\n// 2. Remove padding so people aren't caught out if they zero out fieldsets.\n//\n\nlegend {\n border: 0; // 1\n padding: 0; // 2\n}\n\n//\n// Remove default vertical scrollbar in IE 8/9/10/11.\n//\n\ntextarea {\n overflow: auto;\n}\n\n//\n// Don't inherit the `font-weight` (applied by a rule above).\n// NOTE: the default cannot safely be changed in Chrome and Safari on OS X.\n//\n\noptgroup {\n font-weight: bold;\n}\n\n// Tables\n// ==========================================================================\n\n//\n// Remove most spacing between table cells.\n//\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\ntd,\nth {\n padding: 0;\n}\n","/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */\n\n// ==========================================================================\n// Print styles.\n// Inlined to avoid the additional HTTP request: h5bp.com/r\n// ==========================================================================\n\n@media print {\n *,\n *:before,\n *:after {\n background: transparent !important;\n color: #000 !important; // Black prints faster: h5bp.com/s\n box-shadow: none !important;\n text-shadow: none !important;\n }\n\n a,\n a:visited {\n text-decoration: underline;\n }\n\n a[href]:after {\n content: \" (\" attr(href) \")\";\n }\n\n abbr[title]:after {\n content: \" (\" attr(title) \")\";\n }\n\n // Don't show links that are fragment identifiers,\n // or use the `javascript:` pseudo protocol\n a[href^=\"#\"]:after,\n a[href^=\"javascript:\"]:after {\n content: \"\";\n }\n\n pre,\n blockquote {\n border: 1px solid #999;\n page-break-inside: avoid;\n }\n\n thead {\n display: table-header-group; // h5bp.com/t\n }\n\n tr,\n img {\n page-break-inside: avoid;\n }\n\n img {\n max-width: 100% !important;\n }\n\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n\n h2,\n h3 {\n page-break-after: avoid;\n }\n\n // Bootstrap specific changes start\n\n // Bootstrap components\n .navbar {\n display: none;\n }\n .btn,\n .dropup > .btn {\n > .caret {\n border-top-color: #000 !important;\n }\n }\n .label {\n border: 1px solid #000;\n }\n\n .table {\n border-collapse: collapse !important;\n\n td,\n th {\n background-color: #fff !important;\n }\n }\n .table-bordered {\n th,\n td {\n border: 1px solid #ddd !important;\n }\n }\n\n // Bootstrap specific changes end\n}\n","/*!\n * Bootstrap v3.3.7 (http://getbootstrap.com)\n * Copyright 2011-2016 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */\nhtml {\n font-family: sans-serif;\n -ms-text-size-adjust: 100%;\n -webkit-text-size-adjust: 100%;\n}\nbody {\n margin: 0;\n}\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmain,\nmenu,\nnav,\nsection,\nsummary {\n display: block;\n}\naudio,\ncanvas,\nprogress,\nvideo {\n display: inline-block;\n vertical-align: baseline;\n}\naudio:not([controls]) {\n display: none;\n height: 0;\n}\n[hidden],\ntemplate {\n display: none;\n}\na {\n background-color: transparent;\n}\na:active,\na:hover {\n outline: 0;\n}\nabbr[title] {\n border-bottom: 1px dotted;\n}\nb,\nstrong {\n font-weight: bold;\n}\ndfn {\n font-style: italic;\n}\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\nmark {\n background: #ff0;\n color: #000;\n}\nsmall {\n font-size: 80%;\n}\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsup {\n top: -0.5em;\n}\nsub {\n bottom: -0.25em;\n}\nimg {\n border: 0;\n}\nsvg:not(:root) {\n overflow: hidden;\n}\nfigure {\n margin: 1em 40px;\n}\nhr {\n box-sizing: content-box;\n height: 0;\n}\npre {\n overflow: auto;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: monospace, monospace;\n font-size: 1em;\n}\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n color: inherit;\n font: inherit;\n margin: 0;\n}\nbutton {\n overflow: visible;\n}\nbutton,\nselect {\n text-transform: none;\n}\nbutton,\nhtml input[type=\"button\"],\ninput[type=\"reset\"],\ninput[type=\"submit\"] {\n -webkit-appearance: button;\n cursor: pointer;\n}\nbutton[disabled],\nhtml input[disabled] {\n cursor: default;\n}\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n border: 0;\n padding: 0;\n}\ninput {\n line-height: normal;\n}\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n box-sizing: border-box;\n padding: 0;\n}\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-appearance: textfield;\n box-sizing: content-box;\n}\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\nfieldset {\n border: 1px solid #c0c0c0;\n margin: 0 2px;\n padding: 0.35em 0.625em 0.75em;\n}\nlegend {\n border: 0;\n padding: 0;\n}\ntextarea {\n overflow: auto;\n}\noptgroup {\n font-weight: bold;\n}\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\ntd,\nth {\n padding: 0;\n}\n/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */\n@media print {\n *,\n *:before,\n *:after {\n background: transparent !important;\n color: #000 !important;\n box-shadow: none !important;\n text-shadow: none !important;\n }\n a,\n a:visited {\n text-decoration: underline;\n }\n a[href]:after {\n content: \" (\" attr(href) \")\";\n }\n abbr[title]:after {\n content: \" (\" attr(title) \")\";\n }\n a[href^=\"#\"]:after,\n a[href^=\"javascript:\"]:after {\n content: \"\";\n }\n pre,\n blockquote {\n border: 1px solid #999;\n page-break-inside: avoid;\n }\n thead {\n display: table-header-group;\n }\n tr,\n img {\n page-break-inside: avoid;\n }\n img {\n max-width: 100% !important;\n }\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n h2,\n h3 {\n page-break-after: avoid;\n }\n .navbar {\n display: none;\n }\n .btn > .caret,\n .dropup > .btn > .caret {\n border-top-color: #000 !important;\n }\n .label {\n border: 1px solid #000;\n }\n .table {\n border-collapse: collapse !important;\n }\n .table td,\n .table th {\n background-color: #fff !important;\n }\n .table-bordered th,\n .table-bordered td {\n border: 1px solid #ddd !important;\n }\n}\n@font-face {\n font-family: 'Glyphicons Halflings';\n src: url('../fonts/glyphicons-halflings-regular.eot');\n src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');\n}\n.glyphicon {\n position: relative;\n top: 1px;\n display: inline-block;\n font-family: 'Glyphicons Halflings';\n font-style: normal;\n font-weight: normal;\n line-height: 1;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n.glyphicon-asterisk:before {\n content: \"\\002a\";\n}\n.glyphicon-plus:before {\n content: \"\\002b\";\n}\n.glyphicon-euro:before,\n.glyphicon-eur:before {\n content: \"\\20ac\";\n}\n.glyphicon-minus:before {\n content: \"\\2212\";\n}\n.glyphicon-cloud:before {\n content: \"\\2601\";\n}\n.glyphicon-envelope:before {\n content: \"\\2709\";\n}\n.glyphicon-pencil:before {\n content: \"\\270f\";\n}\n.glyphicon-glass:before {\n content: \"\\e001\";\n}\n.glyphicon-music:before {\n content: \"\\e002\";\n}\n.glyphicon-search:before {\n content: \"\\e003\";\n}\n.glyphicon-heart:before {\n content: \"\\e005\";\n}\n.glyphicon-star:before {\n content: \"\\e006\";\n}\n.glyphicon-star-empty:before {\n content: \"\\e007\";\n}\n.glyphicon-user:before {\n content: \"\\e008\";\n}\n.glyphicon-film:before {\n content: \"\\e009\";\n}\n.glyphicon-th-large:before {\n content: \"\\e010\";\n}\n.glyphicon-th:before {\n content: \"\\e011\";\n}\n.glyphicon-th-list:before {\n content: \"\\e012\";\n}\n.glyphicon-ok:before {\n content: \"\\e013\";\n}\n.glyphicon-remove:before {\n content: \"\\e014\";\n}\n.glyphicon-zoom-in:before {\n content: \"\\e015\";\n}\n.glyphicon-zoom-out:before {\n content: \"\\e016\";\n}\n.glyphicon-off:before {\n content: \"\\e017\";\n}\n.glyphicon-signal:before {\n content: \"\\e018\";\n}\n.glyphicon-cog:before {\n content: \"\\e019\";\n}\n.glyphicon-trash:before {\n content: \"\\e020\";\n}\n.glyphicon-home:before {\n content: \"\\e021\";\n}\n.glyphicon-file:before {\n content: \"\\e022\";\n}\n.glyphicon-time:before {\n content: \"\\e023\";\n}\n.glyphicon-road:before {\n content: \"\\e024\";\n}\n.glyphicon-download-alt:before {\n content: \"\\e025\";\n}\n.glyphicon-download:before {\n content: \"\\e026\";\n}\n.glyphicon-upload:before {\n content: \"\\e027\";\n}\n.glyphicon-inbox:before {\n content: \"\\e028\";\n}\n.glyphicon-play-circle:before {\n content: \"\\e029\";\n}\n.glyphicon-repeat:before {\n content: \"\\e030\";\n}\n.glyphicon-refresh:before {\n content: \"\\e031\";\n}\n.glyphicon-list-alt:before {\n content: \"\\e032\";\n}\n.glyphicon-lock:before {\n content: \"\\e033\";\n}\n.glyphicon-flag:before {\n content: \"\\e034\";\n}\n.glyphicon-headphones:before {\n content: \"\\e035\";\n}\n.glyphicon-volume-off:before {\n content: \"\\e036\";\n}\n.glyphicon-volume-down:before {\n content: \"\\e037\";\n}\n.glyphicon-volume-up:before {\n content: \"\\e038\";\n}\n.glyphicon-qrcode:before {\n content: \"\\e039\";\n}\n.glyphicon-barcode:before {\n content: \"\\e040\";\n}\n.glyphicon-tag:before {\n content: \"\\e041\";\n}\n.glyphicon-tags:before {\n content: \"\\e042\";\n}\n.glyphicon-book:before {\n content: \"\\e043\";\n}\n.glyphicon-bookmark:before {\n content: \"\\e044\";\n}\n.glyphicon-print:before {\n content: \"\\e045\";\n}\n.glyphicon-camera:before {\n content: \"\\e046\";\n}\n.glyphicon-font:before {\n content: \"\\e047\";\n}\n.glyphicon-bold:before {\n content: \"\\e048\";\n}\n.glyphicon-italic:before {\n content: \"\\e049\";\n}\n.glyphicon-text-height:before {\n content: \"\\e050\";\n}\n.glyphicon-text-width:before {\n content: \"\\e051\";\n}\n.glyphicon-align-left:before {\n content: \"\\e052\";\n}\n.glyphicon-align-center:before {\n content: \"\\e053\";\n}\n.glyphicon-align-right:before {\n content: \"\\e054\";\n}\n.glyphicon-align-justify:before {\n content: \"\\e055\";\n}\n.glyphicon-list:before {\n content: \"\\e056\";\n}\n.glyphicon-indent-left:before {\n content: \"\\e057\";\n}\n.glyphicon-indent-right:before {\n content: \"\\e058\";\n}\n.glyphicon-facetime-video:before {\n content: \"\\e059\";\n}\n.glyphicon-picture:before {\n content: \"\\e060\";\n}\n.glyphicon-map-marker:before {\n content: \"\\e062\";\n}\n.glyphicon-adjust:before {\n content: \"\\e063\";\n}\n.glyphicon-tint:before {\n content: \"\\e064\";\n}\n.glyphicon-edit:before {\n content: \"\\e065\";\n}\n.glyphicon-share:before {\n content: \"\\e066\";\n}\n.glyphicon-check:before {\n content: \"\\e067\";\n}\n.glyphicon-move:before {\n content: \"\\e068\";\n}\n.glyphicon-step-backward:before {\n content: \"\\e069\";\n}\n.glyphicon-fast-backward:before {\n content: \"\\e070\";\n}\n.glyphicon-backward:before {\n content: \"\\e071\";\n}\n.glyphicon-play:before {\n content: \"\\e072\";\n}\n.glyphicon-pause:before {\n content: \"\\e073\";\n}\n.glyphicon-stop:before {\n content: \"\\e074\";\n}\n.glyphicon-forward:before {\n content: \"\\e075\";\n}\n.glyphicon-fast-forward:before {\n content: \"\\e076\";\n}\n.glyphicon-step-forward:before {\n content: \"\\e077\";\n}\n.glyphicon-eject:before {\n content: \"\\e078\";\n}\n.glyphicon-chevron-left:before {\n content: \"\\e079\";\n}\n.glyphicon-chevron-right:before {\n content: \"\\e080\";\n}\n.glyphicon-plus-sign:before {\n content: \"\\e081\";\n}\n.glyphicon-minus-sign:before {\n content: \"\\e082\";\n}\n.glyphicon-remove-sign:before {\n content: \"\\e083\";\n}\n.glyphicon-ok-sign:before {\n content: \"\\e084\";\n}\n.glyphicon-question-sign:before {\n content: \"\\e085\";\n}\n.glyphicon-info-sign:before {\n content: \"\\e086\";\n}\n.glyphicon-screenshot:before {\n content: \"\\e087\";\n}\n.glyphicon-remove-circle:before {\n content: \"\\e088\";\n}\n.glyphicon-ok-circle:before {\n content: \"\\e089\";\n}\n.glyphicon-ban-circle:before {\n content: \"\\e090\";\n}\n.glyphicon-arrow-left:before {\n content: \"\\e091\";\n}\n.glyphicon-arrow-right:before {\n content: \"\\e092\";\n}\n.glyphicon-arrow-up:before {\n content: \"\\e093\";\n}\n.glyphicon-arrow-down:before {\n content: \"\\e094\";\n}\n.glyphicon-share-alt:before {\n content: \"\\e095\";\n}\n.glyphicon-resize-full:before {\n content: \"\\e096\";\n}\n.glyphicon-resize-small:before {\n content: \"\\e097\";\n}\n.glyphicon-exclamation-sign:before {\n content: \"\\e101\";\n}\n.glyphicon-gift:before {\n content: \"\\e102\";\n}\n.glyphicon-leaf:before {\n content: \"\\e103\";\n}\n.glyphicon-fire:before {\n content: \"\\e104\";\n}\n.glyphicon-eye-open:before {\n content: \"\\e105\";\n}\n.glyphicon-eye-close:before {\n content: \"\\e106\";\n}\n.glyphicon-warning-sign:before {\n content: \"\\e107\";\n}\n.glyphicon-plane:before {\n content: \"\\e108\";\n}\n.glyphicon-calendar:before {\n content: \"\\e109\";\n}\n.glyphicon-random:before {\n content: \"\\e110\";\n}\n.glyphicon-comment:before {\n content: \"\\e111\";\n}\n.glyphicon-magnet:before {\n content: \"\\e112\";\n}\n.glyphicon-chevron-up:before {\n content: \"\\e113\";\n}\n.glyphicon-chevron-down:before {\n content: \"\\e114\";\n}\n.glyphicon-retweet:before {\n content: \"\\e115\";\n}\n.glyphicon-shopping-cart:before {\n content: \"\\e116\";\n}\n.glyphicon-folder-close:before {\n content: \"\\e117\";\n}\n.glyphicon-folder-open:before {\n content: \"\\e118\";\n}\n.glyphicon-resize-vertical:before {\n content: \"\\e119\";\n}\n.glyphicon-resize-horizontal:before {\n content: \"\\e120\";\n}\n.glyphicon-hdd:before {\n content: \"\\e121\";\n}\n.glyphicon-bullhorn:before {\n content: \"\\e122\";\n}\n.glyphicon-bell:before {\n content: \"\\e123\";\n}\n.glyphicon-certificate:before {\n content: \"\\e124\";\n}\n.glyphicon-thumbs-up:before {\n content: \"\\e125\";\n}\n.glyphicon-thumbs-down:before {\n content: \"\\e126\";\n}\n.glyphicon-hand-right:before {\n content: \"\\e127\";\n}\n.glyphicon-hand-left:before {\n content: \"\\e128\";\n}\n.glyphicon-hand-up:before {\n content: \"\\e129\";\n}\n.glyphicon-hand-down:before {\n content: \"\\e130\";\n}\n.glyphicon-circle-arrow-right:before {\n content: \"\\e131\";\n}\n.glyphicon-circle-arrow-left:before {\n content: \"\\e132\";\n}\n.glyphicon-circle-arrow-up:before {\n content: \"\\e133\";\n}\n.glyphicon-circle-arrow-down:before {\n content: \"\\e134\";\n}\n.glyphicon-globe:before {\n content: \"\\e135\";\n}\n.glyphicon-wrench:before {\n content: \"\\e136\";\n}\n.glyphicon-tasks:before {\n content: \"\\e137\";\n}\n.glyphicon-filter:before {\n content: \"\\e138\";\n}\n.glyphicon-briefcase:before {\n content: \"\\e139\";\n}\n.glyphicon-fullscreen:before {\n content: \"\\e140\";\n}\n.glyphicon-dashboard:before {\n content: \"\\e141\";\n}\n.glyphicon-paperclip:before {\n content: \"\\e142\";\n}\n.glyphicon-heart-empty:before {\n content: \"\\e143\";\n}\n.glyphicon-link:before {\n content: \"\\e144\";\n}\n.glyphicon-phone:before {\n content: \"\\e145\";\n}\n.glyphicon-pushpin:before {\n content: \"\\e146\";\n}\n.glyphicon-usd:before {\n content: \"\\e148\";\n}\n.glyphicon-gbp:before {\n content: \"\\e149\";\n}\n.glyphicon-sort:before {\n content: \"\\e150\";\n}\n.glyphicon-sort-by-alphabet:before {\n content: \"\\e151\";\n}\n.glyphicon-sort-by-alphabet-alt:before {\n content: \"\\e152\";\n}\n.glyphicon-sort-by-order:before {\n content: \"\\e153\";\n}\n.glyphicon-sort-by-order-alt:before {\n content: \"\\e154\";\n}\n.glyphicon-sort-by-attributes:before {\n content: \"\\e155\";\n}\n.glyphicon-sort-by-attributes-alt:before {\n content: \"\\e156\";\n}\n.glyphicon-unchecked:before {\n content: \"\\e157\";\n}\n.glyphicon-expand:before {\n content: \"\\e158\";\n}\n.glyphicon-collapse-down:before {\n content: \"\\e159\";\n}\n.glyphicon-collapse-up:before {\n content: \"\\e160\";\n}\n.glyphicon-log-in:before {\n content: \"\\e161\";\n}\n.glyphicon-flash:before {\n content: \"\\e162\";\n}\n.glyphicon-log-out:before {\n content: \"\\e163\";\n}\n.glyphicon-new-window:before {\n content: \"\\e164\";\n}\n.glyphicon-record:before {\n content: \"\\e165\";\n}\n.glyphicon-save:before {\n content: \"\\e166\";\n}\n.glyphicon-open:before {\n content: \"\\e167\";\n}\n.glyphicon-saved:before {\n content: \"\\e168\";\n}\n.glyphicon-import:before {\n content: \"\\e169\";\n}\n.glyphicon-export:before {\n content: \"\\e170\";\n}\n.glyphicon-send:before {\n content: \"\\e171\";\n}\n.glyphicon-floppy-disk:before {\n content: \"\\e172\";\n}\n.glyphicon-floppy-saved:before {\n content: \"\\e173\";\n}\n.glyphicon-floppy-remove:before {\n content: \"\\e174\";\n}\n.glyphicon-floppy-save:before {\n content: \"\\e175\";\n}\n.glyphicon-floppy-open:before {\n content: \"\\e176\";\n}\n.glyphicon-credit-card:before {\n content: \"\\e177\";\n}\n.glyphicon-transfer:before {\n content: \"\\e178\";\n}\n.glyphicon-cutlery:before {\n content: \"\\e179\";\n}\n.glyphicon-header:before {\n content: \"\\e180\";\n}\n.glyphicon-compressed:before {\n content: \"\\e181\";\n}\n.glyphicon-earphone:before {\n content: \"\\e182\";\n}\n.glyphicon-phone-alt:before {\n content: \"\\e183\";\n}\n.glyphicon-tower:before {\n content: \"\\e184\";\n}\n.glyphicon-stats:before {\n content: \"\\e185\";\n}\n.glyphicon-sd-video:before {\n content: \"\\e186\";\n}\n.glyphicon-hd-video:before {\n content: \"\\e187\";\n}\n.glyphicon-subtitles:before {\n content: \"\\e188\";\n}\n.glyphicon-sound-stereo:before {\n content: \"\\e189\";\n}\n.glyphicon-sound-dolby:before {\n content: \"\\e190\";\n}\n.glyphicon-sound-5-1:before {\n content: \"\\e191\";\n}\n.glyphicon-sound-6-1:before {\n content: \"\\e192\";\n}\n.glyphicon-sound-7-1:before {\n content: \"\\e193\";\n}\n.glyphicon-copyright-mark:before {\n content: \"\\e194\";\n}\n.glyphicon-registration-mark:before {\n content: \"\\e195\";\n}\n.glyphicon-cloud-download:before {\n content: \"\\e197\";\n}\n.glyphicon-cloud-upload:before {\n content: \"\\e198\";\n}\n.glyphicon-tree-conifer:before {\n content: \"\\e199\";\n}\n.glyphicon-tree-deciduous:before {\n content: \"\\e200\";\n}\n.glyphicon-cd:before {\n content: \"\\e201\";\n}\n.glyphicon-save-file:before {\n content: \"\\e202\";\n}\n.glyphicon-open-file:before {\n content: \"\\e203\";\n}\n.glyphicon-level-up:before {\n content: \"\\e204\";\n}\n.glyphicon-copy:before {\n content: \"\\e205\";\n}\n.glyphicon-paste:before {\n content: \"\\e206\";\n}\n.glyphicon-alert:before {\n content: \"\\e209\";\n}\n.glyphicon-equalizer:before {\n content: \"\\e210\";\n}\n.glyphicon-king:before {\n content: \"\\e211\";\n}\n.glyphicon-queen:before {\n content: \"\\e212\";\n}\n.glyphicon-pawn:before {\n content: \"\\e213\";\n}\n.glyphicon-bishop:before {\n content: \"\\e214\";\n}\n.glyphicon-knight:before {\n content: \"\\e215\";\n}\n.glyphicon-baby-formula:before {\n content: \"\\e216\";\n}\n.glyphicon-tent:before {\n content: \"\\26fa\";\n}\n.glyphicon-blackboard:before {\n content: \"\\e218\";\n}\n.glyphicon-bed:before {\n content: \"\\e219\";\n}\n.glyphicon-apple:before {\n content: \"\\f8ff\";\n}\n.glyphicon-erase:before {\n content: \"\\e221\";\n}\n.glyphicon-hourglass:before {\n content: \"\\231b\";\n}\n.glyphicon-lamp:before {\n content: \"\\e223\";\n}\n.glyphicon-duplicate:before {\n content: \"\\e224\";\n}\n.glyphicon-piggy-bank:before {\n content: \"\\e225\";\n}\n.glyphicon-scissors:before {\n content: \"\\e226\";\n}\n.glyphicon-bitcoin:before {\n content: \"\\e227\";\n}\n.glyphicon-btc:before {\n content: \"\\e227\";\n}\n.glyphicon-xbt:before {\n content: \"\\e227\";\n}\n.glyphicon-yen:before {\n content: \"\\00a5\";\n}\n.glyphicon-jpy:before {\n content: \"\\00a5\";\n}\n.glyphicon-ruble:before {\n content: \"\\20bd\";\n}\n.glyphicon-rub:before {\n content: \"\\20bd\";\n}\n.glyphicon-scale:before {\n content: \"\\e230\";\n}\n.glyphicon-ice-lolly:before {\n content: \"\\e231\";\n}\n.glyphicon-ice-lolly-tasted:before {\n content: \"\\e232\";\n}\n.glyphicon-education:before {\n content: \"\\e233\";\n}\n.glyphicon-option-horizontal:before {\n content: \"\\e234\";\n}\n.glyphicon-option-vertical:before {\n content: \"\\e235\";\n}\n.glyphicon-menu-hamburger:before {\n content: \"\\e236\";\n}\n.glyphicon-modal-window:before {\n content: \"\\e237\";\n}\n.glyphicon-oil:before {\n content: \"\\e238\";\n}\n.glyphicon-grain:before {\n content: \"\\e239\";\n}\n.glyphicon-sunglasses:before {\n content: \"\\e240\";\n}\n.glyphicon-text-size:before {\n content: \"\\e241\";\n}\n.glyphicon-text-color:before {\n content: \"\\e242\";\n}\n.glyphicon-text-background:before {\n content: \"\\e243\";\n}\n.glyphicon-object-align-top:before {\n content: \"\\e244\";\n}\n.glyphicon-object-align-bottom:before {\n content: \"\\e245\";\n}\n.glyphicon-object-align-horizontal:before {\n content: \"\\e246\";\n}\n.glyphicon-object-align-left:before {\n content: \"\\e247\";\n}\n.glyphicon-object-align-vertical:before {\n content: \"\\e248\";\n}\n.glyphicon-object-align-right:before {\n content: \"\\e249\";\n}\n.glyphicon-triangle-right:before {\n content: \"\\e250\";\n}\n.glyphicon-triangle-left:before {\n content: \"\\e251\";\n}\n.glyphicon-triangle-bottom:before {\n content: \"\\e252\";\n}\n.glyphicon-triangle-top:before {\n content: \"\\e253\";\n}\n.glyphicon-console:before {\n content: \"\\e254\";\n}\n.glyphicon-superscript:before {\n content: \"\\e255\";\n}\n.glyphicon-subscript:before {\n content: \"\\e256\";\n}\n.glyphicon-menu-left:before {\n content: \"\\e257\";\n}\n.glyphicon-menu-right:before {\n content: \"\\e258\";\n}\n.glyphicon-menu-down:before {\n content: \"\\e259\";\n}\n.glyphicon-menu-up:before {\n content: \"\\e260\";\n}\n* {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\n*:before,\n*:after {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\nhtml {\n font-size: 10px;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\nbody {\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 14px;\n line-height: 1.42857143;\n color: #333333;\n background-color: #fff;\n}\ninput,\nbutton,\nselect,\ntextarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\na {\n color: #337ab7;\n text-decoration: none;\n}\na:hover,\na:focus {\n color: #23527c;\n text-decoration: underline;\n}\na:focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\nfigure {\n margin: 0;\n}\nimg {\n vertical-align: middle;\n}\n.img-responsive,\n.thumbnail > img,\n.thumbnail a > img,\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n display: block;\n max-width: 100%;\n height: auto;\n}\n.img-rounded {\n border-radius: 6px;\n}\n.img-thumbnail {\n padding: 4px;\n line-height: 1.42857143;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n -webkit-transition: all 0.2s ease-in-out;\n -o-transition: all 0.2s ease-in-out;\n transition: all 0.2s ease-in-out;\n display: inline-block;\n max-width: 100%;\n height: auto;\n}\n.img-circle {\n border-radius: 50%;\n}\nhr {\n margin-top: 20px;\n margin-bottom: 20px;\n border: 0;\n border-top: 1px solid #eeeeee;\n}\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n border: 0;\n}\n.sr-only-focusable:active,\n.sr-only-focusable:focus {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n overflow: visible;\n clip: auto;\n}\n[role=\"button\"] {\n cursor: pointer;\n}\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\n.h1,\n.h2,\n.h3,\n.h4,\n.h5,\n.h6 {\n font-family: inherit;\n font-weight: 500;\n line-height: 1.1;\n color: inherit;\n}\nh1 small,\nh2 small,\nh3 small,\nh4 small,\nh5 small,\nh6 small,\n.h1 small,\n.h2 small,\n.h3 small,\n.h4 small,\n.h5 small,\n.h6 small,\nh1 .small,\nh2 .small,\nh3 .small,\nh4 .small,\nh5 .small,\nh6 .small,\n.h1 .small,\n.h2 .small,\n.h3 .small,\n.h4 .small,\n.h5 .small,\n.h6 .small {\n font-weight: normal;\n line-height: 1;\n color: #777777;\n}\nh1,\n.h1,\nh2,\n.h2,\nh3,\n.h3 {\n margin-top: 20px;\n margin-bottom: 10px;\n}\nh1 small,\n.h1 small,\nh2 small,\n.h2 small,\nh3 small,\n.h3 small,\nh1 .small,\n.h1 .small,\nh2 .small,\n.h2 .small,\nh3 .small,\n.h3 .small {\n font-size: 65%;\n}\nh4,\n.h4,\nh5,\n.h5,\nh6,\n.h6 {\n margin-top: 10px;\n margin-bottom: 10px;\n}\nh4 small,\n.h4 small,\nh5 small,\n.h5 small,\nh6 small,\n.h6 small,\nh4 .small,\n.h4 .small,\nh5 .small,\n.h5 .small,\nh6 .small,\n.h6 .small {\n font-size: 75%;\n}\nh1,\n.h1 {\n font-size: 36px;\n}\nh2,\n.h2 {\n font-size: 30px;\n}\nh3,\n.h3 {\n font-size: 24px;\n}\nh4,\n.h4 {\n font-size: 18px;\n}\nh5,\n.h5 {\n font-size: 14px;\n}\nh6,\n.h6 {\n font-size: 12px;\n}\np {\n margin: 0 0 10px;\n}\n.lead {\n margin-bottom: 20px;\n font-size: 16px;\n font-weight: 300;\n line-height: 1.4;\n}\n@media (min-width: 768px) {\n .lead {\n font-size: 21px;\n }\n}\nsmall,\n.small {\n font-size: 85%;\n}\nmark,\n.mark {\n background-color: #fcf8e3;\n padding: .2em;\n}\n.text-left {\n text-align: left;\n}\n.text-right {\n text-align: right;\n}\n.text-center {\n text-align: center;\n}\n.text-justify {\n text-align: justify;\n}\n.text-nowrap {\n white-space: nowrap;\n}\n.text-lowercase {\n text-transform: lowercase;\n}\n.text-uppercase {\n text-transform: uppercase;\n}\n.text-capitalize {\n text-transform: capitalize;\n}\n.text-muted {\n color: #777777;\n}\n.text-primary {\n color: #337ab7;\n}\na.text-primary:hover,\na.text-primary:focus {\n color: #286090;\n}\n.text-success {\n color: #3c763d;\n}\na.text-success:hover,\na.text-success:focus {\n color: #2b542c;\n}\n.text-info {\n color: #31708f;\n}\na.text-info:hover,\na.text-info:focus {\n color: #245269;\n}\n.text-warning {\n color: #8a6d3b;\n}\na.text-warning:hover,\na.text-warning:focus {\n color: #66512c;\n}\n.text-danger {\n color: #a94442;\n}\na.text-danger:hover,\na.text-danger:focus {\n color: #843534;\n}\n.bg-primary {\n color: #fff;\n background-color: #337ab7;\n}\na.bg-primary:hover,\na.bg-primary:focus {\n background-color: #286090;\n}\n.bg-success {\n background-color: #dff0d8;\n}\na.bg-success:hover,\na.bg-success:focus {\n background-color: #c1e2b3;\n}\n.bg-info {\n background-color: #d9edf7;\n}\na.bg-info:hover,\na.bg-info:focus {\n background-color: #afd9ee;\n}\n.bg-warning {\n background-color: #fcf8e3;\n}\na.bg-warning:hover,\na.bg-warning:focus {\n background-color: #f7ecb5;\n}\n.bg-danger {\n background-color: #f2dede;\n}\na.bg-danger:hover,\na.bg-danger:focus {\n background-color: #e4b9b9;\n}\n.page-header {\n padding-bottom: 9px;\n margin: 40px 0 20px;\n border-bottom: 1px solid #eeeeee;\n}\nul,\nol {\n margin-top: 0;\n margin-bottom: 10px;\n}\nul ul,\nol ul,\nul ol,\nol ol {\n margin-bottom: 0;\n}\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n.list-inline {\n padding-left: 0;\n list-style: none;\n margin-left: -5px;\n}\n.list-inline > li {\n display: inline-block;\n padding-left: 5px;\n padding-right: 5px;\n}\ndl {\n margin-top: 0;\n margin-bottom: 20px;\n}\ndt,\ndd {\n line-height: 1.42857143;\n}\ndt {\n font-weight: bold;\n}\ndd {\n margin-left: 0;\n}\n@media (min-width: 768px) {\n .dl-horizontal dt {\n float: left;\n width: 160px;\n clear: left;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n .dl-horizontal dd {\n margin-left: 180px;\n }\n}\nabbr[title],\nabbr[data-original-title] {\n cursor: help;\n border-bottom: 1px dotted #777777;\n}\n.initialism {\n font-size: 90%;\n text-transform: uppercase;\n}\nblockquote {\n padding: 10px 20px;\n margin: 0 0 20px;\n font-size: 17.5px;\n border-left: 5px solid #eeeeee;\n}\nblockquote p:last-child,\nblockquote ul:last-child,\nblockquote ol:last-child {\n margin-bottom: 0;\n}\nblockquote footer,\nblockquote small,\nblockquote .small {\n display: block;\n font-size: 80%;\n line-height: 1.42857143;\n color: #777777;\n}\nblockquote footer:before,\nblockquote small:before,\nblockquote .small:before {\n content: '\\2014 \\00A0';\n}\n.blockquote-reverse,\nblockquote.pull-right {\n padding-right: 15px;\n padding-left: 0;\n border-right: 5px solid #eeeeee;\n border-left: 0;\n text-align: right;\n}\n.blockquote-reverse footer:before,\nblockquote.pull-right footer:before,\n.blockquote-reverse small:before,\nblockquote.pull-right small:before,\n.blockquote-reverse .small:before,\nblockquote.pull-right .small:before {\n content: '';\n}\n.blockquote-reverse footer:after,\nblockquote.pull-right footer:after,\n.blockquote-reverse small:after,\nblockquote.pull-right small:after,\n.blockquote-reverse .small:after,\nblockquote.pull-right .small:after {\n content: '\\00A0 \\2014';\n}\naddress {\n margin-bottom: 20px;\n font-style: normal;\n line-height: 1.42857143;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: Menlo, Monaco, Consolas, \"Courier New\", monospace;\n}\ncode {\n padding: 2px 4px;\n font-size: 90%;\n color: #c7254e;\n background-color: #f9f2f4;\n border-radius: 4px;\n}\nkbd {\n padding: 2px 4px;\n font-size: 90%;\n color: #fff;\n background-color: #333;\n border-radius: 3px;\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25);\n}\nkbd kbd {\n padding: 0;\n font-size: 100%;\n font-weight: bold;\n box-shadow: none;\n}\npre {\n display: block;\n padding: 9.5px;\n margin: 0 0 10px;\n font-size: 13px;\n line-height: 1.42857143;\n word-break: break-all;\n word-wrap: break-word;\n color: #333333;\n background-color: #f5f5f5;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\npre code {\n padding: 0;\n font-size: inherit;\n color: inherit;\n white-space: pre-wrap;\n background-color: transparent;\n border-radius: 0;\n}\n.pre-scrollable {\n max-height: 340px;\n overflow-y: scroll;\n}\n.container {\n margin-right: auto;\n margin-left: auto;\n padding-left: 15px;\n padding-right: 15px;\n}\n@media (min-width: 768px) {\n .container {\n width: 750px;\n }\n}\n@media (min-width: 992px) {\n .container {\n width: 970px;\n }\n}\n@media (min-width: 1200px) {\n .container {\n width: 1170px;\n }\n}\n.container-fluid {\n margin-right: auto;\n margin-left: auto;\n padding-left: 15px;\n padding-right: 15px;\n}\n.row {\n margin-left: -15px;\n margin-right: -15px;\n}\n.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 {\n position: relative;\n min-height: 1px;\n padding-left: 15px;\n padding-right: 15px;\n}\n.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 {\n float: left;\n}\n.col-xs-12 {\n width: 100%;\n}\n.col-xs-11 {\n width: 91.66666667%;\n}\n.col-xs-10 {\n width: 83.33333333%;\n}\n.col-xs-9 {\n width: 75%;\n}\n.col-xs-8 {\n width: 66.66666667%;\n}\n.col-xs-7 {\n width: 58.33333333%;\n}\n.col-xs-6 {\n width: 50%;\n}\n.col-xs-5 {\n width: 41.66666667%;\n}\n.col-xs-4 {\n width: 33.33333333%;\n}\n.col-xs-3 {\n width: 25%;\n}\n.col-xs-2 {\n width: 16.66666667%;\n}\n.col-xs-1 {\n width: 8.33333333%;\n}\n.col-xs-pull-12 {\n right: 100%;\n}\n.col-xs-pull-11 {\n right: 91.66666667%;\n}\n.col-xs-pull-10 {\n right: 83.33333333%;\n}\n.col-xs-pull-9 {\n right: 75%;\n}\n.col-xs-pull-8 {\n right: 66.66666667%;\n}\n.col-xs-pull-7 {\n right: 58.33333333%;\n}\n.col-xs-pull-6 {\n right: 50%;\n}\n.col-xs-pull-5 {\n right: 41.66666667%;\n}\n.col-xs-pull-4 {\n right: 33.33333333%;\n}\n.col-xs-pull-3 {\n right: 25%;\n}\n.col-xs-pull-2 {\n right: 16.66666667%;\n}\n.col-xs-pull-1 {\n right: 8.33333333%;\n}\n.col-xs-pull-0 {\n right: auto;\n}\n.col-xs-push-12 {\n left: 100%;\n}\n.col-xs-push-11 {\n left: 91.66666667%;\n}\n.col-xs-push-10 {\n left: 83.33333333%;\n}\n.col-xs-push-9 {\n left: 75%;\n}\n.col-xs-push-8 {\n left: 66.66666667%;\n}\n.col-xs-push-7 {\n left: 58.33333333%;\n}\n.col-xs-push-6 {\n left: 50%;\n}\n.col-xs-push-5 {\n left: 41.66666667%;\n}\n.col-xs-push-4 {\n left: 33.33333333%;\n}\n.col-xs-push-3 {\n left: 25%;\n}\n.col-xs-push-2 {\n left: 16.66666667%;\n}\n.col-xs-push-1 {\n left: 8.33333333%;\n}\n.col-xs-push-0 {\n left: auto;\n}\n.col-xs-offset-12 {\n margin-left: 100%;\n}\n.col-xs-offset-11 {\n margin-left: 91.66666667%;\n}\n.col-xs-offset-10 {\n margin-left: 83.33333333%;\n}\n.col-xs-offset-9 {\n margin-left: 75%;\n}\n.col-xs-offset-8 {\n margin-left: 66.66666667%;\n}\n.col-xs-offset-7 {\n margin-left: 58.33333333%;\n}\n.col-xs-offset-6 {\n margin-left: 50%;\n}\n.col-xs-offset-5 {\n margin-left: 41.66666667%;\n}\n.col-xs-offset-4 {\n margin-left: 33.33333333%;\n}\n.col-xs-offset-3 {\n margin-left: 25%;\n}\n.col-xs-offset-2 {\n margin-left: 16.66666667%;\n}\n.col-xs-offset-1 {\n margin-left: 8.33333333%;\n}\n.col-xs-offset-0 {\n margin-left: 0%;\n}\n@media (min-width: 768px) {\n .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 {\n float: left;\n }\n .col-sm-12 {\n width: 100%;\n }\n .col-sm-11 {\n width: 91.66666667%;\n }\n .col-sm-10 {\n width: 83.33333333%;\n }\n .col-sm-9 {\n width: 75%;\n }\n .col-sm-8 {\n width: 66.66666667%;\n }\n .col-sm-7 {\n width: 58.33333333%;\n }\n .col-sm-6 {\n width: 50%;\n }\n .col-sm-5 {\n width: 41.66666667%;\n }\n .col-sm-4 {\n width: 33.33333333%;\n }\n .col-sm-3 {\n width: 25%;\n }\n .col-sm-2 {\n width: 16.66666667%;\n }\n .col-sm-1 {\n width: 8.33333333%;\n }\n .col-sm-pull-12 {\n right: 100%;\n }\n .col-sm-pull-11 {\n right: 91.66666667%;\n }\n .col-sm-pull-10 {\n right: 83.33333333%;\n }\n .col-sm-pull-9 {\n right: 75%;\n }\n .col-sm-pull-8 {\n right: 66.66666667%;\n }\n .col-sm-pull-7 {\n right: 58.33333333%;\n }\n .col-sm-pull-6 {\n right: 50%;\n }\n .col-sm-pull-5 {\n right: 41.66666667%;\n }\n .col-sm-pull-4 {\n right: 33.33333333%;\n }\n .col-sm-pull-3 {\n right: 25%;\n }\n .col-sm-pull-2 {\n right: 16.66666667%;\n }\n .col-sm-pull-1 {\n right: 8.33333333%;\n }\n .col-sm-pull-0 {\n right: auto;\n }\n .col-sm-push-12 {\n left: 100%;\n }\n .col-sm-push-11 {\n left: 91.66666667%;\n }\n .col-sm-push-10 {\n left: 83.33333333%;\n }\n .col-sm-push-9 {\n left: 75%;\n }\n .col-sm-push-8 {\n left: 66.66666667%;\n }\n .col-sm-push-7 {\n left: 58.33333333%;\n }\n .col-sm-push-6 {\n left: 50%;\n }\n .col-sm-push-5 {\n left: 41.66666667%;\n }\n .col-sm-push-4 {\n left: 33.33333333%;\n }\n .col-sm-push-3 {\n left: 25%;\n }\n .col-sm-push-2 {\n left: 16.66666667%;\n }\n .col-sm-push-1 {\n left: 8.33333333%;\n }\n .col-sm-push-0 {\n left: auto;\n }\n .col-sm-offset-12 {\n margin-left: 100%;\n }\n .col-sm-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-sm-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-sm-offset-9 {\n margin-left: 75%;\n }\n .col-sm-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-sm-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-sm-offset-6 {\n margin-left: 50%;\n }\n .col-sm-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-sm-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-sm-offset-3 {\n margin-left: 25%;\n }\n .col-sm-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-sm-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-sm-offset-0 {\n margin-left: 0%;\n }\n}\n@media (min-width: 992px) {\n .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 {\n float: left;\n }\n .col-md-12 {\n width: 100%;\n }\n .col-md-11 {\n width: 91.66666667%;\n }\n .col-md-10 {\n width: 83.33333333%;\n }\n .col-md-9 {\n width: 75%;\n }\n .col-md-8 {\n width: 66.66666667%;\n }\n .col-md-7 {\n width: 58.33333333%;\n }\n .col-md-6 {\n width: 50%;\n }\n .col-md-5 {\n width: 41.66666667%;\n }\n .col-md-4 {\n width: 33.33333333%;\n }\n .col-md-3 {\n width: 25%;\n }\n .col-md-2 {\n width: 16.66666667%;\n }\n .col-md-1 {\n width: 8.33333333%;\n }\n .col-md-pull-12 {\n right: 100%;\n }\n .col-md-pull-11 {\n right: 91.66666667%;\n }\n .col-md-pull-10 {\n right: 83.33333333%;\n }\n .col-md-pull-9 {\n right: 75%;\n }\n .col-md-pull-8 {\n right: 66.66666667%;\n }\n .col-md-pull-7 {\n right: 58.33333333%;\n }\n .col-md-pull-6 {\n right: 50%;\n }\n .col-md-pull-5 {\n right: 41.66666667%;\n }\n .col-md-pull-4 {\n right: 33.33333333%;\n }\n .col-md-pull-3 {\n right: 25%;\n }\n .col-md-pull-2 {\n right: 16.66666667%;\n }\n .col-md-pull-1 {\n right: 8.33333333%;\n }\n .col-md-pull-0 {\n right: auto;\n }\n .col-md-push-12 {\n left: 100%;\n }\n .col-md-push-11 {\n left: 91.66666667%;\n }\n .col-md-push-10 {\n left: 83.33333333%;\n }\n .col-md-push-9 {\n left: 75%;\n }\n .col-md-push-8 {\n left: 66.66666667%;\n }\n .col-md-push-7 {\n left: 58.33333333%;\n }\n .col-md-push-6 {\n left: 50%;\n }\n .col-md-push-5 {\n left: 41.66666667%;\n }\n .col-md-push-4 {\n left: 33.33333333%;\n }\n .col-md-push-3 {\n left: 25%;\n }\n .col-md-push-2 {\n left: 16.66666667%;\n }\n .col-md-push-1 {\n left: 8.33333333%;\n }\n .col-md-push-0 {\n left: auto;\n }\n .col-md-offset-12 {\n margin-left: 100%;\n }\n .col-md-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-md-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-md-offset-9 {\n margin-left: 75%;\n }\n .col-md-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-md-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-md-offset-6 {\n margin-left: 50%;\n }\n .col-md-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-md-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-md-offset-3 {\n margin-left: 25%;\n }\n .col-md-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-md-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-md-offset-0 {\n margin-left: 0%;\n }\n}\n@media (min-width: 1200px) {\n .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 {\n float: left;\n }\n .col-lg-12 {\n width: 100%;\n }\n .col-lg-11 {\n width: 91.66666667%;\n }\n .col-lg-10 {\n width: 83.33333333%;\n }\n .col-lg-9 {\n width: 75%;\n }\n .col-lg-8 {\n width: 66.66666667%;\n }\n .col-lg-7 {\n width: 58.33333333%;\n }\n .col-lg-6 {\n width: 50%;\n }\n .col-lg-5 {\n width: 41.66666667%;\n }\n .col-lg-4 {\n width: 33.33333333%;\n }\n .col-lg-3 {\n width: 25%;\n }\n .col-lg-2 {\n width: 16.66666667%;\n }\n .col-lg-1 {\n width: 8.33333333%;\n }\n .col-lg-pull-12 {\n right: 100%;\n }\n .col-lg-pull-11 {\n right: 91.66666667%;\n }\n .col-lg-pull-10 {\n right: 83.33333333%;\n }\n .col-lg-pull-9 {\n right: 75%;\n }\n .col-lg-pull-8 {\n right: 66.66666667%;\n }\n .col-lg-pull-7 {\n right: 58.33333333%;\n }\n .col-lg-pull-6 {\n right: 50%;\n }\n .col-lg-pull-5 {\n right: 41.66666667%;\n }\n .col-lg-pull-4 {\n right: 33.33333333%;\n }\n .col-lg-pull-3 {\n right: 25%;\n }\n .col-lg-pull-2 {\n right: 16.66666667%;\n }\n .col-lg-pull-1 {\n right: 8.33333333%;\n }\n .col-lg-pull-0 {\n right: auto;\n }\n .col-lg-push-12 {\n left: 100%;\n }\n .col-lg-push-11 {\n left: 91.66666667%;\n }\n .col-lg-push-10 {\n left: 83.33333333%;\n }\n .col-lg-push-9 {\n left: 75%;\n }\n .col-lg-push-8 {\n left: 66.66666667%;\n }\n .col-lg-push-7 {\n left: 58.33333333%;\n }\n .col-lg-push-6 {\n left: 50%;\n }\n .col-lg-push-5 {\n left: 41.66666667%;\n }\n .col-lg-push-4 {\n left: 33.33333333%;\n }\n .col-lg-push-3 {\n left: 25%;\n }\n .col-lg-push-2 {\n left: 16.66666667%;\n }\n .col-lg-push-1 {\n left: 8.33333333%;\n }\n .col-lg-push-0 {\n left: auto;\n }\n .col-lg-offset-12 {\n margin-left: 100%;\n }\n .col-lg-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-lg-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-lg-offset-9 {\n margin-left: 75%;\n }\n .col-lg-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-lg-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-lg-offset-6 {\n margin-left: 50%;\n }\n .col-lg-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-lg-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-lg-offset-3 {\n margin-left: 25%;\n }\n .col-lg-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-lg-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-lg-offset-0 {\n margin-left: 0%;\n }\n}\ntable {\n background-color: transparent;\n}\ncaption {\n padding-top: 8px;\n padding-bottom: 8px;\n color: #777777;\n text-align: left;\n}\nth {\n text-align: left;\n}\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: 20px;\n}\n.table > thead > tr > th,\n.table > tbody > tr > th,\n.table > tfoot > tr > th,\n.table > thead > tr > td,\n.table > tbody > tr > td,\n.table > tfoot > tr > td {\n padding: 8px;\n line-height: 1.42857143;\n vertical-align: top;\n border-top: 1px solid #ddd;\n}\n.table > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid #ddd;\n}\n.table > caption + thead > tr:first-child > th,\n.table > colgroup + thead > tr:first-child > th,\n.table > thead:first-child > tr:first-child > th,\n.table > caption + thead > tr:first-child > td,\n.table > colgroup + thead > tr:first-child > td,\n.table > thead:first-child > tr:first-child > td {\n border-top: 0;\n}\n.table > tbody + tbody {\n border-top: 2px solid #ddd;\n}\n.table .table {\n background-color: #fff;\n}\n.table-condensed > thead > tr > th,\n.table-condensed > tbody > tr > th,\n.table-condensed > tfoot > tr > th,\n.table-condensed > thead > tr > td,\n.table-condensed > tbody > tr > td,\n.table-condensed > tfoot > tr > td {\n padding: 5px;\n}\n.table-bordered {\n border: 1px solid #ddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > tbody > tr > th,\n.table-bordered > tfoot > tr > th,\n.table-bordered > thead > tr > td,\n.table-bordered > tbody > tr > td,\n.table-bordered > tfoot > tr > td {\n border: 1px solid #ddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > thead > tr > td {\n border-bottom-width: 2px;\n}\n.table-striped > tbody > tr:nth-of-type(odd) {\n background-color: #f9f9f9;\n}\n.table-hover > tbody > tr:hover {\n background-color: #f5f5f5;\n}\ntable col[class*=\"col-\"] {\n position: static;\n float: none;\n display: table-column;\n}\ntable td[class*=\"col-\"],\ntable th[class*=\"col-\"] {\n position: static;\n float: none;\n display: table-cell;\n}\n.table > thead > tr > td.active,\n.table > tbody > tr > td.active,\n.table > tfoot > tr > td.active,\n.table > thead > tr > th.active,\n.table > tbody > tr > th.active,\n.table > tfoot > tr > th.active,\n.table > thead > tr.active > td,\n.table > tbody > tr.active > td,\n.table > tfoot > tr.active > td,\n.table > thead > tr.active > th,\n.table > tbody > tr.active > th,\n.table > tfoot > tr.active > th {\n background-color: #f5f5f5;\n}\n.table-hover > tbody > tr > td.active:hover,\n.table-hover > tbody > tr > th.active:hover,\n.table-hover > tbody > tr.active:hover > td,\n.table-hover > tbody > tr:hover > .active,\n.table-hover > tbody > tr.active:hover > th {\n background-color: #e8e8e8;\n}\n.table > thead > tr > td.success,\n.table > tbody > tr > td.success,\n.table > tfoot > tr > td.success,\n.table > thead > tr > th.success,\n.table > tbody > tr > th.success,\n.table > tfoot > tr > th.success,\n.table > thead > tr.success > td,\n.table > tbody > tr.success > td,\n.table > tfoot > tr.success > td,\n.table > thead > tr.success > th,\n.table > tbody > tr.success > th,\n.table > tfoot > tr.success > th {\n background-color: #dff0d8;\n}\n.table-hover > tbody > tr > td.success:hover,\n.table-hover > tbody > tr > th.success:hover,\n.table-hover > tbody > tr.success:hover > td,\n.table-hover > tbody > tr:hover > .success,\n.table-hover > tbody > tr.success:hover > th {\n background-color: #d0e9c6;\n}\n.table > thead > tr > td.info,\n.table > tbody > tr > td.info,\n.table > tfoot > tr > td.info,\n.table > thead > tr > th.info,\n.table > tbody > tr > th.info,\n.table > tfoot > tr > th.info,\n.table > thead > tr.info > td,\n.table > tbody > tr.info > td,\n.table > tfoot > tr.info > td,\n.table > thead > tr.info > th,\n.table > tbody > tr.info > th,\n.table > tfoot > tr.info > th {\n background-color: #d9edf7;\n}\n.table-hover > tbody > tr > td.info:hover,\n.table-hover > tbody > tr > th.info:hover,\n.table-hover > tbody > tr.info:hover > td,\n.table-hover > tbody > tr:hover > .info,\n.table-hover > tbody > tr.info:hover > th {\n background-color: #c4e3f3;\n}\n.table > thead > tr > td.warning,\n.table > tbody > tr > td.warning,\n.table > tfoot > tr > td.warning,\n.table > thead > tr > th.warning,\n.table > tbody > tr > th.warning,\n.table > tfoot > tr > th.warning,\n.table > thead > tr.warning > td,\n.table > tbody > tr.warning > td,\n.table > tfoot > tr.warning > td,\n.table > thead > tr.warning > th,\n.table > tbody > tr.warning > th,\n.table > tfoot > tr.warning > th {\n background-color: #fcf8e3;\n}\n.table-hover > tbody > tr > td.warning:hover,\n.table-hover > tbody > tr > th.warning:hover,\n.table-hover > tbody > tr.warning:hover > td,\n.table-hover > tbody > tr:hover > .warning,\n.table-hover > tbody > tr.warning:hover > th {\n background-color: #faf2cc;\n}\n.table > thead > tr > td.danger,\n.table > tbody > tr > td.danger,\n.table > tfoot > tr > td.danger,\n.table > thead > tr > th.danger,\n.table > tbody > tr > th.danger,\n.table > tfoot > tr > th.danger,\n.table > thead > tr.danger > td,\n.table > tbody > tr.danger > td,\n.table > tfoot > tr.danger > td,\n.table > thead > tr.danger > th,\n.table > tbody > tr.danger > th,\n.table > tfoot > tr.danger > th {\n background-color: #f2dede;\n}\n.table-hover > tbody > tr > td.danger:hover,\n.table-hover > tbody > tr > th.danger:hover,\n.table-hover > tbody > tr.danger:hover > td,\n.table-hover > tbody > tr:hover > .danger,\n.table-hover > tbody > tr.danger:hover > th {\n background-color: #ebcccc;\n}\n.table-responsive {\n overflow-x: auto;\n min-height: 0.01%;\n}\n@media screen and (max-width: 767px) {\n .table-responsive {\n width: 100%;\n margin-bottom: 15px;\n overflow-y: hidden;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n border: 1px solid #ddd;\n }\n .table-responsive > .table {\n margin-bottom: 0;\n }\n .table-responsive > .table > thead > tr > th,\n .table-responsive > .table > tbody > tr > th,\n .table-responsive > .table > tfoot > tr > th,\n .table-responsive > .table > thead > tr > td,\n .table-responsive > .table > tbody > tr > td,\n .table-responsive > .table > tfoot > tr > td {\n white-space: nowrap;\n }\n .table-responsive > .table-bordered {\n border: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:first-child,\n .table-responsive > .table-bordered > tbody > tr > th:first-child,\n .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n .table-responsive > .table-bordered > thead > tr > td:first-child,\n .table-responsive > .table-bordered > tbody > tr > td:first-child,\n .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:last-child,\n .table-responsive > .table-bordered > tbody > tr > th:last-child,\n .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n .table-responsive > .table-bordered > thead > tr > td:last-child,\n .table-responsive > .table-bordered > tbody > tr > td:last-child,\n .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n }\n .table-responsive > .table-bordered > tbody > tr:last-child > th,\n .table-responsive > .table-bordered > tfoot > tr:last-child > th,\n .table-responsive > .table-bordered > tbody > tr:last-child > td,\n .table-responsive > .table-bordered > tfoot > tr:last-child > td {\n border-bottom: 0;\n }\n}\nfieldset {\n padding: 0;\n margin: 0;\n border: 0;\n min-width: 0;\n}\nlegend {\n display: block;\n width: 100%;\n padding: 0;\n margin-bottom: 20px;\n font-size: 21px;\n line-height: inherit;\n color: #333333;\n border: 0;\n border-bottom: 1px solid #e5e5e5;\n}\nlabel {\n display: inline-block;\n max-width: 100%;\n margin-bottom: 5px;\n font-weight: bold;\n}\ninput[type=\"search\"] {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n margin: 4px 0 0;\n margin-top: 1px \\9;\n line-height: normal;\n}\ninput[type=\"file\"] {\n display: block;\n}\ninput[type=\"range\"] {\n display: block;\n width: 100%;\n}\nselect[multiple],\nselect[size] {\n height: auto;\n}\ninput[type=\"file\"]:focus,\ninput[type=\"radio\"]:focus,\ninput[type=\"checkbox\"]:focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\noutput {\n display: block;\n padding-top: 7px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555555;\n}\n.form-control {\n display: block;\n width: 100%;\n height: 34px;\n padding: 6px 12px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555555;\n background-color: #fff;\n background-image: none;\n border: 1px solid #ccc;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n -webkit-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n}\n.form-control:focus {\n border-color: #66afe9;\n outline: 0;\n -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);\n box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);\n}\n.form-control::-moz-placeholder {\n color: #999;\n opacity: 1;\n}\n.form-control:-ms-input-placeholder {\n color: #999;\n}\n.form-control::-webkit-input-placeholder {\n color: #999;\n}\n.form-control::-ms-expand {\n border: 0;\n background-color: transparent;\n}\n.form-control[disabled],\n.form-control[readonly],\nfieldset[disabled] .form-control {\n background-color: #eeeeee;\n opacity: 1;\n}\n.form-control[disabled],\nfieldset[disabled] .form-control {\n cursor: not-allowed;\n}\ntextarea.form-control {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-appearance: none;\n}\n@media screen and (-webkit-min-device-pixel-ratio: 0) {\n input[type=\"date\"].form-control,\n input[type=\"time\"].form-control,\n input[type=\"datetime-local\"].form-control,\n input[type=\"month\"].form-control {\n line-height: 34px;\n }\n input[type=\"date\"].input-sm,\n input[type=\"time\"].input-sm,\n input[type=\"datetime-local\"].input-sm,\n input[type=\"month\"].input-sm,\n .input-group-sm input[type=\"date\"],\n .input-group-sm input[type=\"time\"],\n .input-group-sm input[type=\"datetime-local\"],\n .input-group-sm input[type=\"month\"] {\n line-height: 30px;\n }\n input[type=\"date\"].input-lg,\n input[type=\"time\"].input-lg,\n input[type=\"datetime-local\"].input-lg,\n input[type=\"month\"].input-lg,\n .input-group-lg input[type=\"date\"],\n .input-group-lg input[type=\"time\"],\n .input-group-lg input[type=\"datetime-local\"],\n .input-group-lg input[type=\"month\"] {\n line-height: 46px;\n }\n}\n.form-group {\n margin-bottom: 15px;\n}\n.radio,\n.checkbox {\n position: relative;\n display: block;\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.radio label,\n.checkbox label {\n min-height: 20px;\n padding-left: 20px;\n margin-bottom: 0;\n font-weight: normal;\n cursor: pointer;\n}\n.radio input[type=\"radio\"],\n.radio-inline input[type=\"radio\"],\n.checkbox input[type=\"checkbox\"],\n.checkbox-inline input[type=\"checkbox\"] {\n position: absolute;\n margin-left: -20px;\n margin-top: 4px \\9;\n}\n.radio + .radio,\n.checkbox + .checkbox {\n margin-top: -5px;\n}\n.radio-inline,\n.checkbox-inline {\n position: relative;\n display: inline-block;\n padding-left: 20px;\n margin-bottom: 0;\n vertical-align: middle;\n font-weight: normal;\n cursor: pointer;\n}\n.radio-inline + .radio-inline,\n.checkbox-inline + .checkbox-inline {\n margin-top: 0;\n margin-left: 10px;\n}\ninput[type=\"radio\"][disabled],\ninput[type=\"checkbox\"][disabled],\ninput[type=\"radio\"].disabled,\ninput[type=\"checkbox\"].disabled,\nfieldset[disabled] input[type=\"radio\"],\nfieldset[disabled] input[type=\"checkbox\"] {\n cursor: not-allowed;\n}\n.radio-inline.disabled,\n.checkbox-inline.disabled,\nfieldset[disabled] .radio-inline,\nfieldset[disabled] .checkbox-inline {\n cursor: not-allowed;\n}\n.radio.disabled label,\n.checkbox.disabled label,\nfieldset[disabled] .radio label,\nfieldset[disabled] .checkbox label {\n cursor: not-allowed;\n}\n.form-control-static {\n padding-top: 7px;\n padding-bottom: 7px;\n margin-bottom: 0;\n min-height: 34px;\n}\n.form-control-static.input-lg,\n.form-control-static.input-sm {\n padding-left: 0;\n padding-right: 0;\n}\n.input-sm {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-sm {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-sm,\nselect[multiple].input-sm {\n height: auto;\n}\n.form-group-sm .form-control {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.form-group-sm select.form-control {\n height: 30px;\n line-height: 30px;\n}\n.form-group-sm textarea.form-control,\n.form-group-sm select[multiple].form-control {\n height: auto;\n}\n.form-group-sm .form-control-static {\n height: 30px;\n min-height: 32px;\n padding: 6px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.input-lg {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-lg {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-lg,\nselect[multiple].input-lg {\n height: auto;\n}\n.form-group-lg .form-control {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.form-group-lg select.form-control {\n height: 46px;\n line-height: 46px;\n}\n.form-group-lg textarea.form-control,\n.form-group-lg select[multiple].form-control {\n height: auto;\n}\n.form-group-lg .form-control-static {\n height: 46px;\n min-height: 38px;\n padding: 11px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.has-feedback {\n position: relative;\n}\n.has-feedback .form-control {\n padding-right: 42.5px;\n}\n.form-control-feedback {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2;\n display: block;\n width: 34px;\n height: 34px;\n line-height: 34px;\n text-align: center;\n pointer-events: none;\n}\n.input-lg + .form-control-feedback,\n.input-group-lg + .form-control-feedback,\n.form-group-lg .form-control + .form-control-feedback {\n width: 46px;\n height: 46px;\n line-height: 46px;\n}\n.input-sm + .form-control-feedback,\n.input-group-sm + .form-control-feedback,\n.form-group-sm .form-control + .form-control-feedback {\n width: 30px;\n height: 30px;\n line-height: 30px;\n}\n.has-success .help-block,\n.has-success .control-label,\n.has-success .radio,\n.has-success .checkbox,\n.has-success .radio-inline,\n.has-success .checkbox-inline,\n.has-success.radio label,\n.has-success.checkbox label,\n.has-success.radio-inline label,\n.has-success.checkbox-inline label {\n color: #3c763d;\n}\n.has-success .form-control {\n border-color: #3c763d;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-success .form-control:focus {\n border-color: #2b542c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;\n}\n.has-success .input-group-addon {\n color: #3c763d;\n border-color: #3c763d;\n background-color: #dff0d8;\n}\n.has-success .form-control-feedback {\n color: #3c763d;\n}\n.has-warning .help-block,\n.has-warning .control-label,\n.has-warning .radio,\n.has-warning .checkbox,\n.has-warning .radio-inline,\n.has-warning .checkbox-inline,\n.has-warning.radio label,\n.has-warning.checkbox label,\n.has-warning.radio-inline label,\n.has-warning.checkbox-inline label {\n color: #8a6d3b;\n}\n.has-warning .form-control {\n border-color: #8a6d3b;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-warning .form-control:focus {\n border-color: #66512c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;\n}\n.has-warning .input-group-addon {\n color: #8a6d3b;\n border-color: #8a6d3b;\n background-color: #fcf8e3;\n}\n.has-warning .form-control-feedback {\n color: #8a6d3b;\n}\n.has-error .help-block,\n.has-error .control-label,\n.has-error .radio,\n.has-error .checkbox,\n.has-error .radio-inline,\n.has-error .checkbox-inline,\n.has-error.radio label,\n.has-error.checkbox label,\n.has-error.radio-inline label,\n.has-error.checkbox-inline label {\n color: #a94442;\n}\n.has-error .form-control {\n border-color: #a94442;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-error .form-control:focus {\n border-color: #843534;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;\n}\n.has-error .input-group-addon {\n color: #a94442;\n border-color: #a94442;\n background-color: #f2dede;\n}\n.has-error .form-control-feedback {\n color: #a94442;\n}\n.has-feedback label ~ .form-control-feedback {\n top: 25px;\n}\n.has-feedback label.sr-only ~ .form-control-feedback {\n top: 0;\n}\n.help-block {\n display: block;\n margin-top: 5px;\n margin-bottom: 10px;\n color: #737373;\n}\n@media (min-width: 768px) {\n .form-inline .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .form-inline .form-control-static {\n display: inline-block;\n }\n .form-inline .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .form-inline .input-group .input-group-addon,\n .form-inline .input-group .input-group-btn,\n .form-inline .input-group .form-control {\n width: auto;\n }\n .form-inline .input-group > .form-control {\n width: 100%;\n }\n .form-inline .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio,\n .form-inline .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio label,\n .form-inline .checkbox label {\n padding-left: 0;\n }\n .form-inline .radio input[type=\"radio\"],\n .form-inline .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .form-inline .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox,\n.form-horizontal .radio-inline,\n.form-horizontal .checkbox-inline {\n margin-top: 0;\n margin-bottom: 0;\n padding-top: 7px;\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox {\n min-height: 27px;\n}\n.form-horizontal .form-group {\n margin-left: -15px;\n margin-right: -15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .control-label {\n text-align: right;\n margin-bottom: 0;\n padding-top: 7px;\n }\n}\n.form-horizontal .has-feedback .form-control-feedback {\n right: 15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-lg .control-label {\n padding-top: 11px;\n font-size: 18px;\n }\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-sm .control-label {\n padding-top: 6px;\n font-size: 12px;\n }\n}\n.btn {\n display: inline-block;\n margin-bottom: 0;\n font-weight: normal;\n text-align: center;\n vertical-align: middle;\n touch-action: manipulation;\n cursor: pointer;\n background-image: none;\n border: 1px solid transparent;\n white-space: nowrap;\n padding: 6px 12px;\n font-size: 14px;\n line-height: 1.42857143;\n border-radius: 4px;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n.btn:focus,\n.btn:active:focus,\n.btn.active:focus,\n.btn.focus,\n.btn:active.focus,\n.btn.active.focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\n.btn:hover,\n.btn:focus,\n.btn.focus {\n color: #333;\n text-decoration: none;\n}\n.btn:active,\n.btn.active {\n outline: 0;\n background-image: none;\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n}\n.btn.disabled,\n.btn[disabled],\nfieldset[disabled] .btn {\n cursor: not-allowed;\n opacity: 0.65;\n filter: alpha(opacity=65);\n -webkit-box-shadow: none;\n box-shadow: none;\n}\na.btn.disabled,\nfieldset[disabled] a.btn {\n pointer-events: none;\n}\n.btn-default {\n color: #333;\n background-color: #fff;\n border-color: #ccc;\n}\n.btn-default:focus,\n.btn-default.focus {\n color: #333;\n background-color: #e6e6e6;\n border-color: #8c8c8c;\n}\n.btn-default:hover {\n color: #333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n color: #333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active:hover,\n.btn-default.active:hover,\n.open > .dropdown-toggle.btn-default:hover,\n.btn-default:active:focus,\n.btn-default.active:focus,\n.open > .dropdown-toggle.btn-default:focus,\n.btn-default:active.focus,\n.btn-default.active.focus,\n.open > .dropdown-toggle.btn-default.focus {\n color: #333;\n background-color: #d4d4d4;\n border-color: #8c8c8c;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n background-image: none;\n}\n.btn-default.disabled:hover,\n.btn-default[disabled]:hover,\nfieldset[disabled] .btn-default:hover,\n.btn-default.disabled:focus,\n.btn-default[disabled]:focus,\nfieldset[disabled] .btn-default:focus,\n.btn-default.disabled.focus,\n.btn-default[disabled].focus,\nfieldset[disabled] .btn-default.focus {\n background-color: #fff;\n border-color: #ccc;\n}\n.btn-default .badge {\n color: #fff;\n background-color: #333;\n}\n.btn-primary {\n color: #fff;\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary:focus,\n.btn-primary.focus {\n color: #fff;\n background-color: #286090;\n border-color: #122b40;\n}\n.btn-primary:hover {\n color: #fff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n color: #fff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active:hover,\n.btn-primary.active:hover,\n.open > .dropdown-toggle.btn-primary:hover,\n.btn-primary:active:focus,\n.btn-primary.active:focus,\n.open > .dropdown-toggle.btn-primary:focus,\n.btn-primary:active.focus,\n.btn-primary.active.focus,\n.open > .dropdown-toggle.btn-primary.focus {\n color: #fff;\n background-color: #204d74;\n border-color: #122b40;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n background-image: none;\n}\n.btn-primary.disabled:hover,\n.btn-primary[disabled]:hover,\nfieldset[disabled] .btn-primary:hover,\n.btn-primary.disabled:focus,\n.btn-primary[disabled]:focus,\nfieldset[disabled] .btn-primary:focus,\n.btn-primary.disabled.focus,\n.btn-primary[disabled].focus,\nfieldset[disabled] .btn-primary.focus {\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.btn-success {\n color: #fff;\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success:focus,\n.btn-success.focus {\n color: #fff;\n background-color: #449d44;\n border-color: #255625;\n}\n.btn-success:hover {\n color: #fff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n color: #fff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active:hover,\n.btn-success.active:hover,\n.open > .dropdown-toggle.btn-success:hover,\n.btn-success:active:focus,\n.btn-success.active:focus,\n.open > .dropdown-toggle.btn-success:focus,\n.btn-success:active.focus,\n.btn-success.active.focus,\n.open > .dropdown-toggle.btn-success.focus {\n color: #fff;\n background-color: #398439;\n border-color: #255625;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n background-image: none;\n}\n.btn-success.disabled:hover,\n.btn-success[disabled]:hover,\nfieldset[disabled] .btn-success:hover,\n.btn-success.disabled:focus,\n.btn-success[disabled]:focus,\nfieldset[disabled] .btn-success:focus,\n.btn-success.disabled.focus,\n.btn-success[disabled].focus,\nfieldset[disabled] .btn-success.focus {\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success .badge {\n color: #5cb85c;\n background-color: #fff;\n}\n.btn-info {\n color: #fff;\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info:focus,\n.btn-info.focus {\n color: #fff;\n background-color: #31b0d5;\n border-color: #1b6d85;\n}\n.btn-info:hover {\n color: #fff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n color: #fff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active:hover,\n.btn-info.active:hover,\n.open > .dropdown-toggle.btn-info:hover,\n.btn-info:active:focus,\n.btn-info.active:focus,\n.open > .dropdown-toggle.btn-info:focus,\n.btn-info:active.focus,\n.btn-info.active.focus,\n.open > .dropdown-toggle.btn-info.focus {\n color: #fff;\n background-color: #269abc;\n border-color: #1b6d85;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n background-image: none;\n}\n.btn-info.disabled:hover,\n.btn-info[disabled]:hover,\nfieldset[disabled] .btn-info:hover,\n.btn-info.disabled:focus,\n.btn-info[disabled]:focus,\nfieldset[disabled] .btn-info:focus,\n.btn-info.disabled.focus,\n.btn-info[disabled].focus,\nfieldset[disabled] .btn-info.focus {\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info .badge {\n color: #5bc0de;\n background-color: #fff;\n}\n.btn-warning {\n color: #fff;\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning:focus,\n.btn-warning.focus {\n color: #fff;\n background-color: #ec971f;\n border-color: #985f0d;\n}\n.btn-warning:hover {\n color: #fff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n color: #fff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active:hover,\n.btn-warning.active:hover,\n.open > .dropdown-toggle.btn-warning:hover,\n.btn-warning:active:focus,\n.btn-warning.active:focus,\n.open > .dropdown-toggle.btn-warning:focus,\n.btn-warning:active.focus,\n.btn-warning.active.focus,\n.open > .dropdown-toggle.btn-warning.focus {\n color: #fff;\n background-color: #d58512;\n border-color: #985f0d;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n background-image: none;\n}\n.btn-warning.disabled:hover,\n.btn-warning[disabled]:hover,\nfieldset[disabled] .btn-warning:hover,\n.btn-warning.disabled:focus,\n.btn-warning[disabled]:focus,\nfieldset[disabled] .btn-warning:focus,\n.btn-warning.disabled.focus,\n.btn-warning[disabled].focus,\nfieldset[disabled] .btn-warning.focus {\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning .badge {\n color: #f0ad4e;\n background-color: #fff;\n}\n.btn-danger {\n color: #fff;\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger:focus,\n.btn-danger.focus {\n color: #fff;\n background-color: #c9302c;\n border-color: #761c19;\n}\n.btn-danger:hover {\n color: #fff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n color: #fff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active:hover,\n.btn-danger.active:hover,\n.open > .dropdown-toggle.btn-danger:hover,\n.btn-danger:active:focus,\n.btn-danger.active:focus,\n.open > .dropdown-toggle.btn-danger:focus,\n.btn-danger:active.focus,\n.btn-danger.active.focus,\n.open > .dropdown-toggle.btn-danger.focus {\n color: #fff;\n background-color: #ac2925;\n border-color: #761c19;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n background-image: none;\n}\n.btn-danger.disabled:hover,\n.btn-danger[disabled]:hover,\nfieldset[disabled] .btn-danger:hover,\n.btn-danger.disabled:focus,\n.btn-danger[disabled]:focus,\nfieldset[disabled] .btn-danger:focus,\n.btn-danger.disabled.focus,\n.btn-danger[disabled].focus,\nfieldset[disabled] .btn-danger.focus {\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger .badge {\n color: #d9534f;\n background-color: #fff;\n}\n.btn-link {\n color: #337ab7;\n font-weight: normal;\n border-radius: 0;\n}\n.btn-link,\n.btn-link:active,\n.btn-link.active,\n.btn-link[disabled],\nfieldset[disabled] .btn-link {\n background-color: transparent;\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn-link,\n.btn-link:hover,\n.btn-link:focus,\n.btn-link:active {\n border-color: transparent;\n}\n.btn-link:hover,\n.btn-link:focus {\n color: #23527c;\n text-decoration: underline;\n background-color: transparent;\n}\n.btn-link[disabled]:hover,\nfieldset[disabled] .btn-link:hover,\n.btn-link[disabled]:focus,\nfieldset[disabled] .btn-link:focus {\n color: #777777;\n text-decoration: none;\n}\n.btn-lg,\n.btn-group-lg > .btn {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.btn-sm,\n.btn-group-sm > .btn {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-xs,\n.btn-group-xs > .btn {\n padding: 1px 5px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-block {\n display: block;\n width: 100%;\n}\n.btn-block + .btn-block {\n margin-top: 5px;\n}\ninput[type=\"submit\"].btn-block,\ninput[type=\"reset\"].btn-block,\ninput[type=\"button\"].btn-block {\n width: 100%;\n}\n.fade {\n opacity: 0;\n -webkit-transition: opacity 0.15s linear;\n -o-transition: opacity 0.15s linear;\n transition: opacity 0.15s linear;\n}\n.fade.in {\n opacity: 1;\n}\n.collapse {\n display: none;\n}\n.collapse.in {\n display: block;\n}\ntr.collapse.in {\n display: table-row;\n}\ntbody.collapse.in {\n display: table-row-group;\n}\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n -webkit-transition-property: height, visibility;\n transition-property: height, visibility;\n -webkit-transition-duration: 0.35s;\n transition-duration: 0.35s;\n -webkit-transition-timing-function: ease;\n transition-timing-function: ease;\n}\n.caret {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 2px;\n vertical-align: middle;\n border-top: 4px dashed;\n border-top: 4px solid \\9;\n border-right: 4px solid transparent;\n border-left: 4px solid transparent;\n}\n.dropup,\n.dropdown {\n position: relative;\n}\n.dropdown-toggle:focus {\n outline: 0;\n}\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: 1000;\n display: none;\n float: left;\n min-width: 160px;\n padding: 5px 0;\n margin: 2px 0 0;\n list-style: none;\n font-size: 14px;\n text-align: left;\n background-color: #fff;\n border: 1px solid #ccc;\n border: 1px solid rgba(0, 0, 0, 0.15);\n border-radius: 4px;\n -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);\n box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);\n background-clip: padding-box;\n}\n.dropdown-menu.pull-right {\n right: 0;\n left: auto;\n}\n.dropdown-menu .divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.dropdown-menu > li > a {\n display: block;\n padding: 3px 20px;\n clear: both;\n font-weight: normal;\n line-height: 1.42857143;\n color: #333333;\n white-space: nowrap;\n}\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n text-decoration: none;\n color: #262626;\n background-color: #f5f5f5;\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n color: #fff;\n text-decoration: none;\n outline: 0;\n background-color: #337ab7;\n}\n.dropdown-menu > .disabled > a,\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n color: #777777;\n}\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n text-decoration: none;\n background-color: transparent;\n background-image: none;\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n cursor: not-allowed;\n}\n.open > .dropdown-menu {\n display: block;\n}\n.open > a {\n outline: 0;\n}\n.dropdown-menu-right {\n left: auto;\n right: 0;\n}\n.dropdown-menu-left {\n left: 0;\n right: auto;\n}\n.dropdown-header {\n display: block;\n padding: 3px 20px;\n font-size: 12px;\n line-height: 1.42857143;\n color: #777777;\n white-space: nowrap;\n}\n.dropdown-backdrop {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n top: 0;\n z-index: 990;\n}\n.pull-right > .dropdown-menu {\n right: 0;\n left: auto;\n}\n.dropup .caret,\n.navbar-fixed-bottom .dropdown .caret {\n border-top: 0;\n border-bottom: 4px dashed;\n border-bottom: 4px solid \\9;\n content: \"\";\n}\n.dropup .dropdown-menu,\n.navbar-fixed-bottom .dropdown .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-bottom: 2px;\n}\n@media (min-width: 768px) {\n .navbar-right .dropdown-menu {\n left: auto;\n right: 0;\n }\n .navbar-right .dropdown-menu-left {\n left: 0;\n right: auto;\n }\n}\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-block;\n vertical-align: middle;\n}\n.btn-group > .btn,\n.btn-group-vertical > .btn {\n position: relative;\n float: left;\n}\n.btn-group > .btn:hover,\n.btn-group-vertical > .btn:hover,\n.btn-group > .btn:focus,\n.btn-group-vertical > .btn:focus,\n.btn-group > .btn:active,\n.btn-group-vertical > .btn:active,\n.btn-group > .btn.active,\n.btn-group-vertical > .btn.active {\n z-index: 2;\n}\n.btn-group .btn + .btn,\n.btn-group .btn + .btn-group,\n.btn-group .btn-group + .btn,\n.btn-group .btn-group + .btn-group {\n margin-left: -1px;\n}\n.btn-toolbar {\n margin-left: -5px;\n}\n.btn-toolbar .btn,\n.btn-toolbar .btn-group,\n.btn-toolbar .input-group {\n float: left;\n}\n.btn-toolbar > .btn,\n.btn-toolbar > .btn-group,\n.btn-toolbar > .input-group {\n margin-left: 5px;\n}\n.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {\n border-radius: 0;\n}\n.btn-group > .btn:first-child {\n margin-left: 0;\n}\n.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.btn-group > .btn:last-child:not(:first-child),\n.btn-group > .dropdown-toggle:not(:first-child) {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group > .btn-group {\n float: left;\n}\n.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group .dropdown-toggle:active,\n.btn-group.open .dropdown-toggle {\n outline: 0;\n}\n.btn-group > .btn + .dropdown-toggle {\n padding-left: 8px;\n padding-right: 8px;\n}\n.btn-group > .btn-lg + .dropdown-toggle {\n padding-left: 12px;\n padding-right: 12px;\n}\n.btn-group.open .dropdown-toggle {\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n}\n.btn-group.open .dropdown-toggle.btn-link {\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn .caret {\n margin-left: 0;\n}\n.btn-lg .caret {\n border-width: 5px 5px 0;\n border-bottom-width: 0;\n}\n.dropup .btn-lg .caret {\n border-width: 0 5px 5px;\n}\n.btn-group-vertical > .btn,\n.btn-group-vertical > .btn-group,\n.btn-group-vertical > .btn-group > .btn {\n display: block;\n float: none;\n width: 100%;\n max-width: 100%;\n}\n.btn-group-vertical > .btn-group > .btn {\n float: none;\n}\n.btn-group-vertical > .btn + .btn,\n.btn-group-vertical > .btn + .btn-group,\n.btn-group-vertical > .btn-group + .btn,\n.btn-group-vertical > .btn-group + .btn-group {\n margin-top: -1px;\n margin-left: 0;\n}\n.btn-group-vertical > .btn:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.btn-group-vertical > .btn:first-child:not(:last-child) {\n border-top-right-radius: 4px;\n border-top-left-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn:last-child:not(:first-child) {\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\n.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group-justified {\n display: table;\n width: 100%;\n table-layout: fixed;\n border-collapse: separate;\n}\n.btn-group-justified > .btn,\n.btn-group-justified > .btn-group {\n float: none;\n display: table-cell;\n width: 1%;\n}\n.btn-group-justified > .btn-group .btn {\n width: 100%;\n}\n.btn-group-justified > .btn-group .dropdown-menu {\n left: auto;\n}\n[data-toggle=\"buttons\"] > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn input[type=\"checkbox\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0, 0, 0, 0);\n pointer-events: none;\n}\n.input-group {\n position: relative;\n display: table;\n border-collapse: separate;\n}\n.input-group[class*=\"col-\"] {\n float: none;\n padding-left: 0;\n padding-right: 0;\n}\n.input-group .form-control {\n position: relative;\n z-index: 2;\n float: left;\n width: 100%;\n margin-bottom: 0;\n}\n.input-group .form-control:focus {\n z-index: 3;\n}\n.input-group-lg > .form-control,\n.input-group-lg > .input-group-addon,\n.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-group-lg > .form-control,\nselect.input-group-lg > .input-group-addon,\nselect.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-group-lg > .form-control,\ntextarea.input-group-lg > .input-group-addon,\ntextarea.input-group-lg > .input-group-btn > .btn,\nselect[multiple].input-group-lg > .form-control,\nselect[multiple].input-group-lg > .input-group-addon,\nselect[multiple].input-group-lg > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-sm > .form-control,\n.input-group-sm > .input-group-addon,\n.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-group-sm > .form-control,\nselect.input-group-sm > .input-group-addon,\nselect.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-group-sm > .form-control,\ntextarea.input-group-sm > .input-group-addon,\ntextarea.input-group-sm > .input-group-btn > .btn,\nselect[multiple].input-group-sm > .form-control,\nselect[multiple].input-group-sm > .input-group-addon,\nselect[multiple].input-group-sm > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-addon,\n.input-group-btn,\n.input-group .form-control {\n display: table-cell;\n}\n.input-group-addon:not(:first-child):not(:last-child),\n.input-group-btn:not(:first-child):not(:last-child),\n.input-group .form-control:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.input-group-addon,\n.input-group-btn {\n width: 1%;\n white-space: nowrap;\n vertical-align: middle;\n}\n.input-group-addon {\n padding: 6px 12px;\n font-size: 14px;\n font-weight: normal;\n line-height: 1;\n color: #555555;\n text-align: center;\n background-color: #eeeeee;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\n.input-group-addon.input-sm {\n padding: 5px 10px;\n font-size: 12px;\n border-radius: 3px;\n}\n.input-group-addon.input-lg {\n padding: 10px 16px;\n font-size: 18px;\n border-radius: 6px;\n}\n.input-group-addon input[type=\"radio\"],\n.input-group-addon input[type=\"checkbox\"] {\n margin-top: 0;\n}\n.input-group .form-control:first-child,\n.input-group-addon:first-child,\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group > .btn,\n.input-group-btn:first-child > .dropdown-toggle,\n.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group-btn:last-child > .btn-group:not(:last-child) > .btn {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.input-group-addon:first-child {\n border-right: 0;\n}\n.input-group .form-control:last-child,\n.input-group-addon:last-child,\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group > .btn,\n.input-group-btn:last-child > .dropdown-toggle,\n.input-group-btn:first-child > .btn:not(:first-child),\n.input-group-btn:first-child > .btn-group:not(:first-child) > .btn {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.input-group-addon:last-child {\n border-left: 0;\n}\n.input-group-btn {\n position: relative;\n font-size: 0;\n white-space: nowrap;\n}\n.input-group-btn > .btn {\n position: relative;\n}\n.input-group-btn > .btn + .btn {\n margin-left: -1px;\n}\n.input-group-btn > .btn:hover,\n.input-group-btn > .btn:focus,\n.input-group-btn > .btn:active {\n z-index: 2;\n}\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group {\n margin-right: -1px;\n}\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group {\n z-index: 2;\n margin-left: -1px;\n}\n.nav {\n margin-bottom: 0;\n padding-left: 0;\n list-style: none;\n}\n.nav > li {\n position: relative;\n display: block;\n}\n.nav > li > a {\n position: relative;\n display: block;\n padding: 10px 15px;\n}\n.nav > li > a:hover,\n.nav > li > a:focus {\n text-decoration: none;\n background-color: #eeeeee;\n}\n.nav > li.disabled > a {\n color: #777777;\n}\n.nav > li.disabled > a:hover,\n.nav > li.disabled > a:focus {\n color: #777777;\n text-decoration: none;\n background-color: transparent;\n cursor: not-allowed;\n}\n.nav .open > a,\n.nav .open > a:hover,\n.nav .open > a:focus {\n background-color: #eeeeee;\n border-color: #337ab7;\n}\n.nav .nav-divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.nav > li > a > img {\n max-width: none;\n}\n.nav-tabs {\n border-bottom: 1px solid #ddd;\n}\n.nav-tabs > li {\n float: left;\n margin-bottom: -1px;\n}\n.nav-tabs > li > a {\n margin-right: 2px;\n line-height: 1.42857143;\n border: 1px solid transparent;\n border-radius: 4px 4px 0 0;\n}\n.nav-tabs > li > a:hover {\n border-color: #eeeeee #eeeeee #ddd;\n}\n.nav-tabs > li.active > a,\n.nav-tabs > li.active > a:hover,\n.nav-tabs > li.active > a:focus {\n color: #555555;\n background-color: #fff;\n border: 1px solid #ddd;\n border-bottom-color: transparent;\n cursor: default;\n}\n.nav-tabs.nav-justified {\n width: 100%;\n border-bottom: 0;\n}\n.nav-tabs.nav-justified > li {\n float: none;\n}\n.nav-tabs.nav-justified > li > a {\n text-align: center;\n margin-bottom: 5px;\n}\n.nav-tabs.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-tabs.nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs.nav-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs.nav-justified > .active > a,\n.nav-tabs.nav-justified > .active > a:hover,\n.nav-tabs.nav-justified > .active > a:focus {\n border: 1px solid #ddd;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li > a {\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs.nav-justified > .active > a,\n .nav-tabs.nav-justified > .active > a:hover,\n .nav-tabs.nav-justified > .active > a:focus {\n border-bottom-color: #fff;\n }\n}\n.nav-pills > li {\n float: left;\n}\n.nav-pills > li > a {\n border-radius: 4px;\n}\n.nav-pills > li + li {\n margin-left: 2px;\n}\n.nav-pills > li.active > a,\n.nav-pills > li.active > a:hover,\n.nav-pills > li.active > a:focus {\n color: #fff;\n background-color: #337ab7;\n}\n.nav-stacked > li {\n float: none;\n}\n.nav-stacked > li + li {\n margin-top: 2px;\n margin-left: 0;\n}\n.nav-justified {\n width: 100%;\n}\n.nav-justified > li {\n float: none;\n}\n.nav-justified > li > a {\n text-align: center;\n margin-bottom: 5px;\n}\n.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs-justified {\n border-bottom: 0;\n}\n.nav-tabs-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs-justified > .active > a,\n.nav-tabs-justified > .active > a:hover,\n.nav-tabs-justified > .active > a:focus {\n border: 1px solid #ddd;\n}\n@media (min-width: 768px) {\n .nav-tabs-justified > li > a {\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs-justified > .active > a,\n .nav-tabs-justified > .active > a:hover,\n .nav-tabs-justified > .active > a:focus {\n border-bottom-color: #fff;\n }\n}\n.tab-content > .tab-pane {\n display: none;\n}\n.tab-content > .active {\n display: block;\n}\n.nav-tabs .dropdown-menu {\n margin-top: -1px;\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.navbar {\n position: relative;\n min-height: 50px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n}\n@media (min-width: 768px) {\n .navbar {\n border-radius: 4px;\n }\n}\n@media (min-width: 768px) {\n .navbar-header {\n float: left;\n }\n}\n.navbar-collapse {\n overflow-x: visible;\n padding-right: 15px;\n padding-left: 15px;\n border-top: 1px solid transparent;\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);\n -webkit-overflow-scrolling: touch;\n}\n.navbar-collapse.in {\n overflow-y: auto;\n}\n@media (min-width: 768px) {\n .navbar-collapse {\n width: auto;\n border-top: 0;\n box-shadow: none;\n }\n .navbar-collapse.collapse {\n display: block !important;\n height: auto !important;\n padding-bottom: 0;\n overflow: visible !important;\n }\n .navbar-collapse.in {\n overflow-y: visible;\n }\n .navbar-fixed-top .navbar-collapse,\n .navbar-static-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n padding-left: 0;\n padding-right: 0;\n }\n}\n.navbar-fixed-top .navbar-collapse,\n.navbar-fixed-bottom .navbar-collapse {\n max-height: 340px;\n}\n@media (max-device-width: 480px) and (orientation: landscape) {\n .navbar-fixed-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n max-height: 200px;\n }\n}\n.container > .navbar-header,\n.container-fluid > .navbar-header,\n.container > .navbar-collapse,\n.container-fluid > .navbar-collapse {\n margin-right: -15px;\n margin-left: -15px;\n}\n@media (min-width: 768px) {\n .container > .navbar-header,\n .container-fluid > .navbar-header,\n .container > .navbar-collapse,\n .container-fluid > .navbar-collapse {\n margin-right: 0;\n margin-left: 0;\n }\n}\n.navbar-static-top {\n z-index: 1000;\n border-width: 0 0 1px;\n}\n@media (min-width: 768px) {\n .navbar-static-top {\n border-radius: 0;\n }\n}\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n position: fixed;\n right: 0;\n left: 0;\n z-index: 1030;\n}\n@media (min-width: 768px) {\n .navbar-fixed-top,\n .navbar-fixed-bottom {\n border-radius: 0;\n }\n}\n.navbar-fixed-top {\n top: 0;\n border-width: 0 0 1px;\n}\n.navbar-fixed-bottom {\n bottom: 0;\n margin-bottom: 0;\n border-width: 1px 0 0;\n}\n.navbar-brand {\n float: left;\n padding: 15px 15px;\n font-size: 18px;\n line-height: 20px;\n height: 50px;\n}\n.navbar-brand:hover,\n.navbar-brand:focus {\n text-decoration: none;\n}\n.navbar-brand > img {\n display: block;\n}\n@media (min-width: 768px) {\n .navbar > .container .navbar-brand,\n .navbar > .container-fluid .navbar-brand {\n margin-left: -15px;\n }\n}\n.navbar-toggle {\n position: relative;\n float: right;\n margin-right: 15px;\n padding: 9px 10px;\n margin-top: 8px;\n margin-bottom: 8px;\n background-color: transparent;\n background-image: none;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.navbar-toggle:focus {\n outline: 0;\n}\n.navbar-toggle .icon-bar {\n display: block;\n width: 22px;\n height: 2px;\n border-radius: 1px;\n}\n.navbar-toggle .icon-bar + .icon-bar {\n margin-top: 4px;\n}\n@media (min-width: 768px) {\n .navbar-toggle {\n display: none;\n }\n}\n.navbar-nav {\n margin: 7.5px -15px;\n}\n.navbar-nav > li > a {\n padding-top: 10px;\n padding-bottom: 10px;\n line-height: 20px;\n}\n@media (max-width: 767px) {\n .navbar-nav .open .dropdown-menu {\n position: static;\n float: none;\n width: auto;\n margin-top: 0;\n background-color: transparent;\n border: 0;\n box-shadow: none;\n }\n .navbar-nav .open .dropdown-menu > li > a,\n .navbar-nav .open .dropdown-menu .dropdown-header {\n padding: 5px 15px 5px 25px;\n }\n .navbar-nav .open .dropdown-menu > li > a {\n line-height: 20px;\n }\n .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-nav .open .dropdown-menu > li > a:focus {\n background-image: none;\n }\n}\n@media (min-width: 768px) {\n .navbar-nav {\n float: left;\n margin: 0;\n }\n .navbar-nav > li {\n float: left;\n }\n .navbar-nav > li > a {\n padding-top: 15px;\n padding-bottom: 15px;\n }\n}\n.navbar-form {\n margin-left: -15px;\n margin-right: -15px;\n padding: 10px 15px;\n border-top: 1px solid transparent;\n border-bottom: 1px solid transparent;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);\n margin-top: 8px;\n margin-bottom: 8px;\n}\n@media (min-width: 768px) {\n .navbar-form .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .navbar-form .form-control-static {\n display: inline-block;\n }\n .navbar-form .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .navbar-form .input-group .input-group-addon,\n .navbar-form .input-group .input-group-btn,\n .navbar-form .input-group .form-control {\n width: auto;\n }\n .navbar-form .input-group > .form-control {\n width: 100%;\n }\n .navbar-form .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio,\n .navbar-form .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio label,\n .navbar-form .checkbox label {\n padding-left: 0;\n }\n .navbar-form .radio input[type=\"radio\"],\n .navbar-form .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .navbar-form .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n@media (max-width: 767px) {\n .navbar-form .form-group {\n margin-bottom: 5px;\n }\n .navbar-form .form-group:last-child {\n margin-bottom: 0;\n }\n}\n@media (min-width: 768px) {\n .navbar-form {\n width: auto;\n border: 0;\n margin-left: 0;\n margin-right: 0;\n padding-top: 0;\n padding-bottom: 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n}\n.navbar-nav > li > .dropdown-menu {\n margin-top: 0;\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu {\n margin-bottom: 0;\n border-top-right-radius: 4px;\n border-top-left-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.navbar-btn {\n margin-top: 8px;\n margin-bottom: 8px;\n}\n.navbar-btn.btn-sm {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.navbar-btn.btn-xs {\n margin-top: 14px;\n margin-bottom: 14px;\n}\n.navbar-text {\n margin-top: 15px;\n margin-bottom: 15px;\n}\n@media (min-width: 768px) {\n .navbar-text {\n float: left;\n margin-left: 15px;\n margin-right: 15px;\n }\n}\n@media (min-width: 768px) {\n .navbar-left {\n float: left !important;\n }\n .navbar-right {\n float: right !important;\n margin-right: -15px;\n }\n .navbar-right ~ .navbar-right {\n margin-right: 0;\n }\n}\n.navbar-default {\n background-color: #f8f8f8;\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-brand {\n color: #777;\n}\n.navbar-default .navbar-brand:hover,\n.navbar-default .navbar-brand:focus {\n color: #5e5e5e;\n background-color: transparent;\n}\n.navbar-default .navbar-text {\n color: #777;\n}\n.navbar-default .navbar-nav > li > a {\n color: #777;\n}\n.navbar-default .navbar-nav > li > a:hover,\n.navbar-default .navbar-nav > li > a:focus {\n color: #333;\n background-color: transparent;\n}\n.navbar-default .navbar-nav > .active > a,\n.navbar-default .navbar-nav > .active > a:hover,\n.navbar-default .navbar-nav > .active > a:focus {\n color: #555;\n background-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .disabled > a,\n.navbar-default .navbar-nav > .disabled > a:hover,\n.navbar-default .navbar-nav > .disabled > a:focus {\n color: #ccc;\n background-color: transparent;\n}\n.navbar-default .navbar-toggle {\n border-color: #ddd;\n}\n.navbar-default .navbar-toggle:hover,\n.navbar-default .navbar-toggle:focus {\n background-color: #ddd;\n}\n.navbar-default .navbar-toggle .icon-bar {\n background-color: #888;\n}\n.navbar-default .navbar-collapse,\n.navbar-default .navbar-form {\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .open > a,\n.navbar-default .navbar-nav > .open > a:hover,\n.navbar-default .navbar-nav > .open > a:focus {\n background-color: #e7e7e7;\n color: #555;\n}\n@media (max-width: 767px) {\n .navbar-default .navbar-nav .open .dropdown-menu > li > a {\n color: #777;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #333;\n background-color: transparent;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #555;\n background-color: #e7e7e7;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #ccc;\n background-color: transparent;\n }\n}\n.navbar-default .navbar-link {\n color: #777;\n}\n.navbar-default .navbar-link:hover {\n color: #333;\n}\n.navbar-default .btn-link {\n color: #777;\n}\n.navbar-default .btn-link:hover,\n.navbar-default .btn-link:focus {\n color: #333;\n}\n.navbar-default .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-default .btn-link:hover,\n.navbar-default .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-default .btn-link:focus {\n color: #ccc;\n}\n.navbar-inverse {\n background-color: #222;\n border-color: #080808;\n}\n.navbar-inverse .navbar-brand {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-brand:hover,\n.navbar-inverse .navbar-brand:focus {\n color: #fff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-text {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a:hover,\n.navbar-inverse .navbar-nav > li > a:focus {\n color: #fff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-nav > .active > a,\n.navbar-inverse .navbar-nav > .active > a:hover,\n.navbar-inverse .navbar-nav > .active > a:focus {\n color: #fff;\n background-color: #080808;\n}\n.navbar-inverse .navbar-nav > .disabled > a,\n.navbar-inverse .navbar-nav > .disabled > a:hover,\n.navbar-inverse .navbar-nav > .disabled > a:focus {\n color: #444;\n background-color: transparent;\n}\n.navbar-inverse .navbar-toggle {\n border-color: #333;\n}\n.navbar-inverse .navbar-toggle:hover,\n.navbar-inverse .navbar-toggle:focus {\n background-color: #333;\n}\n.navbar-inverse .navbar-toggle .icon-bar {\n background-color: #fff;\n}\n.navbar-inverse .navbar-collapse,\n.navbar-inverse .navbar-form {\n border-color: #101010;\n}\n.navbar-inverse .navbar-nav > .open > a,\n.navbar-inverse .navbar-nav > .open > a:hover,\n.navbar-inverse .navbar-nav > .open > a:focus {\n background-color: #080808;\n color: #fff;\n}\n@media (max-width: 767px) {\n .navbar-inverse .navbar-nav .open .dropdown-menu > .dropdown-header {\n border-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu .divider {\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a {\n color: #9d9d9d;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #fff;\n background-color: transparent;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #fff;\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #444;\n background-color: transparent;\n }\n}\n.navbar-inverse .navbar-link {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-link:hover {\n color: #fff;\n}\n.navbar-inverse .btn-link {\n color: #9d9d9d;\n}\n.navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link:focus {\n color: #fff;\n}\n.navbar-inverse .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-inverse .btn-link:focus {\n color: #444;\n}\n.breadcrumb {\n padding: 8px 15px;\n margin-bottom: 20px;\n list-style: none;\n background-color: #f5f5f5;\n border-radius: 4px;\n}\n.breadcrumb > li {\n display: inline-block;\n}\n.breadcrumb > li + li:before {\n content: \"/\\00a0\";\n padding: 0 5px;\n color: #ccc;\n}\n.breadcrumb > .active {\n color: #777777;\n}\n.pagination {\n display: inline-block;\n padding-left: 0;\n margin: 20px 0;\n border-radius: 4px;\n}\n.pagination > li {\n display: inline;\n}\n.pagination > li > a,\n.pagination > li > span {\n position: relative;\n float: left;\n padding: 6px 12px;\n line-height: 1.42857143;\n text-decoration: none;\n color: #337ab7;\n background-color: #fff;\n border: 1px solid #ddd;\n margin-left: -1px;\n}\n.pagination > li:first-child > a,\n.pagination > li:first-child > span {\n margin-left: 0;\n border-bottom-left-radius: 4px;\n border-top-left-radius: 4px;\n}\n.pagination > li:last-child > a,\n.pagination > li:last-child > span {\n border-bottom-right-radius: 4px;\n border-top-right-radius: 4px;\n}\n.pagination > li > a:hover,\n.pagination > li > span:hover,\n.pagination > li > a:focus,\n.pagination > li > span:focus {\n z-index: 2;\n color: #23527c;\n background-color: #eeeeee;\n border-color: #ddd;\n}\n.pagination > .active > a,\n.pagination > .active > span,\n.pagination > .active > a:hover,\n.pagination > .active > span:hover,\n.pagination > .active > a:focus,\n.pagination > .active > span:focus {\n z-index: 3;\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n cursor: default;\n}\n.pagination > .disabled > span,\n.pagination > .disabled > span:hover,\n.pagination > .disabled > span:focus,\n.pagination > .disabled > a,\n.pagination > .disabled > a:hover,\n.pagination > .disabled > a:focus {\n color: #777777;\n background-color: #fff;\n border-color: #ddd;\n cursor: not-allowed;\n}\n.pagination-lg > li > a,\n.pagination-lg > li > span {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.pagination-lg > li:first-child > a,\n.pagination-lg > li:first-child > span {\n border-bottom-left-radius: 6px;\n border-top-left-radius: 6px;\n}\n.pagination-lg > li:last-child > a,\n.pagination-lg > li:last-child > span {\n border-bottom-right-radius: 6px;\n border-top-right-radius: 6px;\n}\n.pagination-sm > li > a,\n.pagination-sm > li > span {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.pagination-sm > li:first-child > a,\n.pagination-sm > li:first-child > span {\n border-bottom-left-radius: 3px;\n border-top-left-radius: 3px;\n}\n.pagination-sm > li:last-child > a,\n.pagination-sm > li:last-child > span {\n border-bottom-right-radius: 3px;\n border-top-right-radius: 3px;\n}\n.pager {\n padding-left: 0;\n margin: 20px 0;\n list-style: none;\n text-align: center;\n}\n.pager li {\n display: inline;\n}\n.pager li > a,\n.pager li > span {\n display: inline-block;\n padding: 5px 14px;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 15px;\n}\n.pager li > a:hover,\n.pager li > a:focus {\n text-decoration: none;\n background-color: #eeeeee;\n}\n.pager .next > a,\n.pager .next > span {\n float: right;\n}\n.pager .previous > a,\n.pager .previous > span {\n float: left;\n}\n.pager .disabled > a,\n.pager .disabled > a:hover,\n.pager .disabled > a:focus,\n.pager .disabled > span {\n color: #777777;\n background-color: #fff;\n cursor: not-allowed;\n}\n.label {\n display: inline;\n padding: .2em .6em .3em;\n font-size: 75%;\n font-weight: bold;\n line-height: 1;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: .25em;\n}\na.label:hover,\na.label:focus {\n color: #fff;\n text-decoration: none;\n cursor: pointer;\n}\n.label:empty {\n display: none;\n}\n.btn .label {\n position: relative;\n top: -1px;\n}\n.label-default {\n background-color: #777777;\n}\n.label-default[href]:hover,\n.label-default[href]:focus {\n background-color: #5e5e5e;\n}\n.label-primary {\n background-color: #337ab7;\n}\n.label-primary[href]:hover,\n.label-primary[href]:focus {\n background-color: #286090;\n}\n.label-success {\n background-color: #5cb85c;\n}\n.label-success[href]:hover,\n.label-success[href]:focus {\n background-color: #449d44;\n}\n.label-info {\n background-color: #5bc0de;\n}\n.label-info[href]:hover,\n.label-info[href]:focus {\n background-color: #31b0d5;\n}\n.label-warning {\n background-color: #f0ad4e;\n}\n.label-warning[href]:hover,\n.label-warning[href]:focus {\n background-color: #ec971f;\n}\n.label-danger {\n background-color: #d9534f;\n}\n.label-danger[href]:hover,\n.label-danger[href]:focus {\n background-color: #c9302c;\n}\n.badge {\n display: inline-block;\n min-width: 10px;\n padding: 3px 7px;\n font-size: 12px;\n font-weight: bold;\n color: #fff;\n line-height: 1;\n vertical-align: middle;\n white-space: nowrap;\n text-align: center;\n background-color: #777777;\n border-radius: 10px;\n}\n.badge:empty {\n display: none;\n}\n.btn .badge {\n position: relative;\n top: -1px;\n}\n.btn-xs .badge,\n.btn-group-xs > .btn .badge {\n top: 0;\n padding: 1px 5px;\n}\na.badge:hover,\na.badge:focus {\n color: #fff;\n text-decoration: none;\n cursor: pointer;\n}\n.list-group-item.active > .badge,\n.nav-pills > .active > a > .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.list-group-item > .badge {\n float: right;\n}\n.list-group-item > .badge + .badge {\n margin-right: 5px;\n}\n.nav-pills > li > a > .badge {\n margin-left: 3px;\n}\n.jumbotron {\n padding-top: 30px;\n padding-bottom: 30px;\n margin-bottom: 30px;\n color: inherit;\n background-color: #eeeeee;\n}\n.jumbotron h1,\n.jumbotron .h1 {\n color: inherit;\n}\n.jumbotron p {\n margin-bottom: 15px;\n font-size: 21px;\n font-weight: 200;\n}\n.jumbotron > hr {\n border-top-color: #d5d5d5;\n}\n.container .jumbotron,\n.container-fluid .jumbotron {\n border-radius: 6px;\n padding-left: 15px;\n padding-right: 15px;\n}\n.jumbotron .container {\n max-width: 100%;\n}\n@media screen and (min-width: 768px) {\n .jumbotron {\n padding-top: 48px;\n padding-bottom: 48px;\n }\n .container .jumbotron,\n .container-fluid .jumbotron {\n padding-left: 60px;\n padding-right: 60px;\n }\n .jumbotron h1,\n .jumbotron .h1 {\n font-size: 63px;\n }\n}\n.thumbnail {\n display: block;\n padding: 4px;\n margin-bottom: 20px;\n line-height: 1.42857143;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n -webkit-transition: border 0.2s ease-in-out;\n -o-transition: border 0.2s ease-in-out;\n transition: border 0.2s ease-in-out;\n}\n.thumbnail > img,\n.thumbnail a > img {\n margin-left: auto;\n margin-right: auto;\n}\na.thumbnail:hover,\na.thumbnail:focus,\na.thumbnail.active {\n border-color: #337ab7;\n}\n.thumbnail .caption {\n padding: 9px;\n color: #333333;\n}\n.alert {\n padding: 15px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.alert h4 {\n margin-top: 0;\n color: inherit;\n}\n.alert .alert-link {\n font-weight: bold;\n}\n.alert > p,\n.alert > ul {\n margin-bottom: 0;\n}\n.alert > p + p {\n margin-top: 5px;\n}\n.alert-dismissable,\n.alert-dismissible {\n padding-right: 35px;\n}\n.alert-dismissable .close,\n.alert-dismissible .close {\n position: relative;\n top: -2px;\n right: -21px;\n color: inherit;\n}\n.alert-success {\n background-color: #dff0d8;\n border-color: #d6e9c6;\n color: #3c763d;\n}\n.alert-success hr {\n border-top-color: #c9e2b3;\n}\n.alert-success .alert-link {\n color: #2b542c;\n}\n.alert-info {\n background-color: #d9edf7;\n border-color: #bce8f1;\n color: #31708f;\n}\n.alert-info hr {\n border-top-color: #a6e1ec;\n}\n.alert-info .alert-link {\n color: #245269;\n}\n.alert-warning {\n background-color: #fcf8e3;\n border-color: #faebcc;\n color: #8a6d3b;\n}\n.alert-warning hr {\n border-top-color: #f7e1b5;\n}\n.alert-warning .alert-link {\n color: #66512c;\n}\n.alert-danger {\n background-color: #f2dede;\n border-color: #ebccd1;\n color: #a94442;\n}\n.alert-danger hr {\n border-top-color: #e4b9c0;\n}\n.alert-danger .alert-link {\n color: #843534;\n}\n@-webkit-keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n@keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n.progress {\n overflow: hidden;\n height: 20px;\n margin-bottom: 20px;\n background-color: #f5f5f5;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n}\n.progress-bar {\n float: left;\n width: 0%;\n height: 100%;\n font-size: 12px;\n line-height: 20px;\n color: #fff;\n text-align: center;\n background-color: #337ab7;\n -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);\n -webkit-transition: width 0.6s ease;\n -o-transition: width 0.6s ease;\n transition: width 0.6s ease;\n}\n.progress-striped .progress-bar,\n.progress-bar-striped {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-size: 40px 40px;\n}\n.progress.active .progress-bar,\n.progress-bar.active {\n -webkit-animation: progress-bar-stripes 2s linear infinite;\n -o-animation: progress-bar-stripes 2s linear infinite;\n animation: progress-bar-stripes 2s linear infinite;\n}\n.progress-bar-success {\n background-color: #5cb85c;\n}\n.progress-striped .progress-bar-success {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-info {\n background-color: #5bc0de;\n}\n.progress-striped .progress-bar-info {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-warning {\n background-color: #f0ad4e;\n}\n.progress-striped .progress-bar-warning {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-danger {\n background-color: #d9534f;\n}\n.progress-striped .progress-bar-danger {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.media {\n margin-top: 15px;\n}\n.media:first-child {\n margin-top: 0;\n}\n.media,\n.media-body {\n zoom: 1;\n overflow: hidden;\n}\n.media-body {\n width: 10000px;\n}\n.media-object {\n display: block;\n}\n.media-object.img-thumbnail {\n max-width: none;\n}\n.media-right,\n.media > .pull-right {\n padding-left: 10px;\n}\n.media-left,\n.media > .pull-left {\n padding-right: 10px;\n}\n.media-left,\n.media-right,\n.media-body {\n display: table-cell;\n vertical-align: top;\n}\n.media-middle {\n vertical-align: middle;\n}\n.media-bottom {\n vertical-align: bottom;\n}\n.media-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.media-list {\n padding-left: 0;\n list-style: none;\n}\n.list-group {\n margin-bottom: 20px;\n padding-left: 0;\n}\n.list-group-item {\n position: relative;\n display: block;\n padding: 10px 15px;\n margin-bottom: -1px;\n background-color: #fff;\n border: 1px solid #ddd;\n}\n.list-group-item:first-child {\n border-top-right-radius: 4px;\n border-top-left-radius: 4px;\n}\n.list-group-item:last-child {\n margin-bottom: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\na.list-group-item,\nbutton.list-group-item {\n color: #555;\n}\na.list-group-item .list-group-item-heading,\nbutton.list-group-item .list-group-item-heading {\n color: #333;\n}\na.list-group-item:hover,\nbutton.list-group-item:hover,\na.list-group-item:focus,\nbutton.list-group-item:focus {\n text-decoration: none;\n color: #555;\n background-color: #f5f5f5;\n}\nbutton.list-group-item {\n width: 100%;\n text-align: left;\n}\n.list-group-item.disabled,\n.list-group-item.disabled:hover,\n.list-group-item.disabled:focus {\n background-color: #eeeeee;\n color: #777777;\n cursor: not-allowed;\n}\n.list-group-item.disabled .list-group-item-heading,\n.list-group-item.disabled:hover .list-group-item-heading,\n.list-group-item.disabled:focus .list-group-item-heading {\n color: inherit;\n}\n.list-group-item.disabled .list-group-item-text,\n.list-group-item.disabled:hover .list-group-item-text,\n.list-group-item.disabled:focus .list-group-item-text {\n color: #777777;\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n z-index: 2;\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.list-group-item.active .list-group-item-heading,\n.list-group-item.active:hover .list-group-item-heading,\n.list-group-item.active:focus .list-group-item-heading,\n.list-group-item.active .list-group-item-heading > small,\n.list-group-item.active:hover .list-group-item-heading > small,\n.list-group-item.active:focus .list-group-item-heading > small,\n.list-group-item.active .list-group-item-heading > .small,\n.list-group-item.active:hover .list-group-item-heading > .small,\n.list-group-item.active:focus .list-group-item-heading > .small {\n color: inherit;\n}\n.list-group-item.active .list-group-item-text,\n.list-group-item.active:hover .list-group-item-text,\n.list-group-item.active:focus .list-group-item-text {\n color: #c7ddef;\n}\n.list-group-item-success {\n color: #3c763d;\n background-color: #dff0d8;\n}\na.list-group-item-success,\nbutton.list-group-item-success {\n color: #3c763d;\n}\na.list-group-item-success .list-group-item-heading,\nbutton.list-group-item-success .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-success:hover,\nbutton.list-group-item-success:hover,\na.list-group-item-success:focus,\nbutton.list-group-item-success:focus {\n color: #3c763d;\n background-color: #d0e9c6;\n}\na.list-group-item-success.active,\nbutton.list-group-item-success.active,\na.list-group-item-success.active:hover,\nbutton.list-group-item-success.active:hover,\na.list-group-item-success.active:focus,\nbutton.list-group-item-success.active:focus {\n color: #fff;\n background-color: #3c763d;\n border-color: #3c763d;\n}\n.list-group-item-info {\n color: #31708f;\n background-color: #d9edf7;\n}\na.list-group-item-info,\nbutton.list-group-item-info {\n color: #31708f;\n}\na.list-group-item-info .list-group-item-heading,\nbutton.list-group-item-info .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-info:hover,\nbutton.list-group-item-info:hover,\na.list-group-item-info:focus,\nbutton.list-group-item-info:focus {\n color: #31708f;\n background-color: #c4e3f3;\n}\na.list-group-item-info.active,\nbutton.list-group-item-info.active,\na.list-group-item-info.active:hover,\nbutton.list-group-item-info.active:hover,\na.list-group-item-info.active:focus,\nbutton.list-group-item-info.active:focus {\n color: #fff;\n background-color: #31708f;\n border-color: #31708f;\n}\n.list-group-item-warning {\n color: #8a6d3b;\n background-color: #fcf8e3;\n}\na.list-group-item-warning,\nbutton.list-group-item-warning {\n color: #8a6d3b;\n}\na.list-group-item-warning .list-group-item-heading,\nbutton.list-group-item-warning .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-warning:hover,\nbutton.list-group-item-warning:hover,\na.list-group-item-warning:focus,\nbutton.list-group-item-warning:focus {\n color: #8a6d3b;\n background-color: #faf2cc;\n}\na.list-group-item-warning.active,\nbutton.list-group-item-warning.active,\na.list-group-item-warning.active:hover,\nbutton.list-group-item-warning.active:hover,\na.list-group-item-warning.active:focus,\nbutton.list-group-item-warning.active:focus {\n color: #fff;\n background-color: #8a6d3b;\n border-color: #8a6d3b;\n}\n.list-group-item-danger {\n color: #a94442;\n background-color: #f2dede;\n}\na.list-group-item-danger,\nbutton.list-group-item-danger {\n color: #a94442;\n}\na.list-group-item-danger .list-group-item-heading,\nbutton.list-group-item-danger .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-danger:hover,\nbutton.list-group-item-danger:hover,\na.list-group-item-danger:focus,\nbutton.list-group-item-danger:focus {\n color: #a94442;\n background-color: #ebcccc;\n}\na.list-group-item-danger.active,\nbutton.list-group-item-danger.active,\na.list-group-item-danger.active:hover,\nbutton.list-group-item-danger.active:hover,\na.list-group-item-danger.active:focus,\nbutton.list-group-item-danger.active:focus {\n color: #fff;\n background-color: #a94442;\n border-color: #a94442;\n}\n.list-group-item-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.list-group-item-text {\n margin-bottom: 0;\n line-height: 1.3;\n}\n.panel {\n margin-bottom: 20px;\n background-color: #fff;\n border: 1px solid transparent;\n border-radius: 4px;\n -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);\n box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);\n}\n.panel-body {\n padding: 15px;\n}\n.panel-heading {\n padding: 10px 15px;\n border-bottom: 1px solid transparent;\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel-heading > .dropdown .dropdown-toggle {\n color: inherit;\n}\n.panel-title {\n margin-top: 0;\n margin-bottom: 0;\n font-size: 16px;\n color: inherit;\n}\n.panel-title > a,\n.panel-title > small,\n.panel-title > .small,\n.panel-title > small > a,\n.panel-title > .small > a {\n color: inherit;\n}\n.panel-footer {\n padding: 10px 15px;\n background-color: #f5f5f5;\n border-top: 1px solid #ddd;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .list-group,\n.panel > .panel-collapse > .list-group {\n margin-bottom: 0;\n}\n.panel > .list-group .list-group-item,\n.panel > .panel-collapse > .list-group .list-group-item {\n border-width: 1px 0;\n border-radius: 0;\n}\n.panel > .list-group:first-child .list-group-item:first-child,\n.panel > .panel-collapse > .list-group:first-child .list-group-item:first-child {\n border-top: 0;\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel > .list-group:last-child .list-group-item:last-child,\n.panel > .panel-collapse > .list-group:last-child .list-group-item:last-child {\n border-bottom: 0;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .panel-heading + .panel-collapse > .list-group .list-group-item:first-child {\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.panel-heading + .list-group .list-group-item:first-child {\n border-top-width: 0;\n}\n.list-group + .panel-footer {\n border-top-width: 0;\n}\n.panel > .table,\n.panel > .table-responsive > .table,\n.panel > .panel-collapse > .table {\n margin-bottom: 0;\n}\n.panel > .table caption,\n.panel > .table-responsive > .table caption,\n.panel > .panel-collapse > .table caption {\n padding-left: 15px;\n padding-right: 15px;\n}\n.panel > .table:first-child,\n.panel > .table-responsive:first-child > .table:first-child {\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child {\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:first-child {\n border-top-left-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:last-child {\n border-top-right-radius: 3px;\n}\n.panel > .table:last-child,\n.panel > .table-responsive:last-child > .table:last-child {\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child {\n border-bottom-left-radius: 3px;\n border-bottom-right-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:first-child {\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:last-child {\n border-bottom-right-radius: 3px;\n}\n.panel > .panel-body + .table,\n.panel > .panel-body + .table-responsive,\n.panel > .table + .panel-body,\n.panel > .table-responsive + .panel-body {\n border-top: 1px solid #ddd;\n}\n.panel > .table > tbody:first-child > tr:first-child th,\n.panel > .table > tbody:first-child > tr:first-child td {\n border-top: 0;\n}\n.panel > .table-bordered,\n.panel > .table-responsive > .table-bordered {\n border: 0;\n}\n.panel > .table-bordered > thead > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:first-child,\n.panel > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-bordered > thead > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:first-child,\n.panel > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-bordered > tfoot > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n}\n.panel > .table-bordered > thead > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:last-child,\n.panel > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-bordered > thead > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:last-child,\n.panel > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-bordered > tfoot > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n}\n.panel > .table-bordered > thead > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > td,\n.panel > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-bordered > thead > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > th,\n.panel > .table-bordered > tbody > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > th {\n border-bottom: 0;\n}\n.panel > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-bordered > tfoot > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > th {\n border-bottom: 0;\n}\n.panel > .table-responsive {\n border: 0;\n margin-bottom: 0;\n}\n.panel-group {\n margin-bottom: 20px;\n}\n.panel-group .panel {\n margin-bottom: 0;\n border-radius: 4px;\n}\n.panel-group .panel + .panel {\n margin-top: 5px;\n}\n.panel-group .panel-heading {\n border-bottom: 0;\n}\n.panel-group .panel-heading + .panel-collapse > .panel-body,\n.panel-group .panel-heading + .panel-collapse > .list-group {\n border-top: 1px solid #ddd;\n}\n.panel-group .panel-footer {\n border-top: 0;\n}\n.panel-group .panel-footer + .panel-collapse .panel-body {\n border-bottom: 1px solid #ddd;\n}\n.panel-default {\n border-color: #ddd;\n}\n.panel-default > .panel-heading {\n color: #333333;\n background-color: #f5f5f5;\n border-color: #ddd;\n}\n.panel-default > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ddd;\n}\n.panel-default > .panel-heading .badge {\n color: #f5f5f5;\n background-color: #333333;\n}\n.panel-default > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ddd;\n}\n.panel-primary {\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading {\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #337ab7;\n}\n.panel-primary > .panel-heading .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.panel-primary > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #337ab7;\n}\n.panel-success {\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading {\n color: #3c763d;\n background-color: #dff0d8;\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #d6e9c6;\n}\n.panel-success > .panel-heading .badge {\n color: #dff0d8;\n background-color: #3c763d;\n}\n.panel-success > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #d6e9c6;\n}\n.panel-info {\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading {\n color: #31708f;\n background-color: #d9edf7;\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #bce8f1;\n}\n.panel-info > .panel-heading .badge {\n color: #d9edf7;\n background-color: #31708f;\n}\n.panel-info > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #bce8f1;\n}\n.panel-warning {\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading {\n color: #8a6d3b;\n background-color: #fcf8e3;\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #faebcc;\n}\n.panel-warning > .panel-heading .badge {\n color: #fcf8e3;\n background-color: #8a6d3b;\n}\n.panel-warning > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #faebcc;\n}\n.panel-danger {\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading {\n color: #a94442;\n background-color: #f2dede;\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ebccd1;\n}\n.panel-danger > .panel-heading .badge {\n color: #f2dede;\n background-color: #a94442;\n}\n.panel-danger > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ebccd1;\n}\n.embed-responsive {\n position: relative;\n display: block;\n height: 0;\n padding: 0;\n overflow: hidden;\n}\n.embed-responsive .embed-responsive-item,\n.embed-responsive iframe,\n.embed-responsive embed,\n.embed-responsive object,\n.embed-responsive video {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n height: 100%;\n width: 100%;\n border: 0;\n}\n.embed-responsive-16by9 {\n padding-bottom: 56.25%;\n}\n.embed-responsive-4by3 {\n padding-bottom: 75%;\n}\n.well {\n min-height: 20px;\n padding: 19px;\n margin-bottom: 20px;\n background-color: #f5f5f5;\n border: 1px solid #e3e3e3;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);\n}\n.well blockquote {\n border-color: #ddd;\n border-color: rgba(0, 0, 0, 0.15);\n}\n.well-lg {\n padding: 24px;\n border-radius: 6px;\n}\n.well-sm {\n padding: 9px;\n border-radius: 3px;\n}\n.close {\n float: right;\n font-size: 21px;\n font-weight: bold;\n line-height: 1;\n color: #000;\n text-shadow: 0 1px 0 #fff;\n opacity: 0.2;\n filter: alpha(opacity=20);\n}\n.close:hover,\n.close:focus {\n color: #000;\n text-decoration: none;\n cursor: pointer;\n opacity: 0.5;\n filter: alpha(opacity=50);\n}\nbutton.close {\n padding: 0;\n cursor: pointer;\n background: transparent;\n border: 0;\n -webkit-appearance: none;\n}\n.modal-open {\n overflow: hidden;\n}\n.modal {\n display: none;\n overflow: hidden;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1050;\n -webkit-overflow-scrolling: touch;\n outline: 0;\n}\n.modal.fade .modal-dialog {\n -webkit-transform: translate(0, -25%);\n -ms-transform: translate(0, -25%);\n -o-transform: translate(0, -25%);\n transform: translate(0, -25%);\n -webkit-transition: -webkit-transform 0.3s ease-out;\n -moz-transition: -moz-transform 0.3s ease-out;\n -o-transition: -o-transform 0.3s ease-out;\n transition: transform 0.3s ease-out;\n}\n.modal.in .modal-dialog {\n -webkit-transform: translate(0, 0);\n -ms-transform: translate(0, 0);\n -o-transform: translate(0, 0);\n transform: translate(0, 0);\n}\n.modal-open .modal {\n overflow-x: hidden;\n overflow-y: auto;\n}\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 10px;\n}\n.modal-content {\n position: relative;\n background-color: #fff;\n border: 1px solid #999;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 6px;\n -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5);\n box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5);\n background-clip: padding-box;\n outline: 0;\n}\n.modal-backdrop {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1040;\n background-color: #000;\n}\n.modal-backdrop.fade {\n opacity: 0;\n filter: alpha(opacity=0);\n}\n.modal-backdrop.in {\n opacity: 0.5;\n filter: alpha(opacity=50);\n}\n.modal-header {\n padding: 15px;\n border-bottom: 1px solid #e5e5e5;\n}\n.modal-header .close {\n margin-top: -2px;\n}\n.modal-title {\n margin: 0;\n line-height: 1.42857143;\n}\n.modal-body {\n position: relative;\n padding: 15px;\n}\n.modal-footer {\n padding: 15px;\n text-align: right;\n border-top: 1px solid #e5e5e5;\n}\n.modal-footer .btn + .btn {\n margin-left: 5px;\n margin-bottom: 0;\n}\n.modal-footer .btn-group .btn + .btn {\n margin-left: -1px;\n}\n.modal-footer .btn-block + .btn-block {\n margin-left: 0;\n}\n.modal-scrollbar-measure {\n position: absolute;\n top: -9999px;\n width: 50px;\n height: 50px;\n overflow: scroll;\n}\n@media (min-width: 768px) {\n .modal-dialog {\n width: 600px;\n margin: 30px auto;\n }\n .modal-content {\n -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);\n box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);\n }\n .modal-sm {\n width: 300px;\n }\n}\n@media (min-width: 992px) {\n .modal-lg {\n width: 900px;\n }\n}\n.tooltip {\n position: absolute;\n z-index: 1070;\n display: block;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-break: auto;\n line-height: 1.42857143;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n white-space: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n font-size: 12px;\n opacity: 0;\n filter: alpha(opacity=0);\n}\n.tooltip.in {\n opacity: 0.9;\n filter: alpha(opacity=90);\n}\n.tooltip.top {\n margin-top: -3px;\n padding: 5px 0;\n}\n.tooltip.right {\n margin-left: 3px;\n padding: 0 5px;\n}\n.tooltip.bottom {\n margin-top: 3px;\n padding: 5px 0;\n}\n.tooltip.left {\n margin-left: -3px;\n padding: 0 5px;\n}\n.tooltip-inner {\n max-width: 200px;\n padding: 3px 8px;\n color: #fff;\n text-align: center;\n background-color: #000;\n border-radius: 4px;\n}\n.tooltip-arrow {\n position: absolute;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.tooltip.top .tooltip-arrow {\n bottom: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.top-left .tooltip-arrow {\n bottom: 0;\n right: 5px;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.top-right .tooltip-arrow {\n bottom: 0;\n left: 5px;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.right .tooltip-arrow {\n top: 50%;\n left: 0;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: #000;\n}\n.tooltip.left .tooltip-arrow {\n top: 50%;\n right: 0;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: #000;\n}\n.tooltip.bottom .tooltip-arrow {\n top: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.tooltip.bottom-left .tooltip-arrow {\n top: 0;\n right: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.tooltip.bottom-right .tooltip-arrow {\n top: 0;\n left: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.popover {\n position: absolute;\n top: 0;\n left: 0;\n z-index: 1060;\n display: none;\n max-width: 276px;\n padding: 1px;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-break: auto;\n line-height: 1.42857143;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n white-space: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n font-size: 14px;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid #ccc;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 6px;\n -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);\n box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);\n}\n.popover.top {\n margin-top: -10px;\n}\n.popover.right {\n margin-left: 10px;\n}\n.popover.bottom {\n margin-top: 10px;\n}\n.popover.left {\n margin-left: -10px;\n}\n.popover-title {\n margin: 0;\n padding: 8px 14px;\n font-size: 14px;\n background-color: #f7f7f7;\n border-bottom: 1px solid #ebebeb;\n border-radius: 5px 5px 0 0;\n}\n.popover-content {\n padding: 9px 14px;\n}\n.popover > .arrow,\n.popover > .arrow:after {\n position: absolute;\n display: block;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.popover > .arrow {\n border-width: 11px;\n}\n.popover > .arrow:after {\n border-width: 10px;\n content: \"\";\n}\n.popover.top > .arrow {\n left: 50%;\n margin-left: -11px;\n border-bottom-width: 0;\n border-top-color: #999999;\n border-top-color: rgba(0, 0, 0, 0.25);\n bottom: -11px;\n}\n.popover.top > .arrow:after {\n content: \" \";\n bottom: 1px;\n margin-left: -10px;\n border-bottom-width: 0;\n border-top-color: #fff;\n}\n.popover.right > .arrow {\n top: 50%;\n left: -11px;\n margin-top: -11px;\n border-left-width: 0;\n border-right-color: #999999;\n border-right-color: rgba(0, 0, 0, 0.25);\n}\n.popover.right > .arrow:after {\n content: \" \";\n left: 1px;\n bottom: -10px;\n border-left-width: 0;\n border-right-color: #fff;\n}\n.popover.bottom > .arrow {\n left: 50%;\n margin-left: -11px;\n border-top-width: 0;\n border-bottom-color: #999999;\n border-bottom-color: rgba(0, 0, 0, 0.25);\n top: -11px;\n}\n.popover.bottom > .arrow:after {\n content: \" \";\n top: 1px;\n margin-left: -10px;\n border-top-width: 0;\n border-bottom-color: #fff;\n}\n.popover.left > .arrow {\n top: 50%;\n right: -11px;\n margin-top: -11px;\n border-right-width: 0;\n border-left-color: #999999;\n border-left-color: rgba(0, 0, 0, 0.25);\n}\n.popover.left > .arrow:after {\n content: \" \";\n right: 1px;\n border-right-width: 0;\n border-left-color: #fff;\n bottom: -10px;\n}\n.carousel {\n position: relative;\n}\n.carousel-inner {\n position: relative;\n overflow: hidden;\n width: 100%;\n}\n.carousel-inner > .item {\n display: none;\n position: relative;\n -webkit-transition: 0.6s ease-in-out left;\n -o-transition: 0.6s ease-in-out left;\n transition: 0.6s ease-in-out left;\n}\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n line-height: 1;\n}\n@media all and (transform-3d), (-webkit-transform-3d) {\n .carousel-inner > .item {\n -webkit-transition: -webkit-transform 0.6s ease-in-out;\n -moz-transition: -moz-transform 0.6s ease-in-out;\n -o-transition: -o-transform 0.6s ease-in-out;\n transition: transform 0.6s ease-in-out;\n -webkit-backface-visibility: hidden;\n -moz-backface-visibility: hidden;\n backface-visibility: hidden;\n -webkit-perspective: 1000px;\n -moz-perspective: 1000px;\n perspective: 1000px;\n }\n .carousel-inner > .item.next,\n .carousel-inner > .item.active.right {\n -webkit-transform: translate3d(100%, 0, 0);\n transform: translate3d(100%, 0, 0);\n left: 0;\n }\n .carousel-inner > .item.prev,\n .carousel-inner > .item.active.left {\n -webkit-transform: translate3d(-100%, 0, 0);\n transform: translate3d(-100%, 0, 0);\n left: 0;\n }\n .carousel-inner > .item.next.left,\n .carousel-inner > .item.prev.right,\n .carousel-inner > .item.active {\n -webkit-transform: translate3d(0, 0, 0);\n transform: translate3d(0, 0, 0);\n left: 0;\n }\n}\n.carousel-inner > .active,\n.carousel-inner > .next,\n.carousel-inner > .prev {\n display: block;\n}\n.carousel-inner > .active {\n left: 0;\n}\n.carousel-inner > .next,\n.carousel-inner > .prev {\n position: absolute;\n top: 0;\n width: 100%;\n}\n.carousel-inner > .next {\n left: 100%;\n}\n.carousel-inner > .prev {\n left: -100%;\n}\n.carousel-inner > .next.left,\n.carousel-inner > .prev.right {\n left: 0;\n}\n.carousel-inner > .active.left {\n left: -100%;\n}\n.carousel-inner > .active.right {\n left: 100%;\n}\n.carousel-control {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: 15%;\n opacity: 0.5;\n filter: alpha(opacity=50);\n font-size: 20px;\n color: #fff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);\n background-color: rgba(0, 0, 0, 0);\n}\n.carousel-control.left {\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-image: linear-gradient(to right, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);\n}\n.carousel-control.right {\n left: auto;\n right: 0;\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-image: linear-gradient(to right, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);\n}\n.carousel-control:hover,\n.carousel-control:focus {\n outline: 0;\n color: #fff;\n text-decoration: none;\n opacity: 0.9;\n filter: alpha(opacity=90);\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-left,\n.carousel-control .glyphicon-chevron-right {\n position: absolute;\n top: 50%;\n margin-top: -10px;\n z-index: 5;\n display: inline-block;\n}\n.carousel-control .icon-prev,\n.carousel-control .glyphicon-chevron-left {\n left: 50%;\n margin-left: -10px;\n}\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-right {\n right: 50%;\n margin-right: -10px;\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next {\n width: 20px;\n height: 20px;\n line-height: 1;\n font-family: serif;\n}\n.carousel-control .icon-prev:before {\n content: '\\2039';\n}\n.carousel-control .icon-next:before {\n content: '\\203a';\n}\n.carousel-indicators {\n position: absolute;\n bottom: 10px;\n left: 50%;\n z-index: 15;\n width: 60%;\n margin-left: -30%;\n padding-left: 0;\n list-style: none;\n text-align: center;\n}\n.carousel-indicators li {\n display: inline-block;\n width: 10px;\n height: 10px;\n margin: 1px;\n text-indent: -999px;\n border: 1px solid #fff;\n border-radius: 10px;\n cursor: pointer;\n background-color: #000 \\9;\n background-color: rgba(0, 0, 0, 0);\n}\n.carousel-indicators .active {\n margin: 0;\n width: 12px;\n height: 12px;\n background-color: #fff;\n}\n.carousel-caption {\n position: absolute;\n left: 15%;\n right: 15%;\n bottom: 20px;\n z-index: 10;\n padding-top: 20px;\n padding-bottom: 20px;\n color: #fff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);\n}\n.carousel-caption .btn {\n text-shadow: none;\n}\n@media screen and (min-width: 768px) {\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-prev,\n .carousel-control .icon-next {\n width: 30px;\n height: 30px;\n margin-top: -10px;\n font-size: 30px;\n }\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .icon-prev {\n margin-left: -10px;\n }\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-next {\n margin-right: -10px;\n }\n .carousel-caption {\n left: 20%;\n right: 20%;\n padding-bottom: 30px;\n }\n .carousel-indicators {\n bottom: 20px;\n }\n}\n.clearfix:before,\n.clearfix:after,\n.dl-horizontal dd:before,\n.dl-horizontal dd:after,\n.container:before,\n.container:after,\n.container-fluid:before,\n.container-fluid:after,\n.row:before,\n.row:after,\n.form-horizontal .form-group:before,\n.form-horizontal .form-group:after,\n.btn-toolbar:before,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:before,\n.btn-group-vertical > .btn-group:after,\n.nav:before,\n.nav:after,\n.navbar:before,\n.navbar:after,\n.navbar-header:before,\n.navbar-header:after,\n.navbar-collapse:before,\n.navbar-collapse:after,\n.pager:before,\n.pager:after,\n.panel-body:before,\n.panel-body:after,\n.modal-header:before,\n.modal-header:after,\n.modal-footer:before,\n.modal-footer:after {\n content: \" \";\n display: table;\n}\n.clearfix:after,\n.dl-horizontal dd:after,\n.container:after,\n.container-fluid:after,\n.row:after,\n.form-horizontal .form-group:after,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:after,\n.nav:after,\n.navbar:after,\n.navbar-header:after,\n.navbar-collapse:after,\n.pager:after,\n.panel-body:after,\n.modal-header:after,\n.modal-footer:after {\n clear: both;\n}\n.center-block {\n display: block;\n margin-left: auto;\n margin-right: auto;\n}\n.pull-right {\n float: right !important;\n}\n.pull-left {\n float: left !important;\n}\n.hide {\n display: none !important;\n}\n.show {\n display: block !important;\n}\n.invisible {\n visibility: hidden;\n}\n.text-hide {\n font: 0/0 a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n.hidden {\n display: none !important;\n}\n.affix {\n position: fixed;\n}\n@-ms-viewport {\n width: device-width;\n}\n.visible-xs,\n.visible-sm,\n.visible-md,\n.visible-lg {\n display: none !important;\n}\n.visible-xs-block,\n.visible-xs-inline,\n.visible-xs-inline-block,\n.visible-sm-block,\n.visible-sm-inline,\n.visible-sm-inline-block,\n.visible-md-block,\n.visible-md-inline,\n.visible-md-inline-block,\n.visible-lg-block,\n.visible-lg-inline,\n.visible-lg-inline-block {\n display: none !important;\n}\n@media (max-width: 767px) {\n .visible-xs {\n display: block !important;\n }\n table.visible-xs {\n display: table !important;\n }\n tr.visible-xs {\n display: table-row !important;\n }\n th.visible-xs,\n td.visible-xs {\n display: table-cell !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-block {\n display: block !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline {\n display: inline !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm {\n display: block !important;\n }\n table.visible-sm {\n display: table !important;\n }\n tr.visible-sm {\n display: table-row !important;\n }\n th.visible-sm,\n td.visible-sm {\n display: table-cell !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-block {\n display: block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline {\n display: inline !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md {\n display: block !important;\n }\n table.visible-md {\n display: table !important;\n }\n tr.visible-md {\n display: table-row !important;\n }\n th.visible-md,\n td.visible-md {\n display: table-cell !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-block {\n display: block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline {\n display: inline !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg {\n display: block !important;\n }\n table.visible-lg {\n display: table !important;\n }\n tr.visible-lg {\n display: table-row !important;\n }\n th.visible-lg,\n td.visible-lg {\n display: table-cell !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-block {\n display: block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline {\n display: inline !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline-block {\n display: inline-block !important;\n }\n}\n@media (max-width: 767px) {\n .hidden-xs {\n display: none !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .hidden-sm {\n display: none !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .hidden-md {\n display: none !important;\n }\n}\n@media (min-width: 1200px) {\n .hidden-lg {\n display: none !important;\n }\n}\n.visible-print {\n display: none !important;\n}\n@media print {\n .visible-print {\n display: block !important;\n }\n table.visible-print {\n display: table !important;\n }\n tr.visible-print {\n display: table-row !important;\n }\n th.visible-print,\n td.visible-print {\n display: table-cell !important;\n }\n}\n.visible-print-block {\n display: none !important;\n}\n@media print {\n .visible-print-block {\n display: block !important;\n }\n}\n.visible-print-inline {\n display: none !important;\n}\n@media print {\n .visible-print-inline {\n display: inline !important;\n }\n}\n.visible-print-inline-block {\n display: none !important;\n}\n@media print {\n .visible-print-inline-block {\n display: inline-block !important;\n }\n}\n@media print {\n .hidden-print {\n display: none !important;\n }\n}\n/*# sourceMappingURL=bootstrap.css.map */","/*!\n * Bootstrap v3.3.7 (http://getbootstrap.com)\n * Copyright 2011-2016 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */\nhtml {\n font-family: sans-serif;\n -webkit-text-size-adjust: 100%;\n -ms-text-size-adjust: 100%;\n}\nbody {\n margin: 0;\n}\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmain,\nmenu,\nnav,\nsection,\nsummary {\n display: block;\n}\naudio,\ncanvas,\nprogress,\nvideo {\n display: inline-block;\n vertical-align: baseline;\n}\naudio:not([controls]) {\n display: none;\n height: 0;\n}\n[hidden],\ntemplate {\n display: none;\n}\na {\n background-color: transparent;\n}\na:active,\na:hover {\n outline: 0;\n}\nabbr[title] {\n border-bottom: 1px dotted;\n}\nb,\nstrong {\n font-weight: bold;\n}\ndfn {\n font-style: italic;\n}\nh1 {\n margin: .67em 0;\n font-size: 2em;\n}\nmark {\n color: #000;\n background: #ff0;\n}\nsmall {\n font-size: 80%;\n}\nsub,\nsup {\n position: relative;\n font-size: 75%;\n line-height: 0;\n vertical-align: baseline;\n}\nsup {\n top: -.5em;\n}\nsub {\n bottom: -.25em;\n}\nimg {\n border: 0;\n}\nsvg:not(:root) {\n overflow: hidden;\n}\nfigure {\n margin: 1em 40px;\n}\nhr {\n height: 0;\n -webkit-box-sizing: content-box;\n -moz-box-sizing: content-box;\n box-sizing: content-box;\n}\npre {\n overflow: auto;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: monospace, monospace;\n font-size: 1em;\n}\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n margin: 0;\n font: inherit;\n color: inherit;\n}\nbutton {\n overflow: visible;\n}\nbutton,\nselect {\n text-transform: none;\n}\nbutton,\nhtml input[type=\"button\"],\ninput[type=\"reset\"],\ninput[type=\"submit\"] {\n -webkit-appearance: button;\n cursor: pointer;\n}\nbutton[disabled],\nhtml input[disabled] {\n cursor: default;\n}\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n padding: 0;\n border: 0;\n}\ninput {\n line-height: normal;\n}\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n padding: 0;\n}\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-box-sizing: content-box;\n -moz-box-sizing: content-box;\n box-sizing: content-box;\n -webkit-appearance: textfield;\n}\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\nfieldset {\n padding: .35em .625em .75em;\n margin: 0 2px;\n border: 1px solid #c0c0c0;\n}\nlegend {\n padding: 0;\n border: 0;\n}\ntextarea {\n overflow: auto;\n}\noptgroup {\n font-weight: bold;\n}\ntable {\n border-spacing: 0;\n border-collapse: collapse;\n}\ntd,\nth {\n padding: 0;\n}\n/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */\n@media print {\n *,\n *:before,\n *:after {\n color: #000 !important;\n text-shadow: none !important;\n background: transparent !important;\n -webkit-box-shadow: none !important;\n box-shadow: none !important;\n }\n a,\n a:visited {\n text-decoration: underline;\n }\n a[href]:after {\n content: \" (\" attr(href) \")\";\n }\n abbr[title]:after {\n content: \" (\" attr(title) \")\";\n }\n a[href^=\"#\"]:after,\n a[href^=\"javascript:\"]:after {\n content: \"\";\n }\n pre,\n blockquote {\n border: 1px solid #999;\n\n page-break-inside: avoid;\n }\n thead {\n display: table-header-group;\n }\n tr,\n img {\n page-break-inside: avoid;\n }\n img {\n max-width: 100% !important;\n }\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n h2,\n h3 {\n page-break-after: avoid;\n }\n .navbar {\n display: none;\n }\n .btn > .caret,\n .dropup > .btn > .caret {\n border-top-color: #000 !important;\n }\n .label {\n border: 1px solid #000;\n }\n .table {\n border-collapse: collapse !important;\n }\n .table td,\n .table th {\n background-color: #fff !important;\n }\n .table-bordered th,\n .table-bordered td {\n border: 1px solid #ddd !important;\n }\n}\n@font-face {\n font-family: 'Glyphicons Halflings';\n\n src: url('../fonts/glyphicons-halflings-regular.eot');\n src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');\n}\n.glyphicon {\n position: relative;\n top: 1px;\n display: inline-block;\n font-family: 'Glyphicons Halflings';\n font-style: normal;\n font-weight: normal;\n line-height: 1;\n\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n.glyphicon-asterisk:before {\n content: \"\\002a\";\n}\n.glyphicon-plus:before {\n content: \"\\002b\";\n}\n.glyphicon-euro:before,\n.glyphicon-eur:before {\n content: \"\\20ac\";\n}\n.glyphicon-minus:before {\n content: \"\\2212\";\n}\n.glyphicon-cloud:before {\n content: \"\\2601\";\n}\n.glyphicon-envelope:before {\n content: \"\\2709\";\n}\n.glyphicon-pencil:before {\n content: \"\\270f\";\n}\n.glyphicon-glass:before {\n content: \"\\e001\";\n}\n.glyphicon-music:before {\n content: \"\\e002\";\n}\n.glyphicon-search:before {\n content: \"\\e003\";\n}\n.glyphicon-heart:before {\n content: \"\\e005\";\n}\n.glyphicon-star:before {\n content: \"\\e006\";\n}\n.glyphicon-star-empty:before {\n content: \"\\e007\";\n}\n.glyphicon-user:before {\n content: \"\\e008\";\n}\n.glyphicon-film:before {\n content: \"\\e009\";\n}\n.glyphicon-th-large:before {\n content: \"\\e010\";\n}\n.glyphicon-th:before {\n content: \"\\e011\";\n}\n.glyphicon-th-list:before {\n content: \"\\e012\";\n}\n.glyphicon-ok:before {\n content: \"\\e013\";\n}\n.glyphicon-remove:before {\n content: \"\\e014\";\n}\n.glyphicon-zoom-in:before {\n content: \"\\e015\";\n}\n.glyphicon-zoom-out:before {\n content: \"\\e016\";\n}\n.glyphicon-off:before {\n content: \"\\e017\";\n}\n.glyphicon-signal:before {\n content: \"\\e018\";\n}\n.glyphicon-cog:before {\n content: \"\\e019\";\n}\n.glyphicon-trash:before {\n content: \"\\e020\";\n}\n.glyphicon-home:before {\n content: \"\\e021\";\n}\n.glyphicon-file:before {\n content: \"\\e022\";\n}\n.glyphicon-time:before {\n content: \"\\e023\";\n}\n.glyphicon-road:before {\n content: \"\\e024\";\n}\n.glyphicon-download-alt:before {\n content: \"\\e025\";\n}\n.glyphicon-download:before {\n content: \"\\e026\";\n}\n.glyphicon-upload:before {\n content: \"\\e027\";\n}\n.glyphicon-inbox:before {\n content: \"\\e028\";\n}\n.glyphicon-play-circle:before {\n content: \"\\e029\";\n}\n.glyphicon-repeat:before {\n content: \"\\e030\";\n}\n.glyphicon-refresh:before {\n content: \"\\e031\";\n}\n.glyphicon-list-alt:before {\n content: \"\\e032\";\n}\n.glyphicon-lock:before {\n content: \"\\e033\";\n}\n.glyphicon-flag:before {\n content: \"\\e034\";\n}\n.glyphicon-headphones:before {\n content: \"\\e035\";\n}\n.glyphicon-volume-off:before {\n content: \"\\e036\";\n}\n.glyphicon-volume-down:before {\n content: \"\\e037\";\n}\n.glyphicon-volume-up:before {\n content: \"\\e038\";\n}\n.glyphicon-qrcode:before {\n content: \"\\e039\";\n}\n.glyphicon-barcode:before {\n content: \"\\e040\";\n}\n.glyphicon-tag:before {\n content: \"\\e041\";\n}\n.glyphicon-tags:before {\n content: \"\\e042\";\n}\n.glyphicon-book:before {\n content: \"\\e043\";\n}\n.glyphicon-bookmark:before {\n content: \"\\e044\";\n}\n.glyphicon-print:before {\n content: \"\\e045\";\n}\n.glyphicon-camera:before {\n content: \"\\e046\";\n}\n.glyphicon-font:before {\n content: \"\\e047\";\n}\n.glyphicon-bold:before {\n content: \"\\e048\";\n}\n.glyphicon-italic:before {\n content: \"\\e049\";\n}\n.glyphicon-text-height:before {\n content: \"\\e050\";\n}\n.glyphicon-text-width:before {\n content: \"\\e051\";\n}\n.glyphicon-align-left:before {\n content: \"\\e052\";\n}\n.glyphicon-align-center:before {\n content: \"\\e053\";\n}\n.glyphicon-align-right:before {\n content: \"\\e054\";\n}\n.glyphicon-align-justify:before {\n content: \"\\e055\";\n}\n.glyphicon-list:before {\n content: \"\\e056\";\n}\n.glyphicon-indent-left:before {\n content: \"\\e057\";\n}\n.glyphicon-indent-right:before {\n content: \"\\e058\";\n}\n.glyphicon-facetime-video:before {\n content: \"\\e059\";\n}\n.glyphicon-picture:before {\n content: \"\\e060\";\n}\n.glyphicon-map-marker:before {\n content: \"\\e062\";\n}\n.glyphicon-adjust:before {\n content: \"\\e063\";\n}\n.glyphicon-tint:before {\n content: \"\\e064\";\n}\n.glyphicon-edit:before {\n content: \"\\e065\";\n}\n.glyphicon-share:before {\n content: \"\\e066\";\n}\n.glyphicon-check:before {\n content: \"\\e067\";\n}\n.glyphicon-move:before {\n content: \"\\e068\";\n}\n.glyphicon-step-backward:before {\n content: \"\\e069\";\n}\n.glyphicon-fast-backward:before {\n content: \"\\e070\";\n}\n.glyphicon-backward:before {\n content: \"\\e071\";\n}\n.glyphicon-play:before {\n content: \"\\e072\";\n}\n.glyphicon-pause:before {\n content: \"\\e073\";\n}\n.glyphicon-stop:before {\n content: \"\\e074\";\n}\n.glyphicon-forward:before {\n content: \"\\e075\";\n}\n.glyphicon-fast-forward:before {\n content: \"\\e076\";\n}\n.glyphicon-step-forward:before {\n content: \"\\e077\";\n}\n.glyphicon-eject:before {\n content: \"\\e078\";\n}\n.glyphicon-chevron-left:before {\n content: \"\\e079\";\n}\n.glyphicon-chevron-right:before {\n content: \"\\e080\";\n}\n.glyphicon-plus-sign:before {\n content: \"\\e081\";\n}\n.glyphicon-minus-sign:before {\n content: \"\\e082\";\n}\n.glyphicon-remove-sign:before {\n content: \"\\e083\";\n}\n.glyphicon-ok-sign:before {\n content: \"\\e084\";\n}\n.glyphicon-question-sign:before {\n content: \"\\e085\";\n}\n.glyphicon-info-sign:before {\n content: \"\\e086\";\n}\n.glyphicon-screenshot:before {\n content: \"\\e087\";\n}\n.glyphicon-remove-circle:before {\n content: \"\\e088\";\n}\n.glyphicon-ok-circle:before {\n content: \"\\e089\";\n}\n.glyphicon-ban-circle:before {\n content: \"\\e090\";\n}\n.glyphicon-arrow-left:before {\n content: \"\\e091\";\n}\n.glyphicon-arrow-right:before {\n content: \"\\e092\";\n}\n.glyphicon-arrow-up:before {\n content: \"\\e093\";\n}\n.glyphicon-arrow-down:before {\n content: \"\\e094\";\n}\n.glyphicon-share-alt:before {\n content: \"\\e095\";\n}\n.glyphicon-resize-full:before {\n content: \"\\e096\";\n}\n.glyphicon-resize-small:before {\n content: \"\\e097\";\n}\n.glyphicon-exclamation-sign:before {\n content: \"\\e101\";\n}\n.glyphicon-gift:before {\n content: \"\\e102\";\n}\n.glyphicon-leaf:before {\n content: \"\\e103\";\n}\n.glyphicon-fire:before {\n content: \"\\e104\";\n}\n.glyphicon-eye-open:before {\n content: \"\\e105\";\n}\n.glyphicon-eye-close:before {\n content: \"\\e106\";\n}\n.glyphicon-warning-sign:before {\n content: \"\\e107\";\n}\n.glyphicon-plane:before {\n content: \"\\e108\";\n}\n.glyphicon-calendar:before {\n content: \"\\e109\";\n}\n.glyphicon-random:before {\n content: \"\\e110\";\n}\n.glyphicon-comment:before {\n content: \"\\e111\";\n}\n.glyphicon-magnet:before {\n content: \"\\e112\";\n}\n.glyphicon-chevron-up:before {\n content: \"\\e113\";\n}\n.glyphicon-chevron-down:before {\n content: \"\\e114\";\n}\n.glyphicon-retweet:before {\n content: \"\\e115\";\n}\n.glyphicon-shopping-cart:before {\n content: \"\\e116\";\n}\n.glyphicon-folder-close:before {\n content: \"\\e117\";\n}\n.glyphicon-folder-open:before {\n content: \"\\e118\";\n}\n.glyphicon-resize-vertical:before {\n content: \"\\e119\";\n}\n.glyphicon-resize-horizontal:before {\n content: \"\\e120\";\n}\n.glyphicon-hdd:before {\n content: \"\\e121\";\n}\n.glyphicon-bullhorn:before {\n content: \"\\e122\";\n}\n.glyphicon-bell:before {\n content: \"\\e123\";\n}\n.glyphicon-certificate:before {\n content: \"\\e124\";\n}\n.glyphicon-thumbs-up:before {\n content: \"\\e125\";\n}\n.glyphicon-thumbs-down:before {\n content: \"\\e126\";\n}\n.glyphicon-hand-right:before {\n content: \"\\e127\";\n}\n.glyphicon-hand-left:before {\n content: \"\\e128\";\n}\n.glyphicon-hand-up:before {\n content: \"\\e129\";\n}\n.glyphicon-hand-down:before {\n content: \"\\e130\";\n}\n.glyphicon-circle-arrow-right:before {\n content: \"\\e131\";\n}\n.glyphicon-circle-arrow-left:before {\n content: \"\\e132\";\n}\n.glyphicon-circle-arrow-up:before {\n content: \"\\e133\";\n}\n.glyphicon-circle-arrow-down:before {\n content: \"\\e134\";\n}\n.glyphicon-globe:before {\n content: \"\\e135\";\n}\n.glyphicon-wrench:before {\n content: \"\\e136\";\n}\n.glyphicon-tasks:before {\n content: \"\\e137\";\n}\n.glyphicon-filter:before {\n content: \"\\e138\";\n}\n.glyphicon-briefcase:before {\n content: \"\\e139\";\n}\n.glyphicon-fullscreen:before {\n content: \"\\e140\";\n}\n.glyphicon-dashboard:before {\n content: \"\\e141\";\n}\n.glyphicon-paperclip:before {\n content: \"\\e142\";\n}\n.glyphicon-heart-empty:before {\n content: \"\\e143\";\n}\n.glyphicon-link:before {\n content: \"\\e144\";\n}\n.glyphicon-phone:before {\n content: \"\\e145\";\n}\n.glyphicon-pushpin:before {\n content: \"\\e146\";\n}\n.glyphicon-usd:before {\n content: \"\\e148\";\n}\n.glyphicon-gbp:before {\n content: \"\\e149\";\n}\n.glyphicon-sort:before {\n content: \"\\e150\";\n}\n.glyphicon-sort-by-alphabet:before {\n content: \"\\e151\";\n}\n.glyphicon-sort-by-alphabet-alt:before {\n content: \"\\e152\";\n}\n.glyphicon-sort-by-order:before {\n content: \"\\e153\";\n}\n.glyphicon-sort-by-order-alt:before {\n content: \"\\e154\";\n}\n.glyphicon-sort-by-attributes:before {\n content: \"\\e155\";\n}\n.glyphicon-sort-by-attributes-alt:before {\n content: \"\\e156\";\n}\n.glyphicon-unchecked:before {\n content: \"\\e157\";\n}\n.glyphicon-expand:before {\n content: \"\\e158\";\n}\n.glyphicon-collapse-down:before {\n content: \"\\e159\";\n}\n.glyphicon-collapse-up:before {\n content: \"\\e160\";\n}\n.glyphicon-log-in:before {\n content: \"\\e161\";\n}\n.glyphicon-flash:before {\n content: \"\\e162\";\n}\n.glyphicon-log-out:before {\n content: \"\\e163\";\n}\n.glyphicon-new-window:before {\n content: \"\\e164\";\n}\n.glyphicon-record:before {\n content: \"\\e165\";\n}\n.glyphicon-save:before {\n content: \"\\e166\";\n}\n.glyphicon-open:before {\n content: \"\\e167\";\n}\n.glyphicon-saved:before {\n content: \"\\e168\";\n}\n.glyphicon-import:before {\n content: \"\\e169\";\n}\n.glyphicon-export:before {\n content: \"\\e170\";\n}\n.glyphicon-send:before {\n content: \"\\e171\";\n}\n.glyphicon-floppy-disk:before {\n content: \"\\e172\";\n}\n.glyphicon-floppy-saved:before {\n content: \"\\e173\";\n}\n.glyphicon-floppy-remove:before {\n content: \"\\e174\";\n}\n.glyphicon-floppy-save:before {\n content: \"\\e175\";\n}\n.glyphicon-floppy-open:before {\n content: \"\\e176\";\n}\n.glyphicon-credit-card:before {\n content: \"\\e177\";\n}\n.glyphicon-transfer:before {\n content: \"\\e178\";\n}\n.glyphicon-cutlery:before {\n content: \"\\e179\";\n}\n.glyphicon-header:before {\n content: \"\\e180\";\n}\n.glyphicon-compressed:before {\n content: \"\\e181\";\n}\n.glyphicon-earphone:before {\n content: \"\\e182\";\n}\n.glyphicon-phone-alt:before {\n content: \"\\e183\";\n}\n.glyphicon-tower:before {\n content: \"\\e184\";\n}\n.glyphicon-stats:before {\n content: \"\\e185\";\n}\n.glyphicon-sd-video:before {\n content: \"\\e186\";\n}\n.glyphicon-hd-video:before {\n content: \"\\e187\";\n}\n.glyphicon-subtitles:before {\n content: \"\\e188\";\n}\n.glyphicon-sound-stereo:before {\n content: \"\\e189\";\n}\n.glyphicon-sound-dolby:before {\n content: \"\\e190\";\n}\n.glyphicon-sound-5-1:before {\n content: \"\\e191\";\n}\n.glyphicon-sound-6-1:before {\n content: \"\\e192\";\n}\n.glyphicon-sound-7-1:before {\n content: \"\\e193\";\n}\n.glyphicon-copyright-mark:before {\n content: \"\\e194\";\n}\n.glyphicon-registration-mark:before {\n content: \"\\e195\";\n}\n.glyphicon-cloud-download:before {\n content: \"\\e197\";\n}\n.glyphicon-cloud-upload:before {\n content: \"\\e198\";\n}\n.glyphicon-tree-conifer:before {\n content: \"\\e199\";\n}\n.glyphicon-tree-deciduous:before {\n content: \"\\e200\";\n}\n.glyphicon-cd:before {\n content: \"\\e201\";\n}\n.glyphicon-save-file:before {\n content: \"\\e202\";\n}\n.glyphicon-open-file:before {\n content: \"\\e203\";\n}\n.glyphicon-level-up:before {\n content: \"\\e204\";\n}\n.glyphicon-copy:before {\n content: \"\\e205\";\n}\n.glyphicon-paste:before {\n content: \"\\e206\";\n}\n.glyphicon-alert:before {\n content: \"\\e209\";\n}\n.glyphicon-equalizer:before {\n content: \"\\e210\";\n}\n.glyphicon-king:before {\n content: \"\\e211\";\n}\n.glyphicon-queen:before {\n content: \"\\e212\";\n}\n.glyphicon-pawn:before {\n content: \"\\e213\";\n}\n.glyphicon-bishop:before {\n content: \"\\e214\";\n}\n.glyphicon-knight:before {\n content: \"\\e215\";\n}\n.glyphicon-baby-formula:before {\n content: \"\\e216\";\n}\n.glyphicon-tent:before {\n content: \"\\26fa\";\n}\n.glyphicon-blackboard:before {\n content: \"\\e218\";\n}\n.glyphicon-bed:before {\n content: \"\\e219\";\n}\n.glyphicon-apple:before {\n content: \"\\f8ff\";\n}\n.glyphicon-erase:before {\n content: \"\\e221\";\n}\n.glyphicon-hourglass:before {\n content: \"\\231b\";\n}\n.glyphicon-lamp:before {\n content: \"\\e223\";\n}\n.glyphicon-duplicate:before {\n content: \"\\e224\";\n}\n.glyphicon-piggy-bank:before {\n content: \"\\e225\";\n}\n.glyphicon-scissors:before {\n content: \"\\e226\";\n}\n.glyphicon-bitcoin:before {\n content: \"\\e227\";\n}\n.glyphicon-btc:before {\n content: \"\\e227\";\n}\n.glyphicon-xbt:before {\n content: \"\\e227\";\n}\n.glyphicon-yen:before {\n content: \"\\00a5\";\n}\n.glyphicon-jpy:before {\n content: \"\\00a5\";\n}\n.glyphicon-ruble:before {\n content: \"\\20bd\";\n}\n.glyphicon-rub:before {\n content: \"\\20bd\";\n}\n.glyphicon-scale:before {\n content: \"\\e230\";\n}\n.glyphicon-ice-lolly:before {\n content: \"\\e231\";\n}\n.glyphicon-ice-lolly-tasted:before {\n content: \"\\e232\";\n}\n.glyphicon-education:before {\n content: \"\\e233\";\n}\n.glyphicon-option-horizontal:before {\n content: \"\\e234\";\n}\n.glyphicon-option-vertical:before {\n content: \"\\e235\";\n}\n.glyphicon-menu-hamburger:before {\n content: \"\\e236\";\n}\n.glyphicon-modal-window:before {\n content: \"\\e237\";\n}\n.glyphicon-oil:before {\n content: \"\\e238\";\n}\n.glyphicon-grain:before {\n content: \"\\e239\";\n}\n.glyphicon-sunglasses:before {\n content: \"\\e240\";\n}\n.glyphicon-text-size:before {\n content: \"\\e241\";\n}\n.glyphicon-text-color:before {\n content: \"\\e242\";\n}\n.glyphicon-text-background:before {\n content: \"\\e243\";\n}\n.glyphicon-object-align-top:before {\n content: \"\\e244\";\n}\n.glyphicon-object-align-bottom:before {\n content: \"\\e245\";\n}\n.glyphicon-object-align-horizontal:before {\n content: \"\\e246\";\n}\n.glyphicon-object-align-left:before {\n content: \"\\e247\";\n}\n.glyphicon-object-align-vertical:before {\n content: \"\\e248\";\n}\n.glyphicon-object-align-right:before {\n content: \"\\e249\";\n}\n.glyphicon-triangle-right:before {\n content: \"\\e250\";\n}\n.glyphicon-triangle-left:before {\n content: \"\\e251\";\n}\n.glyphicon-triangle-bottom:before {\n content: \"\\e252\";\n}\n.glyphicon-triangle-top:before {\n content: \"\\e253\";\n}\n.glyphicon-console:before {\n content: \"\\e254\";\n}\n.glyphicon-superscript:before {\n content: \"\\e255\";\n}\n.glyphicon-subscript:before {\n content: \"\\e256\";\n}\n.glyphicon-menu-left:before {\n content: \"\\e257\";\n}\n.glyphicon-menu-right:before {\n content: \"\\e258\";\n}\n.glyphicon-menu-down:before {\n content: \"\\e259\";\n}\n.glyphicon-menu-up:before {\n content: \"\\e260\";\n}\n* {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\n*:before,\n*:after {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\nhtml {\n font-size: 10px;\n\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\nbody {\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 14px;\n line-height: 1.42857143;\n color: #333;\n background-color: #fff;\n}\ninput,\nbutton,\nselect,\ntextarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\na {\n color: #337ab7;\n text-decoration: none;\n}\na:hover,\na:focus {\n color: #23527c;\n text-decoration: underline;\n}\na:focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\nfigure {\n margin: 0;\n}\nimg {\n vertical-align: middle;\n}\n.img-responsive,\n.thumbnail > img,\n.thumbnail a > img,\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n display: block;\n max-width: 100%;\n height: auto;\n}\n.img-rounded {\n border-radius: 6px;\n}\n.img-thumbnail {\n display: inline-block;\n max-width: 100%;\n height: auto;\n padding: 4px;\n line-height: 1.42857143;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n -webkit-transition: all .2s ease-in-out;\n -o-transition: all .2s ease-in-out;\n transition: all .2s ease-in-out;\n}\n.img-circle {\n border-radius: 50%;\n}\nhr {\n margin-top: 20px;\n margin-bottom: 20px;\n border: 0;\n border-top: 1px solid #eee;\n}\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n border: 0;\n}\n.sr-only-focusable:active,\n.sr-only-focusable:focus {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n overflow: visible;\n clip: auto;\n}\n[role=\"button\"] {\n cursor: pointer;\n}\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\n.h1,\n.h2,\n.h3,\n.h4,\n.h5,\n.h6 {\n font-family: inherit;\n font-weight: 500;\n line-height: 1.1;\n color: inherit;\n}\nh1 small,\nh2 small,\nh3 small,\nh4 small,\nh5 small,\nh6 small,\n.h1 small,\n.h2 small,\n.h3 small,\n.h4 small,\n.h5 small,\n.h6 small,\nh1 .small,\nh2 .small,\nh3 .small,\nh4 .small,\nh5 .small,\nh6 .small,\n.h1 .small,\n.h2 .small,\n.h3 .small,\n.h4 .small,\n.h5 .small,\n.h6 .small {\n font-weight: normal;\n line-height: 1;\n color: #777;\n}\nh1,\n.h1,\nh2,\n.h2,\nh3,\n.h3 {\n margin-top: 20px;\n margin-bottom: 10px;\n}\nh1 small,\n.h1 small,\nh2 small,\n.h2 small,\nh3 small,\n.h3 small,\nh1 .small,\n.h1 .small,\nh2 .small,\n.h2 .small,\nh3 .small,\n.h3 .small {\n font-size: 65%;\n}\nh4,\n.h4,\nh5,\n.h5,\nh6,\n.h6 {\n margin-top: 10px;\n margin-bottom: 10px;\n}\nh4 small,\n.h4 small,\nh5 small,\n.h5 small,\nh6 small,\n.h6 small,\nh4 .small,\n.h4 .small,\nh5 .small,\n.h5 .small,\nh6 .small,\n.h6 .small {\n font-size: 75%;\n}\nh1,\n.h1 {\n font-size: 36px;\n}\nh2,\n.h2 {\n font-size: 30px;\n}\nh3,\n.h3 {\n font-size: 24px;\n}\nh4,\n.h4 {\n font-size: 18px;\n}\nh5,\n.h5 {\n font-size: 14px;\n}\nh6,\n.h6 {\n font-size: 12px;\n}\np {\n margin: 0 0 10px;\n}\n.lead {\n margin-bottom: 20px;\n font-size: 16px;\n font-weight: 300;\n line-height: 1.4;\n}\n@media (min-width: 768px) {\n .lead {\n font-size: 21px;\n }\n}\nsmall,\n.small {\n font-size: 85%;\n}\nmark,\n.mark {\n padding: .2em;\n background-color: #fcf8e3;\n}\n.text-left {\n text-align: left;\n}\n.text-right {\n text-align: right;\n}\n.text-center {\n text-align: center;\n}\n.text-justify {\n text-align: justify;\n}\n.text-nowrap {\n white-space: nowrap;\n}\n.text-lowercase {\n text-transform: lowercase;\n}\n.text-uppercase {\n text-transform: uppercase;\n}\n.text-capitalize {\n text-transform: capitalize;\n}\n.text-muted {\n color: #777;\n}\n.text-primary {\n color: #337ab7;\n}\na.text-primary:hover,\na.text-primary:focus {\n color: #286090;\n}\n.text-success {\n color: #3c763d;\n}\na.text-success:hover,\na.text-success:focus {\n color: #2b542c;\n}\n.text-info {\n color: #31708f;\n}\na.text-info:hover,\na.text-info:focus {\n color: #245269;\n}\n.text-warning {\n color: #8a6d3b;\n}\na.text-warning:hover,\na.text-warning:focus {\n color: #66512c;\n}\n.text-danger {\n color: #a94442;\n}\na.text-danger:hover,\na.text-danger:focus {\n color: #843534;\n}\n.bg-primary {\n color: #fff;\n background-color: #337ab7;\n}\na.bg-primary:hover,\na.bg-primary:focus {\n background-color: #286090;\n}\n.bg-success {\n background-color: #dff0d8;\n}\na.bg-success:hover,\na.bg-success:focus {\n background-color: #c1e2b3;\n}\n.bg-info {\n background-color: #d9edf7;\n}\na.bg-info:hover,\na.bg-info:focus {\n background-color: #afd9ee;\n}\n.bg-warning {\n background-color: #fcf8e3;\n}\na.bg-warning:hover,\na.bg-warning:focus {\n background-color: #f7ecb5;\n}\n.bg-danger {\n background-color: #f2dede;\n}\na.bg-danger:hover,\na.bg-danger:focus {\n background-color: #e4b9b9;\n}\n.page-header {\n padding-bottom: 9px;\n margin: 40px 0 20px;\n border-bottom: 1px solid #eee;\n}\nul,\nol {\n margin-top: 0;\n margin-bottom: 10px;\n}\nul ul,\nol ul,\nul ol,\nol ol {\n margin-bottom: 0;\n}\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n.list-inline {\n padding-left: 0;\n margin-left: -5px;\n list-style: none;\n}\n.list-inline > li {\n display: inline-block;\n padding-right: 5px;\n padding-left: 5px;\n}\ndl {\n margin-top: 0;\n margin-bottom: 20px;\n}\ndt,\ndd {\n line-height: 1.42857143;\n}\ndt {\n font-weight: bold;\n}\ndd {\n margin-left: 0;\n}\n@media (min-width: 768px) {\n .dl-horizontal dt {\n float: left;\n width: 160px;\n overflow: hidden;\n clear: left;\n text-align: right;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n .dl-horizontal dd {\n margin-left: 180px;\n }\n}\nabbr[title],\nabbr[data-original-title] {\n cursor: help;\n border-bottom: 1px dotted #777;\n}\n.initialism {\n font-size: 90%;\n text-transform: uppercase;\n}\nblockquote {\n padding: 10px 20px;\n margin: 0 0 20px;\n font-size: 17.5px;\n border-left: 5px solid #eee;\n}\nblockquote p:last-child,\nblockquote ul:last-child,\nblockquote ol:last-child {\n margin-bottom: 0;\n}\nblockquote footer,\nblockquote small,\nblockquote .small {\n display: block;\n font-size: 80%;\n line-height: 1.42857143;\n color: #777;\n}\nblockquote footer:before,\nblockquote small:before,\nblockquote .small:before {\n content: '\\2014 \\00A0';\n}\n.blockquote-reverse,\nblockquote.pull-right {\n padding-right: 15px;\n padding-left: 0;\n text-align: right;\n border-right: 5px solid #eee;\n border-left: 0;\n}\n.blockquote-reverse footer:before,\nblockquote.pull-right footer:before,\n.blockquote-reverse small:before,\nblockquote.pull-right small:before,\n.blockquote-reverse .small:before,\nblockquote.pull-right .small:before {\n content: '';\n}\n.blockquote-reverse footer:after,\nblockquote.pull-right footer:after,\n.blockquote-reverse small:after,\nblockquote.pull-right small:after,\n.blockquote-reverse .small:after,\nblockquote.pull-right .small:after {\n content: '\\00A0 \\2014';\n}\naddress {\n margin-bottom: 20px;\n font-style: normal;\n line-height: 1.42857143;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: Menlo, Monaco, Consolas, \"Courier New\", monospace;\n}\ncode {\n padding: 2px 4px;\n font-size: 90%;\n color: #c7254e;\n background-color: #f9f2f4;\n border-radius: 4px;\n}\nkbd {\n padding: 2px 4px;\n font-size: 90%;\n color: #fff;\n background-color: #333;\n border-radius: 3px;\n -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25);\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25);\n}\nkbd kbd {\n padding: 0;\n font-size: 100%;\n font-weight: bold;\n -webkit-box-shadow: none;\n box-shadow: none;\n}\npre {\n display: block;\n padding: 9.5px;\n margin: 0 0 10px;\n font-size: 13px;\n line-height: 1.42857143;\n color: #333;\n word-break: break-all;\n word-wrap: break-word;\n background-color: #f5f5f5;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\npre code {\n padding: 0;\n font-size: inherit;\n color: inherit;\n white-space: pre-wrap;\n background-color: transparent;\n border-radius: 0;\n}\n.pre-scrollable {\n max-height: 340px;\n overflow-y: scroll;\n}\n.container {\n padding-right: 15px;\n padding-left: 15px;\n margin-right: auto;\n margin-left: auto;\n}\n@media (min-width: 768px) {\n .container {\n width: 750px;\n }\n}\n@media (min-width: 992px) {\n .container {\n width: 970px;\n }\n}\n@media (min-width: 1200px) {\n .container {\n width: 1170px;\n }\n}\n.container-fluid {\n padding-right: 15px;\n padding-left: 15px;\n margin-right: auto;\n margin-left: auto;\n}\n.row {\n margin-right: -15px;\n margin-left: -15px;\n}\n.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 {\n position: relative;\n min-height: 1px;\n padding-right: 15px;\n padding-left: 15px;\n}\n.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 {\n float: left;\n}\n.col-xs-12 {\n width: 100%;\n}\n.col-xs-11 {\n width: 91.66666667%;\n}\n.col-xs-10 {\n width: 83.33333333%;\n}\n.col-xs-9 {\n width: 75%;\n}\n.col-xs-8 {\n width: 66.66666667%;\n}\n.col-xs-7 {\n width: 58.33333333%;\n}\n.col-xs-6 {\n width: 50%;\n}\n.col-xs-5 {\n width: 41.66666667%;\n}\n.col-xs-4 {\n width: 33.33333333%;\n}\n.col-xs-3 {\n width: 25%;\n}\n.col-xs-2 {\n width: 16.66666667%;\n}\n.col-xs-1 {\n width: 8.33333333%;\n}\n.col-xs-pull-12 {\n right: 100%;\n}\n.col-xs-pull-11 {\n right: 91.66666667%;\n}\n.col-xs-pull-10 {\n right: 83.33333333%;\n}\n.col-xs-pull-9 {\n right: 75%;\n}\n.col-xs-pull-8 {\n right: 66.66666667%;\n}\n.col-xs-pull-7 {\n right: 58.33333333%;\n}\n.col-xs-pull-6 {\n right: 50%;\n}\n.col-xs-pull-5 {\n right: 41.66666667%;\n}\n.col-xs-pull-4 {\n right: 33.33333333%;\n}\n.col-xs-pull-3 {\n right: 25%;\n}\n.col-xs-pull-2 {\n right: 16.66666667%;\n}\n.col-xs-pull-1 {\n right: 8.33333333%;\n}\n.col-xs-pull-0 {\n right: auto;\n}\n.col-xs-push-12 {\n left: 100%;\n}\n.col-xs-push-11 {\n left: 91.66666667%;\n}\n.col-xs-push-10 {\n left: 83.33333333%;\n}\n.col-xs-push-9 {\n left: 75%;\n}\n.col-xs-push-8 {\n left: 66.66666667%;\n}\n.col-xs-push-7 {\n left: 58.33333333%;\n}\n.col-xs-push-6 {\n left: 50%;\n}\n.col-xs-push-5 {\n left: 41.66666667%;\n}\n.col-xs-push-4 {\n left: 33.33333333%;\n}\n.col-xs-push-3 {\n left: 25%;\n}\n.col-xs-push-2 {\n left: 16.66666667%;\n}\n.col-xs-push-1 {\n left: 8.33333333%;\n}\n.col-xs-push-0 {\n left: auto;\n}\n.col-xs-offset-12 {\n margin-left: 100%;\n}\n.col-xs-offset-11 {\n margin-left: 91.66666667%;\n}\n.col-xs-offset-10 {\n margin-left: 83.33333333%;\n}\n.col-xs-offset-9 {\n margin-left: 75%;\n}\n.col-xs-offset-8 {\n margin-left: 66.66666667%;\n}\n.col-xs-offset-7 {\n margin-left: 58.33333333%;\n}\n.col-xs-offset-6 {\n margin-left: 50%;\n}\n.col-xs-offset-5 {\n margin-left: 41.66666667%;\n}\n.col-xs-offset-4 {\n margin-left: 33.33333333%;\n}\n.col-xs-offset-3 {\n margin-left: 25%;\n}\n.col-xs-offset-2 {\n margin-left: 16.66666667%;\n}\n.col-xs-offset-1 {\n margin-left: 8.33333333%;\n}\n.col-xs-offset-0 {\n margin-left: 0;\n}\n@media (min-width: 768px) {\n .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 {\n float: left;\n }\n .col-sm-12 {\n width: 100%;\n }\n .col-sm-11 {\n width: 91.66666667%;\n }\n .col-sm-10 {\n width: 83.33333333%;\n }\n .col-sm-9 {\n width: 75%;\n }\n .col-sm-8 {\n width: 66.66666667%;\n }\n .col-sm-7 {\n width: 58.33333333%;\n }\n .col-sm-6 {\n width: 50%;\n }\n .col-sm-5 {\n width: 41.66666667%;\n }\n .col-sm-4 {\n width: 33.33333333%;\n }\n .col-sm-3 {\n width: 25%;\n }\n .col-sm-2 {\n width: 16.66666667%;\n }\n .col-sm-1 {\n width: 8.33333333%;\n }\n .col-sm-pull-12 {\n right: 100%;\n }\n .col-sm-pull-11 {\n right: 91.66666667%;\n }\n .col-sm-pull-10 {\n right: 83.33333333%;\n }\n .col-sm-pull-9 {\n right: 75%;\n }\n .col-sm-pull-8 {\n right: 66.66666667%;\n }\n .col-sm-pull-7 {\n right: 58.33333333%;\n }\n .col-sm-pull-6 {\n right: 50%;\n }\n .col-sm-pull-5 {\n right: 41.66666667%;\n }\n .col-sm-pull-4 {\n right: 33.33333333%;\n }\n .col-sm-pull-3 {\n right: 25%;\n }\n .col-sm-pull-2 {\n right: 16.66666667%;\n }\n .col-sm-pull-1 {\n right: 8.33333333%;\n }\n .col-sm-pull-0 {\n right: auto;\n }\n .col-sm-push-12 {\n left: 100%;\n }\n .col-sm-push-11 {\n left: 91.66666667%;\n }\n .col-sm-push-10 {\n left: 83.33333333%;\n }\n .col-sm-push-9 {\n left: 75%;\n }\n .col-sm-push-8 {\n left: 66.66666667%;\n }\n .col-sm-push-7 {\n left: 58.33333333%;\n }\n .col-sm-push-6 {\n left: 50%;\n }\n .col-sm-push-5 {\n left: 41.66666667%;\n }\n .col-sm-push-4 {\n left: 33.33333333%;\n }\n .col-sm-push-3 {\n left: 25%;\n }\n .col-sm-push-2 {\n left: 16.66666667%;\n }\n .col-sm-push-1 {\n left: 8.33333333%;\n }\n .col-sm-push-0 {\n left: auto;\n }\n .col-sm-offset-12 {\n margin-left: 100%;\n }\n .col-sm-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-sm-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-sm-offset-9 {\n margin-left: 75%;\n }\n .col-sm-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-sm-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-sm-offset-6 {\n margin-left: 50%;\n }\n .col-sm-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-sm-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-sm-offset-3 {\n margin-left: 25%;\n }\n .col-sm-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-sm-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-sm-offset-0 {\n margin-left: 0;\n }\n}\n@media (min-width: 992px) {\n .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 {\n float: left;\n }\n .col-md-12 {\n width: 100%;\n }\n .col-md-11 {\n width: 91.66666667%;\n }\n .col-md-10 {\n width: 83.33333333%;\n }\n .col-md-9 {\n width: 75%;\n }\n .col-md-8 {\n width: 66.66666667%;\n }\n .col-md-7 {\n width: 58.33333333%;\n }\n .col-md-6 {\n width: 50%;\n }\n .col-md-5 {\n width: 41.66666667%;\n }\n .col-md-4 {\n width: 33.33333333%;\n }\n .col-md-3 {\n width: 25%;\n }\n .col-md-2 {\n width: 16.66666667%;\n }\n .col-md-1 {\n width: 8.33333333%;\n }\n .col-md-pull-12 {\n right: 100%;\n }\n .col-md-pull-11 {\n right: 91.66666667%;\n }\n .col-md-pull-10 {\n right: 83.33333333%;\n }\n .col-md-pull-9 {\n right: 75%;\n }\n .col-md-pull-8 {\n right: 66.66666667%;\n }\n .col-md-pull-7 {\n right: 58.33333333%;\n }\n .col-md-pull-6 {\n right: 50%;\n }\n .col-md-pull-5 {\n right: 41.66666667%;\n }\n .col-md-pull-4 {\n right: 33.33333333%;\n }\n .col-md-pull-3 {\n right: 25%;\n }\n .col-md-pull-2 {\n right: 16.66666667%;\n }\n .col-md-pull-1 {\n right: 8.33333333%;\n }\n .col-md-pull-0 {\n right: auto;\n }\n .col-md-push-12 {\n left: 100%;\n }\n .col-md-push-11 {\n left: 91.66666667%;\n }\n .col-md-push-10 {\n left: 83.33333333%;\n }\n .col-md-push-9 {\n left: 75%;\n }\n .col-md-push-8 {\n left: 66.66666667%;\n }\n .col-md-push-7 {\n left: 58.33333333%;\n }\n .col-md-push-6 {\n left: 50%;\n }\n .col-md-push-5 {\n left: 41.66666667%;\n }\n .col-md-push-4 {\n left: 33.33333333%;\n }\n .col-md-push-3 {\n left: 25%;\n }\n .col-md-push-2 {\n left: 16.66666667%;\n }\n .col-md-push-1 {\n left: 8.33333333%;\n }\n .col-md-push-0 {\n left: auto;\n }\n .col-md-offset-12 {\n margin-left: 100%;\n }\n .col-md-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-md-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-md-offset-9 {\n margin-left: 75%;\n }\n .col-md-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-md-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-md-offset-6 {\n margin-left: 50%;\n }\n .col-md-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-md-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-md-offset-3 {\n margin-left: 25%;\n }\n .col-md-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-md-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-md-offset-0 {\n margin-left: 0;\n }\n}\n@media (min-width: 1200px) {\n .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 {\n float: left;\n }\n .col-lg-12 {\n width: 100%;\n }\n .col-lg-11 {\n width: 91.66666667%;\n }\n .col-lg-10 {\n width: 83.33333333%;\n }\n .col-lg-9 {\n width: 75%;\n }\n .col-lg-8 {\n width: 66.66666667%;\n }\n .col-lg-7 {\n width: 58.33333333%;\n }\n .col-lg-6 {\n width: 50%;\n }\n .col-lg-5 {\n width: 41.66666667%;\n }\n .col-lg-4 {\n width: 33.33333333%;\n }\n .col-lg-3 {\n width: 25%;\n }\n .col-lg-2 {\n width: 16.66666667%;\n }\n .col-lg-1 {\n width: 8.33333333%;\n }\n .col-lg-pull-12 {\n right: 100%;\n }\n .col-lg-pull-11 {\n right: 91.66666667%;\n }\n .col-lg-pull-10 {\n right: 83.33333333%;\n }\n .col-lg-pull-9 {\n right: 75%;\n }\n .col-lg-pull-8 {\n right: 66.66666667%;\n }\n .col-lg-pull-7 {\n right: 58.33333333%;\n }\n .col-lg-pull-6 {\n right: 50%;\n }\n .col-lg-pull-5 {\n right: 41.66666667%;\n }\n .col-lg-pull-4 {\n right: 33.33333333%;\n }\n .col-lg-pull-3 {\n right: 25%;\n }\n .col-lg-pull-2 {\n right: 16.66666667%;\n }\n .col-lg-pull-1 {\n right: 8.33333333%;\n }\n .col-lg-pull-0 {\n right: auto;\n }\n .col-lg-push-12 {\n left: 100%;\n }\n .col-lg-push-11 {\n left: 91.66666667%;\n }\n .col-lg-push-10 {\n left: 83.33333333%;\n }\n .col-lg-push-9 {\n left: 75%;\n }\n .col-lg-push-8 {\n left: 66.66666667%;\n }\n .col-lg-push-7 {\n left: 58.33333333%;\n }\n .col-lg-push-6 {\n left: 50%;\n }\n .col-lg-push-5 {\n left: 41.66666667%;\n }\n .col-lg-push-4 {\n left: 33.33333333%;\n }\n .col-lg-push-3 {\n left: 25%;\n }\n .col-lg-push-2 {\n left: 16.66666667%;\n }\n .col-lg-push-1 {\n left: 8.33333333%;\n }\n .col-lg-push-0 {\n left: auto;\n }\n .col-lg-offset-12 {\n margin-left: 100%;\n }\n .col-lg-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-lg-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-lg-offset-9 {\n margin-left: 75%;\n }\n .col-lg-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-lg-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-lg-offset-6 {\n margin-left: 50%;\n }\n .col-lg-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-lg-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-lg-offset-3 {\n margin-left: 25%;\n }\n .col-lg-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-lg-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-lg-offset-0 {\n margin-left: 0;\n }\n}\ntable {\n background-color: transparent;\n}\ncaption {\n padding-top: 8px;\n padding-bottom: 8px;\n color: #777;\n text-align: left;\n}\nth {\n text-align: left;\n}\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: 20px;\n}\n.table > thead > tr > th,\n.table > tbody > tr > th,\n.table > tfoot > tr > th,\n.table > thead > tr > td,\n.table > tbody > tr > td,\n.table > tfoot > tr > td {\n padding: 8px;\n line-height: 1.42857143;\n vertical-align: top;\n border-top: 1px solid #ddd;\n}\n.table > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid #ddd;\n}\n.table > caption + thead > tr:first-child > th,\n.table > colgroup + thead > tr:first-child > th,\n.table > thead:first-child > tr:first-child > th,\n.table > caption + thead > tr:first-child > td,\n.table > colgroup + thead > tr:first-child > td,\n.table > thead:first-child > tr:first-child > td {\n border-top: 0;\n}\n.table > tbody + tbody {\n border-top: 2px solid #ddd;\n}\n.table .table {\n background-color: #fff;\n}\n.table-condensed > thead > tr > th,\n.table-condensed > tbody > tr > th,\n.table-condensed > tfoot > tr > th,\n.table-condensed > thead > tr > td,\n.table-condensed > tbody > tr > td,\n.table-condensed > tfoot > tr > td {\n padding: 5px;\n}\n.table-bordered {\n border: 1px solid #ddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > tbody > tr > th,\n.table-bordered > tfoot > tr > th,\n.table-bordered > thead > tr > td,\n.table-bordered > tbody > tr > td,\n.table-bordered > tfoot > tr > td {\n border: 1px solid #ddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > thead > tr > td {\n border-bottom-width: 2px;\n}\n.table-striped > tbody > tr:nth-of-type(odd) {\n background-color: #f9f9f9;\n}\n.table-hover > tbody > tr:hover {\n background-color: #f5f5f5;\n}\ntable col[class*=\"col-\"] {\n position: static;\n display: table-column;\n float: none;\n}\ntable td[class*=\"col-\"],\ntable th[class*=\"col-\"] {\n position: static;\n display: table-cell;\n float: none;\n}\n.table > thead > tr > td.active,\n.table > tbody > tr > td.active,\n.table > tfoot > tr > td.active,\n.table > thead > tr > th.active,\n.table > tbody > tr > th.active,\n.table > tfoot > tr > th.active,\n.table > thead > tr.active > td,\n.table > tbody > tr.active > td,\n.table > tfoot > tr.active > td,\n.table > thead > tr.active > th,\n.table > tbody > tr.active > th,\n.table > tfoot > tr.active > th {\n background-color: #f5f5f5;\n}\n.table-hover > tbody > tr > td.active:hover,\n.table-hover > tbody > tr > th.active:hover,\n.table-hover > tbody > tr.active:hover > td,\n.table-hover > tbody > tr:hover > .active,\n.table-hover > tbody > tr.active:hover > th {\n background-color: #e8e8e8;\n}\n.table > thead > tr > td.success,\n.table > tbody > tr > td.success,\n.table > tfoot > tr > td.success,\n.table > thead > tr > th.success,\n.table > tbody > tr > th.success,\n.table > tfoot > tr > th.success,\n.table > thead > tr.success > td,\n.table > tbody > tr.success > td,\n.table > tfoot > tr.success > td,\n.table > thead > tr.success > th,\n.table > tbody > tr.success > th,\n.table > tfoot > tr.success > th {\n background-color: #dff0d8;\n}\n.table-hover > tbody > tr > td.success:hover,\n.table-hover > tbody > tr > th.success:hover,\n.table-hover > tbody > tr.success:hover > td,\n.table-hover > tbody > tr:hover > .success,\n.table-hover > tbody > tr.success:hover > th {\n background-color: #d0e9c6;\n}\n.table > thead > tr > td.info,\n.table > tbody > tr > td.info,\n.table > tfoot > tr > td.info,\n.table > thead > tr > th.info,\n.table > tbody > tr > th.info,\n.table > tfoot > tr > th.info,\n.table > thead > tr.info > td,\n.table > tbody > tr.info > td,\n.table > tfoot > tr.info > td,\n.table > thead > tr.info > th,\n.table > tbody > tr.info > th,\n.table > tfoot > tr.info > th {\n background-color: #d9edf7;\n}\n.table-hover > tbody > tr > td.info:hover,\n.table-hover > tbody > tr > th.info:hover,\n.table-hover > tbody > tr.info:hover > td,\n.table-hover > tbody > tr:hover > .info,\n.table-hover > tbody > tr.info:hover > th {\n background-color: #c4e3f3;\n}\n.table > thead > tr > td.warning,\n.table > tbody > tr > td.warning,\n.table > tfoot > tr > td.warning,\n.table > thead > tr > th.warning,\n.table > tbody > tr > th.warning,\n.table > tfoot > tr > th.warning,\n.table > thead > tr.warning > td,\n.table > tbody > tr.warning > td,\n.table > tfoot > tr.warning > td,\n.table > thead > tr.warning > th,\n.table > tbody > tr.warning > th,\n.table > tfoot > tr.warning > th {\n background-color: #fcf8e3;\n}\n.table-hover > tbody > tr > td.warning:hover,\n.table-hover > tbody > tr > th.warning:hover,\n.table-hover > tbody > tr.warning:hover > td,\n.table-hover > tbody > tr:hover > .warning,\n.table-hover > tbody > tr.warning:hover > th {\n background-color: #faf2cc;\n}\n.table > thead > tr > td.danger,\n.table > tbody > tr > td.danger,\n.table > tfoot > tr > td.danger,\n.table > thead > tr > th.danger,\n.table > tbody > tr > th.danger,\n.table > tfoot > tr > th.danger,\n.table > thead > tr.danger > td,\n.table > tbody > tr.danger > td,\n.table > tfoot > tr.danger > td,\n.table > thead > tr.danger > th,\n.table > tbody > tr.danger > th,\n.table > tfoot > tr.danger > th {\n background-color: #f2dede;\n}\n.table-hover > tbody > tr > td.danger:hover,\n.table-hover > tbody > tr > th.danger:hover,\n.table-hover > tbody > tr.danger:hover > td,\n.table-hover > tbody > tr:hover > .danger,\n.table-hover > tbody > tr.danger:hover > th {\n background-color: #ebcccc;\n}\n.table-responsive {\n min-height: .01%;\n overflow-x: auto;\n}\n@media screen and (max-width: 767px) {\n .table-responsive {\n width: 100%;\n margin-bottom: 15px;\n overflow-y: hidden;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n border: 1px solid #ddd;\n }\n .table-responsive > .table {\n margin-bottom: 0;\n }\n .table-responsive > .table > thead > tr > th,\n .table-responsive > .table > tbody > tr > th,\n .table-responsive > .table > tfoot > tr > th,\n .table-responsive > .table > thead > tr > td,\n .table-responsive > .table > tbody > tr > td,\n .table-responsive > .table > tfoot > tr > td {\n white-space: nowrap;\n }\n .table-responsive > .table-bordered {\n border: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:first-child,\n .table-responsive > .table-bordered > tbody > tr > th:first-child,\n .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n .table-responsive > .table-bordered > thead > tr > td:first-child,\n .table-responsive > .table-bordered > tbody > tr > td:first-child,\n .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:last-child,\n .table-responsive > .table-bordered > tbody > tr > th:last-child,\n .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n .table-responsive > .table-bordered > thead > tr > td:last-child,\n .table-responsive > .table-bordered > tbody > tr > td:last-child,\n .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n }\n .table-responsive > .table-bordered > tbody > tr:last-child > th,\n .table-responsive > .table-bordered > tfoot > tr:last-child > th,\n .table-responsive > .table-bordered > tbody > tr:last-child > td,\n .table-responsive > .table-bordered > tfoot > tr:last-child > td {\n border-bottom: 0;\n }\n}\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\nlegend {\n display: block;\n width: 100%;\n padding: 0;\n margin-bottom: 20px;\n font-size: 21px;\n line-height: inherit;\n color: #333;\n border: 0;\n border-bottom: 1px solid #e5e5e5;\n}\nlabel {\n display: inline-block;\n max-width: 100%;\n margin-bottom: 5px;\n font-weight: bold;\n}\ninput[type=\"search\"] {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n margin: 4px 0 0;\n margin-top: 1px \\9;\n line-height: normal;\n}\ninput[type=\"file\"] {\n display: block;\n}\ninput[type=\"range\"] {\n display: block;\n width: 100%;\n}\nselect[multiple],\nselect[size] {\n height: auto;\n}\ninput[type=\"file\"]:focus,\ninput[type=\"radio\"]:focus,\ninput[type=\"checkbox\"]:focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\noutput {\n display: block;\n padding-top: 7px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555;\n}\n.form-control {\n display: block;\n width: 100%;\n height: 34px;\n padding: 6px 12px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555;\n background-color: #fff;\n background-image: none;\n border: 1px solid #ccc;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n -webkit-transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s;\n -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n}\n.form-control:focus {\n border-color: #66afe9;\n outline: 0;\n -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, .6);\n box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, .6);\n}\n.form-control::-moz-placeholder {\n color: #999;\n opacity: 1;\n}\n.form-control:-ms-input-placeholder {\n color: #999;\n}\n.form-control::-webkit-input-placeholder {\n color: #999;\n}\n.form-control::-ms-expand {\n background-color: transparent;\n border: 0;\n}\n.form-control[disabled],\n.form-control[readonly],\nfieldset[disabled] .form-control {\n background-color: #eee;\n opacity: 1;\n}\n.form-control[disabled],\nfieldset[disabled] .form-control {\n cursor: not-allowed;\n}\ntextarea.form-control {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-appearance: none;\n}\n@media screen and (-webkit-min-device-pixel-ratio: 0) {\n input[type=\"date\"].form-control,\n input[type=\"time\"].form-control,\n input[type=\"datetime-local\"].form-control,\n input[type=\"month\"].form-control {\n line-height: 34px;\n }\n input[type=\"date\"].input-sm,\n input[type=\"time\"].input-sm,\n input[type=\"datetime-local\"].input-sm,\n input[type=\"month\"].input-sm,\n .input-group-sm input[type=\"date\"],\n .input-group-sm input[type=\"time\"],\n .input-group-sm input[type=\"datetime-local\"],\n .input-group-sm input[type=\"month\"] {\n line-height: 30px;\n }\n input[type=\"date\"].input-lg,\n input[type=\"time\"].input-lg,\n input[type=\"datetime-local\"].input-lg,\n input[type=\"month\"].input-lg,\n .input-group-lg input[type=\"date\"],\n .input-group-lg input[type=\"time\"],\n .input-group-lg input[type=\"datetime-local\"],\n .input-group-lg input[type=\"month\"] {\n line-height: 46px;\n }\n}\n.form-group {\n margin-bottom: 15px;\n}\n.radio,\n.checkbox {\n position: relative;\n display: block;\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.radio label,\n.checkbox label {\n min-height: 20px;\n padding-left: 20px;\n margin-bottom: 0;\n font-weight: normal;\n cursor: pointer;\n}\n.radio input[type=\"radio\"],\n.radio-inline input[type=\"radio\"],\n.checkbox input[type=\"checkbox\"],\n.checkbox-inline input[type=\"checkbox\"] {\n position: absolute;\n margin-top: 4px \\9;\n margin-left: -20px;\n}\n.radio + .radio,\n.checkbox + .checkbox {\n margin-top: -5px;\n}\n.radio-inline,\n.checkbox-inline {\n position: relative;\n display: inline-block;\n padding-left: 20px;\n margin-bottom: 0;\n font-weight: normal;\n vertical-align: middle;\n cursor: pointer;\n}\n.radio-inline + .radio-inline,\n.checkbox-inline + .checkbox-inline {\n margin-top: 0;\n margin-left: 10px;\n}\ninput[type=\"radio\"][disabled],\ninput[type=\"checkbox\"][disabled],\ninput[type=\"radio\"].disabled,\ninput[type=\"checkbox\"].disabled,\nfieldset[disabled] input[type=\"radio\"],\nfieldset[disabled] input[type=\"checkbox\"] {\n cursor: not-allowed;\n}\n.radio-inline.disabled,\n.checkbox-inline.disabled,\nfieldset[disabled] .radio-inline,\nfieldset[disabled] .checkbox-inline {\n cursor: not-allowed;\n}\n.radio.disabled label,\n.checkbox.disabled label,\nfieldset[disabled] .radio label,\nfieldset[disabled] .checkbox label {\n cursor: not-allowed;\n}\n.form-control-static {\n min-height: 34px;\n padding-top: 7px;\n padding-bottom: 7px;\n margin-bottom: 0;\n}\n.form-control-static.input-lg,\n.form-control-static.input-sm {\n padding-right: 0;\n padding-left: 0;\n}\n.input-sm {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-sm {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-sm,\nselect[multiple].input-sm {\n height: auto;\n}\n.form-group-sm .form-control {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.form-group-sm select.form-control {\n height: 30px;\n line-height: 30px;\n}\n.form-group-sm textarea.form-control,\n.form-group-sm select[multiple].form-control {\n height: auto;\n}\n.form-group-sm .form-control-static {\n height: 30px;\n min-height: 32px;\n padding: 6px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.input-lg {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-lg {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-lg,\nselect[multiple].input-lg {\n height: auto;\n}\n.form-group-lg .form-control {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.form-group-lg select.form-control {\n height: 46px;\n line-height: 46px;\n}\n.form-group-lg textarea.form-control,\n.form-group-lg select[multiple].form-control {\n height: auto;\n}\n.form-group-lg .form-control-static {\n height: 46px;\n min-height: 38px;\n padding: 11px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.has-feedback {\n position: relative;\n}\n.has-feedback .form-control {\n padding-right: 42.5px;\n}\n.form-control-feedback {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2;\n display: block;\n width: 34px;\n height: 34px;\n line-height: 34px;\n text-align: center;\n pointer-events: none;\n}\n.input-lg + .form-control-feedback,\n.input-group-lg + .form-control-feedback,\n.form-group-lg .form-control + .form-control-feedback {\n width: 46px;\n height: 46px;\n line-height: 46px;\n}\n.input-sm + .form-control-feedback,\n.input-group-sm + .form-control-feedback,\n.form-group-sm .form-control + .form-control-feedback {\n width: 30px;\n height: 30px;\n line-height: 30px;\n}\n.has-success .help-block,\n.has-success .control-label,\n.has-success .radio,\n.has-success .checkbox,\n.has-success .radio-inline,\n.has-success .checkbox-inline,\n.has-success.radio label,\n.has-success.checkbox label,\n.has-success.radio-inline label,\n.has-success.checkbox-inline label {\n color: #3c763d;\n}\n.has-success .form-control {\n border-color: #3c763d;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n}\n.has-success .form-control:focus {\n border-color: #2b542c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168;\n}\n.has-success .input-group-addon {\n color: #3c763d;\n background-color: #dff0d8;\n border-color: #3c763d;\n}\n.has-success .form-control-feedback {\n color: #3c763d;\n}\n.has-warning .help-block,\n.has-warning .control-label,\n.has-warning .radio,\n.has-warning .checkbox,\n.has-warning .radio-inline,\n.has-warning .checkbox-inline,\n.has-warning.radio label,\n.has-warning.checkbox label,\n.has-warning.radio-inline label,\n.has-warning.checkbox-inline label {\n color: #8a6d3b;\n}\n.has-warning .form-control {\n border-color: #8a6d3b;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n}\n.has-warning .form-control:focus {\n border-color: #66512c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b;\n}\n.has-warning .input-group-addon {\n color: #8a6d3b;\n background-color: #fcf8e3;\n border-color: #8a6d3b;\n}\n.has-warning .form-control-feedback {\n color: #8a6d3b;\n}\n.has-error .help-block,\n.has-error .control-label,\n.has-error .radio,\n.has-error .checkbox,\n.has-error .radio-inline,\n.has-error .checkbox-inline,\n.has-error.radio label,\n.has-error.checkbox label,\n.has-error.radio-inline label,\n.has-error.checkbox-inline label {\n color: #a94442;\n}\n.has-error .form-control {\n border-color: #a94442;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n}\n.has-error .form-control:focus {\n border-color: #843534;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483;\n}\n.has-error .input-group-addon {\n color: #a94442;\n background-color: #f2dede;\n border-color: #a94442;\n}\n.has-error .form-control-feedback {\n color: #a94442;\n}\n.has-feedback label ~ .form-control-feedback {\n top: 25px;\n}\n.has-feedback label.sr-only ~ .form-control-feedback {\n top: 0;\n}\n.help-block {\n display: block;\n margin-top: 5px;\n margin-bottom: 10px;\n color: #737373;\n}\n@media (min-width: 768px) {\n .form-inline .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .form-inline .form-control-static {\n display: inline-block;\n }\n .form-inline .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .form-inline .input-group .input-group-addon,\n .form-inline .input-group .input-group-btn,\n .form-inline .input-group .form-control {\n width: auto;\n }\n .form-inline .input-group > .form-control {\n width: 100%;\n }\n .form-inline .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio,\n .form-inline .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio label,\n .form-inline .checkbox label {\n padding-left: 0;\n }\n .form-inline .radio input[type=\"radio\"],\n .form-inline .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .form-inline .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox,\n.form-horizontal .radio-inline,\n.form-horizontal .checkbox-inline {\n padding-top: 7px;\n margin-top: 0;\n margin-bottom: 0;\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox {\n min-height: 27px;\n}\n.form-horizontal .form-group {\n margin-right: -15px;\n margin-left: -15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .control-label {\n padding-top: 7px;\n margin-bottom: 0;\n text-align: right;\n }\n}\n.form-horizontal .has-feedback .form-control-feedback {\n right: 15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-lg .control-label {\n padding-top: 11px;\n font-size: 18px;\n }\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-sm .control-label {\n padding-top: 6px;\n font-size: 12px;\n }\n}\n.btn {\n display: inline-block;\n padding: 6px 12px;\n margin-bottom: 0;\n font-size: 14px;\n font-weight: normal;\n line-height: 1.42857143;\n text-align: center;\n white-space: nowrap;\n vertical-align: middle;\n -ms-touch-action: manipulation;\n touch-action: manipulation;\n cursor: pointer;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n background-image: none;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.btn:focus,\n.btn:active:focus,\n.btn.active:focus,\n.btn.focus,\n.btn:active.focus,\n.btn.active.focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\n.btn:hover,\n.btn:focus,\n.btn.focus {\n color: #333;\n text-decoration: none;\n}\n.btn:active,\n.btn.active {\n background-image: none;\n outline: 0;\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);\n}\n.btn.disabled,\n.btn[disabled],\nfieldset[disabled] .btn {\n cursor: not-allowed;\n filter: alpha(opacity=65);\n -webkit-box-shadow: none;\n box-shadow: none;\n opacity: .65;\n}\na.btn.disabled,\nfieldset[disabled] a.btn {\n pointer-events: none;\n}\n.btn-default {\n color: #333;\n background-color: #fff;\n border-color: #ccc;\n}\n.btn-default:focus,\n.btn-default.focus {\n color: #333;\n background-color: #e6e6e6;\n border-color: #8c8c8c;\n}\n.btn-default:hover {\n color: #333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n color: #333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active:hover,\n.btn-default.active:hover,\n.open > .dropdown-toggle.btn-default:hover,\n.btn-default:active:focus,\n.btn-default.active:focus,\n.open > .dropdown-toggle.btn-default:focus,\n.btn-default:active.focus,\n.btn-default.active.focus,\n.open > .dropdown-toggle.btn-default.focus {\n color: #333;\n background-color: #d4d4d4;\n border-color: #8c8c8c;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n background-image: none;\n}\n.btn-default.disabled:hover,\n.btn-default[disabled]:hover,\nfieldset[disabled] .btn-default:hover,\n.btn-default.disabled:focus,\n.btn-default[disabled]:focus,\nfieldset[disabled] .btn-default:focus,\n.btn-default.disabled.focus,\n.btn-default[disabled].focus,\nfieldset[disabled] .btn-default.focus {\n background-color: #fff;\n border-color: #ccc;\n}\n.btn-default .badge {\n color: #fff;\n background-color: #333;\n}\n.btn-primary {\n color: #fff;\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary:focus,\n.btn-primary.focus {\n color: #fff;\n background-color: #286090;\n border-color: #122b40;\n}\n.btn-primary:hover {\n color: #fff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n color: #fff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active:hover,\n.btn-primary.active:hover,\n.open > .dropdown-toggle.btn-primary:hover,\n.btn-primary:active:focus,\n.btn-primary.active:focus,\n.open > .dropdown-toggle.btn-primary:focus,\n.btn-primary:active.focus,\n.btn-primary.active.focus,\n.open > .dropdown-toggle.btn-primary.focus {\n color: #fff;\n background-color: #204d74;\n border-color: #122b40;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n background-image: none;\n}\n.btn-primary.disabled:hover,\n.btn-primary[disabled]:hover,\nfieldset[disabled] .btn-primary:hover,\n.btn-primary.disabled:focus,\n.btn-primary[disabled]:focus,\nfieldset[disabled] .btn-primary:focus,\n.btn-primary.disabled.focus,\n.btn-primary[disabled].focus,\nfieldset[disabled] .btn-primary.focus {\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.btn-success {\n color: #fff;\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success:focus,\n.btn-success.focus {\n color: #fff;\n background-color: #449d44;\n border-color: #255625;\n}\n.btn-success:hover {\n color: #fff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n color: #fff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active:hover,\n.btn-success.active:hover,\n.open > .dropdown-toggle.btn-success:hover,\n.btn-success:active:focus,\n.btn-success.active:focus,\n.open > .dropdown-toggle.btn-success:focus,\n.btn-success:active.focus,\n.btn-success.active.focus,\n.open > .dropdown-toggle.btn-success.focus {\n color: #fff;\n background-color: #398439;\n border-color: #255625;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n background-image: none;\n}\n.btn-success.disabled:hover,\n.btn-success[disabled]:hover,\nfieldset[disabled] .btn-success:hover,\n.btn-success.disabled:focus,\n.btn-success[disabled]:focus,\nfieldset[disabled] .btn-success:focus,\n.btn-success.disabled.focus,\n.btn-success[disabled].focus,\nfieldset[disabled] .btn-success.focus {\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success .badge {\n color: #5cb85c;\n background-color: #fff;\n}\n.btn-info {\n color: #fff;\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info:focus,\n.btn-info.focus {\n color: #fff;\n background-color: #31b0d5;\n border-color: #1b6d85;\n}\n.btn-info:hover {\n color: #fff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n color: #fff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active:hover,\n.btn-info.active:hover,\n.open > .dropdown-toggle.btn-info:hover,\n.btn-info:active:focus,\n.btn-info.active:focus,\n.open > .dropdown-toggle.btn-info:focus,\n.btn-info:active.focus,\n.btn-info.active.focus,\n.open > .dropdown-toggle.btn-info.focus {\n color: #fff;\n background-color: #269abc;\n border-color: #1b6d85;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n background-image: none;\n}\n.btn-info.disabled:hover,\n.btn-info[disabled]:hover,\nfieldset[disabled] .btn-info:hover,\n.btn-info.disabled:focus,\n.btn-info[disabled]:focus,\nfieldset[disabled] .btn-info:focus,\n.btn-info.disabled.focus,\n.btn-info[disabled].focus,\nfieldset[disabled] .btn-info.focus {\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info .badge {\n color: #5bc0de;\n background-color: #fff;\n}\n.btn-warning {\n color: #fff;\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning:focus,\n.btn-warning.focus {\n color: #fff;\n background-color: #ec971f;\n border-color: #985f0d;\n}\n.btn-warning:hover {\n color: #fff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n color: #fff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active:hover,\n.btn-warning.active:hover,\n.open > .dropdown-toggle.btn-warning:hover,\n.btn-warning:active:focus,\n.btn-warning.active:focus,\n.open > .dropdown-toggle.btn-warning:focus,\n.btn-warning:active.focus,\n.btn-warning.active.focus,\n.open > .dropdown-toggle.btn-warning.focus {\n color: #fff;\n background-color: #d58512;\n border-color: #985f0d;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n background-image: none;\n}\n.btn-warning.disabled:hover,\n.btn-warning[disabled]:hover,\nfieldset[disabled] .btn-warning:hover,\n.btn-warning.disabled:focus,\n.btn-warning[disabled]:focus,\nfieldset[disabled] .btn-warning:focus,\n.btn-warning.disabled.focus,\n.btn-warning[disabled].focus,\nfieldset[disabled] .btn-warning.focus {\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning .badge {\n color: #f0ad4e;\n background-color: #fff;\n}\n.btn-danger {\n color: #fff;\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger:focus,\n.btn-danger.focus {\n color: #fff;\n background-color: #c9302c;\n border-color: #761c19;\n}\n.btn-danger:hover {\n color: #fff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n color: #fff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active:hover,\n.btn-danger.active:hover,\n.open > .dropdown-toggle.btn-danger:hover,\n.btn-danger:active:focus,\n.btn-danger.active:focus,\n.open > .dropdown-toggle.btn-danger:focus,\n.btn-danger:active.focus,\n.btn-danger.active.focus,\n.open > .dropdown-toggle.btn-danger.focus {\n color: #fff;\n background-color: #ac2925;\n border-color: #761c19;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n background-image: none;\n}\n.btn-danger.disabled:hover,\n.btn-danger[disabled]:hover,\nfieldset[disabled] .btn-danger:hover,\n.btn-danger.disabled:focus,\n.btn-danger[disabled]:focus,\nfieldset[disabled] .btn-danger:focus,\n.btn-danger.disabled.focus,\n.btn-danger[disabled].focus,\nfieldset[disabled] .btn-danger.focus {\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger .badge {\n color: #d9534f;\n background-color: #fff;\n}\n.btn-link {\n font-weight: normal;\n color: #337ab7;\n border-radius: 0;\n}\n.btn-link,\n.btn-link:active,\n.btn-link.active,\n.btn-link[disabled],\nfieldset[disabled] .btn-link {\n background-color: transparent;\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn-link,\n.btn-link:hover,\n.btn-link:focus,\n.btn-link:active {\n border-color: transparent;\n}\n.btn-link:hover,\n.btn-link:focus {\n color: #23527c;\n text-decoration: underline;\n background-color: transparent;\n}\n.btn-link[disabled]:hover,\nfieldset[disabled] .btn-link:hover,\n.btn-link[disabled]:focus,\nfieldset[disabled] .btn-link:focus {\n color: #777;\n text-decoration: none;\n}\n.btn-lg,\n.btn-group-lg > .btn {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.btn-sm,\n.btn-group-sm > .btn {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-xs,\n.btn-group-xs > .btn {\n padding: 1px 5px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-block {\n display: block;\n width: 100%;\n}\n.btn-block + .btn-block {\n margin-top: 5px;\n}\ninput[type=\"submit\"].btn-block,\ninput[type=\"reset\"].btn-block,\ninput[type=\"button\"].btn-block {\n width: 100%;\n}\n.fade {\n opacity: 0;\n -webkit-transition: opacity .15s linear;\n -o-transition: opacity .15s linear;\n transition: opacity .15s linear;\n}\n.fade.in {\n opacity: 1;\n}\n.collapse {\n display: none;\n}\n.collapse.in {\n display: block;\n}\ntr.collapse.in {\n display: table-row;\n}\ntbody.collapse.in {\n display: table-row-group;\n}\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n -webkit-transition-timing-function: ease;\n -o-transition-timing-function: ease;\n transition-timing-function: ease;\n -webkit-transition-duration: .35s;\n -o-transition-duration: .35s;\n transition-duration: .35s;\n -webkit-transition-property: height, visibility;\n -o-transition-property: height, visibility;\n transition-property: height, visibility;\n}\n.caret {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 2px;\n vertical-align: middle;\n border-top: 4px dashed;\n border-top: 4px solid \\9;\n border-right: 4px solid transparent;\n border-left: 4px solid transparent;\n}\n.dropup,\n.dropdown {\n position: relative;\n}\n.dropdown-toggle:focus {\n outline: 0;\n}\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: 1000;\n display: none;\n float: left;\n min-width: 160px;\n padding: 5px 0;\n margin: 2px 0 0;\n font-size: 14px;\n text-align: left;\n list-style: none;\n background-color: #fff;\n -webkit-background-clip: padding-box;\n background-clip: padding-box;\n border: 1px solid #ccc;\n border: 1px solid rgba(0, 0, 0, .15);\n border-radius: 4px;\n -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175);\n box-shadow: 0 6px 12px rgba(0, 0, 0, .175);\n}\n.dropdown-menu.pull-right {\n right: 0;\n left: auto;\n}\n.dropdown-menu .divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.dropdown-menu > li > a {\n display: block;\n padding: 3px 20px;\n clear: both;\n font-weight: normal;\n line-height: 1.42857143;\n color: #333;\n white-space: nowrap;\n}\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n color: #262626;\n text-decoration: none;\n background-color: #f5f5f5;\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n color: #fff;\n text-decoration: none;\n background-color: #337ab7;\n outline: 0;\n}\n.dropdown-menu > .disabled > a,\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n color: #777;\n}\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n text-decoration: none;\n cursor: not-allowed;\n background-color: transparent;\n background-image: none;\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n}\n.open > .dropdown-menu {\n display: block;\n}\n.open > a {\n outline: 0;\n}\n.dropdown-menu-right {\n right: 0;\n left: auto;\n}\n.dropdown-menu-left {\n right: auto;\n left: 0;\n}\n.dropdown-header {\n display: block;\n padding: 3px 20px;\n font-size: 12px;\n line-height: 1.42857143;\n color: #777;\n white-space: nowrap;\n}\n.dropdown-backdrop {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 990;\n}\n.pull-right > .dropdown-menu {\n right: 0;\n left: auto;\n}\n.dropup .caret,\n.navbar-fixed-bottom .dropdown .caret {\n content: \"\";\n border-top: 0;\n border-bottom: 4px dashed;\n border-bottom: 4px solid \\9;\n}\n.dropup .dropdown-menu,\n.navbar-fixed-bottom .dropdown .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-bottom: 2px;\n}\n@media (min-width: 768px) {\n .navbar-right .dropdown-menu {\n right: 0;\n left: auto;\n }\n .navbar-right .dropdown-menu-left {\n right: auto;\n left: 0;\n }\n}\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-block;\n vertical-align: middle;\n}\n.btn-group > .btn,\n.btn-group-vertical > .btn {\n position: relative;\n float: left;\n}\n.btn-group > .btn:hover,\n.btn-group-vertical > .btn:hover,\n.btn-group > .btn:focus,\n.btn-group-vertical > .btn:focus,\n.btn-group > .btn:active,\n.btn-group-vertical > .btn:active,\n.btn-group > .btn.active,\n.btn-group-vertical > .btn.active {\n z-index: 2;\n}\n.btn-group .btn + .btn,\n.btn-group .btn + .btn-group,\n.btn-group .btn-group + .btn,\n.btn-group .btn-group + .btn-group {\n margin-left: -1px;\n}\n.btn-toolbar {\n margin-left: -5px;\n}\n.btn-toolbar .btn,\n.btn-toolbar .btn-group,\n.btn-toolbar .input-group {\n float: left;\n}\n.btn-toolbar > .btn,\n.btn-toolbar > .btn-group,\n.btn-toolbar > .input-group {\n margin-left: 5px;\n}\n.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {\n border-radius: 0;\n}\n.btn-group > .btn:first-child {\n margin-left: 0;\n}\n.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.btn-group > .btn:last-child:not(:first-child),\n.btn-group > .dropdown-toggle:not(:first-child) {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group > .btn-group {\n float: left;\n}\n.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group .dropdown-toggle:active,\n.btn-group.open .dropdown-toggle {\n outline: 0;\n}\n.btn-group > .btn + .dropdown-toggle {\n padding-right: 8px;\n padding-left: 8px;\n}\n.btn-group > .btn-lg + .dropdown-toggle {\n padding-right: 12px;\n padding-left: 12px;\n}\n.btn-group.open .dropdown-toggle {\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);\n}\n.btn-group.open .dropdown-toggle.btn-link {\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn .caret {\n margin-left: 0;\n}\n.btn-lg .caret {\n border-width: 5px 5px 0;\n border-bottom-width: 0;\n}\n.dropup .btn-lg .caret {\n border-width: 0 5px 5px;\n}\n.btn-group-vertical > .btn,\n.btn-group-vertical > .btn-group,\n.btn-group-vertical > .btn-group > .btn {\n display: block;\n float: none;\n width: 100%;\n max-width: 100%;\n}\n.btn-group-vertical > .btn-group > .btn {\n float: none;\n}\n.btn-group-vertical > .btn + .btn,\n.btn-group-vertical > .btn + .btn-group,\n.btn-group-vertical > .btn-group + .btn,\n.btn-group-vertical > .btn-group + .btn-group {\n margin-top: -1px;\n margin-left: 0;\n}\n.btn-group-vertical > .btn:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.btn-group-vertical > .btn:first-child:not(:last-child) {\n border-top-left-radius: 4px;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn:last-child:not(:first-child) {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\n.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n.btn-group-justified {\n display: table;\n width: 100%;\n table-layout: fixed;\n border-collapse: separate;\n}\n.btn-group-justified > .btn,\n.btn-group-justified > .btn-group {\n display: table-cell;\n float: none;\n width: 1%;\n}\n.btn-group-justified > .btn-group .btn {\n width: 100%;\n}\n.btn-group-justified > .btn-group .dropdown-menu {\n left: auto;\n}\n[data-toggle=\"buttons\"] > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn input[type=\"checkbox\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0, 0, 0, 0);\n pointer-events: none;\n}\n.input-group {\n position: relative;\n display: table;\n border-collapse: separate;\n}\n.input-group[class*=\"col-\"] {\n float: none;\n padding-right: 0;\n padding-left: 0;\n}\n.input-group .form-control {\n position: relative;\n z-index: 2;\n float: left;\n width: 100%;\n margin-bottom: 0;\n}\n.input-group .form-control:focus {\n z-index: 3;\n}\n.input-group-lg > .form-control,\n.input-group-lg > .input-group-addon,\n.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-group-lg > .form-control,\nselect.input-group-lg > .input-group-addon,\nselect.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-group-lg > .form-control,\ntextarea.input-group-lg > .input-group-addon,\ntextarea.input-group-lg > .input-group-btn > .btn,\nselect[multiple].input-group-lg > .form-control,\nselect[multiple].input-group-lg > .input-group-addon,\nselect[multiple].input-group-lg > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-sm > .form-control,\n.input-group-sm > .input-group-addon,\n.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-group-sm > .form-control,\nselect.input-group-sm > .input-group-addon,\nselect.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-group-sm > .form-control,\ntextarea.input-group-sm > .input-group-addon,\ntextarea.input-group-sm > .input-group-btn > .btn,\nselect[multiple].input-group-sm > .form-control,\nselect[multiple].input-group-sm > .input-group-addon,\nselect[multiple].input-group-sm > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-addon,\n.input-group-btn,\n.input-group .form-control {\n display: table-cell;\n}\n.input-group-addon:not(:first-child):not(:last-child),\n.input-group-btn:not(:first-child):not(:last-child),\n.input-group .form-control:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.input-group-addon,\n.input-group-btn {\n width: 1%;\n white-space: nowrap;\n vertical-align: middle;\n}\n.input-group-addon {\n padding: 6px 12px;\n font-size: 14px;\n font-weight: normal;\n line-height: 1;\n color: #555;\n text-align: center;\n background-color: #eee;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\n.input-group-addon.input-sm {\n padding: 5px 10px;\n font-size: 12px;\n border-radius: 3px;\n}\n.input-group-addon.input-lg {\n padding: 10px 16px;\n font-size: 18px;\n border-radius: 6px;\n}\n.input-group-addon input[type=\"radio\"],\n.input-group-addon input[type=\"checkbox\"] {\n margin-top: 0;\n}\n.input-group .form-control:first-child,\n.input-group-addon:first-child,\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group > .btn,\n.input-group-btn:first-child > .dropdown-toggle,\n.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group-btn:last-child > .btn-group:not(:last-child) > .btn {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.input-group-addon:first-child {\n border-right: 0;\n}\n.input-group .form-control:last-child,\n.input-group-addon:last-child,\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group > .btn,\n.input-group-btn:last-child > .dropdown-toggle,\n.input-group-btn:first-child > .btn:not(:first-child),\n.input-group-btn:first-child > .btn-group:not(:first-child) > .btn {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n.input-group-addon:last-child {\n border-left: 0;\n}\n.input-group-btn {\n position: relative;\n font-size: 0;\n white-space: nowrap;\n}\n.input-group-btn > .btn {\n position: relative;\n}\n.input-group-btn > .btn + .btn {\n margin-left: -1px;\n}\n.input-group-btn > .btn:hover,\n.input-group-btn > .btn:focus,\n.input-group-btn > .btn:active {\n z-index: 2;\n}\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group {\n margin-right: -1px;\n}\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group {\n z-index: 2;\n margin-left: -1px;\n}\n.nav {\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n}\n.nav > li {\n position: relative;\n display: block;\n}\n.nav > li > a {\n position: relative;\n display: block;\n padding: 10px 15px;\n}\n.nav > li > a:hover,\n.nav > li > a:focus {\n text-decoration: none;\n background-color: #eee;\n}\n.nav > li.disabled > a {\n color: #777;\n}\n.nav > li.disabled > a:hover,\n.nav > li.disabled > a:focus {\n color: #777;\n text-decoration: none;\n cursor: not-allowed;\n background-color: transparent;\n}\n.nav .open > a,\n.nav .open > a:hover,\n.nav .open > a:focus {\n background-color: #eee;\n border-color: #337ab7;\n}\n.nav .nav-divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.nav > li > a > img {\n max-width: none;\n}\n.nav-tabs {\n border-bottom: 1px solid #ddd;\n}\n.nav-tabs > li {\n float: left;\n margin-bottom: -1px;\n}\n.nav-tabs > li > a {\n margin-right: 2px;\n line-height: 1.42857143;\n border: 1px solid transparent;\n border-radius: 4px 4px 0 0;\n}\n.nav-tabs > li > a:hover {\n border-color: #eee #eee #ddd;\n}\n.nav-tabs > li.active > a,\n.nav-tabs > li.active > a:hover,\n.nav-tabs > li.active > a:focus {\n color: #555;\n cursor: default;\n background-color: #fff;\n border: 1px solid #ddd;\n border-bottom-color: transparent;\n}\n.nav-tabs.nav-justified {\n width: 100%;\n border-bottom: 0;\n}\n.nav-tabs.nav-justified > li {\n float: none;\n}\n.nav-tabs.nav-justified > li > a {\n margin-bottom: 5px;\n text-align: center;\n}\n.nav-tabs.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-tabs.nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs.nav-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs.nav-justified > .active > a,\n.nav-tabs.nav-justified > .active > a:hover,\n.nav-tabs.nav-justified > .active > a:focus {\n border: 1px solid #ddd;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li > a {\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs.nav-justified > .active > a,\n .nav-tabs.nav-justified > .active > a:hover,\n .nav-tabs.nav-justified > .active > a:focus {\n border-bottom-color: #fff;\n }\n}\n.nav-pills > li {\n float: left;\n}\n.nav-pills > li > a {\n border-radius: 4px;\n}\n.nav-pills > li + li {\n margin-left: 2px;\n}\n.nav-pills > li.active > a,\n.nav-pills > li.active > a:hover,\n.nav-pills > li.active > a:focus {\n color: #fff;\n background-color: #337ab7;\n}\n.nav-stacked > li {\n float: none;\n}\n.nav-stacked > li + li {\n margin-top: 2px;\n margin-left: 0;\n}\n.nav-justified {\n width: 100%;\n}\n.nav-justified > li {\n float: none;\n}\n.nav-justified > li > a {\n margin-bottom: 5px;\n text-align: center;\n}\n.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs-justified {\n border-bottom: 0;\n}\n.nav-tabs-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs-justified > .active > a,\n.nav-tabs-justified > .active > a:hover,\n.nav-tabs-justified > .active > a:focus {\n border: 1px solid #ddd;\n}\n@media (min-width: 768px) {\n .nav-tabs-justified > li > a {\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs-justified > .active > a,\n .nav-tabs-justified > .active > a:hover,\n .nav-tabs-justified > .active > a:focus {\n border-bottom-color: #fff;\n }\n}\n.tab-content > .tab-pane {\n display: none;\n}\n.tab-content > .active {\n display: block;\n}\n.nav-tabs .dropdown-menu {\n margin-top: -1px;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n.navbar {\n position: relative;\n min-height: 50px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n}\n@media (min-width: 768px) {\n .navbar {\n border-radius: 4px;\n }\n}\n@media (min-width: 768px) {\n .navbar-header {\n float: left;\n }\n}\n.navbar-collapse {\n padding-right: 15px;\n padding-left: 15px;\n overflow-x: visible;\n -webkit-overflow-scrolling: touch;\n border-top: 1px solid transparent;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1);\n}\n.navbar-collapse.in {\n overflow-y: auto;\n}\n@media (min-width: 768px) {\n .navbar-collapse {\n width: auto;\n border-top: 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n .navbar-collapse.collapse {\n display: block !important;\n height: auto !important;\n padding-bottom: 0;\n overflow: visible !important;\n }\n .navbar-collapse.in {\n overflow-y: visible;\n }\n .navbar-fixed-top .navbar-collapse,\n .navbar-static-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n padding-right: 0;\n padding-left: 0;\n }\n}\n.navbar-fixed-top .navbar-collapse,\n.navbar-fixed-bottom .navbar-collapse {\n max-height: 340px;\n}\n@media (max-device-width: 480px) and (orientation: landscape) {\n .navbar-fixed-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n max-height: 200px;\n }\n}\n.container > .navbar-header,\n.container-fluid > .navbar-header,\n.container > .navbar-collapse,\n.container-fluid > .navbar-collapse {\n margin-right: -15px;\n margin-left: -15px;\n}\n@media (min-width: 768px) {\n .container > .navbar-header,\n .container-fluid > .navbar-header,\n .container > .navbar-collapse,\n .container-fluid > .navbar-collapse {\n margin-right: 0;\n margin-left: 0;\n }\n}\n.navbar-static-top {\n z-index: 1000;\n border-width: 0 0 1px;\n}\n@media (min-width: 768px) {\n .navbar-static-top {\n border-radius: 0;\n }\n}\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n position: fixed;\n right: 0;\n left: 0;\n z-index: 1030;\n}\n@media (min-width: 768px) {\n .navbar-fixed-top,\n .navbar-fixed-bottom {\n border-radius: 0;\n }\n}\n.navbar-fixed-top {\n top: 0;\n border-width: 0 0 1px;\n}\n.navbar-fixed-bottom {\n bottom: 0;\n margin-bottom: 0;\n border-width: 1px 0 0;\n}\n.navbar-brand {\n float: left;\n height: 50px;\n padding: 15px 15px;\n font-size: 18px;\n line-height: 20px;\n}\n.navbar-brand:hover,\n.navbar-brand:focus {\n text-decoration: none;\n}\n.navbar-brand > img {\n display: block;\n}\n@media (min-width: 768px) {\n .navbar > .container .navbar-brand,\n .navbar > .container-fluid .navbar-brand {\n margin-left: -15px;\n }\n}\n.navbar-toggle {\n position: relative;\n float: right;\n padding: 9px 10px;\n margin-top: 8px;\n margin-right: 15px;\n margin-bottom: 8px;\n background-color: transparent;\n background-image: none;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.navbar-toggle:focus {\n outline: 0;\n}\n.navbar-toggle .icon-bar {\n display: block;\n width: 22px;\n height: 2px;\n border-radius: 1px;\n}\n.navbar-toggle .icon-bar + .icon-bar {\n margin-top: 4px;\n}\n@media (min-width: 768px) {\n .navbar-toggle {\n display: none;\n }\n}\n.navbar-nav {\n margin: 7.5px -15px;\n}\n.navbar-nav > li > a {\n padding-top: 10px;\n padding-bottom: 10px;\n line-height: 20px;\n}\n@media (max-width: 767px) {\n .navbar-nav .open .dropdown-menu {\n position: static;\n float: none;\n width: auto;\n margin-top: 0;\n background-color: transparent;\n border: 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n .navbar-nav .open .dropdown-menu > li > a,\n .navbar-nav .open .dropdown-menu .dropdown-header {\n padding: 5px 15px 5px 25px;\n }\n .navbar-nav .open .dropdown-menu > li > a {\n line-height: 20px;\n }\n .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-nav .open .dropdown-menu > li > a:focus {\n background-image: none;\n }\n}\n@media (min-width: 768px) {\n .navbar-nav {\n float: left;\n margin: 0;\n }\n .navbar-nav > li {\n float: left;\n }\n .navbar-nav > li > a {\n padding-top: 15px;\n padding-bottom: 15px;\n }\n}\n.navbar-form {\n padding: 10px 15px;\n margin-top: 8px;\n margin-right: -15px;\n margin-bottom: 8px;\n margin-left: -15px;\n border-top: 1px solid transparent;\n border-bottom: 1px solid transparent;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 0 rgba(255, 255, 255, .1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 0 rgba(255, 255, 255, .1);\n}\n@media (min-width: 768px) {\n .navbar-form .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .navbar-form .form-control-static {\n display: inline-block;\n }\n .navbar-form .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .navbar-form .input-group .input-group-addon,\n .navbar-form .input-group .input-group-btn,\n .navbar-form .input-group .form-control {\n width: auto;\n }\n .navbar-form .input-group > .form-control {\n width: 100%;\n }\n .navbar-form .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio,\n .navbar-form .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio label,\n .navbar-form .checkbox label {\n padding-left: 0;\n }\n .navbar-form .radio input[type=\"radio\"],\n .navbar-form .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .navbar-form .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n@media (max-width: 767px) {\n .navbar-form .form-group {\n margin-bottom: 5px;\n }\n .navbar-form .form-group:last-child {\n margin-bottom: 0;\n }\n}\n@media (min-width: 768px) {\n .navbar-form {\n width: auto;\n padding-top: 0;\n padding-bottom: 0;\n margin-right: 0;\n margin-left: 0;\n border: 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n}\n.navbar-nav > li > .dropdown-menu {\n margin-top: 0;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu {\n margin-bottom: 0;\n border-top-left-radius: 4px;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.navbar-btn {\n margin-top: 8px;\n margin-bottom: 8px;\n}\n.navbar-btn.btn-sm {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.navbar-btn.btn-xs {\n margin-top: 14px;\n margin-bottom: 14px;\n}\n.navbar-text {\n margin-top: 15px;\n margin-bottom: 15px;\n}\n@media (min-width: 768px) {\n .navbar-text {\n float: left;\n margin-right: 15px;\n margin-left: 15px;\n }\n}\n@media (min-width: 768px) {\n .navbar-left {\n float: left !important;\n }\n .navbar-right {\n float: right !important;\n margin-right: -15px;\n }\n .navbar-right ~ .navbar-right {\n margin-right: 0;\n }\n}\n.navbar-default {\n background-color: #f8f8f8;\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-brand {\n color: #777;\n}\n.navbar-default .navbar-brand:hover,\n.navbar-default .navbar-brand:focus {\n color: #5e5e5e;\n background-color: transparent;\n}\n.navbar-default .navbar-text {\n color: #777;\n}\n.navbar-default .navbar-nav > li > a {\n color: #777;\n}\n.navbar-default .navbar-nav > li > a:hover,\n.navbar-default .navbar-nav > li > a:focus {\n color: #333;\n background-color: transparent;\n}\n.navbar-default .navbar-nav > .active > a,\n.navbar-default .navbar-nav > .active > a:hover,\n.navbar-default .navbar-nav > .active > a:focus {\n color: #555;\n background-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .disabled > a,\n.navbar-default .navbar-nav > .disabled > a:hover,\n.navbar-default .navbar-nav > .disabled > a:focus {\n color: #ccc;\n background-color: transparent;\n}\n.navbar-default .navbar-toggle {\n border-color: #ddd;\n}\n.navbar-default .navbar-toggle:hover,\n.navbar-default .navbar-toggle:focus {\n background-color: #ddd;\n}\n.navbar-default .navbar-toggle .icon-bar {\n background-color: #888;\n}\n.navbar-default .navbar-collapse,\n.navbar-default .navbar-form {\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .open > a,\n.navbar-default .navbar-nav > .open > a:hover,\n.navbar-default .navbar-nav > .open > a:focus {\n color: #555;\n background-color: #e7e7e7;\n}\n@media (max-width: 767px) {\n .navbar-default .navbar-nav .open .dropdown-menu > li > a {\n color: #777;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #333;\n background-color: transparent;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #555;\n background-color: #e7e7e7;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #ccc;\n background-color: transparent;\n }\n}\n.navbar-default .navbar-link {\n color: #777;\n}\n.navbar-default .navbar-link:hover {\n color: #333;\n}\n.navbar-default .btn-link {\n color: #777;\n}\n.navbar-default .btn-link:hover,\n.navbar-default .btn-link:focus {\n color: #333;\n}\n.navbar-default .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-default .btn-link:hover,\n.navbar-default .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-default .btn-link:focus {\n color: #ccc;\n}\n.navbar-inverse {\n background-color: #222;\n border-color: #080808;\n}\n.navbar-inverse .navbar-brand {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-brand:hover,\n.navbar-inverse .navbar-brand:focus {\n color: #fff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-text {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a:hover,\n.navbar-inverse .navbar-nav > li > a:focus {\n color: #fff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-nav > .active > a,\n.navbar-inverse .navbar-nav > .active > a:hover,\n.navbar-inverse .navbar-nav > .active > a:focus {\n color: #fff;\n background-color: #080808;\n}\n.navbar-inverse .navbar-nav > .disabled > a,\n.navbar-inverse .navbar-nav > .disabled > a:hover,\n.navbar-inverse .navbar-nav > .disabled > a:focus {\n color: #444;\n background-color: transparent;\n}\n.navbar-inverse .navbar-toggle {\n border-color: #333;\n}\n.navbar-inverse .navbar-toggle:hover,\n.navbar-inverse .navbar-toggle:focus {\n background-color: #333;\n}\n.navbar-inverse .navbar-toggle .icon-bar {\n background-color: #fff;\n}\n.navbar-inverse .navbar-collapse,\n.navbar-inverse .navbar-form {\n border-color: #101010;\n}\n.navbar-inverse .navbar-nav > .open > a,\n.navbar-inverse .navbar-nav > .open > a:hover,\n.navbar-inverse .navbar-nav > .open > a:focus {\n color: #fff;\n background-color: #080808;\n}\n@media (max-width: 767px) {\n .navbar-inverse .navbar-nav .open .dropdown-menu > .dropdown-header {\n border-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu .divider {\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a {\n color: #9d9d9d;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #fff;\n background-color: transparent;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #fff;\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #444;\n background-color: transparent;\n }\n}\n.navbar-inverse .navbar-link {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-link:hover {\n color: #fff;\n}\n.navbar-inverse .btn-link {\n color: #9d9d9d;\n}\n.navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link:focus {\n color: #fff;\n}\n.navbar-inverse .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-inverse .btn-link:focus {\n color: #444;\n}\n.breadcrumb {\n padding: 8px 15px;\n margin-bottom: 20px;\n list-style: none;\n background-color: #f5f5f5;\n border-radius: 4px;\n}\n.breadcrumb > li {\n display: inline-block;\n}\n.breadcrumb > li + li:before {\n padding: 0 5px;\n color: #ccc;\n content: \"/\\00a0\";\n}\n.breadcrumb > .active {\n color: #777;\n}\n.pagination {\n display: inline-block;\n padding-left: 0;\n margin: 20px 0;\n border-radius: 4px;\n}\n.pagination > li {\n display: inline;\n}\n.pagination > li > a,\n.pagination > li > span {\n position: relative;\n float: left;\n padding: 6px 12px;\n margin-left: -1px;\n line-height: 1.42857143;\n color: #337ab7;\n text-decoration: none;\n background-color: #fff;\n border: 1px solid #ddd;\n}\n.pagination > li:first-child > a,\n.pagination > li:first-child > span {\n margin-left: 0;\n border-top-left-radius: 4px;\n border-bottom-left-radius: 4px;\n}\n.pagination > li:last-child > a,\n.pagination > li:last-child > span {\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n}\n.pagination > li > a:hover,\n.pagination > li > span:hover,\n.pagination > li > a:focus,\n.pagination > li > span:focus {\n z-index: 2;\n color: #23527c;\n background-color: #eee;\n border-color: #ddd;\n}\n.pagination > .active > a,\n.pagination > .active > span,\n.pagination > .active > a:hover,\n.pagination > .active > span:hover,\n.pagination > .active > a:focus,\n.pagination > .active > span:focus {\n z-index: 3;\n color: #fff;\n cursor: default;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.pagination > .disabled > span,\n.pagination > .disabled > span:hover,\n.pagination > .disabled > span:focus,\n.pagination > .disabled > a,\n.pagination > .disabled > a:hover,\n.pagination > .disabled > a:focus {\n color: #777;\n cursor: not-allowed;\n background-color: #fff;\n border-color: #ddd;\n}\n.pagination-lg > li > a,\n.pagination-lg > li > span {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.pagination-lg > li:first-child > a,\n.pagination-lg > li:first-child > span {\n border-top-left-radius: 6px;\n border-bottom-left-radius: 6px;\n}\n.pagination-lg > li:last-child > a,\n.pagination-lg > li:last-child > span {\n border-top-right-radius: 6px;\n border-bottom-right-radius: 6px;\n}\n.pagination-sm > li > a,\n.pagination-sm > li > span {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.pagination-sm > li:first-child > a,\n.pagination-sm > li:first-child > span {\n border-top-left-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.pagination-sm > li:last-child > a,\n.pagination-sm > li:last-child > span {\n border-top-right-radius: 3px;\n border-bottom-right-radius: 3px;\n}\n.pager {\n padding-left: 0;\n margin: 20px 0;\n text-align: center;\n list-style: none;\n}\n.pager li {\n display: inline;\n}\n.pager li > a,\n.pager li > span {\n display: inline-block;\n padding: 5px 14px;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 15px;\n}\n.pager li > a:hover,\n.pager li > a:focus {\n text-decoration: none;\n background-color: #eee;\n}\n.pager .next > a,\n.pager .next > span {\n float: right;\n}\n.pager .previous > a,\n.pager .previous > span {\n float: left;\n}\n.pager .disabled > a,\n.pager .disabled > a:hover,\n.pager .disabled > a:focus,\n.pager .disabled > span {\n color: #777;\n cursor: not-allowed;\n background-color: #fff;\n}\n.label {\n display: inline;\n padding: .2em .6em .3em;\n font-size: 75%;\n font-weight: bold;\n line-height: 1;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: .25em;\n}\na.label:hover,\na.label:focus {\n color: #fff;\n text-decoration: none;\n cursor: pointer;\n}\n.label:empty {\n display: none;\n}\n.btn .label {\n position: relative;\n top: -1px;\n}\n.label-default {\n background-color: #777;\n}\n.label-default[href]:hover,\n.label-default[href]:focus {\n background-color: #5e5e5e;\n}\n.label-primary {\n background-color: #337ab7;\n}\n.label-primary[href]:hover,\n.label-primary[href]:focus {\n background-color: #286090;\n}\n.label-success {\n background-color: #5cb85c;\n}\n.label-success[href]:hover,\n.label-success[href]:focus {\n background-color: #449d44;\n}\n.label-info {\n background-color: #5bc0de;\n}\n.label-info[href]:hover,\n.label-info[href]:focus {\n background-color: #31b0d5;\n}\n.label-warning {\n background-color: #f0ad4e;\n}\n.label-warning[href]:hover,\n.label-warning[href]:focus {\n background-color: #ec971f;\n}\n.label-danger {\n background-color: #d9534f;\n}\n.label-danger[href]:hover,\n.label-danger[href]:focus {\n background-color: #c9302c;\n}\n.badge {\n display: inline-block;\n min-width: 10px;\n padding: 3px 7px;\n font-size: 12px;\n font-weight: bold;\n line-height: 1;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n vertical-align: middle;\n background-color: #777;\n border-radius: 10px;\n}\n.badge:empty {\n display: none;\n}\n.btn .badge {\n position: relative;\n top: -1px;\n}\n.btn-xs .badge,\n.btn-group-xs > .btn .badge {\n top: 0;\n padding: 1px 5px;\n}\na.badge:hover,\na.badge:focus {\n color: #fff;\n text-decoration: none;\n cursor: pointer;\n}\n.list-group-item.active > .badge,\n.nav-pills > .active > a > .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.list-group-item > .badge {\n float: right;\n}\n.list-group-item > .badge + .badge {\n margin-right: 5px;\n}\n.nav-pills > li > a > .badge {\n margin-left: 3px;\n}\n.jumbotron {\n padding-top: 30px;\n padding-bottom: 30px;\n margin-bottom: 30px;\n color: inherit;\n background-color: #eee;\n}\n.jumbotron h1,\n.jumbotron .h1 {\n color: inherit;\n}\n.jumbotron p {\n margin-bottom: 15px;\n font-size: 21px;\n font-weight: 200;\n}\n.jumbotron > hr {\n border-top-color: #d5d5d5;\n}\n.container .jumbotron,\n.container-fluid .jumbotron {\n padding-right: 15px;\n padding-left: 15px;\n border-radius: 6px;\n}\n.jumbotron .container {\n max-width: 100%;\n}\n@media screen and (min-width: 768px) {\n .jumbotron {\n padding-top: 48px;\n padding-bottom: 48px;\n }\n .container .jumbotron,\n .container-fluid .jumbotron {\n padding-right: 60px;\n padding-left: 60px;\n }\n .jumbotron h1,\n .jumbotron .h1 {\n font-size: 63px;\n }\n}\n.thumbnail {\n display: block;\n padding: 4px;\n margin-bottom: 20px;\n line-height: 1.42857143;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n -webkit-transition: border .2s ease-in-out;\n -o-transition: border .2s ease-in-out;\n transition: border .2s ease-in-out;\n}\n.thumbnail > img,\n.thumbnail a > img {\n margin-right: auto;\n margin-left: auto;\n}\na.thumbnail:hover,\na.thumbnail:focus,\na.thumbnail.active {\n border-color: #337ab7;\n}\n.thumbnail .caption {\n padding: 9px;\n color: #333;\n}\n.alert {\n padding: 15px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.alert h4 {\n margin-top: 0;\n color: inherit;\n}\n.alert .alert-link {\n font-weight: bold;\n}\n.alert > p,\n.alert > ul {\n margin-bottom: 0;\n}\n.alert > p + p {\n margin-top: 5px;\n}\n.alert-dismissable,\n.alert-dismissible {\n padding-right: 35px;\n}\n.alert-dismissable .close,\n.alert-dismissible .close {\n position: relative;\n top: -2px;\n right: -21px;\n color: inherit;\n}\n.alert-success {\n color: #3c763d;\n background-color: #dff0d8;\n border-color: #d6e9c6;\n}\n.alert-success hr {\n border-top-color: #c9e2b3;\n}\n.alert-success .alert-link {\n color: #2b542c;\n}\n.alert-info {\n color: #31708f;\n background-color: #d9edf7;\n border-color: #bce8f1;\n}\n.alert-info hr {\n border-top-color: #a6e1ec;\n}\n.alert-info .alert-link {\n color: #245269;\n}\n.alert-warning {\n color: #8a6d3b;\n background-color: #fcf8e3;\n border-color: #faebcc;\n}\n.alert-warning hr {\n border-top-color: #f7e1b5;\n}\n.alert-warning .alert-link {\n color: #66512c;\n}\n.alert-danger {\n color: #a94442;\n background-color: #f2dede;\n border-color: #ebccd1;\n}\n.alert-danger hr {\n border-top-color: #e4b9c0;\n}\n.alert-danger .alert-link {\n color: #843534;\n}\n@-webkit-keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n@-o-keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n@keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n.progress {\n height: 20px;\n margin-bottom: 20px;\n overflow: hidden;\n background-color: #f5f5f5;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1);\n box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1);\n}\n.progress-bar {\n float: left;\n width: 0;\n height: 100%;\n font-size: 12px;\n line-height: 20px;\n color: #fff;\n text-align: center;\n background-color: #337ab7;\n -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15);\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15);\n -webkit-transition: width .6s ease;\n -o-transition: width .6s ease;\n transition: width .6s ease;\n}\n.progress-striped .progress-bar,\n.progress-bar-striped {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n -webkit-background-size: 40px 40px;\n background-size: 40px 40px;\n}\n.progress.active .progress-bar,\n.progress-bar.active {\n -webkit-animation: progress-bar-stripes 2s linear infinite;\n -o-animation: progress-bar-stripes 2s linear infinite;\n animation: progress-bar-stripes 2s linear infinite;\n}\n.progress-bar-success {\n background-color: #5cb85c;\n}\n.progress-striped .progress-bar-success {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n}\n.progress-bar-info {\n background-color: #5bc0de;\n}\n.progress-striped .progress-bar-info {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n}\n.progress-bar-warning {\n background-color: #f0ad4e;\n}\n.progress-striped .progress-bar-warning {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n}\n.progress-bar-danger {\n background-color: #d9534f;\n}\n.progress-striped .progress-bar-danger {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n}\n.media {\n margin-top: 15px;\n}\n.media:first-child {\n margin-top: 0;\n}\n.media,\n.media-body {\n overflow: hidden;\n zoom: 1;\n}\n.media-body {\n width: 10000px;\n}\n.media-object {\n display: block;\n}\n.media-object.img-thumbnail {\n max-width: none;\n}\n.media-right,\n.media > .pull-right {\n padding-left: 10px;\n}\n.media-left,\n.media > .pull-left {\n padding-right: 10px;\n}\n.media-left,\n.media-right,\n.media-body {\n display: table-cell;\n vertical-align: top;\n}\n.media-middle {\n vertical-align: middle;\n}\n.media-bottom {\n vertical-align: bottom;\n}\n.media-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.media-list {\n padding-left: 0;\n list-style: none;\n}\n.list-group {\n padding-left: 0;\n margin-bottom: 20px;\n}\n.list-group-item {\n position: relative;\n display: block;\n padding: 10px 15px;\n margin-bottom: -1px;\n background-color: #fff;\n border: 1px solid #ddd;\n}\n.list-group-item:first-child {\n border-top-left-radius: 4px;\n border-top-right-radius: 4px;\n}\n.list-group-item:last-child {\n margin-bottom: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\na.list-group-item,\nbutton.list-group-item {\n color: #555;\n}\na.list-group-item .list-group-item-heading,\nbutton.list-group-item .list-group-item-heading {\n color: #333;\n}\na.list-group-item:hover,\nbutton.list-group-item:hover,\na.list-group-item:focus,\nbutton.list-group-item:focus {\n color: #555;\n text-decoration: none;\n background-color: #f5f5f5;\n}\nbutton.list-group-item {\n width: 100%;\n text-align: left;\n}\n.list-group-item.disabled,\n.list-group-item.disabled:hover,\n.list-group-item.disabled:focus {\n color: #777;\n cursor: not-allowed;\n background-color: #eee;\n}\n.list-group-item.disabled .list-group-item-heading,\n.list-group-item.disabled:hover .list-group-item-heading,\n.list-group-item.disabled:focus .list-group-item-heading {\n color: inherit;\n}\n.list-group-item.disabled .list-group-item-text,\n.list-group-item.disabled:hover .list-group-item-text,\n.list-group-item.disabled:focus .list-group-item-text {\n color: #777;\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n z-index: 2;\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.list-group-item.active .list-group-item-heading,\n.list-group-item.active:hover .list-group-item-heading,\n.list-group-item.active:focus .list-group-item-heading,\n.list-group-item.active .list-group-item-heading > small,\n.list-group-item.active:hover .list-group-item-heading > small,\n.list-group-item.active:focus .list-group-item-heading > small,\n.list-group-item.active .list-group-item-heading > .small,\n.list-group-item.active:hover .list-group-item-heading > .small,\n.list-group-item.active:focus .list-group-item-heading > .small {\n color: inherit;\n}\n.list-group-item.active .list-group-item-text,\n.list-group-item.active:hover .list-group-item-text,\n.list-group-item.active:focus .list-group-item-text {\n color: #c7ddef;\n}\n.list-group-item-success {\n color: #3c763d;\n background-color: #dff0d8;\n}\na.list-group-item-success,\nbutton.list-group-item-success {\n color: #3c763d;\n}\na.list-group-item-success .list-group-item-heading,\nbutton.list-group-item-success .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-success:hover,\nbutton.list-group-item-success:hover,\na.list-group-item-success:focus,\nbutton.list-group-item-success:focus {\n color: #3c763d;\n background-color: #d0e9c6;\n}\na.list-group-item-success.active,\nbutton.list-group-item-success.active,\na.list-group-item-success.active:hover,\nbutton.list-group-item-success.active:hover,\na.list-group-item-success.active:focus,\nbutton.list-group-item-success.active:focus {\n color: #fff;\n background-color: #3c763d;\n border-color: #3c763d;\n}\n.list-group-item-info {\n color: #31708f;\n background-color: #d9edf7;\n}\na.list-group-item-info,\nbutton.list-group-item-info {\n color: #31708f;\n}\na.list-group-item-info .list-group-item-heading,\nbutton.list-group-item-info .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-info:hover,\nbutton.list-group-item-info:hover,\na.list-group-item-info:focus,\nbutton.list-group-item-info:focus {\n color: #31708f;\n background-color: #c4e3f3;\n}\na.list-group-item-info.active,\nbutton.list-group-item-info.active,\na.list-group-item-info.active:hover,\nbutton.list-group-item-info.active:hover,\na.list-group-item-info.active:focus,\nbutton.list-group-item-info.active:focus {\n color: #fff;\n background-color: #31708f;\n border-color: #31708f;\n}\n.list-group-item-warning {\n color: #8a6d3b;\n background-color: #fcf8e3;\n}\na.list-group-item-warning,\nbutton.list-group-item-warning {\n color: #8a6d3b;\n}\na.list-group-item-warning .list-group-item-heading,\nbutton.list-group-item-warning .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-warning:hover,\nbutton.list-group-item-warning:hover,\na.list-group-item-warning:focus,\nbutton.list-group-item-warning:focus {\n color: #8a6d3b;\n background-color: #faf2cc;\n}\na.list-group-item-warning.active,\nbutton.list-group-item-warning.active,\na.list-group-item-warning.active:hover,\nbutton.list-group-item-warning.active:hover,\na.list-group-item-warning.active:focus,\nbutton.list-group-item-warning.active:focus {\n color: #fff;\n background-color: #8a6d3b;\n border-color: #8a6d3b;\n}\n.list-group-item-danger {\n color: #a94442;\n background-color: #f2dede;\n}\na.list-group-item-danger,\nbutton.list-group-item-danger {\n color: #a94442;\n}\na.list-group-item-danger .list-group-item-heading,\nbutton.list-group-item-danger .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-danger:hover,\nbutton.list-group-item-danger:hover,\na.list-group-item-danger:focus,\nbutton.list-group-item-danger:focus {\n color: #a94442;\n background-color: #ebcccc;\n}\na.list-group-item-danger.active,\nbutton.list-group-item-danger.active,\na.list-group-item-danger.active:hover,\nbutton.list-group-item-danger.active:hover,\na.list-group-item-danger.active:focus,\nbutton.list-group-item-danger.active:focus {\n color: #fff;\n background-color: #a94442;\n border-color: #a94442;\n}\n.list-group-item-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.list-group-item-text {\n margin-bottom: 0;\n line-height: 1.3;\n}\n.panel {\n margin-bottom: 20px;\n background-color: #fff;\n border: 1px solid transparent;\n border-radius: 4px;\n -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, .05);\n box-shadow: 0 1px 1px rgba(0, 0, 0, .05);\n}\n.panel-body {\n padding: 15px;\n}\n.panel-heading {\n padding: 10px 15px;\n border-bottom: 1px solid transparent;\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel-heading > .dropdown .dropdown-toggle {\n color: inherit;\n}\n.panel-title {\n margin-top: 0;\n margin-bottom: 0;\n font-size: 16px;\n color: inherit;\n}\n.panel-title > a,\n.panel-title > small,\n.panel-title > .small,\n.panel-title > small > a,\n.panel-title > .small > a {\n color: inherit;\n}\n.panel-footer {\n padding: 10px 15px;\n background-color: #f5f5f5;\n border-top: 1px solid #ddd;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .list-group,\n.panel > .panel-collapse > .list-group {\n margin-bottom: 0;\n}\n.panel > .list-group .list-group-item,\n.panel > .panel-collapse > .list-group .list-group-item {\n border-width: 1px 0;\n border-radius: 0;\n}\n.panel > .list-group:first-child .list-group-item:first-child,\n.panel > .panel-collapse > .list-group:first-child .list-group-item:first-child {\n border-top: 0;\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel > .list-group:last-child .list-group-item:last-child,\n.panel > .panel-collapse > .list-group:last-child .list-group-item:last-child {\n border-bottom: 0;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .panel-heading + .panel-collapse > .list-group .list-group-item:first-child {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n.panel-heading + .list-group .list-group-item:first-child {\n border-top-width: 0;\n}\n.list-group + .panel-footer {\n border-top-width: 0;\n}\n.panel > .table,\n.panel > .table-responsive > .table,\n.panel > .panel-collapse > .table {\n margin-bottom: 0;\n}\n.panel > .table caption,\n.panel > .table-responsive > .table caption,\n.panel > .panel-collapse > .table caption {\n padding-right: 15px;\n padding-left: 15px;\n}\n.panel > .table:first-child,\n.panel > .table-responsive:first-child > .table:first-child {\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child {\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:first-child {\n border-top-left-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:last-child {\n border-top-right-radius: 3px;\n}\n.panel > .table:last-child,\n.panel > .table-responsive:last-child > .table:last-child {\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child {\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:first-child {\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:last-child {\n border-bottom-right-radius: 3px;\n}\n.panel > .panel-body + .table,\n.panel > .panel-body + .table-responsive,\n.panel > .table + .panel-body,\n.panel > .table-responsive + .panel-body {\n border-top: 1px solid #ddd;\n}\n.panel > .table > tbody:first-child > tr:first-child th,\n.panel > .table > tbody:first-child > tr:first-child td {\n border-top: 0;\n}\n.panel > .table-bordered,\n.panel > .table-responsive > .table-bordered {\n border: 0;\n}\n.panel > .table-bordered > thead > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:first-child,\n.panel > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-bordered > thead > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:first-child,\n.panel > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-bordered > tfoot > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n}\n.panel > .table-bordered > thead > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:last-child,\n.panel > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-bordered > thead > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:last-child,\n.panel > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-bordered > tfoot > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n}\n.panel > .table-bordered > thead > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > td,\n.panel > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-bordered > thead > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > th,\n.panel > .table-bordered > tbody > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > th {\n border-bottom: 0;\n}\n.panel > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-bordered > tfoot > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > th {\n border-bottom: 0;\n}\n.panel > .table-responsive {\n margin-bottom: 0;\n border: 0;\n}\n.panel-group {\n margin-bottom: 20px;\n}\n.panel-group .panel {\n margin-bottom: 0;\n border-radius: 4px;\n}\n.panel-group .panel + .panel {\n margin-top: 5px;\n}\n.panel-group .panel-heading {\n border-bottom: 0;\n}\n.panel-group .panel-heading + .panel-collapse > .panel-body,\n.panel-group .panel-heading + .panel-collapse > .list-group {\n border-top: 1px solid #ddd;\n}\n.panel-group .panel-footer {\n border-top: 0;\n}\n.panel-group .panel-footer + .panel-collapse .panel-body {\n border-bottom: 1px solid #ddd;\n}\n.panel-default {\n border-color: #ddd;\n}\n.panel-default > .panel-heading {\n color: #333;\n background-color: #f5f5f5;\n border-color: #ddd;\n}\n.panel-default > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ddd;\n}\n.panel-default > .panel-heading .badge {\n color: #f5f5f5;\n background-color: #333;\n}\n.panel-default > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ddd;\n}\n.panel-primary {\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading {\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #337ab7;\n}\n.panel-primary > .panel-heading .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.panel-primary > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #337ab7;\n}\n.panel-success {\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading {\n color: #3c763d;\n background-color: #dff0d8;\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #d6e9c6;\n}\n.panel-success > .panel-heading .badge {\n color: #dff0d8;\n background-color: #3c763d;\n}\n.panel-success > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #d6e9c6;\n}\n.panel-info {\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading {\n color: #31708f;\n background-color: #d9edf7;\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #bce8f1;\n}\n.panel-info > .panel-heading .badge {\n color: #d9edf7;\n background-color: #31708f;\n}\n.panel-info > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #bce8f1;\n}\n.panel-warning {\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading {\n color: #8a6d3b;\n background-color: #fcf8e3;\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #faebcc;\n}\n.panel-warning > .panel-heading .badge {\n color: #fcf8e3;\n background-color: #8a6d3b;\n}\n.panel-warning > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #faebcc;\n}\n.panel-danger {\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading {\n color: #a94442;\n background-color: #f2dede;\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ebccd1;\n}\n.panel-danger > .panel-heading .badge {\n color: #f2dede;\n background-color: #a94442;\n}\n.panel-danger > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ebccd1;\n}\n.embed-responsive {\n position: relative;\n display: block;\n height: 0;\n padding: 0;\n overflow: hidden;\n}\n.embed-responsive .embed-responsive-item,\n.embed-responsive iframe,\n.embed-responsive embed,\n.embed-responsive object,\n.embed-responsive video {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n width: 100%;\n height: 100%;\n border: 0;\n}\n.embed-responsive-16by9 {\n padding-bottom: 56.25%;\n}\n.embed-responsive-4by3 {\n padding-bottom: 75%;\n}\n.well {\n min-height: 20px;\n padding: 19px;\n margin-bottom: 20px;\n background-color: #f5f5f5;\n border: 1px solid #e3e3e3;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05);\n}\n.well blockquote {\n border-color: #ddd;\n border-color: rgba(0, 0, 0, .15);\n}\n.well-lg {\n padding: 24px;\n border-radius: 6px;\n}\n.well-sm {\n padding: 9px;\n border-radius: 3px;\n}\n.close {\n float: right;\n font-size: 21px;\n font-weight: bold;\n line-height: 1;\n color: #000;\n text-shadow: 0 1px 0 #fff;\n filter: alpha(opacity=20);\n opacity: .2;\n}\n.close:hover,\n.close:focus {\n color: #000;\n text-decoration: none;\n cursor: pointer;\n filter: alpha(opacity=50);\n opacity: .5;\n}\nbutton.close {\n -webkit-appearance: none;\n padding: 0;\n cursor: pointer;\n background: transparent;\n border: 0;\n}\n.modal-open {\n overflow: hidden;\n}\n.modal {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1050;\n display: none;\n overflow: hidden;\n -webkit-overflow-scrolling: touch;\n outline: 0;\n}\n.modal.fade .modal-dialog {\n -webkit-transition: -webkit-transform .3s ease-out;\n -o-transition: -o-transform .3s ease-out;\n transition: transform .3s ease-out;\n -webkit-transform: translate(0, -25%);\n -ms-transform: translate(0, -25%);\n -o-transform: translate(0, -25%);\n transform: translate(0, -25%);\n}\n.modal.in .modal-dialog {\n -webkit-transform: translate(0, 0);\n -ms-transform: translate(0, 0);\n -o-transform: translate(0, 0);\n transform: translate(0, 0);\n}\n.modal-open .modal {\n overflow-x: hidden;\n overflow-y: auto;\n}\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 10px;\n}\n.modal-content {\n position: relative;\n background-color: #fff;\n -webkit-background-clip: padding-box;\n background-clip: padding-box;\n border: 1px solid #999;\n border: 1px solid rgba(0, 0, 0, .2);\n border-radius: 6px;\n outline: 0;\n -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, .5);\n box-shadow: 0 3px 9px rgba(0, 0, 0, .5);\n}\n.modal-backdrop {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1040;\n background-color: #000;\n}\n.modal-backdrop.fade {\n filter: alpha(opacity=0);\n opacity: 0;\n}\n.modal-backdrop.in {\n filter: alpha(opacity=50);\n opacity: .5;\n}\n.modal-header {\n padding: 15px;\n border-bottom: 1px solid #e5e5e5;\n}\n.modal-header .close {\n margin-top: -2px;\n}\n.modal-title {\n margin: 0;\n line-height: 1.42857143;\n}\n.modal-body {\n position: relative;\n padding: 15px;\n}\n.modal-footer {\n padding: 15px;\n text-align: right;\n border-top: 1px solid #e5e5e5;\n}\n.modal-footer .btn + .btn {\n margin-bottom: 0;\n margin-left: 5px;\n}\n.modal-footer .btn-group .btn + .btn {\n margin-left: -1px;\n}\n.modal-footer .btn-block + .btn-block {\n margin-left: 0;\n}\n.modal-scrollbar-measure {\n position: absolute;\n top: -9999px;\n width: 50px;\n height: 50px;\n overflow: scroll;\n}\n@media (min-width: 768px) {\n .modal-dialog {\n width: 600px;\n margin: 30px auto;\n }\n .modal-content {\n -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5);\n box-shadow: 0 5px 15px rgba(0, 0, 0, .5);\n }\n .modal-sm {\n width: 300px;\n }\n}\n@media (min-width: 992px) {\n .modal-lg {\n width: 900px;\n }\n}\n.tooltip {\n position: absolute;\n z-index: 1070;\n display: block;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 12px;\n font-style: normal;\n font-weight: normal;\n line-height: 1.42857143;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n white-space: normal;\n filter: alpha(opacity=0);\n opacity: 0;\n\n line-break: auto;\n}\n.tooltip.in {\n filter: alpha(opacity=90);\n opacity: .9;\n}\n.tooltip.top {\n padding: 5px 0;\n margin-top: -3px;\n}\n.tooltip.right {\n padding: 0 5px;\n margin-left: 3px;\n}\n.tooltip.bottom {\n padding: 5px 0;\n margin-top: 3px;\n}\n.tooltip.left {\n padding: 0 5px;\n margin-left: -3px;\n}\n.tooltip-inner {\n max-width: 200px;\n padding: 3px 8px;\n color: #fff;\n text-align: center;\n background-color: #000;\n border-radius: 4px;\n}\n.tooltip-arrow {\n position: absolute;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.tooltip.top .tooltip-arrow {\n bottom: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.top-left .tooltip-arrow {\n right: 5px;\n bottom: 0;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.top-right .tooltip-arrow {\n bottom: 0;\n left: 5px;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.right .tooltip-arrow {\n top: 50%;\n left: 0;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: #000;\n}\n.tooltip.left .tooltip-arrow {\n top: 50%;\n right: 0;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: #000;\n}\n.tooltip.bottom .tooltip-arrow {\n top: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.tooltip.bottom-left .tooltip-arrow {\n top: 0;\n right: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.tooltip.bottom-right .tooltip-arrow {\n top: 0;\n left: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.popover {\n position: absolute;\n top: 0;\n left: 0;\n z-index: 1060;\n display: none;\n max-width: 276px;\n padding: 1px;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 14px;\n font-style: normal;\n font-weight: normal;\n line-height: 1.42857143;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n white-space: normal;\n background-color: #fff;\n -webkit-background-clip: padding-box;\n background-clip: padding-box;\n border: 1px solid #ccc;\n border: 1px solid rgba(0, 0, 0, .2);\n border-radius: 6px;\n -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, .2);\n box-shadow: 0 5px 10px rgba(0, 0, 0, .2);\n\n line-break: auto;\n}\n.popover.top {\n margin-top: -10px;\n}\n.popover.right {\n margin-left: 10px;\n}\n.popover.bottom {\n margin-top: 10px;\n}\n.popover.left {\n margin-left: -10px;\n}\n.popover-title {\n padding: 8px 14px;\n margin: 0;\n font-size: 14px;\n background-color: #f7f7f7;\n border-bottom: 1px solid #ebebeb;\n border-radius: 5px 5px 0 0;\n}\n.popover-content {\n padding: 9px 14px;\n}\n.popover > .arrow,\n.popover > .arrow:after {\n position: absolute;\n display: block;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.popover > .arrow {\n border-width: 11px;\n}\n.popover > .arrow:after {\n content: \"\";\n border-width: 10px;\n}\n.popover.top > .arrow {\n bottom: -11px;\n left: 50%;\n margin-left: -11px;\n border-top-color: #999;\n border-top-color: rgba(0, 0, 0, .25);\n border-bottom-width: 0;\n}\n.popover.top > .arrow:after {\n bottom: 1px;\n margin-left: -10px;\n content: \" \";\n border-top-color: #fff;\n border-bottom-width: 0;\n}\n.popover.right > .arrow {\n top: 50%;\n left: -11px;\n margin-top: -11px;\n border-right-color: #999;\n border-right-color: rgba(0, 0, 0, .25);\n border-left-width: 0;\n}\n.popover.right > .arrow:after {\n bottom: -10px;\n left: 1px;\n content: \" \";\n border-right-color: #fff;\n border-left-width: 0;\n}\n.popover.bottom > .arrow {\n top: -11px;\n left: 50%;\n margin-left: -11px;\n border-top-width: 0;\n border-bottom-color: #999;\n border-bottom-color: rgba(0, 0, 0, .25);\n}\n.popover.bottom > .arrow:after {\n top: 1px;\n margin-left: -10px;\n content: \" \";\n border-top-width: 0;\n border-bottom-color: #fff;\n}\n.popover.left > .arrow {\n top: 50%;\n right: -11px;\n margin-top: -11px;\n border-right-width: 0;\n border-left-color: #999;\n border-left-color: rgba(0, 0, 0, .25);\n}\n.popover.left > .arrow:after {\n right: 1px;\n bottom: -10px;\n content: \" \";\n border-right-width: 0;\n border-left-color: #fff;\n}\n.carousel {\n position: relative;\n}\n.carousel-inner {\n position: relative;\n width: 100%;\n overflow: hidden;\n}\n.carousel-inner > .item {\n position: relative;\n display: none;\n -webkit-transition: .6s ease-in-out left;\n -o-transition: .6s ease-in-out left;\n transition: .6s ease-in-out left;\n}\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n line-height: 1;\n}\n@media all and (transform-3d), (-webkit-transform-3d) {\n .carousel-inner > .item {\n -webkit-transition: -webkit-transform .6s ease-in-out;\n -o-transition: -o-transform .6s ease-in-out;\n transition: transform .6s ease-in-out;\n\n -webkit-backface-visibility: hidden;\n backface-visibility: hidden;\n -webkit-perspective: 1000px;\n perspective: 1000px;\n }\n .carousel-inner > .item.next,\n .carousel-inner > .item.active.right {\n left: 0;\n -webkit-transform: translate3d(100%, 0, 0);\n transform: translate3d(100%, 0, 0);\n }\n .carousel-inner > .item.prev,\n .carousel-inner > .item.active.left {\n left: 0;\n -webkit-transform: translate3d(-100%, 0, 0);\n transform: translate3d(-100%, 0, 0);\n }\n .carousel-inner > .item.next.left,\n .carousel-inner > .item.prev.right,\n .carousel-inner > .item.active {\n left: 0;\n -webkit-transform: translate3d(0, 0, 0);\n transform: translate3d(0, 0, 0);\n }\n}\n.carousel-inner > .active,\n.carousel-inner > .next,\n.carousel-inner > .prev {\n display: block;\n}\n.carousel-inner > .active {\n left: 0;\n}\n.carousel-inner > .next,\n.carousel-inner > .prev {\n position: absolute;\n top: 0;\n width: 100%;\n}\n.carousel-inner > .next {\n left: 100%;\n}\n.carousel-inner > .prev {\n left: -100%;\n}\n.carousel-inner > .next.left,\n.carousel-inner > .prev.right {\n left: 0;\n}\n.carousel-inner > .active.left {\n left: -100%;\n}\n.carousel-inner > .active.right {\n left: 100%;\n}\n.carousel-control {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n width: 15%;\n font-size: 20px;\n color: #fff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, .6);\n background-color: rgba(0, 0, 0, 0);\n filter: alpha(opacity=50);\n opacity: .5;\n}\n.carousel-control.left {\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%);\n background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, .5)), to(rgba(0, 0, 0, .0001)));\n background-image: linear-gradient(to right, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);\n background-repeat: repeat-x;\n}\n.carousel-control.right {\n right: 0;\n left: auto;\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%);\n background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, .0001)), to(rgba(0, 0, 0, .5)));\n background-image: linear-gradient(to right, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);\n background-repeat: repeat-x;\n}\n.carousel-control:hover,\n.carousel-control:focus {\n color: #fff;\n text-decoration: none;\n filter: alpha(opacity=90);\n outline: 0;\n opacity: .9;\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-left,\n.carousel-control .glyphicon-chevron-right {\n position: absolute;\n top: 50%;\n z-index: 5;\n display: inline-block;\n margin-top: -10px;\n}\n.carousel-control .icon-prev,\n.carousel-control .glyphicon-chevron-left {\n left: 50%;\n margin-left: -10px;\n}\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-right {\n right: 50%;\n margin-right: -10px;\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next {\n width: 20px;\n height: 20px;\n font-family: serif;\n line-height: 1;\n}\n.carousel-control .icon-prev:before {\n content: '\\2039';\n}\n.carousel-control .icon-next:before {\n content: '\\203a';\n}\n.carousel-indicators {\n position: absolute;\n bottom: 10px;\n left: 50%;\n z-index: 15;\n width: 60%;\n padding-left: 0;\n margin-left: -30%;\n text-align: center;\n list-style: none;\n}\n.carousel-indicators li {\n display: inline-block;\n width: 10px;\n height: 10px;\n margin: 1px;\n text-indent: -999px;\n cursor: pointer;\n background-color: #000 \\9;\n background-color: rgba(0, 0, 0, 0);\n border: 1px solid #fff;\n border-radius: 10px;\n}\n.carousel-indicators .active {\n width: 12px;\n height: 12px;\n margin: 0;\n background-color: #fff;\n}\n.carousel-caption {\n position: absolute;\n right: 15%;\n bottom: 20px;\n left: 15%;\n z-index: 10;\n padding-top: 20px;\n padding-bottom: 20px;\n color: #fff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, .6);\n}\n.carousel-caption .btn {\n text-shadow: none;\n}\n@media screen and (min-width: 768px) {\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-prev,\n .carousel-control .icon-next {\n width: 30px;\n height: 30px;\n margin-top: -10px;\n font-size: 30px;\n }\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .icon-prev {\n margin-left: -10px;\n }\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-next {\n margin-right: -10px;\n }\n .carousel-caption {\n right: 20%;\n left: 20%;\n padding-bottom: 30px;\n }\n .carousel-indicators {\n bottom: 20px;\n }\n}\n.clearfix:before,\n.clearfix:after,\n.dl-horizontal dd:before,\n.dl-horizontal dd:after,\n.container:before,\n.container:after,\n.container-fluid:before,\n.container-fluid:after,\n.row:before,\n.row:after,\n.form-horizontal .form-group:before,\n.form-horizontal .form-group:after,\n.btn-toolbar:before,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:before,\n.btn-group-vertical > .btn-group:after,\n.nav:before,\n.nav:after,\n.navbar:before,\n.navbar:after,\n.navbar-header:before,\n.navbar-header:after,\n.navbar-collapse:before,\n.navbar-collapse:after,\n.pager:before,\n.pager:after,\n.panel-body:before,\n.panel-body:after,\n.modal-header:before,\n.modal-header:after,\n.modal-footer:before,\n.modal-footer:after {\n display: table;\n content: \" \";\n}\n.clearfix:after,\n.dl-horizontal dd:after,\n.container:after,\n.container-fluid:after,\n.row:after,\n.form-horizontal .form-group:after,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:after,\n.nav:after,\n.navbar:after,\n.navbar-header:after,\n.navbar-collapse:after,\n.pager:after,\n.panel-body:after,\n.modal-header:after,\n.modal-footer:after {\n clear: both;\n}\n.center-block {\n display: block;\n margin-right: auto;\n margin-left: auto;\n}\n.pull-right {\n float: right !important;\n}\n.pull-left {\n float: left !important;\n}\n.hide {\n display: none !important;\n}\n.show {\n display: block !important;\n}\n.invisible {\n visibility: hidden;\n}\n.text-hide {\n font: 0/0 a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n.hidden {\n display: none !important;\n}\n.affix {\n position: fixed;\n}\n@-ms-viewport {\n width: device-width;\n}\n.visible-xs,\n.visible-sm,\n.visible-md,\n.visible-lg {\n display: none !important;\n}\n.visible-xs-block,\n.visible-xs-inline,\n.visible-xs-inline-block,\n.visible-sm-block,\n.visible-sm-inline,\n.visible-sm-inline-block,\n.visible-md-block,\n.visible-md-inline,\n.visible-md-inline-block,\n.visible-lg-block,\n.visible-lg-inline,\n.visible-lg-inline-block {\n display: none !important;\n}\n@media (max-width: 767px) {\n .visible-xs {\n display: block !important;\n }\n table.visible-xs {\n display: table !important;\n }\n tr.visible-xs {\n display: table-row !important;\n }\n th.visible-xs,\n td.visible-xs {\n display: table-cell !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-block {\n display: block !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline {\n display: inline !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm {\n display: block !important;\n }\n table.visible-sm {\n display: table !important;\n }\n tr.visible-sm {\n display: table-row !important;\n }\n th.visible-sm,\n td.visible-sm {\n display: table-cell !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-block {\n display: block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline {\n display: inline !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md {\n display: block !important;\n }\n table.visible-md {\n display: table !important;\n }\n tr.visible-md {\n display: table-row !important;\n }\n th.visible-md,\n td.visible-md {\n display: table-cell !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-block {\n display: block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline {\n display: inline !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg {\n display: block !important;\n }\n table.visible-lg {\n display: table !important;\n }\n tr.visible-lg {\n display: table-row !important;\n }\n th.visible-lg,\n td.visible-lg {\n display: table-cell !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-block {\n display: block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline {\n display: inline !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline-block {\n display: inline-block !important;\n }\n}\n@media (max-width: 767px) {\n .hidden-xs {\n display: none !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .hidden-sm {\n display: none !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .hidden-md {\n display: none !important;\n }\n}\n@media (min-width: 1200px) {\n .hidden-lg {\n display: none !important;\n }\n}\n.visible-print {\n display: none !important;\n}\n@media print {\n .visible-print {\n display: block !important;\n }\n table.visible-print {\n display: table !important;\n }\n tr.visible-print {\n display: table-row !important;\n }\n th.visible-print,\n td.visible-print {\n display: table-cell !important;\n }\n}\n.visible-print-block {\n display: none !important;\n}\n@media print {\n .visible-print-block {\n display: block !important;\n }\n}\n.visible-print-inline {\n display: none !important;\n}\n@media print {\n .visible-print-inline {\n display: inline !important;\n }\n}\n.visible-print-inline-block {\n display: none !important;\n}\n@media print {\n .visible-print-inline-block {\n display: inline-block !important;\n }\n}\n@media print {\n .hidden-print {\n display: none !important;\n }\n}\n/*# sourceMappingURL=bootstrap.css.map */\n","//\n// Glyphicons for Bootstrap\n//\n// Since icons are fonts, they can be placed anywhere text is placed and are\n// thus automatically sized to match the surrounding child. To use, create an\n// inline element with the appropriate classes, like so:\n//\n// Star\n\n// Import the fonts\n@font-face {\n font-family: 'Glyphicons Halflings';\n src: url('@{icon-font-path}@{icon-font-name}.eot');\n src: url('@{icon-font-path}@{icon-font-name}.eot?#iefix') format('embedded-opentype'),\n url('@{icon-font-path}@{icon-font-name}.woff2') format('woff2'),\n url('@{icon-font-path}@{icon-font-name}.woff') format('woff'),\n url('@{icon-font-path}@{icon-font-name}.ttf') format('truetype'),\n url('@{icon-font-path}@{icon-font-name}.svg#@{icon-font-svg-id}') format('svg');\n}\n\n// Catchall baseclass\n.glyphicon {\n position: relative;\n top: 1px;\n display: inline-block;\n font-family: 'Glyphicons Halflings';\n font-style: normal;\n font-weight: normal;\n line-height: 1;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n\n// Individual icons\n.glyphicon-asterisk { &:before { content: \"\\002a\"; } }\n.glyphicon-plus { &:before { content: \"\\002b\"; } }\n.glyphicon-euro,\n.glyphicon-eur { &:before { content: \"\\20ac\"; } }\n.glyphicon-minus { &:before { content: \"\\2212\"; } }\n.glyphicon-cloud { &:before { content: \"\\2601\"; } }\n.glyphicon-envelope { &:before { content: \"\\2709\"; } }\n.glyphicon-pencil { &:before { content: \"\\270f\"; } }\n.glyphicon-glass { &:before { content: \"\\e001\"; } }\n.glyphicon-music { &:before { content: \"\\e002\"; } }\n.glyphicon-search { &:before { content: \"\\e003\"; } }\n.glyphicon-heart { &:before { content: \"\\e005\"; } }\n.glyphicon-star { &:before { content: \"\\e006\"; } }\n.glyphicon-star-empty { &:before { content: \"\\e007\"; } }\n.glyphicon-user { &:before { content: \"\\e008\"; } }\n.glyphicon-film { &:before { content: \"\\e009\"; } }\n.glyphicon-th-large { &:before { content: \"\\e010\"; } }\n.glyphicon-th { &:before { content: \"\\e011\"; } }\n.glyphicon-th-list { &:before { content: \"\\e012\"; } }\n.glyphicon-ok { &:before { content: \"\\e013\"; } }\n.glyphicon-remove { &:before { content: \"\\e014\"; } }\n.glyphicon-zoom-in { &:before { content: \"\\e015\"; } }\n.glyphicon-zoom-out { &:before { content: \"\\e016\"; } }\n.glyphicon-off { &:before { content: \"\\e017\"; } }\n.glyphicon-signal { &:before { content: \"\\e018\"; } }\n.glyphicon-cog { &:before { content: \"\\e019\"; } }\n.glyphicon-trash { &:before { content: \"\\e020\"; } }\n.glyphicon-home { &:before { content: \"\\e021\"; } }\n.glyphicon-file { &:before { content: \"\\e022\"; } }\n.glyphicon-time { &:before { content: \"\\e023\"; } }\n.glyphicon-road { &:before { content: \"\\e024\"; } }\n.glyphicon-download-alt { &:before { content: \"\\e025\"; } }\n.glyphicon-download { &:before { content: \"\\e026\"; } }\n.glyphicon-upload { &:before { content: \"\\e027\"; } }\n.glyphicon-inbox { &:before { content: \"\\e028\"; } }\n.glyphicon-play-circle { &:before { content: \"\\e029\"; } }\n.glyphicon-repeat { &:before { content: \"\\e030\"; } }\n.glyphicon-refresh { &:before { content: \"\\e031\"; } }\n.glyphicon-list-alt { &:before { content: \"\\e032\"; } }\n.glyphicon-lock { &:before { content: \"\\e033\"; } }\n.glyphicon-flag { &:before { content: \"\\e034\"; } }\n.glyphicon-headphones { &:before { content: \"\\e035\"; } }\n.glyphicon-volume-off { &:before { content: \"\\e036\"; } }\n.glyphicon-volume-down { &:before { content: \"\\e037\"; } }\n.glyphicon-volume-up { &:before { content: \"\\e038\"; } }\n.glyphicon-qrcode { &:before { content: \"\\e039\"; } }\n.glyphicon-barcode { &:before { content: \"\\e040\"; } }\n.glyphicon-tag { &:before { content: \"\\e041\"; } }\n.glyphicon-tags { &:before { content: \"\\e042\"; } }\n.glyphicon-book { &:before { content: \"\\e043\"; } }\n.glyphicon-bookmark { &:before { content: \"\\e044\"; } }\n.glyphicon-print { &:before { content: \"\\e045\"; } }\n.glyphicon-camera { &:before { content: \"\\e046\"; } }\n.glyphicon-font { &:before { content: \"\\e047\"; } }\n.glyphicon-bold { &:before { content: \"\\e048\"; } }\n.glyphicon-italic { &:before { content: \"\\e049\"; } }\n.glyphicon-text-height { &:before { content: \"\\e050\"; } }\n.glyphicon-text-width { &:before { content: \"\\e051\"; } }\n.glyphicon-align-left { &:before { content: \"\\e052\"; } }\n.glyphicon-align-center { &:before { content: \"\\e053\"; } }\n.glyphicon-align-right { &:before { content: \"\\e054\"; } }\n.glyphicon-align-justify { &:before { content: \"\\e055\"; } }\n.glyphicon-list { &:before { content: \"\\e056\"; } }\n.glyphicon-indent-left { &:before { content: \"\\e057\"; } }\n.glyphicon-indent-right { &:before { content: \"\\e058\"; } }\n.glyphicon-facetime-video { &:before { content: \"\\e059\"; } }\n.glyphicon-picture { &:before { content: \"\\e060\"; } }\n.glyphicon-map-marker { &:before { content: \"\\e062\"; } }\n.glyphicon-adjust { &:before { content: \"\\e063\"; } }\n.glyphicon-tint { &:before { content: \"\\e064\"; } }\n.glyphicon-edit { &:before { content: \"\\e065\"; } }\n.glyphicon-share { &:before { content: \"\\e066\"; } }\n.glyphicon-check { &:before { content: \"\\e067\"; } }\n.glyphicon-move { &:before { content: \"\\e068\"; } }\n.glyphicon-step-backward { &:before { content: \"\\e069\"; } }\n.glyphicon-fast-backward { &:before { content: \"\\e070\"; } }\n.glyphicon-backward { &:before { content: \"\\e071\"; } }\n.glyphicon-play { &:before { content: \"\\e072\"; } }\n.glyphicon-pause { &:before { content: \"\\e073\"; } }\n.glyphicon-stop { &:before { content: \"\\e074\"; } }\n.glyphicon-forward { &:before { content: \"\\e075\"; } }\n.glyphicon-fast-forward { &:before { content: \"\\e076\"; } }\n.glyphicon-step-forward { &:before { content: \"\\e077\"; } }\n.glyphicon-eject { &:before { content: \"\\e078\"; } }\n.glyphicon-chevron-left { &:before { content: \"\\e079\"; } }\n.glyphicon-chevron-right { &:before { content: \"\\e080\"; } }\n.glyphicon-plus-sign { &:before { content: \"\\e081\"; } }\n.glyphicon-minus-sign { &:before { content: \"\\e082\"; } }\n.glyphicon-remove-sign { &:before { content: \"\\e083\"; } }\n.glyphicon-ok-sign { &:before { content: \"\\e084\"; } }\n.glyphicon-question-sign { &:before { content: \"\\e085\"; } }\n.glyphicon-info-sign { &:before { content: \"\\e086\"; } }\n.glyphicon-screenshot { &:before { content: \"\\e087\"; } }\n.glyphicon-remove-circle { &:before { content: \"\\e088\"; } }\n.glyphicon-ok-circle { &:before { content: \"\\e089\"; } }\n.glyphicon-ban-circle { &:before { content: \"\\e090\"; } }\n.glyphicon-arrow-left { &:before { content: \"\\e091\"; } }\n.glyphicon-arrow-right { &:before { content: \"\\e092\"; } }\n.glyphicon-arrow-up { &:before { content: \"\\e093\"; } }\n.glyphicon-arrow-down { &:before { content: \"\\e094\"; } }\n.glyphicon-share-alt { &:before { content: \"\\e095\"; } }\n.glyphicon-resize-full { &:before { content: \"\\e096\"; } }\n.glyphicon-resize-small { &:before { content: \"\\e097\"; } }\n.glyphicon-exclamation-sign { &:before { content: \"\\e101\"; } }\n.glyphicon-gift { &:before { content: \"\\e102\"; } }\n.glyphicon-leaf { &:before { content: \"\\e103\"; } }\n.glyphicon-fire { &:before { content: \"\\e104\"; } }\n.glyphicon-eye-open { &:before { content: \"\\e105\"; } }\n.glyphicon-eye-close { &:before { content: \"\\e106\"; } }\n.glyphicon-warning-sign { &:before { content: \"\\e107\"; } }\n.glyphicon-plane { &:before { content: \"\\e108\"; } }\n.glyphicon-calendar { &:before { content: \"\\e109\"; } }\n.glyphicon-random { &:before { content: \"\\e110\"; } }\n.glyphicon-comment { &:before { content: \"\\e111\"; } }\n.glyphicon-magnet { &:before { content: \"\\e112\"; } }\n.glyphicon-chevron-up { &:before { content: \"\\e113\"; } }\n.glyphicon-chevron-down { &:before { content: \"\\e114\"; } }\n.glyphicon-retweet { &:before { content: \"\\e115\"; } }\n.glyphicon-shopping-cart { &:before { content: \"\\e116\"; } }\n.glyphicon-folder-close { &:before { content: \"\\e117\"; } }\n.glyphicon-folder-open { &:before { content: \"\\e118\"; } }\n.glyphicon-resize-vertical { &:before { content: \"\\e119\"; } }\n.glyphicon-resize-horizontal { &:before { content: \"\\e120\"; } }\n.glyphicon-hdd { &:before { content: \"\\e121\"; } }\n.glyphicon-bullhorn { &:before { content: \"\\e122\"; } }\n.glyphicon-bell { &:before { content: \"\\e123\"; } }\n.glyphicon-certificate { &:before { content: \"\\e124\"; } }\n.glyphicon-thumbs-up { &:before { content: \"\\e125\"; } }\n.glyphicon-thumbs-down { &:before { content: \"\\e126\"; } }\n.glyphicon-hand-right { &:before { content: \"\\e127\"; } }\n.glyphicon-hand-left { &:before { content: \"\\e128\"; } }\n.glyphicon-hand-up { &:before { content: \"\\e129\"; } }\n.glyphicon-hand-down { &:before { content: \"\\e130\"; } }\n.glyphicon-circle-arrow-right { &:before { content: \"\\e131\"; } }\n.glyphicon-circle-arrow-left { &:before { content: \"\\e132\"; } }\n.glyphicon-circle-arrow-up { &:before { content: \"\\e133\"; } }\n.glyphicon-circle-arrow-down { &:before { content: \"\\e134\"; } }\n.glyphicon-globe { &:before { content: \"\\e135\"; } }\n.glyphicon-wrench { &:before { content: \"\\e136\"; } }\n.glyphicon-tasks { &:before { content: \"\\e137\"; } }\n.glyphicon-filter { &:before { content: \"\\e138\"; } }\n.glyphicon-briefcase { &:before { content: \"\\e139\"; } }\n.glyphicon-fullscreen { &:before { content: \"\\e140\"; } }\n.glyphicon-dashboard { &:before { content: \"\\e141\"; } }\n.glyphicon-paperclip { &:before { content: \"\\e142\"; } }\n.glyphicon-heart-empty { &:before { content: \"\\e143\"; } }\n.glyphicon-link { &:before { content: \"\\e144\"; } }\n.glyphicon-phone { &:before { content: \"\\e145\"; } }\n.glyphicon-pushpin { &:before { content: \"\\e146\"; } }\n.glyphicon-usd { &:before { content: \"\\e148\"; } }\n.glyphicon-gbp { &:before { content: \"\\e149\"; } }\n.glyphicon-sort { &:before { content: \"\\e150\"; } }\n.glyphicon-sort-by-alphabet { &:before { content: \"\\e151\"; } }\n.glyphicon-sort-by-alphabet-alt { &:before { content: \"\\e152\"; } }\n.glyphicon-sort-by-order { &:before { content: \"\\e153\"; } }\n.glyphicon-sort-by-order-alt { &:before { content: \"\\e154\"; } }\n.glyphicon-sort-by-attributes { &:before { content: \"\\e155\"; } }\n.glyphicon-sort-by-attributes-alt { &:before { content: \"\\e156\"; } }\n.glyphicon-unchecked { &:before { content: \"\\e157\"; } }\n.glyphicon-expand { &:before { content: \"\\e158\"; } }\n.glyphicon-collapse-down { &:before { content: \"\\e159\"; } }\n.glyphicon-collapse-up { &:before { content: \"\\e160\"; } }\n.glyphicon-log-in { &:before { content: \"\\e161\"; } }\n.glyphicon-flash { &:before { content: \"\\e162\"; } }\n.glyphicon-log-out { &:before { content: \"\\e163\"; } }\n.glyphicon-new-window { &:before { content: \"\\e164\"; } }\n.glyphicon-record { &:before { content: \"\\e165\"; } }\n.glyphicon-save { &:before { content: \"\\e166\"; } }\n.glyphicon-open { &:before { content: \"\\e167\"; } }\n.glyphicon-saved { &:before { content: \"\\e168\"; } }\n.glyphicon-import { &:before { content: \"\\e169\"; } }\n.glyphicon-export { &:before { content: \"\\e170\"; } }\n.glyphicon-send { &:before { content: \"\\e171\"; } }\n.glyphicon-floppy-disk { &:before { content: \"\\e172\"; } }\n.glyphicon-floppy-saved { &:before { content: \"\\e173\"; } }\n.glyphicon-floppy-remove { &:before { content: \"\\e174\"; } }\n.glyphicon-floppy-save { &:before { content: \"\\e175\"; } }\n.glyphicon-floppy-open { &:before { content: \"\\e176\"; } }\n.glyphicon-credit-card { &:before { content: \"\\e177\"; } }\n.glyphicon-transfer { &:before { content: \"\\e178\"; } }\n.glyphicon-cutlery { &:before { content: \"\\e179\"; } }\n.glyphicon-header { &:before { content: \"\\e180\"; } }\n.glyphicon-compressed { &:before { content: \"\\e181\"; } }\n.glyphicon-earphone { &:before { content: \"\\e182\"; } }\n.glyphicon-phone-alt { &:before { content: \"\\e183\"; } }\n.glyphicon-tower { &:before { content: \"\\e184\"; } }\n.glyphicon-stats { &:before { content: \"\\e185\"; } }\n.glyphicon-sd-video { &:before { content: \"\\e186\"; } }\n.glyphicon-hd-video { &:before { content: \"\\e187\"; } }\n.glyphicon-subtitles { &:before { content: \"\\e188\"; } }\n.glyphicon-sound-stereo { &:before { content: \"\\e189\"; } }\n.glyphicon-sound-dolby { &:before { content: \"\\e190\"; } }\n.glyphicon-sound-5-1 { &:before { content: \"\\e191\"; } }\n.glyphicon-sound-6-1 { &:before { content: \"\\e192\"; } }\n.glyphicon-sound-7-1 { &:before { content: \"\\e193\"; } }\n.glyphicon-copyright-mark { &:before { content: \"\\e194\"; } }\n.glyphicon-registration-mark { &:before { content: \"\\e195\"; } }\n.glyphicon-cloud-download { &:before { content: \"\\e197\"; } }\n.glyphicon-cloud-upload { &:before { content: \"\\e198\"; } }\n.glyphicon-tree-conifer { &:before { content: \"\\e199\"; } }\n.glyphicon-tree-deciduous { &:before { content: \"\\e200\"; } }\n.glyphicon-cd { &:before { content: \"\\e201\"; } }\n.glyphicon-save-file { &:before { content: \"\\e202\"; } }\n.glyphicon-open-file { &:before { content: \"\\e203\"; } }\n.glyphicon-level-up { &:before { content: \"\\e204\"; } }\n.glyphicon-copy { &:before { content: \"\\e205\"; } }\n.glyphicon-paste { &:before { content: \"\\e206\"; } }\n// The following 2 Glyphicons are omitted for the time being because\n// they currently use Unicode codepoints that are outside the\n// Basic Multilingual Plane (BMP). Older buggy versions of WebKit can't handle\n// non-BMP codepoints in CSS string escapes, and thus can't display these two icons.\n// Notably, the bug affects some older versions of the Android Browser.\n// More info: https://github.com/twbs/bootstrap/issues/10106\n// .glyphicon-door { &:before { content: \"\\1f6aa\"; } }\n// .glyphicon-key { &:before { content: \"\\1f511\"; } }\n.glyphicon-alert { &:before { content: \"\\e209\"; } }\n.glyphicon-equalizer { &:before { content: \"\\e210\"; } }\n.glyphicon-king { &:before { content: \"\\e211\"; } }\n.glyphicon-queen { &:before { content: \"\\e212\"; } }\n.glyphicon-pawn { &:before { content: \"\\e213\"; } }\n.glyphicon-bishop { &:before { content: \"\\e214\"; } }\n.glyphicon-knight { &:before { content: \"\\e215\"; } }\n.glyphicon-baby-formula { &:before { content: \"\\e216\"; } }\n.glyphicon-tent { &:before { content: \"\\26fa\"; } }\n.glyphicon-blackboard { &:before { content: \"\\e218\"; } }\n.glyphicon-bed { &:before { content: \"\\e219\"; } }\n.glyphicon-apple { &:before { content: \"\\f8ff\"; } }\n.glyphicon-erase { &:before { content: \"\\e221\"; } }\n.glyphicon-hourglass { &:before { content: \"\\231b\"; } }\n.glyphicon-lamp { &:before { content: \"\\e223\"; } }\n.glyphicon-duplicate { &:before { content: \"\\e224\"; } }\n.glyphicon-piggy-bank { &:before { content: \"\\e225\"; } }\n.glyphicon-scissors { &:before { content: \"\\e226\"; } }\n.glyphicon-bitcoin { &:before { content: \"\\e227\"; } }\n.glyphicon-btc { &:before { content: \"\\e227\"; } }\n.glyphicon-xbt { &:before { content: \"\\e227\"; } }\n.glyphicon-yen { &:before { content: \"\\00a5\"; } }\n.glyphicon-jpy { &:before { content: \"\\00a5\"; } }\n.glyphicon-ruble { &:before { content: \"\\20bd\"; } }\n.glyphicon-rub { &:before { content: \"\\20bd\"; } }\n.glyphicon-scale { &:before { content: \"\\e230\"; } }\n.glyphicon-ice-lolly { &:before { content: \"\\e231\"; } }\n.glyphicon-ice-lolly-tasted { &:before { content: \"\\e232\"; } }\n.glyphicon-education { &:before { content: \"\\e233\"; } }\n.glyphicon-option-horizontal { &:before { content: \"\\e234\"; } }\n.glyphicon-option-vertical { &:before { content: \"\\e235\"; } }\n.glyphicon-menu-hamburger { &:before { content: \"\\e236\"; } }\n.glyphicon-modal-window { &:before { content: \"\\e237\"; } }\n.glyphicon-oil { &:before { content: \"\\e238\"; } }\n.glyphicon-grain { &:before { content: \"\\e239\"; } }\n.glyphicon-sunglasses { &:before { content: \"\\e240\"; } }\n.glyphicon-text-size { &:before { content: \"\\e241\"; } }\n.glyphicon-text-color { &:before { content: \"\\e242\"; } }\n.glyphicon-text-background { &:before { content: \"\\e243\"; } }\n.glyphicon-object-align-top { &:before { content: \"\\e244\"; } }\n.glyphicon-object-align-bottom { &:before { content: \"\\e245\"; } }\n.glyphicon-object-align-horizontal{ &:before { content: \"\\e246\"; } }\n.glyphicon-object-align-left { &:before { content: \"\\e247\"; } }\n.glyphicon-object-align-vertical { &:before { content: \"\\e248\"; } }\n.glyphicon-object-align-right { &:before { content: \"\\e249\"; } }\n.glyphicon-triangle-right { &:before { content: \"\\e250\"; } }\n.glyphicon-triangle-left { &:before { content: \"\\e251\"; } }\n.glyphicon-triangle-bottom { &:before { content: \"\\e252\"; } }\n.glyphicon-triangle-top { &:before { content: \"\\e253\"; } }\n.glyphicon-console { &:before { content: \"\\e254\"; } }\n.glyphicon-superscript { &:before { content: \"\\e255\"; } }\n.glyphicon-subscript { &:before { content: \"\\e256\"; } }\n.glyphicon-menu-left { &:before { content: \"\\e257\"; } }\n.glyphicon-menu-right { &:before { content: \"\\e258\"; } }\n.glyphicon-menu-down { &:before { content: \"\\e259\"; } }\n.glyphicon-menu-up { &:before { content: \"\\e260\"; } }\n","//\n// Scaffolding\n// --------------------------------------------------\n\n\n// Reset the box-sizing\n//\n// Heads up! This reset may cause conflicts with some third-party widgets.\n// For recommendations on resolving such conflicts, see\n// http://getbootstrap.com/getting-started/#third-box-sizing\n* {\n .box-sizing(border-box);\n}\n*:before,\n*:after {\n .box-sizing(border-box);\n}\n\n\n// Body reset\n\nhtml {\n font-size: 10px;\n -webkit-tap-highlight-color: rgba(0,0,0,0);\n}\n\nbody {\n font-family: @font-family-base;\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @text-color;\n background-color: @body-bg;\n}\n\n// Reset fonts for relevant elements\ninput,\nbutton,\nselect,\ntextarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\n\n// Links\n\na {\n color: @link-color;\n text-decoration: none;\n\n &:hover,\n &:focus {\n color: @link-hover-color;\n text-decoration: @link-hover-decoration;\n }\n\n &:focus {\n .tab-focus();\n }\n}\n\n\n// Figures\n//\n// We reset this here because previously Normalize had no `figure` margins. This\n// ensures we don't break anyone's use of the element.\n\nfigure {\n margin: 0;\n}\n\n\n// Images\n\nimg {\n vertical-align: middle;\n}\n\n// Responsive images (ensure images don't scale beyond their parents)\n.img-responsive {\n .img-responsive();\n}\n\n// Rounded corners\n.img-rounded {\n border-radius: @border-radius-large;\n}\n\n// Image thumbnails\n//\n// Heads up! This is mixin-ed into thumbnails.less for `.thumbnail`.\n.img-thumbnail {\n padding: @thumbnail-padding;\n line-height: @line-height-base;\n background-color: @thumbnail-bg;\n border: 1px solid @thumbnail-border;\n border-radius: @thumbnail-border-radius;\n .transition(all .2s ease-in-out);\n\n // Keep them at most 100% wide\n .img-responsive(inline-block);\n}\n\n// Perfect circle\n.img-circle {\n border-radius: 50%; // set radius in percents\n}\n\n\n// Horizontal rules\n\nhr {\n margin-top: @line-height-computed;\n margin-bottom: @line-height-computed;\n border: 0;\n border-top: 1px solid @hr-border;\n}\n\n\n// Only display content to screen readers\n//\n// See: http://a11yproject.com/posts/how-to-hide-content\n\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0,0,0,0);\n border: 0;\n}\n\n// Use in conjunction with .sr-only to only display content when it's focused.\n// Useful for \"Skip to main content\" links; see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1\n// Credit: HTML5 Boilerplate\n\n.sr-only-focusable {\n &:active,\n &:focus {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n overflow: visible;\n clip: auto;\n }\n}\n\n\n// iOS \"clickable elements\" fix for role=\"button\"\n//\n// Fixes \"clickability\" issue (and more generally, the firing of events such as focus as well)\n// for traditionally non-focusable elements with role=\"button\"\n// see https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile\n\n[role=\"button\"] {\n cursor: pointer;\n}\n","// Vendor Prefixes\n//\n// All vendor mixins are deprecated as of v3.2.0 due to the introduction of\n// Autoprefixer in our Gruntfile. They have been removed in v4.\n\n// - Animations\n// - Backface visibility\n// - Box shadow\n// - Box sizing\n// - Content columns\n// - Hyphens\n// - Placeholder text\n// - Transformations\n// - Transitions\n// - User Select\n\n\n// Animations\n.animation(@animation) {\n -webkit-animation: @animation;\n -o-animation: @animation;\n animation: @animation;\n}\n.animation-name(@name) {\n -webkit-animation-name: @name;\n animation-name: @name;\n}\n.animation-duration(@duration) {\n -webkit-animation-duration: @duration;\n animation-duration: @duration;\n}\n.animation-timing-function(@timing-function) {\n -webkit-animation-timing-function: @timing-function;\n animation-timing-function: @timing-function;\n}\n.animation-delay(@delay) {\n -webkit-animation-delay: @delay;\n animation-delay: @delay;\n}\n.animation-iteration-count(@iteration-count) {\n -webkit-animation-iteration-count: @iteration-count;\n animation-iteration-count: @iteration-count;\n}\n.animation-direction(@direction) {\n -webkit-animation-direction: @direction;\n animation-direction: @direction;\n}\n.animation-fill-mode(@fill-mode) {\n -webkit-animation-fill-mode: @fill-mode;\n animation-fill-mode: @fill-mode;\n}\n\n// Backface visibility\n// Prevent browsers from flickering when using CSS 3D transforms.\n// Default value is `visible`, but can be changed to `hidden`\n\n.backface-visibility(@visibility) {\n -webkit-backface-visibility: @visibility;\n -moz-backface-visibility: @visibility;\n backface-visibility: @visibility;\n}\n\n// Drop shadows\n//\n// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's\n// supported browsers that have box shadow capabilities now support it.\n\n.box-shadow(@shadow) {\n -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1\n box-shadow: @shadow;\n}\n\n// Box sizing\n.box-sizing(@boxmodel) {\n -webkit-box-sizing: @boxmodel;\n -moz-box-sizing: @boxmodel;\n box-sizing: @boxmodel;\n}\n\n// CSS3 Content Columns\n.content-columns(@column-count; @column-gap: @grid-gutter-width) {\n -webkit-column-count: @column-count;\n -moz-column-count: @column-count;\n column-count: @column-count;\n -webkit-column-gap: @column-gap;\n -moz-column-gap: @column-gap;\n column-gap: @column-gap;\n}\n\n// Optional hyphenation\n.hyphens(@mode: auto) {\n word-wrap: break-word;\n -webkit-hyphens: @mode;\n -moz-hyphens: @mode;\n -ms-hyphens: @mode; // IE10+\n -o-hyphens: @mode;\n hyphens: @mode;\n}\n\n// Placeholder text\n.placeholder(@color: @input-color-placeholder) {\n // Firefox\n &::-moz-placeholder {\n color: @color;\n opacity: 1; // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526\n }\n &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+\n &::-webkit-input-placeholder { color: @color; } // Safari and Chrome\n}\n\n// Transformations\n.scale(@ratio) {\n -webkit-transform: scale(@ratio);\n -ms-transform: scale(@ratio); // IE9 only\n -o-transform: scale(@ratio);\n transform: scale(@ratio);\n}\n.scale(@ratioX; @ratioY) {\n -webkit-transform: scale(@ratioX, @ratioY);\n -ms-transform: scale(@ratioX, @ratioY); // IE9 only\n -o-transform: scale(@ratioX, @ratioY);\n transform: scale(@ratioX, @ratioY);\n}\n.scaleX(@ratio) {\n -webkit-transform: scaleX(@ratio);\n -ms-transform: scaleX(@ratio); // IE9 only\n -o-transform: scaleX(@ratio);\n transform: scaleX(@ratio);\n}\n.scaleY(@ratio) {\n -webkit-transform: scaleY(@ratio);\n -ms-transform: scaleY(@ratio); // IE9 only\n -o-transform: scaleY(@ratio);\n transform: scaleY(@ratio);\n}\n.skew(@x; @y) {\n -webkit-transform: skewX(@x) skewY(@y);\n -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+\n -o-transform: skewX(@x) skewY(@y);\n transform: skewX(@x) skewY(@y);\n}\n.translate(@x; @y) {\n -webkit-transform: translate(@x, @y);\n -ms-transform: translate(@x, @y); // IE9 only\n -o-transform: translate(@x, @y);\n transform: translate(@x, @y);\n}\n.translate3d(@x; @y; @z) {\n -webkit-transform: translate3d(@x, @y, @z);\n transform: translate3d(@x, @y, @z);\n}\n.rotate(@degrees) {\n -webkit-transform: rotate(@degrees);\n -ms-transform: rotate(@degrees); // IE9 only\n -o-transform: rotate(@degrees);\n transform: rotate(@degrees);\n}\n.rotateX(@degrees) {\n -webkit-transform: rotateX(@degrees);\n -ms-transform: rotateX(@degrees); // IE9 only\n -o-transform: rotateX(@degrees);\n transform: rotateX(@degrees);\n}\n.rotateY(@degrees) {\n -webkit-transform: rotateY(@degrees);\n -ms-transform: rotateY(@degrees); // IE9 only\n -o-transform: rotateY(@degrees);\n transform: rotateY(@degrees);\n}\n.perspective(@perspective) {\n -webkit-perspective: @perspective;\n -moz-perspective: @perspective;\n perspective: @perspective;\n}\n.perspective-origin(@perspective) {\n -webkit-perspective-origin: @perspective;\n -moz-perspective-origin: @perspective;\n perspective-origin: @perspective;\n}\n.transform-origin(@origin) {\n -webkit-transform-origin: @origin;\n -moz-transform-origin: @origin;\n -ms-transform-origin: @origin; // IE9 only\n transform-origin: @origin;\n}\n\n\n// Transitions\n\n.transition(@transition) {\n -webkit-transition: @transition;\n -o-transition: @transition;\n transition: @transition;\n}\n.transition-property(@transition-property) {\n -webkit-transition-property: @transition-property;\n transition-property: @transition-property;\n}\n.transition-delay(@transition-delay) {\n -webkit-transition-delay: @transition-delay;\n transition-delay: @transition-delay;\n}\n.transition-duration(@transition-duration) {\n -webkit-transition-duration: @transition-duration;\n transition-duration: @transition-duration;\n}\n.transition-timing-function(@timing-function) {\n -webkit-transition-timing-function: @timing-function;\n transition-timing-function: @timing-function;\n}\n.transition-transform(@transition) {\n -webkit-transition: -webkit-transform @transition;\n -moz-transition: -moz-transform @transition;\n -o-transition: -o-transform @transition;\n transition: transform @transition;\n}\n\n\n// User select\n// For selecting text on the page\n\n.user-select(@select) {\n -webkit-user-select: @select;\n -moz-user-select: @select;\n -ms-user-select: @select; // IE10+\n user-select: @select;\n}\n","// WebKit-style focus\n\n.tab-focus() {\n // WebKit-specific. Other browsers will keep their default outline style.\n // (Initially tried to also force default via `outline: initial`,\n // but that seems to erroneously remove the outline in Firefox altogether.)\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\n","// Image Mixins\n// - Responsive image\n// - Retina image\n\n\n// Responsive image\n//\n// Keep images from scaling beyond the width of their parents.\n.img-responsive(@display: block) {\n display: @display;\n max-width: 100%; // Part 1: Set a maximum relative to the parent\n height: auto; // Part 2: Scale the height according to the width, otherwise you get stretching\n}\n\n\n// Retina image\n//\n// Short retina mixin for setting background-image and -size. Note that the\n// spelling of `min--moz-device-pixel-ratio` is intentional.\n.img-retina(@file-1x; @file-2x; @width-1x; @height-1x) {\n background-image: url(\"@{file-1x}\");\n\n @media\n only screen and (-webkit-min-device-pixel-ratio: 2),\n only screen and ( min--moz-device-pixel-ratio: 2),\n only screen and ( -o-min-device-pixel-ratio: 2/1),\n only screen and ( min-device-pixel-ratio: 2),\n only screen and ( min-resolution: 192dpi),\n only screen and ( min-resolution: 2dppx) {\n background-image: url(\"@{file-2x}\");\n background-size: @width-1x @height-1x;\n }\n}\n","//\n// Typography\n// --------------------------------------------------\n\n\n// Headings\n// -------------------------\n\nh1, h2, h3, h4, h5, h6,\n.h1, .h2, .h3, .h4, .h5, .h6 {\n font-family: @headings-font-family;\n font-weight: @headings-font-weight;\n line-height: @headings-line-height;\n color: @headings-color;\n\n small,\n .small {\n font-weight: normal;\n line-height: 1;\n color: @headings-small-color;\n }\n}\n\nh1, .h1,\nh2, .h2,\nh3, .h3 {\n margin-top: @line-height-computed;\n margin-bottom: (@line-height-computed / 2);\n\n small,\n .small {\n font-size: 65%;\n }\n}\nh4, .h4,\nh5, .h5,\nh6, .h6 {\n margin-top: (@line-height-computed / 2);\n margin-bottom: (@line-height-computed / 2);\n\n small,\n .small {\n font-size: 75%;\n }\n}\n\nh1, .h1 { font-size: @font-size-h1; }\nh2, .h2 { font-size: @font-size-h2; }\nh3, .h3 { font-size: @font-size-h3; }\nh4, .h4 { font-size: @font-size-h4; }\nh5, .h5 { font-size: @font-size-h5; }\nh6, .h6 { font-size: @font-size-h6; }\n\n\n// Body text\n// -------------------------\n\np {\n margin: 0 0 (@line-height-computed / 2);\n}\n\n.lead {\n margin-bottom: @line-height-computed;\n font-size: floor((@font-size-base * 1.15));\n font-weight: 300;\n line-height: 1.4;\n\n @media (min-width: @screen-sm-min) {\n font-size: (@font-size-base * 1.5);\n }\n}\n\n\n// Emphasis & misc\n// -------------------------\n\n// Ex: (12px small font / 14px base font) * 100% = about 85%\nsmall,\n.small {\n font-size: floor((100% * @font-size-small / @font-size-base));\n}\n\nmark,\n.mark {\n background-color: @state-warning-bg;\n padding: .2em;\n}\n\n// Alignment\n.text-left { text-align: left; }\n.text-right { text-align: right; }\n.text-center { text-align: center; }\n.text-justify { text-align: justify; }\n.text-nowrap { white-space: nowrap; }\n\n// Transformation\n.text-lowercase { text-transform: lowercase; }\n.text-uppercase { text-transform: uppercase; }\n.text-capitalize { text-transform: capitalize; }\n\n// Contextual colors\n.text-muted {\n color: @text-muted;\n}\n.text-primary {\n .text-emphasis-variant(@brand-primary);\n}\n.text-success {\n .text-emphasis-variant(@state-success-text);\n}\n.text-info {\n .text-emphasis-variant(@state-info-text);\n}\n.text-warning {\n .text-emphasis-variant(@state-warning-text);\n}\n.text-danger {\n .text-emphasis-variant(@state-danger-text);\n}\n\n// Contextual backgrounds\n// For now we'll leave these alongside the text classes until v4 when we can\n// safely shift things around (per SemVer rules).\n.bg-primary {\n // Given the contrast here, this is the only class to have its color inverted\n // automatically.\n color: #fff;\n .bg-variant(@brand-primary);\n}\n.bg-success {\n .bg-variant(@state-success-bg);\n}\n.bg-info {\n .bg-variant(@state-info-bg);\n}\n.bg-warning {\n .bg-variant(@state-warning-bg);\n}\n.bg-danger {\n .bg-variant(@state-danger-bg);\n}\n\n\n// Page header\n// -------------------------\n\n.page-header {\n padding-bottom: ((@line-height-computed / 2) - 1);\n margin: (@line-height-computed * 2) 0 @line-height-computed;\n border-bottom: 1px solid @page-header-border-color;\n}\n\n\n// Lists\n// -------------------------\n\n// Unordered and Ordered lists\nul,\nol {\n margin-top: 0;\n margin-bottom: (@line-height-computed / 2);\n ul,\n ol {\n margin-bottom: 0;\n }\n}\n\n// List options\n\n// Unstyled keeps list items block level, just removes default browser padding and list-style\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n\n// Inline turns list items into inline-block\n.list-inline {\n .list-unstyled();\n margin-left: -5px;\n\n > li {\n display: inline-block;\n padding-left: 5px;\n padding-right: 5px;\n }\n}\n\n// Description Lists\ndl {\n margin-top: 0; // Remove browser default\n margin-bottom: @line-height-computed;\n}\ndt,\ndd {\n line-height: @line-height-base;\n}\ndt {\n font-weight: bold;\n}\ndd {\n margin-left: 0; // Undo browser default\n}\n\n// Horizontal description lists\n//\n// Defaults to being stacked without any of the below styles applied, until the\n// grid breakpoint is reached (default of ~768px).\n\n.dl-horizontal {\n dd {\n &:extend(.clearfix all); // Clear the floated `dt` if an empty `dd` is present\n }\n\n @media (min-width: @dl-horizontal-breakpoint) {\n dt {\n float: left;\n width: (@dl-horizontal-offset - 20);\n clear: left;\n text-align: right;\n .text-overflow();\n }\n dd {\n margin-left: @dl-horizontal-offset;\n }\n }\n}\n\n\n// Misc\n// -------------------------\n\n// Abbreviations and acronyms\nabbr[title],\n// Add data-* attribute to help out our tooltip plugin, per https://github.com/twbs/bootstrap/issues/5257\nabbr[data-original-title] {\n cursor: help;\n border-bottom: 1px dotted @abbr-border-color;\n}\n.initialism {\n font-size: 90%;\n .text-uppercase();\n}\n\n// Blockquotes\nblockquote {\n padding: (@line-height-computed / 2) @line-height-computed;\n margin: 0 0 @line-height-computed;\n font-size: @blockquote-font-size;\n border-left: 5px solid @blockquote-border-color;\n\n p,\n ul,\n ol {\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n // Note: Deprecated small and .small as of v3.1.0\n // Context: https://github.com/twbs/bootstrap/issues/11660\n footer,\n small,\n .small {\n display: block;\n font-size: 80%; // back to default font-size\n line-height: @line-height-base;\n color: @blockquote-small-color;\n\n &:before {\n content: '\\2014 \\00A0'; // em dash, nbsp\n }\n }\n}\n\n// Opposite alignment of blockquote\n//\n// Heads up: `blockquote.pull-right` has been deprecated as of v3.1.0.\n.blockquote-reverse,\nblockquote.pull-right {\n padding-right: 15px;\n padding-left: 0;\n border-right: 5px solid @blockquote-border-color;\n border-left: 0;\n text-align: right;\n\n // Account for citation\n footer,\n small,\n .small {\n &:before { content: ''; }\n &:after {\n content: '\\00A0 \\2014'; // nbsp, em dash\n }\n }\n}\n\n// Addresses\naddress {\n margin-bottom: @line-height-computed;\n font-style: normal;\n line-height: @line-height-base;\n}\n","// Typography\n\n.text-emphasis-variant(@color) {\n color: @color;\n a&:hover,\n a&:focus {\n color: darken(@color, 10%);\n }\n}\n","// Contextual backgrounds\n\n.bg-variant(@color) {\n background-color: @color;\n a&:hover,\n a&:focus {\n background-color: darken(@color, 10%);\n }\n}\n","// Text overflow\n// Requires inline-block or block for proper styling\n\n.text-overflow() {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n","//\n// Code (inline and block)\n// --------------------------------------------------\n\n\n// Inline and block code styles\ncode,\nkbd,\npre,\nsamp {\n font-family: @font-family-monospace;\n}\n\n// Inline code\ncode {\n padding: 2px 4px;\n font-size: 90%;\n color: @code-color;\n background-color: @code-bg;\n border-radius: @border-radius-base;\n}\n\n// User input typically entered via keyboard\nkbd {\n padding: 2px 4px;\n font-size: 90%;\n color: @kbd-color;\n background-color: @kbd-bg;\n border-radius: @border-radius-small;\n box-shadow: inset 0 -1px 0 rgba(0,0,0,.25);\n\n kbd {\n padding: 0;\n font-size: 100%;\n font-weight: bold;\n box-shadow: none;\n }\n}\n\n// Blocks of code\npre {\n display: block;\n padding: ((@line-height-computed - 1) / 2);\n margin: 0 0 (@line-height-computed / 2);\n font-size: (@font-size-base - 1); // 14px to 13px\n line-height: @line-height-base;\n word-break: break-all;\n word-wrap: break-word;\n color: @pre-color;\n background-color: @pre-bg;\n border: 1px solid @pre-border-color;\n border-radius: @border-radius-base;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n padding: 0;\n font-size: inherit;\n color: inherit;\n white-space: pre-wrap;\n background-color: transparent;\n border-radius: 0;\n }\n}\n\n// Enable scrollable blocks of code\n.pre-scrollable {\n max-height: @pre-scrollable-max-height;\n overflow-y: scroll;\n}\n","//\n// Grid system\n// --------------------------------------------------\n\n\n// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n.container {\n .container-fixed();\n\n @media (min-width: @screen-sm-min) {\n width: @container-sm;\n }\n @media (min-width: @screen-md-min) {\n width: @container-md;\n }\n @media (min-width: @screen-lg-min) {\n width: @container-lg;\n }\n}\n\n\n// Fluid container\n//\n// Utilizes the mixin meant for fixed width containers, but without any defined\n// width for fluid, full width layouts.\n\n.container-fluid {\n .container-fixed();\n}\n\n\n// Row\n//\n// Rows contain and clear the floats of your columns.\n\n.row {\n .make-row();\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n.make-grid-columns();\n\n\n// Extra small grid\n//\n// Columns, offsets, pushes, and pulls for extra small devices like\n// smartphones.\n\n.make-grid(xs);\n\n\n// Small grid\n//\n// Columns, offsets, pushes, and pulls for the small device range, from phones\n// to tablets.\n\n@media (min-width: @screen-sm-min) {\n .make-grid(sm);\n}\n\n\n// Medium grid\n//\n// Columns, offsets, pushes, and pulls for the desktop device range.\n\n@media (min-width: @screen-md-min) {\n .make-grid(md);\n}\n\n\n// Large grid\n//\n// Columns, offsets, pushes, and pulls for the large desktop device range.\n\n@media (min-width: @screen-lg-min) {\n .make-grid(lg);\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n// Centered container element\n.container-fixed(@gutter: @grid-gutter-width) {\n margin-right: auto;\n margin-left: auto;\n padding-left: floor((@gutter / 2));\n padding-right: ceil((@gutter / 2));\n &:extend(.clearfix all);\n}\n\n// Creates a wrapper for a series of columns\n.make-row(@gutter: @grid-gutter-width) {\n margin-left: ceil((@gutter / -2));\n margin-right: floor((@gutter / -2));\n &:extend(.clearfix all);\n}\n\n// Generate the extra small columns\n.make-xs-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n float: left;\n width: percentage((@columns / @grid-columns));\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n}\n.make-xs-column-offset(@columns) {\n margin-left: percentage((@columns / @grid-columns));\n}\n.make-xs-column-push(@columns) {\n left: percentage((@columns / @grid-columns));\n}\n.make-xs-column-pull(@columns) {\n right: percentage((@columns / @grid-columns));\n}\n\n// Generate the small columns\n.make-sm-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-sm-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-offset(@columns) {\n @media (min-width: @screen-sm-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-push(@columns) {\n @media (min-width: @screen-sm-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-pull(@columns) {\n @media (min-width: @screen-sm-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n\n// Generate the medium columns\n.make-md-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-md-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-offset(@columns) {\n @media (min-width: @screen-md-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-push(@columns) {\n @media (min-width: @screen-md-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-pull(@columns) {\n @media (min-width: @screen-md-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n\n// Generate the large columns\n.make-lg-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-lg-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-offset(@columns) {\n @media (min-width: @screen-lg-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-push(@columns) {\n @media (min-width: @screen-lg-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-pull(@columns) {\n @media (min-width: @screen-lg-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n","// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `@grid-columns`.\n\n.make-grid-columns() {\n // Common styles for all sizes of grid columns, widths 1-12\n .col(@index) { // initial\n @item: ~\".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}\";\n .col((@index + 1), @item);\n }\n .col(@index, @list) when (@index =< @grid-columns) { // general; \"=<\" isn't a typo\n @item: ~\".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}\";\n .col((@index + 1), ~\"@{list}, @{item}\");\n }\n .col(@index, @list) when (@index > @grid-columns) { // terminal\n @{list} {\n position: relative;\n // Prevent columns from collapsing when empty\n min-height: 1px;\n // Inner gutter via padding\n padding-left: ceil((@grid-gutter-width / 2));\n padding-right: floor((@grid-gutter-width / 2));\n }\n }\n .col(1); // kickstart it\n}\n\n.float-grid-columns(@class) {\n .col(@index) { // initial\n @item: ~\".col-@{class}-@{index}\";\n .col((@index + 1), @item);\n }\n .col(@index, @list) when (@index =< @grid-columns) { // general\n @item: ~\".col-@{class}-@{index}\";\n .col((@index + 1), ~\"@{list}, @{item}\");\n }\n .col(@index, @list) when (@index > @grid-columns) { // terminal\n @{list} {\n float: left;\n }\n }\n .col(1); // kickstart it\n}\n\n.calc-grid-column(@index, @class, @type) when (@type = width) and (@index > 0) {\n .col-@{class}-@{index} {\n width: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = push) and (@index > 0) {\n .col-@{class}-push-@{index} {\n left: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = push) and (@index = 0) {\n .col-@{class}-push-0 {\n left: auto;\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = pull) and (@index > 0) {\n .col-@{class}-pull-@{index} {\n right: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = pull) and (@index = 0) {\n .col-@{class}-pull-0 {\n right: auto;\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = offset) {\n .col-@{class}-offset-@{index} {\n margin-left: percentage((@index / @grid-columns));\n }\n}\n\n// Basic looping in LESS\n.loop-grid-columns(@index, @class, @type) when (@index >= 0) {\n .calc-grid-column(@index, @class, @type);\n // next iteration\n .loop-grid-columns((@index - 1), @class, @type);\n}\n\n// Create grid for specific class\n.make-grid(@class) {\n .float-grid-columns(@class);\n .loop-grid-columns(@grid-columns, @class, width);\n .loop-grid-columns(@grid-columns, @class, pull);\n .loop-grid-columns(@grid-columns, @class, push);\n .loop-grid-columns(@grid-columns, @class, offset);\n}\n","//\n// Tables\n// --------------------------------------------------\n\n\ntable {\n background-color: @table-bg;\n}\ncaption {\n padding-top: @table-cell-padding;\n padding-bottom: @table-cell-padding;\n color: @text-muted;\n text-align: left;\n}\nth {\n text-align: left;\n}\n\n\n// Baseline styles\n\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: @line-height-computed;\n // Cells\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n padding: @table-cell-padding;\n line-height: @line-height-base;\n vertical-align: top;\n border-top: 1px solid @table-border-color;\n }\n }\n }\n // Bottom align for column headings\n > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid @table-border-color;\n }\n // Remove top border from thead by default\n > caption + thead,\n > colgroup + thead,\n > thead:first-child {\n > tr:first-child {\n > th,\n > td {\n border-top: 0;\n }\n }\n }\n // Account for multiple tbody instances\n > tbody + tbody {\n border-top: 2px solid @table-border-color;\n }\n\n // Nesting\n .table {\n background-color: @body-bg;\n }\n}\n\n\n// Condensed table w/ half padding\n\n.table-condensed {\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n padding: @table-condensed-cell-padding;\n }\n }\n }\n}\n\n\n// Bordered version\n//\n// Add borders all around the table and between all the columns.\n\n.table-bordered {\n border: 1px solid @table-border-color;\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n border: 1px solid @table-border-color;\n }\n }\n }\n > thead > tr {\n > th,\n > td {\n border-bottom-width: 2px;\n }\n }\n}\n\n\n// Zebra-striping\n//\n// Default zebra-stripe styles (alternating gray and transparent backgrounds)\n\n.table-striped {\n > tbody > tr:nth-of-type(odd) {\n background-color: @table-bg-accent;\n }\n}\n\n\n// Hover effect\n//\n// Placed here since it has to come after the potential zebra striping\n\n.table-hover {\n > tbody > tr:hover {\n background-color: @table-bg-hover;\n }\n}\n\n\n// Table cell sizing\n//\n// Reset default table behavior\n\ntable col[class*=\"col-\"] {\n position: static; // Prevent border hiding in Firefox and IE9-11 (see https://github.com/twbs/bootstrap/issues/11623)\n float: none;\n display: table-column;\n}\ntable {\n td,\n th {\n &[class*=\"col-\"] {\n position: static; // Prevent border hiding in Firefox and IE9-11 (see https://github.com/twbs/bootstrap/issues/11623)\n float: none;\n display: table-cell;\n }\n }\n}\n\n\n// Table backgrounds\n//\n// Exact selectors below required to override `.table-striped` and prevent\n// inheritance to nested tables.\n\n// Generate the contextual variants\n.table-row-variant(active; @table-bg-active);\n.table-row-variant(success; @state-success-bg);\n.table-row-variant(info; @state-info-bg);\n.table-row-variant(warning; @state-warning-bg);\n.table-row-variant(danger; @state-danger-bg);\n\n\n// Responsive tables\n//\n// Wrap your tables in `.table-responsive` and we'll make them mobile friendly\n// by enabling horizontal scrolling. Only applies <768px. Everything above that\n// will display normally.\n\n.table-responsive {\n overflow-x: auto;\n min-height: 0.01%; // Workaround for IE9 bug (see https://github.com/twbs/bootstrap/issues/14837)\n\n @media screen and (max-width: @screen-xs-max) {\n width: 100%;\n margin-bottom: (@line-height-computed * 0.75);\n overflow-y: hidden;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n border: 1px solid @table-border-color;\n\n // Tighten up spacing\n > .table {\n margin-bottom: 0;\n\n // Ensure the content doesn't wrap\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n white-space: nowrap;\n }\n }\n }\n }\n\n // Special overrides for the bordered tables\n > .table-bordered {\n border: 0;\n\n // Nuke the appropriate borders so that the parent can handle them\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th:first-child,\n > td:first-child {\n border-left: 0;\n }\n > th:last-child,\n > td:last-child {\n border-right: 0;\n }\n }\n }\n\n // Only nuke the last row's bottom-border in `tbody` and `tfoot` since\n // chances are there will be only one `tr` in a `thead` and that would\n // remove the border altogether.\n > tbody,\n > tfoot {\n > tr:last-child {\n > th,\n > td {\n border-bottom: 0;\n }\n }\n }\n\n }\n }\n}\n","// Tables\n\n.table-row-variant(@state; @background) {\n // Exact selectors below required to override `.table-striped` and prevent\n // inheritance to nested tables.\n .table > thead > tr,\n .table > tbody > tr,\n .table > tfoot > tr {\n > td.@{state},\n > th.@{state},\n &.@{state} > td,\n &.@{state} > th {\n background-color: @background;\n }\n }\n\n // Hover states for `.table-hover`\n // Note: this is not available for cells or rows within `thead` or `tfoot`.\n .table-hover > tbody > tr {\n > td.@{state}:hover,\n > th.@{state}:hover,\n &.@{state}:hover > td,\n &:hover > .@{state},\n &.@{state}:hover > th {\n background-color: darken(@background, 5%);\n }\n }\n}\n","//\n// Forms\n// --------------------------------------------------\n\n\n// Normalize non-controls\n//\n// Restyle and baseline non-control form elements.\n\nfieldset {\n padding: 0;\n margin: 0;\n border: 0;\n // Chrome and Firefox set a `min-width: min-content;` on fieldsets,\n // so we reset that to ensure it behaves more like a standard block element.\n // See https://github.com/twbs/bootstrap/issues/12359.\n min-width: 0;\n}\n\nlegend {\n display: block;\n width: 100%;\n padding: 0;\n margin-bottom: @line-height-computed;\n font-size: (@font-size-base * 1.5);\n line-height: inherit;\n color: @legend-color;\n border: 0;\n border-bottom: 1px solid @legend-border-color;\n}\n\nlabel {\n display: inline-block;\n max-width: 100%; // Force IE8 to wrap long content (see https://github.com/twbs/bootstrap/issues/13141)\n margin-bottom: 5px;\n font-weight: bold;\n}\n\n\n// Normalize form controls\n//\n// While most of our form styles require extra classes, some basic normalization\n// is required to ensure optimum display with or without those classes to better\n// address browser inconsistencies.\n\n// Override content-box in Normalize (* isn't specific enough)\ninput[type=\"search\"] {\n .box-sizing(border-box);\n}\n\n// Position radios and checkboxes better\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n margin: 4px 0 0;\n margin-top: 1px \\9; // IE8-9\n line-height: normal;\n}\n\ninput[type=\"file\"] {\n display: block;\n}\n\n// Make range inputs behave like textual form controls\ninput[type=\"range\"] {\n display: block;\n width: 100%;\n}\n\n// Make multiple select elements height not fixed\nselect[multiple],\nselect[size] {\n height: auto;\n}\n\n// Focus for file, radio, and checkbox\ninput[type=\"file\"]:focus,\ninput[type=\"radio\"]:focus,\ninput[type=\"checkbox\"]:focus {\n .tab-focus();\n}\n\n// Adjust output element\noutput {\n display: block;\n padding-top: (@padding-base-vertical + 1);\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @input-color;\n}\n\n\n// Common form controls\n//\n// Shared size and type resets for form controls. Apply `.form-control` to any\n// of the following form controls:\n//\n// select\n// textarea\n// input[type=\"text\"]\n// input[type=\"password\"]\n// input[type=\"datetime\"]\n// input[type=\"datetime-local\"]\n// input[type=\"date\"]\n// input[type=\"month\"]\n// input[type=\"time\"]\n// input[type=\"week\"]\n// input[type=\"number\"]\n// input[type=\"email\"]\n// input[type=\"url\"]\n// input[type=\"search\"]\n// input[type=\"tel\"]\n// input[type=\"color\"]\n\n.form-control {\n display: block;\n width: 100%;\n height: @input-height-base; // Make inputs at least the height of their button counterpart (base line-height + padding + border)\n padding: @padding-base-vertical @padding-base-horizontal;\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @input-color;\n background-color: @input-bg;\n background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n border: 1px solid @input-border;\n border-radius: @input-border-radius; // Note: This has no effect on s in CSS.\n .box-shadow(inset 0 1px 1px rgba(0,0,0,.075));\n .transition(~\"border-color ease-in-out .15s, box-shadow ease-in-out .15s\");\n\n // Customize the `:focus` state to imitate native WebKit styles.\n .form-control-focus();\n\n // Placeholder\n .placeholder();\n\n // Unstyle the caret on ``\n// element gets special love because it's special, and that's a fact!\n.input-size(@input-height; @padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) {\n height: @input-height;\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n line-height: @line-height;\n border-radius: @border-radius;\n\n select& {\n height: @input-height;\n line-height: @input-height;\n }\n\n textarea&,\n select[multiple]& {\n height: auto;\n }\n}\n","//\n// Buttons\n// --------------------------------------------------\n\n\n// Base styles\n// --------------------------------------------------\n\n.btn {\n display: inline-block;\n margin-bottom: 0; // For input.btn\n font-weight: @btn-font-weight;\n text-align: center;\n vertical-align: middle;\n touch-action: manipulation;\n cursor: pointer;\n background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n border: 1px solid transparent;\n white-space: nowrap;\n .button-size(@padding-base-vertical; @padding-base-horizontal; @font-size-base; @line-height-base; @btn-border-radius-base);\n .user-select(none);\n\n &,\n &:active,\n &.active {\n &:focus,\n &.focus {\n .tab-focus();\n }\n }\n\n &:hover,\n &:focus,\n &.focus {\n color: @btn-default-color;\n text-decoration: none;\n }\n\n &:active,\n &.active {\n outline: 0;\n background-image: none;\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n }\n\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n cursor: @cursor-disabled;\n .opacity(.65);\n .box-shadow(none);\n }\n\n a& {\n &.disabled,\n fieldset[disabled] & {\n pointer-events: none; // Future-proof disabling of clicks on `` elements\n }\n }\n}\n\n\n// Alternate buttons\n// --------------------------------------------------\n\n.btn-default {\n .button-variant(@btn-default-color; @btn-default-bg; @btn-default-border);\n}\n.btn-primary {\n .button-variant(@btn-primary-color; @btn-primary-bg; @btn-primary-border);\n}\n// Success appears as green\n.btn-success {\n .button-variant(@btn-success-color; @btn-success-bg; @btn-success-border);\n}\n// Info appears as blue-green\n.btn-info {\n .button-variant(@btn-info-color; @btn-info-bg; @btn-info-border);\n}\n// Warning appears as orange\n.btn-warning {\n .button-variant(@btn-warning-color; @btn-warning-bg; @btn-warning-border);\n}\n// Danger and error appear as red\n.btn-danger {\n .button-variant(@btn-danger-color; @btn-danger-bg; @btn-danger-border);\n}\n\n\n// Link buttons\n// -------------------------\n\n// Make a button look and behave like a link\n.btn-link {\n color: @link-color;\n font-weight: normal;\n border-radius: 0;\n\n &,\n &:active,\n &.active,\n &[disabled],\n fieldset[disabled] & {\n background-color: transparent;\n .box-shadow(none);\n }\n &,\n &:hover,\n &:focus,\n &:active {\n border-color: transparent;\n }\n &:hover,\n &:focus {\n color: @link-hover-color;\n text-decoration: @link-hover-decoration;\n background-color: transparent;\n }\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus {\n color: @btn-link-disabled-color;\n text-decoration: none;\n }\n }\n}\n\n\n// Button Sizes\n// --------------------------------------------------\n\n.btn-lg {\n // line-height: ensure even-numbered height of button next to large input\n .button-size(@padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @btn-border-radius-large);\n}\n.btn-sm {\n // line-height: ensure proper height of button next to small input\n .button-size(@padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @btn-border-radius-small);\n}\n.btn-xs {\n .button-size(@padding-xs-vertical; @padding-xs-horizontal; @font-size-small; @line-height-small; @btn-border-radius-small);\n}\n\n\n// Block button\n// --------------------------------------------------\n\n.btn-block {\n display: block;\n width: 100%;\n}\n\n// Vertically space out multiple block buttons\n.btn-block + .btn-block {\n margin-top: 5px;\n}\n\n// Specificity overrides\ninput[type=\"submit\"],\ninput[type=\"reset\"],\ninput[type=\"button\"] {\n &.btn-block {\n width: 100%;\n }\n}\n","// Button variants\n//\n// Easily pump out default styles, as well as :hover, :focus, :active,\n// and disabled options for all buttons\n\n.button-variant(@color; @background; @border) {\n color: @color;\n background-color: @background;\n border-color: @border;\n\n &:focus,\n &.focus {\n color: @color;\n background-color: darken(@background, 10%);\n border-color: darken(@border, 25%);\n }\n &:hover {\n color: @color;\n background-color: darken(@background, 10%);\n border-color: darken(@border, 12%);\n }\n &:active,\n &.active,\n .open > .dropdown-toggle& {\n color: @color;\n background-color: darken(@background, 10%);\n border-color: darken(@border, 12%);\n\n &:hover,\n &:focus,\n &.focus {\n color: @color;\n background-color: darken(@background, 17%);\n border-color: darken(@border, 25%);\n }\n }\n &:active,\n &.active,\n .open > .dropdown-toggle& {\n background-image: none;\n }\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus,\n &.focus {\n background-color: @background;\n border-color: @border;\n }\n }\n\n .badge {\n color: @background;\n background-color: @color;\n }\n}\n\n// Button sizes\n.button-size(@padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) {\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n line-height: @line-height;\n border-radius: @border-radius;\n}\n","// Opacity\n\n.opacity(@opacity) {\n opacity: @opacity;\n // IE8 filter\n @opacity-ie: (@opacity * 100);\n filter: ~\"alpha(opacity=@{opacity-ie})\";\n}\n","//\n// Component animations\n// --------------------------------------------------\n\n// Heads up!\n//\n// We don't use the `.opacity()` mixin here since it causes a bug with text\n// fields in IE7-8. Source: https://github.com/twbs/bootstrap/pull/3552.\n\n.fade {\n opacity: 0;\n .transition(opacity .15s linear);\n &.in {\n opacity: 1;\n }\n}\n\n.collapse {\n display: none;\n\n &.in { display: block; }\n tr&.in { display: table-row; }\n tbody&.in { display: table-row-group; }\n}\n\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n .transition-property(~\"height, visibility\");\n .transition-duration(.35s);\n .transition-timing-function(ease);\n}\n","//\n// Dropdown menus\n// --------------------------------------------------\n\n\n// Dropdown arrow/caret\n.caret {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 2px;\n vertical-align: middle;\n border-top: @caret-width-base dashed;\n border-top: @caret-width-base solid ~\"\\9\"; // IE8\n border-right: @caret-width-base solid transparent;\n border-left: @caret-width-base solid transparent;\n}\n\n// The dropdown wrapper (div)\n.dropup,\n.dropdown {\n position: relative;\n}\n\n// Prevent the focus on the dropdown toggle when closing dropdowns\n.dropdown-toggle:focus {\n outline: 0;\n}\n\n// The dropdown menu (ul)\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: @zindex-dropdown;\n display: none; // none by default, but block on \"open\" of the menu\n float: left;\n min-width: 160px;\n padding: 5px 0;\n margin: 2px 0 0; // override default ul\n list-style: none;\n font-size: @font-size-base;\n text-align: left; // Ensures proper alignment if parent has it changed (e.g., modal footer)\n background-color: @dropdown-bg;\n border: 1px solid @dropdown-fallback-border; // IE8 fallback\n border: 1px solid @dropdown-border;\n border-radius: @border-radius-base;\n .box-shadow(0 6px 12px rgba(0,0,0,.175));\n background-clip: padding-box;\n\n // Aligns the dropdown menu to right\n //\n // Deprecated as of 3.1.0 in favor of `.dropdown-menu-[dir]`\n &.pull-right {\n right: 0;\n left: auto;\n }\n\n // Dividers (basically an hr) within the dropdown\n .divider {\n .nav-divider(@dropdown-divider-bg);\n }\n\n // Links within the dropdown menu\n > li > a {\n display: block;\n padding: 3px 20px;\n clear: both;\n font-weight: normal;\n line-height: @line-height-base;\n color: @dropdown-link-color;\n white-space: nowrap; // prevent links from randomly breaking onto new lines\n }\n}\n\n// Hover/Focus state\n.dropdown-menu > li > a {\n &:hover,\n &:focus {\n text-decoration: none;\n color: @dropdown-link-hover-color;\n background-color: @dropdown-link-hover-bg;\n }\n}\n\n// Active state\n.dropdown-menu > .active > a {\n &,\n &:hover,\n &:focus {\n color: @dropdown-link-active-color;\n text-decoration: none;\n outline: 0;\n background-color: @dropdown-link-active-bg;\n }\n}\n\n// Disabled state\n//\n// Gray out text and ensure the hover/focus state remains gray\n\n.dropdown-menu > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @dropdown-link-disabled-color;\n }\n\n // Nuke hover/focus effects\n &:hover,\n &:focus {\n text-decoration: none;\n background-color: transparent;\n background-image: none; // Remove CSS gradient\n .reset-filter();\n cursor: @cursor-disabled;\n }\n}\n\n// Open state for the dropdown\n.open {\n // Show the menu\n > .dropdown-menu {\n display: block;\n }\n\n // Remove the outline when :focus is triggered\n > a {\n outline: 0;\n }\n}\n\n// Menu positioning\n//\n// Add extra class to `.dropdown-menu` to flip the alignment of the dropdown\n// menu with the parent.\n.dropdown-menu-right {\n left: auto; // Reset the default from `.dropdown-menu`\n right: 0;\n}\n// With v3, we enabled auto-flipping if you have a dropdown within a right\n// aligned nav component. To enable the undoing of that, we provide an override\n// to restore the default dropdown menu alignment.\n//\n// This is only for left-aligning a dropdown menu within a `.navbar-right` or\n// `.pull-right` nav component.\n.dropdown-menu-left {\n left: 0;\n right: auto;\n}\n\n// Dropdown section headers\n.dropdown-header {\n display: block;\n padding: 3px 20px;\n font-size: @font-size-small;\n line-height: @line-height-base;\n color: @dropdown-header-color;\n white-space: nowrap; // as with > li > a\n}\n\n// Backdrop to catch body clicks on mobile, etc.\n.dropdown-backdrop {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n top: 0;\n z-index: (@zindex-dropdown - 10);\n}\n\n// Right aligned dropdowns\n.pull-right > .dropdown-menu {\n right: 0;\n left: auto;\n}\n\n// Allow for dropdowns to go bottom up (aka, dropup-menu)\n//\n// Just add .dropup after the standard .dropdown class and you're set, bro.\n// TODO: abstract this so that the navbar fixed styles are not placed here?\n\n.dropup,\n.navbar-fixed-bottom .dropdown {\n // Reverse the caret\n .caret {\n border-top: 0;\n border-bottom: @caret-width-base dashed;\n border-bottom: @caret-width-base solid ~\"\\9\"; // IE8\n content: \"\";\n }\n // Different positioning for bottom up menu\n .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-bottom: 2px;\n }\n}\n\n\n// Component alignment\n//\n// Reiterate per navbar.less and the modified component alignment there.\n\n@media (min-width: @grid-float-breakpoint) {\n .navbar-right {\n .dropdown-menu {\n .dropdown-menu-right();\n }\n // Necessary for overrides of the default right aligned menu.\n // Will remove come v4 in all likelihood.\n .dropdown-menu-left {\n .dropdown-menu-left();\n }\n }\n}\n","// Horizontal dividers\n//\n// Dividers (basically an hr) within dropdowns and nav lists\n\n.nav-divider(@color: #e5e5e5) {\n height: 1px;\n margin: ((@line-height-computed / 2) - 1) 0;\n overflow: hidden;\n background-color: @color;\n}\n","// Reset filters for IE\n//\n// When you need to remove a gradient background, do not forget to use this to reset\n// the IE filter for IE9 and below.\n\n.reset-filter() {\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(enabled = false)\"));\n}\n","//\n// Button groups\n// --------------------------------------------------\n\n// Make the div behave like a button\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-block;\n vertical-align: middle; // match .btn alignment given font-size hack above\n > .btn {\n position: relative;\n float: left;\n // Bring the \"active\" button to the front\n &:hover,\n &:focus,\n &:active,\n &.active {\n z-index: 2;\n }\n }\n}\n\n// Prevent double borders when buttons are next to each other\n.btn-group {\n .btn + .btn,\n .btn + .btn-group,\n .btn-group + .btn,\n .btn-group + .btn-group {\n margin-left: -1px;\n }\n}\n\n// Optional: Group multiple button groups together for a toolbar\n.btn-toolbar {\n margin-left: -5px; // Offset the first child's margin\n &:extend(.clearfix all);\n\n .btn,\n .btn-group,\n .input-group {\n float: left;\n }\n > .btn,\n > .btn-group,\n > .input-group {\n margin-left: 5px;\n }\n}\n\n.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {\n border-radius: 0;\n}\n\n// Set corners individual because sometimes a single button can be in a .btn-group and we need :first-child and :last-child to both match\n.btn-group > .btn:first-child {\n margin-left: 0;\n &:not(:last-child):not(.dropdown-toggle) {\n .border-right-radius(0);\n }\n}\n// Need .dropdown-toggle since :last-child doesn't apply, given that a .dropdown-menu is used immediately after it\n.btn-group > .btn:last-child:not(:first-child),\n.btn-group > .dropdown-toggle:not(:first-child) {\n .border-left-radius(0);\n}\n\n// Custom edits for including btn-groups within btn-groups (useful for including dropdown buttons within a btn-group)\n.btn-group > .btn-group {\n float: left;\n}\n.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group > .btn-group:first-child:not(:last-child) {\n > .btn:last-child,\n > .dropdown-toggle {\n .border-right-radius(0);\n }\n}\n.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child {\n .border-left-radius(0);\n}\n\n// On active and open, don't show outline\n.btn-group .dropdown-toggle:active,\n.btn-group.open .dropdown-toggle {\n outline: 0;\n}\n\n\n// Sizing\n//\n// Remix the default button sizing classes into new ones for easier manipulation.\n\n.btn-group-xs > .btn { &:extend(.btn-xs); }\n.btn-group-sm > .btn { &:extend(.btn-sm); }\n.btn-group-lg > .btn { &:extend(.btn-lg); }\n\n\n// Split button dropdowns\n// ----------------------\n\n// Give the line between buttons some depth\n.btn-group > .btn + .dropdown-toggle {\n padding-left: 8px;\n padding-right: 8px;\n}\n.btn-group > .btn-lg + .dropdown-toggle {\n padding-left: 12px;\n padding-right: 12px;\n}\n\n// The clickable button for toggling the menu\n// Remove the gradient and set the same inset shadow as the :active state\n.btn-group.open .dropdown-toggle {\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n\n // Show no shadow for `.btn-link` since it has no other button styles.\n &.btn-link {\n .box-shadow(none);\n }\n}\n\n\n// Reposition the caret\n.btn .caret {\n margin-left: 0;\n}\n// Carets in other button sizes\n.btn-lg .caret {\n border-width: @caret-width-large @caret-width-large 0;\n border-bottom-width: 0;\n}\n// Upside down carets for .dropup\n.dropup .btn-lg .caret {\n border-width: 0 @caret-width-large @caret-width-large;\n}\n\n\n// Vertical button groups\n// ----------------------\n\n.btn-group-vertical {\n > .btn,\n > .btn-group,\n > .btn-group > .btn {\n display: block;\n float: none;\n width: 100%;\n max-width: 100%;\n }\n\n // Clear floats so dropdown menus can be properly placed\n > .btn-group {\n &:extend(.clearfix all);\n > .btn {\n float: none;\n }\n }\n\n > .btn + .btn,\n > .btn + .btn-group,\n > .btn-group + .btn,\n > .btn-group + .btn-group {\n margin-top: -1px;\n margin-left: 0;\n }\n}\n\n.btn-group-vertical > .btn {\n &:not(:first-child):not(:last-child) {\n border-radius: 0;\n }\n &:first-child:not(:last-child) {\n .border-top-radius(@btn-border-radius-base);\n .border-bottom-radius(0);\n }\n &:last-child:not(:first-child) {\n .border-top-radius(0);\n .border-bottom-radius(@btn-border-radius-base);\n }\n}\n.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group-vertical > .btn-group:first-child:not(:last-child) {\n > .btn:last-child,\n > .dropdown-toggle {\n .border-bottom-radius(0);\n }\n}\n.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child {\n .border-top-radius(0);\n}\n\n\n// Justified button groups\n// ----------------------\n\n.btn-group-justified {\n display: table;\n width: 100%;\n table-layout: fixed;\n border-collapse: separate;\n > .btn,\n > .btn-group {\n float: none;\n display: table-cell;\n width: 1%;\n }\n > .btn-group .btn {\n width: 100%;\n }\n\n > .btn-group .dropdown-menu {\n left: auto;\n }\n}\n\n\n// Checkbox and radio options\n//\n// In order to support the browser's form validation feedback, powered by the\n// `required` attribute, we have to \"hide\" the inputs via `clip`. We cannot use\n// `display: none;` or `visibility: hidden;` as that also hides the popover.\n// Simply visually hiding the inputs via `opacity` would leave them clickable in\n// certain cases which is prevented by using `clip` and `pointer-events`.\n// This way, we ensure a DOM element is visible to position the popover from.\n//\n// See https://github.com/twbs/bootstrap/pull/12794 and\n// https://github.com/twbs/bootstrap/pull/14559 for more information.\n\n[data-toggle=\"buttons\"] {\n > .btn,\n > .btn-group > .btn {\n input[type=\"radio\"],\n input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0,0,0,0);\n pointer-events: none;\n }\n }\n}\n","// Single side border-radius\n\n.border-top-radius(@radius) {\n border-top-right-radius: @radius;\n border-top-left-radius: @radius;\n}\n.border-right-radius(@radius) {\n border-bottom-right-radius: @radius;\n border-top-right-radius: @radius;\n}\n.border-bottom-radius(@radius) {\n border-bottom-right-radius: @radius;\n border-bottom-left-radius: @radius;\n}\n.border-left-radius(@radius) {\n border-bottom-left-radius: @radius;\n border-top-left-radius: @radius;\n}\n","//\n// Input groups\n// --------------------------------------------------\n\n// Base styles\n// -------------------------\n.input-group {\n position: relative; // For dropdowns\n display: table;\n border-collapse: separate; // prevent input groups from inheriting border styles from table cells when placed within a table\n\n // Undo padding and float of grid classes\n &[class*=\"col-\"] {\n float: none;\n padding-left: 0;\n padding-right: 0;\n }\n\n .form-control {\n // Ensure that the input is always above the *appended* addon button for\n // proper border colors.\n position: relative;\n z-index: 2;\n\n // IE9 fubars the placeholder attribute in text inputs and the arrows on\n // select elements in input groups. To fix it, we float the input. Details:\n // https://github.com/twbs/bootstrap/issues/11561#issuecomment-28936855\n float: left;\n\n width: 100%;\n margin-bottom: 0;\n\n &:focus {\n z-index: 3;\n }\n }\n}\n\n// Sizing options\n//\n// Remix the default form control sizing classes into new ones for easier\n// manipulation.\n\n.input-group-lg > .form-control,\n.input-group-lg > .input-group-addon,\n.input-group-lg > .input-group-btn > .btn {\n .input-lg();\n}\n.input-group-sm > .form-control,\n.input-group-sm > .input-group-addon,\n.input-group-sm > .input-group-btn > .btn {\n .input-sm();\n}\n\n\n// Display as table-cell\n// -------------------------\n.input-group-addon,\n.input-group-btn,\n.input-group .form-control {\n display: table-cell;\n\n &:not(:first-child):not(:last-child) {\n border-radius: 0;\n }\n}\n// Addon and addon wrapper for buttons\n.input-group-addon,\n.input-group-btn {\n width: 1%;\n white-space: nowrap;\n vertical-align: middle; // Match the inputs\n}\n\n// Text input groups\n// -------------------------\n.input-group-addon {\n padding: @padding-base-vertical @padding-base-horizontal;\n font-size: @font-size-base;\n font-weight: normal;\n line-height: 1;\n color: @input-color;\n text-align: center;\n background-color: @input-group-addon-bg;\n border: 1px solid @input-group-addon-border-color;\n border-radius: @input-border-radius;\n\n // Sizing\n &.input-sm {\n padding: @padding-small-vertical @padding-small-horizontal;\n font-size: @font-size-small;\n border-radius: @input-border-radius-small;\n }\n &.input-lg {\n padding: @padding-large-vertical @padding-large-horizontal;\n font-size: @font-size-large;\n border-radius: @input-border-radius-large;\n }\n\n // Nuke default margins from checkboxes and radios to vertically center within.\n input[type=\"radio\"],\n input[type=\"checkbox\"] {\n margin-top: 0;\n }\n}\n\n// Reset rounded corners\n.input-group .form-control:first-child,\n.input-group-addon:first-child,\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group > .btn,\n.input-group-btn:first-child > .dropdown-toggle,\n.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group-btn:last-child > .btn-group:not(:last-child) > .btn {\n .border-right-radius(0);\n}\n.input-group-addon:first-child {\n border-right: 0;\n}\n.input-group .form-control:last-child,\n.input-group-addon:last-child,\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group > .btn,\n.input-group-btn:last-child > .dropdown-toggle,\n.input-group-btn:first-child > .btn:not(:first-child),\n.input-group-btn:first-child > .btn-group:not(:first-child) > .btn {\n .border-left-radius(0);\n}\n.input-group-addon:last-child {\n border-left: 0;\n}\n\n// Button input groups\n// -------------------------\n.input-group-btn {\n position: relative;\n // Jankily prevent input button groups from wrapping with `white-space` and\n // `font-size` in combination with `inline-block` on buttons.\n font-size: 0;\n white-space: nowrap;\n\n // Negative margin for spacing, position for bringing hovered/focused/actived\n // element above the siblings.\n > .btn {\n position: relative;\n + .btn {\n margin-left: -1px;\n }\n // Bring the \"active\" button to the front\n &:hover,\n &:focus,\n &:active {\n z-index: 2;\n }\n }\n\n // Negative margin to only have a 1px border between the two\n &:first-child {\n > .btn,\n > .btn-group {\n margin-right: -1px;\n }\n }\n &:last-child {\n > .btn,\n > .btn-group {\n z-index: 2;\n margin-left: -1px;\n }\n }\n}\n","//\n// Navs\n// --------------------------------------------------\n\n\n// Base class\n// --------------------------------------------------\n\n.nav {\n margin-bottom: 0;\n padding-left: 0; // Override default ul/ol\n list-style: none;\n &:extend(.clearfix all);\n\n > li {\n position: relative;\n display: block;\n\n > a {\n position: relative;\n display: block;\n padding: @nav-link-padding;\n &:hover,\n &:focus {\n text-decoration: none;\n background-color: @nav-link-hover-bg;\n }\n }\n\n // Disabled state sets text to gray and nukes hover/tab effects\n &.disabled > a {\n color: @nav-disabled-link-color;\n\n &:hover,\n &:focus {\n color: @nav-disabled-link-hover-color;\n text-decoration: none;\n background-color: transparent;\n cursor: @cursor-disabled;\n }\n }\n }\n\n // Open dropdowns\n .open > a {\n &,\n &:hover,\n &:focus {\n background-color: @nav-link-hover-bg;\n border-color: @link-color;\n }\n }\n\n // Nav dividers (deprecated with v3.0.1)\n //\n // This should have been removed in v3 with the dropping of `.nav-list`, but\n // we missed it. We don't currently support this anywhere, but in the interest\n // of maintaining backward compatibility in case you use it, it's deprecated.\n .nav-divider {\n .nav-divider();\n }\n\n // Prevent IE8 from misplacing imgs\n //\n // See https://github.com/h5bp/html5-boilerplate/issues/984#issuecomment-3985989\n > li > a > img {\n max-width: none;\n }\n}\n\n\n// Tabs\n// -------------------------\n\n// Give the tabs something to sit on\n.nav-tabs {\n border-bottom: 1px solid @nav-tabs-border-color;\n > li {\n float: left;\n // Make the list-items overlay the bottom border\n margin-bottom: -1px;\n\n // Actual tabs (as links)\n > a {\n margin-right: 2px;\n line-height: @line-height-base;\n border: 1px solid transparent;\n border-radius: @border-radius-base @border-radius-base 0 0;\n &:hover {\n border-color: @nav-tabs-link-hover-border-color @nav-tabs-link-hover-border-color @nav-tabs-border-color;\n }\n }\n\n // Active state, and its :hover to override normal :hover\n &.active > a {\n &,\n &:hover,\n &:focus {\n color: @nav-tabs-active-link-hover-color;\n background-color: @nav-tabs-active-link-hover-bg;\n border: 1px solid @nav-tabs-active-link-hover-border-color;\n border-bottom-color: transparent;\n cursor: default;\n }\n }\n }\n // pulling this in mainly for less shorthand\n &.nav-justified {\n .nav-justified();\n .nav-tabs-justified();\n }\n}\n\n\n// Pills\n// -------------------------\n.nav-pills {\n > li {\n float: left;\n\n // Links rendered as pills\n > a {\n border-radius: @nav-pills-border-radius;\n }\n + li {\n margin-left: 2px;\n }\n\n // Active state\n &.active > a {\n &,\n &:hover,\n &:focus {\n color: @nav-pills-active-link-hover-color;\n background-color: @nav-pills-active-link-hover-bg;\n }\n }\n }\n}\n\n\n// Stacked pills\n.nav-stacked {\n > li {\n float: none;\n + li {\n margin-top: 2px;\n margin-left: 0; // no need for this gap between nav items\n }\n }\n}\n\n\n// Nav variations\n// --------------------------------------------------\n\n// Justified nav links\n// -------------------------\n\n.nav-justified {\n width: 100%;\n\n > li {\n float: none;\n > a {\n text-align: center;\n margin-bottom: 5px;\n }\n }\n\n > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n }\n\n @media (min-width: @screen-sm-min) {\n > li {\n display: table-cell;\n width: 1%;\n > a {\n margin-bottom: 0;\n }\n }\n }\n}\n\n// Move borders to anchors instead of bottom of list\n//\n// Mixin for adding on top the shared `.nav-justified` styles for our tabs\n.nav-tabs-justified {\n border-bottom: 0;\n\n > li > a {\n // Override margin from .nav-tabs\n margin-right: 0;\n border-radius: @border-radius-base;\n }\n\n > .active > a,\n > .active > a:hover,\n > .active > a:focus {\n border: 1px solid @nav-tabs-justified-link-border-color;\n }\n\n @media (min-width: @screen-sm-min) {\n > li > a {\n border-bottom: 1px solid @nav-tabs-justified-link-border-color;\n border-radius: @border-radius-base @border-radius-base 0 0;\n }\n > .active > a,\n > .active > a:hover,\n > .active > a:focus {\n border-bottom-color: @nav-tabs-justified-active-link-border-color;\n }\n }\n}\n\n\n// Tabbable tabs\n// -------------------------\n\n// Hide tabbable panes to start, show them when `.active`\n.tab-content {\n > .tab-pane {\n display: none;\n }\n > .active {\n display: block;\n }\n}\n\n\n// Dropdowns\n// -------------------------\n\n// Specific dropdowns\n.nav-tabs .dropdown-menu {\n // make dropdown border overlap tab border\n margin-top: -1px;\n // Remove the top rounded corners here since there is a hard edge above the menu\n .border-top-radius(0);\n}\n","//\n// Navbars\n// --------------------------------------------------\n\n\n// Wrapper and base class\n//\n// Provide a static navbar from which we expand to create full-width, fixed, and\n// other navbar variations.\n\n.navbar {\n position: relative;\n min-height: @navbar-height; // Ensure a navbar always shows (e.g., without a .navbar-brand in collapsed mode)\n margin-bottom: @navbar-margin-bottom;\n border: 1px solid transparent;\n\n // Prevent floats from breaking the navbar\n &:extend(.clearfix all);\n\n @media (min-width: @grid-float-breakpoint) {\n border-radius: @navbar-border-radius;\n }\n}\n\n\n// Navbar heading\n//\n// Groups `.navbar-brand` and `.navbar-toggle` into a single component for easy\n// styling of responsive aspects.\n\n.navbar-header {\n &:extend(.clearfix all);\n\n @media (min-width: @grid-float-breakpoint) {\n float: left;\n }\n}\n\n\n// Navbar collapse (body)\n//\n// Group your navbar content into this for easy collapsing and expanding across\n// various device sizes. By default, this content is collapsed when <768px, but\n// will expand past that for a horizontal display.\n//\n// To start (on mobile devices) the navbar links, forms, and buttons are stacked\n// vertically and include a `max-height` to overflow in case you have too much\n// content for the user's viewport.\n\n.navbar-collapse {\n overflow-x: visible;\n padding-right: @navbar-padding-horizontal;\n padding-left: @navbar-padding-horizontal;\n border-top: 1px solid transparent;\n box-shadow: inset 0 1px 0 rgba(255,255,255,.1);\n &:extend(.clearfix all);\n -webkit-overflow-scrolling: touch;\n\n &.in {\n overflow-y: auto;\n }\n\n @media (min-width: @grid-float-breakpoint) {\n width: auto;\n border-top: 0;\n box-shadow: none;\n\n &.collapse {\n display: block !important;\n height: auto !important;\n padding-bottom: 0; // Override default setting\n overflow: visible !important;\n }\n\n &.in {\n overflow-y: visible;\n }\n\n // Undo the collapse side padding for navbars with containers to ensure\n // alignment of right-aligned contents.\n .navbar-fixed-top &,\n .navbar-static-top &,\n .navbar-fixed-bottom & {\n padding-left: 0;\n padding-right: 0;\n }\n }\n}\n\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n .navbar-collapse {\n max-height: @navbar-collapse-max-height;\n\n @media (max-device-width: @screen-xs-min) and (orientation: landscape) {\n max-height: 200px;\n }\n }\n}\n\n\n// Both navbar header and collapse\n//\n// When a container is present, change the behavior of the header and collapse.\n\n.container,\n.container-fluid {\n > .navbar-header,\n > .navbar-collapse {\n margin-right: -@navbar-padding-horizontal;\n margin-left: -@navbar-padding-horizontal;\n\n @media (min-width: @grid-float-breakpoint) {\n margin-right: 0;\n margin-left: 0;\n }\n }\n}\n\n\n//\n// Navbar alignment options\n//\n// Display the navbar across the entirety of the page or fixed it to the top or\n// bottom of the page.\n\n// Static top (unfixed, but 100% wide) navbar\n.navbar-static-top {\n z-index: @zindex-navbar;\n border-width: 0 0 1px;\n\n @media (min-width: @grid-float-breakpoint) {\n border-radius: 0;\n }\n}\n\n// Fix the top/bottom navbars when screen real estate supports it\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n position: fixed;\n right: 0;\n left: 0;\n z-index: @zindex-navbar-fixed;\n\n // Undo the rounded corners\n @media (min-width: @grid-float-breakpoint) {\n border-radius: 0;\n }\n}\n.navbar-fixed-top {\n top: 0;\n border-width: 0 0 1px;\n}\n.navbar-fixed-bottom {\n bottom: 0;\n margin-bottom: 0; // override .navbar defaults\n border-width: 1px 0 0;\n}\n\n\n// Brand/project name\n\n.navbar-brand {\n float: left;\n padding: @navbar-padding-vertical @navbar-padding-horizontal;\n font-size: @font-size-large;\n line-height: @line-height-computed;\n height: @navbar-height;\n\n &:hover,\n &:focus {\n text-decoration: none;\n }\n\n > img {\n display: block;\n }\n\n @media (min-width: @grid-float-breakpoint) {\n .navbar > .container &,\n .navbar > .container-fluid & {\n margin-left: -@navbar-padding-horizontal;\n }\n }\n}\n\n\n// Navbar toggle\n//\n// Custom button for toggling the `.navbar-collapse`, powered by the collapse\n// JavaScript plugin.\n\n.navbar-toggle {\n position: relative;\n float: right;\n margin-right: @navbar-padding-horizontal;\n padding: 9px 10px;\n .navbar-vertical-align(34px);\n background-color: transparent;\n background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n border: 1px solid transparent;\n border-radius: @border-radius-base;\n\n // We remove the `outline` here, but later compensate by attaching `:hover`\n // styles to `:focus`.\n &:focus {\n outline: 0;\n }\n\n // Bars\n .icon-bar {\n display: block;\n width: 22px;\n height: 2px;\n border-radius: 1px;\n }\n .icon-bar + .icon-bar {\n margin-top: 4px;\n }\n\n @media (min-width: @grid-float-breakpoint) {\n display: none;\n }\n}\n\n\n// Navbar nav links\n//\n// Builds on top of the `.nav` components with its own modifier class to make\n// the nav the full height of the horizontal nav (above 768px).\n\n.navbar-nav {\n margin: (@navbar-padding-vertical / 2) -@navbar-padding-horizontal;\n\n > li > a {\n padding-top: 10px;\n padding-bottom: 10px;\n line-height: @line-height-computed;\n }\n\n @media (max-width: @grid-float-breakpoint-max) {\n // Dropdowns get custom display when collapsed\n .open .dropdown-menu {\n position: static;\n float: none;\n width: auto;\n margin-top: 0;\n background-color: transparent;\n border: 0;\n box-shadow: none;\n > li > a,\n .dropdown-header {\n padding: 5px 15px 5px 25px;\n }\n > li > a {\n line-height: @line-height-computed;\n &:hover,\n &:focus {\n background-image: none;\n }\n }\n }\n }\n\n // Uncollapse the nav\n @media (min-width: @grid-float-breakpoint) {\n float: left;\n margin: 0;\n\n > li {\n float: left;\n > a {\n padding-top: @navbar-padding-vertical;\n padding-bottom: @navbar-padding-vertical;\n }\n }\n }\n}\n\n\n// Navbar form\n//\n// Extension of the `.form-inline` with some extra flavor for optimum display in\n// our navbars.\n\n.navbar-form {\n margin-left: -@navbar-padding-horizontal;\n margin-right: -@navbar-padding-horizontal;\n padding: 10px @navbar-padding-horizontal;\n border-top: 1px solid transparent;\n border-bottom: 1px solid transparent;\n @shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.1);\n .box-shadow(@shadow);\n\n // Mixin behavior for optimum display\n .form-inline();\n\n .form-group {\n @media (max-width: @grid-float-breakpoint-max) {\n margin-bottom: 5px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n }\n\n // Vertically center in expanded, horizontal navbar\n .navbar-vertical-align(@input-height-base);\n\n // Undo 100% width for pull classes\n @media (min-width: @grid-float-breakpoint) {\n width: auto;\n border: 0;\n margin-left: 0;\n margin-right: 0;\n padding-top: 0;\n padding-bottom: 0;\n .box-shadow(none);\n }\n}\n\n\n// Dropdown menus\n\n// Menu position and menu carets\n.navbar-nav > li > .dropdown-menu {\n margin-top: 0;\n .border-top-radius(0);\n}\n// Menu position and menu caret support for dropups via extra dropup class\n.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu {\n margin-bottom: 0;\n .border-top-radius(@navbar-border-radius);\n .border-bottom-radius(0);\n}\n\n\n// Buttons in navbars\n//\n// Vertically center a button within a navbar (when *not* in a form).\n\n.navbar-btn {\n .navbar-vertical-align(@input-height-base);\n\n &.btn-sm {\n .navbar-vertical-align(@input-height-small);\n }\n &.btn-xs {\n .navbar-vertical-align(22);\n }\n}\n\n\n// Text in navbars\n//\n// Add a class to make any element properly align itself vertically within the navbars.\n\n.navbar-text {\n .navbar-vertical-align(@line-height-computed);\n\n @media (min-width: @grid-float-breakpoint) {\n float: left;\n margin-left: @navbar-padding-horizontal;\n margin-right: @navbar-padding-horizontal;\n }\n}\n\n\n// Component alignment\n//\n// Repurpose the pull utilities as their own navbar utilities to avoid specificity\n// issues with parents and chaining. Only do this when the navbar is uncollapsed\n// though so that navbar contents properly stack and align in mobile.\n//\n// Declared after the navbar components to ensure more specificity on the margins.\n\n@media (min-width: @grid-float-breakpoint) {\n .navbar-left { .pull-left(); }\n .navbar-right {\n .pull-right();\n margin-right: -@navbar-padding-horizontal;\n\n ~ .navbar-right {\n margin-right: 0;\n }\n }\n}\n\n\n// Alternate navbars\n// --------------------------------------------------\n\n// Default navbar\n.navbar-default {\n background-color: @navbar-default-bg;\n border-color: @navbar-default-border;\n\n .navbar-brand {\n color: @navbar-default-brand-color;\n &:hover,\n &:focus {\n color: @navbar-default-brand-hover-color;\n background-color: @navbar-default-brand-hover-bg;\n }\n }\n\n .navbar-text {\n color: @navbar-default-color;\n }\n\n .navbar-nav {\n > li > a {\n color: @navbar-default-link-color;\n\n &:hover,\n &:focus {\n color: @navbar-default-link-hover-color;\n background-color: @navbar-default-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-active-color;\n background-color: @navbar-default-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-disabled-color;\n background-color: @navbar-default-link-disabled-bg;\n }\n }\n }\n\n .navbar-toggle {\n border-color: @navbar-default-toggle-border-color;\n &:hover,\n &:focus {\n background-color: @navbar-default-toggle-hover-bg;\n }\n .icon-bar {\n background-color: @navbar-default-toggle-icon-bar-bg;\n }\n }\n\n .navbar-collapse,\n .navbar-form {\n border-color: @navbar-default-border;\n }\n\n // Dropdown menu items\n .navbar-nav {\n // Remove background color from open dropdown\n > .open > a {\n &,\n &:hover,\n &:focus {\n background-color: @navbar-default-link-active-bg;\n color: @navbar-default-link-active-color;\n }\n }\n\n @media (max-width: @grid-float-breakpoint-max) {\n // Dropdowns get custom display when collapsed\n .open .dropdown-menu {\n > li > a {\n color: @navbar-default-link-color;\n &:hover,\n &:focus {\n color: @navbar-default-link-hover-color;\n background-color: @navbar-default-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-active-color;\n background-color: @navbar-default-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-disabled-color;\n background-color: @navbar-default-link-disabled-bg;\n }\n }\n }\n }\n }\n\n\n // Links in navbars\n //\n // Add a class to ensure links outside the navbar nav are colored correctly.\n\n .navbar-link {\n color: @navbar-default-link-color;\n &:hover {\n color: @navbar-default-link-hover-color;\n }\n }\n\n .btn-link {\n color: @navbar-default-link-color;\n &:hover,\n &:focus {\n color: @navbar-default-link-hover-color;\n }\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus {\n color: @navbar-default-link-disabled-color;\n }\n }\n }\n}\n\n// Inverse navbar\n\n.navbar-inverse {\n background-color: @navbar-inverse-bg;\n border-color: @navbar-inverse-border;\n\n .navbar-brand {\n color: @navbar-inverse-brand-color;\n &:hover,\n &:focus {\n color: @navbar-inverse-brand-hover-color;\n background-color: @navbar-inverse-brand-hover-bg;\n }\n }\n\n .navbar-text {\n color: @navbar-inverse-color;\n }\n\n .navbar-nav {\n > li > a {\n color: @navbar-inverse-link-color;\n\n &:hover,\n &:focus {\n color: @navbar-inverse-link-hover-color;\n background-color: @navbar-inverse-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-active-color;\n background-color: @navbar-inverse-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-disabled-color;\n background-color: @navbar-inverse-link-disabled-bg;\n }\n }\n }\n\n // Darken the responsive nav toggle\n .navbar-toggle {\n border-color: @navbar-inverse-toggle-border-color;\n &:hover,\n &:focus {\n background-color: @navbar-inverse-toggle-hover-bg;\n }\n .icon-bar {\n background-color: @navbar-inverse-toggle-icon-bar-bg;\n }\n }\n\n .navbar-collapse,\n .navbar-form {\n border-color: darken(@navbar-inverse-bg, 7%);\n }\n\n // Dropdowns\n .navbar-nav {\n > .open > a {\n &,\n &:hover,\n &:focus {\n background-color: @navbar-inverse-link-active-bg;\n color: @navbar-inverse-link-active-color;\n }\n }\n\n @media (max-width: @grid-float-breakpoint-max) {\n // Dropdowns get custom display\n .open .dropdown-menu {\n > .dropdown-header {\n border-color: @navbar-inverse-border;\n }\n .divider {\n background-color: @navbar-inverse-border;\n }\n > li > a {\n color: @navbar-inverse-link-color;\n &:hover,\n &:focus {\n color: @navbar-inverse-link-hover-color;\n background-color: @navbar-inverse-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-active-color;\n background-color: @navbar-inverse-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-disabled-color;\n background-color: @navbar-inverse-link-disabled-bg;\n }\n }\n }\n }\n }\n\n .navbar-link {\n color: @navbar-inverse-link-color;\n &:hover {\n color: @navbar-inverse-link-hover-color;\n }\n }\n\n .btn-link {\n color: @navbar-inverse-link-color;\n &:hover,\n &:focus {\n color: @navbar-inverse-link-hover-color;\n }\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus {\n color: @navbar-inverse-link-disabled-color;\n }\n }\n }\n}\n","// Navbar vertical align\n//\n// Vertically center elements in the navbar.\n// Example: an element has a height of 30px, so write out `.navbar-vertical-align(30px);` to calculate the appropriate top margin.\n\n.navbar-vertical-align(@element-height) {\n margin-top: ((@navbar-height - @element-height) / 2);\n margin-bottom: ((@navbar-height - @element-height) / 2);\n}\n","//\n// Utility classes\n// --------------------------------------------------\n\n\n// Floats\n// -------------------------\n\n.clearfix {\n .clearfix();\n}\n.center-block {\n .center-block();\n}\n.pull-right {\n float: right !important;\n}\n.pull-left {\n float: left !important;\n}\n\n\n// Toggling content\n// -------------------------\n\n// Note: Deprecated .hide in favor of .hidden or .sr-only (as appropriate) in v3.0.1\n.hide {\n display: none !important;\n}\n.show {\n display: block !important;\n}\n.invisible {\n visibility: hidden;\n}\n.text-hide {\n .text-hide();\n}\n\n\n// Hide from screenreaders and browsers\n//\n// Credit: HTML5 Boilerplate\n\n.hidden {\n display: none !important;\n}\n\n\n// For Affix plugin\n// -------------------------\n\n.affix {\n position: fixed;\n}\n","//\n// Breadcrumbs\n// --------------------------------------------------\n\n\n.breadcrumb {\n padding: @breadcrumb-padding-vertical @breadcrumb-padding-horizontal;\n margin-bottom: @line-height-computed;\n list-style: none;\n background-color: @breadcrumb-bg;\n border-radius: @border-radius-base;\n\n > li {\n display: inline-block;\n\n + li:before {\n content: \"@{breadcrumb-separator}\\00a0\"; // Unicode space added since inline-block means non-collapsing white-space\n padding: 0 5px;\n color: @breadcrumb-color;\n }\n }\n\n > .active {\n color: @breadcrumb-active-color;\n }\n}\n","//\n// Pagination (multiple pages)\n// --------------------------------------------------\n.pagination {\n display: inline-block;\n padding-left: 0;\n margin: @line-height-computed 0;\n border-radius: @border-radius-base;\n\n > li {\n display: inline; // Remove list-style and block-level defaults\n > a,\n > span {\n position: relative;\n float: left; // Collapse white-space\n padding: @padding-base-vertical @padding-base-horizontal;\n line-height: @line-height-base;\n text-decoration: none;\n color: @pagination-color;\n background-color: @pagination-bg;\n border: 1px solid @pagination-border;\n margin-left: -1px;\n }\n &:first-child {\n > a,\n > span {\n margin-left: 0;\n .border-left-radius(@border-radius-base);\n }\n }\n &:last-child {\n > a,\n > span {\n .border-right-radius(@border-radius-base);\n }\n }\n }\n\n > li > a,\n > li > span {\n &:hover,\n &:focus {\n z-index: 2;\n color: @pagination-hover-color;\n background-color: @pagination-hover-bg;\n border-color: @pagination-hover-border;\n }\n }\n\n > .active > a,\n > .active > span {\n &,\n &:hover,\n &:focus {\n z-index: 3;\n color: @pagination-active-color;\n background-color: @pagination-active-bg;\n border-color: @pagination-active-border;\n cursor: default;\n }\n }\n\n > .disabled {\n > span,\n > span:hover,\n > span:focus,\n > a,\n > a:hover,\n > a:focus {\n color: @pagination-disabled-color;\n background-color: @pagination-disabled-bg;\n border-color: @pagination-disabled-border;\n cursor: @cursor-disabled;\n }\n }\n}\n\n// Sizing\n// --------------------------------------------------\n\n// Large\n.pagination-lg {\n .pagination-size(@padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @border-radius-large);\n}\n\n// Small\n.pagination-sm {\n .pagination-size(@padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @border-radius-small);\n}\n","// Pagination\n\n.pagination-size(@padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) {\n > li {\n > a,\n > span {\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n line-height: @line-height;\n }\n &:first-child {\n > a,\n > span {\n .border-left-radius(@border-radius);\n }\n }\n &:last-child {\n > a,\n > span {\n .border-right-radius(@border-radius);\n }\n }\n }\n}\n","//\n// Pager pagination\n// --------------------------------------------------\n\n\n.pager {\n padding-left: 0;\n margin: @line-height-computed 0;\n list-style: none;\n text-align: center;\n &:extend(.clearfix all);\n li {\n display: inline;\n > a,\n > span {\n display: inline-block;\n padding: 5px 14px;\n background-color: @pager-bg;\n border: 1px solid @pager-border;\n border-radius: @pager-border-radius;\n }\n\n > a:hover,\n > a:focus {\n text-decoration: none;\n background-color: @pager-hover-bg;\n }\n }\n\n .next {\n > a,\n > span {\n float: right;\n }\n }\n\n .previous {\n > a,\n > span {\n float: left;\n }\n }\n\n .disabled {\n > a,\n > a:hover,\n > a:focus,\n > span {\n color: @pager-disabled-color;\n background-color: @pager-bg;\n cursor: @cursor-disabled;\n }\n }\n}\n","//\n// Labels\n// --------------------------------------------------\n\n.label {\n display: inline;\n padding: .2em .6em .3em;\n font-size: 75%;\n font-weight: bold;\n line-height: 1;\n color: @label-color;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: .25em;\n\n // Add hover effects, but only for links\n a& {\n &:hover,\n &:focus {\n color: @label-link-hover-color;\n text-decoration: none;\n cursor: pointer;\n }\n }\n\n // Empty labels collapse automatically (not available in IE8)\n &:empty {\n display: none;\n }\n\n // Quick fix for labels in buttons\n .btn & {\n position: relative;\n top: -1px;\n }\n}\n\n// Colors\n// Contextual variations (linked labels get darker on :hover)\n\n.label-default {\n .label-variant(@label-default-bg);\n}\n\n.label-primary {\n .label-variant(@label-primary-bg);\n}\n\n.label-success {\n .label-variant(@label-success-bg);\n}\n\n.label-info {\n .label-variant(@label-info-bg);\n}\n\n.label-warning {\n .label-variant(@label-warning-bg);\n}\n\n.label-danger {\n .label-variant(@label-danger-bg);\n}\n","// Labels\n\n.label-variant(@color) {\n background-color: @color;\n\n &[href] {\n &:hover,\n &:focus {\n background-color: darken(@color, 10%);\n }\n }\n}\n","//\n// Badges\n// --------------------------------------------------\n\n\n// Base class\n.badge {\n display: inline-block;\n min-width: 10px;\n padding: 3px 7px;\n font-size: @font-size-small;\n font-weight: @badge-font-weight;\n color: @badge-color;\n line-height: @badge-line-height;\n vertical-align: middle;\n white-space: nowrap;\n text-align: center;\n background-color: @badge-bg;\n border-radius: @badge-border-radius;\n\n // Empty badges collapse automatically (not available in IE8)\n &:empty {\n display: none;\n }\n\n // Quick fix for badges in buttons\n .btn & {\n position: relative;\n top: -1px;\n }\n\n .btn-xs &,\n .btn-group-xs > .btn & {\n top: 0;\n padding: 1px 5px;\n }\n\n // Hover state, but only for links\n a& {\n &:hover,\n &:focus {\n color: @badge-link-hover-color;\n text-decoration: none;\n cursor: pointer;\n }\n }\n\n // Account for badges in navs\n .list-group-item.active > &,\n .nav-pills > .active > a > & {\n color: @badge-active-color;\n background-color: @badge-active-bg;\n }\n\n .list-group-item > & {\n float: right;\n }\n\n .list-group-item > & + & {\n margin-right: 5px;\n }\n\n .nav-pills > li > a > & {\n margin-left: 3px;\n }\n}\n","//\n// Jumbotron\n// --------------------------------------------------\n\n\n.jumbotron {\n padding-top: @jumbotron-padding;\n padding-bottom: @jumbotron-padding;\n margin-bottom: @jumbotron-padding;\n color: @jumbotron-color;\n background-color: @jumbotron-bg;\n\n h1,\n .h1 {\n color: @jumbotron-heading-color;\n }\n\n p {\n margin-bottom: (@jumbotron-padding / 2);\n font-size: @jumbotron-font-size;\n font-weight: 200;\n }\n\n > hr {\n border-top-color: darken(@jumbotron-bg, 10%);\n }\n\n .container &,\n .container-fluid & {\n border-radius: @border-radius-large; // Only round corners at higher resolutions if contained in a container\n padding-left: (@grid-gutter-width / 2);\n padding-right: (@grid-gutter-width / 2);\n }\n\n .container {\n max-width: 100%;\n }\n\n @media screen and (min-width: @screen-sm-min) {\n padding-top: (@jumbotron-padding * 1.6);\n padding-bottom: (@jumbotron-padding * 1.6);\n\n .container &,\n .container-fluid & {\n padding-left: (@jumbotron-padding * 2);\n padding-right: (@jumbotron-padding * 2);\n }\n\n h1,\n .h1 {\n font-size: @jumbotron-heading-font-size;\n }\n }\n}\n","//\n// Thumbnails\n// --------------------------------------------------\n\n\n// Mixin and adjust the regular image class\n.thumbnail {\n display: block;\n padding: @thumbnail-padding;\n margin-bottom: @line-height-computed;\n line-height: @line-height-base;\n background-color: @thumbnail-bg;\n border: 1px solid @thumbnail-border;\n border-radius: @thumbnail-border-radius;\n .transition(border .2s ease-in-out);\n\n > img,\n a > img {\n &:extend(.img-responsive);\n margin-left: auto;\n margin-right: auto;\n }\n\n // Add a hover state for linked versions only\n a&:hover,\n a&:focus,\n a&.active {\n border-color: @link-color;\n }\n\n // Image captions\n .caption {\n padding: @thumbnail-caption-padding;\n color: @thumbnail-caption-color;\n }\n}\n","//\n// Alerts\n// --------------------------------------------------\n\n\n// Base styles\n// -------------------------\n\n.alert {\n padding: @alert-padding;\n margin-bottom: @line-height-computed;\n border: 1px solid transparent;\n border-radius: @alert-border-radius;\n\n // Headings for larger alerts\n h4 {\n margin-top: 0;\n // Specified for the h4 to prevent conflicts of changing @headings-color\n color: inherit;\n }\n\n // Provide class for links that match alerts\n .alert-link {\n font-weight: @alert-link-font-weight;\n }\n\n // Improve alignment and spacing of inner content\n > p,\n > ul {\n margin-bottom: 0;\n }\n\n > p + p {\n margin-top: 5px;\n }\n}\n\n// Dismissible alerts\n//\n// Expand the right padding and account for the close button's positioning.\n\n.alert-dismissable, // The misspelled .alert-dismissable was deprecated in 3.2.0.\n.alert-dismissible {\n padding-right: (@alert-padding + 20);\n\n // Adjust close link position\n .close {\n position: relative;\n top: -2px;\n right: -21px;\n color: inherit;\n }\n}\n\n// Alternate styles\n//\n// Generate contextual modifier classes for colorizing the alert.\n\n.alert-success {\n .alert-variant(@alert-success-bg; @alert-success-border; @alert-success-text);\n}\n\n.alert-info {\n .alert-variant(@alert-info-bg; @alert-info-border; @alert-info-text);\n}\n\n.alert-warning {\n .alert-variant(@alert-warning-bg; @alert-warning-border; @alert-warning-text);\n}\n\n.alert-danger {\n .alert-variant(@alert-danger-bg; @alert-danger-border; @alert-danger-text);\n}\n","// Alerts\n\n.alert-variant(@background; @border; @text-color) {\n background-color: @background;\n border-color: @border;\n color: @text-color;\n\n hr {\n border-top-color: darken(@border, 5%);\n }\n .alert-link {\n color: darken(@text-color, 10%);\n }\n}\n","//\n// Progress bars\n// --------------------------------------------------\n\n\n// Bar animations\n// -------------------------\n\n// WebKit\n@-webkit-keyframes progress-bar-stripes {\n from { background-position: 40px 0; }\n to { background-position: 0 0; }\n}\n\n// Spec and IE10+\n@keyframes progress-bar-stripes {\n from { background-position: 40px 0; }\n to { background-position: 0 0; }\n}\n\n\n// Bar itself\n// -------------------------\n\n// Outer container\n.progress {\n overflow: hidden;\n height: @line-height-computed;\n margin-bottom: @line-height-computed;\n background-color: @progress-bg;\n border-radius: @progress-border-radius;\n .box-shadow(inset 0 1px 2px rgba(0,0,0,.1));\n}\n\n// Bar of progress\n.progress-bar {\n float: left;\n width: 0%;\n height: 100%;\n font-size: @font-size-small;\n line-height: @line-height-computed;\n color: @progress-bar-color;\n text-align: center;\n background-color: @progress-bar-bg;\n .box-shadow(inset 0 -1px 0 rgba(0,0,0,.15));\n .transition(width .6s ease);\n}\n\n// Striped bars\n//\n// `.progress-striped .progress-bar` is deprecated as of v3.2.0 in favor of the\n// `.progress-bar-striped` class, which you just add to an existing\n// `.progress-bar`.\n.progress-striped .progress-bar,\n.progress-bar-striped {\n #gradient > .striped();\n background-size: 40px 40px;\n}\n\n// Call animation for the active one\n//\n// `.progress.active .progress-bar` is deprecated as of v3.2.0 in favor of the\n// `.progress-bar.active` approach.\n.progress.active .progress-bar,\n.progress-bar.active {\n .animation(progress-bar-stripes 2s linear infinite);\n}\n\n\n// Variations\n// -------------------------\n\n.progress-bar-success {\n .progress-bar-variant(@progress-bar-success-bg);\n}\n\n.progress-bar-info {\n .progress-bar-variant(@progress-bar-info-bg);\n}\n\n.progress-bar-warning {\n .progress-bar-variant(@progress-bar-warning-bg);\n}\n\n.progress-bar-danger {\n .progress-bar-variant(@progress-bar-danger-bg);\n}\n","// Gradients\n\n#gradient {\n\n // Horizontal gradient, from left to right\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .horizontal(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n // Vertical gradient, from top to bottom\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .vertical(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n .directional(@start-color: #555; @end-color: #333; @deg: 45deg) {\n background-repeat: repeat-x;\n background-image: -webkit-linear-gradient(@deg, @start-color, @end-color); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(@deg, @start-color, @end-color); // Opera 12\n background-image: linear-gradient(@deg, @start-color, @end-color); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n }\n .horizontal-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .vertical-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .radial(@inner-color: #555; @outer-color: #333) {\n background-image: -webkit-radial-gradient(circle, @inner-color, @outer-color);\n background-image: radial-gradient(circle, @inner-color, @outer-color);\n background-repeat: no-repeat;\n }\n .striped(@color: rgba(255,255,255,.15); @angle: 45deg) {\n background-image: -webkit-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n }\n}\n","// Progress bars\n\n.progress-bar-variant(@color) {\n background-color: @color;\n\n // Deprecated parent class requirement as of v3.2.0\n .progress-striped & {\n #gradient > .striped();\n }\n}\n",".media {\n // Proper spacing between instances of .media\n margin-top: 15px;\n\n &:first-child {\n margin-top: 0;\n }\n}\n\n.media,\n.media-body {\n zoom: 1;\n overflow: hidden;\n}\n\n.media-body {\n width: 10000px;\n}\n\n.media-object {\n display: block;\n\n // Fix collapse in webkit from max-width: 100% and display: table-cell.\n &.img-thumbnail {\n max-width: none;\n }\n}\n\n.media-right,\n.media > .pull-right {\n padding-left: 10px;\n}\n\n.media-left,\n.media > .pull-left {\n padding-right: 10px;\n}\n\n.media-left,\n.media-right,\n.media-body {\n display: table-cell;\n vertical-align: top;\n}\n\n.media-middle {\n vertical-align: middle;\n}\n\n.media-bottom {\n vertical-align: bottom;\n}\n\n// Reset margins on headings for tighter default spacing\n.media-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n\n// Media list variation\n//\n// Undo default ul/ol styles\n.media-list {\n padding-left: 0;\n list-style: none;\n}\n","//\n// List groups\n// --------------------------------------------------\n\n\n// Base class\n//\n// Easily usable on